feat: WebUI diagnostics dashboard (Issue #562) #567
230
ui/diagnostics_panel.css
Normal file
230
ui/diagnostics_panel.css
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/* diagnostics_panel.css — Saltybot System Diagnostics Dashboard (Issue #562) */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg0: #050510;
|
||||||
|
--bg1: #070712;
|
||||||
|
--bg2: #0a0a1a;
|
||||||
|
--border: #0c2a3a;
|
||||||
|
--border2: #1e3a5f;
|
||||||
|
--text-dim: #374151;
|
||||||
|
--text-mid: #6b7280;
|
||||||
|
--text-base: #9ca3af;
|
||||||
|
--text-hi: #d1d5db;
|
||||||
|
--cyan: #06b6d4;
|
||||||
|
--cyan-dim: #0e4f69;
|
||||||
|
--green: #22c55e;
|
||||||
|
--amber: #f59e0b;
|
||||||
|
--red: #ef4444;
|
||||||
|
--orange: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--text-base);
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
#header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.logo { color: #f97316; font-weight: bold; letter-spacing: 0.15em; font-size: 13px; }
|
||||||
|
|
||||||
|
#conn-bar { display: flex; align-items: center; gap: 6px; }
|
||||||
|
#conn-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--text-dim); flex-shrink: 0; transition: background 0.3s;
|
||||||
|
}
|
||||||
|
#conn-dot.connected { background: var(--green); }
|
||||||
|
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
|
||||||
|
|
||||||
|
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
||||||
|
|
||||||
|
#ws-input {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2); border-radius: 4px;
|
||||||
|
color: #67e8f9; padding: 2px 8px; font-family: monospace; font-size: 11px; width: 200px;
|
||||||
|
}
|
||||||
|
#ws-input:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
|
||||||
|
.hdr-btn {
|
||||||
|
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border2);
|
||||||
|
background: var(--bg2); color: #67e8f9; font-family: monospace;
|
||||||
|
font-size: 10px; font-weight: bold; cursor: pointer; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.hdr-btn:hover { background: var(--cyan-dim); }
|
||||||
|
|
||||||
|
#refresh-info {
|
||||||
|
font-size: 10px; color: var(--text-mid);
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
#refresh-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--text-dim); transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#refresh-dot.pulse { background: var(--cyan); animation: blink 0.4s; }
|
||||||
|
|
||||||
|
/* ── Status bar ── */
|
||||||
|
#status-bar {
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
padding: 4px 16px; background: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--border); font-size: 10px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sys-badge {
|
||||||
|
padding: 2px 8px; border-radius: 3px; font-weight: bold;
|
||||||
|
border: 1px solid; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge-ok { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||||
|
.badge-warn { background: #451a03; border-color: #92400e; color: #fcd34d; }
|
||||||
|
.badge-error { background: #450a0a; border-color: #991b1b; color: #f87171; animation: blink 1s infinite; }
|
||||||
|
.badge-stale { background: #111827; border-color: #374151; color: #6b7280; }
|
||||||
|
#last-update { color: var(--text-mid); margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Dashboard grid ── */
|
||||||
|
#dashboard {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) { #dashboard { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 640px) { #dashboard { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Spanning cards */
|
||||||
|
.span2 { grid-column: span 2; }
|
||||||
|
.span3 { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* ── Card ── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card.alert-warn { border-color: #92400e; }
|
||||||
|
.card.alert-error { border-color: #991b1b; }
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 9px; font-weight: bold; letter-spacing: 0.15em;
|
||||||
|
color: #0891b2; text-transform: uppercase;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.card-title .badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── Gauge bar ── */
|
||||||
|
.gauge-row {
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.gauge-label-row {
|
||||||
|
display: flex; justify-content: space-between; font-size: 10px;
|
||||||
|
}
|
||||||
|
.gauge-label { color: var(--text-mid); }
|
||||||
|
.gauge-value { font-family: monospace; }
|
||||||
|
.gauge-track {
|
||||||
|
width: 100%; height: 8px;
|
||||||
|
background: var(--bg2); border-radius: 4px;
|
||||||
|
overflow: hidden; border: 1px solid var(--border2);
|
||||||
|
}
|
||||||
|
.gauge-fill {
|
||||||
|
height: 100%; border-radius: 4px;
|
||||||
|
transition: width 0.5s ease, background 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Big metric ── */
|
||||||
|
.big-metric {
|
||||||
|
display: flex; align-items: baseline; gap: 4px;
|
||||||
|
}
|
||||||
|
.big-num { font-size: 28px; font-weight: bold; font-family: monospace; }
|
||||||
|
.big-unit { font-size: 11px; color: var(--text-mid); }
|
||||||
|
|
||||||
|
/* ── Sparkline ── */
|
||||||
|
#batt-sparkline {
|
||||||
|
width: 100%; border-radius: 4px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
background: var(--bg2);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Node list ── */
|
||||||
|
.node-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 3px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.node-row:last-child { border-bottom: none; }
|
||||||
|
.node-name { color: var(--text-base); font-family: monospace; }
|
||||||
|
.node-status {
|
||||||
|
padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: bold;
|
||||||
|
}
|
||||||
|
.ns-ok { background: #052e16; color: #4ade80; }
|
||||||
|
.ns-warn { background: #451a03; color: #fcd34d; }
|
||||||
|
.ns-error { background: #450a0a; color: #f87171; }
|
||||||
|
.ns-stale { background: #111827; color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Temp arc display ── */
|
||||||
|
.temp-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px;
|
||||||
|
}
|
||||||
|
.temp-box {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px; padding: 6px; text-align: center;
|
||||||
|
}
|
||||||
|
.temp-label { font-size: 9px; color: var(--text-mid); margin-bottom: 2px; }
|
||||||
|
.temp-value { font-size: 22px; font-weight: bold; font-family: monospace; }
|
||||||
|
.temp-bar-track {
|
||||||
|
margin-top: 4px; width: 100%; height: 4px;
|
||||||
|
background: var(--bg0); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.temp-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s; }
|
||||||
|
|
||||||
|
/* ── Motor section ── */
|
||||||
|
.motor-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
|
||||||
|
}
|
||||||
|
.motor-box {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px; padding: 8px;
|
||||||
|
}
|
||||||
|
.motor-label { font-size: 9px; color: var(--text-mid); margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* ── WiFi bars ── */
|
||||||
|
.rssi-bars { display: flex; align-items: flex-end; gap: 3px; }
|
||||||
|
.rssi-bar { width: 6px; border-radius: 2px 2px 0 0; }
|
||||||
|
|
||||||
|
/* ── MQTT indicator ── */
|
||||||
|
.mqtt-status { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.mqtt-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--text-dim); transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.mqtt-dot.connected { background: var(--green); animation: none; }
|
||||||
|
.mqtt-dot.disconnected { background: var(--red); animation: blink 1s infinite; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
#footer {
|
||||||
|
background: var(--bg1); border-top: 1px solid var(--border);
|
||||||
|
padding: 4px 16px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
flex-shrink: 0; font-size: 10px; color: var(--text-dim);
|
||||||
|
}
|
||||||
267
ui/diagnostics_panel.html
Normal file
267
ui/diagnostics_panel.html
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>Saltybot — System Diagnostics</title>
|
||||||
|
<link rel="stylesheet" href="diagnostics_panel.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div id="header">
|
||||||
|
<div class="logo">⚡ SALTYBOT — DIAGNOSTICS</div>
|
||||||
|
|
||||||
|
<div id="conn-bar">
|
||||||
|
<div id="conn-dot"></div>
|
||||||
|
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
|
||||||
|
<button id="btn-connect" class="hdr-btn">CONNECT</button>
|
||||||
|
<span id="conn-label" style="color:#4b5563;font-size:10px">Not connected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="refresh-info">
|
||||||
|
<div id="refresh-dot"></div>
|
||||||
|
<span>auto 2 Hz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── System status bar ── -->
|
||||||
|
<div id="status-bar">
|
||||||
|
<span style="color:#6b7280;font-size:10px">SYSTEM</span>
|
||||||
|
<span class="sys-badge badge-stale" id="system-badge">STALE</span>
|
||||||
|
<span style="color:#4b5563">│</span>
|
||||||
|
<span style="color:#6b7280;font-size:10px">BATTERY</span>
|
||||||
|
<span class="sys-badge badge-stale" id="batt-badge">—</span>
|
||||||
|
<span style="color:#4b5563">│</span>
|
||||||
|
<span style="color:#6b7280;font-size:10px">THERMAL</span>
|
||||||
|
<span class="sys-badge badge-stale" id="temp-badge">—</span>
|
||||||
|
<span style="color:#4b5563">│</span>
|
||||||
|
<span style="color:#6b7280;font-size:10px">NETWORK</span>
|
||||||
|
<span class="sys-badge badge-stale" id="net-badge">—</span>
|
||||||
|
<span id="last-update">Awaiting data…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Dashboard grid ── -->
|
||||||
|
<div id="dashboard">
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ BATTERY ═══════════════════╗ -->
|
||||||
|
<div class="card span2">
|
||||||
|
<div class="card-title">
|
||||||
|
BATTERY — 4S LiPo (12.0–16.8 V)
|
||||||
|
<span class="badge badge-stale" id="batt-badge-2" style="font-size:9px">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Big readout row -->
|
||||||
|
<div style="display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9px;color:#6b7280;margin-bottom:2px">VOLTAGE</div>
|
||||||
|
<div class="big-metric">
|
||||||
|
<span class="big-num" id="batt-voltage" style="color:#22c55e">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9px;color:#6b7280;margin-bottom:2px">SOC</div>
|
||||||
|
<div class="big-metric">
|
||||||
|
<span class="big-num" id="batt-soc" style="color:#22c55e">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9px;color:#6b7280;margin-bottom:2px">CURRENT</div>
|
||||||
|
<div class="big-metric">
|
||||||
|
<span class="big-num" id="batt-current" style="color:#06b6d4;font-size:20px">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gauge bars -->
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">Voltage</span>
|
||||||
|
<span class="gauge-value" id="batt-voltage-2" style="color:#6b7280">12.0–16.8 V</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-track"><div class="gauge-fill" id="batt-volt-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">State of Charge</span>
|
||||||
|
<span class="gauge-value" style="color:#6b7280">0–100%</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-track"><div class="gauge-fill" id="batt-soc-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sparkline history -->
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9px;color:#6b7280;margin-bottom:4px">VOLTAGE HISTORY (last 2 min)</div>
|
||||||
|
<canvas id="batt-sparkline" height="56"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ TEMPERATURES ═══════════════════╗ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">TEMPERATURES</div>
|
||||||
|
<div class="temp-grid">
|
||||||
|
<div class="temp-box" id="cpu-temp-box">
|
||||||
|
<div class="temp-label">CPU (Jetson)</div>
|
||||||
|
<div class="temp-value" id="cpu-temp-val">—</div>
|
||||||
|
<div class="temp-bar-track"><div class="temp-bar-fill" id="cpu-temp-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="temp-box" id="gpu-temp-box">
|
||||||
|
<div class="temp-label">GPU (Jetson)</div>
|
||||||
|
<div class="temp-value" id="gpu-temp-val">—</div>
|
||||||
|
<div class="temp-bar-track"><div class="temp-bar-fill" id="gpu-temp-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="temp-box" id="board-temp-box">
|
||||||
|
<div class="temp-label">Board / STM32</div>
|
||||||
|
<div class="temp-value" id="board-temp-val">—</div>
|
||||||
|
<div class="temp-bar-track"><div class="temp-bar-fill" id="board-temp-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="temp-box" id="motor-temp-l-box">
|
||||||
|
<div class="temp-label">Motor L</div>
|
||||||
|
<div class="temp-value" id="motor-temp-l-val">—</div>
|
||||||
|
<div class="temp-bar-track"><div class="temp-bar-fill" id="motor-temp-l-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- spacer to keep 2-col grid balanced if motor-temp-r is alone -->
|
||||||
|
<div class="temp-box" id="motor-temp-r-box">
|
||||||
|
<div class="temp-label">Motor R</div>
|
||||||
|
<div class="temp-value" id="motor-temp-r-val">—</div>
|
||||||
|
<div class="temp-bar-track"><div class="temp-bar-fill" id="motor-temp-r-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#374151">
|
||||||
|
Zones: <60°C green · 60–75°C amber · >75°C red
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ MOTOR CURRENT ═══════════════════╗ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">MOTOR CURRENT & CMD</div>
|
||||||
|
<div class="motor-grid">
|
||||||
|
<div class="motor-box">
|
||||||
|
<div class="motor-label">LEFT WHEEL</div>
|
||||||
|
<div style="font-size:20px;font-weight:bold;font-family:monospace" id="motor-cur-l">—</div>
|
||||||
|
<div class="gauge-track" style="margin-top:4px">
|
||||||
|
<div class="gauge-fill" id="motor-bar-l" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#4b5563;margin-top:4px">
|
||||||
|
CMD: <span id="motor-cmd-l" style="color:#6b7280">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="motor-box">
|
||||||
|
<div class="motor-label">RIGHT WHEEL</div>
|
||||||
|
<div style="font-size:20px;font-weight:bold;font-family:monospace" id="motor-cur-r">—</div>
|
||||||
|
<div class="gauge-track" style="margin-top:4px">
|
||||||
|
<div class="gauge-fill" id="motor-bar-r" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#4b5563;margin-top:4px">
|
||||||
|
CMD: <span id="motor-cmd-r" style="color:#6b7280">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#6b7280">
|
||||||
|
Balance state: <span id="balance-state" style="color:#9ca3af;font-family:monospace">—</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#374151">Thresholds: warn 8A · crit 12A</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ MEMORY / DISK ═══════════════════╗ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">RESOURCES</div>
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">RAM</span>
|
||||||
|
<span class="gauge-value" id="ram-val" style="color:#9ca3af">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-track"><div class="gauge-fill" id="ram-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">GPU Memory</span>
|
||||||
|
<span class="gauge-value" id="gpu-val" style="color:#9ca3af">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-track"><div class="gauge-fill" id="gpu-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">Disk</span>
|
||||||
|
<span class="gauge-value" id="disk-val" style="color:#9ca3af">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-track"><div class="gauge-fill" id="disk-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#374151">Warn ≥80% · Critical ≥95%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ WIFI / LATENCY ═══════════════════╗ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">NETWORK</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<div id="rssi-bars" class="rssi-bars"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9px;color:#6b7280">RSSI</div>
|
||||||
|
<div style="font-size:20px;font-weight:bold;font-family:monospace" id="rssi-val">—</div>
|
||||||
|
<div style="font-size:9px" id="rssi-label" style="color:#6b7280">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-row">
|
||||||
|
<div class="gauge-label-row">
|
||||||
|
<span class="gauge-label">Rosbridge Latency</span>
|
||||||
|
<span class="gauge-value" id="latency-val" style="color:#9ca3af">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- MQTT status -->
|
||||||
|
<div style="margin-top:4px;padding-top:8px;border-top:1px solid #0c2a3a">
|
||||||
|
<div style="font-size:9px;color:#6b7280;margin-bottom:4px">MQTT BROKER</div>
|
||||||
|
<div class="mqtt-status">
|
||||||
|
<div class="mqtt-dot" id="mqtt-dot"></div>
|
||||||
|
<span id="mqtt-label" style="color:#6b7280">No data</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#374151;margin-top:4px">
|
||||||
|
Via /diagnostics KeyValue: mqtt_connected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╔═══════════════════ ROS2 NODE HEALTH ═══════════════════╗ -->
|
||||||
|
<div class="card span3">
|
||||||
|
<div class="card-title">
|
||||||
|
ROS2 NODE HEALTH — /diagnostics
|
||||||
|
<span style="font-size:9px;color:#4b5563" id="node-count">0 nodes</span>
|
||||||
|
</div>
|
||||||
|
<div id="node-list">
|
||||||
|
<div style="color:#374151;font-size:10px;text-align:center;padding:12px">
|
||||||
|
Waiting for /diagnostics data…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<div id="footer">
|
||||||
|
<span>rosbridge: <code id="footer-ws">ws://localhost:9090</code></span>
|
||||||
|
<span>diagnostics dashboard — issue #562 · auto 2Hz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="diagnostics_panel.js"></script>
|
||||||
|
<script>
|
||||||
|
// Sync footer WS URL
|
||||||
|
document.getElementById('ws-input').addEventListener('input', (e) => {
|
||||||
|
document.getElementById('footer-ws').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep node count updated after render
|
||||||
|
const origRenderNodes = window.renderNodes;
|
||||||
|
const nodeCountEl = document.getElementById('node-count');
|
||||||
|
const _origOnDiag = window.onDiagnostics;
|
||||||
|
|
||||||
|
// Node count updates via MutationObserver on the node-list div
|
||||||
|
const nl = document.getElementById('node-list');
|
||||||
|
if (nl) {
|
||||||
|
new MutationObserver(() => {
|
||||||
|
const rows = nl.querySelectorAll('.node-row');
|
||||||
|
if (nodeCountEl) nodeCountEl.textContent = rows.length + ' node' + (rows.length !== 1 ? 's' : '');
|
||||||
|
}).observe(nl, { childList: true, subtree: false });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
592
ui/diagnostics_panel.js
Normal file
592
ui/diagnostics_panel.js
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* diagnostics_panel.js — Saltybot System Diagnostics Dashboard (Issue #562)
|
||||||
|
*
|
||||||
|
* Connects to rosbridge WebSocket and subscribes to:
|
||||||
|
* /diagnostics diagnostic_msgs/DiagnosticArray — everything
|
||||||
|
* /saltybot/balance_state std_msgs/String (JSON) — motor cmd / state
|
||||||
|
*
|
||||||
|
* Diagnostic KeyValues decoded:
|
||||||
|
* battery_voltage_v, battery_current_a, battery_soc_pct
|
||||||
|
* cpu_temp_c, gpu_temp_c, board_temp_c
|
||||||
|
* motor_temp_l_c, motor_temp_r_c
|
||||||
|
* motor_current_l_a, motor_current_r_a
|
||||||
|
* ram_used_mb, ram_total_mb
|
||||||
|
* gpu_used_mb, gpu_total_mb
|
||||||
|
* disk_used_gb, disk_total_gb
|
||||||
|
* wifi_rssi_dbm, wifi_latency_ms
|
||||||
|
* mqtt_connected
|
||||||
|
*
|
||||||
|
* Auto-refresh: rosbridge subscriptions push at ~2 Hz; UI polls canvas draws
|
||||||
|
* at the same rate and updates DOM elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LIPO_MIN = 12.0; // 4S empty
|
||||||
|
const LIPO_MAX = 16.8; // 4S full
|
||||||
|
|
||||||
|
const BATT_HISTORY_MAX = 120; // ~2 min at 1 Hz
|
||||||
|
|
||||||
|
// Alert thresholds
|
||||||
|
const THRESHOLDS = {
|
||||||
|
voltage_warn: 13.6,
|
||||||
|
voltage_crit: 13.0,
|
||||||
|
soc_warn: 25,
|
||||||
|
soc_crit: 10,
|
||||||
|
cpu_temp_warn: 75,
|
||||||
|
cpu_temp_crit: 90,
|
||||||
|
gpu_temp_warn: 75,
|
||||||
|
gpu_temp_crit: 90,
|
||||||
|
motor_temp_warn: 70,
|
||||||
|
motor_temp_crit: 85,
|
||||||
|
ram_pct_warn: 80,
|
||||||
|
ram_pct_crit: 95,
|
||||||
|
disk_pct_warn: 80,
|
||||||
|
disk_pct_crit: 95,
|
||||||
|
rssi_warn: -70,
|
||||||
|
rssi_crit: -80,
|
||||||
|
latency_warn: 150,
|
||||||
|
latency_crit: 500,
|
||||||
|
current_warn: 8.0,
|
||||||
|
current_crit: 12.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ros = null;
|
||||||
|
let diagSub = null;
|
||||||
|
let balanceSub = null;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
// Battery
|
||||||
|
voltage: null,
|
||||||
|
current: null,
|
||||||
|
soc: null,
|
||||||
|
battHistory: [], // [{ts, v}]
|
||||||
|
|
||||||
|
// Temperatures
|
||||||
|
cpuTemp: null,
|
||||||
|
gpuTemp: null,
|
||||||
|
boardTemp: null,
|
||||||
|
motorTempL: null,
|
||||||
|
motorTempR: null,
|
||||||
|
|
||||||
|
// Motor
|
||||||
|
motorCurrentL: null,
|
||||||
|
motorCurrentR: null,
|
||||||
|
motorCmdL: null,
|
||||||
|
motorCmdR: null,
|
||||||
|
balanceState: 'UNKNOWN',
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
ramUsed: null, ramTotal: null,
|
||||||
|
gpuUsed: null, gpuTotal: null,
|
||||||
|
diskUsed: null, diskTotal: null,
|
||||||
|
|
||||||
|
// Network
|
||||||
|
rssi: null,
|
||||||
|
latency: null,
|
||||||
|
|
||||||
|
// MQTT
|
||||||
|
mqttConnected: null,
|
||||||
|
|
||||||
|
// ROS nodes (DiagnosticStatus array)
|
||||||
|
nodes: [],
|
||||||
|
|
||||||
|
// Overall health
|
||||||
|
overallLevel: 3, // 3=STALE by default
|
||||||
|
lastUpdate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Utility ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function numOrNull(s) {
|
||||||
|
const n = parseFloat(s);
|
||||||
|
return isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(used, total) {
|
||||||
|
if (!total || total === 0) return 0;
|
||||||
|
return Math.min(100, Math.max(0, (used / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function socFromVoltage(v) {
|
||||||
|
if (v == null || v <= 0) return null;
|
||||||
|
return Math.max(0, Math.min(100, ((v - LIPO_MIN) / (LIPO_MAX - LIPO_MIN)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function threshColor(value, warnThr, critThr, invert = false) {
|
||||||
|
// invert: lower is worse (e.g. voltage, SOC, RSSI)
|
||||||
|
if (value == null) return '#6b7280';
|
||||||
|
const warn = invert ? value <= warnThr : value >= warnThr;
|
||||||
|
const crit = invert ? value <= critThr : value >= critThr;
|
||||||
|
if (crit) return '#ef4444';
|
||||||
|
if (warn) return '#f59e0b';
|
||||||
|
return '#22c55e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelClass(level) {
|
||||||
|
return ['ns-ok', 'ns-warn', 'ns-error', 'ns-stale'][level] ?? 'ns-stale';
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelLabel(level) {
|
||||||
|
return ['OK', 'WARN', 'ERROR', 'STALE'][level] ?? 'STALE';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBadgeClass(el, level) {
|
||||||
|
el.className = 'sys-badge ' + ['badge-ok','badge-warn','badge-error','badge-stale'][level ?? 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const url = document.getElementById('ws-input').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
if (ros) ros.close();
|
||||||
|
|
||||||
|
ros = new ROSLIB.Ros({ url });
|
||||||
|
|
||||||
|
ros.on('connection', () => {
|
||||||
|
document.getElementById('conn-dot').className = 'connected';
|
||||||
|
document.getElementById('conn-label').textContent = url;
|
||||||
|
setupSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
ros.on('error', (err) => {
|
||||||
|
document.getElementById('conn-dot').className = 'error';
|
||||||
|
document.getElementById('conn-label').textContent = 'ERROR: ' + (err?.message || err);
|
||||||
|
teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
ros.on('close', () => {
|
||||||
|
document.getElementById('conn-dot').className = '';
|
||||||
|
document.getElementById('conn-label').textContent = 'Disconnected';
|
||||||
|
teardown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSubscriptions() {
|
||||||
|
// /diagnostics — throttle 500ms → ~2 Hz
|
||||||
|
diagSub = new ROSLIB.Topic({
|
||||||
|
ros, name: '/diagnostics',
|
||||||
|
messageType: 'diagnostic_msgs/DiagnosticArray',
|
||||||
|
throttle_rate: 500,
|
||||||
|
});
|
||||||
|
diagSub.subscribe(onDiagnostics);
|
||||||
|
|
||||||
|
// /saltybot/balance_state
|
||||||
|
balanceSub = new ROSLIB.Topic({
|
||||||
|
ros, name: '/saltybot/balance_state',
|
||||||
|
messageType: 'std_msgs/String',
|
||||||
|
throttle_rate: 500,
|
||||||
|
});
|
||||||
|
balanceSub.subscribe(onBalanceState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
if (diagSub) { diagSub.unsubscribe(); diagSub = null; }
|
||||||
|
if (balanceSub) { balanceSub.unsubscribe(); balanceSub = null; }
|
||||||
|
state.overallLevel = 3;
|
||||||
|
state.lastUpdate = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onDiagnostics(msg) {
|
||||||
|
const statuses = msg.status ?? [];
|
||||||
|
state.nodes = [];
|
||||||
|
let overallLevel = 0;
|
||||||
|
|
||||||
|
for (const status of statuses) {
|
||||||
|
const kv = {};
|
||||||
|
for (const { key, value } of (status.values ?? [])) {
|
||||||
|
kv[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery
|
||||||
|
if ('battery_voltage_v' in kv) state.voltage = numOrNull(kv.battery_voltage_v);
|
||||||
|
if ('battery_current_a' in kv) state.current = numOrNull(kv.battery_current_a);
|
||||||
|
if ('battery_soc_pct' in kv) state.soc = numOrNull(kv.battery_soc_pct);
|
||||||
|
|
||||||
|
// Temperatures
|
||||||
|
if ('cpu_temp_c' in kv) state.cpuTemp = numOrNull(kv.cpu_temp_c);
|
||||||
|
if ('gpu_temp_c' in kv) state.gpuTemp = numOrNull(kv.gpu_temp_c);
|
||||||
|
if ('board_temp_c' in kv) state.boardTemp = numOrNull(kv.board_temp_c);
|
||||||
|
if ('motor_temp_l_c' in kv) state.motorTempL = numOrNull(kv.motor_temp_l_c);
|
||||||
|
if ('motor_temp_r_c' in kv) state.motorTempR = numOrNull(kv.motor_temp_r_c);
|
||||||
|
|
||||||
|
// Motor current
|
||||||
|
if ('motor_current_l_a' in kv) state.motorCurrentL = numOrNull(kv.motor_current_l_a);
|
||||||
|
if ('motor_current_r_a' in kv) state.motorCurrentR = numOrNull(kv.motor_current_r_a);
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
if ('ram_used_mb' in kv) state.ramUsed = numOrNull(kv.ram_used_mb);
|
||||||
|
if ('ram_total_mb' in kv) state.ramTotal = numOrNull(kv.ram_total_mb);
|
||||||
|
if ('gpu_used_mb' in kv) state.gpuUsed = numOrNull(kv.gpu_used_mb);
|
||||||
|
if ('gpu_total_mb' in kv) state.gpuTotal = numOrNull(kv.gpu_total_mb);
|
||||||
|
if ('disk_used_gb' in kv) state.diskUsed = numOrNull(kv.disk_used_gb);
|
||||||
|
if ('disk_total_gb' in kv) state.diskTotal = numOrNull(kv.disk_total_gb);
|
||||||
|
|
||||||
|
// Network
|
||||||
|
if ('wifi_rssi_dbm' in kv) state.rssi = numOrNull(kv.wifi_rssi_dbm);
|
||||||
|
if ('wifi_latency_ms' in kv) state.latency = numOrNull(kv.wifi_latency_ms);
|
||||||
|
|
||||||
|
// MQTT
|
||||||
|
if ('mqtt_connected' in kv) state.mqttConnected = kv.mqtt_connected === 'true';
|
||||||
|
|
||||||
|
// Node health
|
||||||
|
state.nodes.push({
|
||||||
|
name: status.name || status.hardware_id || 'unknown',
|
||||||
|
level: status.level ?? 3,
|
||||||
|
message: status.message || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
overallLevel = Math.max(overallLevel, status.level ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.overallLevel = overallLevel;
|
||||||
|
state.lastUpdate = new Date();
|
||||||
|
|
||||||
|
// Battery history
|
||||||
|
if (state.voltage != null) {
|
||||||
|
state.battHistory.push({ ts: Date.now(), v: state.voltage });
|
||||||
|
if (state.battHistory.length > BATT_HISTORY_MAX) {
|
||||||
|
state.battHistory.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive SOC from voltage if not provided
|
||||||
|
if (state.soc == null && state.voltage != null) {
|
||||||
|
state.soc = socFromVoltage(state.voltage);
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
flashRefreshDot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBalanceState(msg) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.data);
|
||||||
|
state.balanceState = data.state ?? 'UNKNOWN';
|
||||||
|
state.motorCmdL = data.motor_cmd ?? null;
|
||||||
|
state.motorCmdR = data.motor_cmd ?? null; // single motor_cmd field
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh indicator ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function flashRefreshDot() {
|
||||||
|
const dot = document.getElementById('refresh-dot');
|
||||||
|
dot.classList.add('pulse');
|
||||||
|
setTimeout(() => dot.classList.remove('pulse'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sparkline canvas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function drawSparkline() {
|
||||||
|
const canvas = document.getElementById('batt-sparkline');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width = canvas.offsetWidth || 300;
|
||||||
|
const H = canvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#0a0a1a';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const data = state.battHistory;
|
||||||
|
if (data.length < 2) {
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '10px Courier New';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Awaiting battery data…', W / 2, H / 2 + 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vMin = LIPO_MIN - 0.2;
|
||||||
|
const vMax = LIPO_MAX + 0.2;
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = '#1e3a5f';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
[0.25, 0.5, 0.75].forEach((f) => {
|
||||||
|
const y = H - f * H;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plot
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
data.forEach((pt, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * W;
|
||||||
|
const y = H - ((pt.v - vMin) / (vMax - vMin)) * H;
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastV = data[data.length - 1].v;
|
||||||
|
ctx.strokeStyle = threshColor(lastV, THRESHOLDS.voltage_warn, THRESHOLDS.voltage_crit, true);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Fill under
|
||||||
|
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
|
||||||
|
ctx.fillStyle = 'rgba(6,182,212,0.06)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '9px Courier New';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`${LIPO_MAX}V`, 2, 10);
|
||||||
|
ctx.fillText(`${LIPO_MIN}V`, 2, H - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gauge helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setGauge(barId, pctVal, color) {
|
||||||
|
const el = document.getElementById(barId);
|
||||||
|
if (!el) return;
|
||||||
|
el.style.width = `${Math.max(0, Math.min(100, pctVal))}%`;
|
||||||
|
el.style.background = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setColor(id, color) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTempBox(id, tempC, warnT, critT) {
|
||||||
|
const box = document.getElementById(id + '-box');
|
||||||
|
const val = document.getElementById(id + '-val');
|
||||||
|
const bar = document.getElementById(id + '-bar');
|
||||||
|
const color = threshColor(tempC, warnT, critT);
|
||||||
|
|
||||||
|
if (val) { val.textContent = tempC != null ? tempC.toFixed(0) + '°C' : '—'; val.style.color = color; }
|
||||||
|
if (bar) {
|
||||||
|
const p = tempC != null ? Math.min(100, (tempC / 100) * 100) : 0;
|
||||||
|
bar.style.width = p + '%';
|
||||||
|
bar.style.background = color;
|
||||||
|
}
|
||||||
|
if (box && tempC != null) {
|
||||||
|
box.style.borderColor = tempC >= critT ? '#991b1b' : tempC >= warnT ? '#92400e' : '#1e3a5f';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSSI bars ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function rssiToLevel(dBm) {
|
||||||
|
if (dBm == null) return { label: 'N/A', color: '#374151', bars: 0 };
|
||||||
|
if (dBm >= -50) return { label: 'Excellent', color: '#22c55e', bars: 5 };
|
||||||
|
if (dBm >= -60) return { label: 'Good', color: '#3b82f6', bars: 4 };
|
||||||
|
if (dBm >= -70) return { label: 'Fair', color: '#f59e0b', bars: 3 };
|
||||||
|
if (dBm >= -80) return { label: 'Weak', color: '#ef4444', bars: 2 };
|
||||||
|
return { label: 'Poor', color: '#7f1d1d', bars: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRssiBars() {
|
||||||
|
const container = document.getElementById('rssi-bars');
|
||||||
|
if (!container) return;
|
||||||
|
const lv = rssiToLevel(state.rssi);
|
||||||
|
const BAR_H = [4, 8, 12, 16, 20];
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'rssi-bar';
|
||||||
|
bar.style.height = BAR_H[i] + 'px';
|
||||||
|
bar.style.width = '7px';
|
||||||
|
bar.style.background = i < lv.bars ? lv.color : '#1f2937';
|
||||||
|
container.appendChild(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node list renderer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderNodes() {
|
||||||
|
const container = document.getElementById('node-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (state.nodes.length === 0) {
|
||||||
|
container.innerHTML = '<div style="color:#374151;font-size:10px;text-align:center;padding:8px">No /diagnostics data yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = state.nodes.map((n) => `
|
||||||
|
<div class="node-row">
|
||||||
|
<span class="node-name">${n.name}</span>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
${n.message ? `<span style="color:#4b5563;font-size:9px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${n.message}">${n.message}</span>` : ''}
|
||||||
|
<span class="node-status ${levelClass(n.level)}">${levelLabel(n.level)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overall status bar ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateStatusBar() {
|
||||||
|
const lvl = state.overallLevel;
|
||||||
|
const badge = document.getElementById('system-badge');
|
||||||
|
const labels = ['HEALTHY', 'DEGRADED', 'FAULT', 'STALE'];
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = labels[lvl] ?? 'STALE';
|
||||||
|
setBadgeClass(badge, lvl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual badges
|
||||||
|
updateBattBadge();
|
||||||
|
updateTempBadge();
|
||||||
|
updateNetBadge();
|
||||||
|
|
||||||
|
const upd = document.getElementById('last-update');
|
||||||
|
if (upd && state.lastUpdate) {
|
||||||
|
upd.textContent = 'Updated ' + state.lastUpdate.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBattBadge() {
|
||||||
|
const el = document.getElementById('batt-badge');
|
||||||
|
if (!el) return;
|
||||||
|
const v = state.voltage;
|
||||||
|
if (v == null) { el.textContent = 'NO DATA'; setBadgeClass(el, 3); return; }
|
||||||
|
if (v <= THRESHOLDS.voltage_crit) { el.textContent = 'CRITICAL'; setBadgeClass(el, 2); }
|
||||||
|
else if (v <= THRESHOLDS.voltage_warn) { el.textContent = 'LOW'; setBadgeClass(el, 1); }
|
||||||
|
else { el.textContent = 'OK'; setBadgeClass(el, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTempBadge() {
|
||||||
|
const el = document.getElementById('temp-badge');
|
||||||
|
if (!el) return;
|
||||||
|
const temps = [state.cpuTemp, state.gpuTemp, state.motorTempL, state.motorTempR].filter(t => t != null);
|
||||||
|
if (temps.length === 0) { el.textContent = 'NO DATA'; setBadgeClass(el, 3); return; }
|
||||||
|
const max = Math.max(...temps);
|
||||||
|
if (max >= THRESHOLDS.cpu_temp_crit) { el.textContent = 'CRITICAL'; setBadgeClass(el, 2); }
|
||||||
|
else if (max >= THRESHOLDS.cpu_temp_warn) { el.textContent = 'HOT'; setBadgeClass(el, 1); }
|
||||||
|
else { el.textContent = 'OK'; setBadgeClass(el, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNetBadge() {
|
||||||
|
const el = document.getElementById('net-badge');
|
||||||
|
if (!el) return;
|
||||||
|
const r = state.rssi;
|
||||||
|
if (r == null) { el.textContent = 'NO DATA'; setBadgeClass(el, 3); return; }
|
||||||
|
if (r <= THRESHOLDS.rssi_crit) { el.textContent = 'POOR'; setBadgeClass(el, 2); }
|
||||||
|
else if (r <= THRESHOLDS.rssi_warn) { el.textContent = 'WEAK'; setBadgeClass(el, 1); }
|
||||||
|
else { el.textContent = 'OK'; setBadgeClass(el, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
// ── Battery ──
|
||||||
|
const v = state.voltage;
|
||||||
|
const soc = state.soc ?? socFromVoltage(v);
|
||||||
|
const vColor = threshColor(v, THRESHOLDS.voltage_warn, THRESHOLDS.voltage_crit, true);
|
||||||
|
const sColor = threshColor(soc, THRESHOLDS.soc_warn, THRESHOLDS.soc_crit, true);
|
||||||
|
|
||||||
|
setText('batt-voltage', v != null ? v.toFixed(2) + ' V' : '—');
|
||||||
|
setColor('batt-voltage', vColor);
|
||||||
|
setText('batt-soc', soc != null ? soc.toFixed(0) + ' %' : '—');
|
||||||
|
setColor('batt-soc', sColor);
|
||||||
|
setText('batt-current', state.current != null ? state.current.toFixed(2) + ' A' : '—');
|
||||||
|
|
||||||
|
setGauge('batt-soc-bar', soc ?? 0, sColor);
|
||||||
|
setGauge('batt-volt-bar', v != null ? ((v - LIPO_MIN) / (LIPO_MAX - LIPO_MIN)) * 100 : 0, vColor);
|
||||||
|
|
||||||
|
drawSparkline();
|
||||||
|
|
||||||
|
// ── Temperatures ──
|
||||||
|
setTempBox('cpu-temp', state.cpuTemp, THRESHOLDS.cpu_temp_warn, THRESHOLDS.cpu_temp_crit);
|
||||||
|
setTempBox('gpu-temp', state.gpuTemp, THRESHOLDS.gpu_temp_warn, THRESHOLDS.gpu_temp_crit);
|
||||||
|
setTempBox('board-temp', state.boardTemp, 60, 80);
|
||||||
|
setTempBox('motor-temp-l', state.motorTempL, THRESHOLDS.motor_temp_warn, THRESHOLDS.motor_temp_crit);
|
||||||
|
setTempBox('motor-temp-r', state.motorTempR, THRESHOLDS.motor_temp_warn, THRESHOLDS.motor_temp_crit);
|
||||||
|
|
||||||
|
// ── Motor current ──
|
||||||
|
const curL = state.motorCurrentL;
|
||||||
|
const curR = state.motorCurrentR;
|
||||||
|
const curColorL = threshColor(curL, THRESHOLDS.current_warn, THRESHOLDS.current_crit);
|
||||||
|
const curColorR = threshColor(curR, THRESHOLDS.current_warn, THRESHOLDS.current_crit);
|
||||||
|
|
||||||
|
setText('motor-cur-l', curL != null ? curL.toFixed(2) + ' A' : '—');
|
||||||
|
setColor('motor-cur-l', curColorL);
|
||||||
|
setText('motor-cur-r', curR != null ? curR.toFixed(2) + ' A' : '—');
|
||||||
|
setColor('motor-cur-r', curColorR);
|
||||||
|
setGauge('motor-bar-l', curL != null ? (curL / THRESHOLDS.current_crit) * 100 : 0, curColorL);
|
||||||
|
setGauge('motor-bar-r', curR != null ? (curR / THRESHOLDS.current_crit) * 100 : 0, curColorR);
|
||||||
|
setText('motor-cmd-l', state.motorCmdL != null ? state.motorCmdL.toString() : '—');
|
||||||
|
setText('motor-cmd-r', state.motorCmdR != null ? state.motorCmdR.toString() : '—');
|
||||||
|
setText('balance-state', state.balanceState);
|
||||||
|
|
||||||
|
// ── Resources ──
|
||||||
|
const ramPct = pct(state.ramUsed, state.ramTotal);
|
||||||
|
const gpuPct = pct(state.gpuUsed, state.gpuTotal);
|
||||||
|
const diskPct = pct(state.diskUsed, state.diskTotal);
|
||||||
|
|
||||||
|
setText('ram-val',
|
||||||
|
state.ramUsed != null ? `${state.ramUsed.toFixed(0)} / ${state.ramTotal?.toFixed(0) ?? '?'} MB` : '—');
|
||||||
|
setGauge('ram-bar', ramPct, threshColor(ramPct, THRESHOLDS.ram_pct_warn, THRESHOLDS.ram_pct_crit));
|
||||||
|
|
||||||
|
setText('gpu-val',
|
||||||
|
state.gpuUsed != null ? `${state.gpuUsed.toFixed(0)} / ${state.gpuTotal?.toFixed(0) ?? '?'} MB` : '—');
|
||||||
|
setGauge('gpu-bar', gpuPct, threshColor(gpuPct, 70, 90));
|
||||||
|
|
||||||
|
setText('disk-val',
|
||||||
|
state.diskUsed != null ? `${state.diskUsed.toFixed(1)} / ${state.diskTotal?.toFixed(1) ?? '?'} GB` : '—');
|
||||||
|
setGauge('disk-bar', diskPct, threshColor(diskPct, THRESHOLDS.disk_pct_warn, THRESHOLDS.disk_pct_crit));
|
||||||
|
|
||||||
|
// ── WiFi ──
|
||||||
|
const rLevel = rssiToLevel(state.rssi);
|
||||||
|
setText('rssi-val', state.rssi != null ? state.rssi + ' dBm' : '—');
|
||||||
|
setColor('rssi-val', rLevel.color);
|
||||||
|
setText('rssi-label', rLevel.label);
|
||||||
|
setColor('rssi-label', rLevel.color);
|
||||||
|
setText('latency-val', state.latency != null ? state.latency.toFixed(0) + ' ms' : '—');
|
||||||
|
setColor('latency-val', threshColor(state.latency, THRESHOLDS.latency_warn, THRESHOLDS.latency_crit));
|
||||||
|
drawRssiBars();
|
||||||
|
|
||||||
|
// ── MQTT ──
|
||||||
|
const mqttDot = document.getElementById('mqtt-dot');
|
||||||
|
const mqttLbl = document.getElementById('mqtt-label');
|
||||||
|
if (mqttDot) {
|
||||||
|
mqttDot.className = 'mqtt-dot ' + (state.mqttConnected ? 'connected' : 'disconnected');
|
||||||
|
}
|
||||||
|
if (mqttLbl) {
|
||||||
|
mqttLbl.textContent = state.mqttConnected === null ? 'No data'
|
||||||
|
: state.mqttConnected ? 'Broker connected'
|
||||||
|
: 'Broker disconnected';
|
||||||
|
mqttLbl.style.color = state.mqttConnected ? '#4ade80' : '#f87171';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nodes ──
|
||||||
|
renderNodes();
|
||||||
|
|
||||||
|
// ── Status bar ──
|
||||||
|
updateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-connect').addEventListener('click', connect);
|
||||||
|
document.getElementById('ws-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('diag_ws_url');
|
||||||
|
if (stored) document.getElementById('ws-input').value = stored;
|
||||||
|
document.getElementById('ws-input').addEventListener('change', (e) => {
|
||||||
|
localStorage.setItem('diag_ws_url', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial render (blank state)
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Periodic sparkline resize + redraw on window resize
|
||||||
|
window.addEventListener('resize', drawSparkline);
|
||||||
Loading…
x
Reference in New Issue
Block a user