ponfw/main.js
Jon Vanvik 80fd7e8d37 Add bulk OLT PON flooding-mode tool; restore README; docs
Adds a second bulk workflow alongside the firmware upgrader: changing
PON flooding mode (private<->auto) on OLT NNI services across many OLTs.

Flooding mode is encoded by the presence of the "PON FLOOD ID" key on
each OLT-CFG["NNI Networks"] entry (present = private, absent = auto).
private->auto deletes the key; auto->private sets it to 0. The rest of
the OLT-CFG is preserved (PUT-as-replace, same as ONU-CFG).

- src/mcms-api.js: listAllOltConfigs / getOltConfig / putOltConfig, plus
  pure helpers floodModeOfNni, summarizeOltNniNetworks, planFloodChange
  (planFloodChange clones and never mutates its input)
- main.js: api:listOlts + api:executeFloodChange IPC handlers
  (concurrency 3, GET->plan->PUT, streams olt-flood:progress)
- preload.js: expose both channels + onFloodProgress
- renderer: OLT inspector view (#view-olts), two-step APPLY confirm,
  auto-saved pon-olt-flood-*.csv result report
- Restore README.md (lost to a filesystem error) and document the new
  feature in README.md and CLAUDE.md (new section 14)

Verified: npm run check passes; pure helpers unit-tested for the
no-mutation, idempotency, and rollback invariants.

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

708 lines
24 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) };
}
});