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
Pi-sized screens, and reduced excess map/footer whitespace that previously forced unnecessary
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 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
from gevent.pywsgi import WSGIServer
@@ -44,6 +44,10 @@ def history():
def settings():
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'])
def api_settings():
if request.method == 'GET':
@@ -298,6 +302,10 @@ def upload_status():
def 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')
def alert_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) {
const liveBadge = document.getElementById('gps-live-badge');
const fixDetail = document.getElementById('gps-fix-detail');
const healthStatus = document.getElementById('pi-gps-status');
const healthDetail = document.getElementById('pi-gps-detail');
if (!liveBadge || !fixDetail || !healthStatus || !healthDetail) return;
if (!liveBadge || !fixDetail) return;
let label = 'Unavailable';
let badgeClass = 'text-bg-secondary';
@@ -248,9 +246,6 @@ function renderGpsStatus(status) {
liveBadge.className = `badge ${badgeClass}`;
liveBadge.textContent = label;
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() {
@@ -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) {
renderGpsStatus({thread_alive: false, has_fix: false, fix_age_seconds: null});
const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
});
window.setInterval(function () {
refreshGpsStatus().catch(function (error) {
const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
});
}, 15000);
+9
View File
@@ -41,6 +41,11 @@
<i class="fas fa-exclamation-triangle fa-fw"></i><span>DTC</span>
</a>
</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">
<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>
@@ -111,6 +116,10 @@
<i class="fas fa-exclamation-triangle"></i>
<span>DTC</span>
</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') }}">
<i class="fas fa-cog"></i>
<span>Settings</span>
-35
View File
@@ -127,41 +127,6 @@
</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 ──────────────────────────────────── #}
<div class="row g-3 mb-3 dashboard-primary-row">
<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()