<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>pamoja status</title>
<!-- The floor page renders with the smallest possible script. With scripting off it
falls back to the device's server-rendered, meta-refreshing table at /lite. -->
<noscript><meta http-equiv="refresh" content="0; url=lite" /></noscript>
<style>
:root { --bg: #f6f7fb; --card: #fff; --line: #c8cedd; --text: #11151f; --muted: #5a6478; --ok: #137a4b; --warn: #8a5a00; --alarm: #b3261e; }
@media (prefers-color-scheme: dark) { :root { --bg: #0b0f1a; --card: #131a2a; --line: #2a3552; --text: #eef2fb; --muted: #98a3bd; --ok: #34d399; --warn: #fbbf24; --alarm: #f87171; } }
* { box-sizing: border-box; }
body { margin: 0; padding: 1rem; background: var(--bg); color: var(--text); font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
h1 { margin: 0; font-size: 1.3rem; letter-spacing: 0.02em; }
h2 { margin: 1.6rem 0 0.5rem; font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 0.35rem; }
h3 { margin: 0.9rem 0 0.35rem; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.status { font-weight: 700; font-size: 1.05rem; }
.badge { font-size: 0.78rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid currentColor; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .alarm { color: var(--alarm); }
.muted { color: var(--muted); font-size: 0.85rem; margin: 0.1rem 0; }
.count { color: var(--muted); font-size: 0.78rem; font-weight: 400; }
.bars { letter-spacing: 0.12em; }
.stats { font-size: 0.8rem; margin-top: 0.35rem; }
section.group { margin: 0.2rem 0 0.9rem; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
th, td { text-align: start; padding: 0.55rem 0.7rem; border-bottom: 1px solid var(--line); }
th.reading, td.reading, th.state, td.state { text-align: end; }
th.reading { width: 34%; } th.state { width: 22%; }
.unit { color: var(--muted); }
thead th { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
tbody th { font-weight: 600; }
td.ok, td.warn, td.alarm { font-weight: 700; white-space: nowrap; }
footer { margin-top: 1.5rem; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; }
a { color: inherit; }
</style>
</head>
<body>
<header>
<h1>pamoja</h1>
<p id="overall" class="status" aria-live="polite">Loading</p>
</header>
<main id="root"><p class="muted">Loading the latest readings…</p></main>
<footer>
<a href="./">Full dashboard</a>
<span id="stamp" class="muted"></span>
</footer>
<script>
(function () {
var root = document.getElementById('root');
var overall = document.getElementById('overall');
var stamp = document.getElementById('stamp');
// Every status is carried by a word, a shape, and a color together, so it reads on a
// washed-out screen, in a second language, and with color blindness.
function bits(s) {
if (s === 'alarm') return ['ALARM', '✕', 'alarm'];
if (s === 'warn') return ['WARN', '▲', 'warn'];
return ['OK', '✓', 'ok'];
}
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// Short, stable English link labels, matching the server-rendered /lite floor.
var LINKS = { lora: 'LoRa', wifi: 'Wi-Fi', cellular: 'Cellular', nbiot: 'NB-IoT', satellite: 'Satellite', ethernet: 'Ethernet', mesh: 'Mesh' };
// Canonical units abbreviated to conventional symbols, so a reading reads like an
// instrument; an unmapped unit falls back to its raw name, a non-physical one to none.
var UNITS = { celsius: '°C', fahrenheit: '°F', kelvin: 'K', percent: '%', volt: 'V', millivolt: 'mV', ampere: 'A', milliampere: 'mA', watt: 'W', kilowatt: 'kW', watt_hour: 'Wh', kilowatt_hour: 'kWh', hectopascal: 'hPa', pascal: 'Pa', kilopascal: 'kPa', meter: 'm', millimeter: 'mm', centimeter: 'cm', kilometer: 'km', meter_per_second: 'm/s', kilometer_per_hour: 'km/h', liter: 'L', liter_per_minute: 'L/min', liter_per_hour: 'L/h', lux: 'lx', decibel: 'dB', hertz: 'Hz', degree: '°', count: '', record: '', state: '' };
function unitSym(u) { return u in UNITS ? UNITS[u] : u; }
function num(v) { return String(Math.round(v * 100) / 100); }
function reading(r) {
if (r.state) { var p = r.state.split('.'); return esc(p[p.length - 1]); }
var u = unitSym(r.unit);
return num(r.value) + (u ? ' <span class="unit">' + esc(u) + '</span>' : '');
}
// A signal-strength meter of filled and empty dots, 0..=4: reads without color and
// does not look like a sensor count the way a bare "3/4" can.
function barsStr(n) { n = Math.max(0, Math.min(4, n | 0)); var s = ''; for (var i = 0; i < 4; i++) s += i < n ? '●' : '○'; return s; }
function label(k) { return esc(String(k).replace(/_/g, ' ')); }
function statText(r) { if (r.state) { var p = r.state.split('.'); return esc(p[p.length - 1]); } return num(r.value); }
function rank(s) { return s === 'alarm' ? 2 : (s === 'warn' ? 1 : 0); }
function worst(a, b) { return rank(b) > rank(a) ? b : a; }
function render(s) {
var o = bits(s.status);
overall.className = 'status ' + o[2];
overall.textContent = o[1] + ' ' + o[0];
var html = '';
var orgs = s.orgs || [];
for (var i = 0; i < orgs.length; i++) {
var org = orgs[i];
var groups = org.groups || [];
var os = 'ok';
for (var g0 = 0; g0 < groups.length; g0++) os = worst(os, groups[g0].status || 'ok');
var ob = bits(os);
html += '<section class="org"><h2>' + esc(org.name) + ' <span class="badge ' + ob[2] + '">' + ob[1] + ' ' + ob[0] + '</span> <span class="count">' + groups.length + ' group' + (groups.length === 1 ? '' : 's') + '</span></h2>';
for (var j = 0; j < groups.length; j++) {
var g = groups[j];
var gb = bits(g.status);
var all = g.sensors || [];
var sensors = all.filter(function (x) { return !(x.reading && x.reading.stat); });
var stats = all.filter(function (x) { return x.reading && x.reading.stat; });
var kind = g.link ? (LINKS[g.link.kind] || esc(g.link.kind)) : '';
var online = g.link && g.link.online ? 'online' : 'offline';
html += '<section class="group"><h3>' + esc(g.name) + ' <span class="badge ' + gb[2] + '">' + gb[1] + ' ' + gb[0] + '</span></h3>';
html += '<p class="muted">' + kind + ' <span class="bars">' + barsStr(g.link ? g.link.strength : 0) + '</span> · ' + online + ' · ' + sensors.length + ' sensor' + (sensors.length === 1 ? '' : 's') + '</p>';
if (sensors.length) {
html += '<table><thead><tr><th>Sensor</th><th class="reading">Reading</th><th class="state">Status</th></tr></thead><tbody>';
for (var k = 0; k < sensors.length; k++) {
var se = sensors[k];
var rb = bits(se.reading.status);
html += '<tr><th scope="row">' + esc(se.id) + '</th><td class="reading">' + reading(se.reading) + '</td><td class="state ' + rb[2] + '">' + rb[1] + ' ' + rb[0] + '</td></tr>';
}
html += '</tbody></table>';
}
if (stats.length) {
html += '<p class="muted stats">' + stats.map(function (x) { return label(x.reading.key) + ' ' + statText(x.reading); }).join(' · ') + '</p>';
}
html += '</section>';
}
html += '</section>';
}
root.innerHTML = html || '<p class="muted">No sensors are reporting yet.</p>';
stamp.textContent = 'Updated ' + new Date().toLocaleTimeString();
}
function unreachable() {
overall.className = 'status warn';
overall.textContent = '▲ Device unreachable';
}
var timer = null;
function stop() { if (timer) { clearInterval(timer); timer = null; } }
function get(url, ok, fail) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function () {
if (x.readyState !== 4) return;
if (x.status >= 200 && x.status < 300) { try { ok(JSON.parse(x.responseText)); return; } catch (e) {} }
fail();
};
x.onerror = fail;
x.send();
}
// A live device: show the snapshot, then poll for fresh readings so the values move.
// A transient poll failure keeps the last frame rather than dropping to the fallback.
function goLive(first) {
stop();
render(first);
timer = setInterval(function () { get('/state', render, function () {}); }, 3000);
}
// A static host (the showcase) has no device: replay the bundled multi-frame snapshot on
// the live cadence, so the floor animates like the device feed instead of freezing.
function goStatic() {
stop();
get('./state.normal.json', function (d) {
var frames = d && d.length ? d : [d];
var i = 0;
render(frames[0]);
if (frames.length > 1) timer = setInterval(function () { i = (i + 1) % frames.length; render(frames[i]); }, 2500);
}, unreachable);
}
// Probe the device once, then choose the live or static source (as the full app does).
get('/state', goLive, goStatic);
})();
</script>
</body>
</html>