pamoja-dashboard 0.1.17

Local-first device dashboard for pamoja: a node serves a hand-built, localized web UI over its own hotspot from a language-neutral state snapshot, fully offline.
Documentation
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        }
        // 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>