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
+8
View File
@@ -19,3 +19,11 @@ DATA_DIR=./data
# Reject dashcam uploads larger than this (MB). Bump if your dashcam
# produces very large individual clips.
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
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
COPY app.py .
COPY modules/ ./modules/
COPY scripts/ ./scripts/
# Copy server application code
COPY server/app.py .
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
+281 -1
View File
@@ -1,17 +1,63 @@
"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
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 os
import secrets
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.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__)
# 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
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
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.
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")
def health():
@@ -54,6 +110,14 @@ def upload_tracking_batch():
return jsonify(error="every point requires 'recorded_at'"), 400
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
@@ -254,6 +318,222 @@ def dashcam_upload_chunk():
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():
app.run(host="0.0.0.0", port=8098)
+6 -1
View File
@@ -10,6 +10,7 @@ services:
volumes:
- ${DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
interval: 10s
@@ -19,7 +20,9 @@ services:
# `api` container over the internal compose network.
api:
build: .
build:
context: ..
dockerfile: server/Dockerfile
container_name: yaris-obd-api
restart: unless-stopped
depends_on:
@@ -33,6 +36,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a POSTGRES_PASSWORD in .env}
DASHCAM_STORAGE_DIR: /data/dashcam
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:
- ${DATA_DIR:-./data}/dashcam:/data/dashcam
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),
)
# ── 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-Login
gunicorn
psycopg[binary]
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
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>