// 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// 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) }; } });