Add top tabs + telemetry Dashboard + animated edge glow

Restructure the UI behind top-level tabs (Dashboard / ONU / OLT) so more
features have room to land. showView() now drives the active tab via a
VIEW_TO_TAB map, so sub-views (campaign/verify) keep the ONU tab lit.
Tabs appear on login (landing on Dashboard) and hide on logout.

Dashboard: a read-only network telemetry view modeled on the PONGo iOS
app's DashboardViewModel. New api:dashboardStats aggregates, all in
main.js (renderer just formats):
- counts: ONUs, OLTs, controllers (controllers best-effort via
  /v3/controllers/configs/)
- aggregate traffic (OLT TX BW = downstream, RX BW = upstream)
- health: OLTs with laser off; abnormal-Rx count (rx < -28 || > -10)
- ONU registration-state breakdown (clicking a state deep-links to the
  ONU tab pre-filtered)
- opt-in detailed scan: per-ONU optical + FEC-health distribution
fmtBps is a port of the iOS Format.bps.

Edge glow: #edge-glow draws a neon conic-gradient beam masked to a 3px
border ring and spins an @property --beam-angle 0->360deg, so a glow
travels around the screen edge. Honors prefers-reduced-motion.

mcms-api: add listAllControllerConfigs.
Docs: CLAUDE.md section 15 + README + IPC table.

Verified: npm run check passes; rendered the Dashboard + tabs + edge beam
offscreen (beam frozen on the right edge to confirm it follows the border).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 13:45:24 +02:00
parent 7cc9649f03
commit 9106e5a071
8 changed files with 533 additions and 12 deletions

113
main.js
View file

@ -706,3 +706,116 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
return { ok: false, error: toWireError(err) };
}
});
// ---- 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.
*/
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).
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(),
},
};
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});