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>
821 lines
28 KiB
JavaScript
821 lines
28 KiB
JavaScript
// 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) };
|
||
}
|
||
});
|