From 7183ae31bc0bc6772dcf166c426b173e16734c19 Mon Sep 17 00:00:00 2001 From: Daniel Mork Date: Sat, 11 Jul 2026 19:30:52 +0200 Subject: [PATCH] Initial commit --- .env.example | 38 + Dockerfile | 17 + README.md | 213 +++++ docker-compose.yml | 13 + main.py | 611 +++++++++++++ requirements.txt | 5 + static/index.html | 1919 ++++++++++++++++++++++++++++++++++++++++ travel-monitor.service | 15 + 8 files changed, 2831 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/index.html create mode 100644 travel-monitor.service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eaabf5b --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b38ae3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3efc937 --- /dev/null +++ b/README.md @@ -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, `1–9` 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@:/opt/travel-monitor + +# 3. SSH in and configure +ssh root@ +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://:` 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.20–1.50× | Worsened — 20–50% slower than baseline | +| 🔴 Red | 1.50–1.70× | Bad — 50–70% 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://: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://: +} +``` + +### 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5e82fb8 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..c0f1dac --- /dev/null +++ b/main.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1c352a --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ec1da17 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1919 @@ + + + + + + Travel Monitor + + + + + + +
⚠ Connection lost — data may be stale. Retrying…
+ +
+
+

Loading travel data…

+
+ +
+ + +
+
+
+ +
+
+ + + + + +
+ + + +
+ +
+ +
✏ Rename
+
↺ Reset baseline
+ +
+ +
+ R refresh   + 1–9 jump to location   + Tab switch tab   + Esc close +
+ +
+
📍 By address
+
🎯 By coordinates
+
+ +
+
🌑 Dark Matter
+
☀️ Voyager (light)
+
⚪ Positron (light, minimal)
+
🗺 OpenStreetMap (standard)
+
🛰 Satellite
+
+ +
+
Provider status
+
+
+ +
+
+

Remove locations?

+

+ +
+
+ +
+
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + + + diff --git a/travel-monitor.service b/travel-monitor.service new file mode 100644 index 0000000..6980b69 --- /dev/null +++ b/travel-monitor.service @@ -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