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

106
main.js
View file

@ -12,6 +12,7 @@ const {
McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planFloodChange,
} = require('./src/mcms-api');
const { summarizeDashboard } = require('./src/dashboard');
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */
@ -709,112 +710,21 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
// ---- Dashboard telemetry --------------------------------------------
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;
}
/**
* Network-wide telemetry for the Dashboard tab, modeled on the PONGo iOS
* app's DashboardViewModel. The "light" path is cheap everything below
* comes from the small OLT-STATE docs:
* - OLT count
* - ONU total + registration-state breakdown (OLT-STATE "ONU States"
* buckets, dev guide Procedure 2)
* - aggregate traffic: OLT TX BW = downstream, RX BW = upstream
* (STATS["OLT-PON"]["TX|RX BW Ethernet Rate bps"])
* - OLTs with the laser shut down (OLT["Laser Shutdown"] != "Laser ON")
* Controllers count is best-effort (not on every build).
*
* The heavy per-ONU optical/FEC scan (pull every ONU-STATE) runs only
* when `extraStats` is set same opt-in as the iOS "Rx scan" toggle.
* Network-wide telemetry for the Dashboard tab. Aggregation lives in the
* shared, pure src/dashboard.js (so the web server computes identically);
* this handler just fetches the inputs. Light by default (small OLT-STATE
* docs); the per-ONU optical/FEC scan runs only when `extraStats` is set.
*/
ipcMain.handle('api:dashboardStats', async (_ev, opts) => {
try {
const c = requireClient();
const extraStats = !!(opts && opts.extraStats);
const olts = await c.listAllOltStates();
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.
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 });
}
}
// Controllers — best-effort (some builds don't expose this).
const onuStates = extraStats ? await c.listAllOnuStates() : null;
let controllerCount = null;
try {
controllerCount = (await c.listAllControllerConfigs()).length;
} catch (_) { controllerCount = null; }
// Opt-in heavy scan: every ONU-STATE for optical + FEC health.
let optical = { available: false, abnormalRx: [], abnormalRxCount: 0 };
let fecCounts = null;
if (extraStats) {
const states = await c.listAllOnuStates();
const RX_LOW = -28, RX_HIGH = -10; // PONGo OpticalThreshold.rxAbnormal
const abnormalRx = [];
let available = false;
fecCounts = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 };
for (const st of states) {
const health = extractOnuHealth(st);
if (fecCounts[health.flag] !== undefined) fecCounts[health.flag] += 1;
const rx = health.optical && typeof health.optical.rx === 'number' ? health.optical.rx : null;
if (rx !== null) {
available = true;
if (rx < RX_LOW || rx > RX_HIGH) {
abnormalRx.push({ onuId: st._id, rx, name: st?.ONU?.Name || '' });
}
}
}
optical = { available, abnormalRx, abnormalRxCount: abnormalRx.length };
}
return {
ok: true,
data: {
onuTotal, oltCount, controllerCount,
onuCounts,
traffic: trafficSeen ? { downBps, upBps } : null,
lasersOff,
optical,
fecCounts,
extraStats,
sampledAt: new Date().toISOString(),
},
};
try { controllerCount = (await c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ }
return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) };
} catch (err) {
return { ok: false, error: toWireError(err) };
}