- 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]>
203 lines
8.8 KiB
HTML
203 lines
8.8 KiB
HTML
{% 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 %}
|