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
+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 %}