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:
Mathieu Moran
2026-08-10 03:58:45 -04:00
co-authored by Copilot
parent 51c6be3a94
commit c4dfcbaf25
18 changed files with 1453 additions and 59 deletions
+59 -53
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+6 -1
View File
@@ -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:
+265
View File
@@ -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]),
}
+47
View File
@@ -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
+3
View File
@@ -1,4 +1,7 @@
Flask Flask
Flask-Login
gunicorn gunicorn
psycopg[binary] psycopg[binary]
psycopg-pool psycopg-pool
bcrypt
reverse_geocoder
+47
View File
@@ -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()
+30
View File
@@ -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()
+30
View File
@@ -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);
+35
View File
@@ -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);
+110
View File
@@ -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 }}
&nbsp;·&nbsp;
<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">
&copy; vroom vroom yaris de course 2023 &mdash; 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>
+31
View File
@@ -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 %}
+156
View File
@@ -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>
&nbsp;·&nbsp;
<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 %}
+81
View File
@@ -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 %}
+202
View File
@@ -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 %}
+46
View File
@@ -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>