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
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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
19
main.js
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
226
renderer/app.js
226
renderer/app.js
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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
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 };
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue