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