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:
@@ -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