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
+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);