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>
1744 lines
68 KiB
JavaScript
1744 lines
68 KiB
JavaScript
// 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, '&')
|
||
.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 = `
|
||
<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 : ''}`;
|
||
}
|