// 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 }
};
// -------- Helpers --------
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
function showView(id) {
for (const v of $$('.view')) v.classList.add('hidden');
$(id).classList.remove('hidden');
}
function activeBank(cfg) {
const ptr = cfg?.ONU?.['FW Bank Ptr'];
return typeof ptr === 'number' ? ptr : null;
}
function versionAt(cfg, slot) {
return cfg?.ONU?.['FW Bank Versions']?.[slot] || '';
}
function fileAt(cfg, slot) {
return cfg?.ONU?.['FW Bank Files']?.[slot] || '';
}
function activeVersion(cfg) {
const ptr = activeBank(cfg);
if (ptr === null || ptr === 65535) return '(unset)';
return versionAt(cfg, ptr) || '(empty)';
}
function inactiveVersion(cfg) {
const ptr = activeBank(cfg);
if (ptr === null || ptr === 65535) return '(unset)';
const other = ptr === 0 ? 1 : 0;
return versionAt(cfg, other) || '(empty)';
}
// Equipment ID is device-reported and lives on ONU-STATE (joined into
// `_state` by main.js). Some deployments also surface it under ONU-CFG
// directly; check both paths so the filter works regardless.
function equipmentId(cfg) {
return (
cfg?._state?.ONU?.['Equipment ID'] ||
cfg?._state?.['Equipment ID'] ||
cfg?.ONU?.['Equipment ID'] ||
''
);
}
function vendor(cfg) {
return (
cfg?._state?.ONU?.Vendor ||
cfg?._state?.Vendor ||
cfg?.ONU?.Vendor ||
''
);
}
// Registration bucket from OLT-STATE["ONU States"] (see 323-1961-306
// Procedure 2). null means the ONU didn't appear in any OLT bucket —
// usually a preprovisioned ONU that has never registered.
function onuStatus(cfg) {
return cfg?._status?.status || null;
}
// Statuses we consider "down" for the bulk-down filter + delete workflow.
// "Disallowed *" are admin/config states rather than fault states, but
// from a "this ONU isn't carrying traffic" perspective they behave the
// same — group them in when the user picks the "Down" aggregate.
const DOWN_STATUSES = new Set([
'Deregistered',
'Dying Gasp',
'Disabled',
'Disallowed Admin',
'Disallowed Error',
'Disallowed Reg ID',
]);
// Timestamp of the most recent MCMS update to this ONU's STATE doc. For
// a down ONU this is a proxy for "last heard from" — the OLT refreshes
// ONU-STATE on registration events, alarms, and periodic stats, so an
// ONU that's been dark for a month will have a correspondingly old Time.
function lastSeenDate(cfg) {
const t = cfg?._state?.Time;
if (!t) return null;
// MCMS emits timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC.
// Replace the space with 'T' and append 'Z' so Date parses as UTC.
const iso = String(t).replace(' ', 'T') + (String(t).endsWith('Z') ? '' : 'Z');
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
function daysSince(date) {
if (!date) return null;
return (Date.now() - date.getTime()) / 86400000;
}
function formatRelative(days) {
if (days === null || days === undefined) return '';
if (days < 1) return `${Math.round(days * 24)}h ago`;
if (days < 60) return `${Math.round(days)}d ago`;
return `${Math.round(days / 30)}mo ago`;
}
// FEC health pill. The mapping mirrors the user-guide green-LED
// criteria (323-1961-302 p149): post-FEC nonzero is the bright red
// signal, pre-FEC alone (post=0) is "FEC is doing its job, marginal"
// and shows yellow. Blue ("buggy") is added on top of the user-guide
// rules: when pre-FEC == post-FEC and both > 0, the ONU firmware is
// almost certainly mirroring one counter into the other rather than
// the link being identically broken — surface it distinctly so the
// operator doesn't waste time troubleshooting a phantom.
const FEC_LABELS = {
ok: { text: 'OK', cls: 'status-ok' },
pre: { text: 'Pre-FEC errors', cls: 'status-warn' },
post: { text: 'Post-FEC errors', cls: 'status-err' },
buggy: { text: 'Pre==Post (ONU bug?)', cls: 'status-info' },
nodata: { text: 'no recent stats', cls: 'muted' },
error: { text: 'fetch failed', cls: 'muted' },
pending: { text: 'checking…', cls: 'muted' },
};
// Mirror of formatFecNumber in src/mcms-api.js. Kept in the renderer so
// we don't have to round-trip through IPC to format a number — and so
// we can re-format if the structure ever changes.
function formatFecNumber(n) {
if (n === 0) return '0';
const abs = Math.abs(n);
if (Number.isInteger(n)) return String(n);
if (abs < 0.01 || abs >= 1e6) return n.toExponential(2);
return Number(n.toPrecision(3)).toString();
}
function fecCountersHtml(health) {
const list = Array.isArray(health?.counters) ? health.counters : [];
const optical = health?.optical || {};
if (!list.length && optical.rx == null && optical.tx == null) return '';
const lines = [];
// FEC counters: ONU side first (most relevant — the ONU is the device
// we're about to upgrade), then OLT side. Skip counters whose scope
// we don't recognise.
const order = (c) => (c.scope === 'onu' ? 0 : c.scope === 'olt' ? 1 : 2);
const sorted = [...list].sort((a, b) => {
const so = order(a) - order(b);
if (so) return so;
return a.kind === b.kind ? 0 : a.kind === 'pre' ? -1 : 1;
});
for (const c of sorted) {
const cls = c.value > 0 ? c.kind : 'ok';
lines.push(
`${escapeHtml(c.tail)}=${escapeHtml(formatFecNumber(c.value))}`,
);
}
// Optical levels (dBm). User-guide green-LED threshold is RX >= -30
// dBm and TX >= 3 dBm (323-1961-302 p149). We highlight RX in red
// below -30 and yellow as it approaches the floor (-28 dBm), so the
// operator sees the marginal links during the FEC sweep.
if (optical.rx != null || optical.tx != null) {
const opticalParts = [];
if (optical.rx != null) {
const rxCls = optical.rx < -30 ? 'post' : optical.rx < -28 ? 'pre' : 'ok';
opticalParts.push(
`RX ${escapeHtml(optical.rx.toFixed(1))} dBm`,
);
}
if (optical.tx != null) {
const txCls = optical.tx < 3 ? 'post' : 'ok';
opticalParts.push(
`TX ${escapeHtml(optical.tx.toFixed(1))} dBm`,
);
}
lines.push(opticalParts.join(' '));
}
return `${lines.join(' ')}`;
}
function fecPillHtml(health) {
const f = (health && health.flag) || 'pending';
const label = FEC_LABELS[f] || FEC_LABELS.pending;
const title = health?.detail || health?.error?.message || '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return (
`${escapeHtml(label.text)}` +
fecCountersHtml(health)
);
}
// -------- Login --------
$('#btn-login').addEventListener('click', async () => {
$('#login-error').textContent = '';
const baseUrl = $('#in-host').value.trim();
const username = $('#in-user').value.trim();
const password = $('#in-pass').value;
const acceptSelfSigned = $('#in-self-signed').checked;
if (!baseUrl || !username || !password) {
$('#login-error').textContent = 'Host, username and password are required.';
return;
}
$('#btn-login').disabled = true;
$('#btn-login').textContent = 'Connecting…';
const res = await window.api.login({ baseUrl, username, password, acceptSelfSigned });
$('#btn-login').disabled = false;
$('#btn-login').textContent = 'Connect';
if (!res.ok) {
$('#login-error').textContent = res.error?.message || 'Login failed';
return;
}
$('#session-label').textContent = `${username} @ ${baseUrl}`;
$('#btn-logout').classList.remove('hidden');
showView('#view-fleet');
});
$('#btn-logout').addEventListener('click', async () => {
await window.api.logout();
$('#session-label').textContent = 'Not connected';
$('#btn-logout').classList.add('hidden');
state.fleet = []; state.filtered = []; state.selected.clear();
showView('#view-login');
});
// -------- 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 = `