ponfw/renderer/app.js
Jon Vanvik 2046fad0f8 onu detail polish + clickable OLT detail modal
- Fix alarm card overflow: alarm rows stack (severity+text on one line that
  wraps, meta below) instead of squeezing the text to ~0 width next to the
  nowrap meta — which had made long alarm text render one char per line in
  narrow columns. Also min-width:0 on cards/kv so long IPv6/descr strings
  don't blow out the grid track.
- Combine Parent OLT + Upstream switch into one "Parent OLT & uplink" card.
- ONU list/search de-compacted: add equipment/version + PON-mode filters;
  two-line rows (serial+status, then equipment · version · last-seen).
- UNI card labels the speed as configured ("cfg Auto/Auto"); negotiated
  speed isn't in the payloads — link up/down still comes from LAN-LOS.

Clickable parent OLT -> OLT detail modal (showOltModal):
- new src/oltdetail.js (pure, shared; reuses summarizeAlarms) + getOltState
  / getOltAlarms client methods + api:getOltDetail (main + web + bridges).
- shows identity, traffic + util, ASIC temperature, ONU counts, laser,
  alarms, upstream switch; plus a connected-ONU list with per-ONU
  FEC/optical built from the loaded fleet (filtered by _status.oltMac),
  each row clickable back into that ONU's detail.

Docs: CLAUDE.md §17 + IPC table.

Verified: node --check; oltdetail.js unit-tested 9/9 (fw active-bank,
traffic down=TX/up=RX, util, ASIC temp, ONU counts, laser, LLDP, alarms);
rendered the OLT modal + richer list offscreen and confirmed the alarm-text
wrap fix (alarm head ~1-2 lines, not vertical).

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

