ponfw/main.js
Jon Vanvik 9106e5a071 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>
2026-06-23 13:45:24 +02:00

821 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Electron main process for pon-fleet-upgrader.
//
// Responsibilities:
// - Own the MCMS session (cookies stay out of the renderer).
// - Expose IPC handlers that the renderer calls via window.api.*.
// - Centralize TLS cert handling for self-signed internal MCMS installs.
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const {
McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planFloodChange,
} = require('./src/mcms-api');
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */
let client = null;
let mainWindow = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
title: 'PON Fleet Upgrader',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// ---- IPC handlers ----------------------------------------------------
function requireClient() {
if (!client) throw new Error('Not logged in — call api.login first.');
return client;
}
// Format MCMS API errors for the renderer without leaking stack traces.
function toWireError(err) {
if (err instanceof McmsApiError) {
return { message: err.message, status: err.status, body: err.body };
}
return { message: err?.message || String(err) };
}
ipcMain.handle('api:login', async (_ev, { baseUrl, username, password, acceptSelfSigned }) => {
try {
client = new McmsClient({
baseUrl,
rejectUnauthorized: !acceptSelfSigned,
// Verbose logging goes to the Electron terminal (npm start output).
// Turn off by setting PON_FLEET_VERBOSE=0 in the env.
verbose: process.env.PON_FLEET_VERBOSE !== '0',
});
const result = await client.login(username, password);
return { ok: true, data: result };
} catch (err) {
client = null;
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:logout', async () => {
if (client) await client.logout();
client = null;
return { ok: true };
});
ipcMain.handle('api:listOnuConfigs', async (_ev, opts) => {
try {
const c = requireClient();
// No projection and no query — MCMS's schema validator is picky about
// projection paths that don't exist on every document version, and its
// URL-param encoding of Mongo-style queries has been unreliable across
// builds (spaces in field names, JSON escaping). We load the full fleet
// (bounded by `limit`) and filter entirely client-side.
const data = await c.listOnuConfigs({
limit: opts?.limit,
skip: opts?.skip,
});
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Fast fleet load: configs + onu-states + olt-states. Tries the bulk
* state endpoint first; if that fails, the renderer can follow up with
* api:fetchStates to fetch states per-ONU. OLT-STATE carries the
* registration bucket (Registered / Deregistered / Dying Gasp / etc.)
* per ONU — see 323-1961-306 Procedure 2 — so we load it here and tag
* each merged ONU with its `_status.status`.
*/
ipcMain.handle('api:listFleet', async (_ev) => {
try {
const c = requireClient();
const configs = await c.listAllOnuConfigs();
let states = [];
let statesError = null;
try {
states = await c.listAllOnuStates();
} catch (e) {
statesError = toWireError(e);
}
// OLT-STATE fetch is best-effort. If it fails the UI falls back to
// "unknown" status and the down-duration filter is simply unavailable.
let oltStates = [];
let oltStatesError = null;
try {
oltStates = await c.listAllOltStates();
} catch (e) {
oltStatesError = toWireError(e);
}
// Build onuId -> { status, oltMac } from OLT-STATE["ONU States"].
// An ONU should appear in exactly one bucket on exactly one OLT; in
// the rare case of duplicates we prefer a non-"Registered" bucket
// since that's the interesting signal (the newer OLT wins otherwise).
const statusByOnu = new Map();
for (const olt of oltStates) {
const buckets = olt?.['ONU States'] || {};
const oltMac = olt?._id;
for (const [bucket, ids] of Object.entries(buckets)) {
if (!Array.isArray(ids)) continue;
for (const id of ids) {
const existing = statusByOnu.get(id);
if (!existing || existing.status === 'Registered') {
statusByOnu.set(id, { status: bucket, oltMac });
}
}
}
}
const stateById = new Map(states.map((s) => [s._id, s]));
const merged = configs.map((cfg) => ({
...cfg,
_state: stateById.get(cfg._id) || null,
_status: statusByOnu.get(cfg._id) || null,
}));
return {
ok: true,
data: merged,
meta: {
configCount: configs.length,
stateCount: states.length,
oltStateCount: oltStates.length,
statesError, // null on success, error object on failure
oltStatesError, // same, for OLT-STATE fetch
},
};
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Phase 2: fetch ONU-STATE per-ONU with bounded concurrency. The web
* UI on this MCMS build hits /v3/onus/states/<id>/ individually rather
* than the bulk list endpoint, which matches what we do here. Progress
* events stream back on 'states:progress' so the UI can fill in the
* Equipment ID column as results arrive.
*/
ipcMain.handle('api:fetchStates', async (event, { onuIds, concurrency = 20 }) => {
try {
const c = requireClient();
const queue = [...onuIds];
const results = [];
let done = 0;
const total = onuIds.length;
async function worker() {
for (;;) {
const id = queue.shift();
if (id === undefined) return;
let state = null;
try {
state = await c.getOnuState(id);
} catch (e) {
// Per-ONU failure is non-fatal; the UI will just show a blank
// Equipment ID for this row.
state = null;
}
results.push({ onuId: id, state });
done += 1;
event.sender.send('states:progress', { onuId: id, state, done, total });
}
}
const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
await Promise.all(workers);
return { ok: true, data: results };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:getOnuConfig', async (_ev, { onuId }) => {
try {
const c = requireClient();
const data = await c.getOnuConfig(onuId);
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:listOnuFirmware', async () => {
try {
const c = requireClient();
const data = await c.listOnuFirmware();
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:uploadFirmware', async () => {
try {
const c = requireClient();
const picked = await dialog.showOpenDialog(mainWindow, {
title: 'Select ONU firmware (.bin)',
filters: [{ name: 'Firmware', extensions: ['bin'] }],
properties: ['openFile'],
});
if (picked.canceled || picked.filePaths.length === 0) {
return { ok: false, error: { message: 'cancelled' } };
}
const filePath = picked.filePaths[0];
const buf = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const base64 = buf.toString('base64');
// Infer version from filename (e.g. Interos-Everest-5.12.0-R-EV05120R.bin
// -> version "EV05120R"). Users can fix this up later if wrong.
const versionMatch = filename.match(/([A-Z]{2}\d{5}[A-Z]?)/);
const metadata = {
'Compatible Manufacturer': 'TIBITCOM',
'Compatible Model': ['MicroPlug ONU'],
Version: versionMatch ? versionMatch[1] : '',
};
const body = await c.uploadOnuFirmware(filename, base64, metadata);
return { ok: true, data: { filename, metadata, body } };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Dry-run planner: given a set of ONUs and a target firmware, return the
* planned write-slot + before/after for each ONU *without* mutating anything.
*/
ipcMain.handle('api:planUpgrade', async (_ev, { onuIds, targetFile, targetVersion }) => {
try {
const c = requireClient();
const plans = [];
for (const onuId of onuIds) {
const cfg = await c.getOnuConfig(onuId);
if (!cfg) {
plans.push({ onuId, error: 'ONU config not found' });
continue;
}
const plan = planUpgrade(cfg, { targetFile, targetVersion });
plans.push({
onuId,
name: cfg?.ONU?.Name || '',
address: cfg?.ONU?.Address || '',
ponMode: cfg?.ONU?.['PON Mode'] || '',
...plan.summary,
writeSlot: plan.writeSlot,
});
}
return { ok: true, data: plans };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Per-ONU execution (Procedure 7) — iterates, fetches the doc, mutates the
* FW bank fields, PUTs the full document back. This is the "one-at-a-time,
* see errors live" path.
*/
ipcMain.handle('api:executePerOnu', async (event, { onuIds, targetFile, targetVersion }) => {
try {
const c = requireClient();
const results = [];
for (let i = 0; i < onuIds.length; i++) {
const onuId = onuIds[i];
// Fire per-ONU progress back to the renderer.
event.sender.send('upgrade:progress', {
index: i,
total: onuIds.length,
onuId,
phase: 'fetching',
});
try {
const cfg = await c.getOnuConfig(onuId);
if (!cfg) throw new Error('ONU config not found');
const plan = planUpgrade(cfg, { targetFile, targetVersion });
// Apply the mutation in place on the fetched document so we PUT the
// full doc back (MCMS uses PUT = replace, not partial).
const mutated = { ...cfg, ONU: { ...cfg.ONU, ...plan.fwFields } };
event.sender.send('upgrade:progress', {
index: i,
total: onuIds.length,
onuId,
phase: 'writing',
writeSlot: plan.writeSlot,
});
await c.putOnuConfig(onuId, mutated);
results.push({ onuId, ok: true, writeSlot: plan.writeSlot });
} catch (err) {
results.push({ onuId, ok: false, error: toWireError(err) });
}
}
return { ok: true, data: results };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Bulk execution (Procedure 8) — single AUTO-TASK-CFG that MCMS schedules
* across all selected serials. Recommended path for large fleets.
*/
ipcMain.handle('api:executeBulkTask', async (_ev, opts) => {
try {
const c = requireClient();
const body = await c.createFirmwareTask(opts);
return { ok: true, data: body };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:getTaskConfig', async (_ev, { taskId }) => {
try {
const c = requireClient();
const data = await c.getTaskConfig(taskId);
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:getUpgradeStatus', async (_ev, { onuId }) => {
try {
const c = requireClient();
const data = await c.getOnuUpgradeStatus(onuId);
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Pre-flight link-health check. For each selected ONU, re-fetch the
* ONU-STATE document (so the snapshot is fresh at decision time, not
* stale from the original fleet load) and reduce it to a single
* health flag (ok / pre / post / buggy / nodata / error) plus the
* raw FEC counters and optical levels.
*
* Source fields (observed on MCMS 6.2 + Interos Everest 5.x ONUs):
* STATE.STATS["ONU-PON"]["RX Pre-FEC BER" | "RX Post-FEC BER"
* | "RX Optical Level" | "TX Optical Level"]
* STATE.STATS["OLT-PON"]["RX Pre-FEC BER" | "RX Post-FEC BER"]
*
* Streams progress on 'fec:progress' as each ONU resolves so the
* preview table can paint its pill the moment its result lands.
*/
ipcMain.handle('api:fetchFecHealth', async (event, { onuIds, concurrency = 8 }) => {
try {
const c = requireClient();
const queue = [...onuIds];
const results = [];
let done = 0;
const total = onuIds.length;
async function worker() {
for (;;) {
const id = queue.shift();
if (id === undefined) return;
let entry = { onuId: id, flag: 'nodata' };
try {
const stateDoc = await c.getOnuState(id);
const health = extractOnuHealth(stateDoc);
entry = {
onuId: id,
flag: health.flag,
detail: health.detail || '',
counters: health.counters || [],
optical: health.optical || {},
sampleTime: stateDoc?.Time || '',
};
} catch (e) {
entry = { onuId: id, flag: 'error', error: toWireError(e) };
}
results.push(entry);
done += 1;
event.sender.send('fec:progress', { ...entry, done, total });
}
}
const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
await Promise.all(workers);
return { ok: true, data: results };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Internal CSV writer used by both the plan-save (on Execute) and the
* verify-save (after a comparison run). Always quotes every cell —
* MCMS values are full of CSV-hostile characters (commas in addresses,
* embedded quotes in operator-entered names, occasional newlines).
* Always prepends a UTF-8 BOM so Excel auto-detects the encoding and
* Norwegian Æ/Ø/Å survive on Windows.
*/
function writeCsvFile({ prefix, filenameHint, headers, rows }) {
const downloadsDir = app.getPath('downloads');
const ts = new Date();
const pad = (n) => String(n).padStart(2, '0');
const stamp =
`${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}` +
`-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}`;
const safeHint = (filenameHint || 'csv')
.toString()
.replace(/[^A-Za-z0-9._-]+/g, '_')
.slice(0, 60);
const filename = `${prefix}-${safeHint}-${stamp}.csv`;
const fullPath = path.join(downloadsDir, filename);
const csvCell = (v) => {
if (v === null || v === undefined) return '';
const s = typeof v === 'string' ? v : String(v);
return '"' + s.replace(/"/g, '""') + '"';
};
const csvRow = (cells) => cells.map(csvCell).join(',');
const lines = [csvRow(headers)];
for (const row of rows) lines.push(csvRow(headers.map((h) => row[h])));
const body = '' + lines.join('\r\n') + '\r\n';
fs.writeFileSync(fullPath, body, { encoding: 'utf8' });
return { path: fullPath, filename, rowCount: rows.length };
}
/**
* Save an upgrade plan to a timestamped CSV in the user's Downloads
* folder. Called automatically by the renderer the moment the operator
* clicks Execute, so there's always a paper trail of what got
* scheduled — even if the MCMS write later fails.
*/
ipcMain.handle('api:savePlanCsv', async (_ev, { rows, filenameHint, headers }) => {
try {
if (!Array.isArray(rows) || !Array.isArray(headers)) {
throw new Error('rows and headers are required arrays');
}
const data = writeCsvFile({
prefix: 'pon-upgrade', filenameHint, headers, rows,
});
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Generic CSV save. Used by the verify flow with prefix='pon-verify'.
* Same writeCsvFile semantics as the plan save.
*/
ipcMain.handle('api:saveCsvFile', async (_ev, { rows, filenameHint, headers, prefix = 'pon-csv' }) => {
try {
if (!Array.isArray(rows) || !Array.isArray(headers)) {
throw new Error('rows and headers are required arrays');
}
const safePrefix = String(prefix).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40) || 'pon-csv';
const data = writeCsvFile({ prefix: safePrefix, filenameHint, headers, rows });
return { ok: true, data };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Open an existing CSV from disk (default: Downloads). Returns the raw
* text so the renderer can parse it with its own CSV parser. We don't
* parse here because the renderer already needs to know the column
* layout to build comparisons.
*/
ipcMain.handle('api:openCsvFile', async () => {
try {
const picked = await dialog.showOpenDialog(mainWindow, {
title: 'Open upgrade plan CSV',
defaultPath: app.getPath('downloads'),
filters: [{ name: 'CSV', extensions: ['csv'] }],
properties: ['openFile'],
});
if (picked.canceled || !picked.filePaths.length) {
return { ok: false, error: { message: 'cancelled' } };
}
const filePath = picked.filePaths[0];
const text = fs.readFileSync(filePath, 'utf8');
return { ok: true, data: { path: filePath, text } };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Per-ONU snapshot fetch for the verify flow: pulls BOTH the ONU-STATE
* (for current FEC counters and optical levels) AND the ONU-CFG (so we
* can verify the active firmware version actually flipped to the
* target). Streams progress on 'verify:progress' so the table can
* update incrementally on big lists.
*/
ipcMain.handle('api:fetchOnuSnapshot', async (event, { onuIds, concurrency = 8 }) => {
try {
const c = requireClient();
const queue = [...onuIds];
const results = [];
let done = 0;
const total = onuIds.length;
async function worker() {
for (;;) {
const id = queue.shift();
if (id === undefined) return;
let entry = { onuId: id };
try {
const [stateDoc, cfgDoc] = await Promise.all([
c.getOnuState(id).catch(() => null),
c.getOnuConfig(id).catch(() => null),
]);
const health = extractOnuHealth(stateDoc);
entry = {
onuId: id,
flag: health.flag,
counters: health.counters || [],
optical: health.optical || {},
sampleTime: stateDoc?.Time || '',
cfg: cfgDoc,
};
} catch (e) {
entry = { onuId: id, flag: 'error', error: toWireError(e) };
}
results.push(entry);
done += 1;
event.sender.send('verify:progress', { onuId: id, done, total, flag: entry.flag });
}
}
const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
await Promise.all(workers);
return { ok: true, data: results };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Bulk delete ONUs from MCMS. For each ONU we delete the CFG document
* (Procedure 43 in the PON Manager User Guide), then best-effort delete
* the STATE document so stale Equipment-ID/registration entries don't
* hang around. STATE deletion failures are ignored — the OLT will
* rewrite it on next registration anyway, but CFG deletion is the
* authoritative action.
*
* Progress streams back on 'delete:progress' so the renderer can tick
* through a large selection without locking up. Concurrency is kept low
* (default 5) because DELETE is more disruptive than GET and we don't
* want to stampede the MCMS backend.
*/
ipcMain.handle('api:deleteOnus', async (event, { onuIds, concurrency = 5 }) => {
try {
const c = requireClient();
const queue = [...onuIds];
const results = [];
let done = 0;
const total = onuIds.length;
async function worker() {
for (;;) {
const id = queue.shift();
if (id === undefined) return;
let ok = false;
let error = null;
try {
await c.deleteOnuConfig(id);
ok = true;
// State deletion is best-effort and does not affect `ok`.
try { await c.deleteOnuState(id); } catch (_) { /* ignore */ }
} catch (e) {
error = toWireError(e);
}
results.push({ onuId: id, ok, error });
done += 1;
event.sender.send('delete:progress', { onuId: id, ok, error, done, total });
}
}
const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
await Promise.all(workers);
return { ok: true, data: results };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
// ---- OLT PON flooding mode -------------------------------------------
/**
* Bulk-load every OLT-CFG, summarize each one's NNI Networks against
* the given TAG MATCH pattern, and return a flat row per OLT.
*/
ipcMain.handle('api:listOlts', async (_ev, opts) => {
try {
const c = requireClient();
const tagPattern = (opts && opts.tagPattern) || '';
const cfgs = await c.listAllOltConfigs();
const rows = cfgs.map((cfg) => {
const summary = summarizeOltNniNetworks(cfg, tagPattern);
const counts = { private: 0, auto: 0, unknown: 0 };
for (const s of summary) counts[s.mode] = (counts[s.mode] || 0) + 1;
return {
_id: cfg._id,
name: cfg?.OLT?.Name || '',
location: cfg?.OLT?.Location || '',
ponMode: cfg?.OLT?.['PON Mode'] || '',
activeFwVersion: cfg?.OLT?.['FW Bank Versions']?.[cfg?.OLT?.['FW Bank Ptr']] || '',
matchingNni: summary,
counts,
};
});
return { ok: true, data: rows };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Apply a planned flooding mode change to a list of OLT IDs. For each
* OLT we GET the current CFG (to avoid stomping concurrent edits),
* run planFloodChange, and PUT the result back. Streams progress.
*
* Concurrency is kept low (default 3) — OLT writes carry a much larger
* blast radius than ONU reads, so we deliberately stay below the ONU
* delete (5) and state-fetch (8) limits.
*/
ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try {
const c = requireClient();
const {
oltIds, tagPattern, fromMode = 'private',
targetMode = 'auto', ponFloodIdValue = 0,
concurrency = 3,
} = opts || {};
const queue = [...oltIds];
const results = [];
let done = 0;
const total = oltIds.length;
async function worker() {
for (;;) {
const id = queue.shift();
if (id === undefined) return;
let entry = { oltId: id, ok: false };
try {
const cfg = await c.getOltConfig(id);
if (!cfg) throw new Error('OLT not found');
const plan = planFloodChange(cfg, {
tagPattern, fromMode, targetMode, ponFloodIdValue,
});
if (plan.changes.length === 0) {
entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
} else {
await c.putOltConfig(id, plan.newDoc);
entry = { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged };
}
} catch (e) {
entry = { oltId: id, ok: false, error: toWireError(e) };
}
results.push(entry);
done += 1;
event.sender.send('olt-flood:progress', { ...entry, done, total });
}
}
const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
await Promise.all(workers);
return { ok: true, data: results };
} catch (err) {
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) };
}
});