Add Pi status page

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:37:26 -04:00
co-authored by Copilot
parent a7302229d1
commit 5406935bf5
9 changed files with 831 additions and 140 deletions
+4
View File
@@ -1763,6 +1763,10 @@ updates its display-facing values without a manual restart once sync lands.
cards no longer stretch to the speed graph's full height, shortened the speed graph on cards no longer stretch to the speed graph's full height, shortened the speed graph on
Pi-sized screens, and reduced excess map/footer whitespace that previously forced unnecessary Pi-sized screens, and reduced excess map/footer whitespace that previously forced unnecessary
scrolling. scrolling.
- **Next local UX follow-up 2026-08-10**: split the bulky Pi-health block out of the dashboard
into a dedicated local **Status** page and expand it into a broader runtime diagnostics view:
local `systemd` service states, server reachability/auth status, Wi-Fi uplink state, GPS fix
state, SIM868/cellular-HAT responsiveness, upload backlog, and OBD adapter presence.
--- ---
+9 -1
View File
@@ -1,5 +1,5 @@
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config, app_device_sync, app_remote_config from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config, app_device_sync, app_remote_config, app_status
import os, time import os, time
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@@ -44,6 +44,10 @@ def history():
def settings(): def settings():
return render_template('settings.html', account=account, car=car) return render_template('settings.html', account=account, car=car)
@app.route('/status')
def status_page():
return render_template('status.html', account=account, car=car)
@app.route('/api/settings', methods=['GET', 'POST']) @app.route('/api/settings', methods=['GET', 'POST'])
def api_settings(): def api_settings():
if request.method == 'GET': if request.method == 'GET':
@@ -298,6 +302,10 @@ def upload_status():
def health(): def health():
return jsonify(app_health.collect_health()) return jsonify(app_health.collect_health())
@app.route('/api/status')
def api_status():
return jsonify(app_status.collect_status())
@app.route('/api/alerts/status') @app.route('/api/alerts/status')
def alert_status(): def alert_status():
return jsonify(app_alerts.get_status()) return jsonify(app_alerts.get_status())
+187
View File
@@ -0,0 +1,187 @@
import os
import subprocess
import time
from urllib.parse import urlparse
import requests
from modules import (
app_cellular,
app_gps,
app_health,
app_network,
app_obd,
app_remote_config,
app_upload,
)
SERVICE_UNITS = (
("Web app", "yaris-obd.service"),
("NetworkManager", "NetworkManager.service"),
("Hotspot watcher", "yaris-netwatch.timer"),
("Phone uplink wait", "yaris-uplink-wait.service"),
)
def parse_systemctl_show(text):
values = {}
for line in text.splitlines():
key, separator, value = line.partition("=")
if separator:
values[key] = value
restarts = values.get("NRestarts", "")
return {
"load_state": values.get("LoadState", "unknown"),
"active_state": values.get("ActiveState", "unknown"),
"sub_state": values.get("SubState", "unknown"),
"unit_file_state": values.get("UnitFileState", "unknown"),
"result": values.get("Result", "unknown"),
"restarts": int(restarts) if restarts.isdigit() else None,
}
def _systemd_unit_status(label, unit):
try:
result = subprocess.run(
[
"systemctl",
"show",
unit,
"--property=LoadState,ActiveState,SubState,UnitFileState,Result,NRestarts",
],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError) as error:
return {
"label": label,
"unit": unit,
"ok": False,
"error": str(error),
}
status = parse_systemctl_show(result.stdout)
status.update(
{
"label": label,
"unit": unit,
"ok": True,
}
)
return status
def _server_status():
credentials = app_remote_config.server_credentials()
server_url = credentials["server_url"]
device_name = credentials["device_name"]
api_key = credentials["api_key"]
parsed = urlparse(server_url) if server_url else None
status = {
"configured": bool(server_url),
"server_url": server_url,
"host": parsed.netloc if parsed else "",
"device_name": device_name,
"api_key_configured": bool(api_key),
"reachable": False,
"authenticated": False,
"latency_ms": None,
"health_http_status": None,
"error": None,
}
if not server_url:
status["error"] = "Server URL is not configured."
return status
started = time.monotonic()
try:
health_response = requests.get(f"{server_url}/health", timeout=5)
except requests.RequestException as error:
status["error"] = f"Could not reach server: {error}"
return status
status["latency_ms"] = round((time.monotonic() - started) * 1000)
status["health_http_status"] = health_response.status_code
if health_response.ok:
status["reachable"] = True
else:
status["error"] = f"/health returned HTTP {health_response.status_code}."
return status
if not device_name or not api_key:
status["error"] = "Device credentials are not fully configured yet."
return status
try:
auth_response = requests.get(
f"{server_url}/api/device-config",
headers={
"X-Device-Name": device_name,
"X-Api-Key": api_key,
},
timeout=5,
)
except requests.RequestException as error:
status["error"] = f"Server reachable, but auth check failed: {error}"
return status
if auth_response.status_code == 200:
status["authenticated"] = True
return status
if auth_response.status_code == 401:
status["error"] = "Server reachable, but device credentials were rejected."
return status
status["error"] = (
f"Server reachable, but config auth returned HTTP {auth_response.status_code}."
)
return status
def _cellular_hat_status():
serial_port = app_gps.SERIAL_PORT
serial_port_present = os.path.exists(serial_port)
module_responding = False
if serial_port_present:
with app_gps._serial_lock:
module_responding = app_gps._module_responds()
return {
"serial_port": serial_port,
"serial_port_present": serial_port_present,
"module_responding": module_responding,
"configured": app_cellular.is_configured(),
"apn_configured": bool(app_cellular.get_apn()),
}
def _obd_status():
port = app_obd._find_obd_port()
connected = app_obd._is_car_connected()
return {
"adapter_present": bool(port),
"port": port,
"car_connected": connected,
"status_label": "Connected" if connected else "Disconnected",
}
def collect_status():
try:
uplink = {"ok": True, **app_network.get_status()}
except app_network.NetworkConfigurationError as error:
uplink = {"ok": False, "error": str(error)}
return {
"generated_at": time.time(),
"health": app_health.collect_health(),
"gps": app_gps.get_status(),
"uplink": uplink,
"server": _server_status(),
"upload": app_upload.get_status(),
"cellular_hat": _cellular_hat_status(),
"obd": _obd_status(),
"services": [
_systemd_unit_status(label, unit) for label, unit in SERVICE_UNITS
],
}
+298
View File
@@ -0,0 +1,298 @@
function setBadge(id, label, kind) {
const badge = document.getElementById(id);
if (!badge) return;
badge.className = `badge ${kind}`;
badge.textContent = label;
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) {
element.textContent = value;
}
}
function formatUptime(seconds) {
if (seconds == null) return 'Unavailable';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`;
}
function formatGpsAge(seconds) {
if (seconds == null) return 'No fix yet';
if (seconds < 10) return 'Just now';
if (seconds < 60) return `${Math.round(seconds)}s`;
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return secs ? `${mins}m ${secs}s` : `${mins}m`;
}
function healthWarningLabel(warning) {
return {
under_voltage: 'Power supply undervoltage',
frequency_capped: 'CPU frequency capped',
throttled: 'CPU currently throttled',
soft_temperature_limit: 'CPU temperature limit active',
high_temperature: 'CPU temperature is high',
disk_nearly_full: 'Disk is nearly full',
memory_pressure: 'Memory usage is high',
}[warning] || warning;
}
function historicalHealthWarningLabel(warning) {
return {
under_voltage: 'Power supply undervoltage',
frequency_capped: 'CPU frequency capping',
throttled: 'CPU throttling',
soft_temperature_limit: 'CPU temperature limiting',
}[warning] || warning;
}
function renderServer(server) {
setText('server-url', server.server_url || 'Not configured');
setText('server-device', server.device_name || 'Not configured');
setText(
'server-detail',
server.reachable
? `HTTP ${server.health_http_status} · ${server.latency_ms} ms`
: 'Unavailable'
);
setText('server-message', server.error || (server.authenticated
? 'Server reachable and device credentials are valid.'
: server.reachable
? 'Server reachable.'
: 'Server is not reachable.'));
if (!server.configured) {
setBadge('server-badge', 'Not configured', 'text-bg-secondary');
} else if (server.authenticated) {
setBadge('server-badge', 'Online', 'text-bg-success');
} else if (server.reachable) {
setBadge('server-badge', 'Reachable', 'text-bg-warning');
} else {
setBadge('server-badge', 'Offline', 'text-bg-danger');
}
}
function renderUplink(uplink) {
if (!uplink.ok) {
setBadge('uplink-badge', 'Unavailable', 'text-bg-danger');
setText('uplink-mode', 'Unavailable');
setText('uplink-interface', '—');
setText('uplink-connection', '—');
setText('uplink-ip', '—');
setText('uplink-message', uplink.error || 'Could not load uplink status.');
return;
}
const modeLabel = {
phone: 'Phone hotspot',
local_hotspot: 'Local hotspot',
wifi: 'Wi-Fi / Ethernet',
disconnected: 'Disconnected',
}[uplink.mode] || uplink.mode || 'Unknown';
if (uplink.internet_reachable) {
setBadge('uplink-badge', 'Internet up', 'text-bg-success');
} else if (uplink.mode === 'local_hotspot') {
setBadge('uplink-badge', 'Hotspot only', 'text-bg-warning');
} else {
setBadge('uplink-badge', 'Offline', 'text-bg-danger');
}
setText('uplink-mode', modeLabel);
setText('uplink-interface', uplink.interface || '—');
setText('uplink-connection', uplink.active_connection || 'None');
setText('uplink-ip', uplink.ip_address || '—');
const details = [];
if (uplink.phone_configured && uplink.phone_ssid) {
details.push(`Saved phone SSID: ${uplink.phone_ssid}`);
}
if (uplink.connectivity) {
details.push(`Connectivity: ${uplink.connectivity}`);
}
if (uplink.last_transition && uplink.last_transition.action) {
details.push(`Last action: ${uplink.last_transition.action}`);
}
setText('uplink-message', details.join(' · ') || 'No uplink details available.');
}
function renderUpload(upload) {
const configured = upload.configured ? 'Configured' : 'Not configured';
const dashcam = upload.dashcam_upload_enabled ? 'Enabled' : 'Paused';
setText('upload-pending', String(upload.pending_tracking_rows));
setText('upload-path', upload.last_network_path || '—');
setText('upload-configured', configured);
setText('upload-dashcam', dashcam);
setText(
'upload-message',
upload.last_error
? `Last error: ${upload.last_error}`
: upload.last_success_at
? `Last success: ${new Date(upload.last_success_at * 1000).toLocaleString()}`
: 'No successful upload yet in this session.'
);
if (upload.last_error) {
setBadge('upload-badge', 'Error', 'text-bg-danger');
} else if (upload.pending_tracking_rows > 0) {
setBadge('upload-badge', 'Queued', 'text-bg-warning');
} else {
setBadge('upload-badge', 'Idle', 'text-bg-success');
}
}
function renderHealth(health) {
setText(
'health-temperature',
health.temperature_c == null ? 'Unavailable' : `${health.temperature_c.toFixed(1)} °C`
);
setText(
'health-memory',
health.memory == null ? 'Unavailable' : `${health.memory.used_percent.toFixed(1)}%`
);
setText(
'health-disk',
health.disk == null
? 'Unavailable'
: `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free`
);
setText('health-uptime', formatUptime(health.uptime_seconds));
const warning = document.getElementById('health-warning');
const error = document.getElementById('health-error');
const currentWarnings = (health.warnings || []).map(healthWarningLabel);
const historicalWarnings = (health.historical_warnings || []).map(
historicalHealthWarningLabel
);
const combinedWarnings = currentWarnings.concat(
historicalWarnings.length
? [`Previously detected: ${historicalWarnings.join(', ')}`]
: []
);
warning.hidden = combinedWarnings.length === 0;
warning.textContent = combinedWarnings.join(' · ');
const unavailable = Object.keys(health.errors || {});
error.hidden = unavailable.length === 0;
error.textContent = unavailable.length
? `Unavailable health sources: ${unavailable.join(', ')}`
: '';
if (health.status === 'warning') {
setBadge('health-badge', 'Warning', 'text-bg-warning');
} else if (health.status === 'degraded') {
setBadge('health-badge', 'Degraded', 'text-bg-danger');
} else {
setBadge('health-badge', 'Healthy', 'text-bg-success');
}
}
function renderGpsAndCellular(gps, cellularHat) {
const liveFix = gps.thread_alive && gps.has_fix;
if (liveFix) {
setBadge('gps-badge', 'Live fix', 'text-bg-success');
} else if (gps.thread_alive) {
setBadge('gps-badge', 'No fix', 'text-bg-warning');
} else {
setBadge('gps-badge', 'Offline', 'text-bg-danger');
}
setText('gps-fix', liveFix ? 'Available' : gps.thread_alive ? 'Searching' : 'Offline');
setText('gps-age', formatGpsAge(gps.fix_age_seconds));
setText('gps-latitude', gps.latitude || '—');
setText('gps-longitude', gps.longitude || '—');
setText(
'cellular-uart',
cellularHat.serial_port_present ? cellularHat.serial_port : 'Missing'
);
setText(
'cellular-modem',
cellularHat.module_responding ? 'Responding' : 'Not responding'
);
const details = [];
details.push(cellularHat.configured ? 'Cellular fallback configured' : 'Cellular fallback disabled');
details.push(cellularHat.apn_configured ? 'APN set' : 'No APN');
setText('cellular-message', details.join(' · '));
}
function renderObd(obd) {
if (obd.car_connected) {
setBadge('obd-badge', 'Connected', 'text-bg-success');
} else if (obd.adapter_present) {
setBadge('obd-badge', 'Adapter only', 'text-bg-warning');
} else {
setBadge('obd-badge', 'Missing', 'text-bg-danger');
}
setText('obd-adapter', obd.adapter_present ? 'Detected' : 'Not detected');
setText('obd-port', obd.port || '—');
setText('obd-status', obd.status_label || 'Unknown');
}
function renderServices(services) {
const table = document.getElementById('services-table');
table.replaceChildren();
services.forEach((service) => {
const row = document.createElement('tr');
const statusLabel = service.ok
? `${service.active_state}`
: 'Unavailable';
const statusClass = !service.ok
? 'text-danger'
: service.active_state === 'active'
? 'text-success'
: service.active_state === 'inactive'
? 'text-muted'
: 'text-warning';
row.innerHTML = `
<td class="ps-3">
<div class="fw-semibold">${service.label}</div>
<div class="small text-muted">${service.unit}</div>
</td>
<td class="${statusClass} fw-semibold">${statusLabel}</td>
<td>${service.ok ? service.sub_state : '—'}</td>
<td>${service.ok ? service.unit_file_state : '—'}</td>
<td>${service.ok && service.restarts != null ? service.restarts : '—'}</td>
<td class="pe-3">${service.ok ? service.result : (service.error || '—')}</td>
`;
table.appendChild(row);
});
}
async function refreshStatus() {
const response = await fetch('/api/status');
const status = await response.json();
if (!response.ok) {
throw new Error(status.error || 'Could not load status page data.');
}
document.getElementById('status-error').hidden = true;
document.getElementById('status-updated').textContent =
`Updated ${new Date(status.generated_at * 1000).toLocaleTimeString()}`;
renderServer(status.server);
renderUplink(status.uplink);
renderUpload(status.upload);
renderHealth(status.health);
renderGpsAndCellular(status.gps, status.cellular_hat);
renderObd(status.obd);
renderServices(status.services);
}
function showStatusError(error) {
const banner = document.getElementById('status-error');
banner.textContent = error.message;
banner.hidden = false;
}
refreshStatus().catch(showStatusError);
window.setInterval(function () {
refreshStatus().catch(showStatusError);
}, 30000);
+1 -104
View File
@@ -221,9 +221,7 @@ function formatGpsAge(seconds) {
function renderGpsStatus(status) { function renderGpsStatus(status) {
const liveBadge = document.getElementById('gps-live-badge'); const liveBadge = document.getElementById('gps-live-badge');
const fixDetail = document.getElementById('gps-fix-detail'); const fixDetail = document.getElementById('gps-fix-detail');
const healthStatus = document.getElementById('pi-gps-status'); if (!liveBadge || !fixDetail) return;
const healthDetail = document.getElementById('pi-gps-detail');
if (!liveBadge || !fixDetail || !healthStatus || !healthDetail) return;
let label = 'Unavailable'; let label = 'Unavailable';
let badgeClass = 'text-bg-secondary'; let badgeClass = 'text-bg-secondary';
@@ -248,9 +246,6 @@ function renderGpsStatus(status) {
liveBadge.className = `badge ${badgeClass}`; liveBadge.className = `badge ${badgeClass}`;
liveBadge.textContent = label; liveBadge.textContent = label;
fixDetail.textContent = detail; fixDetail.textContent = detail;
healthStatus.textContent = label;
healthStatus.className = `h6 fw-bold mb-0 ${label === 'Live fix' ? 'text-success' : label === 'Offline' ? 'text-danger' : 'text-warning'}`;
healthDetail.textContent = detail;
} }
async function refreshGpsStatus() { async function refreshGpsStatus() {
@@ -295,112 +290,14 @@ socket.on('gps_location', function(data) {
} }
}); });
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`;
}
function setHealthValue(id, value) {
document.getElementById(id).textContent = value;
}
function healthWarningLabel(warning) {
return {
under_voltage: "Power supply undervoltage",
frequency_capped: "CPU frequency capped",
throttled: "CPU currently throttled",
soft_temperature_limit: "CPU temperature limit active",
high_temperature: "CPU temperature is high",
disk_nearly_full: "Disk is nearly full",
memory_pressure: "Memory usage is high",
}[warning] || warning;
}
function historicalHealthWarningLabel(warning) {
return {
under_voltage: "Power supply undervoltage",
frequency_capped: "CPU frequency capping",
throttled: "CPU throttling",
soft_temperature_limit: "CPU temperature limiting",
}[warning] || warning;
}
async function refreshHealth() {
const response = await fetch("/api/health");
const health = await response.json();
if (!response.ok) {
throw new Error(health.error || "Could not load Pi health.");
}
setHealthValue(
"pi-temperature",
health.temperature_c == null ? "Unavailable" : `${health.temperature_c.toFixed(1)} °C`
);
setHealthValue(
"pi-memory",
health.memory == null ? "Unavailable" : `${health.memory.used_percent.toFixed(1)}%`
);
setHealthValue(
"pi-disk",
health.disk == null
? "Unavailable"
: `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free`
);
setHealthValue(
"pi-uptime",
health.uptime_seconds == null ? "Unavailable" : formatUptime(health.uptime_seconds)
);
const warning = document.getElementById("pi-health-warning");
const currentWarnings = health.warnings.map(healthWarningLabel);
const historicalWarnings = health.historical_warnings.map(
historicalHealthWarningLabel
);
const warnings = currentWarnings.concat(
historicalWarnings.length
? [`Previously detected: ${historicalWarnings.join(", ")}`]
: []
);
warning.hidden = warnings.length === 0;
warning.textContent = warnings.join(" · ");
const error = document.getElementById("pi-health-error");
const unavailable = Object.keys(health.errors);
error.hidden = unavailable.length === 0;
error.textContent = unavailable.length
? `Unavailable health sources: ${unavailable.join(", ")}`
: "";
document.getElementById("pi-health-updated").textContent =
`Updated ${new Date().toLocaleTimeString()}`;
}
refreshHealth().catch(function (error) {
const message = document.getElementById("pi-health-error");
message.textContent = error.message;
message.hidden = false;
});
window.setInterval(function () {
refreshHealth().catch(function (error) {
const message = document.getElementById("pi-health-error");
message.textContent = error.message;
message.hidden = false;
});
}, 30000);
refreshGpsStatus().catch(function (error) { refreshGpsStatus().catch(function (error) {
renderGpsStatus({thread_alive: false, has_fix: false, fix_age_seconds: null}); renderGpsStatus({thread_alive: false, has_fix: false, fix_age_seconds: null});
const fixDetail = document.getElementById('gps-fix-detail'); const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message; if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
}); });
window.setInterval(function () { window.setInterval(function () {
refreshGpsStatus().catch(function (error) { refreshGpsStatus().catch(function (error) {
const fixDetail = document.getElementById('gps-fix-detail'); const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message; if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
}); });
}, 15000); }, 15000);
+9
View File
@@ -41,6 +41,11 @@
<i class="fas fa-exclamation-triangle fa-fw"></i><span>DTC</span> <i class="fas fa-exclamation-triangle fa-fw"></i><span>DTC</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link{% if request.endpoint == 'status_page' %} active{% endif %}" href="{{ url_for('status_page') }}">
<i class="fas fa-heartbeat fa-fw"></i><span>Status</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if request.endpoint == 'settings' %} active{% endif %}" href="{{ url_for('settings') }}"> <a class="nav-link{% if request.endpoint == 'settings' %} active{% endif %}" href="{{ url_for('settings') }}">
<i class="fas fa-cog fa-fw"></i><span>Settings</span> <i class="fas fa-cog fa-fw"></i><span>Settings</span>
@@ -111,6 +116,10 @@
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
<span>DTC</span> <span>DTC</span>
</a> </a>
<a class="mobile-nav-item{% if request.endpoint == 'status_page' %} active{% endif %}" href="{{ url_for('status_page') }}">
<i class="fas fa-heartbeat"></i>
<span>Status</span>
</a>
<a class="mobile-nav-item{% if request.endpoint == 'settings' %} active{% endif %}" href="{{ url_for('settings') }}"> <a class="mobile-nav-item{% if request.endpoint == 'settings' %} active{% endif %}" href="{{ url_for('settings') }}">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
<span>Settings</span> <span>Settings</span>
-35
View File
@@ -127,41 +127,6 @@
</div> </div>
</div> </div>
{# ── Pi health ───────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-3" id="pi-health-card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2 py-2">
<h6 class="text-primary fw-bold m-0">Pi health</h6>
<span id="pi-health-updated" class="small text-muted">Loading…</span>
</div>
<div class="card-body py-2">
<div id="pi-health-warning" class="alert alert-warning py-2 mb-2" role="alert" hidden></div>
<div id="pi-health-error" class="alert alert-danger py-2 mb-2" role="alert" hidden></div>
<div class="row g-2">
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">CPU temp</div>
<div id="pi-temperature" class="h6 text-dark fw-bold mb-0">--</div>
</div>
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Memory</div>
<div id="pi-memory" class="h6 text-dark fw-bold mb-0">--</div>
</div>
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Disk</div>
<div id="pi-disk" class="h6 text-dark fw-bold mb-0">--</div>
</div>
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Uptime</div>
<div id="pi-uptime" class="h6 text-dark fw-bold mb-0">--</div>
</div>
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">GPS</div>
<div id="pi-gps-status" class="h6 text-dark fw-bold mb-0">--</div>
<div id="pi-gps-detail" class="small text-muted">Waiting…</div>
</div>
</div>
</div>
</div>
{# ── GPS map + Stats + Speed history ──────────────────────────────────── #} {# ── GPS map + Stats + Speed history ──────────────────────────────────── #}
<div class="row g-3 mb-3 dashboard-primary-row"> <div class="row g-3 mb-3 dashboard-primary-row">
<div class="col-lg-4 col-xl-3"> <div class="col-lg-4 col-xl-3">
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Status - Yaris OBD{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h3 class="text-dark fw-bold m-0">Status</h3>
<span id="status-updated" class="small text-muted">Loading…</span>
</div>
<div id="status-error" class="alert alert-danger py-2" role="alert" hidden></div>
<div class="row g-3 mb-3">
<div class="col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Remote server</h6>
<span id="server-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div class="small text-muted text-uppercase fw-bold mb-1">Server URL</div>
<div id="server-url" class="fw-semibold text-break mb-3">—</div>
<div class="small text-muted text-uppercase fw-bold mb-1">Device</div>
<div id="server-device" class="fw-semibold mb-3">—</div>
<div class="small text-muted text-uppercase fw-bold mb-1">Health check</div>
<div id="server-detail" class="fw-semibold mb-2">—</div>
<div id="server-message" class="small text-muted">—</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Wi-Fi / uplink</h6>
<span id="uplink-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Mode</div>
<div id="uplink-mode" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Interface</div>
<div id="uplink-interface" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Connection</div>
<div id="uplink-connection" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">IP</div>
<div id="uplink-ip" class="fw-semibold">—</div>
</div>
<div class="col-12">
<div id="uplink-message" class="small text-muted">—</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Uploads</h6>
<span id="upload-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Pending rows</div>
<div id="upload-pending" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Last path</div>
<div id="upload-path" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Trip sync</div>
<div id="upload-configured" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Dashcam upload</div>
<div id="upload-dashcam" class="fw-semibold">—</div>
</div>
<div class="col-12">
<div id="upload-message" class="small text-muted">—</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-5">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Pi health</h6>
<span id="health-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div id="health-warning" class="alert alert-warning py-2 mb-2" role="alert" hidden></div>
<div id="health-error" class="alert alert-danger py-2 mb-2" role="alert" hidden></div>
<div class="row g-3">
<div class="col-6 col-lg-3">
<div class="small text-muted text-uppercase fw-bold mb-1">CPU temp</div>
<div id="health-temperature" class="fw-semibold">—</div>
</div>
<div class="col-6 col-lg-3">
<div class="small text-muted text-uppercase fw-bold mb-1">Memory</div>
<div id="health-memory" class="fw-semibold">—</div>
</div>
<div class="col-6 col-lg-3">
<div class="small text-muted text-uppercase fw-bold mb-1">Disk</div>
<div id="health-disk" class="fw-semibold">—</div>
</div>
<div class="col-6 col-lg-3">
<div class="small text-muted text-uppercase fw-bold mb-1">Uptime</div>
<div id="health-uptime" class="fw-semibold">—</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">GPS / cellular HAT</h6>
<span id="gps-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">GPS fix</div>
<div id="gps-fix" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Fix age</div>
<div id="gps-age" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Latitude</div>
<div id="gps-latitude" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Longitude</div>
<div id="gps-longitude" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">SIM868 UART</div>
<div id="cellular-uart" class="fw-semibold">—</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-bold mb-1">Modem</div>
<div id="cellular-modem" class="fw-semibold">—</div>
</div>
<div class="col-12">
<div id="cellular-message" class="small text-muted">—</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">OBD</h6>
<span id="obd-badge" class="badge text-bg-secondary">Checking…</span>
</div>
<div class="card-body">
<div class="small text-muted text-uppercase fw-bold mb-1">Adapter</div>
<div id="obd-adapter" class="fw-semibold mb-3">—</div>
<div class="small text-muted text-uppercase fw-bold mb-1">Port</div>
<div id="obd-port" class="fw-semibold mb-3">—</div>
<div class="small text-muted text-uppercase fw-bold mb-1">Car link</div>
<div id="obd-status" class="fw-semibold">—</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Local services</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Service</th>
<th>Status</th>
<th>Sub-state</th>
<th>Enabled</th>
<th>Restarts</th>
<th class="pe-3">Result</th>
</tr>
</thead>
<tbody id="services-table">
<tr>
<td colspan="6" class="text-muted ps-3 py-3">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/status.js') }}"></script>
{% endblock %}
+109
View File
@@ -0,0 +1,109 @@
import unittest
from unittest.mock import patch
from modules import app_status
class AppStatusTest(unittest.TestCase):
def test_parses_systemctl_show_output(self):
status = app_status.parse_systemctl_show(
"\n".join(
[
"LoadState=loaded",
"ActiveState=active",
"SubState=running",
"UnitFileState=enabled",
"Result=success",
"NRestarts=3",
]
)
)
self.assertEqual(status["load_state"], "loaded")
self.assertEqual(status["active_state"], "active")
self.assertEqual(status["sub_state"], "running")
self.assertEqual(status["unit_file_state"], "enabled")
self.assertEqual(status["result"], "success")
self.assertEqual(status["restarts"], 3)
@patch("modules.app_status._systemd_unit_status", return_value={"ok": True})
@patch("modules.app_status._obd_status", return_value={"status_label": "Connected"})
@patch(
"modules.app_status._cellular_hat_status",
return_value={"module_responding": True},
)
@patch(
"modules.app_status._server_status",
return_value={"configured": True, "reachable": True},
)
@patch(
"modules.app_status.app_upload.get_status",
return_value={"pending_tracking_rows": 0},
)
@patch(
"modules.app_status.app_network.get_status",
return_value={"mode": "wifi", "internet_reachable": True},
)
@patch("modules.app_status.app_gps.get_status", return_value={"thread_alive": True})
@patch("modules.app_status.app_health.collect_health", return_value={"status": "ok"})
def test_collect_status_aggregates_subsystems(
self,
_collect_health,
_gps_status,
_network_status,
_upload_status,
_server_status,
_cellular_status,
_obd_status,
_systemd_status,
):
status = app_status.collect_status()
self.assertEqual(status["health"]["status"], "ok")
self.assertTrue(status["gps"]["thread_alive"])
self.assertTrue(status["uplink"]["ok"])
self.assertEqual(status["uplink"]["mode"], "wifi")
self.assertTrue(status["server"]["reachable"])
self.assertTrue(status["cellular_hat"]["module_responding"])
self.assertEqual(status["obd"]["status_label"], "Connected")
self.assertEqual(len(status["services"]), len(app_status.SERVICE_UNITS))
@patch(
"modules.app_status.app_network.get_status",
side_effect=app_status.app_network.NetworkConfigurationError("nmcli failed"),
)
@patch("modules.app_status._systemd_unit_status", return_value={"ok": True})
@patch("modules.app_status._obd_status", return_value={"status_label": "Connected"})
@patch(
"modules.app_status._cellular_hat_status",
return_value={"module_responding": True},
)
@patch(
"modules.app_status._server_status",
return_value={"configured": False, "reachable": False},
)
@patch(
"modules.app_status.app_upload.get_status",
return_value={"pending_tracking_rows": 0},
)
@patch("modules.app_status.app_gps.get_status", return_value={"thread_alive": True})
@patch("modules.app_status.app_health.collect_health", return_value={"status": "ok"})
def test_collect_status_surfaces_uplink_errors(
self,
_collect_health,
_gps_status,
_upload_status,
_server_status,
_cellular_status,
_obd_status,
_systemd_status,
_network_status,
):
status = app_status.collect_status()
self.assertFalse(status["uplink"]["ok"])
self.assertIn("nmcli failed", status["uplink"]["error"])
if __name__ == "__main__":
unittest.main()