onu: info/stats sub-tab with CPE lease health + OUI vendor

Adds an ONU info page and splits the ONU tab into two sub-tabs (Info / stats,
Fleet & upgrade). The ONU tab now lands on Info; campaign/verify keep the
Fleet sub-tab lit.

ONU info page (#view-onu-info): master-detail over the already-loaded fleet
(search + status filter, no new bulk fetch). Per-ONU detail shows identity,
link/optical + FEC (read from the merged ONU-STATE), firmware, and a CPE card.

CPE (customer router):
- McmsClient.getCpe — GET /api/cpe/onu/<id>/ (unversioned, [status,doc] tuple).
- src/cpe.js (pure, shared): summarizeCpe + leaseHealth. Renewal rule —
  <50% of the lease remaining => "renewal overdue" (amber), past end =>
  expired. v4 expiry = DHCP "Remove Time"; v6 spans Last Success ->
  Preferred Lifetime (T1 midpoint), with Prefix Delegation surfaced.
- api:getOnuCpe (main.js + web/server.js, exposed in both bridges).

OUI vendor (src/oui.js, pure, shared): lazy fetch of the IEEE oui.csv,
prefix->org map, cached in memory ~30 days + best-effort to disk
(PFW_CACHE_DIR / temp; PrivateTmp makes /tmp writable). Resolved server-side
in getOnuCpe; every failure degrades to "unknown OUI". CPE itself is not
TTL-cached (lease must be live); only the OUI list is.

Dashboard abnormal Rx/Tx rows are now clickable -> deep-link to that ONU's
info page (showListModal gained per-item onClick).

Docs: CLAUDE.md §17 + IPC table + intro; README.

Verified: node --check; cpe.js/oui.js unit-tested (15/15: lease ok/warn/
expired/unknown + 50% boundary, v6 prefix parse, OUI quoted-CSV parse +
lookup); ONU info page + CPE card rendered offscreen over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 15:19:28 +02:00
parent 5e3d9271a8
commit 55e95ed467
12 changed files with 677 additions and 7 deletions

View file

@ -480,6 +480,94 @@ button.danger:hover:not(:disabled) {
box-shadow: 0 0 12px rgba(33, 200, 255, 0.45), inset 0 0 14px rgba(33, 200, 255, 0.12);
}
/* -------- Sub-tabs (within the ONU tab) -------- */
.subtabs {
display: flex;
gap: 6px;
margin: -4px 0 4px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.subtabs .subtab {
flex: 1;
background: transparent;
border: 1px solid var(--border);
color: var(--fg-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
padding: 6px 8px;
}
.subtabs .subtab:hover:not(.active) { color: var(--neon); border-color: transparent; box-shadow: none; }
.subtabs .subtab.active {
color: var(--neon);
border-color: var(--neon);
background: rgba(33, 200, 255, 0.1);
text-shadow: var(--glow-neon);
box-shadow: inset 0 0 12px rgba(33, 200, 255, 0.12);
}
/* -------- ONU info / stats page -------- */
.onu-info-list {
margin-top: 4px;
display: flex;
flex-direction: column;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
flex: 1;
min-height: 120px;
}
.onu-info-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.onu-info-row:last-child { border-bottom: none; }
.onu-info-row:hover { background: rgba(33, 200, 255, 0.06); }
.onu-info-row.selected { background: rgba(33, 200, 255, 0.14); box-shadow: inset 3px 0 0 var(--neon); }
.onu-info-row .oir-serial { font-weight: 600; font-size: 12px; }
.onu-info-row .oir-sub { font-size: 11px; color: var(--fg-dim); display: flex; gap: 8px; align-items: center; }
.onu-detail { overflow-y: auto; display: flex; flex-direction: column; gap: 18px; padding-right: 6px; }
.onu-detail h2 { margin: 0; font-size: 17px; color: var(--neon); text-shadow: var(--glow-neon); }
.detail-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
}
.detail-card > h3 {
margin: 0 0 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--neon);
text-shadow: var(--glow-neon);
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 16px;
font-size: 13px;
}
.kv dt { color: var(--fg-dim); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; word-break: break-all; }
.lease-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* -------- Dashboard -------- */
.dash-wrap { overflow: hidden; }
.dash-scroll {
@ -592,6 +680,7 @@ button.danger:hover:not(:disabled) {
border-bottom: 1px solid var(--border);
}
.list-modal-item:last-child { border-bottom: none; }
.list-modal-item.tappable { cursor: pointer; }
.list-modal-item:hover { background: rgba(33, 200, 255, 0.06); }
.lm-primary { font-weight: 600; }
.lm-secondary { color: var(--fg-dim); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }

View file

@ -15,6 +15,7 @@ const state = {
oltFiltered: [], // current visible OLTs
oltSelected: new Set(),
dashboard: null, // result of dashboardStats (null until first load)
onuInfoSelected: null, // onuId currently shown on the ONU info page
};
// -------- Helpers --------
@ -25,6 +26,7 @@ const $$ = (sel) => document.querySelectorAll(sel);
// ONU tab highlighted and showView() callers don't each have to know.
const VIEW_TO_TAB = {
'#view-dashboard': 'dashboard',
'#view-onu-info': 'onu',
'#view-fleet': 'onu',
'#view-campaign': 'onu',
'#view-verify': 'onu',
@ -36,6 +38,10 @@ function showView(id) {
$(id).classList.remove('hidden');
const tab = VIEW_TO_TAB[id] || null;
for (const t of $$('.tab')) t.classList.toggle('active', t.dataset.tab === tab);
// Sub-tabs (within ONU): the two switchable views are Info and Fleet;
// campaign/verify are sub-flows reached from Fleet, so keep Fleet lit there.
const subId = (id === '#view-campaign' || id === '#view-verify') ? '#view-fleet' : id;
for (const st of $$('.subtab')) st.classList.toggle('active', st.dataset.subview === subId);
}
function activeBank(cfg) {
@ -1682,13 +1688,16 @@ function dashHealthRow(title, detail, count, drillKey) {
// Scrollable list/menu popped from a clickable dashboard stat (e.g. the
// list of ONUs with abnormal Rx). `items` is [{primary, secondary}].
// `items` is [{ primary, secondary, onClick? }]. Items with onClick render
// as clickable rows that close the modal then fire the handler.
function showListModal(title, items) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const list = items.length
? `<div class="list-modal-items">${items.map((it) =>
`<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="list-modal-items">${items.map((it, i) =>
`<div class="list-modal-item${it.onClick ? ' tappable' : ''}" data-idx="${i}"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</span>` : ''}${
it.onClick ? ' <span class="dash-chevron"></span>' : ''}</div>`).join('')}</div>`
: '<div class="modal-body muted">Nothing to show — all clear.</div>';
overlay.innerHTML = `
<div class="modal list-modal" role="dialog" aria-modal="true">
@ -1699,6 +1708,10 @@ function showListModal(title, items) {
document.body.appendChild(overlay);
const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
for (const el of overlay.querySelectorAll('.list-modal-item.tappable')) {
const it = items[Number(el.dataset.idx)];
el.addEventListener('click', () => { close(); it.onClick(); });
}
overlay.querySelector('.modal-ok').addEventListener('click', close);
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', onKey, true);
@ -1815,11 +1828,13 @@ function renderDashboard(d) {
showListModal('ONUs with abnormal Rx', (d.optical.abnormalRx || []).map((x) => ({
primary: x.name || x.onuId,
secondary: `${fmtDbm(x.rx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
onClick: () => openOnuInfo(x.onuId),
})));
} else if (drill === 'tx') {
showListModal('ONUs with abnormal Tx', (d.optical.abnormalTx || []).map((x) => ({
primary: x.name || x.onuId,
secondary: `${fmtDbm(x.tx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
onClick: () => openOnuInfo(x.onuId),
})));
} else if (drill === 'laser') {
showListModal('OLTs with laser off', (d.lasersOff || []).map((x) => ({
@ -1836,3 +1851,208 @@ function renderDashboard(d) {
renderCacheBadge(d.cache);
}
// -------- Sub-tab navigation (ONU: Info / Fleet) --------
for (const st of $$('.subtab')) {
st.addEventListener('click', () => {
const view = st.dataset.subview;
showView(view);
if (view === '#view-onu-info' && state.fleet.length) renderOnuInfoList();
});
}
// -------- ONU info / stats page --------
$('#onu-info-load').addEventListener('click', async () => {
await loadFleet();
renderOnuInfoList();
});
$('#onu-info-search').addEventListener('input', renderOnuInfoList);
$('#onu-info-status').addEventListener('change', renderOnuInfoList);
// Jump straight to one ONU's info page (used by dashboard drill-downs).
async function openOnuInfo(onuId) {
showView('#view-onu-info');
if (!state.fleet.length) await loadFleet();
renderOnuInfoList();
selectOnuInfo(onuId);
}
function onuInfoMatches() {
const q = $('#onu-info-search').value.trim().toLowerCase();
const status = $('#onu-info-status').value;
return state.fleet.filter((cfg) => {
if (status === '__down__') { if (!DOWN_STATUSES.has(onuStatus(cfg))) return false; }
else if (status === '__unknown__') { if (onuStatus(cfg) !== null) return false; }
else if (status) { if (onuStatus(cfg) !== status) return false; }
if (!q) return true;
const hay = `${cfg._id} ${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''}`.toLowerCase();
return hay.includes(q);
});
}
function renderOnuInfoList() {
const list = $('#onu-info-list');
if (!state.fleet.length) {
list.innerHTML = '<div class="muted small" style="padding:10px">Load the fleet to list ONUs.</div>';
$('#onu-info-status-line').textContent = 'Load the fleet, then pick an ONU.';
return;
}
const matched = onuInfoMatches().slice(0, 400); // cap the DOM for huge fleets
list.innerHTML = matched.map((cfg) => {
const st = onuStatus(cfg);
const cls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending';
const sel = cfg._id === state.onuInfoSelected ? ' selected' : '';
return `<div class="onu-info-row${sel}" data-id="${escapeHtml(cfg._id)}">
<span class="oir-serial">${escapeHtml(cfg._id)}</span>
<span class="oir-sub"><span class="${cls}">${escapeHtml(st || 'unknown')}</span><span>${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')}</span></span>
</div>`;
}).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>';
const total = onuInfoMatches().length;
$('#onu-info-status-line').textContent =
`${total} ONU(s) match${total > matched.length ? ` (showing first ${matched.length})` : ''}.`;
for (const row of list.querySelectorAll('.onu-info-row')) {
row.addEventListener('click', () => selectOnuInfo(row.dataset.id));
}
}
function selectOnuInfo(onuId) {
state.onuInfoSelected = onuId;
for (const row of $$('#onu-info-list .onu-info-row')) {
row.classList.toggle('selected', row.dataset.id === onuId);
}
renderOnuDetail(onuId);
}
// Read optical + FEC straight from the merged ONU-STATE (no extra fetch).
function onuClientStats(cfg) {
const s = cfg?._state?.STATS || cfg?._state?.state_collection?.STATS || {};
const onu = s['ONU-PON'] || {};
const olt = s['OLT-PON'] || {};
const num = (v) => (typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null));
return {
rx: num(onu['RX Optical Level']), tx: num(onu['TX Optical Level']),
onuPre: num(onu['RX Pre-FEC BER']), onuPost: num(onu['RX Post-FEC BER']),
oltPre: num(olt['RX Pre-FEC BER']), oltPost: num(olt['RX Post-FEC BER']),
};
}
function rxCls(rx) { return rx == null ? 'muted' : rx >= -28 ? 'status-ok' : rx >= -30 ? 'status-warn' : 'status-err'; }
function txCls(tx) { return tx == null ? 'muted' : tx >= 3 ? 'status-ok' : 'status-err'; }
function fecVerdict(s) {
if ((s.onuPost ?? 0) > 0 || (s.oltPost ?? 0) > 0) return ['status-err', 'post-FEC errors'];
if ((s.onuPre ?? 0) > 0 || (s.oltPre ?? 0) > 0) return ['status-warn', 'pre-FEC only'];
return ['status-ok', 'clean'];
}
function fmtDuration(ms) {
if (ms == null) return '—';
const neg = ms < 0; let s = Math.round(Math.abs(ms) / 1000);
const d = Math.floor(s / 86400); s -= d * 86400;
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60);
const parts = d ? [`${d}d`, `${h}h`] : h ? [`${h}h`, `${m}m`] : [`${m}m`];
return (neg ? '-' : '') + parts.join(' ');
}
function leasePill(health) {
const h = health || {};
const map = {
ok: ['status-ok', 'OK'],
warn: ['status-warn', 'renewal overdue'],
expired: ['status-err', 'EXPIRED'],
unknown: ['muted', 'unknown'],
};
const [cls, label] = map[h.level] || map.unknown;
const rem = h.remainingMs != null
? (h.remainingMs > 0 ? ` · ${fmtDuration(h.remainingMs)} left` : ` · ${fmtDuration(h.remainingMs)} ago`)
: '';
return `<span class="lease-pill ${cls}">${label}${rem}</span>`;
}
const kvRow = (k, v) => `<dt>${escapeHtml(k)}</dt><dd>${v}</dd>`;
async function renderOnuDetail(onuId) {
const el = $('#onu-info-detail');
const cfg = state.fleet.find((c) => c._id === onuId);
if (!cfg) { el.innerHTML = '<p class="muted">ONU not found in the loaded fleet — reload the fleet.</p>'; return; }
const st = onuStatus(cfg);
const stCls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending';
const stats = onuClientStats(cfg);
const [fc, ftxt] = fecVerdict(stats);
el.innerHTML = `
<h2>${escapeHtml(cfg._id)}</h2>
<div class="detail-card">
<h3>ONU</h3>
<dl class="kv">
${kvRow('Name', escapeHtml(cfg?.ONU?.Name || '—'))}
${kvRow('Address', escapeHtml(cfg?.ONU?.Address || '—'))}
${kvRow('Equipment ID', escapeHtml(equipmentId(cfg) || '—'))}
${kvRow('Vendor', escapeHtml(vendor(cfg) || '—'))}
${kvRow('PON mode', escapeHtml(cfg?.ONU?.['PON Mode'] || '—'))}
${kvRow('Status', `<span class="${stCls}">${escapeHtml(st || 'unknown')}</span>`)}
${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))}
${kvRow('Active version', escapeHtml(activeVersion(cfg)))}
${kvRow('Inactive version', escapeHtml(inactiveVersion(cfg)))}
</dl>
</div>
<div class="detail-card">
<h3>Link / optical</h3>
<dl class="kv">
${kvRow('FEC', `<span class="${fc}">${escapeHtml(ftxt)}</span>`)}
${kvRow('ONU RX', `<span class="${rxCls(stats.rx)}">${stats.rx == null ? '—' : escapeHtml(stats.rx.toFixed(2)) + ' dBm'}</span>`)}
${kvRow('ONU TX', `<span class="${txCls(stats.tx)}">${stats.tx == null ? '—' : escapeHtml(stats.tx.toFixed(2)) + ' dBm'}</span>`)}
${kvRow('ONU pre/post FEC', `${escapeHtml(String(stats.onuPre ?? '—'))} / ${escapeHtml(String(stats.onuPost ?? '—'))}`)}
${kvRow('OLT pre/post FEC', `${escapeHtml(String(stats.oltPre ?? '—'))} / ${escapeHtml(String(stats.oltPost ?? '—'))}`)}
</dl>
</div>
<div class="detail-card" id="onu-cpe-card">
<h3>CPE (customer router)</h3>
<p class="muted small">Loading lease info</p>
</div>`;
// CPE is a separate fetch; guard against the operator clicking another ONU
// before it returns.
const res = await window.api.getOnuCpe({ onuId });
if (state.onuInfoSelected !== onuId) return; // selection moved on
const card = $('#onu-cpe-card');
if (!card) return;
if (!res.ok) { card.innerHTML = `<h3>CPE (customer router)</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p>`; return; }
card.innerHTML = renderCpeCard(res.data);
}
function renderCpeCard(cpe) {
if (!cpe) return '<h3>CPE (customer router)</h3><p class="muted">No CPE / DHCP lease record for this ONU.</p>';
const vendor = cpe.cpeVendor ? escapeHtml(cpe.cpeVendor) : '<span class="muted">unknown OUI</span>';
const head = `<dl class="kv">
${kvRow('CPE MAC', escapeHtml(cpe.cpeMac || '—'))}
${kvRow('Vendor (OUI)', vendor)}
${cpe.fwVersion ? kvRow('CPE FW', escapeHtml(cpe.fwVersion)) : ''}
</dl>`;
const v4 = cpe.v4 ? `
<h3 style="margin-top:14px">DHCPv4 ${leasePill(cpe.v4.health)}</h3>
<dl class="kv">
${kvRow('State', escapeHtml(cpe.v4.state || '—'))}
${kvRow('IP', escapeHtml(cpe.v4.ip || '—'))}
${kvRow('Lease', cpe.v4.leaseSeconds ? fmtDuration(cpe.v4.leaseSeconds * 1000) : '—')}
${kvRow('Last renew', escapeHtml(cpe.v4.lastSuccess || '—'))}
${kvRow('Expires', escapeHtml(cpe.v4.expiry || '—'))}
${cpe.v4.circuitId ? kvRow('Circuit ID', escapeHtml(cpe.v4.circuitId)) : ''}
</dl>` : '<h3 style="margin-top:14px">DHCPv4 <span class="lease-pill muted">none</span></h3>';
const v6 = cpe.v6 ? `
<h3 style="margin-top:14px">DHCPv6 ${leasePill(cpe.v6.health)}</h3>
<dl class="kv">
${kvRow('State', escapeHtml(cpe.v6.state || '—'))}
${kvRow('IP', escapeHtml(cpe.v6.ip || '—'))}
${cpe.v6.prefixes && cpe.v6.prefixes.length ? kvRow('Prefix deleg.', escapeHtml(cpe.v6.prefixes.join(', '))) : ''}
${kvRow('Renew (T1)', escapeHtml(cpe.v6.t1 || '—'))}
${kvRow('Preferred till', escapeHtml(cpe.v6.preferred || '—'))}
${kvRow('Valid till', escapeHtml(cpe.v6.valid || '—'))}
</dl>` : '<h3 style="margin-top:14px">DHCPv6 <span class="lease-pill muted">none</span></h3>';
const ppp = cpe.pppoe ? `<h3 style="margin-top:14px">PPPoE</h3><dl class="kv">${kvRow('State', escapeHtml(cpe.pppoe.state))}${kvRow('Session', escapeHtml(String(cpe.pppoe.sessionId)))}</dl>` : '';
return `<h3>CPE (customer router)</h3>${head}${v4}${v6}${ppp}`;
}

View file

@ -13,7 +13,7 @@
<div class="brand">PON Fleet Upgrader</div>
<nav id="tabs" class="tabs hidden">
<button class="tab" data-tab="dashboard" data-view="#view-dashboard">Dashboard</button>
<button class="tab" data-tab="onu" data-view="#view-fleet">ONU</button>
<button class="tab" data-tab="onu" data-view="#view-onu-info">ONU</button>
<button class="tab" data-tab="olt" data-view="#view-olts">OLT</button>
</nav>
<div class="session" id="session-label">Not connected</div>
@ -66,9 +66,47 @@
</div>
</section>
<!-- ================= ONU info / stats view ================= -->
<section id="view-onu-info" class="view hidden">
<div class="panel-left">
<nav class="subtabs">
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
<button class="subtab" data-subview="#view-fleet">Fleet &amp; upgrade</button>
</nav>
<h2>Find ONU</h2>
<label>Search (name / address / serial)
<input id="onu-info-search" type="search" placeholder="e.g. ORKANGER or GNXS05…" />
</label>
<label>Registration status
<select id="onu-info-status">
<option value="">Any</option>
<option value="__down__">Down (Dereg / Dying Gasp / Disabled)</option>
<option value="Registered">Registered</option>
<option value="Deregistered">Deregistered</option>
<option value="Dying Gasp">Dying Gasp</option>
<option value="Disabled">Disabled</option>
<option value="__unknown__">Unknown (no OLT state)</option>
</select>
</label>
<button id="onu-info-load" class="primary">Load fleet</button>
<p class="muted small" id="onu-info-status-line">Load the fleet, then pick an ONU.</p>
<div class="onu-info-list" id="onu-info-list"></div>
</div>
<div class="panel-main">
<div id="onu-info-detail" class="onu-detail">
<p class="muted">Select an ONU on the left to see its stats and the CPE (customer router) behind it.</p>
</div>
</div>
</section>
<!-- ================= Fleet view ================= -->
<section id="view-fleet" class="view hidden">
<div class="panel-left">
<nav class="subtabs">
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
<button class="subtab" data-subview="#view-fleet">Fleet &amp; upgrade</button>
</nav>
<h2>Filters</h2>
<label>Name / address contains
<input id="filter-text" type="search" placeholder="e.g. ORKANGER" />