Add multi-user web server variant (browser UI behind a reverse proxy)

New web/ subfolder: a Node http server that serves the SAME tooling as the
Electron app to any browser, with per-session MCMS credentials so several
operators can use it at once behind a TLS reverse proxy. Mobile-responsive.

Not a fork — it reuses the core and the UI:
- serves renderer/app.js + app.css verbatim and rewrites index.html on the
  fly (mobile viewport + browser window.api shim + responsive overlay)
- web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming;
  Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob
- web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry,
  routes mirroring the IPC handlers, NDJSON for the 6 streaming ops
- pure Node http, no new deps (borrows ../node_modules tough-cookie)

Shared improvements (benefit both apps):
- src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses
  it); adds abnormal-Tx detection alongside abnormal-Rx
- renderer: dashboard health stats are now clickable — abnormal Rx / Tx /
  lasers-off pop a list of the offending devices (like the iOS app), via a
  new showListModal

Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16.

Verified: node --check all; started the server and confirmed transformed
index, shared-asset serving, 401 auth gate, login (per-session client), and
NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 14:07:23 +02:00
parent 9106e5a071
commit 96b0264179
10 changed files with 1096 additions and 109 deletions

110
src/dashboard.js Normal file
View file

@ -0,0 +1,110 @@
// Shared dashboard telemetry aggregation. Pure — no Electron, no HTTP — so
// both the Electron main process (api:dashboardStats) and the web server
// (/api/dashboardStats) compute identical numbers. Modeled on the PONGo
// iOS app's DashboardViewModel.
const { extractOnuHealth } = require('./mcms-api');
function toNum(v) {
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
}
// PONGo OpticalThreshold (dBm): Rx in-spec is -28..-10; Tx floor is 3.
const RX_LOW = -28, RX_HIGH = -10, TX_LOW = 3;
/**
* @param {object} args
* @param {object[]} args.olts OLT-STATE docs (listAllOltStates).
* @param {object[]|null} [args.onuStates] ONU-STATE docs for the heavy
* optical/FEC scan, or null/undefined to skip it (the light default).
* @param {number|null} [args.controllerCount]
*/
function summarizeDashboard({ olts = [], onuStates = null, controllerCount = null } = {}) {
const oltCount = olts.length;
// Registration map: onuId -> bucket (prefer a non-Registered bucket on
// duplicates — the interesting signal), matching api:listFleet.
const stateByOnu = new Map();
for (const olt of olts) {
const buckets = olt?.['ONU States'] || {};
for (const [bucket, ids] of Object.entries(buckets)) {
if (!Array.isArray(ids)) continue;
for (const id of ids) {
const existing = stateByOnu.get(id);
if (!existing || existing === 'Registered') stateByOnu.set(id, bucket);
}
}
}
const onuTotal = stateByOnu.size;
const stateCounts = {};
for (const s of stateByOnu.values()) stateCounts[s] = (stateCounts[s] || 0) + 1;
const onuCounts = Object.entries(stateCounts)
.map(([state, count]) => ({ state, count }))
.sort((a, b) => b.count - a.count);
// Aggregate traffic + laser state from OLT-STATE.
// OLT TX BW = downstream (OLT→ONU), RX BW = upstream (ONU→OLT).
let downBps = 0, upBps = 0, trafficSeen = false;
const lasersOff = [];
for (const olt of olts) {
const pon = olt?.STATS?.['OLT-PON'] || {};
const tx = toNum(pon['TX BW Ethernet Rate bps']);
const rx = toNum(pon['RX BW Ethernet Rate bps']);
if (tx !== null) { downBps += tx; trafficSeen = true; }
if (rx !== null) { upBps += rx; trafficSeen = true; }
const laser = olt?.OLT?.['Laser Shutdown'];
if (typeof laser === 'string' && laser.toLowerCase() !== 'laser on') {
lasersOff.push({ oltId: olt._id, name: olt?.OLT?.Name || '', laser });
}
}
// Opt-in heavy scan: every ONU-STATE for optical + FEC health. Keeps the
// actual offender lists so the dashboard can drill down without re-fetch.
let optical = {
available: false,
abnormalRx: [], abnormalRxCount: 0,
abnormalTx: [], abnormalTxCount: 0,
};
let fecCounts = null;
if (Array.isArray(onuStates)) {
const abnormalRx = [], abnormalTx = [];
let available = false;
fecCounts = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 };
for (const st of onuStates) {
const health = extractOnuHealth(st);
if (fecCounts[health.flag] !== undefined) fecCounts[health.flag] += 1;
const o = health.optical || {};
const rx = typeof o.rx === 'number' ? o.rx : null;
const tx = typeof o.tx === 'number' ? o.tx : null;
const name = st?.ONU?.Name || '';
if (rx !== null) {
available = true;
if (rx < RX_LOW || rx > RX_HIGH) abnormalRx.push({ onuId: st._id, rx, name });
}
if (tx !== null) {
available = true;
if (tx < TX_LOW) abnormalTx.push({ onuId: st._id, tx, name });
}
}
optical = {
available,
abnormalRx, abnormalRxCount: abnormalRx.length,
abnormalTx, abnormalTxCount: abnormalTx.length,
};
}
return {
onuTotal, oltCount, controllerCount,
onuCounts,
traffic: trafficSeen ? { downBps, upBps } : null,
lasersOff,
optical,
fecCounts,
extraStats: Array.isArray(onuStates),
sampledAt: new Date().toISOString(),
};
}
module.exports = { summarizeDashboard };