Initial commit
This commit is contained in:
@@ -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
@@ -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"]
|
||||||
@@ -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@<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.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://<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)
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
Reference in New Issue
Block a user