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:
parent
5e3d9271a8
commit
55e95ed467
12 changed files with 677 additions and 7 deletions
105
src/cpe.js
Normal file
105
src/cpe.js
Normal 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 };
|
||||
|
|
@ -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
118
src/oui.js
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue