Phase 24: remote trip + dashcam portal
- Add Flask-Login browser session auth (separate from device X-Api-Key auth)
- modules/user_auth.py: User model, require_login decorator, bcrypt helpers
- /login with CSRF token, /logout
- scripts/create_user.py to provision admin user
- Add server-side trip computation from tracking_points
- db.compute_trips_for_device(): gap > 5 min = new trip, haversine distance,
upserts into trips table, auto-triggered in bg thread after each upload batch
- db.list_trips(), db.get_trip_route(): same JSON shape as Pi API
- db.get_overview_stats(): aggregated totals for overview page
- db.list_dashcam_files(), db.get_dashcam_file(): browse/stream helpers
- Add web portal to server/app.py
- Overview page (/): stat cards + recent trips + recent clips
- Trip history (/history): reuses Pi history.js + data-list.js unchanged
via /api/trips + /api/trip/<id> compatibility aliases
- Dashcam archive (/dashcam): file list + inline video player
- /api/v1/portal/dashcam/stream/<id>: Range-request streaming, path traversal guard
- /api/geocode: same Nominatim→reverse_geocoder offline fallback as Pi
- Add server templates (server/templates/)
- base.html: shared shell with 'Remote server' badge in sidebar + mobile topbar
- login.html, index.html, history.html, dashcam.html, components.html
- DB schema: users + trips tables added to init.sql (fresh deploys) +
migrate_phase24.sql (existing deployments)
- Infrastructure: Dockerfile uses repo-root build context to copy shared
static/ assets; docker-compose updated; SECRET_KEY + DEVICE_NAME in .env;
requirements: Flask-Login, bcrypt, reverse_geocoder
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -1241,60 +1241,66 @@ including two UX polish passes and multiple bug-fix rounds.
|
|||||||
- Status JSON now includes `interface` field for diagnostics.
|
- Status JSON now includes `interface` field for diagnostics.
|
||||||
- SSH alias updated to new wlan1 IP (192.168.50.213).
|
- SSH alias updated to new wlan1 IP (192.168.50.213).
|
||||||
|
|
||||||
### Phase 24: Remote trip + dashcam portal with shared web UI — 🚧 PLANNED
|
### Phase 24: Remote trip + dashcam portal with shared web UI — ✅ DONE
|
||||||
**Added 2026-08-09; intentionally scheduled after Phase 23.** Phase 23 first establishes the final
|
**Completed 2026-08-10.** Extended the existing `server/` Flask deployment from an upload-only API into a full read-only browser portal backed by Postgres tracking data and the uploaded dashcam archive.
|
||||||
visual language and complete in-car interface. Building the remote portal afterward avoids
|
|
||||||
duplicating the current unfinished theme and lets both deployments deliberately share the same
|
#### What was built
|
||||||
finished shell, components, trip list, route map, timeline replay, dashcam library, units, and
|
|
||||||
responsive behavior.
|
**Database (server/sql/)**
|
||||||
|
- `migrate_phase24.sql`: adds `users` (bcrypt browser auth) and `trips` (server-side computed trip index) tables
|
||||||
|
- `init.sql` updated to include both tables so fresh deployments don't need a separate migration step
|
||||||
|
- `trips` table: `(device_id, start_time)` unique key, full summary stats, start/end coordinates
|
||||||
|
|
||||||
|
**DB layer (server/modules/db.py)**
|
||||||
|
- `get_user_by_id()`, `get_user_by_username()`, `create_user()` — browser auth helpers
|
||||||
|
- `compute_trips_for_device()` — splits raw `tracking_points` into trip segments (gap > 5 min = new trip), filters out <50m blips, computes distance (haversine), duration, avg/max speed, upserts into `trips` table
|
||||||
|
- `list_trips()`, `get_trip_route()` — portal trip API helpers returning same JSON shape as Pi `/api/trips`/`/api/trip/<id>`
|
||||||
|
- `list_dashcam_files()`, `get_dashcam_file()` — dashcam browse/stream helpers
|
||||||
|
- `get_overview_stats()` — aggregated counts/totals for the Overview page
|
||||||
|
|
||||||
|
**Auth (server/modules/user_auth.py)**
|
||||||
|
- Flask-Login based browser session auth, completely separate from device `X-Api-Key` auth
|
||||||
|
- `User` class, `load_user()` loader, `check_password()`, `hash_password()`, `require_login` decorator
|
||||||
|
- `login_manager.login_view = "login_page"` — unauthenticated requests redirected to `/login`
|
||||||
|
|
||||||
|
**Application (server/app.py)**
|
||||||
|
- Added Flask-Login init, `SECRET_KEY` from env
|
||||||
|
- `_geocode_cached()` — same Nominatim→reverse_geocoder offline fallback as Pi, LRU cached
|
||||||
|
- `/login` GET/POST with CSRF token (stored in session, validated on POST)
|
||||||
|
- `/logout`
|
||||||
|
- Portal pages: `GET /` → Overview, `GET /history` → Trip history (reuses Pi `history.js`), `GET /dashcam` → Dashcam archive
|
||||||
|
- Portal JSON API: `/api/v1/portal/stats`, `/api/v1/portal/trips`, `/api/v1/portal/trip/<id>`, `/api/v1/portal/dashcam`, `/api/v1/portal/dashcam/stream/<id>` (Range-request streaming), `/api/v1/portal/recompute-trips`
|
||||||
|
- Pi history.js compatibility aliases: `/api/trips`, `/api/trip/<id>`, `/api/geocode`
|
||||||
|
- Auto-recompute trips in background thread after each tracking batch upload
|
||||||
|
- Path traversal guard on dashcam stream: rejects any storage path that escapes `STORAGE_DIR`
|
||||||
|
|
||||||
|
**Templates (server/templates/)**
|
||||||
|
- `base.html` — shared shell matching Pi visual style; "Remote server" badge in sidebar and mobile topbar; navigation has Overview / Trip history / Dashcam archive only (no Pi-hardware nav items)
|
||||||
|
- `login.html` — standalone login page with CSRF token, flash messages
|
||||||
|
- `index.html` — Overview page: 6 stat cards (trips, distance, drive time, top speed, videos, odometer), recent trips table, recent dashcam clips table
|
||||||
|
- `history.html` — identical layout to Pi history page; reuses `history.js` + `data-list.js` unchanged
|
||||||
|
- `dashcam.html` — file list table (name, size, uploaded date, download button) + inline video player with seek/Range support
|
||||||
|
- `components.html` — same `data_table` macro as Pi (needed by history.html)
|
||||||
|
|
||||||
|
**Scripts (server/scripts/)**
|
||||||
|
- `create_user.py` — create or update browser portal user: `docker compose exec server python scripts/create_user.py admin yourpassword`
|
||||||
|
- `recompute_trips.py` — recompute trips for all devices: `docker compose exec server python scripts/recompute_trips.py`
|
||||||
|
|
||||||
|
**Infrastructure**
|
||||||
|
- `server/requirements.txt`: added `Flask-Login`, `bcrypt`, `reverse_geocoder`
|
||||||
|
- `server/Dockerfile`: added `ffmpeg`, updated COPY paths to use repo-root build context (`context: ..`), copies shared `static/` assets
|
||||||
|
- `server/docker-compose.yml`: build `context: ..`, `dockerfile: server/Dockerfile`; added `SECRET_KEY` and `DEVICE_NAME` env vars; added `migrate_phase24.sql` mount (for existing deployments)
|
||||||
|
- `server/.env.example`: added `SECRET_KEY`, `DEVICE_NAME` entries with generation instructions
|
||||||
|
|
||||||
|
#### Deployment steps (existing unraid stack)
|
||||||
|
1. `git pull` on unraid (or however you deploy the server)
|
||||||
|
2. Run migration: `docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase24.sql`
|
||||||
|
3. Add `SECRET_KEY` and `DEVICE_NAME` to `.env`
|
||||||
|
4. Rebuild: `docker compose up -d --build`
|
||||||
|
5. Create portal user: `docker compose exec api python scripts/create_user.py admin yourpassword`
|
||||||
|
6. Run initial trip computation: `docker compose exec api python scripts/recompute_trips.py`
|
||||||
|
7. Browse to `https://your-cloudflare-tunnel-url/` → login page
|
||||||
|
|
||||||
Extend the existing self-hosted `server/` deployment from an upload-only API into a secure remote
|
|
||||||
web portal backed by its Postgres tracking data and uploaded dashcam archive, so trip history,
|
|
||||||
recorded trip details, and footage can be accessed away from the car:
|
|
||||||
- Add server-side trip boundary/index data equivalent to Phase 18, generated from uploaded tracking
|
|
||||||
points without changing or deleting the raw records. Expose trip summaries, full/downsampled
|
|
||||||
routes, timestamps, distance in km/mi, average/max speed in km/h and mph, and timeline replay.
|
|
||||||
- Reuse the redesigned Phase 23 Jinja/Bootstrap UI rather than creating a visually separate app.
|
|
||||||
Extract/share presentation assets where practical so local and remote pages do not drift, while
|
|
||||||
keeping their data providers and available actions explicit.
|
|
||||||
- Make the uploaded dashcam archive a required first-class remote page, matching the redesigned
|
|
||||||
in-car imported-video library: sortable/searchable file list, relative path, size, timestamp,
|
|
||||||
thumbnails, inline H.264 playback with Range/seeking support, and an explicit download action for
|
|
||||||
individual original files. Downloads must stream from server storage with safe
|
|
||||||
`Content-Disposition`, bounded/traversal-safe paths, and no whole-video memory buffering. Remote
|
|
||||||
deletion is not included unless it is designed later with separate safeguards; the server remains
|
|
||||||
the archive/backup.
|
|
||||||
- Put an always-visible environment indicator in the shared shell: **In-car / Local Pi** on
|
|
||||||
`yaris.local`, and **Remote server** on the hosted portal. Page title, navigation, and status
|
|
||||||
styling must also make the context unambiguous so the user cannot mistake remote historical data
|
|
||||||
for a live connection to the car.
|
|
||||||
- The remote portal is historical/read-only. It should feel like the same product, but render only
|
|
||||||
server-relevant navigation and data—not redacted/empty imitations of unavailable hardware.
|
|
||||||
Physical dashcam mount/import/delete, live OBD/GPS, cellular status/configuration, Pi health, and
|
|
||||||
local Pi settings are omitted entirely. Trip history/replay and the uploaded dashcam archive are
|
|
||||||
the required remote scope.
|
|
||||||
- Add real human-user authentication and secure sessions for the internet-facing UI. Existing
|
|
||||||
device `X-Device-Name`/`X-Api-Key` upload authentication remains separate and must not become a
|
|
||||||
browser login mechanism. Preserve Cloudflare Tunnel compatibility and do not expose secrets in
|
|
||||||
HTML/JavaScript.
|
|
||||||
- Support selecting a vehicle/device if more than one is registered later, while defaulting
|
|
||||||
cleanly to the current Yaris. Keep trip APIs scoped to the authenticated user/device.
|
|
||||||
- Synchronize alert transitions, recoveries, timestamps, severity/details, and current state from
|
|
||||||
the Pi to the server through an authenticated, idempotent offline queue. The Pi remains
|
|
||||||
authoritative for vehicle/hardware detection and its local bell must never depend on internet
|
|
||||||
access. The server stores per-vehicle alert history/current state, reconciles reconnects without
|
|
||||||
duplicates, and exposes it in the remote portal; remote acknowledgement must not silently clear
|
|
||||||
the underlying Pi condition.
|
|
||||||
- Synchronize alert transitions, recoveries, timestamps, severity/details, and current state from
|
|
||||||
the Pi to the server through an authenticated, idempotent offline queue. The Pi remains
|
|
||||||
authoritative for vehicle/hardware detection and its local bell must never depend on internet
|
|
||||||
access. The server stores per-vehicle alert history/current state, reconciles reconnects without
|
|
||||||
duplicates, and exposes it in the remote portal; remote acknowledgement must not silently clear
|
|
||||||
the underlying Pi condition.
|
|
||||||
- Deploy only after Phase 23 is complete: rebuild the unraid Docker stack, verify migrations against
|
|
||||||
copied data first, compare remote trip calculations with uploaded Pi records, verify playback,
|
|
||||||
Range requests, and downloads against disposable copies of real server footage, and browser-test
|
|
||||||
desktop/mobile through the real hosted endpoint before committing.
|
|
||||||
|
|
||||||
### Phase 25: Pre-install Yaris 2013 compatibility test — 🚧 PLANNED (core-system gate)
|
### Phase 25: Pre-install Yaris 2013 compatibility test — 🚧 PLANNED (core-system gate)
|
||||||
**Added 2026-08-09; intentionally immediately before the Android app.** Bench tests, synthetic
|
**Added 2026-08-09; intentionally immediately before the Android app.** Bench tests, synthetic
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ DATA_DIR=./data
|
|||||||
# Reject dashcam uploads larger than this (MB). Bump if your dashcam
|
# Reject dashcam uploads larger than this (MB). Bump if your dashcam
|
||||||
# produces very large individual clips.
|
# produces very large individual clips.
|
||||||
MAX_DASHCAM_UPLOAD_MB=2048
|
MAX_DASHCAM_UPLOAD_MB=2048
|
||||||
|
|
||||||
|
# Secret key for browser sessions (login cookies). Generate with:
|
||||||
|
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=changeme_generate_a_real_secret_key
|
||||||
|
|
||||||
|
# The device name registered in the devices table - used by the portal to
|
||||||
|
# scope all queries to the right Pi.
|
||||||
|
DEVICE_NAME=yaris-2013
|
||||||
|
|||||||
+16
-4
@@ -2,12 +2,24 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
# ffmpeg for potential thumbnail generation; build tools needed by some
|
||||||
|
# reverse_geocoder wheels.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY server/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app.py .
|
# Copy server application code
|
||||||
COPY modules/ ./modules/
|
COPY server/app.py .
|
||||||
COPY scripts/ ./scripts/
|
COPY server/modules/ ./modules/
|
||||||
|
COPY server/scripts/ ./scripts/
|
||||||
|
COPY server/templates/ ./templates/
|
||||||
|
COPY server/sql/ ./sql/
|
||||||
|
|
||||||
|
# Copy shared frontend assets (Bootstrap, Leaflet, fonts, CSS, JS)
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
EXPOSE 8098
|
EXPOSE 8098
|
||||||
|
|
||||||
|
|||||||
+281
-1
@@ -1,17 +1,63 @@
|
|||||||
"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
|
"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
|
||||||
uploaded by the Pi, storing them in Postgres + on-disk file storage.
|
uploaded by the Pi, storing them in Postgres + on-disk file storage.
|
||||||
|
Also serves a read-only web portal for browsing trip history and dashcam archive.
|
||||||
"""
|
"""
|
||||||
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from flask import Flask, request, jsonify, g
|
from flask import (
|
||||||
|
Flask, request, jsonify, g, render_template, redirect, url_for,
|
||||||
|
flash, session, Response, abort
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, current_user
|
||||||
|
|
||||||
from modules import db
|
from modules import db
|
||||||
from modules.auth import require_device_auth
|
from modules.auth import require_device_auth
|
||||||
|
from modules.user_auth import login_manager, User, check_password, require_login
|
||||||
|
|
||||||
|
# ── Geocode cache ──────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
import reverse_geocoder as _rg
|
||||||
|
_RG_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_RG_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=512)
|
||||||
|
def _geocode_cached(lat4, lon4):
|
||||||
|
lat = lat4 / 10000.0
|
||||||
|
lon = lon4 / 10000.0
|
||||||
|
try:
|
||||||
|
import urllib.request, urllib.error, json as _json
|
||||||
|
url = (
|
||||||
|
f"https://nominatim.openstreetmap.org/reverse"
|
||||||
|
f"?lat={lat}&lon={lon}&format=json&addressdetails=1"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "yaris-obd-server/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=4) as resp:
|
||||||
|
data = _json.loads(resp.read())
|
||||||
|
addr = data.get("address", {})
|
||||||
|
road = addr.get("road") or addr.get("pedestrian") or addr.get("cycleway", "")
|
||||||
|
city = addr.get("city") or addr.get("town") or addr.get("village", "")
|
||||||
|
return f"{road}, {city}".strip(", ") if road or city else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _RG_AVAILABLE:
|
||||||
|
results = _rg.search([(lat, lon)], verbose=False)
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
return f"{r.get('name', '')}, {r.get('admin1', '')}".strip(", ")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Secret key for browser sessions — read from env in production.
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
||||||
|
|
||||||
# Where uploaded dashcam files get written on disk (mounted to the unraid
|
# Where uploaded dashcam files get written on disk (mounted to the unraid
|
||||||
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
|
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
|
||||||
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
|
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
|
||||||
@@ -22,6 +68,16 @@ app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
|
|||||||
# groups of 500, this just guards against a runaway/misbehaving client.
|
# groups of 500, this just guards against a runaway/misbehaving client.
|
||||||
MAX_TRACKING_BATCH_POINTS = 5000
|
MAX_TRACKING_BATCH_POINTS = 5000
|
||||||
|
|
||||||
|
# Initialise Flask-Login
|
||||||
|
login_manager.init_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_device_id():
|
||||||
|
"""Return the device id for the configured device name (single Pi setup)."""
|
||||||
|
name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
||||||
|
dev = db.get_device_by_name(name)
|
||||||
|
return dev["id"] if dev else None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -54,6 +110,14 @@ def upload_tracking_batch():
|
|||||||
return jsonify(error="every point requires 'recorded_at'"), 400
|
return jsonify(error="every point requires 'recorded_at'"), 400
|
||||||
|
|
||||||
inserted = db.insert_tracking_points(g.device_id, points)
|
inserted = db.insert_tracking_points(g.device_id, points)
|
||||||
|
# Recompute trips in background thread so portal reflects new data immediately.
|
||||||
|
if inserted > 0:
|
||||||
|
import threading
|
||||||
|
threading.Thread(
|
||||||
|
target=db.compute_trips_for_device,
|
||||||
|
args=(g.device_id,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
return jsonify(status="ok", received=inserted), 201
|
return jsonify(status="ok", received=inserted), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +318,222 @@ def dashcam_upload_chunk():
|
|||||||
return jsonify(status="complete", size_bytes=size_bytes), 201
|
return jsonify(status="complete", size_bytes=size_bytes), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ── Browser auth ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login_page():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("portal_index"))
|
||||||
|
csrf_token = secrets.token_hex(16)
|
||||||
|
session["csrf_token"] = csrf_token
|
||||||
|
return render_template("login.html", csrf_token=csrf_token)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
def login_submit():
|
||||||
|
# CSRF check
|
||||||
|
token = request.form.get("csrf_token", "")
|
||||||
|
if not token or token != session.pop("csrf_token", None):
|
||||||
|
flash("Invalid request. Please try again.", "error")
|
||||||
|
return redirect(url_for("login_page"))
|
||||||
|
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
user_row = db.get_user_by_username(username)
|
||||||
|
if user_row and check_password(password, user_row["password_hash"]):
|
||||||
|
login_user(User(user_row["id"], user_row["username"]), remember=False)
|
||||||
|
return redirect(url_for("portal_index"))
|
||||||
|
flash("Invalid username or password.", "error")
|
||||||
|
return redirect(url_for("login_page"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
@require_login
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for("login_page"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Portal UI pages ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@require_login
|
||||||
|
def portal_index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/history")
|
||||||
|
@require_login
|
||||||
|
def portal_history():
|
||||||
|
return render_template("history.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashcam")
|
||||||
|
@require_login
|
||||||
|
def portal_dashcam():
|
||||||
|
return render_template("dashcam.html")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Portal JSON API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/stats")
|
||||||
|
@require_login
|
||||||
|
def portal_stats():
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
return jsonify(db.get_overview_stats(device_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/trips")
|
||||||
|
@require_login
|
||||||
|
def portal_trips():
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
limit = min(int(request.args.get("limit", 200)), 1000)
|
||||||
|
return jsonify(db.list_trips(device_id, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/trip/<int:trip_id>")
|
||||||
|
@require_login
|
||||||
|
def portal_trip_detail(trip_id):
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
data = db.get_trip_route(device_id, trip_id)
|
||||||
|
if data is None:
|
||||||
|
return jsonify(error="trip not found"), 404
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/dashcam")
|
||||||
|
@require_login
|
||||||
|
def portal_dashcam_list():
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
limit = min(int(request.args.get("limit", 500)), 2000)
|
||||||
|
return jsonify(db.list_dashcam_files(device_id, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/portal/dashcam/stream/<int:file_id>")
|
||||||
|
@require_login
|
||||||
|
def portal_dashcam_stream(file_id):
|
||||||
|
"""Stream a dashcam file with Range request support for video seeking."""
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
abort(404)
|
||||||
|
file_row = db.get_dashcam_file(device_id, file_id)
|
||||||
|
if file_row is None:
|
||||||
|
abort(404)
|
||||||
|
storage_path = file_row["storage_path"]
|
||||||
|
# Safety check: must still be under STORAGE_DIR
|
||||||
|
if not os.path.commonpath([STORAGE_DIR, os.path.normpath(storage_path)]) == os.path.normpath(STORAGE_DIR):
|
||||||
|
abort(403)
|
||||||
|
if not os.path.isfile(storage_path):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(storage_path)
|
||||||
|
range_header = request.headers.get("Range")
|
||||||
|
mime = "video/mp4"
|
||||||
|
filename = file_row["relative_path"].split("/")[-1]
|
||||||
|
|
||||||
|
if not range_header:
|
||||||
|
# Full file download
|
||||||
|
def generate_full():
|
||||||
|
with open(storage_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'inline; filename="{filename}"',
|
||||||
|
"Content-Length": file_size,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
}
|
||||||
|
return Response(generate_full(), status=200, headers=headers, mimetype=mime)
|
||||||
|
|
||||||
|
# Parse Range header (bytes=start-end)
|
||||||
|
try:
|
||||||
|
range_val = range_header.replace("bytes=", "")
|
||||||
|
start_s, end_s = range_val.split("-")
|
||||||
|
start = int(start_s)
|
||||||
|
end = int(end_s) if end_s else file_size - 1
|
||||||
|
except Exception:
|
||||||
|
abort(416)
|
||||||
|
|
||||||
|
if start >= file_size or end >= file_size or start > end:
|
||||||
|
abort(416)
|
||||||
|
|
||||||
|
length = end - start + 1
|
||||||
|
|
||||||
|
def generate_range():
|
||||||
|
with open(storage_path, "rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
remaining = length
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = f.read(min(1024 * 1024, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Content-Length": length,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Disposition": f'inline; filename="{filename}"',
|
||||||
|
}
|
||||||
|
return Response(generate_range(), status=206, headers=headers, mimetype=mime)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geocode (same offline logic as Pi) ────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/geocode")
|
||||||
|
@require_login
|
||||||
|
def geocode():
|
||||||
|
try:
|
||||||
|
lat = float(request.args["lat"])
|
||||||
|
lon = float(request.args["lon"])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return jsonify(error="lat and lon required"), 400
|
||||||
|
lat4 = int(round(lat, 4) * 10000)
|
||||||
|
lon4 = int(round(lon, 4) * 10000)
|
||||||
|
name = _geocode_cached(lat4, lon4)
|
||||||
|
if name:
|
||||||
|
return jsonify(name=name)
|
||||||
|
return jsonify(name=None), 204
|
||||||
|
|
||||||
|
|
||||||
|
# ── Uplink trigger (recompute trips after new batch) ──────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/v1/portal/recompute-trips")
|
||||||
|
@require_login
|
||||||
|
def recompute_trips():
|
||||||
|
"""Manually trigger trip recomputation. Called after upload batch for freshness."""
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
n = db.compute_trips_for_device(device_id)
|
||||||
|
return jsonify(status="ok", trips_computed=n)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/trips")
|
||||||
|
@require_login
|
||||||
|
def portal_trips_compat():
|
||||||
|
"""Pi history.js compatibility alias."""
|
||||||
|
return portal_trips()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/trip/<int:trip_id>")
|
||||||
|
@require_login
|
||||||
|
def portal_trip_compat(trip_id):
|
||||||
|
"""Pi history.js compatibility alias."""
|
||||||
|
return portal_trip_detail(trip_id)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app.run(host="0.0.0.0", port=8098)
|
app.run(host="0.0.0.0", port=8098)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
|
- ${DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
|
||||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
- ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -19,7 +20,9 @@ services:
|
|||||||
# `api` container over the internal compose network.
|
# `api` container over the internal compose network.
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
container_name: yaris-obd-api
|
container_name: yaris-obd-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -33,6 +36,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a POSTGRES_PASSWORD in .env}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a POSTGRES_PASSWORD in .env}
|
||||||
DASHCAM_STORAGE_DIR: /data/dashcam
|
DASHCAM_STORAGE_DIR: /data/dashcam
|
||||||
MAX_DASHCAM_UPLOAD_MB: ${MAX_DASHCAM_UPLOAD_MB:-2048}
|
MAX_DASHCAM_UPLOAD_MB: ${MAX_DASHCAM_UPLOAD_MB:-2048}
|
||||||
|
SECRET_KEY: ${SECRET_KEY:?set a SECRET_KEY in .env}
|
||||||
|
DEVICE_NAME: ${DEVICE_NAME:-yaris-2013}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_DIR:-./data}/dashcam:/data/dashcam
|
- ${DATA_DIR:-./data}/dashcam:/data/dashcam
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -106,3 +106,268 @@ def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_pa
|
|||||||
""",
|
""",
|
||||||
(device_id, relative_path, size_bytes, sha256, storage_path),
|
(device_id, relative_path, size_bytes, sha256, storage_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── User auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute("SELECT id, username, password_hash FROM users WHERE id = %s", (user_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return {"id": row[0], "username": row[1], "password_hash": row[2]} if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute("SELECT id, username, password_hash FROM users WHERE username = %s", (username,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return {"id": row[0], "username": row[1], "password_hash": row[2]} if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username, password_hash):
|
||||||
|
with get_cursor(commit=True) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (username, password_hash) VALUES (%s, %s) ON CONFLICT (username) DO NOTHING RETURNING id",
|
||||||
|
(username, password_hash),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Server-side trips ──────────────────────────────────────────────────────
|
||||||
|
# Gap threshold: if two consecutive points are more than this many seconds
|
||||||
|
# apart, they belong to different trips.
|
||||||
|
_TRIP_GAP_S = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def compute_trips_for_device(device_id):
|
||||||
|
"""Recompute trip boundaries from tracking_points and upsert into trips table."""
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT recorded_at, gps_lat, gps_lon, speed
|
||||||
|
FROM tracking_points
|
||||||
|
WHERE device_id = %s AND gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||||
|
ORDER BY recorded_at ASC
|
||||||
|
""",
|
||||||
|
(device_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
def haversine(lat1, lon1, lat2, lon2):
|
||||||
|
R = 6371.0
|
||||||
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
dlon = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
# Split into segments by gap
|
||||||
|
segments = []
|
||||||
|
current = [rows[0]]
|
||||||
|
for prev, curr in zip(rows, rows[1:]):
|
||||||
|
gap = (curr[0] - prev[0]).total_seconds()
|
||||||
|
if gap > _TRIP_GAP_S:
|
||||||
|
segments.append(current)
|
||||||
|
current = [curr]
|
||||||
|
else:
|
||||||
|
current.append(curr)
|
||||||
|
segments.append(current)
|
||||||
|
|
||||||
|
# Build trip records
|
||||||
|
trips = []
|
||||||
|
for seg in segments:
|
||||||
|
if len(seg) < 2:
|
||||||
|
continue
|
||||||
|
start_time = seg[0][0]
|
||||||
|
end_time = seg[-1][0]
|
||||||
|
duration_s = int((end_time - start_time).total_seconds())
|
||||||
|
if duration_s < 30:
|
||||||
|
continue
|
||||||
|
dist = sum(
|
||||||
|
haversine(seg[i][1], seg[i][2], seg[i+1][1], seg[i+1][2])
|
||||||
|
for i in range(len(seg) - 1)
|
||||||
|
)
|
||||||
|
if dist < 0.05: # skip <50m blips
|
||||||
|
continue
|
||||||
|
speeds = [r[3] for r in seg if r[3] is not None]
|
||||||
|
trips.append({
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"distance_km": round(dist, 3),
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"avg_speed_kmh": round(sum(speeds) / len(speeds), 1) if speeds else None,
|
||||||
|
"max_speed_kmh": round(max(speeds), 1) if speeds else None,
|
||||||
|
"start_lat": seg[0][1],
|
||||||
|
"start_lon": seg[0][2],
|
||||||
|
"end_lat": seg[-1][1],
|
||||||
|
"end_lon": seg[-1][2],
|
||||||
|
"point_count": len(seg),
|
||||||
|
})
|
||||||
|
|
||||||
|
with get_cursor(commit=True) as cur:
|
||||||
|
for t in trips:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trips (device_id, start_time, end_time, distance_km, duration_s,
|
||||||
|
avg_speed_kmh, max_speed_kmh, start_lat, start_lon, end_lat, end_lon,
|
||||||
|
point_count, computed_at)
|
||||||
|
VALUES (%(device_id)s, %(start_time)s, %(end_time)s, %(distance_km)s, %(duration_s)s,
|
||||||
|
%(avg_speed_kmh)s, %(max_speed_kmh)s, %(start_lat)s, %(start_lon)s,
|
||||||
|
%(end_lat)s, %(end_lon)s, %(point_count)s, now())
|
||||||
|
ON CONFLICT (device_id, start_time) DO UPDATE SET
|
||||||
|
end_time = EXCLUDED.end_time,
|
||||||
|
distance_km = EXCLUDED.distance_km,
|
||||||
|
duration_s = EXCLUDED.duration_s,
|
||||||
|
avg_speed_kmh = EXCLUDED.avg_speed_kmh,
|
||||||
|
max_speed_kmh = EXCLUDED.max_speed_kmh,
|
||||||
|
end_lat = EXCLUDED.end_lat,
|
||||||
|
end_lon = EXCLUDED.end_lon,
|
||||||
|
point_count = EXCLUDED.point_count,
|
||||||
|
computed_at = now()
|
||||||
|
""",
|
||||||
|
{"device_id": device_id, **t},
|
||||||
|
)
|
||||||
|
return len(trips)
|
||||||
|
|
||||||
|
|
||||||
|
def list_trips(device_id, limit=200):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, start_time, end_time, distance_km, duration_s,
|
||||||
|
avg_speed_kmh, max_speed_kmh, start_lat, start_lon,
|
||||||
|
end_lat, end_lon, point_count
|
||||||
|
FROM trips
|
||||||
|
WHERE device_id = %s
|
||||||
|
ORDER BY start_time DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(device_id, limit),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"start_datetime": r[1].isoformat(),
|
||||||
|
"end_datetime": r[2].isoformat(),
|
||||||
|
"distance_km": r[3],
|
||||||
|
"duration_seconds": r[4],
|
||||||
|
"average_speed_kmh": r[5],
|
||||||
|
"max_speed_kmh": r[6],
|
||||||
|
"start_lat": r[7],
|
||||||
|
"start_lon": r[8],
|
||||||
|
"end_lat": r[9],
|
||||||
|
"end_lon": r[10],
|
||||||
|
"point_count": r[11],
|
||||||
|
"odo_start_km": None,
|
||||||
|
"odo_end_km": None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_trip_route(device_id, trip_id):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT start_time, end_time, distance_km, duration_s, avg_speed_kmh, max_speed_kmh FROM trips WHERE id = %s AND device_id = %s",
|
||||||
|
(trip_id, device_id),
|
||||||
|
)
|
||||||
|
trip_row = cur.fetchone()
|
||||||
|
if trip_row is None:
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT recorded_at, gps_lat, gps_lon, speed
|
||||||
|
FROM tracking_points
|
||||||
|
WHERE device_id = %s AND recorded_at BETWEEN %s AND %s
|
||||||
|
AND gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||||
|
ORDER BY recorded_at ASC
|
||||||
|
""",
|
||||||
|
(device_id, trip_row[0], trip_row[1]),
|
||||||
|
)
|
||||||
|
points = cur.fetchall()
|
||||||
|
return {
|
||||||
|
"trip": {
|
||||||
|
"distance_km": trip_row[2],
|
||||||
|
"duration_seconds": trip_row[3],
|
||||||
|
"average_speed_kmh": trip_row[4],
|
||||||
|
"max_speed_kmh": trip_row[5],
|
||||||
|
},
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"datetime": p[0].isoformat(),
|
||||||
|
"latitude": p[1],
|
||||||
|
"longitude": p[2],
|
||||||
|
"speed": p[3] or 0,
|
||||||
|
"speed_source": "gps",
|
||||||
|
}
|
||||||
|
for p in points
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_dashcam_files(device_id, limit=500):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, relative_path, size_bytes, uploaded_at, storage_path
|
||||||
|
FROM dashcam_files
|
||||||
|
WHERE device_id = %s
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(device_id, limit),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"relative_path": r[1],
|
||||||
|
"size_bytes": r[2],
|
||||||
|
"uploaded_at": r[3].isoformat(),
|
||||||
|
"storage_path": r[4],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashcam_file(device_id, file_id):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, relative_path, size_bytes, uploaded_at, storage_path FROM dashcam_files WHERE id = %s AND device_id = %s",
|
||||||
|
(file_id, device_id),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
return {"id": r[0], "relative_path": r[1], "size_bytes": r[2], "uploaded_at": r[3].isoformat(), "storage_path": r[4]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_overview_stats(device_id):
|
||||||
|
with get_cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM trips WHERE device_id = %s) AS trip_count,
|
||||||
|
(SELECT COALESCE(SUM(distance_km), 0) FROM trips WHERE device_id = %s) AS total_km,
|
||||||
|
(SELECT COALESCE(SUM(duration_s), 0) FROM trips WHERE device_id = %s) AS total_s,
|
||||||
|
(SELECT COALESCE(MAX(max_speed_kmh), 0) FROM trips WHERE device_id = %s) AS top_speed,
|
||||||
|
(SELECT COUNT(*) FROM dashcam_files WHERE device_id = %s) AS video_count,
|
||||||
|
(SELECT COALESCE(MAX(odometer), 0) FROM tracking_points WHERE device_id = %s) AS odometer_km
|
||||||
|
""",
|
||||||
|
(device_id,) * 6,
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return {
|
||||||
|
"trip_count": r[0],
|
||||||
|
"total_distance_km": float(r[1]),
|
||||||
|
"total_drive_time_s": int(r[2]),
|
||||||
|
"top_speed_kmh": float(r[3]),
|
||||||
|
"video_count": r[4],
|
||||||
|
"odometer_km": float(r[5]),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Browser session authentication using Flask-Login + bcrypt.
|
||||||
|
|
||||||
|
A single admin user is created via scripts/create_user.py.
|
||||||
|
Device upload auth (X-Api-Key) is separate and unchanged.
|
||||||
|
"""
|
||||||
|
import bcrypt
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import redirect, url_for
|
||||||
|
from flask_login import LoginManager, UserMixin, current_user
|
||||||
|
|
||||||
|
from modules import db
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.login_view = "login_page"
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin):
|
||||||
|
def __init__(self, id, username):
|
||||||
|
self.id = id
|
||||||
|
self.username = username
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
row = db.get_user_by_id(int(user_id))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return User(row["id"], row["username"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(plain, hashed):
|
||||||
|
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain):
|
||||||
|
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def require_login(view):
|
||||||
|
"""Decorator: redirect to login page if not authenticated."""
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for("login_page"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
Flask
|
Flask
|
||||||
|
Flask-Login
|
||||||
gunicorn
|
gunicorn
|
||||||
psycopg[binary]
|
psycopg[binary]
|
||||||
psycopg-pool
|
psycopg-pool
|
||||||
|
bcrypt
|
||||||
|
reverse_geocoder
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create or update a browser portal user (admin).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/create_user.py <username> <password>
|
||||||
|
|
||||||
|
Run inside the running container or with POSTGRES_* env vars set:
|
||||||
|
docker compose exec server python scripts/create_user.py admin yourpassword
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Allow importing modules from the app root when run directly.
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from modules import db
|
||||||
|
from modules.user_auth import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("Usage: create_user.py <username> <password>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
username, password = sys.argv[1], sys.argv[2]
|
||||||
|
if len(password) < 8:
|
||||||
|
print("Password must be at least 8 characters.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
hashed = hash_password(password)
|
||||||
|
|
||||||
|
# Try to create; if user already exists, update password.
|
||||||
|
uid = db.create_user(username, hashed)
|
||||||
|
if uid is None:
|
||||||
|
# User exists – update password
|
||||||
|
with db.get_cursor(commit=True) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET password_hash = %s WHERE username = %s",
|
||||||
|
(hashed, username),
|
||||||
|
)
|
||||||
|
print(f"Updated password for user '{username}'.")
|
||||||
|
else:
|
||||||
|
print(f"Created user '{username}' (id={uid}).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recompute server-side trip boundaries from stored tracking_points.
|
||||||
|
|
||||||
|
Run after migrating to Phase 24 schema, or any time you want to refresh
|
||||||
|
trips from raw data:
|
||||||
|
docker compose exec server python scripts/recompute_trips.py
|
||||||
|
"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from modules import db
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with db.get_cursor() as cur:
|
||||||
|
cur.execute("SELECT id, name FROM devices ORDER BY id")
|
||||||
|
devices = cur.fetchall()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
print("No devices found in database.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for device_id, device_name in devices:
|
||||||
|
print(f"Recomputing trips for device '{device_name}' (id={device_id})…", end=" ", flush=True)
|
||||||
|
n = db.compute_trips_for_device(device_id)
|
||||||
|
print(f"{n} trips computed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -42,3 +42,33 @@ CREATE TABLE IF NOT EXISTS dashcam_files (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dashcam_files_device
|
CREATE INDEX IF NOT EXISTS idx_dashcam_files_device
|
||||||
ON dashcam_files (device_id, uploaded_at DESC);
|
ON dashcam_files (device_id, uploaded_at DESC);
|
||||||
|
|
||||||
|
-- ── Phase 24: browser auth + server-side trips ─────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt hash
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trips (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
distance_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
duration_s INTEGER NOT NULL DEFAULT 0,
|
||||||
|
avg_speed_kmh DOUBLE PRECISION,
|
||||||
|
max_speed_kmh DOUBLE PRECISION,
|
||||||
|
start_lat DOUBLE PRECISION,
|
||||||
|
start_lon DOUBLE PRECISION,
|
||||||
|
end_lat DOUBLE PRECISION,
|
||||||
|
end_lon DOUBLE PRECISION,
|
||||||
|
point_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (device_id, start_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trips_device_start
|
||||||
|
ON trips (device_id, start_time DESC);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Phase 24 migration: add users table (browser auth) and server-side trips.
|
||||||
|
-- Run manually against the live Postgres DB:
|
||||||
|
-- psql $DSN -f sql/migrate_phase24.sql
|
||||||
|
|
||||||
|
-- ── Browser session users ──────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt hash
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Server-side trip index ─────────────────────────────────────────────────
|
||||||
|
-- Computed from tracking_points by compute_trips_for_device().
|
||||||
|
-- Idempotent: re-running recomputes and upserts without duplicating rows.
|
||||||
|
CREATE TABLE IF NOT EXISTS trips (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
distance_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
duration_s INTEGER NOT NULL DEFAULT 0,
|
||||||
|
avg_speed_kmh DOUBLE PRECISION,
|
||||||
|
max_speed_kmh DOUBLE PRECISION,
|
||||||
|
start_lat DOUBLE PRECISION,
|
||||||
|
start_lon DOUBLE PRECISION,
|
||||||
|
end_lat DOUBLE PRECISION,
|
||||||
|
end_lon DOUBLE PRECISION,
|
||||||
|
point_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (device_id, start_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trips_device_start
|
||||||
|
ON trips (device_id, start_time DESC);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html data-bs-theme="light" lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
|
<title>{% block title %}Yaris OBD – Remote{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body id="page-top">
|
||||||
|
|
||||||
|
{# ── Desktop sidebar ─────────────────────────────────────────────────── #}
|
||||||
|
<nav id="app-sidebar" class="app-sidebar d-none d-md-flex flex-column">
|
||||||
|
<a class="sidebar-brand" href="{{ url_for('portal_index') }}" aria-label="Home">
|
||||||
|
<i class="fas fa-car sidebar-brand-icon"></i>
|
||||||
|
<span class="sidebar-brand-text">Yaris OBD</span>
|
||||||
|
</a>
|
||||||
|
<div class="px-3 pb-1">
|
||||||
|
<span class="badge bg-warning text-dark w-100 text-center" style="font-size:.65rem;letter-spacing:.04em;">
|
||||||
|
<i class="fas fa-cloud fa-fw"></i> Remote server
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<hr class="sidebar-divider">
|
||||||
|
<ul class="nav flex-column flex-grow-1">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.endpoint == 'portal_index' %} active{% endif %}" href="{{ url_for('portal_index') }}">
|
||||||
|
<i class="fas fa-chart-line fa-fw"></i><span>Overview</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.endpoint == 'portal_history' %} active{% endif %}" href="{{ url_for('portal_history') }}">
|
||||||
|
<i class="fas fa-route fa-fw"></i><span>Trip history</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.endpoint == 'portal_dashcam' %} active{% endif %}" href="{{ url_for('portal_dashcam') }}">
|
||||||
|
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="px-3 pb-2 mt-auto text-muted" style="font-size:.75rem;">
|
||||||
|
<i class="fas fa-user fa-fw"></i> {{ current_user.username }}
|
||||||
|
·
|
||||||
|
<a href="{{ url_for('logout') }}" class="text-muted">Logout</a>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-collapse-btn" id="sidebarToggle" type="button" aria-label="Collapse sidebar">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# ── Main content area ────────────────────────────────────────────────── #}
|
||||||
|
<div id="content-wrapper">
|
||||||
|
|
||||||
|
{# Top bar #}
|
||||||
|
<nav class="topbar navbar navbar-light bg-white shadow-sm">
|
||||||
|
<div class="container-fluid px-3 gap-2">
|
||||||
|
<a class="navbar-brand d-flex d-md-none align-items-center gap-2 text-decoration-none" href="{{ url_for('portal_index') }}">
|
||||||
|
<i class="fas fa-car text-primary"></i>
|
||||||
|
<span class="fw-bold text-dark">Yaris OBD</span>
|
||||||
|
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
|
||||||
|
</a>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2">
|
||||||
|
<span class="text-muted small d-none d-sm-inline">{{ current_user.username }}</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout">
|
||||||
|
<i class="fas fa-sign-out-alt fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container-fluid px-3 px-md-4 main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-white border-top text-center py-3 small text-muted d-none d-md-block">
|
||||||
|
© vroom vroom yaris de course 2023 — remote server
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Mobile bottom navigation ──────────────────────────────────────────── #}
|
||||||
|
<nav class="mobile-bottom-nav d-flex d-md-none" aria-label="Main navigation">
|
||||||
|
<a class="mobile-nav-item{% if request.endpoint == 'portal_index' %} active{% endif %}" href="{{ url_for('portal_index') }}">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<span>Overview</span>
|
||||||
|
</a>
|
||||||
|
<a class="mobile-nav-item{% if request.endpoint == 'portal_history' %} active{% endif %}" href="{{ url_for('portal_history') }}">
|
||||||
|
<i class="fas fa-route"></i>
|
||||||
|
<span>History</span>
|
||||||
|
</a>
|
||||||
|
<a class="mobile-nav-item{% if request.endpoint == 'portal_dashcam' %} active{% endif %}" href="{{ url_for('portal_dashcam') }}">
|
||||||
|
<i class="fas fa-video"></i>
|
||||||
|
<span>Dashcam</span>
|
||||||
|
</a>
|
||||||
|
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>Logout</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% macro data_table(id, columns, empty_title, empty_message) %}
|
||||||
|
<div class="data-list" id="{{ id }}" data-data-list>
|
||||||
|
<div class="table-responsive data-list-table" data-data-list-table hidden>
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}
|
||||||
|
<th scope="col"{% if column.class %} class="{{ column.class }}"{% endif %}{% if column.sortable %} aria-sort="none"{% endif %}>
|
||||||
|
{% if column.sortable %}
|
||||||
|
<button class="data-list-sort" type="button" data-sort-key="{{ column.key }}">
|
||||||
|
{{ column.label }}
|
||||||
|
<i class="fas fa-sort" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
{{ column.label }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{# Each injected row must define data-<column.key> for sortable columns. #}
|
||||||
|
<tbody data-data-list-body></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="data-list-empty text-center" data-data-list-empty>
|
||||||
|
<i class="fas fa-inbox fa-2x text-gray-300 mb-3" aria-hidden="true"></i>
|
||||||
|
<h6 class="text-dark mb-1">{{ empty_title }}</h6>
|
||||||
|
<p class="text-muted mb-0">{{ empty_message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashcam archive – Yaris OBD Remote{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3 class="text-dark mb-3 fw-bold">Dashcam archive</h3>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{# ── File list ────────────────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Uploaded clips</h6>
|
||||||
|
<span id="file-count" class="badge bg-secondary small">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="files-loading" class="text-center text-muted py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<div id="files-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 small" id="files-table" hidden>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th class="text-nowrap">Size</th>
|
||||||
|
<th class="text-nowrap d-none d-sm-table-cell">Uploaded</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="files-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="files-empty" class="text-muted text-center py-4 small" hidden>
|
||||||
|
<i class="fas fa-inbox fa-2x mb-2 d-block text-muted"></i>
|
||||||
|
No dashcam files uploaded yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Video player ─────────────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Preview</h6>
|
||||||
|
<a id="btn-download" class="btn btn-outline-secondary btn-sm" href="#" download hidden>
|
||||||
|
<i class="fas fa-download fa-fw me-1"></i> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="player-placeholder" class="text-muted text-center py-5 small">
|
||||||
|
<i class="fas fa-film fa-2x mb-2 d-block"></i>
|
||||||
|
Select a clip to preview
|
||||||
|
</div>
|
||||||
|
<video id="video-player" class="w-100" controls preload="metadata" hidden
|
||||||
|
style="background:#000;max-height:420px;"></video>
|
||||||
|
<div id="player-meta" class="px-3 py-2 border-top small text-muted" hidden>
|
||||||
|
<span id="player-filename"></span>
|
||||||
|
·
|
||||||
|
<span id="player-size"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function fmtSize(b) {
|
||||||
|
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
|
||||||
|
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
|
||||||
|
return (b / 1e3).toFixed(0) + ' KB';
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
return new Date(iso).toLocaleString('fr-CA', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentFileId = null;
|
||||||
|
|
||||||
|
function playFile(id, path, sizeBytes) {
|
||||||
|
const player = document.getElementById('video-player');
|
||||||
|
const placeholder = document.getElementById('player-placeholder');
|
||||||
|
const meta = document.getElementById('player-meta');
|
||||||
|
const btnDownload = document.getElementById('btn-download');
|
||||||
|
const name = path.split('/').pop();
|
||||||
|
|
||||||
|
currentFileId = id;
|
||||||
|
const streamUrl = `/api/v1/portal/dashcam/stream/${id}`;
|
||||||
|
player.src = streamUrl;
|
||||||
|
player.hidden = false;
|
||||||
|
placeholder.hidden = true;
|
||||||
|
meta.hidden = false;
|
||||||
|
document.getElementById('player-filename').textContent = name;
|
||||||
|
document.getElementById('player-size').textContent = fmtSize(sizeBytes);
|
||||||
|
btnDownload.href = streamUrl;
|
||||||
|
btnDownload.download = name;
|
||||||
|
btnDownload.hidden = false;
|
||||||
|
player.play().catch(() => {});
|
||||||
|
|
||||||
|
// Highlight selected row
|
||||||
|
document.querySelectorAll('#files-body tr').forEach(r => r.classList.remove('table-active'));
|
||||||
|
const row = document.querySelector(`#files-body tr[data-file-id="${id}"]`);
|
||||||
|
if (row) row.classList.add('table-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const loading = document.getElementById('files-loading');
|
||||||
|
const table = document.getElementById('files-table');
|
||||||
|
const empty = document.getElementById('files-empty');
|
||||||
|
const errEl = document.getElementById('files-error');
|
||||||
|
const countEl = document.getElementById('file-count');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/portal/dashcam');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const files = await res.json();
|
||||||
|
loading.hidden = true;
|
||||||
|
countEl.textContent = files.length;
|
||||||
|
if (!files.length) { empty.hidden = false; return; }
|
||||||
|
const tbody = document.getElementById('files-body');
|
||||||
|
tbody.innerHTML = files.map(f => {
|
||||||
|
const name = f.relative_path.split('/').pop();
|
||||||
|
const isVid = /\.(mp4|mov|avi|mkv|ts|h264)$/i.test(name);
|
||||||
|
return `
|
||||||
|
<tr data-file-id="${f.id}" onclick="playFile(${f.id}, '${f.relative_path.replace(/'/g,"\\'")}', ${f.size_bytes})"
|
||||||
|
style="cursor:pointer;">
|
||||||
|
<td class="text-truncate" style="max-width:200px;" title="${f.relative_path}">${name}</td>
|
||||||
|
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
|
||||||
|
<td class="text-nowrap d-none d-sm-table-cell">${fmtDate(f.uploaded_at)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="/api/v1/portal/dashcam/stream/${f.id}" download="${name}"
|
||||||
|
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
<i class="fas fa-download fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
table.hidden = false;
|
||||||
|
} catch (e) {
|
||||||
|
loading.hidden = true;
|
||||||
|
errEl.textContent = 'Failed to load files: ' + e.message;
|
||||||
|
errEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFiles();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "components.html" import data_table %}
|
||||||
|
|
||||||
|
{% block title %}Trip history – Yaris OBD Remote{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3 class="text-dark mb-3 fw-bold">Trip history</h3>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{# ── Trip list ──────────────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Recorded trips</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="trip-loading" class="text-center text-muted py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<div id="trip-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||||||
|
{{ data_table(
|
||||||
|
"trip-list",
|
||||||
|
[
|
||||||
|
{"key": "started", "label": "Started", "sortable": true},
|
||||||
|
{"key": "distance", "label": "km", "sortable": true},
|
||||||
|
{"key": "duration", "label": "Time", "sortable": true},
|
||||||
|
{"key": "maxSpeed", "label": "Max", "sortable": true},
|
||||||
|
{"key": "odometer", "label": "Odometer", "sortable": false},
|
||||||
|
{"key": "action", "label": "", "sortable": false, "class": "d-none d-sm-table-cell"}
|
||||||
|
],
|
||||||
|
"No trips detected",
|
||||||
|
"Trips appear once the Pi has uploaded tracking data."
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Route replay map ───────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Route replay</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="btn-recenter-history" class="btn btn-outline-secondary btn-sm" type="button" title="Recenter map" hidden>
|
||||||
|
<i class="fas fa-crosshairs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<span id="trip-summary" class="small text-muted">Select a trip</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="history-map" aria-label="Selected trip route map"></div>
|
||||||
|
<div id="trip-timeline" class="border-top p-3" hidden>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<button id="trip-play" class="btn btn-primary btn-sm" type="button" aria-label="Play">
|
||||||
|
<i class="fas fa-play" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<input id="trip-scrubber" class="form-range flex-grow-1" type="range"
|
||||||
|
min="0" max="0" value="0" aria-label="Trip replay timeline">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-between gap-2 small mt-2">
|
||||||
|
<strong id="trip-replay-time"></strong>
|
||||||
|
<span id="trip-replay-speed" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="trip-replay-location" class="text-muted mt-1" style="font-size:0.75rem;min-height:1rem"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/data-list.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/history.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Overview – Yaris OBD Remote{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3 class="text-dark mb-3 fw-bold">Overview</h3>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-route fa-fw"></i> Trips</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-trips">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-road fa-fw"></i> Distance</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-distance">—</div>
|
||||||
|
<div class="text-muted" style="font-size:.7rem;">km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-clock fa-fw"></i> Drive time</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-drivetime">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-tachometer-alt fa-fw"></i> Top speed</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-topspeed">—</div>
|
||||||
|
<div class="text-muted" style="font-size:.7rem;">km/h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-video fa-fw"></i> Videos</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-videos">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="card shadow-sm h-100 text-center">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="fas fa-gauge-high fa-fw"></i> Odometer</div>
|
||||||
|
<div class="fw-bold fs-4" id="stat-odometer">—</div>
|
||||||
|
<div class="text-muted" style="font-size:.7rem;">km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Recent trips</h6>
|
||||||
|
<a href="{{ url_for('portal_history') }}" class="btn btn-outline-primary btn-sm">View all</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="recent-trips-loading" class="text-center text-muted py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span> Loading…
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small" id="recent-trips-table" hidden>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Max km/h</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-trips-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="recent-trips-empty" class="text-muted text-center py-4 small" hidden>No trips recorded yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary fw-bold m-0">Recent dashcam clips</h6>
|
||||||
|
<a href="{{ url_for('portal_dashcam') }}" class="btn btn-outline-primary btn-sm">View all</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="recent-videos-loading" class="text-center text-muted py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span> Loading…
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small" id="recent-videos-table" hidden>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-videos-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="recent-videos-empty" class="text-muted text-center py-4 small" hidden>No dashcam files uploaded yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function fmtDuration(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
function fmtSize(b) {
|
||||||
|
if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
|
||||||
|
if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
|
||||||
|
return (b/1e3).toFixed(0) + ' KB';
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
return new Date(iso).toLocaleString('fr-CA', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const res = await fetch('/api/v1/portal/stats');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const d = await res.json();
|
||||||
|
document.getElementById('stat-trips').textContent = d.trip_count;
|
||||||
|
document.getElementById('stat-distance').textContent = d.total_distance_km.toFixed(0);
|
||||||
|
document.getElementById('stat-drivetime').textContent = fmtDuration(d.total_drive_time_s);
|
||||||
|
document.getElementById('stat-topspeed').textContent = d.top_speed_kmh.toFixed(0);
|
||||||
|
document.getElementById('stat-videos').textContent = d.video_count;
|
||||||
|
document.getElementById('stat-odometer').textContent = d.odometer_km.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentTrips() {
|
||||||
|
const loading = document.getElementById('recent-trips-loading');
|
||||||
|
const table = document.getElementById('recent-trips-table');
|
||||||
|
const empty = document.getElementById('recent-trips-empty');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/portal/trips?limit=8');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const trips = await res.json();
|
||||||
|
loading.hidden = true;
|
||||||
|
if (!trips.length) { empty.hidden = false; return; }
|
||||||
|
const tbody = document.getElementById('recent-trips-body');
|
||||||
|
tbody.innerHTML = trips.map(t => `
|
||||||
|
<tr onclick="location.href='/history?trip=${t.id}'" style="cursor:pointer;">
|
||||||
|
<td>${fmtDate(t.start_datetime)}</td>
|
||||||
|
<td>${t.distance_km.toFixed(1)} km</td>
|
||||||
|
<td>${fmtDuration(t.duration_seconds)}</td>
|
||||||
|
<td>${t.max_speed_kmh ? t.max_speed_kmh.toFixed(0) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
table.hidden = false;
|
||||||
|
} catch { loading.hidden = true; empty.hidden = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentVideos() {
|
||||||
|
const loading = document.getElementById('recent-videos-loading');
|
||||||
|
const table = document.getElementById('recent-videos-table');
|
||||||
|
const empty = document.getElementById('recent-videos-empty');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/portal/dashcam?limit=8');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const files = await res.json();
|
||||||
|
loading.hidden = true;
|
||||||
|
if (!files.length) { empty.hidden = false; return; }
|
||||||
|
const tbody = document.getElementById('recent-videos-body');
|
||||||
|
tbody.innerHTML = files.map(f => {
|
||||||
|
const name = f.relative_path.split('/').pop();
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="text-truncate" style="max-width:160px;" title="${f.relative_path}">${name}</td>
|
||||||
|
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
|
||||||
|
<td class="text-nowrap">${fmtDate(f.uploaded_at)}</td>
|
||||||
|
<td><a href="/api/v1/portal/dashcam/stream/${f.id}" class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download">
|
||||||
|
<i class="fas fa-download fa-fw"></i></a></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
table.hidden = false;
|
||||||
|
} catch { loading.hidden = true; empty.hidden = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
loadRecentTrips();
|
||||||
|
loadRecentVideos();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html data-bs-theme="light" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
|
<title>Login – Yaris OBD Remote</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||||
|
<div class="card shadow-sm p-4" style="width:100%;max-width:380px;">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="fas fa-car fa-2x text-primary mb-2"></i>
|
||||||
|
<h5 class="fw-bold mb-0">Yaris OBD</h5>
|
||||||
|
<div class="badge bg-warning text-dark mt-1">Remote server</div>
|
||||||
|
</div>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} py-2 small" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="post" action="{{ url_for('login_page') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label small fw-semibold">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username"
|
||||||
|
autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label small fw-semibold">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="fas fa-sign-in-alt me-1"></i> Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user