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

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 };