// Renderer-side app logic. Uses window.api exposed by preload.js. // No framework — vanilla DOM. // -------- State -------- const state = { fleet: [], // array of ONU-CFG documents (shallow — see main.js projection) filtered: [], selected: new Set(), firmwareList: [], // GET /files/onu-firmware/ result plan: [], // result of api.planUpgrade fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? } campaignMode: 'task', // 'task' | 'peronu' verify: null, // { sourcePath, beforeRows, snapshotById, comparisons } olts: [], // result of listOlts oltFiltered: [], // current visible OLTs oltSelected: new Set(), dashboard: null, // result of dashboardStats (null until first load) onuInfoSelected: null, // onuId currently shown on the ONU info page }; // -------- Helpers -------- const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); // Which top tab "owns" each view, so sub-views (campaign/verify) keep the // ONU tab highlighted and showView() callers don't each have to know. const VIEW_TO_TAB = { '#view-dashboard': 'dashboard', '#view-onu-info': 'onu', '#view-fleet': 'onu', '#view-campaign': 'onu', '#view-verify': 'onu', '#view-olts': 'olt', }; function showView(id) { for (const v of $$('.view')) v.classList.add('hidden'); $(id).classList.remove('hidden'); const tab = VIEW_TO_TAB[id] || null; for (const t of $$('.tab')) t.classList.toggle('active', t.dataset.tab === tab); // Sub-tabs (within ONU): the two switchable views are Info and Fleet; // campaign/verify are sub-flows reached from Fleet, so keep Fleet lit there. const subId = (id === '#view-campaign' || id === '#view-verify') ? '#view-fleet' : id; for (const st of $$('.subtab')) st.classList.toggle('active', st.dataset.subview === subId); } function activeBank(cfg) { const ptr = cfg?.ONU?.['FW Bank Ptr']; return typeof ptr === 'number' ? ptr : null; } function versionAt(cfg, slot) { return cfg?.ONU?.['FW Bank Versions']?.[slot] || ''; } function fileAt(cfg, slot) { return cfg?.ONU?.['FW Bank Files']?.[slot] || ''; } function activeVersion(cfg) { const ptr = activeBank(cfg); if (ptr === null || ptr === 65535) return '(unset)'; return versionAt(cfg, ptr) || '(empty)'; } function inactiveVersion(cfg) { const ptr = activeBank(cfg); if (ptr === null || ptr === 65535) return '(unset)'; const other = ptr === 0 ? 1 : 0; return versionAt(cfg, other) || '(empty)'; } // Equipment ID is device-reported and lives on ONU-STATE (joined into // `_state` by main.js). Some deployments also surface it under ONU-CFG // directly; check both paths so the filter works regardless. function equipmentId(cfg) { return ( cfg?._state?.ONU?.['Equipment ID'] || cfg?._state?.['Equipment ID'] || cfg?.ONU?.['Equipment ID'] || '' ); } function vendor(cfg) { return ( cfg?._state?.ONU?.Vendor || cfg?._state?.Vendor || cfg?.ONU?.Vendor || '' ); } // Registration bucket from OLT-STATE["ONU States"] (see 323-1961-306 // Procedure 2). null means the ONU didn't appear in any OLT bucket — // usually a preprovisioned ONU that has never registered. function onuStatus(cfg) { return cfg?._status?.status || null; } // Statuses we consider "down" for the bulk-down filter + delete workflow. // "Disallowed *" are admin/config states rather than fault states, but // from a "this ONU isn't carrying traffic" perspective they behave the // same — group them in when the user picks the "Down" aggregate. const DOWN_STATUSES = new Set([ 'Deregistered', 'Dying Gasp', 'Disabled', 'Disallowed Admin', 'Disallowed Error', 'Disallowed Reg ID', ]); // Timestamp of the most recent MCMS update to this ONU's STATE doc. For // a down ONU this is a proxy for "last heard from" — the OLT refreshes // ONU-STATE on registration events, alarms, and periodic stats, so an // ONU that's been dark for a month will have a correspondingly old Time. function lastSeenDate(cfg) { const t = cfg?._state?.Time; if (!t) return null; // MCMS emits timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC. // Replace the space with 'T' and append 'Z' so Date parses as UTC. const iso = String(t).replace(' ', 'T') + (String(t).endsWith('Z') ? '' : 'Z'); const d = new Date(iso); return isNaN(d.getTime()) ? null : d; } function daysSince(date) { if (!date) return null; return (Date.now() - date.getTime()) / 86400000; } function formatRelative(days) { if (days === null || days === undefined) return ''; if (days < 1) return `${Math.round(days * 24)}h ago`; if (days < 60) return `${Math.round(days)}d ago`; return `${Math.round(days / 30)}mo ago`; } // FEC health pill. The mapping mirrors the user-guide green-LED // criteria (323-1961-302 p149): post-FEC nonzero is the bright red // signal, pre-FEC alone (post=0) is "FEC is doing its job, marginal" // and shows yellow. Blue ("buggy") is added on top of the user-guide // rules: when pre-FEC == post-FEC and both > 0, the ONU firmware is // almost certainly mirroring one counter into the other rather than // the link being identically broken — surface it distinctly so the // operator doesn't waste time troubleshooting a phantom. const FEC_LABELS = { ok: { text: 'OK', cls: 'status-ok' }, pre: { text: 'Pre-FEC errors', cls: 'status-warn' }, post: { text: 'Post-FEC errors', cls: 'status-err' }, buggy: { text: 'Pre==Post (ONU bug?)', cls: 'status-info' }, nodata: { text: 'no recent stats', cls: 'muted' }, error: { text: 'fetch failed', cls: 'muted' }, pending: { text: 'checking…', cls: 'muted' }, }; // Mirror of formatFecNumber in src/mcms-api.js. Kept in the renderer so // we don't have to round-trip through IPC to format a number — and so // we can re-format if the structure ever changes. function formatFecNumber(n) { if (n === 0) return '0'; const abs = Math.abs(n); if (Number.isInteger(n)) return String(n); if (abs < 0.01 || abs >= 1e6) return n.toExponential(2); return Number(n.toPrecision(3)).toString(); } function fecCountersHtml(health) { const list = Array.isArray(health?.counters) ? health.counters : []; const optical = health?.optical || {}; if (!list.length && optical.rx == null && optical.tx == null) return ''; const lines = []; // FEC counters: ONU side first (most relevant — the ONU is the device // we're about to upgrade), then OLT side. Skip counters whose scope // we don't recognise. const order = (c) => (c.scope === 'onu' ? 0 : c.scope === 'olt' ? 1 : 2); const sorted = [...list].sort((a, b) => { const so = order(a) - order(b); if (so) return so; return a.kind === b.kind ? 0 : a.kind === 'pre' ? -1 : 1; }); for (const c of sorted) { const cls = c.value > 0 ? c.kind : 'ok'; lines.push( `${escapeHtml(c.tail)}=${escapeHtml(formatFecNumber(c.value))}`, ); } // Optical levels (dBm). User-guide green-LED threshold is RX >= -30 // dBm and TX >= 3 dBm (323-1961-302 p149). We highlight RX in red // below -30 and yellow as it approaches the floor (-28 dBm), so the // operator sees the marginal links during the FEC sweep. if (optical.rx != null || optical.tx != null) { const opticalParts = []; if (optical.rx != null) { const rxCls = optical.rx < -30 ? 'post' : optical.rx < -28 ? 'pre' : 'ok'; opticalParts.push( `RX ${escapeHtml(optical.rx.toFixed(1))} dBm`, ); } if (optical.tx != null) { const txCls = optical.tx < 3 ? 'post' : 'ok'; opticalParts.push( `TX ${escapeHtml(optical.tx.toFixed(1))} dBm`, ); } lines.push(opticalParts.join(' ')); } return `${lines.join('
')}
`; } function fecPillHtml(health) { const f = (health && health.flag) || 'pending'; const label = FEC_LABELS[f] || FEC_LABELS.pending; const title = health?.detail || health?.error?.message || ''; const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; return ( `${escapeHtml(label.text)}` + fecCountersHtml(health) ); } // -------- Login -------- $('#btn-login').addEventListener('click', async () => { $('#login-error').textContent = ''; const baseUrl = $('#in-host').value.trim(); const username = $('#in-user').value.trim(); const password = $('#in-pass').value; if (!baseUrl || !username || !password) { $('#login-error').textContent = 'Host, username and password are required.'; return; } $('#btn-login').disabled = true; $('#btn-login').textContent = 'Connecting…'; const res = await window.api.login({ baseUrl, username, password }); $('#btn-login').disabled = false; $('#btn-login').textContent = 'Connect'; if (!res.ok) { $('#login-error').textContent = res.error?.message || 'Login failed'; return; } $('#session-label').textContent = `${username} @ ${baseUrl}`; $('#btn-logout').classList.remove('hidden'); $('#tabs').classList.remove('hidden'); showView('#view-dashboard'); loadDashboard(); }); $('#btn-logout').addEventListener('click', async () => { await window.api.logout(); $('#session-label').textContent = 'Not connected'; $('#btn-logout').classList.add('hidden'); $('#tabs').classList.add('hidden'); state.fleet = []; state.filtered = []; state.selected.clear(); state.dashboard = null; showView('#view-login'); }); // -------- Top tab navigation -------- for (const tab of $$('.tab')) { tab.addEventListener('click', () => { const view = tab.dataset.view; showView(view); // Dashboard loads itself on first visit; other tabs load on demand. if (view === '#view-dashboard' && !state.dashboard) loadDashboard(); if (view === '#view-onu-info') $('#view-onu-info').classList.remove('show-detail'); }); } // -------- Fleet loading + filtering -------- $('#btn-refresh').addEventListener('click', loadFleet); async function loadFleet() { $('#fleet-status').textContent = 'Loading fleet…'; const res = await window.api.listFleet({}); if (!res.ok) { $('#fleet-status').textContent = `Error: ${res.error?.message || 'unknown'}`; return; } state.fleet = res.data || []; applyFilters(); const meta = res.meta || {}; const withEq = state.fleet.filter((c) => equipmentId(c)).length; if (meta.statesError) { // Bulk state fetch failed — fall back to per-ONU streaming fetch. $('#fleet-status').textContent = `Loaded ${state.fleet.length} ONUs. Fetching hardware info per-ONU…`; const byId = new Map(state.fleet.map((c) => [c._id, c])); const unbind = window.api.onStatesProgress(({ onuId, state: s, done, total }) => { const cfg = byId.get(onuId); if (cfg && s) cfg._state = s; // Throttle full re-renders: update every 50 results (or on last). if (done === total || done % 50 === 0) { applyFilters(); const eq = state.fleet.filter((c) => equipmentId(c)).length; $('#fleet-status').textContent = `Loaded ${state.fleet.length} ONUs. Hardware info: ${done}/${total} (${eq} with Equipment ID).`; } }); const fetchRes = await window.api.fetchStates({ onuIds: state.fleet.map((c) => c._id), }); unbind(); applyFilters(); const eq = state.fleet.filter((c) => equipmentId(c)).length; if (!fetchRes.ok) { $('#fleet-status').textContent = `Loaded ${state.fleet.length} ONUs. Hardware info fetch failed: ${fetchRes.error?.message}`; } else { $('#fleet-status').textContent = `Loaded ${state.fleet.length} ONUs (${eq} with Equipment ID).`; } return; } const stateNote = meta.stateCount === 0 ? ' — no ONU states returned (Equipment ID will be blank)' : withEq ? ` (${withEq} with Equipment ID)` : ''; const oltNote = meta.oltStatesError ? ` — OLT-STATE fetch failed (${meta.oltStatesError.message}); status filter unavailable` : meta.oltStateCount ? `, ${meta.oltStateCount} OLT(s) for status` : ''; $('#fleet-status').textContent = `Loaded ${state.fleet.length} ONUs${stateNote}${oltNote}.`; } function applyFilters() { const text = $('#filter-text').value.trim().toLowerCase(); const equipment = $('#filter-equipment').value.trim().toLowerCase(); const ponMode = $('#filter-pon').value.trim(); const version = $('#filter-version').value.trim().toLowerCase(); const excludeVersion = $('#filter-exclude-version').value.trim().toLowerCase(); const model = $('#filter-model').value.trim().toLowerCase(); const statusFilter = $('#filter-status').value; const downDaysRaw = $('#filter-down-days').value.trim(); const downDays = downDaysRaw ? Number(downDaysRaw) : null; state.filtered = state.fleet.filter((cfg) => { if (text) { const hay = `${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''} ${cfg?._id || ''}`.toLowerCase(); if (!hay.includes(text)) return false; } if (equipment) { const eid = equipmentId(cfg).toLowerCase(); if (!eid.includes(equipment)) return false; } if (ponMode) { // PON Mode is a CFG field — client-side exact match now. if ((cfg?.ONU?.['PON Mode'] || '') !== ponMode) return false; } if (version) { const v = activeVersion(cfg).toLowerCase(); if (!v.includes(version)) return false; } if (excludeVersion) { // Drop devices whose active version matches — useful for "already // upgraded" devices during a rolling fleet rollout. const v = activeVersion(cfg).toLowerCase(); if (v.includes(excludeVersion)) return false; } if (model) { // "model" here is matched against either slot's version string, so a // prefix like "EV051" catches the whole Everest 5.1x family whether // the device is currently on 5.11 or 5.12. const v0 = versionAt(cfg, 0).toLowerCase(); const v1 = versionAt(cfg, 1).toLowerCase(); if (!v0.includes(model) && !v1.includes(model)) return false; } if (statusFilter) { const s = onuStatus(cfg); if (statusFilter === '__down__') { if (!DOWN_STATUSES.has(s)) return false; } else if (statusFilter === '__unknown__') { if (s !== null) return false; } else { if (s !== statusFilter) return false; } } if (downDays !== null && !Number.isNaN(downDays)) { // "Down for at least N days" — only meaningful for ONUs that are // actually down AND have a last-seen timestamp. Registered ONUs and // those without an ONU-STATE doc are excluded when this filter is // active (otherwise we'd show a bunch of online ONUs whose Time // field happens to be old just because they're idle). const s = onuStatus(cfg); if (!DOWN_STATUSES.has(s)) return false; const d = daysSince(lastSeenDate(cfg)); if (d === null || d < downDays) return false; } return true; }); renderFleet(); } for (const el of [ $('#filter-text'), $('#filter-equipment'), $('#filter-version'), $('#filter-exclude-version'), $('#filter-model'), $('#filter-down-days'), ]) { el.addEventListener('input', applyFilters); } $('#filter-pon').addEventListener('change', applyFilters); $('#filter-status').addEventListener('change', applyFilters); function renderFleet() { const tbody = $('#fleet-tbody'); tbody.innerHTML = ''; for (const cfg of state.filtered) { const tr = document.createElement('tr'); const id = cfg._id; if (state.selected.has(id)) tr.classList.add('selected'); const ptr = activeBank(cfg); const s = onuStatus(cfg); const statusCls = s === 'Registered' ? 'status-ok' : DOWN_STATUSES.has(s) ? 'status-err' : s ? 'status-pending' : 'muted'; const statusText = s || 'unknown'; const seen = lastSeenDate(cfg); const seenDays = daysSince(seen); tr.innerHTML = ` ${escapeHtml(id)} ${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')} ${escapeHtml(equipmentId(cfg))} ${escapeHtml(cfg?.ONU?.['PON Mode'] || '')} ${escapeHtml(statusText)} ${seenDays === null ? '' : escapeHtml(formatRelative(seenDays))} ${ptr === null || ptr === 65535 ? 'unset' : ptr} ${escapeHtml(activeVersion(cfg))} ${escapeHtml(inactiveVersion(cfg))} `; tbody.appendChild(tr); } $('#fleet-count').textContent = state.filtered.length; updateSelectionSummary(); // Wire checkboxes for (const cb of tbody.querySelectorAll('input[type="checkbox"]')) { cb.addEventListener('change', () => { const id = cb.getAttribute('data-id'); if (cb.checked) state.selected.add(id); else state.selected.delete(id); cb.closest('tr').classList.toggle('selected', cb.checked); updateSelectionSummary(); }); } } $('#th-check').addEventListener('change', (e) => { if (e.target.checked) { for (const cfg of state.filtered) state.selected.add(cfg._id); } else { for (const cfg of state.filtered) state.selected.delete(cfg._id); } renderFleet(); }); $('#btn-select-all').addEventListener('click', () => { for (const cfg of state.filtered) state.selected.add(cfg._id); renderFleet(); }); $('#btn-select-none').addEventListener('click', () => { state.selected.clear(); renderFleet(); }); function updateSelectionSummary() { $('#sel-count').textContent = state.selected.size; $('#btn-to-campaign').disabled = state.selected.size === 0; $('#btn-delete-selected').disabled = state.selected.size === 0; } // -------- Delete -------- $('#btn-delete-selected').addEventListener('click', async () => { const ids = Array.from(state.selected); if (!ids.length) return; // Two-step confirmation: summary dialog first, then require typing // DELETE to proceed. This is destructive (Procedure 43 removes the // ONU-CFG document from MongoDB) and there's no undo from inside this // app — a returning ONU will re-register but service-config is gone. const downCount = ids.filter((id) => { const cfg = state.fleet.find((c) => c._id === id); return DOWN_STATUSES.has(onuStatus(cfg)); }).length; const summary = `You're about to DELETE ${ids.length} ONU(s) from MCMS ` + `(${downCount} currently down).\n\n` + `This removes the ONU-CFG document and service configuration. ` + `Physical ONUs that come back online will re-register from scratch.`; const confirmed = await confirmTyped({ title: 'Delete ONUs from MCMS', body: summary, requireWord: 'DELETE', confirmLabel: 'Delete', danger: true, }); if (!confirmed) { $('#delete-status').textContent = 'Cancelled.'; return; } $('#btn-delete-selected').disabled = true; $('#btn-to-campaign').disabled = true; $('#delete-status').textContent = `Deleting 0/${ids.length}…`; const unbind = window.api.onDeleteProgress(({ done, total, ok, onuId, error }) => { $('#delete-status').textContent = `Deleting ${done}/${total}${error ? ` (last error on ${onuId}: ${error.message})` : ''}`; }); const res = await window.api.deleteOnus({ onuIds: ids }); unbind(); if (!res.ok) { $('#delete-status').textContent = `Delete failed: ${res.error?.message}`; $('#btn-delete-selected').disabled = false; return; } const okCount = res.data.filter((r) => r.ok).length; const failed = res.data.filter((r) => !r.ok); // Drop successfully-deleted ONUs from local state so the table reflects // the new reality without needing a full reload. const deletedIds = new Set(res.data.filter((r) => r.ok).map((r) => r.onuId)); state.fleet = state.fleet.filter((c) => !deletedIds.has(c._id)); for (const id of deletedIds) state.selected.delete(id); applyFilters(); let msg = `Deleted ${okCount}/${res.data.length}.`; if (failed.length) { msg += ` Failures: ` + failed.slice(0, 3).map((f) => `${f.onuId} (${f.error?.message || '?'})`).join(', '); if (failed.length > 3) msg += ` and ${failed.length - 3} more`; } $('#delete-status').textContent = msg; }); // -------- Campaign -------- $('#btn-to-campaign').addEventListener('click', async () => { showView('#view-campaign'); await refreshFirmwareList(); }); $('#btn-back').addEventListener('click', () => showView('#view-fleet')); $('#btn-reload-fw').addEventListener('click', refreshFirmwareList); $('#btn-upload-fw').addEventListener('click', async () => { $('#exec-status').textContent = 'Uploading firmware…'; const res = await window.api.uploadFirmware(); if (!res.ok) { if (res.error?.message === 'cancelled') { $('#exec-status').textContent = ''; } else { $('#exec-status').textContent = `Upload failed: ${res.error?.message}`; } return; } $('#exec-status').textContent = `Uploaded ${res.data.filename}.`; await refreshFirmwareList(); }); async function refreshFirmwareList() { const res = await window.api.listOnuFirmware(); if (!res.ok) { $('#exec-status').textContent = `Firmware list error: ${res.error?.message}`; return; } state.firmwareList = res.data || []; const sel = $('#fw-select'); sel.innerHTML = ''; for (const fw of state.firmwareList) { const opt = document.createElement('option'); // The firmware list entries are GridFS file metadata; shape varies // slightly by MCMS version but _id / filename is always present. const filename = fw.filename || fw._id || JSON.stringify(fw); const version = fw?.metadata?.Version || fw?.Version || ''; opt.value = filename; opt.textContent = version ? `${filename} — ${version}` : filename; opt.dataset.version = version; sel.appendChild(opt); } if (state.firmwareList.length) { sel.selectedIndex = 0; $('#fw-version').value = sel.options[0].dataset.version || ''; } } $('#fw-select').addEventListener('change', () => { const opt = $('#fw-select').selectedOptions[0]; if (opt) $('#fw-version').value = opt.dataset.version || ''; }); $('#mode-select').addEventListener('change', () => { state.campaignMode = $('#mode-select').value; $('#row-schedule').style.display = state.campaignMode === 'task' ? '' : 'none'; }); $('#btn-preview').addEventListener('click', async () => { const targetFile = $('#fw-select').value; const targetVersion = $('#fw-version').value.trim(); if (!targetFile || !targetVersion) { $('#exec-status').textContent = 'Pick a firmware file and fill in the version string.'; return; } $('#exec-status').textContent = 'Computing plan…'; const onuIds = Array.from(state.selected); // Fresh FEC results for each preview — link health can change between // hitting Preview and hitting Execute, so don't carry stale flags. state.fecHealth = new Map(); const res = await window.api.planUpgrade({ onuIds, targetFile, targetVersion }); if (!res.ok) { $('#exec-status').textContent = `Plan failed: ${res.error?.message}`; return; } state.plan = res.data; renderPreview(); $('#btn-execute').disabled = false; $('#exec-status').textContent = `Previewed ${state.plan.length} ONUs. Checking FEC health…`; // Kick off the FEC pre-flight in parallel. Each result paints its row // immediately so the operator can spot "Post-FEC errors" rows before // committing the upgrade. The MCMS /onus/stats// endpoint is // per-ONU, so this is bounded to the selected set rather than the // whole fleet (per Jon's preference: "before committing the firmware // update, not for the entire fleet"). const planIds = state.plan.filter((p) => !p.error).map((p) => p.onuId); if (!planIds.length) return; const unbind = window.api.onFecProgress((p) => { state.fecHealth.set(p.onuId, p); paintFecCell(p.onuId); if (p.done === p.total) { const counts = countFecFlags(); $('#exec-status').textContent = `FEC pre-flight complete: ` + `${counts.ok} OK, ${counts.pre} pre-FEC, ${counts.post} post-FEC, ` + `${counts.buggy} pre==post (ONU bug?), ` + `${counts.nodata + counts.error} unknown.`; } }); const fecRes = await window.api.fetchFecHealth({ onuIds: planIds }); unbind(); if (!fecRes.ok) { $('#exec-status').textContent = `Plan ready (FEC fetch failed: ${fecRes.error?.message}). ` + `You can still execute, but pre-flight health is unavailable.`; return; } // Final paint in case any progress events were missed. for (const r of fecRes.data) state.fecHealth.set(r.onuId, r); for (const r of fecRes.data) paintFecCell(r.onuId); }); function countFecFlags() { const c = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 }; for (const v of state.fecHealth.values()) { if (c[v.flag] !== undefined) c[v.flag] += 1; } return c; } function paintFecCell(onuId) { const tbody = $('#preview-tbody'); const tr = tbody.querySelector(`tr[data-onu="${cssEscape(onuId)}"]`); if (!tr) return; const cell = tr.querySelector('td.fec-cell'); if (!cell) return; cell.innerHTML = fecPillHtml(state.fecHealth.get(onuId)); } function cssEscape(s) { // Minimal CSS attribute selector escaping. ONU IDs are MAC addresses / // hex strings on this MCMS build so this is mostly belt-and-braces. return String(s).replace(/(["\\])/g, '\\$1'); } function renderPreview() { const tbody = $('#preview-tbody'); tbody.innerHTML = ''; for (const p of state.plan) { const tr = document.createElement('tr'); tr.setAttribute('data-onu', p.onuId); if (p.error) { tr.innerHTML = ` ${escapeHtml(p.onuId)} — ${escapeHtml(p.error)}`; } else { const prevPtr = p.previousActiveSlot; const arrow = `${prevPtr} → ${p.writeSlot}`; const fec = state.fecHealth.get(p.onuId); tr.innerHTML = ` ${escapeHtml(p.onuId)} ${escapeHtml(p.name || p.address || '')} ${arrow} ${escapeHtml(p.currentVersionInSlot0)} ${escapeHtml(p.currentVersionInSlot1)} ${escapeHtml(p.targetVersion)} ${fecPillHtml(fec)} ready`; } tbody.appendChild(tr); } } $('#btn-execute').addEventListener('click', async () => { const onuIds = state.plan.filter((p) => !p.error).map((p) => p.onuId); const targetFile = $('#fw-select').value; const targetVersion = $('#fw-version').value.trim(); if (!onuIds.length) { $('#exec-status').textContent = 'No valid ONUs in plan.'; return; } // Warn (but don't block) if any ONU shows post-FEC errors or the // pre==post buggy signature. Operators sometimes intentionally // upgrade marginal links — that's exactly what the new firmware // might be fixing — and the buggy-counter case is itself a reason // to upgrade. So this is a soft gate, not a hard one. const counts = countFecFlags(); const warnings = []; if (counts.post) { warnings.push( `${counts.post} ONU(s) currently report Post-FEC errors ` + `(uncorrected bits hitting the user).`, ); } if (counts.buggy) { warnings.push( `${counts.buggy} ONU(s) report identical pre/post FEC values ` + `— likely an ONU firmware bug. Upgrading may be the fix.`, ); } const fecWarning = warnings.length ? `\n\nWARNING:\n • ${warnings.join('\n • ')}` : ''; const proceed = await confirmTyped({ title: 'Stage firmware upgrade', body: `About to stage ${targetVersion} to the inactive bank on ${onuIds.length} ONU(s).${fecWarning}`, confirmLabel: 'Execute upgrade', danger: true, }); if (!proceed) return; $('#btn-execute').disabled = true; // Save the plan to CSV BEFORE writing anything to MCMS, so even if // the API call dies mid-way we have a record of what we were about // to do (and which links looked sketchy at the time). const csvHeaders = [ 'ONU ID', 'Name', 'Address', 'PON Mode', 'Active slot (before)', 'Write slot (target)', 'Slot 0 version (before)', 'Slot 1 version (before)', 'Target version', 'Target file', 'FEC health', 'ONU RX Pre-FEC BER', 'ONU RX Post-FEC BER', 'OLT RX Pre-FEC BER', 'OLT RX Post-FEC BER', 'ONU RX Optical (dBm)', 'ONU TX Optical (dBm)', 'FEC sample time', 'Mode', 'Notes', ]; // Helper: pluck a counter by (scope, kind) from the structured array. const counterValue = (counters, scope, kind) => { if (!Array.isArray(counters)) return ''; const c = counters.find((x) => x.scope === scope && x.kind === kind); return c ? c.value : ''; }; const csvRows = state.plan.map((p) => { const fec = state.fecHealth.get(p.onuId) || {}; const label = FEC_LABELS[fec.flag]?.text || ''; return { 'ONU ID': p.onuId, 'Name': p.name || '', 'Address': p.address || '', 'PON Mode': p.ponMode || '', 'Active slot (before)': p.error ? '' : p.previousActiveSlot, 'Write slot (target)': p.error ? '' : p.writeSlot, 'Slot 0 version (before)': p.error ? '' : p.currentVersionInSlot0, 'Slot 1 version (before)': p.error ? '' : p.currentVersionInSlot1, 'Target version': targetVersion, 'Target file': targetFile, 'FEC health': label, 'ONU RX Pre-FEC BER': counterValue(fec.counters, 'onu', 'pre'), 'ONU RX Post-FEC BER': counterValue(fec.counters, 'onu', 'post'), 'OLT RX Pre-FEC BER': counterValue(fec.counters, 'olt', 'pre'), 'OLT RX Post-FEC BER': counterValue(fec.counters, 'olt', 'post'), 'ONU RX Optical (dBm)': fec.optical?.rx ?? '', 'ONU TX Optical (dBm)': fec.optical?.tx ?? '', 'FEC sample time': fec.sampleTime || '', 'Mode': state.campaignMode, 'Notes': p.error || (fec.error ? `FEC fetch: ${fec.error.message}` : ''), }; }); const csvSave = await window.api.savePlanCsv({ rows: csvRows, headers: csvHeaders, filenameHint: `${targetVersion || 'unknown'}-${state.campaignMode}-${onuIds.length}onus`, }); if (csvSave.ok) { $('#exec-status').textContent = `Plan saved → ${csvSave.data.path}. Submitting upgrade…`; } else { $('#exec-status').textContent = `(CSV save failed: ${csvSave.error?.message}) Submitting upgrade anyway…`; } if (state.campaignMode === 'task') { // Procedure 8 — single scheduled task. const scheduleLocal = $('#in-schedule').value; // Convert local datetime to MCMS "YYYY-MM-DD HH:MM:SS" in UTC. If the // field is blank, start immediately (now). const when = scheduleLocal ? new Date(scheduleLocal) : new Date(); const pad = (n) => String(n).padStart(2, '0'); const scheduledStart = `${when.getUTCFullYear()}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())} ` + `${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}`; // All ONUs in the plan currently share a single inactive bank target // because we compute per-ONU. For the task endpoint we need ONE bank // number, so we bucket by writeSlot and create one task per slot. const buckets = { 0: [], 1: [] }; for (const p of state.plan) { if (p.error) continue; buckets[p.writeSlot].push(p.onuId); } const results = []; for (const [slotStr, serials] of Object.entries(buckets)) { if (!serials.length) continue; const slot = Number(slotStr); const taskId = `FwUp-${targetVersion}-slot${slot}-${Date.now()}`; $('#exec-status').textContent = `Creating task ${taskId} (${serials.length} ONUs)…`; const res = await window.api.executeBulkTask({ taskId, serials, scheduledStart, fwBankPtr: slot, targetFile, targetVersion, }); results.push({ taskId, slot, count: serials.length, res }); } $('#exec-status').textContent = `Submitted ${results.length} task(s): ` + results.map((r) => `${r.taskId} (${r.count} ONUs, slot ${r.slot}, ${r.res.ok ? 'ok' : 'FAILED'})`).join('; '); markAllPreviewRows(results.every((r) => r.res.ok) ? 'submitted' : 'error'); } else { // Procedure 7 — per-ONU PUTs. Slower but gives immediate feedback. const unbind = window.api.onUpgradeProgress((p) => { $('#exec-status').textContent = `[${p.index + 1}/${p.total}] ${p.phase} ${p.onuId}` + (p.writeSlot !== undefined ? ` (slot ${p.writeSlot})` : ''); }); const res = await window.api.executePerOnu({ onuIds, targetFile, targetVersion }); unbind(); if (!res.ok) { $('#exec-status').textContent = `Execution failed: ${res.error?.message}`; $('#btn-execute').disabled = false; return; } // Apply per-row status back onto the preview table. const byId = new Map(res.data.map((r) => [r.onuId, r])); const rows = $('#preview-tbody').querySelectorAll('tr'); let i = 0; for (const p of state.plan) { const row = rows[i++]; const cell = row?.querySelector('td:last-child'); if (!cell) continue; if (p.error) continue; const r = byId.get(p.onuId); if (!r) { cell.textContent = 'skipped'; continue; } if (r.ok) { cell.textContent = `wrote slot ${r.writeSlot}`; cell.className = 'status-ok'; } else { cell.textContent = r.error?.message || 'failed'; cell.className = 'status-err'; } } const okCount = res.data.filter((r) => r.ok).length; $('#exec-status').textContent = `Done: ${okCount}/${res.data.length} succeeded.`; } }); function markAllPreviewRows(state) { const rows = $('#preview-tbody').querySelectorAll('tr'); for (const r of rows) { const cell = r.querySelector('td:last-child'); if (!cell) continue; if (state === 'submitted') { cell.textContent = 'submitted to MCMS'; cell.className = 'status-ok'; } else if (state === 'error') { cell.textContent = 'task submission failed'; cell.className = 'status-err'; } } } // -------- Verify view: open saved plan CSV, re-fetch, compare, save -------- $('#btn-to-verify').addEventListener('click', () => { showView('#view-verify'); resetVerifyView(); }); $('#btn-verify-back').addEventListener('click', () => showView('#view-fleet')); $('#btn-pick-plan').addEventListener('click', runVerifyFlow); $('#btn-save-verify').addEventListener('click', saveVerifyReport); function resetVerifyView() { $('#verify-status').textContent = ''; $('#verify-save-status').textContent = ''; $('#verify-tbody').innerHTML = ''; $('#verify-count').textContent = '0'; $('#btn-save-verify').disabled = true; $('#verify-summary').innerHTML = '
Open a CSV to start.
'; state.verify = null; } async function runVerifyFlow() { $('#verify-status').textContent = 'Picking file…'; const fileRes = await window.api.openCsvFile(); if (!fileRes.ok) { $('#verify-status').textContent = fileRes.error?.message === 'cancelled' ? '' : `Open failed: ${fileRes.error?.message || 'unknown'}`; return; } const { path: sourcePath, text } = fileRes.data; let beforeRows; try { beforeRows = parsePlanCsv(text); } catch (e) { $('#verify-status').textContent = `CSV parse failed: ${e.message}`; return; } if (!beforeRows.length) { $('#verify-status').textContent = 'No ONU rows found in CSV.'; return; } const ids = beforeRows.map((r) => r.onuId).filter(Boolean); $('#verify-status').textContent = `Loaded ${beforeRows.length} rows from ${sourcePath}. Fetching current state…`; const unbind = window.api.onVerifyProgress(({ done, total }) => { $('#verify-status').textContent = `Loaded ${beforeRows.length} from ${sourcePath}. Fetching current state… ${done}/${total}`; }); const snap = await window.api.fetchOnuSnapshot({ onuIds: ids }); unbind(); if (!snap.ok) { $('#verify-status').textContent = `Fetch failed: ${snap.error?.message}`; return; } const snapshotById = new Map(snap.data.map((r) => [r.onuId, r])); const comparisons = beforeRows.map((b) => buildComparison(b, snapshotById.get(b.onuId))); state.verify = { sourcePath, beforeRows, snapshotById, comparisons }; renderVerifyTable(comparisons); $('#btn-save-verify').disabled = false; $('#verify-status').textContent = `Compared ${comparisons.length} ONUs from ${sourcePath}. Saving report…`; // Auto-save the verification report immediately, mirroring the // auto-save behavior on Execute. await saveVerifyReport(); } // CSV parsing ----------------------------------------------------------- // Lenient CSV reader. Handles quoted cells with embedded commas, escaped // quotes ("" inside quoted strings), CRLF or LF line endings, and a // leading UTF-8 BOM. Returns a 2-D array of strings. function parseCsvText(text) { if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); const rows = []; let row = []; let cell = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else { inQuotes = false; } } else { cell += ch; } } else { if (ch === '"') inQuotes = true; else if (ch === ',') { row.push(cell); cell = ''; } else if (ch === '\r') { /* swallow; \n closes the row */ } else if (ch === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } else cell += ch; } } if (cell !== '' || row.length) { row.push(cell); rows.push(row); } return rows; } // Locate a column in a header row by trying a list of candidate names // (case-insensitive, trimmed). Returns -1 if none match. function findColumn(headers, candidates) { const norm = headers.map((h) => String(h).trim().toLowerCase()); for (const cand of candidates) { const idx = norm.indexOf(String(cand).toLowerCase()); if (idx >= 0) return idx; } return -1; } // Parse the saved plan CSV into structured "before" rows. Tolerant of // missing columns (a CSV authored by an older version of this app). function parsePlanCsv(text) { const matrix = parseCsvText(text); if (matrix.length < 2) return []; const headers = matrix[0]; const idCol = findColumn(headers, ['ONU ID', 'Serial', 'serial']); if (idCol < 0) throw new Error('No "ONU ID" column found in CSV header'); const cellOf = (row, ...names) => { const i = findColumn(headers, names); return i >= 0 ? (row[i] ?? '') : ''; }; const numOf = (row, ...names) => { const v = String(cellOf(row, ...names)).trim(); if (v === '' || v === '—') return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; const rows = []; for (let r = 1; r < matrix.length; r++) { const row = matrix[r]; if (!row || row.length === 0) continue; const id = (row[idCol] || '').trim(); if (!id) continue; rows.push({ onuId: id, name: cellOf(row, 'Name'), address: cellOf(row, 'Address'), ponMode: cellOf(row, 'PON Mode'), writeSlot: cellOf(row, 'Write slot (target)'), targetVersion: cellOf(row, 'Target version'), targetFile: cellOf(row, 'Target file'), fecHealthBefore: cellOf(row, 'FEC health'), onuPreBefore: numOf(row, 'ONU RX Pre-FEC BER'), onuPostBefore: numOf(row, 'ONU RX Post-FEC BER'), oltPreBefore: numOf(row, 'OLT RX Pre-FEC BER'), oltPostBefore: numOf(row, 'OLT RX Post-FEC BER'), rxBefore: numOf(row, 'ONU RX Optical (dBm)'), txBefore: numOf(row, 'ONU TX Optical (dBm)'), }); } return rows; } // Comparison logic ------------------------------------------------------ // Map the before-row's "FEC health" label string back to a flag so the // verdict logic can compare against the live `after.flag`. The labels // come from FEC_LABELS so this is the inverse of that lookup. function flagFromLabel(label) { if (!label) return 'unknown'; const l = String(label).trim().toLowerCase(); for (const [flag, lab] of Object.entries(FEC_LABELS)) { if (lab.text.toLowerCase() === l) return flag; } return 'unknown'; } function buildComparison(before, after) { const cfg = after?.cfg || null; const activeNow = cfg ? activeVersion(cfg) : ''; const target = before.targetVersion; let upgradeApplied = null; // null = unknown if (target && activeNow && activeNow !== '(unset)' && activeNow !== '(empty)') { upgradeApplied = activeNow === target; } const counterAfter = (scope, kind) => { if (!after?.counters) return null; const c = after.counters.find((x) => x.scope === scope && x.kind === kind); return c ? c.value : null; }; const beforeFlag = flagFromLabel(before.fecHealthBefore); const afterFlag = after?.flag || 'error'; return { onuId: before.onuId, name: before.name, address: before.address, target, activeNow, upgradeApplied, beforeFlag, afterFlag, fecBeforeLabel: before.fecHealthBefore, fecAfterLabel: FEC_LABELS[afterFlag]?.text || afterFlag, onuPreBefore: before.onuPreBefore, onuPreAfter: counterAfter('onu', 'pre'), onuPostBefore: before.onuPostBefore, onuPostAfter: counterAfter('onu', 'post'), oltPreBefore: before.oltPreBefore, oltPreAfter: counterAfter('olt', 'pre'), oltPostBefore: before.oltPostBefore, oltPostAfter: counterAfter('olt', 'post'), rxBefore: before.rxBefore, rxAfter: after?.optical?.rx ?? null, txBefore: before.txBefore, txAfter: after?.optical?.tx ?? null, afterError: after?.error || null, sampleTime: after?.sampleTime || '', verdict: computeVerdict(beforeFlag, afterFlag, upgradeApplied, after), }; } // Single-line verdict per ONU. The verdict is mostly about the FEC flag // transition because the raw counters reset on the upgrade reboot, so // "delta is negative" is normal and not informative. function computeVerdict(beforeFlag, afterFlag, upgradeApplied, after) { if (!after) return 'fetch failed'; if (afterFlag === 'error') return 'fetch failed'; if (upgradeApplied === false) return 'upgrade NOT applied (active version unchanged)'; if (afterFlag === 'nodata') return 'no current FEC data'; const wasGood = beforeFlag === 'ok'; const isGood = afterFlag === 'ok'; const wasBuggy = beforeFlag === 'buggy'; const isBuggy = afterFlag === 'buggy'; if (wasBuggy && isBuggy) return 'still buggy (firmware bug persists)'; if (wasBuggy && isGood) return 'fixed (was buggy)'; if (wasBuggy && afterFlag === 'pre') return 'improved (no longer mirroring counters)'; if (wasBuggy && afterFlag === 'post') return 'changed: now reporting genuine post-FEC errors'; if (wasGood && isGood) return 'still healthy'; if (wasGood && afterFlag === 'pre') return 'regressed (now marginal)'; if (wasGood && afterFlag === 'post') return 'regressed (now post-FEC errors)'; if (wasGood && afterFlag === 'buggy') return 'regressed (now buggy)'; if (!wasGood && isGood) return 'improved'; if (beforeFlag === 'post' && afterFlag === 'pre') return 'improved (post→pre)'; if (beforeFlag === 'pre' && afterFlag === 'post') return 'regressed (pre→post)'; if (afterFlag === 'post') return 'still has post-FEC errors'; if (afterFlag === 'pre') return 'still marginal (pre-FEC only)'; return 'unchanged'; } const VERDICT_CLASSES = [ { match: /fixed|improved|still healthy/i, cls: 'status-ok' }, { match: /still buggy/i, cls: 'status-info' }, { match: /regressed|NOT applied|fetch failed|post-FEC/i, cls: 'status-err' }, { match: /marginal|pre-FEC/i, cls: 'status-warn' }, ]; function verdictCls(v) { for (const r of VERDICT_CLASSES) if (r.match.test(v)) return r.cls; return 'muted'; } // Render ----------------------------------------------------------------- function renderVerifyTable(comparisons) { const tbody = $('#verify-tbody'); tbody.innerHTML = ''; // Counts for the side-panel summary. const tally = { applied: 0, notApplied: 0, fixed: 0, stillBuggy: 0, regressed: 0, healthy: 0, other: 0 }; for (const c of comparisons) { const tr = document.createElement('tr'); tr.setAttribute('data-onu', c.onuId); if (c.upgradeApplied === true) tally.applied++; else if (c.upgradeApplied === false) tally.notApplied++; if (/fixed|improved/.test(c.verdict)) tally.fixed++; else if (/still buggy/.test(c.verdict)) tally.stillBuggy++; else if (/regressed|NOT applied|fetch failed/.test(c.verdict)) tally.regressed++; else if (/still healthy/.test(c.verdict)) tally.healthy++; else tally.other++; const appliedCls = c.upgradeApplied === true ? 'status-ok' : c.upgradeApplied === false ? 'status-err' : 'muted'; const appliedTxt = c.upgradeApplied === true ? 'yes' : c.upgradeApplied === false ? 'no' : '?'; const beforeFecBlock = `${escapeHtml(c.fecBeforeLabel || c.beforeFlag)}` + `` + `pre=${escapeHtml(formatFecOrDash(c.onuPreBefore))}
` + `post=${escapeHtml(formatFecOrDash(c.onuPostBefore))}` + `
`; const afterFecBlock = `${escapeHtml(c.fecAfterLabel)}` + `` + `pre=${escapeHtml(formatFecOrDash(c.onuPreAfter))}
` + `post=${escapeHtml(formatFecOrDash(c.onuPostAfter))}` + `
`; const opticalBlock = renderOpticalCompare(c); const targetCol = c.target ? `
${escapeHtml(c.target)}
now: ${escapeHtml(c.activeNow || '?')}
` : `
${escapeHtml(c.activeNow || '?')}
`; tr.innerHTML = ` ${escapeHtml(c.onuId)} ${escapeHtml(c.name || '')} ${targetCol} ${appliedTxt} ${beforeFecBlock} ${afterFecBlock} ${opticalBlock} ${escapeHtml(c.verdict)} `; tbody.appendChild(tr); } $('#verify-count').textContent = comparisons.length; $('#verify-summary').innerHTML = `
${tally.applied} upgrade applied · ${tally.notApplied} not applied
${tally.fixed} improved/fixed
${tally.healthy} still healthy
${tally.stillBuggy} still buggy
${tally.regressed} regressed/failed
${tally.other} other
`; } function renderOpticalCompare(c) { const fmt = (n) => n == null ? '—' : `${n.toFixed(1)} dBm`; const cls = (n) => n == null ? 'muted' : n < -30 ? 'status-err' : n < -28 ? 'status-warn' : 'status-ok'; const rxBefore = `RX before: ${escapeHtml(fmt(c.rxBefore))}`; const rxAfter = `RX now: ${escapeHtml(fmt(c.rxAfter))}`; const txBefore = `TX before: ${escapeHtml(fmt(c.txBefore))}`; const txAfter = `TX now: ${escapeHtml(fmt(c.txAfter))}`; return rxBefore + rxAfter + txBefore + txAfter; } function formatFecOrDash(n) { if (n === null || n === undefined) return '—'; return formatFecNumber(n); } // Save ------------------------------------------------------------------ async function saveVerifyReport() { if (!state.verify) return; const headers = [ 'ONU ID', 'Name', 'Address', 'Target version', 'Active version (after)', 'Upgrade applied?', 'FEC health (before)', 'FEC health (after)', 'ONU RX Pre-FEC BER (before)', 'ONU RX Pre-FEC BER (after)', 'ONU RX Post-FEC BER (before)', 'ONU RX Post-FEC BER (after)', 'OLT RX Pre-FEC BER (before)', 'OLT RX Pre-FEC BER (after)', 'OLT RX Post-FEC BER (before)', 'OLT RX Post-FEC BER (after)', 'ONU RX Optical dBm (before)', 'ONU RX Optical dBm (after)', 'ONU TX Optical dBm (before)', 'ONU TX Optical dBm (after)', 'After sample time', 'Verdict', 'Fetch error', ]; const num = (n) => (n === null || n === undefined ? '' : n); const rows = state.verify.comparisons.map((c) => ({ 'ONU ID': c.onuId, 'Name': c.name || '', 'Address': c.address || '', 'Target version': c.target || '', 'Active version (after)': c.activeNow || '', 'Upgrade applied?': c.upgradeApplied === true ? 'yes' : c.upgradeApplied === false ? 'no' : 'unknown', 'FEC health (before)': c.fecBeforeLabel || '', 'FEC health (after)': c.fecAfterLabel || '', 'ONU RX Pre-FEC BER (before)': num(c.onuPreBefore), 'ONU RX Pre-FEC BER (after)': num(c.onuPreAfter), 'ONU RX Post-FEC BER (before)': num(c.onuPostBefore), 'ONU RX Post-FEC BER (after)': num(c.onuPostAfter), 'OLT RX Pre-FEC BER (before)': num(c.oltPreBefore), 'OLT RX Pre-FEC BER (after)': num(c.oltPreAfter), 'OLT RX Post-FEC BER (before)': num(c.oltPostBefore), 'OLT RX Post-FEC BER (after)': num(c.oltPostAfter), 'ONU RX Optical dBm (before)': num(c.rxBefore), 'ONU RX Optical dBm (after)': num(c.rxAfter), 'ONU TX Optical dBm (before)': num(c.txBefore), 'ONU TX Optical dBm (after)': num(c.txAfter), 'After sample time': c.sampleTime || '', 'Verdict': c.verdict, 'Fetch error': c.afterError ? c.afterError.message : '', })); // Derive a filename hint from the source CSV: keep its trailing // descriptor (e.g. "EV05120R-task-12onus") so the verify file // sits next to the original lexically when sorted. const baseName = (state.verify.sourcePath || '') .split(/[/\\]/).pop() .replace(/\.csv$/i, '') .replace(/^pon-upgrade-/, ''); const res = await window.api.saveCsvFile({ rows, headers, filenameHint: baseName || 'comparison', prefix: 'pon-verify', }); if (res.ok) { $('#verify-save-status').textContent = `Comparison saved → ${res.data.path}`; $('#verify-status').textContent = `Compared ${rows.length} ONUs from ${state.verify.sourcePath}.`; } else { $('#verify-save-status').textContent = `Save failed: ${res.error?.message}`; } } // -------- Utility -------- function escapeHtml(s) { if (s === null || s === undefined) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Themed confirmation modal. Replaces window.confirm + window.prompt — // the latter is NOT supported in Electron (it returns null and shows // nothing, which silently aborted the delete/flood-change flows). When // `requireWord` is set, the operator must type it exactly to enable the // confirm button (the "are you really sure" gate for destructive ops). // Resolves true only on an explicit confirm; false on cancel/Escape. function confirmTyped({ title, body, requireWord, confirmLabel = 'Confirm', danger = false } = {}) { return new Promise((resolve) => { const needWord = !!requireWord; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const input = overlay.querySelector('.modal-input'); const okBtn = overlay.querySelector('.modal-ok'); const cancelBtn = overlay.querySelector('.modal-cancel'); const matches = () => !needWord || (input && input.value === requireWord); const cleanup = (result) => { document.removeEventListener('keydown', onKey, true); overlay.remove(); resolve(result); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); cleanup(false); } else if (e.key === 'Enter' && matches()) { e.preventDefault(); cleanup(true); } }; if (input) input.addEventListener('input', () => { okBtn.disabled = !matches(); }); okBtn.addEventListener('click', () => { if (matches()) cleanup(true); }); cancelBtn.addEventListener('click', () => cleanup(false)); overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) cleanup(false); }); document.addEventListener('keydown', onKey, true); (input || okBtn).focus(); }); } // -------- OLT inspector: bulk NNI-service edits (flood + DHCP) -------- $('#btn-to-olts').addEventListener('click', () => { showView('#view-olts'); $('#olt-status').textContent = ''; $('#olt-exec-status').textContent = ''; }); $('#btn-olts-back').addEventListener('click', () => showView('#view-fleet')); $('#btn-load-olts').addEventListener('click', loadOlts); $('#olt-tag-pattern').addEventListener('input', applyOltFilters); $('#olt-mode-filter').addEventListener('change', applyOltFilters); $('#olt-name-filter').addEventListener('input', applyOltFilters); // Changing an action re-renders the per-service "→ will change" preview. $('#olt-flood-action').addEventListener('change', renderOlts); $('#olt-dhcpv4-action').addEventListener('change', renderOlts); $('#olt-dhcpv6-action').addEventListener('change', renderOlts); $('#btn-olt-select-all').addEventListener('click', () => { for (const r of state.oltFiltered) state.oltSelected.add(r._id); renderOlts(); }); $('#btn-olt-select-none').addEventListener('click', () => { state.oltSelected.clear(); renderOlts(); }); $('#olt-th-check').addEventListener('change', (e) => { if (e.target.checked) for (const r of state.oltFiltered) state.oltSelected.add(r._id); else for (const r of state.oltFiltered) state.oltSelected.delete(r._id); renderOlts(); }); async function loadOlts() { const tagPattern = $('#olt-tag-pattern').value.trim(); $('#olt-status').textContent = 'Loading OLT configs…'; const res = await window.api.listOlts({ tagPattern }); if (!res.ok) { $('#olt-status').textContent = `Error: ${res.error?.message || 'unknown'}`; return; } state.olts = res.data || []; applyOltFilters(); $('#olt-total').textContent = state.olts.length; const totalMatching = state.olts.reduce((acc, r) => acc + r.matchingNni.length, 0); const totalPriv = state.olts.reduce((acc, r) => acc + r.counts.private, 0); const totalDhcpPass = state.olts.reduce( (acc, r) => acc + r.matchingNni.filter((n) => n.dhcpv4 === 'pass' || n.dhcpv6 === 'pass').length, 0); $('#olt-status').textContent = `${state.olts.length} OLTs loaded. ${totalMatching} matching service(s); ` + `${totalPriv} private flood, ${totalDhcpPass} with DHCP on "pass".`; } // Read the three action dropdowns into a planNniEdit-shaped ops object. function oltActionOps() { const dir = (v) => (v ? { from: v.split('2')[0], to: v.split('2')[1] } : null); const flood = $('#olt-flood-action').value; return { flood: flood ? { fromMode: flood.split('2')[0], targetMode: flood.split('2')[1], ponFloodIdValue: 0 } : null, dhcpv4: dir($('#olt-dhcpv4-action').value), dhcpv6: dir($('#olt-dhcpv6-action').value), }; } // Client-side mirror of planNniEdit: which edits would hit this NNI summary // given the selected actions. DHCP edits only count when the field is present. function nniEditsFor(n, ops) { const edits = []; if (ops.flood && ops.flood.targetMode) { const okFrom = !ops.flood.fromMode || n.mode === ops.flood.fromMode; if (okFrom && n.mode !== ops.flood.targetMode) edits.push('flood'); } for (const [op, key, label] of [[ops.dhcpv4, 'dhcpv4', 'DHCPv4'], [ops.dhcpv6, 'dhcpv6', 'DHCPv6']]) { if (!op || n[key] == null) continue; const okFrom = !op.from || n[key] === op.from; if (okFrom && n[key] !== op.to) edits.push(label); } return edits; } function dhcpChip(label, val) { if (val == null) return ''; const cls = val === 'umt' ? 'status-ok' : val === 'pass' ? 'status-warn' : 'muted'; return ` ${escapeHtml(label)}=${escapeHtml(String(val))}`; } function applyOltFilters() { const mode = $('#olt-mode-filter').value; const nameQ = $('#olt-name-filter').value.trim().toLowerCase(); state.oltFiltered = state.olts.filter((r) => { if (nameQ && !(r.name || '').toLowerCase().includes(nameQ) && !(r._id || '').toLowerCase().includes(nameQ)) return false; if (mode === 'any') return r.matchingNni.length > 0; return r.matchingNni.some((n) => n.mode === mode); }); renderOlts(); } function renderOlts() { const tbody = $('#olt-tbody'); tbody.innerHTML = ''; const ops = oltActionOps(); let totalSvcThatWillChange = 0; for (const r of state.oltFiltered) { const tr = document.createElement('tr'); const selected = state.oltSelected.has(r._id); if (selected) tr.classList.add('selected'); const matchingHtml = r.matchingNni.map((n) => { const modeCls = n.mode === 'private' ? 'status-warn' : n.mode === 'auto' ? 'status-ok' : 'muted'; const edits = selected ? nniEditsFor(n, ops) : []; if (edits.length) totalSvcThatWillChange += 1; const willFlag = edits.length ? ` → will change (${escapeHtml(edits.join(', '))})` : ''; const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (id=${escapeHtml(String(n.ponFloodId))})` : ''; return `
${escapeHtml(n.tagMatch)} ${escapeHtml(n.mode)}${escapeHtml(fid)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}
`; }).join(''); tr.innerHTML = ` ${escapeHtml(r._id)} ${escapeHtml(r.name)} ${escapeHtml(r.ponMode)} ${escapeHtml(r.activeFwVersion)} ${matchingHtml || 'no matching service'} `; tbody.appendChild(tr); } $('#olt-count').textContent = state.oltFiltered.length; $('#olt-sel-count').textContent = state.oltSelected.size; $('#olt-svc-count').textContent = totalSvcThatWillChange; $('#btn-olt-execute').disabled = totalSvcThatWillChange === 0; for (const cb of tbody.querySelectorAll('input[type="checkbox"]')) { cb.addEventListener('change', () => { const id = cb.getAttribute('data-id'); if (cb.checked) state.oltSelected.add(id); else state.oltSelected.delete(id); renderOlts(); }); } } $('#btn-olt-execute').addEventListener('click', async () => { const ids = Array.from(state.oltSelected); if (!ids.length) return; const ops = oltActionOps(); const tagPattern = $('#olt-tag-pattern').value.trim(); const actionLines = []; if (ops.flood) actionLines.push(`Flooding: ${ops.flood.fromMode} → ${ops.flood.targetMode}`); if (ops.dhcpv4) actionLines.push(`DHCPv4: ${ops.dhcpv4.from} → ${ops.dhcpv4.to}`); if (ops.dhcpv6) actionLines.push(`DHCPv6: ${ops.dhcpv6.from} → ${ops.dhcpv6.to}`); if (!actionLines.length) { $('#olt-exec-status').textContent = 'Select at least one action (Flooding / DHCPv4 / DHCPv6).'; return; } const summary = `Apply to NNI services matching "${tagPattern}" on ${ids.length} OLT(s):\n • ` + actionLines.join('\n • ') + `\n\nEach OLT-CFG is fetched, the matching NNI entries edited, and the full ` + `document PUT back. No undo from this app.`; const confirmed = await confirmTyped({ title: 'Apply OLT NNI changes', body: summary, requireWord: 'APPLY', confirmLabel: 'Execute changes', danger: true, }); if (!confirmed) { $('#olt-exec-status').textContent = 'Cancelled.'; return; } $('#btn-olt-execute').disabled = true; $('#olt-exec-status').textContent = `Applying to 0/${ids.length}…`; const unbind = window.api.onFloodProgress(({ done, total, oltId, error, changes }) => { const tail = error ? ` (last error on ${oltId}: ${error.message})` : (changes && changes.length) ? ` (${oltId}: ${changes.length} svc updated)` : ''; $('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`; }); const res = await window.api.executeFloodChange({ oltIds: ids, tagPattern, ...ops }); unbind(); if (!res.ok) { $('#olt-exec-status').textContent = `Bulk apply failed: ${res.error?.message}`; $('#btn-olt-execute').disabled = false; return; } const okCount = res.data.filter((r) => r.ok && !r.noop).length; const noopCount = res.data.filter((r) => r.noop).length; const failed = res.data.filter((r) => !r.ok); let msg = `Done. ${okCount} OLT(s) updated, ${noopCount} no-op, ${failed.length} failed.`; if (failed.length) { msg += ' Failures: ' + failed.slice(0, 3).map((f) => `${f.oltId} (${f.error?.message || '?'})`).join(', '); if (failed.length > 3) msg += ` and ${failed.length - 3} more`; } $('#olt-exec-status').textContent = msg; // Auto-save a result CSV alongside the upgrade/verify ones. Each change row // lists every per-NNI edit, e.g. "s0.c76.c0: flood private→auto, DHCPv4 pass→umt". const actionsStr = actionLines.join(' | '); const headers = [ 'OLT MAC', 'OLT name', 'Actions', 'TAG pattern', 'Result', 'Services changed', 'Services skipped', 'Change detail', 'Skip detail', 'Error', ]; const rows = res.data.map((r) => { const olt = state.olts.find((o) => o._id === r.oltId); const changeDetail = (r.changes || []).map((c) => `${c.tagMatch}: ${(c.edits || []).map((e) => `${e.type} ${e.from}→${e.to}`).join(', ')}`).join('; '); const skipDetail = (r.skipped || []).map((sk) => `${sk.tagMatch}: ${sk.reason}`).join('; '); return { 'OLT MAC': r.oltId, 'OLT name': olt?.name || '', 'Actions': actionsStr, 'TAG pattern': tagPattern, 'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed', 'Services changed': (r.changes || []).length, 'Services skipped': (r.skipped || []).length, 'Change detail': changeDetail, 'Skip detail': skipDetail, 'Error': r.error ? r.error.message : '', }; }); const hintParts = []; if (ops.flood) hintParts.push(`flood-${ops.flood.targetMode}`); if (ops.dhcpv4) hintParts.push(`dhcpv4-${ops.dhcpv4.to}`); if (ops.dhcpv6) hintParts.push(`dhcpv6-${ops.dhcpv6.to}`); const saveRes = await window.api.saveCsvFile({ rows, headers, filenameHint: `${hintParts.join('-')}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`, prefix: 'pon-olt-nni', }); if (saveRes.ok) { $('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`; } // Refresh the table so the user sees the new state. await loadOlts(); }); // -------- Dashboard: network telemetry -------- // Refresh forces a fresh server fetch (bypasses the cache); the toggle and // auto-loads use the cache. Wrap so the DOM event isn't passed as `force`. $('#dash-refresh').addEventListener('click', () => loadDashboard(true)); $('#dash-extra').addEventListener('change', () => loadDashboard()); // Bit-rate formatter, ported from the iOS app's Format.bps. function fmtBps(v) { if (typeof v !== 'number' || !(v >= 0)) return '—'; const units = ['bps', 'kbps', 'Mbps', 'Gbps', 'Tbps']; let x = v, i = 0; while (x >= 1000 && i < units.length - 1) { x /= 1000; i += 1; } const digits = (i === 0 || x >= 100) ? 0 : 1; return `${x.toFixed(digits)} ${units[i]}`; } function statePillClass(stateName) { if (stateName === 'Registered') return 'status-ok'; if (DOWN_STATUSES.has(stateName)) return 'status-err'; return 'status-pending'; } function optionExists(selectSel, value) { return Array.from($(selectSel).options).some((o) => o.value === value); } function fmtDbm(n) { return typeof n === 'number' ? n.toFixed(1) : '—'; } function fmtAge(ms) { const s = Math.max(0, Math.round(ms / 1000)); if (s < 90) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; return `${Math.floor(m / 60)}h ${m % 60}m`; } // Cache-age badge. The web server reports how old its shared cache snapshot // is (`d.cache`); the Electron app sends no cache info → "live". The badge // ticks every second so the operator can see the data going stale. let dashCacheTimer = null; function renderCacheBadge(cache) { const el = $('#dash-cache'); if (!el) return; if (dashCacheTimer) { clearInterval(dashCacheTimer); dashCacheTimer = null; } if (!cache || !cache.enabled) { el.className = 'dash-cache status-ok'; el.textContent = '● live'; el.title = 'Fetched directly from MCMS (no server-side cache).'; return; } const ttl = cache.ttlMs || 0; const baseAge = cache.ageMs || 0; const receivedAt = Date.now(); const paint = () => { const age = baseAge + (Date.now() - receivedAt); const stale = ttl > 0 && age > ttl; el.className = 'dash-cache ' + (stale ? 'status-warn' : 'status-info'); el.textContent = `◷ cached ${fmtAge(age)} ago`; el.title = stale ? `Shared server cache is past its ${Math.round(ttl / 1000)}s TTL — the next load refetches from MCMS.` : `Shared server cache (TTL ${Math.round(ttl / 1000)}s) — collapses repeated MCMS requests across operators. Refresh to force-refetch.`; }; paint(); dashCacheTimer = setInterval(paint, 1000); } function dashHealthRow(title, detail, count, drillKey) { const cls = count > 0 ? 'status-warn' : 'status-ok'; const tappable = drillKey && count > 0; const chevron = tappable ? ' ' : ''; return `
${escapeHtml(title)}${detail ? `${escapeHtml(detail)}` : ''} ${count}${chevron}
`; } // Scrollable list/menu popped from a clickable dashboard stat (e.g. the // list of ONUs with abnormal Rx). `items` is [{primary, secondary}]. // `items` is [{ primary, secondary, onClick? }]. Items with onClick render // as clickable rows that close the modal then fire the handler. function showListModal(title, items) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const list = items.length ? `
${items.map((it, i) => `
${escapeHtml(it.primary)}${ it.secondary ? `${escapeHtml(it.secondary)}` : ''}${ it.onClick ? ' ' : ''}
`).join('')}
` : ''; overlay.innerHTML = ` `; document.body.appendChild(overlay); const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; for (const el of overlay.querySelectorAll('.list-modal-item.tappable')) { const it = items[Number(el.dataset.idx)]; el.addEventListener('click', () => { close(); it.onClick(); }); } overlay.querySelector('.modal-ok').addEventListener('click', close); overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); }); document.addEventListener('keydown', onKey, true); overlay.querySelector('.modal-ok').focus(); } async function loadDashboard(force) { const extra = $('#dash-extra').checked; $('#dash-status').textContent = extra ? 'Loading network + per-ONU Rx/FEC scan… (this fetches every ONU-STATE)' : 'Loading network overview…'; $('#dash-refresh').disabled = true; const res = await window.api.dashboardStats({ extraStats: extra, fresh: !!force }); $('#dash-refresh').disabled = false; if (!res.ok) { $('#dash-status').textContent = `Error: ${res.error?.message || 'unknown'}`; return; } state.dashboard = res.data; renderDashboard(res.data); } function renderDashboard(d) { // --- Count tiles --- const tiles = [ { label: 'ONUs', value: d.onuTotal, cls: '', sub: 'across all OLTs' }, { label: 'OLTs', value: d.oltCount, cls: 'tile-teal', sub: '' }, { label: 'Controllers', value: d.controllerCount === null ? '—' : d.controllerCount, cls: 'tile-indigo', sub: d.controllerCount === null ? 'not exposed on this build' : '', }, ]; $('#dash-tiles').innerHTML = tiles.map((t) => `
${escapeHtml(t.label)} ${escapeHtml(String(t.value))} ${t.sub ? `${escapeHtml(t.sub)}` : ''}
`).join(''); const sections = []; // --- Traffic --- if (d.traffic) { sections.push(`

Traffic · all OLTs

↓ Downstream${escapeHtml(fmtBps(d.traffic.downBps))}
↑ Upstream${escapeHtml(fmtBps(d.traffic.upBps))}
`); } // --- Health (rows with a count drill down to the offenders) --- const healthRows = []; if (d.optical && d.optical.available) { healthRows.push(dashHealthRow('ONUs with abnormal Rx', '< −28 or > −10 dBm', d.optical.abnormalRxCount, 'rx')); healthRows.push(dashHealthRow('ONUs with abnormal Tx', '< 3 dBm', d.optical.abnormalTxCount, 'tx')); } healthRows.push(dashHealthRow('OLTs with laser off', null, (d.lasersOff || []).length, 'laser')); sections.push(`

Health

${healthRows.join('')}
`); // --- ONU registration states (click → ONU tab, filtered) --- if (d.onuCounts && d.onuCounts.length) { const rows = d.onuCounts.map((r) => `
${escapeHtml(r.state)} ${r.count}
`).join(''); sections.push(`

ONU states

${rows}
`); } // --- ONU FEC health (only with the detailed scan) --- if (d.fecCounts) { const order = [ ['ok', 'Healthy', 'status-ok'], ['pre', 'Pre-FEC only (marginal)', 'status-warn'], ['post', 'Post-FEC errors', 'status-err'], ['buggy', 'Buggy (pre ≈ post)', 'status-info'], ['nodata', 'No FEC data', 'status-pending'], ['error', 'Fetch error', 'status-pending'], ]; const rows = order .filter(([k]) => k === 'ok' || (d.fecCounts[k] || 0) > 0) .map(([k, label, cls]) => `
${escapeHtml(label)}${d.fecCounts[k] || 0}
`) .join(''); sections.push(`

ONU FEC health

${rows}
`); } $('#dash-sections').innerHTML = sections.join(''); // Drill-down: ONU-state rows jump to the ONU tab pre-filtered; health // rows pop a list of the offending devices (like the iOS app). for (const row of $$('#dash-sections .dash-row.tappable')) { row.addEventListener('click', () => { if (row.dataset.status !== undefined) { const status = row.dataset.status; $('#filter-status').value = optionExists('#filter-status', status) ? status : ''; showView('#view-fleet'); if (!state.fleet.length) loadFleet(); else applyFilters(); return; } const drill = row.dataset.drill; if (drill === 'rx') { showListModal('ONUs with abnormal Rx', (d.optical.abnormalRx || []).map((x) => ({ primary: x.name || x.onuId, secondary: `${fmtDbm(x.rx)} dBm${x.name ? ' · ' + x.onuId : ''}`, onClick: () => openOnuInfo(x.onuId), }))); } else if (drill === 'tx') { showListModal('ONUs with abnormal Tx', (d.optical.abnormalTx || []).map((x) => ({ primary: x.name || x.onuId, secondary: `${fmtDbm(x.tx)} dBm${x.name ? ' · ' + x.onuId : ''}`, onClick: () => openOnuInfo(x.onuId), }))); } else if (drill === 'laser') { showListModal('OLTs with laser off', (d.lasersOff || []).map((x) => ({ primary: x.name || x.oltId, secondary: `${x.laser} · ${x.oltId}`, }))); } }); } const stamp = d.sampledAt ? new Date(d.sampledAt).toLocaleTimeString() : ''; $('#dash-status').textContent = `${d.onuTotal} ONUs · ${d.oltCount} OLTs${d.extraStats ? ' · detailed scan' : ''}${stamp ? ' · ' + stamp : ''}`; renderCacheBadge(d.cache); } // -------- Sub-tab navigation (ONU: Info / Fleet) -------- for (const st of $$('.subtab')) { st.addEventListener('click', () => { const view = st.dataset.subview; showView(view); if (view === '#view-onu-info') { $('#view-onu-info').classList.remove('show-detail'); // land on the picker if (state.fleet.length) renderOnuInfoList(); } }); } // "Back to list" in the narrow master-detail layout (delegated — the detail // re-renders several times). $('#onu-info-detail').addEventListener('click', (e) => { if (e.target.closest('.onu-detail-back')) { e.preventDefault(); $('#view-onu-info').classList.remove('show-detail'); } }); // -------- ONU info / stats page -------- $('#onu-info-load').addEventListener('click', async () => { await loadFleet(); renderOnuInfoList(); }); $('#onu-info-search').addEventListener('input', renderOnuInfoList); $('#onu-info-eq').addEventListener('input', renderOnuInfoList); $('#onu-info-pon').addEventListener('change', renderOnuInfoList); $('#onu-info-status').addEventListener('change', renderOnuInfoList); // Jump straight to one ONU's info page (used by dashboard drill-downs). async function openOnuInfo(onuId) { showView('#view-onu-info'); if (!state.fleet.length) await loadFleet(); renderOnuInfoList(); selectOnuInfo(onuId); } function onuInfoMatches() { const q = $('#onu-info-search').value.trim().toLowerCase(); const eq = $('#onu-info-eq').value.trim().toLowerCase(); const pon = $('#onu-info-pon').value; const status = $('#onu-info-status').value; return state.fleet.filter((cfg) => { if (status === '__down__') { if (!DOWN_STATUSES.has(onuStatus(cfg))) return false; } else if (status === '__unknown__') { if (onuStatus(cfg) !== null) return false; } else if (status) { if (onuStatus(cfg) !== status) return false; } if (pon && (cfg?.ONU?.['PON Mode'] || '') !== pon) return false; if (q) { const hay = `${cfg._id} ${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''}`.toLowerCase(); if (!hay.includes(q)) return false; } if (eq) { const versions = (cfg?.ONU?.['FW Bank Versions'] || []).join(' '); const hay = `${equipmentId(cfg)} ${versions}`.toLowerCase(); if (!hay.includes(eq)) return false; } return true; }); } function renderOnuInfoList() { const list = $('#onu-info-list'); if (!state.fleet.length) { list.innerHTML = '
Load the fleet to list ONUs.
'; $('#onu-info-status-line').textContent = 'Load the fleet, then pick an ONU.'; return; } const matched = onuInfoMatches().slice(0, 400); // cap the DOM for huge fleets list.innerHTML = matched.map((cfg) => { const st = onuStatus(cfg); const cls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending'; const sel = cfg._id === state.onuInfoSelected ? ' selected' : ''; const name = cfg?.ONU?.Name || cfg?.ONU?.Address || ''; const eqid = equipmentId(cfg); const ver = activeVersion(cfg); const seen = formatRelative(daysSince(lastSeenDate(cfg))); const meta = [eqid, ver && ver !== '(unset)' ? ver : '', seen] .filter(Boolean).map(escapeHtml).join(' · '); return `
${escapeHtml(cfg._id)} ${name ? `${escapeHtml(name)}` : ''} ${meta ? `${meta}` : ''}
`; }).join('') || '
No ONUs match.
'; const total = onuInfoMatches().length; $('#onu-info-status-line').textContent = `${total} ONU(s) match${total > matched.length ? ` (showing first ${matched.length})` : ''}.`; for (const row of list.querySelectorAll('.onu-info-row')) { row.addEventListener('click', () => selectOnuInfo(row.dataset.id)); } } function selectOnuInfo(onuId) { state.onuInfoSelected = onuId; // Narrow layout: switch from the picker to the detail (master-detail). $('#view-onu-info').classList.add('show-detail'); for (const row of $$('#onu-info-list .onu-info-row')) { row.classList.toggle('selected', row.dataset.id === onuId); } renderOnuDetail(onuId); } // Read optical + FEC straight from the merged ONU-STATE (no extra fetch). function onuClientStats(cfg) { const s = cfg?._state?.STATS || cfg?._state?.state_collection?.STATS || {}; const onu = s['ONU-PON'] || {}; const olt = s['OLT-PON'] || {}; const num = (v) => (typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null)); return { rx: num(onu['RX Optical Level']), tx: num(onu['TX Optical Level']), onuPre: num(onu['RX Pre-FEC BER']), onuPost: num(onu['RX Post-FEC BER']), oltPre: num(olt['RX Pre-FEC BER']), oltPost: num(olt['RX Post-FEC BER']), }; } function rxCls(rx) { return rx == null ? 'muted' : rx >= -28 ? 'status-ok' : rx >= -30 ? 'status-warn' : 'status-err'; } function txCls(tx) { return tx == null ? 'muted' : tx >= 3 ? 'status-ok' : 'status-err'; } function fecVerdict(s) { if ((s.onuPost ?? 0) > 0 || (s.oltPost ?? 0) > 0) return ['status-err', 'post-FEC errors']; if ((s.onuPre ?? 0) > 0 || (s.oltPre ?? 0) > 0) return ['status-warn', 'pre-FEC only']; return ['status-ok', 'clean']; } function fmtDuration(ms) { if (ms == null) return '—'; const neg = ms < 0; let s = Math.round(Math.abs(ms) / 1000); const d = Math.floor(s / 86400); s -= d * 86400; const h = Math.floor(s / 3600); s -= h * 3600; const m = Math.floor(s / 60); const parts = d ? [`${d}d`, `${h}h`] : h ? [`${h}h`, `${m}m`] : [`${m}m`]; return (neg ? '-' : '') + parts.join(' '); } function leasePill(health) { const h = health || {}; const map = { ok: ['status-ok', 'OK'], warn: ['status-warn', 'renewal overdue'], expired: ['status-err', 'EXPIRED'], unknown: ['muted', 'unknown'], }; const [cls, label] = map[h.level] || map.unknown; const rem = h.remainingMs != null ? (h.remainingMs > 0 ? ` · ${fmtDuration(h.remainingMs)} left` : ` · ${fmtDuration(h.remainingMs)} ago`) : ''; return `${label}${rem}`; } const kvRow = (k, v) => `
${escapeHtml(k)}
${v}
`; async function renderOnuDetail(onuId) { const el = $('#onu-info-detail'); const cfg = state.fleet.find((c) => c._id === onuId); if (!cfg) { el.innerHTML = '

ONU not found in the loaded fleet — reload the fleet.

'; return; } const head = `

${escapeHtml(cfg._id)}

`; // Identity + link render instantly from the loaded fleet; the rest streams // in from one getOnuDetail call. el.innerHTML = `${head}
${detailIdentityCard(cfg, null)} ${detailLinkCard(cfg)}

Loading…

Fetching OLT, traffic, UNI, alarms, CPE…

`; const res = await window.api.getOnuDetail({ onuId }); if (state.onuInfoSelected !== onuId) return; // selection moved on if (!res.ok) { el.innerHTML = `${head}
${detailIdentityCard(cfg, null)}${detailLinkCard(cfg)}

Detail

Error: ${escapeHtml(res.error?.message || 'failed')}

`; return; } const d = res.data; el.innerHTML = `${head}
${detailIdentityCard(cfg, d)} ${detailLinkCard(cfg)} ${detailTrafficCard(d)} ${detailUniCard(d)} ${detailOltUplinkCard(d)} ${detailAlarmsCard(d)}
${renderCpeCard(d.cpe)}
${detailFirmwareCard(d)}
`; // Clickable parent OLT -> OLT detail modal. for (const a of el.querySelectorAll('.olt-link, .olt-detail-btn')) { a.addEventListener('click', (e) => { e.preventDefault(); showOltModal(a.dataset.olt); }); } wireFirmwareUpgrade(onuId); } function detailIdentityCard(cfg, d) { const st = onuStatus(cfg); const stCls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending'; const activeV = (d && d.firmware && d.firmware.activeVersion) || activeVersion(cfg); const inactiveV = (d && d.firmware && d.firmware.inactiveVersion) || inactiveVersion(cfg); return `

ONU

${kvRow('Name', escapeHtml(cfg?.ONU?.Name || '—'))} ${kvRow('Address', escapeHtml(cfg?.ONU?.Address || '—'))} ${kvRow('Equipment ID', escapeHtml(equipmentId(cfg) || '—'))} ${kvRow('Vendor', escapeHtml(vendor(cfg) || '—'))} ${kvRow('PON mode', escapeHtml(cfg?.ONU?.['PON Mode'] || '—'))} ${kvRow('Status', `${escapeHtml(st || 'unknown')}`)} ${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))} ${kvRow('Active version', escapeHtml(activeV || '—'))} ${kvRow('Inactive version', escapeHtml(inactiveV || '—'))}
`; } function detailLinkCard(cfg) { const s = onuClientStats(cfg); const [fc, ftxt] = fecVerdict(s); return `

Link / optical

${kvRow('FEC', `${escapeHtml(ftxt)}`)} ${kvRow('ONU RX', `${s.rx == null ? '—' : escapeHtml(s.rx.toFixed(2)) + ' dBm'}`)} ${kvRow('ONU TX', `${s.tx == null ? '—' : escapeHtml(s.tx.toFixed(2)) + ' dBm'}`)} ${kvRow('ONU pre/post FEC', `${escapeHtml(String(s.onuPre ?? '—'))} / ${escapeHtml(String(s.onuPost ?? '—'))}`)} ${kvRow('OLT pre/post FEC', `${escapeHtml(String(s.oltPre ?? '—'))} / ${escapeHtml(String(s.oltPost ?? '—'))}`)}
`; } // Tiny inline SVG sparkline normalised to its own min/max. function sparklineSvg(values, stroke, w = 120, h = 26) { const nums = values.filter((v) => typeof v === 'number'); if (nums.length < 2) return ''; const max = Math.max(...nums); const min = Math.min(...nums); const span = (max - min) || 1; const n = values.length; const pts = values.map((v, i) => { const x = (i / (n - 1)) * w; const val = typeof v === 'number' ? v : min; const y = h - (((val - min) / span) * (h - 2)) - 1; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); return ``; } function detailTrafficCard(d) { const t = d.traffic || {}; const cur = t.current; const series = t.series || []; const win = t.windowMin || 60; if (!series.length) { return `

Traffic (last ${win} min)

No PM samples in the window.

`; } return `

Traffic (last ${win} min)

${kvRow('↓ Down now', `${escapeHtml(fmtBps(cur ? cur.downBps : null))} ${sparklineSvg(series.map((x) => x.downBps), 'var(--neon)')}`)} ${kvRow('↑ Up now', `${escapeHtml(fmtBps(cur ? cur.upBps : null))} ${sparklineSvg(series.map((x) => x.upBps), 'var(--cyan)')}`)} ${kvRow('Peak down/up', `${escapeHtml(fmtBps(t.peak.downBps))} / ${escapeHtml(fmtBps(t.peak.upBps))}`)} ${kvRow('Samples', String(series.length))}
`; } function detailUniCard(d) { const uni = d.uni || []; const rows = uni.map((u) => kvRow( escapeHtml(u.name), `${u.enable ? 'enabled' : 'disabled'} · cfg ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`, )).join(''); let losRows = ''; if (d.ethLos) { const los = d.ethLos; const cls = los.active ? 'status-err' : 'status-ok'; losRows = kvRow('Eth link', `${los.active ? 'DOWN (LAN-LOS active)' : 'up'}`) + kvRow('Last LOS', `${escapeHtml(los.lastRaised || '—')}${los.raisedCount ? ` (×${los.raisedCount})` : ''}`); } return `

UNI ports

${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}
`; } // Combined parent-OLT + upstream-switch (LLDP) card. The OLT name links to a // full OLT detail modal (showOltModal). function detailOltUplinkCard(d) { const o = d.olt; const s = d.sw; if (!o && !s) return '

Parent OLT & uplink

Unknown.

'; const oltRows = o ? ` ${kvRow('OLT', `${escapeHtml(o.name || o.mac)}`)} ${kvRow('MAC', escapeHtml(o.mac || '—'))} ${o.model ? kvRow('Model', escapeHtml(o.model)) : ''} ${kvRow('FW / PON', `${escapeHtml(o.fwVersion || '—')} · ${escapeHtml(o.ponMode || '—')}`)} ${o.location ? kvRow('Location', escapeHtml(o.location)) : ''}` : ''; const swRows = s ? ` ${kvRow('Uplink sw', escapeHtml(s.systemName || '—'))} ${kvRow('Sw port', escapeHtml(s.portDesc || s.portId || '—'))} ${s.ipv4 ? kvRow('Sw IPv4', escapeHtml(s.ipv4)) : ''} ${s.systemDesc ? kvRow('Sw descr', `${escapeHtml(s.systemDesc)}`) : ''}` : ''; return `

Parent OLT & uplink

${oltRows}${swRows}
${o ? `
` : ''}
`; } // Full OLT detail in a modal: identity, traffic/temp/ONU-counts, alarms, and // (from the loaded fleet, no extra fetch) the per-ONU FEC/optical list. async function showOltModal(oltMac) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ``; document.body.appendChild(overlay); const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); }); overlay.querySelector('.modal-ok').addEventListener('click', close); document.addEventListener('keydown', onKey, true); const res = await window.api.getOltDetail({ oltMac }); if (!document.body.contains(overlay)) return; const box = overlay.querySelector('.olt-modal'); if (!res.ok) { box.innerHTML = `

OLT ${escapeHtml(oltMac)}

Error: ${escapeHtml(res.error?.message || 'failed')}

`; box.querySelector('.modal-ok').addEventListener('click', close); return; } const d = res.data; const tr = d.traffic; const tempCls = d.tempC == null ? 'muted' : d.tempC >= 80 ? 'status-err' : d.tempC >= 70 ? 'status-warn' : 'status-ok'; const onus = state.fleet.filter((c) => c?._status?.oltMac === oltMac); const onuRows = onus.slice(0, 300).map((c) => { const cs = onuClientStats(c); const [fc, ftxt] = fecVerdict(cs); const st = onuStatus(c); const stc = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending'; return `
${escapeHtml(c._id)} ${escapeHtml(c?.ONU?.Name || '')} · ${escapeHtml(ftxt)} · RX ${cs.rx == null ? '—' : escapeHtml(cs.rx.toFixed(1))} / TX ${cs.tx == null ? '—' : escapeHtml(cs.tx.toFixed(1))} dBm
`; }).join(''); box.innerHTML = `

OLT ${escapeHtml(d.name || oltMac)} ${escapeHtml(oltMac)}

OLT

${kvRow('Model', escapeHtml(d.model || '—'))} ${kvRow('FW', escapeHtml(d.fwVersion || '—'))} ${kvRow('PON mode', escapeHtml(d.ponMode || '—'))} ${d.location ? kvRow('Location', escapeHtml(d.location)) : ''} ${d.serialNumber ? kvRow('Serial', escapeHtml(d.serialNumber)) : ''} ${d.laser ? kvRow('Laser', `${escapeHtml(d.laser.value)}`) : ''}

Traffic / health

${tr ? kvRow('↓ Down / ↑ Up', `${escapeHtml(fmtBps(tr.downBps))} / ${escapeHtml(fmtBps(tr.upBps))}`) : kvRow('Traffic', '—')} ${tr && tr.rxUtil != null ? kvRow('Util RX/TX', `${escapeHtml(String(tr.rxUtil))}% / ${escapeHtml(String(tr.txUtil))}%`) : ''} ${kvRow('Temp (ASIC)', d.tempC == null ? '—' : `${escapeHtml(String(d.tempC))} °C`)} ${kvRow('ONUs', `${d.onuCounts.total ?? '—'} total · ${d.onuCounts.online ?? '—'} online · ${d.onuCounts.offline ?? '—'} offline`)}
${detailAlarmsCard(d)}

Connected ONUs ${state.fleet.length ? `(${onus.length})` : ''}

${state.fleet.length ? `
${onuRows || ''}
` : '

Load the fleet (ONU tab) to list per-ONU FEC/optical here.

'} `; box.querySelector('.modal-ok').addEventListener('click', close); for (const el of box.querySelectorAll('.list-modal-item.tappable')) { el.addEventListener('click', () => { close(); openOnuInfo(el.dataset.onu); }); } } function sevPillClass(rank) { return rank <= 3 ? 'status-err' : rank === 4 ? 'status-warn' : rank === 5 ? 'status-info' : 'muted'; } function detailAlarmsCard(d) { const al = d.alarms || []; if (!al.length) return '

Alarms

No alarm history.

'; const shown = al.slice(0, 12); const rows = shown.map((a) => { const dot = a.active ? '' : ''; return `
${dot} ${escapeHtml(a.sevLabel || a.severity)} ${escapeHtml(a.text || a.type)} ×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}
`; }).join(''); const cls = d.alarmActiveCount ? 'status-err' : 'muted'; return `

Alarms (${d.alarmActiveCount} active / ${al.length})

${rows}${al.length > shown.length ? `

+${al.length - shown.length} more

` : ''}
`; } function detailFirmwareCard(d) { const banks = (d.firmware && d.firmware.banks) || []; const bankRows = banks.map((b) => kvRow(`Bank ${b.slot}${b.active ? ' (active)' : ''}`, `${escapeHtml(b.version || '—')}${b.file ? ` ${escapeHtml(b.file)}` : ''}`)).join(''); return `

Firmware

${bankRows || kvRow('—', 'no banks')}

Writes to the inactive bank (Procedure 7), same as the bulk tool. Reboot applies it.

`; } // Populate the single-ONU firmware picker and wire the upgrade button. Reuses // the bulk per-ONU path (executePerOnu) with a one-element selection. async function wireFirmwareUpgrade(onuId) { const sel = $('#onu-upg-select'); const btn = $('#onu-upg-btn'); const status = $('#onu-upg-status'); if (!sel || !btn) return; if (!state.firmwareList.length) { const r = await window.api.listOnuFirmware(); if (state.onuInfoSelected !== onuId) return; if (r.ok) state.firmwareList = r.data || []; } sel.innerHTML = ''; for (const fw of state.firmwareList) { const filename = fw.filename || fw._id || ''; const version = fw?.metadata?.Version || fw?.Version || ''; const o = document.createElement('option'); o.value = filename; o.dataset.version = version; o.textContent = version ? `${filename} — ${version}` : filename; sel.appendChild(o); } const sync = () => { btn.disabled = !sel.value; }; sel.addEventListener('change', sync); sync(); btn.addEventListener('click', async () => { const targetFile = sel.value; const targetVersion = sel.selectedOptions[0]?.dataset.version || ''; if (!targetFile) return; const ok = await confirmTyped({ title: 'Upgrade this ONU', body: `Stage ${targetVersion || targetFile} to the INACTIVE bank on ${onuId} (Procedure 7, per-ONU PUT). The ONU boots into the new image on its next reboot.`, requireWord: 'UPGRADE', confirmLabel: 'Upgrade', danger: true, }); if (!ok) { status.textContent = 'Cancelled.'; return; } btn.disabled = true; status.textContent = 'Upgrading…'; const unbind = window.api.onUpgradeProgress((p) => { status.textContent = `${p.phase || 'working'} ${p.onuId || ''}${p.writeSlot != null ? ` → bank ${p.writeSlot}` : ''}`; }); const res = await window.api.executePerOnu({ onuIds: [onuId], targetFile, targetVersion }); unbind(); if (!res.ok) { status.textContent = `Failed: ${res.error?.message}`; btn.disabled = false; return; } const r0 = (res.data || [])[0] || {}; status.textContent = r0.ok ? `Staged ${targetVersion || targetFile} to bank ${r0.writeSlot}. Reboot to apply.` : `Failed: ${r0.error?.message || 'unknown'}`; if (r0.ok) setTimeout(() => { if (state.onuInfoSelected === onuId) renderOnuDetail(onuId); }, 1000); }); } function renderCpeCard(cpe) { if (!cpe) return '

CPE (customer router)

No CPE / DHCP lease record for this ONU.

'; const vendor = cpe.cpeVendor ? escapeHtml(cpe.cpeVendor) : 'unknown OUI'; const head = `
${kvRow('CPE MAC', escapeHtml(cpe.cpeMac || '—'))} ${kvRow('Vendor (OUI)', vendor)} ${cpe.fwVersion ? kvRow('CPE FW', escapeHtml(cpe.fwVersion)) : ''}
`; const v4 = cpe.v4 ? `

DHCPv4 ${leasePill(cpe.v4.health)}

${kvRow('State', escapeHtml(cpe.v4.state || '—'))} ${kvRow('IP', escapeHtml(cpe.v4.ip || '—'))} ${kvRow('Lease', cpe.v4.leaseSeconds ? fmtDuration(cpe.v4.leaseSeconds * 1000) : '—')} ${kvRow('Last renew', escapeHtml(cpe.v4.lastSuccess || '—'))} ${kvRow('Expires', escapeHtml(cpe.v4.expiry || '—'))} ${cpe.v4.circuitId ? kvRow('Circuit ID', escapeHtml(cpe.v4.circuitId)) : ''}
` : '

DHCPv4 none

'; const v6 = cpe.v6 ? `

DHCPv6 ${leasePill(cpe.v6.health)}

${kvRow('State', escapeHtml(cpe.v6.state || '—'))} ${kvRow('IP', escapeHtml(cpe.v6.ip || '—'))} ${cpe.v6.prefixes && cpe.v6.prefixes.length ? kvRow('Prefix deleg.', escapeHtml(cpe.v6.prefixes.join(', '))) : ''} ${kvRow('Renew (T1)', escapeHtml(cpe.v6.t1 || '—'))} ${kvRow('Preferred till', escapeHtml(cpe.v6.preferred || '—'))} ${kvRow('Valid till', escapeHtml(cpe.v6.valid || '—'))}
` : '

DHCPv6 none

'; const ppp = cpe.pppoe ? `

PPPoE

${kvRow('State', escapeHtml(cpe.pppoe.state))}${kvRow('Session', escapeHtml(String(cpe.pppoe.sessionId)))}
` : ''; return `

CPE (customer router)

${head}${v4}${v6}${ppp}`; }