Files
yaris-obd/server/templates/index.html
T
Mathieu MoranandCopilot c4dfcbaf25 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]>
2026-08-10 03:58:45 -04:00

203 lines
8.8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}