ponfw/main.js
Jon Vanvik 2046fad0f8 onu detail polish + clickable OLT detail modal
- Fix alarm card overflow: alarm rows stack (severity+text on one line that
  wraps, meta below) instead of squeezing the text to ~0 width next to the
  nowrap meta — which had made long alarm text render one char per line in
  narrow columns. Also min-width:0 on cards/kv so long IPv6/descr strings
  don't blow out the grid track.
- Combine Parent OLT + Upstream switch into one "Parent OLT & uplink" card.
- ONU list/search de-compacted: add equipment/version + PON-mode filters;
  two-line rows (serial+status, then equipment · version · last-seen).
- UNI card labels the speed as configured ("cfg Auto/Auto"); negotiated
  speed isn't in the payloads — link up/down still comes from LAN-LOS.

Clickable parent OLT -> OLT detail modal (showOltModal):
- new src/oltdetail.js (pure, shared; reuses summarizeAlarms) + getOltState
  / getOltAlarms client methods + api:getOltDetail (main + web + bridges).
- shows identity, traffic + util, ASIC temperature, ONU counts, laser,
  alarms, upstream switch; plus a connected-ONU list with per-ONU
  FEC/optical built from the loaded fleet (filtered by _status.oltMac),
  each row clickable back into that ONU's detail.

Docs: CLAUDE.md §17 + IPC table.

Verified: node --check; oltdetail.js unit-tested 9/9 (fw active-bank,
traffic down=TX/up=RX, util, ASIC temp, ONU counts, laser, LLDP, alarms);
rendered the OLT modal + richer list offscreen and confirmed the alarm-text
wrap fix (alarm head ~1-2 lines, not vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:10:34 +02:00

795 lines
27 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, planNniEdit,
} = require('./src/mcms-api');
const { summarizeDashboard } = require('./src/dashboard');
const { summarizeCpe } = require('./src/cpe');
const { lookupOui } = require('./src/oui');
const { summarizeOnuDetail } = require('./src/onudetail');
const { summarizeOltDetail } = require('./src/oltdetail');
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) };
}
});
/**
* CPE (customer router) behind an ONU, for the ONU info page: DHCPv4/v6
* lease state + renewal health, plus the CPE MAC's vendor (OUI lookup,
* cached ~1 month). Returns null when there's no CPE record.
*/
ipcMain.handle('api:getOnuCpe', async (_ev, { onuId }) => {
try {
const c = requireClient();
const doc = await c.getCpe(onuId);
const cpe = summarizeCpe(doc);
if (cpe) cpe.cpeVendor = await lookupOui(cpe.cpeMac);
return { ok: true, data: cpe };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Rich per-ONU detail for the info page: firmware banks, UNI-ETH ports +
* last Ethernet LOS, alarms, parent OLT, upstream switch (LLDP), a traffic
* time-series, and the CPE summary — one round of fetches summarized via
* src/onudetail.js + src/cpe.js.
*/
ipcMain.handle('api:getOnuDetail', async (_ev, { onuId, windowMin = 60 }) => {
try {
const c = requireClient();
const [cfg, alarmsDoc, statsSeries] = await Promise.all([
c.getOnuConfig(onuId).catch(() => null),
c.getOnuAlarms(onuId).catch(() => null),
c.getOnuStatsSeries(onuId, { minutes: windowMin }).catch(() => []),
]);
const oltMac = alarmsDoc?.OLT?.['MAC Address'];
const oltCfg = oltMac ? await c.getOltConfig(oltMac).catch(() => null) : null;
const detail = summarizeOnuDetail({ cfg, alarmsDoc, statsSeries, oltCfg, windowMin });
const cpeDoc = await c.getCpe(onuId).catch(() => null);
detail.cpe = summarizeCpe(cpeDoc);
if (detail.cpe) detail.cpe.cpeVendor = await lookupOui(detail.cpe.cpeMac);
return { ok: true, data: detail };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Rich OLT detail (clicked from an ONU's Parent OLT card): identity +
* traffic + temperature + ONU counts + laser + alarms + upstream switch.
*/
ipcMain.handle('api:getOltDetail', async (_ev, { oltMac }) => {
try {
const c = requireClient();
const [oltCfg, oltState, alarmsDoc] = await Promise.all([
c.getOltConfig(oltMac).catch(() => null),
c.getOltState(oltMac).catch(() => null),
c.getOltAlarms(oltMac).catch(() => null),
]);
return { ok: true, data: summarizeOltDetail({ oltCfg, oltState, alarmsDoc }) };
} 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 set of NNI-service edits (flood mode and/or DHCPv4/DHCPv6 filter)
* to a list of OLT IDs. For each OLT we GET the current CFG (to avoid
* stomping concurrent edits), run planNniEdit, and PUT the result back.
* Streams progress. (Channel name kept as executeFloodChange for wire
* compatibility.)
*
* 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, flood = null, dhcpv4 = null, dhcpv6 = null,
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 = planNniEdit(cfg, { tagPattern, flood, dhcpv4, dhcpv6 });
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 --------------------------------------------
/**
* 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 onuStates = extraStats ? await c.listAllOnuStates() : null;
let controllerCount = null;
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) };
}
});