Initial commit

This commit is contained in:
2026-07-11 19:30:52 +02:00
commit 7183ae31bc
8 changed files with 2831 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# ── App ───────────────────────────────────────────────────────────────────────
PORT=8000
APP_TITLE=Travel Monitor
# ── HQ Location ───────────────────────────────────────────────────────────────
# HQ_NAME is shown in the header. Optional — falls back to address or coordinates.
HQ_NAME=Head Office
HQ_ADDRESS=Storgata 1, Oslo, Norway
# If using coordinates instead of address, comment out HQ_ADDRESS and set these:
# HQ_LAT=59.9139
# HQ_LNG=10.7522
# ── Routing provider ──────────────────────────────────────────────────────────
# Supported values: openrouteservice | google | mapbox | here
# Only the API key for the chosen provider needs to be set.
ROUTING_PROVIDER=openrouteservice
# openrouteservice — free tier: 2,000 req/day — https://openrouteservice.org/dev/#/signup
ORS_API_KEY=your_key_here
# google — $200/month free credit — https://console.cloud.google.com (enable Directions + Geocoding APIs)
# GOOGLE_API_KEY=your_key_here
# mapbox — 100,000 req/month free — https://account.mapbox.com/access-tokens
# MAPBOX_API_KEY=your_key_here
# here — 250,000 req/month free — https://developer.here.com/sign-up
# HERE_API_KEY=your_key_here
# ── Refresh & thresholds ──────────────────────────────────────────────────────
REFRESH_INTERVAL=300
THRESHOLD_YELLOW=1.20
THRESHOLD_RED=1.50
THRESHOLD_DARK_RED=1.70
# ── Advanced ──────────────────────────────────────────────────────────────────
# DATA_DIR=/data
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY static/ static/
RUN mkdir -p /data
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+213
View File
@@ -0,0 +1,213 @@
# Travel Monitor
A self-hosted real-time travel time dashboard. Shows how long it takes to drive from a fixed HQ location to multiple offsite destinations, with color-coded status based on current traffic conditions relative to a learned baseline.
Built for homelabs. Runs as a single Docker container.
---
## Features
- **Real-time routing** via pluggable provider (Google, Mapbox, HERE, OpenRouteService)
- **Baseline-relative status** — green/yellow/red/dark-red based on how much worse current traffic is vs. normal
- **Interactive map** with numbered pins, route display on click, and multiple map styles (Dark Matter, Voyager, Positron, OSM, Satellite)
- **Glanceable sidebar** with color-coded rows, travel time both ways, and delay percentage
- **Stats tab** — sortable table by time, distance, or delay
- **Location management** — add by address or coordinates, rename inline, drag to reorder, delete with confirmation
- **Favicon + tab title** update to reflect worst current status
- **Debug mode** — type `thisisdebug` anywhere on the page to unlock traffic simulation tools
- **Keyboard shortcuts** — `R` refresh, `19` jump to location, `Tab` switch tabs, `?` show shortcuts, `Esc` close
- **Resizable sidebar** — drag the edge, persists across reloads
- **Provider status indicator** — shows in header when any location fails to fetch, with error details
- **Rate limiting** on all API endpoints via slowapi
- **Mobile layout** — stacked map/list, bottom navigation, touch drag-to-reorder, collapsible map
- **Dark mode only** — as it should be
---
## Requirements
- Docker + Docker Compose
- API key for your chosen routing provider (see Routing Providers below)
---
## Setup
```bash
# 1. Install Docker on your LXC (if not already)
curl -fsSL https://get.docker.com | sh
# 2. Copy files to the LXC
scp -r travel-monitor/ root@<LXC_IP>:/opt/travel-monitor
# 3. SSH in and configure
ssh root@<LXC_IP>
cd /opt/travel-monitor
cp .env.example .env
nano .env
# 4. Build and start
docker compose up -d
# 5. Check logs
docker compose logs -f
```
Open `http://<LXC_IP>:<PORT>` in your browser.
---
## Configuration (`.env`)
| Variable | Default | Description |
|---|---|---|
| `PORT` | `8000` | Host port to expose |
| `APP_TITLE` | `Travel Monitor` | Title shown in header and browser tab |
| `HQ_NAME` | — | Friendly name for HQ shown in header (optional) |
| `HQ_ADDRESS` | — | HQ street address (geocoded on startup) |
| `HQ_LAT` / `HQ_LNG` | — | HQ coordinates (alternative to address) |
| `ROUTING_PROVIDER` | `openrouteservice` | See routing providers below |
| `ORS_API_KEY` | — | OpenRouteService key |
| `GOOGLE_API_KEY` | — | Google Maps key |
| `MAPBOX_API_KEY` | — | Mapbox key |
| `HERE_API_KEY` | — | HERE key |
| `REFRESH_INTERVAL` | `300` | Seconds between auto-refreshes |
| `THRESHOLD_YELLOW` | `1.20` | Ratio where green → yellow (20% worse than baseline) |
| `THRESHOLD_RED` | `1.50` | Ratio where yellow → red (50% worse) |
| `THRESHOLD_DARK_RED` | `1.70` | Ratio where red → dark red (70% worse) |
| `DATA_DIR` | `/data` | Path inside container for locations.json |
---
## Routing Providers
Set `ROUTING_PROVIDER` to one of the following and supply the matching API key.
| Provider | Value | Free tier | Traffic-aware | Notes |
|---|---|---|---|---|
| OpenRouteService | `openrouteservice` | 2,000 req/day | ❌ No | Returns static historical times. Fine for testing, not for real traffic monitoring. |
| Google Maps | `google` | ~$200/month credit | ✅ Yes | Best routing quality. Uses `departure_time=now` + `duration_in_traffic`. |
| Mapbox | `mapbox` | 100,000 req/month | ✅ Yes | Uses `driving-traffic` profile. Generous free tier. |
| HERE | `here` | 250,000 req/month | ✅ Yes | Uses `departureTime=now`. Good European coverage. |
**Recommendation:** Mapbox or Google for real traffic data. OpenRouteService only if you don't need live conditions.
---
## Status colors
| Color | Ratio | Meaning |
|---|---|---|
| 🟢 Green | < 1.20× | Normal — within 20% of baseline |
| 🟡 Yellow | 1.201.50× | Worsened — 2050% slower than baseline |
| 🔴 Red | 1.501.70× | Bad — 5070% slower |
| ⬛ Dark red | > 1.70× | Reconsider — more than 70% slower |
Baselines are learned from the first successful fetch per location. To reset a baseline, use the `⋯` menu on any location → Reset baseline.
---
## Data persistence
Locations and baselines are stored in `./data/locations.json` on the host, mounted into the container. Survives container restarts and rebuilds.
To manually reset a baseline without the UI, edit `./data/locations.json` and set `"baseline_seconds": null` for that entry.
---
## Rate limits
All API endpoints are rate-limited per IP via slowapi:
| Endpoint category | Limit |
|---|---|
| `GET /api/config`, `GET /api/locations` | 60/min |
| `GET /api/status` | 30/min |
| `POST /api/refresh` | 10/min |
| Write operations (add, rename, delete, reorder, reset) | 20/min |
| Debug (simulate-delay) | 20/min |
---
## Docker commands
```bash
docker compose up -d # start
docker compose down # stop
docker compose up -d --build # rebuild after code changes
docker compose restart # restart without rebuild
docker compose logs -f # follow logs
```
---
## Exposing via Caddy + Authentik
The app has no built-in authentication. For external access, put it behind a reverse proxy with forward auth.
### DNS
Add an A record: `travel.yourdomain.com` → your Caddy LXC IP.
### Authentik
1. Create a **Proxy Provider** (Forward auth, single application)
- External host: `https://travel.yourdomain.com`
2. Create an **Application** pointing at the provider
3. Add the application to your existing **Outpost**
4. Optionally add a policy to restrict access to specific users or groups
### Caddyfile
```caddy
travel.yourdomain.com {
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
forward_auth http://<AUTHENTIK_OUTPOST_IP>:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Uid
trusted_proxies private_ranges
}
reverse_proxy http://<TRAVEL_MONITOR_LXC_IP>:<PORT>
}
```
### Port binding
When running behind a reverse proxy, bind Docker to the Caddy LXC IP only (already set in `docker-compose.yml`):
```yaml
ports:
- "127.0.0.1:${PORT:-8000}:8000"
```
If Caddy runs on a different host than the Travel Monitor LXC, change `127.0.0.1` to the Caddy LXC's IP.
---
## Security notes
- The app has no authentication of its own — rely on the reverse proxy layer
- API keys are stored in `.env` on the host filesystem — secure the LXC accordingly
- The `simulate-delay` endpoint is a debug tool — unlock it in the UI by typing `thisisdebug`; it is not accessible from the browser by default and is only useful for verifying threshold logic
- All API endpoints are rate-limited to prevent API quota exhaustion
---
## Hardening checklist
- [x] Rate limiting on all endpoints (slowapi)
- [x] Docker port bound to localhost / Caddy IP only
- [x] No user input executed or evaluated server-side
- [x] XSS protection via `escHtml()` on all user-supplied strings in the frontend
- [ ] Caddy security headers (add to Caddyfile block)
- [ ] Authentik group policy restricting access to your account only
- [ ] Firewall rule on Travel Monitor LXC allowing only Caddy LXC on the app port
---
## TODO
- [ ] Mobile layout polish (touch edge cases)
- [ ] Authentik + Caddy deployment (documented above, not yet deployed)
+13
View File
@@ -0,0 +1,13 @@
services:
travel-monitor:
build: .
container_name: travel-monitor
restart: unless-stopped
ports:
# Bound to localhost only — traffic must come through the reverse proxy.
# If Caddy runs on a different host, change 127.0.0.1 to the Caddy LXC IP.
- "127.0.0.1:${PORT:-8000}:8000"
env_file:
- .env
volumes:
- ./data:/data
+611
View File
@@ -0,0 +1,611 @@
import asyncio
import json
import os
import time
from pathlib import Path
from typing import Optional
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
load_dotenv()
# Rate limiter — uses client IP. When behind a reverse proxy, ensure the proxy
# sets X-Forwarded-For so get_remote_address resolves correctly.
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="Travel Monitor")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
BASE_DIR = Path(__file__).parent
LOCATIONS_FILE = Path(os.getenv("DATA_DIR", "/data")) / "locations.json"
STATIC_DIR = BASE_DIR / "static"
# ── Config ────────────────────────────────────────────────────────────────────
APP_TITLE = os.getenv("APP_TITLE", "Travel Monitor")
HQ_NAME = os.getenv("HQ_NAME", "")
HQ_ADDRESS = os.getenv("HQ_ADDRESS", "")
HQ_LAT = os.getenv("HQ_LAT", "")
HQ_LNG = os.getenv("HQ_LNG", "")
REFRESH_INTERVAL = int(os.getenv("REFRESH_INTERVAL", "300"))
THRESHOLD_YELLOW = float(os.getenv("THRESHOLD_YELLOW", "1.20"))
THRESHOLD_RED = float(os.getenv("THRESHOLD_RED", "1.50"))
THRESHOLD_DARK_RED = float(os.getenv("THRESHOLD_DARK_RED", "1.70"))
ROUTING_PROVIDER = os.getenv("ROUTING_PROVIDER", "openrouteservice").lower()
# Provider API keys — only the one matching ROUTING_PROVIDER is required
ORS_API_KEY = os.getenv("ORS_API_KEY", "") # openrouteservice
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "") # google
MAPBOX_API_KEY = os.getenv("MAPBOX_API_KEY", "") # mapbox
HERE_API_KEY = os.getenv("HERE_API_KEY", "") # here
# ── Shared state ──────────────────────────────────────────────────────────────
_cache: dict = {}
_hq_coords: Optional[tuple] = None
# ── Persistence ───────────────────────────────────────────────────────────────
def load_locations() -> list:
if not LOCATIONS_FILE.exists():
return []
with open(LOCATIONS_FILE) as f:
return json.load(f)
def save_locations(locations: list):
with open(LOCATIONS_FILE, "w") as f:
json.dump(locations, f, indent=2)
def get_status(ratio: float) -> str:
if ratio < THRESHOLD_YELLOW:
return "green"
elif ratio < THRESHOLD_RED:
return "yellow"
elif ratio < THRESHOLD_DARK_RED:
return "red"
else:
return "dark_red"
# ── Provider: OpenRouteService ────────────────────────────────────────────────
async def ors_geocode(client: httpx.AsyncClient, address: str) -> tuple:
r = await client.get(
"https://api.openrouteservice.org/geocode/search",
params={"api_key": ORS_API_KEY, "text": address, "size": 1},
timeout=10,
)
r.raise_for_status()
data = r.json()
if not data.get("features"):
raise ValueError(f"No result for: {address}")
coords = data["features"][0]["geometry"]["coordinates"]
return (coords[1], coords[0]) # lat, lng
async def ors_directions(client: httpx.AsyncClient, hq: tuple, dest: tuple) -> dict:
# NOTE: ORS's free driving-car profile does NOT factor in live traffic at all —
# it always returns the same historical/typical duration regardless of time of day.
# If you need real-time traffic awareness, use google, mapbox, or here instead.
body = {
"coordinates": [[hq[1], hq[0]], [dest[1], dest[0]]],
"preference": "fastest",
}
r = await client.post(
"https://api.openrouteservice.org/v2/directions/driving-car",
json=body,
headers={"Authorization": ORS_API_KEY, "Content-Type": "application/json"},
timeout=15,
)
r.raise_for_status()
route = r.json()["routes"][0]
return {
"duration": route["summary"]["duration"],
"distance_km": round(route["summary"]["distance"] / 1000, 1),
"geometry": route.get("geometry"),
"geometry_type": "polyline",
}
# ── Provider: Google Maps ─────────────────────────────────────────────────────
async def google_geocode(client: httpx.AsyncClient, address: str) -> tuple:
r = await client.get(
"https://maps.googleapis.com/maps/api/geocode/json",
params={"address": address, "key": GOOGLE_API_KEY},
timeout=10,
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK" or not data.get("results"):
raise ValueError(f"No result for: {address} ({data.get('status')})")
loc = data["results"][0]["geometry"]["location"]
return (loc["lat"], loc["lng"])
async def google_directions(client: httpx.AsyncClient, hq: tuple, dest: tuple) -> dict:
r = await client.get(
"https://maps.googleapis.com/maps/api/directions/json",
params={
"origin": f"{hq[0]},{hq[1]}",
"destination": f"{dest[0]},{dest[1]}",
"mode": "driving",
"departure_time": "now", # required to get live-traffic duration
"traffic_model": "best_guess",
"key": GOOGLE_API_KEY,
},
timeout=15,
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK" or not data.get("routes"):
raise ValueError(f"No route found ({data.get('status')})")
leg = data["routes"][0]["legs"][0]
poly = data["routes"][0]["overview_polyline"]["points"]
# duration_in_traffic only appears when departure_time is set; fall back to duration if missing
duration = leg.get("duration_in_traffic", leg["duration"])["value"]
return {
"duration": duration,
"distance_km": round(leg["distance"]["value"] / 1000, 1),
"geometry": poly,
"geometry_type": "polyline",
}
# ── Provider: Mapbox ──────────────────────────────────────────────────────────
async def mapbox_geocode(client: httpx.AsyncClient, address: str) -> tuple:
import urllib.parse
encoded = urllib.parse.quote(address)
r = await client.get(
f"https://api.mapbox.com/geocoding/v5/mapbox.places/{encoded}.json",
params={"access_token": MAPBOX_API_KEY, "limit": 1},
timeout=10,
)
r.raise_for_status()
data = r.json()
if not data.get("features"):
raise ValueError(f"No result for: {address}")
coords = data["features"][0]["geometry"]["coordinates"]
return (coords[1], coords[0]) # lat, lng
async def mapbox_directions(client: httpx.AsyncClient, hq: tuple, dest: tuple) -> dict:
coords = f"{hq[1]},{hq[0]};{dest[1]},{dest[0]}"
# driving-traffic profile factors in live + historical traffic conditions
# (plain "driving" profile ignores traffic entirely)
r = await client.get(
f"https://api.mapbox.com/directions/v5/mapbox/driving-traffic/{coords}",
params={
"access_token": MAPBOX_API_KEY,
"geometries": "polyline",
"overview": "full",
},
timeout=15,
)
r.raise_for_status()
data = r.json()
if not data.get("routes"):
raise ValueError("No route found")
route = data["routes"][0]
return {
"duration": route["duration"],
"distance_km": round(route["distance"] / 1000, 1),
"geometry": route["geometry"],
"geometry_type": "polyline",
}
# ── Provider: HERE ────────────────────────────────────────────────────────────
async def here_geocode(client: httpx.AsyncClient, address: str) -> tuple:
r = await client.get(
"https://geocode.search.hereapi.com/v1/geocode",
params={"q": address, "apiKey": HERE_API_KEY, "limit": 1},
timeout=10,
)
r.raise_for_status()
data = r.json()
if not data.get("items"):
raise ValueError(f"No result for: {address}")
pos = data["items"][0]["position"]
return (pos["lat"], pos["lng"])
async def here_directions(client: httpx.AsyncClient, hq: tuple, dest: tuple) -> dict:
# routingMode=fast + return=summary already factors in live traffic by default on HERE.
# departureTime=now makes this explicit so cached/historical routes aren't served.
r = await client.get(
"https://router.hereapi.com/v8/routes",
params={
"apiKey": HERE_API_KEY,
"transportMode": "car",
"origin": f"{hq[0]},{hq[1]}",
"destination": f"{dest[0]},{dest[1]}",
"return": "summary,polyline",
"routingMode": "fast",
"departureTime": "now",
},
timeout=15,
)
r.raise_for_status()
data = r.json()
if not data.get("routes"):
raise ValueError("No route found")
section = data["routes"][0]["sections"][0]
duration = section["summary"]["duration"] # seconds
# HERE returns a Flexible Polyline encoded string
polyline_str = section.get("polyline", None)
return {
"duration": duration,
"distance_km": round(section["summary"]["length"] / 1000, 1),
"geometry": polyline_str,
"geometry_type": "here_polyline",
}
# ── Provider dispatch ─────────────────────────────────────────────────────────
PROVIDERS = {
"openrouteservice": (ors_geocode, ors_directions),
"google": (google_geocode, google_directions),
"mapbox": (mapbox_geocode, mapbox_directions),
"here": (here_geocode, here_directions),
}
async def geocode_address(address: str) -> tuple:
if ROUTING_PROVIDER not in PROVIDERS:
raise ValueError(f"Unknown ROUTING_PROVIDER: '{ROUTING_PROVIDER}'. "
f"Valid options: {', '.join(PROVIDERS)}")
geocode_fn, _ = PROVIDERS[ROUTING_PROVIDER]
async with httpx.AsyncClient() as client:
return await geocode_fn(client, address)
async def fetch_travel_time(hq: tuple, dest: tuple) -> dict:
if ROUTING_PROVIDER not in PROVIDERS:
raise ValueError(f"Unknown ROUTING_PROVIDER: '{ROUTING_PROVIDER}'")
_, directions_fn = PROVIDERS[ROUTING_PROVIDER]
try:
async with httpx.AsyncClient() as client:
result = await directions_fn(client, hq, dest)
print(f"[{ROUTING_PROVIDER}] duration={result['duration']}s "
f"({result['duration']/60:.1f}min)")
return result
except httpx.HTTPStatusError as e:
# Capture the response body so we see the actual provider error message
try:
body = e.response.json()
except Exception:
body = e.response.text
raise ValueError(f"HTTP {e.response.status_code} from {ROUTING_PROVIDER}: {body}") from e
# ── HQ resolution ─────────────────────────────────────────────────────────────
async def get_hq_coords() -> tuple:
global _hq_coords
if _hq_coords:
return _hq_coords
if HQ_LAT and HQ_LNG:
_hq_coords = (float(HQ_LAT), float(HQ_LNG))
elif HQ_ADDRESS:
_hq_coords = await geocode_address(HQ_ADDRESS)
else:
raise ValueError("No HQ location configured in .env")
return _hq_coords
# ── Refresh all locations ─────────────────────────────────────────────────────
_provider_errors: dict = {} # loc_id -> {"message": str, "at": int}
_last_refresh_ok: Optional[int] = None
_last_refresh_attempt: Optional[int] = None
_hq_error: Optional[str] = None
# Semaphore limits concurrent routing API calls — prevents rate limit errors
# when you have many locations. 5 at a time is safe for all providers.
_fetch_semaphore = asyncio.Semaphore(5)
async def fetch_with_semaphore(hq: tuple, dest: tuple) -> dict:
async with _fetch_semaphore:
return await fetch_travel_time(hq, dest)
async def refresh_all_locations():
global _last_refresh_ok, _last_refresh_attempt, _hq_error
locations = load_locations()
_last_refresh_attempt = int(time.time())
if not locations:
return
try:
hq = await get_hq_coords()
_hq_error = None
except Exception as e:
_hq_error = str(e)
print(f"[ERROR] Could not resolve HQ: {e}")
return
tasks = [fetch_with_semaphore(hq, (loc["lat"], loc["lng"])) for loc in locations]
results = await asyncio.gather(*tasks, return_exceptions=True)
updated = False
any_success = False
for loc, result in zip(locations, results):
lid = loc["id"]
if isinstance(result, Exception):
print(f"[WARN] Failed for {loc['name']}: {result!r}")
_provider_errors[lid] = {"message": str(result), "at": int(time.time())}
if lid in _cache:
_cache[lid]["error"] = True
continue
any_success = True
_provider_errors.pop(lid, None)
one_way_seconds = int(result["duration"])
geometry = result.get("geometry")
geometry_type = result.get("geometry_type", "polyline")
if "baseline_seconds" not in loc or loc["baseline_seconds"] is None:
loc["baseline_seconds"] = one_way_seconds
updated = True
baseline = loc["baseline_seconds"]
ratio = one_way_seconds / baseline if baseline > 0 else 1.0
_cache[lid] = {
"current_seconds": one_way_seconds,
"baseline_seconds": baseline,
"ratio": round(ratio, 3),
"status": get_status(ratio),
"one_way_min": round(one_way_seconds / 60),
"round_trip_min": round(one_way_seconds / 60 * 2),
"distance_km": result.get("distance_km"),
"geometry": geometry,
"geometry_type": geometry_type,
"last_updated": int(time.time()),
"error": False,
}
if any_success:
_last_refresh_ok = int(time.time())
if updated:
save_locations(locations)
# ── Background refresh loop ───────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
asyncio.create_task(background_refresh())
async def background_refresh():
while True:
try:
await refresh_all_locations()
except Exception as e:
print(f"[ERROR] Background refresh: {e}")
await asyncio.sleep(REFRESH_INTERVAL)
# ── API routes ────────────────────────────────────────────────────────────────
@app.get("/api/config")
@limiter.limit("60/minute")
async def get_config(request: Request):
if HQ_NAME:
hq_label = HQ_NAME
elif HQ_ADDRESS:
hq_label = HQ_ADDRESS
else:
hq_label = f"{HQ_LAT}, {HQ_LNG}"
try:
hq = await get_hq_coords()
return {
"app_title": APP_TITLE,
"hq_label": hq_label,
"hq_lat": hq[0],
"hq_lng": hq[1],
"refresh_interval": REFRESH_INTERVAL,
"routing_provider": ROUTING_PROVIDER,
"thresholds": {
"yellow": THRESHOLD_YELLOW,
"red": THRESHOLD_RED,
"dark_red": THRESHOLD_DARK_RED,
},
}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/api/locations")
@limiter.limit("60/minute")
async def get_locations(request: Request):
locations = load_locations()
result = []
for loc in locations:
entry = {
"id": loc["id"],
"name": loc["name"],
"lat": loc["lat"],
"lng": loc["lng"],
}
if loc["id"] in _cache:
entry.update(_cache[loc["id"]])
elif loc["id"] in _provider_errors:
entry["error"] = True
result.append(entry)
return result
@app.get("/api/status")
@limiter.limit("30/minute")
async def get_status_summary(request: Request):
locations = load_locations()
failing = [
{
"id": loc["id"],
"name": loc["name"],
"message": _provider_errors[loc["id"]]["message"],
"at": _provider_errors[loc["id"]]["at"],
}
for loc in locations
if loc["id"] in _provider_errors
]
return {
"routing_provider": ROUTING_PROVIDER,
"hq_error": _hq_error,
"last_refresh_attempt": _last_refresh_attempt,
"last_refresh_ok": _last_refresh_ok,
"refresh_interval": REFRESH_INTERVAL,
"total_locations": len(locations),
"failing_locations": failing,
"healthy": _hq_error is None and len(failing) == 0,
}
@app.post("/api/refresh")
@limiter.limit("10/minute")
async def manual_refresh(request: Request):
await refresh_all_locations()
return {"ok": True}
class AddByAddress(BaseModel):
name: str
address: str
class AddByCoords(BaseModel):
name: str
lat: float
lng: float
class RenameLocation(BaseModel):
name: str
@app.post("/api/locations/add/address")
@limiter.limit("20/minute")
async def add_by_address(request: Request, body: AddByAddress):
try:
lat, lng = await geocode_address(body.address)
except Exception as e:
raise HTTPException(400, f"Geocoding failed: {e}")
locations = load_locations()
new_id = str(int(time.time() * 1000))
locations.append({"id": new_id, "name": body.name, "lat": lat, "lng": lng, "baseline_seconds": None})
save_locations(locations)
asyncio.create_task(refresh_all_locations())
return {"ok": True, "id": new_id, "lat": lat, "lng": lng}
@app.post("/api/locations/add/coords")
@limiter.limit("20/minute")
async def add_by_coords(request: Request, body: AddByCoords):
locations = load_locations()
new_id = str(int(time.time() * 1000))
locations.append({"id": new_id, "name": body.name, "lat": body.lat, "lng": body.lng, "baseline_seconds": None})
save_locations(locations)
asyncio.create_task(refresh_all_locations())
return {"ok": True, "id": new_id}
@app.patch("/api/locations/{loc_id}/rename")
@limiter.limit("20/minute")
async def rename_location(request: Request, loc_id: str, body: RenameLocation):
locations = load_locations()
for loc in locations:
if loc["id"] == loc_id:
loc["name"] = body.name.strip()
save_locations(locations)
return {"ok": True}
raise HTTPException(404, "Location not found")
@app.delete("/api/locations")
@limiter.limit("20/minute")
async def remove_locations(request: Request, ids: list[str]):
locations = load_locations()
locations = [l for l in locations if l["id"] not in ids]
save_locations(locations)
for lid in ids:
_cache.pop(lid, None)
return {"ok": True}
class SimulateDelay(BaseModel):
ratio: float # e.g. 1.35 = simulate 35% worse than baseline. Use 1.0 to clear.
@app.post("/api/locations/{loc_id}/simulate-delay")
@limiter.limit("20/minute")
async def simulate_delay(request: Request, loc_id: str, body: SimulateDelay):
"""Debug/testing endpoint. Call with body {"ratio": 1.6} to fake a 60% delay
on this location, to verify status colors and thresholds update correctly
without waiting for real traffic changes. POST {"ratio": 1.0} to clear it.
Overwritten by the next real refresh unless REFRESH_INTERVAL is paused."""
if loc_id not in _cache:
raise HTTPException(404, "No cached data for this location yet — refresh first")
baseline = _cache[loc_id]["baseline_seconds"]
fake_seconds = int(baseline * body.ratio)
_cache[loc_id]["current_seconds"] = fake_seconds
_cache[loc_id]["ratio"] = round(body.ratio, 3)
_cache[loc_id]["status"] = get_status(body.ratio)
_cache[loc_id]["one_way_min"] = round(fake_seconds / 60)
_cache[loc_id]["round_trip_min"] = round(fake_seconds / 60 * 2)
_cache[loc_id]["simulated"] = body.ratio != 1.0
_cache[loc_id]["last_updated"] = int(time.time())
return {"ok": True, "simulated_ratio": body.ratio, "status": _cache[loc_id]["status"]}
@app.post("/api/locations/{loc_id}/reset-baseline")
@limiter.limit("20/minute")
async def reset_baseline(request: Request, loc_id: str):
locations = load_locations()
for loc in locations:
if loc["id"] == loc_id:
loc["baseline_seconds"] = None
save_locations(locations)
if loc_id in _cache:
_cache[loc_id]["baseline_seconds"] = None
_cache[loc_id]["ratio"] = 1.0
_cache[loc_id]["status"] = "green"
asyncio.create_task(refresh_all_locations())
return {"ok": True}
raise HTTPException(404, "Location not found")
class ReorderBody(BaseModel):
ids: list[str]
@app.post("/api/locations/reorder")
@limiter.limit("30/minute")
async def reorder_locations(request: Request, body: ReorderBody):
locations = load_locations()
loc_map = {l["id"]: l for l in locations}
reordered = [loc_map[i] for i in body.ids if i in loc_map]
# Append any locations not in the reorder list (safety net)
seen = set(body.ids)
reordered += [l for l in locations if l["id"] not in seen]
save_locations(reordered)
return {"ok": True}
# ── Serve frontend ────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def root():
return FileResponse(STATIC_DIR / "index.html")
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.111.0
uvicorn==0.29.0
httpx==0.27.0
python-dotenv==1.0.1
slowapi==0.1.9
+1919
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Travel Monitor
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/travel-monitor
ExecStart=/opt/travel-monitor/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=5
EnvironmentFile=/opt/travel-monitor/.env
[Install]
WantedBy=multi-user.target