initial commit

This commit is contained in:
Jon Vanvik 2026-05-26 11:16:18 +02:00
commit 0eb9b55717
11 changed files with 3969 additions and 0 deletions

619
main.js Normal file
View file

@ -0,0 +1,619 @@
// 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 } = 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) };
}
});