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

@ -16,8 +16,9 @@ Electron desktop client for operating a **Ciena MCMS 6.2** PON Manager
fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between:
- **Dashboard** — read-only network telemetry (see §15).
- **ONU** — the fleet table + firmware-upgrade campaign + verify flows
(the original purpose: bulk ONU firmware upgrades).
- **ONU** — two sub-tabs: an **Info / stats** page (per-ONU optical/FEC plus
the CPE router's DHCPv4/v6 lease health, §17) and the **Fleet & upgrade**
flow (the original purpose: bulk ONU firmware upgrades + verify).
- **OLT** — bulk OLT NNI-service edits: PON flooding mode (private↔auto) and
DHCPv4/v6 filter (pass↔umt), see §14.
@ -118,6 +119,7 @@ stream progress events back to the renderer.
| `api:listFleet` | ONU-CFG + ONU-STATE + OLT-STATE merged | — |
| `api:fetchStates` | per-ONU state fallback if bulk failed | `states:progress` |
| `api:listOnuConfigs` / `api:getOnuConfig` | direct CFG access | — |
| `api:getOnuCpe` | CPE (router) DHCP lease health + OUI vendor | — |
| `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — |
| `api:planUpgrade` | dry-run plan for selected ONUs | — |
| `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` |
@ -830,4 +832,51 @@ gitignored) which pulls `tough-cookie` and skips the Electron devDep.
---
## 17. ONU info / stats page (CPE + OUI)
The **ONU** top tab has two sub-tabs (`.subtab` buttons in each ONU view's
panel-left; `showView` lights the right one — see §15): **Info / stats** and
**Fleet & upgrade** (the original fleet/campaign/verify flow). The ONU tab
now lands on Info; campaign/verify keep the Fleet sub-tab lit.
### Info page (`#view-onu-info`)
Master-detail over the already-loaded `state.fleet` (no new bulk fetch):
search (name/address/serial) + status filter on the left; clicking an ONU
renders identity, link/optical (read straight from the merged `_state.STATS`
via `onuClientStats` — no extra round-trip), firmware, and a **CPE card**.
Dashboard abnormal-Rx/Tx drill-downs (§15) deep-link here via `openOnuInfo`.
### CPE (customer router)
`api:getOnuCpe``McmsClient.getCpe` (`GET /api/cpe/onu/<id>/` — unversioned,
returns a `[statusCode, doc]` tuple) → `summarizeCpe` (`src/cpe.js`, pure,
shared). The lease-health rule (`leaseHealth`): a DHCP client renews at T1 =
50% of the lease, so **< 50% remaining renewal overdue** (amber), past end
⇒ expired (red). Field mapping confirmed from a real CPE doc:
- **DHCPv4**: expiry = `DHCP["Remove Time"]` (= `Last Success Time` +
`Lease Time`); health spans `Last Success Time``Remove Time`.
- **DHCPv6**: `DHCPV6` carries `T1/T2/Preferred/Valid Lifetime` + nested
`Prefix Delegation[].Prefixes[]`. Health spans `Last Success Time`
`Preferred Lifetime` (T1 is the midpoint), so the same < 50% rule fires
once now passes T1.
### OUI vendor (`src/oui.js`)
The CPE MAC (`_id` / `Client MAC`) resolves to a vendor via the IEEE registry
(`oui.csv`), fetched lazily on first lookup, parsed to a prefix→org map,
cached **in memory ~30 days + best-effort to disk** (`PFW_CACHE_DIR` or the
temp dir; the web server's `PrivateTmp` makes `/tmp` writable). Every failure
path returns `null` → "unknown OUI"; a missing list never breaks the page.
The lookup runs server-side in `getOnuCpe` so the renderer gets a vendor
string. Needs outbound HTTPS from whoever runs the backend (override the URL
with `PFW_OUI_URL`). The ONU `GNXS…` id is a serial, **not** a MAC — OUI only
applies to the CPE/OLT MACs.
CPE is **not** cached by the web server's TTL cache (lease status must be
live); only the OUI list is cached.
---
*Last updated: 2026-06-23.*

View file

@ -7,7 +7,10 @@ System (MCMS) 6.2** PON Manager. Top tabs split it into:
aggregate up/down traffic, health (abnormal Rx, lasers off), and ONU
registration-state breakdown (with an opt-in per-ONU Rx/FEC scan).
Clicking a state jumps to the ONU tab pre-filtered.
2. **ONU** — the fleet table plus **bulk ONU firmware upgrades** (below).
2. **ONU** — two sub-tabs: an **Info / stats** page (per-ONU optical/FEC plus
the CPE router's DHCPv4/v6 lease status, with a warning when a lease is
past its renew point) and **Fleet & upgrade** (**bulk ONU firmware
upgrades**, below).
3. **OLT****bulk OLT NNI-service edits**: PON flooding mode (private↔auto)
and DHCPv4/v6 filter (pass↔umt) — see "OLT NNI-service edits" below.

19
main.js
View file

@ -13,6 +13,8 @@ const {
summarizeOltNniNetworks, planNniEdit,
} = require('./src/mcms-api');
const { summarizeDashboard } = require('./src/dashboard');
const { summarizeCpe } = require('./src/cpe');
const { lookupOui } = require('./src/oui');
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */
@ -219,6 +221,23 @@ ipcMain.handle('api:getOnuConfig', async (_ev, { onuId }) => {
}
});
/**
* CPE (customer router) behind an ONU, for the ONU info page: DHCPv4/v6
* lease state + renewal health, plus the CPE MAC's vendor (OUI lookup,
* cached ~1 month). Returns null when there's no CPE record.
*/
ipcMain.handle('api:getOnuCpe', async (_ev, { onuId }) => {
try {
const c = requireClient();
const doc = await c.getCpe(onuId);
const cpe = summarizeCpe(doc);
if (cpe) cpe.cpeVendor = await lookupOui(cpe.cpeMac);
return { ok: true, data: cpe };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:listOnuFirmware', async () => {
try {
const c = requireClient();

View file

@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld('api', {
listFleet: (opts) => call('api:listFleet', opts),
fetchStates: (opts) => call('api:fetchStates', opts),
getOnuConfig: (opts) => call('api:getOnuConfig', opts),
getOnuCpe: (opts) => call('api:getOnuCpe', opts),
listOnuFirmware: () => call('api:listOnuFirmware'),
uploadFirmware: () => call('api:uploadFirmware'),

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" />

105
src/cpe.js Normal file
View file

@ -0,0 +1,105 @@
// CPE (customer router) summarization. Pure — no Electron/HTTP. Shared by the
// Electron main process and the web server so both compute identical lease
// health. Reduces the raw /api/cpe/onu/<id>/ document to what the ONU info
// page shows: DHCPv4 / DHCPv6 lease state, IPs, prefix delegation, and a
// renewal-health verdict.
const { parseMcmsTime } = require('./mcms-api');
function ms(t) {
const v = parseMcmsTime(t);
return v > 0 ? v : null;
}
/**
* Lease renewal health. A DHCP client renews at T1 = 50% of the lease, so if
* more than half the lease has elapsed and the client is still on the old
* lease, renewal is failing.
* ok past start, > 50% of the lease still remaining
* warn < 50% remaining (past the renew point; renewal likely failed)
* expired lease end is in the past
* unknown missing/unparseable timestamps
*/
function leaseHealth(startMs, endMs, now = Date.now()) {
if (!startMs || !endMs || endMs <= startMs) return { level: 'unknown', remainingMs: null, fraction: null };
const total = endMs - startMs;
const remainingMs = endMs - now;
const fraction = remainingMs / total;
let level;
if (remainingMs <= 0) level = 'expired';
else if (fraction < 0.5) level = 'warn';
else level = 'ok';
return { level, remainingMs, fraction };
}
/**
* Reduce a raw CPE document to display fields. Accepts either the bare doc or
* the `[statusCode, doc]` tuple the endpoint returns.
*/
function summarizeCpe(raw) {
const doc = Array.isArray(raw) ? raw[1] : raw;
if (!doc || typeof doc !== 'object') return null;
const v4 = doc.DHCP || null;
const v6 = doc.DHCPV6 || null;
const ppp = doc.PPPoE || null;
const out = {
cpeMac: doc._id || v4?.['Client MAC'] || v6?.['Client MAC'] || '',
oltMac: doc.OLT?.['MAC Address'] || '',
fwVersion: doc.CNTL?.Version || '',
v4: null,
v6: null,
pppoe: null,
};
if (v4 && (v4['Client State'] || v4['Client IP Addr'])) {
const start = ms(v4['Last Success Time']) || ms(v4['Create Time']);
// Expiry is Remove Time (= Last Success + Lease Time); fall back to the sum.
const end = ms(v4['Remove Time'])
|| (start && Number(v4['Lease Time']) ? start + Number(v4['Lease Time']) * 1000 : null);
out.v4 = {
state: v4['Client State'] || v4.State || '',
ip: v4['Client IP Addr'] || '',
leaseSeconds: Number(v4['Lease Time']) || null,
lastSuccess: v4['Last Success Time'] || '',
expiry: v4['Remove Time'] || '',
serverId: v4['Server ID'] || '',
circuitId: v4['Circuit ID'] || '',
health: leaseHealth(start, end),
};
}
if (v6 && (v6['Client State'] || v6['Client IP Addr'])) {
const start = ms(v6['Last Success Time']) || ms(v6['Create Time']);
// Preferred Lifetime is the renewal-relevant horizon (T1 = midpoint);
// fall back to Valid Lifetime.
const end = ms(v6['Preferred Lifetime']) || ms(v6['Valid Lifetime']);
const prefixes = Array.isArray(v6['Prefix Delegation'])
? v6['Prefix Delegation'].flatMap((pd) =>
(Array.isArray(pd?.Prefixes) ? pd.Prefixes : [])
.map((p) => `${p.Prefix || '?'}/${p['Prefix Length'] ?? '?'}`))
: [];
out.v6 = {
state: v6['Client State'] || v6.State || '',
ip: v6['Client IP Addr'] || '',
t1: v6['T1 Time'] || '',
t2: v6['T2 Time'] || '',
preferred: v6['Preferred Lifetime'] || '',
valid: v6['Valid Lifetime'] || '',
lastSuccess: v6['Last Success Time'] || '',
serverId: v6['Server ID'] || '',
circuitId: v6['Circuit ID'] || '',
prefixes,
health: leaseHealth(start, end),
};
}
if (ppp && ppp.State && ppp.State !== 'Unknown') {
out.pppoe = { state: ppp.State, sessionId: ppp['Session ID'] || 0 };
}
return out;
}
module.exports = { summarizeCpe, leaseHealth };

View file

@ -523,6 +523,24 @@ class McmsClient {
return res.body;
}
/**
* Fetch the CPE (customer router) record behind an ONU. The endpoint is
* unversioned (`/api/cpe/onu/<id>/`) and returns a `[statusCode, doc]`
* tuple rather than the usual `{status,data}` envelope return the doc
* (or null when absent / status != 0).
*/
async getCpe(onuId) {
try {
const res = await this._request('GET', `/cpe/onu/${encodeURIComponent(onuId)}/`);
const b = res.body;
if (Array.isArray(b)) return b[0] === 0 ? (b[1] || null) : null;
return b?.data ?? b ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
// --- OLTs (for ONU registration status) ------------------------------
/**

118
src/oui.js Normal file
View file

@ -0,0 +1,118 @@
// MAC OUI -> vendor lookup, backed by the IEEE registry. Pure Node (https +
// fs), no Electron. Shared by both apps.
//
// The full list (~5 MB CSV) is fetched lazily on first lookup, parsed into a
// prefix->org map, cached in memory for ~30 days, and best-effort persisted to
// a cache file so restarts within the month don't refetch. Every failure path
// degrades to null (callers show "unknown vendor") — a missing OUI list must
// never break the page.
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // ~1 month
const OUI_URL = process.env.PFW_OUI_URL || 'https://standards-oui.ieee.org/oui/oui.csv';
const CACHE_FILE = path.join(process.env.PFW_CACHE_DIR || os.tmpdir(), 'pon-oui-cache.json');
let memo = null; // { map: Map<hex6, org>, at: ms }
let loading = null; // single-flight promise
function normalizeMac(mac) {
return String(mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
}
// Split one CSV line, honouring quoted fields (org names contain commas).
function splitCsvLine(line) {
const out = [];
let cur = '';
let q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur);
return out;
}
// IEEE oui.csv columns: Registry,Assignment,Organization Name,Organization Address
function parseOuiCsv(text) {
const map = new Map();
for (const line of String(text).split(/\r?\n/)) {
if (!line || line.startsWith('Registry,')) continue;
const cols = splitCsvLine(line);
if (cols.length < 3) continue;
const hex = String(cols[1] || '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
if (hex.length !== 6) continue;
const org = String(cols[2] || '').trim();
if (org) map.set(hex, org);
}
return map;
}
function fetchText(url, redirectsLeft = 4) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'pon-fleet-upgrader/oui' }, timeout: 30000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
res.resume();
const next = new URL(res.headers.location, url).toString();
resolve(fetchText(next, redirectsLeft - 1));
return;
}
if (res.statusCode !== 200) { res.resume(); reject(new Error(`OUI HTTP ${res.statusCode}`)); return; }
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('OUI fetch timeout')));
});
}
function readDiskCache() {
try {
const obj = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
if (!obj || !obj.at || !Array.isArray(obj.entries)) return null;
if (Date.now() - obj.at >= TTL_MS) return null;
return { map: new Map(obj.entries), at: obj.at };
} catch (_) { return null; }
}
function writeDiskCache(map, at) {
try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ at, entries: [...map] }), 'utf8'); }
catch (_) { /* best-effort; e.g. read-only fs */ }
}
async function ensureLoaded() {
if (memo && Date.now() - memo.at < TTL_MS) return memo.map;
if (loading) return loading;
loading = (async () => {
const disk = readDiskCache();
if (disk) { memo = disk; return disk.map; }
const text = await fetchText(OUI_URL);
const map = parseOuiCsv(text);
if (map.size === 0) throw new Error('OUI list parsed empty');
memo = { map, at: Date.now() };
writeDiskCache(map, memo.at);
return map;
})().finally(() => { loading = null; });
return loading;
}
/** Resolve a MAC to its vendor org name, or null if unknown / list unavailable. */
async function lookupOui(mac) {
const hex = normalizeMac(mac);
if (hex.length < 6) return null;
try {
const map = await ensureLoaded();
return map.get(hex.slice(0, 6)) || null;
} catch (_) { return null; }
}
module.exports = { lookupOui, normalizeMac, parseOuiCsv, splitCsvLine };

View file

@ -147,6 +147,7 @@
listFleet: (o) => postJSON('listFleet', o),
fetchStates: (o) => streamJSON('fetchStates', o, 'states'),
getOnuConfig: (o) => postJSON('getOnuConfig', o),
getOnuCpe: (o) => postJSON('getOnuCpe', o),
listOnuFirmware: () => postJSON('listOnuFirmware', {}),
uploadFirmware: async () => {
const file = await pickFile('.bin');

View file

@ -28,6 +28,8 @@ const {
} = require('../src/mcms-api');
const { planUpgrade } = require('../src/bank-strategy');
const { summarizeDashboard } = require('../src/dashboard');
const { summarizeCpe } = require('../src/cpe');
const { lookupOui } = require('../src/oui');
const { TtlCache } = require('./cache');
const PORT = Number(process.env.PORT) || 8080;
@ -268,6 +270,13 @@ const handlers = {
return { ok: true, data: await s.client.getOnuConfig(body.onuId) };
},
async getOnuCpe(s, body) {
const doc = await s.client.getCpe(body.onuId);
const cpe = summarizeCpe(doc);
if (cpe) cpe.cpeVendor = await lookupOui(cpe.cpeMac);
return { ok: true, data: cpe };
},
async listOnuFirmware(s, body) {
const fresh = !!(body && body.fresh);
return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) };