ponfw/renderer/app.js
Jon Vanvik 96b0264179 Add multi-user web server variant (browser UI behind a reverse proxy)
New web/ subfolder: a Node http server that serves the SAME tooling as the
Electron app to any browser, with per-session MCMS credentials so several
operators can use it at once behind a TLS reverse proxy. Mobile-responsive.

Not a fork — it reuses the core and the UI:
- serves renderer/app.js + app.css verbatim and rewrites index.html on the
  fly (mobile viewport + browser window.api shim + responsive overlay)
- web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming;
  Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob
- web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry,
  routes mirroring the IPC handlers, NDJSON for the 6 streaming ops
- pure Node http, no new deps (borrows ../node_modules tough-cookie)

Shared improvements (benefit both apps):
- src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses
  it); adds abnormal-Tx detection alongside abnormal-Rx
- renderer: dashboard health stats are now clickable — abnormal Rx / Tx /
  lasers-off pop a list of the offending devices (like the iOS app), via a
  new showListModal

Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16.

Verified: node --check all; started the server and confirmed transformed
index, shared-asset serving, 401 auth gate, login (per-session client), and
NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP.

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

1744 lines
68 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)
};
// -------- 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-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);
}
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 PON flooding mode change --------
$('#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);
$('#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);
$('#olt-status').textContent =
`${state.olts.length} OLTs loaded. ${totalMatching} service(s) match TAG pattern; ${totalPriv} currently in private mode.`;
}
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 mode = $('#olt-mode-filter').value;
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 willChangeMode = mode === 'auto' ? 'auto' : 'private';
const matchingHtml = r.matchingNni.map((n) => {
const modeCls = n.mode === 'private' ? 'status-warn' : n.mode === 'auto' ? 'status-ok' : 'muted';
const willChange = selected && n.mode === willChangeMode;
if (willChange) totalSvcThatWillChange += 1;
const willFlag = willChange ? ' <span class="status-info">→ will change</span>' : '';
const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (PON FLOOD ID=${escapeHtml(String(n.ponFloodId))})` : '';
return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${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 action = $('#olt-action').value;
const fromMode = action === 'auto2private' ? 'auto' : 'private';
const targetMode = action === 'auto2private' ? 'private' : 'auto';
const tagPattern = $('#olt-tag-pattern').value.trim();
const summary =
`Apply "${fromMode}${targetMode}" to NNI services matching "${tagPattern}" ` +
`on ${ids.length} OLT(s).\n\nThis GETs each OLT-CFG, mutates the matching ` +
`NNI Networks entries, and PUTs the full document back. The operation is ` +
`irreversible from this app (no undo).`;
const confirmed = await confirmTyped({
title: 'Apply PON flooding-mode change',
body: summary,
requireWord: 'APPLY',
confirmLabel: 'Execute change',
danger: true,
});
if (!confirmed) {
$('#olt-exec-status').textContent = 'Cancelled.';
return;
}
$('#btn-olt-execute').disabled = true;
$('#olt-exec-status').textContent = `Applying change to 0/${ids.length}`;
const unbind = window.api.onFloodProgress(({ done, total, oltId, error, changes }) => {
const tail = error ? ` (last error on ${oltId}: ${error.message})`
: changes ? ` (${oltId}: ${changes.length} svc updated)` : '';
$('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`;
});
const res = await window.api.executeFloodChange({
oltIds: ids, tagPattern, fromMode, targetMode,
});
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.
const headers = [
'OLT MAC', 'OLT name', 'Action', '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.fromMode}${c.toMode}`).join('; ');
const skipDetail = (r.skipped || []).map((s) => `${s.tagMatch}: ${s.reason}`).join('; ');
return {
'OLT MAC': r.oltId,
'OLT name': olt?.name || '',
'Action': `${fromMode}${targetMode}`,
'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 saveRes = await window.api.saveCsvFile({
rows, headers,
filenameHint: `${fromMode}-to-${targetMode}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`,
prefix: 'pon-olt-flood',
});
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 --------
$('#dash-refresh').addEventListener('click', loadDashboard);
$('#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 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}].
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) =>
`<div class="list-modal-item"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</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(); } };
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() {
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 });
$('#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 : ''}`,
})));
} 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 : ''}`,
})));
} 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 : ''}`;
}