2317 lines
97 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(
`<span class="${cls}">${escapeHtml(c.tail)}=${escapeHtml(formatFecNumber(c.value))}</span>`,
);
}
// 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(
`<span class="${rxCls}">RX ${escapeHtml(optical.rx.toFixed(1))} dBm</span>`,
);
}
if (optical.tx != null) {
const txCls = optical.tx < 3 ? 'post' : 'ok';
opticalParts.push(
`<span class="${txCls}">TX ${escapeHtml(optical.tx.toFixed(1))} dBm</span>`,
);
}
lines.push(opticalParts.join(' '));
}
return `<span class="fec-counters">${lines.join('<br/>')}</span>`;
}
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 (
`<span class="fec-pill ${label.cls}"${titleAttr}>${escapeHtml(label.text)}</span>` +
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;
const acceptSelfSigned = $('#in-self-signed').checked;
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, acceptSelfSigned });
$('#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();
});
}
// -------- 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 = `
<td><input type="checkbox" data-id="${id}" ${state.selected.has(id) ? 'checked' : ''}/></td>
<td>${escapeHtml(id)}</td>
<td>${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')}</td>
<td>${escapeHtml(equipmentId(cfg))}</td>
<td>${escapeHtml(cfg?.ONU?.['PON Mode'] || '')}</td>
<td class="${statusCls}">${escapeHtml(statusText)}</td>
<td>${seenDays === null ? '<span class="muted">—</span>' : escapeHtml(formatRelative(seenDays))}</td>
<td>${ptr === null || ptr === 65535 ? '<span class="muted">unset</span>' : ptr}</td>
<td>${escapeHtml(activeVersion(cfg))}</td>
<td>${escapeHtml(inactiveVersion(cfg))}</td>
`;
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/<id>/ 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 = `
<td>${escapeHtml(p.onuId)}</td>
<td colspan="5"></td>
<td class="fec-cell muted">—</td>
<td class="status-err">${escapeHtml(p.error)}</td>`;
} else {
const prevPtr = p.previousActiveSlot;
const arrow = `<span class="slot-arrow">${prevPtr}${p.writeSlot}</span>`;
const fec = state.fecHealth.get(p.onuId);
tr.innerHTML = `
<td>${escapeHtml(p.onuId)}</td>
<td>${escapeHtml(p.name || p.address || '')}</td>
<td>${arrow}</td>
<td>${escapeHtml(p.currentVersionInSlot0)}</td>
<td>${escapeHtml(p.currentVersionInSlot1)}</td>
<td>${escapeHtml(p.targetVersion)}</td>
<td class="fec-cell">${fecPillHtml(fec)}</td>
<td class="status-pending">ready</td>`;
}
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 = '<div class="muted small">Open a CSV to start.</div>';
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 =
`<span class="${FEC_LABELS[c.beforeFlag]?.cls || 'muted'} fec-pill">${escapeHtml(c.fecBeforeLabel || c.beforeFlag)}</span>` +
`<span class="fec-counters">` +
`<span>pre=${escapeHtml(formatFecOrDash(c.onuPreBefore))}</span><br/>` +
`<span>post=${escapeHtml(formatFecOrDash(c.onuPostBefore))}</span>` +
`</span>`;
const afterFecBlock =
`<span class="${FEC_LABELS[c.afterFlag]?.cls || 'muted'} fec-pill">${escapeHtml(c.fecAfterLabel)}</span>` +
`<span class="fec-counters">` +
`<span>pre=${escapeHtml(formatFecOrDash(c.onuPreAfter))}</span><br/>` +
`<span>post=${escapeHtml(formatFecOrDash(c.onuPostAfter))}</span>` +
`</span>`;
const opticalBlock = renderOpticalCompare(c);
const targetCol = c.target
? `<div>${escapeHtml(c.target)}</div><div class="muted small">now: ${escapeHtml(c.activeNow || '?')}</div>`
: `<div class="muted">${escapeHtml(c.activeNow || '?')}</div>`;
tr.innerHTML = `
<td>${escapeHtml(c.onuId)}</td>
<td>${escapeHtml(c.name || '')}</td>
<td>${targetCol}</td>
<td class="${appliedCls}">${appliedTxt}</td>
<td class="fec-cell">${beforeFecBlock}</td>
<td class="fec-cell">${afterFecBlock}</td>
<td class="fec-cell">${opticalBlock}</td>
<td class="${verdictCls(c.verdict)}">${escapeHtml(c.verdict)}</td>
`;
tbody.appendChild(tr);
}
$('#verify-count').textContent = comparisons.length;
$('#verify-summary').innerHTML = `
<div><strong>${tally.applied}</strong> upgrade applied · <strong>${tally.notApplied}</strong> not applied</div>
<div><span class="status-ok">${tally.fixed} improved/fixed</span></div>
<div><span class="status-ok">${tally.healthy} still healthy</span></div>
<div><span class="status-info">${tally.stillBuggy} still buggy</span></div>
<div><span class="status-err">${tally.regressed} regressed/failed</span></div>
<div class="muted">${tally.other} other</div>
`;
}
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 = `<span class="before-line">RX before: ${escapeHtml(fmt(c.rxBefore))}</span>`;
const rxAfter = `<span class="after-line ${c.rxAfter == null ? 'muted' : cls(c.rxAfter)}">RX now: ${escapeHtml(fmt(c.rxAfter))}</span>`;
const txBefore = `<span class="before-line">TX before: ${escapeHtml(fmt(c.txBefore))}</span>`;
const txAfter = `<span class="after-line ${c.txAfter == null ? 'muted' : (c.txAfter < 3 ? 'status-err' : 'status-ok')}">TX now: ${escapeHtml(fmt(c.txAfter))}</span>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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 = `
<div class="modal ${danger ? 'modal-danger' : ''}" role="dialog" aria-modal="true">
<h3>${escapeHtml(title || 'Confirm')}</h3>
<div class="modal-body">${escapeHtml(body || '').replace(/\n/g, '<br>')}</div>
${needWord ? `<label class="modal-type">
<span class="modal-type-label">Type <code>${escapeHtml(requireWord)}</code> to confirm</span>
<input type="text" class="modal-input" autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>` : ''}
<div class="modal-actions">
<button class="ghost modal-cancel" type="button">Cancel</button>
<button class="${danger ? 'danger' : 'primary'} modal-ok" type="button" ${needWord ? 'disabled' : ''}>${escapeHtml(confirmLabel)}</button>
</div>
</div>`;
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 ` <span class="${cls}">${escapeHtml(label)}=${escapeHtml(String(val))}</span>`;
}
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 ? ` <span class="status-info">→ will change (${escapeHtml(edits.join(', '))})</span>` : '';
const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (id=${escapeHtml(String(n.ponFloodId))})` : '';
return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}</div>`;
}).join('');
tr.innerHTML = `
<td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td>
<td>${escapeHtml(r._id)}</td>
<td>${escapeHtml(r.name)}</td>
<td>${escapeHtml(r.ponMode)}</td>
<td>${escapeHtml(r.activeFwVersion)}</td>
<td class="fec-cell">${matchingHtml || '<span class="muted">no matching service</span>'}</td>
`;
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 ? ' <span class="dash-chevron"></span>' : '';
return `<div class="dash-row${tappable ? ' tappable' : ''}"${drillKey ? ` data-drill="${drillKey}"` : ''}>
<span class="dash-row-main">${escapeHtml(title)}${detail ? `<span class="dash-row-sub">${escapeHtml(detail)}</span>` : ''}</span>
<span class="dash-num ${cls}">${count}${chevron}</span>
</div>`;
}
// 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
? `<div class="list-modal-items">${items.map((it, i) =>
`<div class="list-modal-item${it.onClick ? ' tappable' : ''}" data-idx="${i}"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</span>` : ''}${
it.onClick ? ' <span class="dash-chevron"></span>' : ''}</div>`).join('')}</div>`
: '<div class="modal-body muted">Nothing to show — all clear.</div>';
overlay.innerHTML = `
<div class="modal list-modal" role="dialog" aria-modal="true">
<h3>${escapeHtml(title)} <span class="muted">(${items.length})</span></h3>
${list}
<div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>
</div>`;
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) => `
<div class="tile ${t.cls}">
<span class="tile-label">${escapeHtml(t.label)}</span>
<span class="tile-value">${escapeHtml(String(t.value))}</span>
${t.sub ? `<span class="tile-sub">${escapeHtml(t.sub)}</span>` : ''}
</div>`).join('');
const sections = [];
// --- Traffic ---
if (d.traffic) {
sections.push(`
<div class="dash-section">
<h3>Traffic · all OLTs</h3>
<div class="dash-card">
<div class="dash-traffic">
<div class="t-block"><span class="t-label">↓ Downstream</span><span class="t-val t-down">${escapeHtml(fmtBps(d.traffic.downBps))}</span></div>
<div class="t-block"><span class="t-label">↑ Upstream</span><span class="t-val t-up">${escapeHtml(fmtBps(d.traffic.upBps))}</span></div>
</div>
</div>
</div>`);
}
// --- 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(`
<div class="dash-section">
<h3>Health</h3>
<div class="dash-card">${healthRows.join('')}</div>
</div>`);
// --- ONU registration states (click → ONU tab, filtered) ---
if (d.onuCounts && d.onuCounts.length) {
const rows = d.onuCounts.map((r) => `
<div class="dash-row tappable" data-status="${escapeHtml(r.state)}" title="Show these in the ONU tab">
<span class="dash-pill ${statePillClass(r.state)}">${escapeHtml(r.state)}</span>
<span class="dash-num">${r.count}</span>
</div>`).join('');
sections.push(`<div class="dash-section"><h3>ONU states</h3><div class="dash-card">${rows}</div></div>`);
}
// --- 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]) =>
`<div class="dash-row"><span class="${cls}">${escapeHtml(label)}</span><span class="dash-num">${d.fecCounts[k] || 0}</span></div>`)
.join('');
sections.push(`<div class="dash-section"><h3>ONU FEC health</h3><div class="dash-card">${rows}</div></div>`);
}
$('#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' && state.fleet.length) renderOnuInfoList();
});
}
// -------- 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 = '<div class="muted small" style="padding:10px">Load the fleet to list ONUs.</div>';
$('#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(' <span class="sep">·</span> ');
return `<div class="onu-info-row${sel}" data-id="${escapeHtml(cfg._id)}">
<span class="oir-serial">${escapeHtml(cfg._id)} <span class="${cls}">●</span></span>
${name ? `<span class="oir-sub">${escapeHtml(name)}</span>` : ''}
${meta ? `<span class="oir-meta">${meta}</span>` : ''}
</div>`;
}).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>';
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;
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 `<span class="lease-pill ${cls}">${label}${rem}</span>`;
}
const kvRow = (k, v) => `<dt>${escapeHtml(k)}</dt><dd>${v}</dd>`;
async function renderOnuDetail(onuId) {
const el = $('#onu-info-detail');
const cfg = state.fleet.find((c) => c._id === onuId);
if (!cfg) { el.innerHTML = '<p class="muted">ONU not found in the loaded fleet — reload the fleet.</p>'; return; }
const head = `<h2>${escapeHtml(cfg._id)}</h2>`;
// Identity + link render instantly from the loaded fleet; the rest streams
// in from one getOnuDetail call.
el.innerHTML = `${head}<div class="detail-grid">
${detailIdentityCard(cfg, null)}
${detailLinkCard(cfg)}
<div class="detail-card"><h3>Loading…</h3><p class="muted small">Fetching OLT, traffic, UNI, alarms, CPE…</p></div>
</div>`;
const res = await window.api.getOnuDetail({ onuId });
if (state.onuInfoSelected !== onuId) return; // selection moved on
if (!res.ok) {
el.innerHTML = `${head}<div class="detail-grid">
${detailIdentityCard(cfg, null)}${detailLinkCard(cfg)}
<div class="detail-card"><h3>Detail</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p></div>
</div>`;
return;
}
const d = res.data;
el.innerHTML = `${head}<div class="detail-grid">
${detailIdentityCard(cfg, d)}
${detailLinkCard(cfg)}
${detailTrafficCard(d)}
${detailUniCard(d)}
${detailOltUplinkCard(d)}
${detailAlarmsCard(d)}
<div class="detail-card">${renderCpeCard(d.cpe)}</div>
${detailFirmwareCard(d)}
</div>`;
// 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 `<div class="detail-card"><h3>ONU</h3><dl class="kv">
${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', `<span class="${stCls}">${escapeHtml(st || 'unknown')}</span>`)}
${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))}
${kvRow('Active version', escapeHtml(activeV || '—'))}
${kvRow('Inactive version', escapeHtml(inactiveV || '—'))}
</dl></div>`;
}
function detailLinkCard(cfg) {
const s = onuClientStats(cfg);
const [fc, ftxt] = fecVerdict(s);
return `<div class="detail-card"><h3>Link / optical</h3><dl class="kv">
${kvRow('FEC', `<span class="${fc}">${escapeHtml(ftxt)}</span>`)}
${kvRow('ONU RX', `<span class="${rxCls(s.rx)}">${s.rx == null ? '—' : escapeHtml(s.rx.toFixed(2)) + ' dBm'}</span>`)}
${kvRow('ONU TX', `<span class="${txCls(s.tx)}">${s.tx == null ? '—' : escapeHtml(s.tx.toFixed(2)) + ' dBm'}</span>`)}
${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 ?? '—'))}`)}
</dl></div>`;
}
// 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 `<svg class="spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="1.5"/></svg>`;
}
function detailTrafficCard(d) {
const t = d.traffic || {};
const cur = t.current;
const series = t.series || [];
const win = t.windowMin || 60;
if (!series.length) {
return `<div class="detail-card"><h3>Traffic (last ${win} min)</h3><p class="muted small">No PM samples in the window.</p></div>`;
}
return `<div class="detail-card"><h3>Traffic (last ${win} min)</h3><dl class="kv">
${kvRow('↓ Down now', `<span class="status-ok">${escapeHtml(fmtBps(cur ? cur.downBps : null))}</span> ${sparklineSvg(series.map((x) => x.downBps), 'var(--neon)')}`)}
${kvRow('↑ Up now', `<span class="status-info">${escapeHtml(fmtBps(cur ? cur.upBps : null))}</span> ${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))}
</dl></div>`;
}
function detailUniCard(d) {
const uni = d.uni || [];
const rows = uni.map((u) => kvRow(
escapeHtml(u.name),
`${u.enable ? '<span class="status-ok">enabled</span>' : '<span class="muted">disabled</span>'} · <span class="muted">cfg</span> ${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', `<span class="${cls}">${los.active ? 'DOWN (LAN-LOS active)' : 'up'}</span>`)
+ kvRow('Last LOS', `${escapeHtml(los.lastRaised || '—')}${los.raisedCount ? ` (×${los.raisedCount})` : ''}`);
}
return `<div class="detail-card"><h3>UNI ports</h3><dl class="kv">${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}</dl></div>`;
}
// 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 '<div class="detail-card"><h3>Parent OLT &amp; uplink</h3><p class="muted small">Unknown.</p></div>';
const oltRows = o ? `
${kvRow('OLT', `<a class="olt-link" data-olt="${escapeHtml(o.mac)}">${escapeHtml(o.name || o.mac)}</a>`)}
${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', `<span class="small muted">${escapeHtml(s.systemDesc)}</span>`) : ''}` : '';
return `<div class="detail-card"><h3>Parent OLT &amp; uplink</h3><dl class="kv">${oltRows}${swRows}</dl>
${o ? `<div class="row" style="margin-top:10px"><button class="ghost olt-detail-btn" data-olt="${escapeHtml(o.mac)}">View OLT details →</button></div>` : ''}</div>`;
}
// 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 = `<div class="modal olt-modal"><h3>OLT ${escapeHtml(oltMac)}</h3><p class="muted small">Loading…</p><div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div></div>`;
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 = `<h3>OLT ${escapeHtml(oltMac)}</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p><div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>`;
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 `<div class="list-modal-item tappable" data-onu="${escapeHtml(c._id)}">
<span class="lm-primary">${escapeHtml(c._id)} <span class="${stc}">●</span></span>
<span class="lm-secondary">${escapeHtml(c?.ONU?.Name || '')} · <span class="${fc}">${escapeHtml(ftxt)}</span> · RX ${cs.rx == null ? '—' : escapeHtml(cs.rx.toFixed(1))} / TX ${cs.tx == null ? '—' : escapeHtml(cs.tx.toFixed(1))} dBm</span>
</div>`;
}).join('');
box.innerHTML = `
<h3>OLT ${escapeHtml(d.name || oltMac)} <span class="muted">${escapeHtml(oltMac)}</span></h3>
<div class="olt-modal-grid">
<div class="detail-card"><h3>OLT</h3><dl class="kv">
${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', `<span class="${d.laser.off ? 'status-err' : 'status-ok'}">${escapeHtml(d.laser.value)}</span>`) : ''}
</dl></div>
<div class="detail-card"><h3>Traffic / health</h3><dl class="kv">
${tr ? kvRow('↓ Down / ↑ Up', `<span class="status-ok">${escapeHtml(fmtBps(tr.downBps))}</span> / <span class="status-info">${escapeHtml(fmtBps(tr.upBps))}</span>`) : kvRow('Traffic', '—')}
${tr && tr.rxUtil != null ? kvRow('Util RX/TX', `${escapeHtml(String(tr.rxUtil))}% / ${escapeHtml(String(tr.txUtil))}%`) : ''}
${kvRow('Temp (ASIC)', d.tempC == null ? '—' : `<span class="${tempCls}">${escapeHtml(String(d.tempC))} °C</span>`)}
${kvRow('ONUs', `${d.onuCounts.total ?? '—'} total · ${d.onuCounts.online ?? '—'} online · ${d.onuCounts.offline ?? '—'} offline`)}
</dl></div>
${detailAlarmsCard(d)}
</div>
<h3 style="margin-top:14px">Connected ONUs ${state.fleet.length ? `<span class="muted">(${onus.length})</span>` : ''}</h3>
${state.fleet.length
? `<div class="list-modal-items olt-onu-list">${onuRows || '<div class="modal-body muted">None on this OLT in the loaded fleet.</div>'}</div>`
: '<p class="muted small">Load the fleet (ONU tab) to list per-ONU FEC/optical here.</p>'}
<div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>`;
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 '<div class="detail-card"><h3>Alarms</h3><p class="muted small">No alarm history.</p></div>';
const shown = al.slice(0, 12);
const rows = shown.map((a) => {
const dot = a.active ? '<span class="status-err">●</span>' : '<span class="muted">○</span>';
return `<div class="alarm-row">
<span class="alarm-head">${dot} <span class="lease-pill ${sevPillClass(a.sevRank)}">${escapeHtml(a.sevLabel || a.severity)}</span> <span class="alarm-text">${escapeHtml(a.text || a.type)}</span></span>
<span class="alarm-meta">×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}</span>
</div>`;
}).join('');
const cls = d.alarmActiveCount ? 'status-err' : 'muted';
return `<div class="detail-card"><h3>Alarms <span class="${cls}">(${d.alarmActiveCount} active / ${al.length})</span></h3>${rows}${al.length > shown.length ? `<p class="muted small">+${al.length - shown.length} more</p>` : ''}</div>`;
}
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 ? ` <span class="muted small">${escapeHtml(b.file)}</span>` : ''}`)).join('');
return `<div class="detail-card"><h3>Firmware</h3><dl class="kv">${bankRows || kvRow('—', 'no banks')}</dl>
<div class="fw-upg">
<label>Upgrade to<select id="onu-upg-select"><option value="">Loading firmware…</option></select></label>
<div class="row"><button id="onu-upg-btn" class="danger" disabled>Upgrade this ONU →</button></div>
<p class="small muted" id="onu-upg-status">Writes to the inactive bank (Procedure 7), same as the bulk tool. Reboot applies it.</p>
</div></div>`;
}
// 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 = '<option value="">— choose firmware —</option>';
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 '<h3>CPE (customer router)</h3><p class="muted">No CPE / DHCP lease record for this ONU.</p>';
const vendor = cpe.cpeVendor ? escapeHtml(cpe.cpeVendor) : '<span class="muted">unknown OUI</span>';
const head = `<dl class="kv">
${kvRow('CPE MAC', escapeHtml(cpe.cpeMac || '—'))}
${kvRow('Vendor (OUI)', vendor)}
${cpe.fwVersion ? kvRow('CPE FW', escapeHtml(cpe.fwVersion)) : ''}
</dl>`;
const v4 = cpe.v4 ? `
<h3 style="margin-top:14px">DHCPv4 ${leasePill(cpe.v4.health)}</h3>
<dl class="kv">
${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)) : ''}
</dl>` : '<h3 style="margin-top:14px">DHCPv4 <span class="lease-pill muted">none</span></h3>';
const v6 = cpe.v6 ? `
<h3 style="margin-top:14px">DHCPv6 ${leasePill(cpe.v6.health)}</h3>
<dl class="kv">
${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 || '—'))}
</dl>` : '<h3 style="margin-top:14px">DHCPv6 <span class="lease-pill muted">none</span></h3>';
const ppp = cpe.pppoe ? `<h3 style="margin-top:14px">PPPoE</h3><dl class="kv">${kvRow('State', escapeHtml(cpe.pppoe.state))}${kvRow('Session', escapeHtml(String(cpe.pppoe.sessionId)))}</dl>` : '';
return `<h3>CPE (customer router)</h3>${head}${v4}${v6}${ppp}`;
}