rov-autonomy/widgets/w1_system_health_indicator.html

289 lines
9.2 KiB
HTML

<!DOCTYPE html>
<!--
============================================================
ARGONAUT 3 — SYSTEM HEALTH INDICATOR
Widget 1 — Primary safety status (always visible)
============================================================
Purpose : Display GREEN / AMBER / RED failsafe state as a
traffic-light row of three circles with a label.
Data : Reads NAMED_VALUE_INT "rov_failsafe" from the
Cockpit data lake. Values: 0=GREEN, 1=AMBER, 2=RED
(The ROS2 → MAVROS → NAMED_VALUE bridge must be
running for live data. Falls back to a "waiting"
state if the variable is absent.)
Import : Cockpit → Edit mode → Add DIY Widget →
gear icon → Import → select this file
Version : 1.0
============================================================
-->
<style>
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #09111a; /* widget background */
--text0: #d8eeff; /* primary label text */
--text1: #6a9bbf; /* secondary / dim text */
--green: #00e09a; /* GREEN state fill */
--amber: #ffb830; /* AMBER state fill */
--red: #ff3a5a; /* RED state fill */
--dim: #1a2d42; /* inactive circle fill */
--border: #1e3050; /* inactive circle border */
--mono: 'Courier New', monospace;
}
/* ── Full-height flex container ─────────────────────────── */
body {
background: var(--bg);
color: var(--text0);
font-family: var(--mono);
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
user-select: none;
}
/* ── Three-circle row ───────────────────────────────────── */
#circles {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
/* Each circle is a labelled flex column */
.circle-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
/* The circle itself */
.circle {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--dim);
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
}
/* Colour states — applied to .circle when active */
.circle.green {
background: var(--green);
border-color: var(--green);
box-shadow: 0 0 12px var(--green);
}
.circle.amber {
background: var(--amber);
border-color: var(--amber);
box-shadow: 0 0 12px var(--amber);
}
.circle.red {
background: var(--red);
border-color: var(--red);
box-shadow: 0 0 16px var(--red);
}
/* RED state flashing border animation */
@keyframes flash-red {
0%, 100% { box-shadow: 0 0 16px var(--red); }
50% { box-shadow: 0 0 32px var(--red), 0 0 8px #ff000088; }
}
.circle.red { animation: flash-red 1s ease-in-out infinite; }
/* Label below each circle */
.circle-label {
font-size: 9px;
color: var(--text1);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Active label is brighter */
.circle-label.active { color: var(--text0); font-weight: bold; }
/* ── Status message line ────────────────────────────────── */
#message {
font-size: 11px;
color: var(--text1);
text-align: center;
max-width: 180px;
line-height: 1.4;
min-height: 16px; /* prevent layout shift when empty */
}
/* Colour the message text to match current state */
#message.green { color: var(--green); }
#message.amber { color: var(--amber); }
#message.red { color: var(--red); }
/* ── Small "last updated" footer ─────────────────────────── */
#footer {
margin-top: 6px;
font-size: 9px;
color: #2a4060; /* very dim — not important */
}
</style>
<body>
<!-- Three traffic-light circles -->
<div id="circles">
<div class="circle-wrap">
<div id="c-green" class="circle"></div>
<span id="l-green" class="circle-label">Green</span>
</div>
<div class="circle-wrap">
<div id="c-amber" class="circle"></div>
<span id="l-amber" class="circle-label">Amber</span>
</div>
<div class="circle-wrap">
<div id="c-red" class="circle"></div>
<span id="l-red" class="circle-label">Red</span>
</div>
</div>
<!-- Human-readable status message -->
<div id="message">Connecting…</div>
<!-- Update timestamp (very dim) -->
<div id="footer"></div>
</body>
<script>
// ── Config ────────────────────────────────────────────────
// Name of the data lake variable published by the ROS2 bridge
const VARIABLE = 'rov_failsafe';
// Poll interval in milliseconds
const POLL_MS = 500;
// State values
const STATE_GREEN = 0;
const STATE_AMBER = 1;
const STATE_RED = 2;
// Default messages for each state (used when no reason string present)
const MESSAGES = {
[STATE_GREEN]: 'Systems nominal',
[STATE_AMBER]: 'Parameter degraded — check system',
[STATE_RED]: 'CRITICAL — Safe action triggered',
};
// ── DOM references ────────────────────────────────────────
const cGreen = document.getElementById('c-green');
const cAmber = document.getElementById('c-amber');
const cRed = document.getElementById('c-red');
const lGreen = document.getElementById('l-green');
const lAmber = document.getElementById('l-amber');
const lRed = document.getElementById('l-red');
const msgEl = document.getElementById('message');
const footEl = document.getElementById('footer');
// ── Apply a state to the UI ───────────────────────────────
// state : 0 (GREEN) | 1 (AMBER) | 2 (RED) | null (waiting)
// reason: optional string to show in the message line
function applyState(state, reason) {
// Clear all active circle classes first
[cGreen, cAmber, cRed].forEach(c =>
c.classList.remove('green', 'amber', 'red')
);
// Clear all active label classes
[lGreen, lAmber, lRed].forEach(l =>
l.classList.remove('active')
);
// Remove all message colour classes
msgEl.classList.remove('green', 'amber', 'red');
if (state === STATE_GREEN) {
cGreen.classList.add('green');
lGreen.classList.add('active');
msgEl.classList.add('green');
msgEl.textContent = reason || MESSAGES[STATE_GREEN];
} else if (state === STATE_AMBER) {
cAmber.classList.add('amber');
lAmber.classList.add('active');
msgEl.classList.add('amber');
msgEl.textContent = reason || MESSAGES[STATE_AMBER];
} else if (state === STATE_RED) {
cRed.classList.add('red');
lRed.classList.add('active');
msgEl.classList.add('red');
msgEl.textContent = reason || MESSAGES[STATE_RED];
} else {
// No known state — waiting for data
msgEl.textContent = 'Waiting for data…';
}
}
// ── Poll the data lake ────────────────────────────────────
function poll() {
try {
// Guard: ensure Cockpit API is available
if (typeof window.cockpit === 'undefined' ||
typeof window.cockpit.getDataLakeValue !== 'function') {
applyState(null);
footEl.textContent = 'API not ready';
return;
}
// Read the primary state variable (integer 0/1/2)
const stateRaw = window.cockpit.getDataLakeValue(VARIABLE);
if (stateRaw === null || stateRaw === undefined) {
// Variable not yet in the data lake — bridge not running
applyState(null);
footEl.textContent = `Waiting for ${VARIABLE}`;
return;
}
const state = parseInt(stateRaw, 10);
// Optional: read a reason string if the bridge publishes one
// (published separately as NAMED_VALUE_FLOAT/rov_failsafe_reason)
// For now we use the default messages above.
const reason = null;
applyState(state, reason);
// Update timestamp footer
const now = new Date();
footEl.textContent = `Updated ${now.toLocaleTimeString()}`;
} catch (err) {
applyState(null);
footEl.textContent = 'Poll error — see console';
console.error('[Health Widget]', err);
}
}
// ── Start ─────────────────────────────────────────────────
// Initial delay gives the Cockpit API time to initialise
setTimeout(poll, 300);
setInterval(poll, POLL_MS);
</script>