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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Browser session authentication using Flask-Login + bcrypt.
|
||||
|
||||
A single admin user is created via scripts/create_user.py.
|
||||
Device upload auth (X-Api-Key) is separate and unchanged.
|
||||
"""
|
||||
import bcrypt
|
||||
from functools import wraps
|
||||
|
||||
from flask import redirect, url_for
|
||||
from flask_login import LoginManager, UserMixin, current_user
|
||||
|
||||
from modules import db
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "login_page"
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
def __init__(self, id, username):
|
||||
self.id = id
|
||||
self.username = username
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
row = db.get_user_by_id(int(user_id))
|
||||
if row is None:
|
||||
return None
|
||||
return User(row["id"], row["username"])
|
||||
|
||||
|
||||
def check_password(plain, hashed):
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def hash_password(plain):
|
||||
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def require_login(view):
|
||||
"""Decorator: redirect to login page if not authenticated."""
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login_page"))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
@@ -1,4 +1,7 @@
|
||||
Flask
|
||||
Flask-Login
|
||||
gunicorn
|
||||
psycopg[binary]
|
||||
psycopg-pool
|
||||
bcrypt
|
||||
reverse_geocoder
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update a browser portal user (admin).
|
||||
|
||||
Usage:
|
||||
python scripts/create_user.py <username> <password>
|
||||
|
||||
Run inside the running container or with POSTGRES_* env vars set:
|
||||
docker compose exec server python scripts/create_user.py admin yourpassword
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Allow importing modules from the app root when run directly.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modules import db
|
||||
from modules.user_auth import hash_password
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: create_user.py <username> <password>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
username, password = sys.argv[1], sys.argv[2]
|
||||
if len(password) < 8:
|
||||
print("Password must be at least 8 characters.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Try to create; if user already exists, update password.
|
||||
uid = db.create_user(username, hashed)
|
||||
if uid is None:
|
||||
# User exists – update password
|
||||
with db.get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s WHERE username = %s",
|
||||
(hashed, username),
|
||||
)
|
||||
print(f"Updated password for user '{username}'.")
|
||||
else:
|
||||
print(f"Created user '{username}' (id={uid}).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recompute server-side trip boundaries from stored tracking_points.
|
||||
|
||||
Run after migrating to Phase 24 schema, or any time you want to refresh
|
||||
trips from raw data:
|
||||
docker compose exec server python scripts/recompute_trips.py
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modules import db
|
||||
|
||||
|
||||
def main():
|
||||
with db.get_cursor() as cur:
|
||||
cur.execute("SELECT id, name FROM devices ORDER BY id")
|
||||
devices = cur.fetchall()
|
||||
|
||||
if not devices:
|
||||
print("No devices found in database.")
|
||||
return
|
||||
|
||||
for device_id, device_name in devices:
|
||||
print(f"Recomputing trips for device '{device_name}' (id={device_id})…", end=" ", flush=True)
|
||||
n = db.compute_trips_for_device(device_id)
|
||||
print(f"{n} trips computed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -42,3 +42,33 @@ CREATE TABLE IF NOT EXISTS dashcam_files (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dashcam_files_device
|
||||
ON dashcam_files (device_id, uploaded_at DESC);
|
||||
|
||||
-- ── Phase 24: browser auth + server-side trips ─────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- bcrypt hash
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
distance_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
duration_s INTEGER NOT NULL DEFAULT 0,
|
||||
avg_speed_kmh DOUBLE PRECISION,
|
||||
max_speed_kmh DOUBLE PRECISION,
|
||||
start_lat DOUBLE PRECISION,
|
||||
start_lon DOUBLE PRECISION,
|
||||
end_lat DOUBLE PRECISION,
|
||||
end_lon DOUBLE PRECISION,
|
||||
point_count INTEGER NOT NULL DEFAULT 0,
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (device_id, start_time)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_device_start
|
||||
ON trips (device_id, start_time DESC);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Phase 24 migration: add users table (browser auth) and server-side trips.
|
||||
-- Run manually against the live Postgres DB:
|
||||
-- psql $DSN -f sql/migrate_phase24.sql
|
||||
|
||||
-- ── Browser session users ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- bcrypt hash
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ── Server-side trip index ─────────────────────────────────────────────────
|
||||
-- Computed from tracking_points by compute_trips_for_device().
|
||||
-- Idempotent: re-running recomputes and upserts without duplicating rows.
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
distance_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
duration_s INTEGER NOT NULL DEFAULT 0,
|
||||
avg_speed_kmh DOUBLE PRECISION,
|
||||
max_speed_kmh DOUBLE PRECISION,
|
||||
start_lat DOUBLE PRECISION,
|
||||
start_lon DOUBLE PRECISION,
|
||||
end_lat DOUBLE PRECISION,
|
||||
end_lon DOUBLE PRECISION,
|
||||
point_count INTEGER NOT NULL DEFAULT 0,
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (device_id, start_time)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_device_start
|
||||
ON trips (device_id, start_time DESC);
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="light" lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>{% block title %}Yaris OBD – Remote{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||
{% block styles %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body id="page-top">
|
||||
|
||||
{# ── Desktop sidebar ─────────────────────────────────────────────────── #}
|
||||
<nav id="app-sidebar" class="app-sidebar d-none d-md-flex flex-column">
|
||||
<a class="sidebar-brand" href="{{ url_for('portal_index') }}" aria-label="Home">
|
||||
<i class="fas fa-car sidebar-brand-icon"></i>
|
||||
<span class="sidebar-brand-text">Yaris OBD</span>
|
||||
</a>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="badge bg-warning text-dark w-100 text-center" style="font-size:.65rem;letter-spacing:.04em;">
|
||||
<i class="fas fa-cloud fa-fw"></i> Remote server
|
||||
</span>
|
||||
</div>
|
||||
<hr class="sidebar-divider">
|
||||
<ul class="nav flex-column flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if request.endpoint == 'portal_index' %} active{% endif %}" href="{{ url_for('portal_index') }}">
|
||||
<i class="fas fa-chart-line fa-fw"></i><span>Overview</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if request.endpoint == 'portal_history' %} active{% endif %}" href="{{ url_for('portal_history') }}">
|
||||
<i class="fas fa-route fa-fw"></i><span>Trip history</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if request.endpoint == 'portal_dashcam' %} active{% endif %}" href="{{ url_for('portal_dashcam') }}">
|
||||
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="px-3 pb-2 mt-auto text-muted" style="font-size:.75rem;">
|
||||
<i class="fas fa-user fa-fw"></i> {{ current_user.username }}
|
||||
·
|
||||
<a href="{{ url_for('logout') }}" class="text-muted">Logout</a>
|
||||
</div>
|
||||
<button class="sidebar-collapse-btn" id="sidebarToggle" type="button" aria-label="Collapse sidebar">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{# ── Main content area ────────────────────────────────────────────────── #}
|
||||
<div id="content-wrapper">
|
||||
|
||||
{# Top bar #}
|
||||
<nav class="topbar navbar navbar-light bg-white shadow-sm">
|
||||
<div class="container-fluid px-3 gap-2">
|
||||
<a class="navbar-brand d-flex d-md-none align-items-center gap-2 text-decoration-none" href="{{ url_for('portal_index') }}">
|
||||
<i class="fas fa-car text-primary"></i>
|
||||
<span class="fw-bold text-dark">Yaris OBD</span>
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="text-muted small d-none d-sm-inline">{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout">
|
||||
<i class="fas fa-sign-out-alt fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container-fluid px-3 px-md-4 main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-white border-top text-center py-3 small text-muted d-none d-md-block">
|
||||
© vroom vroom yaris de course 2023 — remote server
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{# ── Mobile bottom navigation ──────────────────────────────────────────── #}
|
||||
<nav class="mobile-bottom-nav d-flex d-md-none" aria-label="Main navigation">
|
||||
<a class="mobile-nav-item{% if request.endpoint == 'portal_index' %} active{% endif %}" href="{{ url_for('portal_index') }}">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="mobile-nav-item{% if request.endpoint == 'portal_history' %} active{% endif %}" href="{{ url_for('portal_history') }}">
|
||||
<i class="fas fa-route"></i>
|
||||
<span>History</span>
|
||||
</a>
|
||||
<a class="mobile-nav-item{% if request.endpoint == 'portal_dashcam' %} active{% endif %}" href="{{ url_for('portal_dashcam') }}">
|
||||
<i class="fas fa-video"></i>
|
||||
<span>Dashcam</span>
|
||||
</a>
|
||||
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% macro data_table(id, columns, empty_title, empty_message) %}
|
||||
<div class="data-list" id="{{ id }}" data-data-list>
|
||||
<div class="table-responsive data-list-table" data-data-list-table hidden>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in columns %}
|
||||
<th scope="col"{% if column.class %} class="{{ column.class }}"{% endif %}{% if column.sortable %} aria-sort="none"{% endif %}>
|
||||
{% if column.sortable %}
|
||||
<button class="data-list-sort" type="button" data-sort-key="{{ column.key }}">
|
||||
{{ column.label }}
|
||||
<i class="fas fa-sort" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{{ column.label }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
{# Each injected row must define data-<column.key> for sortable columns. #}
|
||||
<tbody data-data-list-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-list-empty text-center" data-data-list-empty>
|
||||
<i class="fas fa-inbox fa-2x text-gray-300 mb-3" aria-hidden="true"></i>
|
||||
<h6 class="text-dark mb-1">{{ empty_title }}</h6>
|
||||
<p class="text-muted mb-0">{{ empty_message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashcam archive – Yaris OBD Remote{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3 class="text-dark mb-3 fw-bold">Dashcam archive</h3>
|
||||
|
||||
<div class="row g-3">
|
||||
{# ── File list ────────────────────────────────────────────────────── #}
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary fw-bold m-0">Uploaded clips</h6>
|
||||
<span id="file-count" class="badge bg-secondary small">…</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="files-loading" class="text-center text-muted py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||
Loading…
|
||||
</div>
|
||||
<div id="files-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 small" id="files-table" hidden>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th class="text-nowrap">Size</th>
|
||||
<th class="text-nowrap d-none d-sm-table-cell">Uploaded</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="files-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="files-empty" class="text-muted text-center py-4 small" hidden>
|
||||
<i class="fas fa-inbox fa-2x mb-2 d-block text-muted"></i>
|
||||
No dashcam files uploaded yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Video player ─────────────────────────────────────────────────── #}
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary fw-bold m-0">Preview</h6>
|
||||
<a id="btn-download" class="btn btn-outline-secondary btn-sm" href="#" download hidden>
|
||||
<i class="fas fa-download fa-fw me-1"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="player-placeholder" class="text-muted text-center py-5 small">
|
||||
<i class="fas fa-film fa-2x mb-2 d-block"></i>
|
||||
Select a clip to preview
|
||||
</div>
|
||||
<video id="video-player" class="w-100" controls preload="metadata" hidden
|
||||
style="background:#000;max-height:420px;"></video>
|
||||
<div id="player-meta" class="px-3 py-2 border-top small text-muted" hidden>
|
||||
<span id="player-filename"></span>
|
||||
·
|
||||
<span id="player-size"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function fmtSize(b) {
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
|
||||
return (b / 1e3).toFixed(0) + ' KB';
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
return new Date(iso).toLocaleString('fr-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
let currentFileId = null;
|
||||
|
||||
function playFile(id, path, sizeBytes) {
|
||||
const player = document.getElementById('video-player');
|
||||
const placeholder = document.getElementById('player-placeholder');
|
||||
const meta = document.getElementById('player-meta');
|
||||
const btnDownload = document.getElementById('btn-download');
|
||||
const name = path.split('/').pop();
|
||||
|
||||
currentFileId = id;
|
||||
const streamUrl = `/api/v1/portal/dashcam/stream/${id}`;
|
||||
player.src = streamUrl;
|
||||
player.hidden = false;
|
||||
placeholder.hidden = true;
|
||||
meta.hidden = false;
|
||||
document.getElementById('player-filename').textContent = name;
|
||||
document.getElementById('player-size').textContent = fmtSize(sizeBytes);
|
||||
btnDownload.href = streamUrl;
|
||||
btnDownload.download = name;
|
||||
btnDownload.hidden = false;
|
||||
player.play().catch(() => {});
|
||||
|
||||
// Highlight selected row
|
||||
document.querySelectorAll('#files-body tr').forEach(r => r.classList.remove('table-active'));
|
||||
const row = document.querySelector(`#files-body tr[data-file-id="${id}"]`);
|
||||
if (row) row.classList.add('table-active');
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
const loading = document.getElementById('files-loading');
|
||||
const table = document.getElementById('files-table');
|
||||
const empty = document.getElementById('files-empty');
|
||||
const errEl = document.getElementById('files-error');
|
||||
const countEl = document.getElementById('file-count');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/portal/dashcam');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const files = await res.json();
|
||||
loading.hidden = true;
|
||||
countEl.textContent = files.length;
|
||||
if (!files.length) { empty.hidden = false; return; }
|
||||
const tbody = document.getElementById('files-body');
|
||||
tbody.innerHTML = files.map(f => {
|
||||
const name = f.relative_path.split('/').pop();
|
||||
const isVid = /\.(mp4|mov|avi|mkv|ts|h264)$/i.test(name);
|
||||
return `
|
||||
<tr data-file-id="${f.id}" onclick="playFile(${f.id}, '${f.relative_path.replace(/'/g,"\\'")}', ${f.size_bytes})"
|
||||
style="cursor:pointer;">
|
||||
<td class="text-truncate" style="max-width:200px;" title="${f.relative_path}">${name}</td>
|
||||
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
|
||||
<td class="text-nowrap d-none d-sm-table-cell">${fmtDate(f.uploaded_at)}</td>
|
||||
<td class="text-end">
|
||||
<a href="/api/v1/portal/dashcam/stream/${f.id}" download="${name}"
|
||||
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
|
||||
onclick="event.stopPropagation()">
|
||||
<i class="fas fa-download fa-fw"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
table.hidden = false;
|
||||
} catch (e) {
|
||||
loading.hidden = true;
|
||||
errEl.textContent = 'Failed to load files: ' + e.message;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadFiles();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components.html" import data_table %}
|
||||
|
||||
{% block title %}Trip history – Yaris OBD Remote{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3 class="text-dark mb-3 fw-bold">Trip history</h3>
|
||||
|
||||
<div class="row g-3">
|
||||
{# ── Trip list ──────────────────────────────────────────────────── #}
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="text-primary fw-bold m-0">Recorded trips</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="trip-loading" class="text-center text-muted py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||||
Loading…
|
||||
</div>
|
||||
<div id="trip-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||||
{{ data_table(
|
||||
"trip-list",
|
||||
[
|
||||
{"key": "started", "label": "Started", "sortable": true},
|
||||
{"key": "distance", "label": "km", "sortable": true},
|
||||
{"key": "duration", "label": "Time", "sortable": true},
|
||||
{"key": "maxSpeed", "label": "Max", "sortable": true},
|
||||
{"key": "odometer", "label": "Odometer", "sortable": false},
|
||||
{"key": "action", "label": "", "sortable": false, "class": "d-none d-sm-table-cell"}
|
||||
],
|
||||
"No trips detected",
|
||||
"Trips appear once the Pi has uploaded tracking data."
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Route replay map ───────────────────────────────────────────── #}
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary fw-bold m-0">Route replay</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button id="btn-recenter-history" class="btn btn-outline-secondary btn-sm" type="button" title="Recenter map" hidden>
|
||||
<i class="fas fa-crosshairs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span id="trip-summary" class="small text-muted">Select a trip</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="history-map" aria-label="Selected trip route map"></div>
|
||||
<div id="trip-timeline" class="border-top p-3" hidden>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button id="trip-play" class="btn btn-primary btn-sm" type="button" aria-label="Play">
|
||||
<i class="fas fa-play" aria-hidden="true"></i>
|
||||
</button>
|
||||
<input id="trip-scrubber" class="form-range flex-grow-1" type="range"
|
||||
min="0" max="0" value="0" aria-label="Trip replay timeline">
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 small mt-2">
|
||||
<strong id="trip-replay-time"></strong>
|
||||
<span id="trip-replay-speed" class="text-muted"></span>
|
||||
</div>
|
||||
<div id="trip-replay-location" class="text-muted mt-1" style="font-size:0.75rem;min-height:1rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/data-list.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/history.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,202 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Overview – Yaris OBD Remote{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3 class="text-dark mb-3 fw-bold">Overview</h3>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-route fa-fw"></i> Trips</div>
|
||||
<div class="fw-bold fs-4" id="stat-trips">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-road fa-fw"></i> Distance</div>
|
||||
<div class="fw-bold fs-4" id="stat-distance">—</div>
|
||||
<div class="text-muted" style="font-size:.7rem;">km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-clock fa-fw"></i> Drive time</div>
|
||||
<div class="fw-bold fs-4" id="stat-drivetime">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-tachometer-alt fa-fw"></i> Top speed</div>
|
||||
<div class="fw-bold fs-4" id="stat-topspeed">—</div>
|
||||
<div class="text-muted" style="font-size:.7rem;">km/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-video fa-fw"></i> Videos</div>
|
||||
<div class="fw-bold fs-4" id="stat-videos">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2">
|
||||
<div class="card shadow-sm h-100 text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small mb-1"><i class="fas fa-gauge-high fa-fw"></i> Odometer</div>
|
||||
<div class="fw-bold fs-4" id="stat-odometer">—</div>
|
||||
<div class="text-muted" style="font-size:.7rem;">km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary fw-bold m-0">Recent trips</h6>
|
||||
<a href="{{ url_for('portal_history') }}" class="btn btn-outline-primary btn-sm">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="recent-trips-loading" class="text-center text-muted py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span> Loading…
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 small" id="recent-trips-table" hidden>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Distance</th>
|
||||
<th>Time</th>
|
||||
<th>Max km/h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-trips-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="recent-trips-empty" class="text-muted text-center py-4 small" hidden>No trips recorded yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary fw-bold m-0">Recent dashcam clips</h6>
|
||||
<a href="{{ url_for('portal_dashcam') }}" class="btn btn-outline-primary btn-sm">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="recent-videos-loading" class="text-center text-muted py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span> Loading…
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 small" id="recent-videos-table" hidden>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-videos-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="recent-videos-empty" class="text-muted text-center py-4 small" hidden>No dashcam files uploaded yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function fmtDuration(s) {
|
||||
if (!s) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
function fmtSize(b) {
|
||||
if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
|
||||
if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
|
||||
return (b/1e3).toFixed(0) + ' KB';
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
return new Date(iso).toLocaleString('fr-CA', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await fetch('/api/v1/portal/stats');
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
document.getElementById('stat-trips').textContent = d.trip_count;
|
||||
document.getElementById('stat-distance').textContent = d.total_distance_km.toFixed(0);
|
||||
document.getElementById('stat-drivetime').textContent = fmtDuration(d.total_drive_time_s);
|
||||
document.getElementById('stat-topspeed').textContent = d.top_speed_kmh.toFixed(0);
|
||||
document.getElementById('stat-videos').textContent = d.video_count;
|
||||
document.getElementById('stat-odometer').textContent = d.odometer_km.toFixed(0);
|
||||
}
|
||||
|
||||
async function loadRecentTrips() {
|
||||
const loading = document.getElementById('recent-trips-loading');
|
||||
const table = document.getElementById('recent-trips-table');
|
||||
const empty = document.getElementById('recent-trips-empty');
|
||||
try {
|
||||
const res = await fetch('/api/v1/portal/trips?limit=8');
|
||||
if (!res.ok) throw new Error();
|
||||
const trips = await res.json();
|
||||
loading.hidden = true;
|
||||
if (!trips.length) { empty.hidden = false; return; }
|
||||
const tbody = document.getElementById('recent-trips-body');
|
||||
tbody.innerHTML = trips.map(t => `
|
||||
<tr onclick="location.href='/history?trip=${t.id}'" style="cursor:pointer;">
|
||||
<td>${fmtDate(t.start_datetime)}</td>
|
||||
<td>${t.distance_km.toFixed(1)} km</td>
|
||||
<td>${fmtDuration(t.duration_seconds)}</td>
|
||||
<td>${t.max_speed_kmh ? t.max_speed_kmh.toFixed(0) : '—'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
table.hidden = false;
|
||||
} catch { loading.hidden = true; empty.hidden = false; }
|
||||
}
|
||||
|
||||
async function loadRecentVideos() {
|
||||
const loading = document.getElementById('recent-videos-loading');
|
||||
const table = document.getElementById('recent-videos-table');
|
||||
const empty = document.getElementById('recent-videos-empty');
|
||||
try {
|
||||
const res = await fetch('/api/v1/portal/dashcam?limit=8');
|
||||
if (!res.ok) throw new Error();
|
||||
const files = await res.json();
|
||||
loading.hidden = true;
|
||||
if (!files.length) { empty.hidden = false; return; }
|
||||
const tbody = document.getElementById('recent-videos-body');
|
||||
tbody.innerHTML = files.map(f => {
|
||||
const name = f.relative_path.split('/').pop();
|
||||
return `
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:160px;" title="${f.relative_path}">${name}</td>
|
||||
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
|
||||
<td class="text-nowrap">${fmtDate(f.uploaded_at)}</td>
|
||||
<td><a href="/api/v1/portal/dashcam/stream/${f.id}" class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download">
|
||||
<i class="fas fa-download fa-fw"></i></a></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
table.hidden = false;
|
||||
} catch { loading.hidden = true; empty.hidden = false; }
|
||||
}
|
||||
|
||||
loadStats();
|
||||
loadRecentTrips();
|
||||
loadRecentVideos();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="light" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>Login – Yaris OBD Remote</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||
</head>
|
||||
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||
<div class="card shadow-sm p-4" style="width:100%;max-width:380px;">
|
||||
<div class="text-center mb-4">
|
||||
<i class="fas fa-car fa-2x text-primary mb-2"></i>
|
||||
<h5 class="fw-bold mb-0">Yaris OBD</h5>
|
||||
<div class="badge bg-warning text-dark mt-1">Remote server</div>
|
||||
</div>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} py-2 small" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post" action="{{ url_for('login_page') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label small fw-semibold">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-semibold">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-sign-in-alt me-1"></i> Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user