@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user