From 55e95ed4670143ec0bb4228dd06d60b3e81191aa Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 15:19:28 +0200 Subject: [PATCH] onu: info/stats sub-tab with CPE lease health + OUI vendor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// (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 --- CLAUDE.md | 53 +++++++++- README.md | 5 +- main.js | 19 ++++ preload.js | 1 + renderer/app.css | 89 +++++++++++++++++ renderer/app.js | 226 +++++++++++++++++++++++++++++++++++++++++- renderer/index.html | 40 +++++++- src/cpe.js | 105 ++++++++++++++++++++ src/mcms-api.js | 18 ++++ src/oui.js | 118 ++++++++++++++++++++++ web/public/api-web.js | 1 + web/server.js | 9 ++ 12 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 src/cpe.js create mode 100644 src/oui.js diff --git a/CLAUDE.md b/CLAUDE.md index 453f68c..2aedad6 100644 --- a/CLAUDE.md +++ b/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//` — 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.* diff --git a/README.md b/README.md index 52ee530..2183a4d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.js b/main.js index a9adcf1..12ca3e4 100644 --- a/main.js +++ b/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(); diff --git a/preload.js b/preload.js index 7449633..e51a15d 100644 --- a/preload.js +++ b/preload.js @@ -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'), diff --git a/renderer/app.css b/renderer/app.css index 6c4eff7..0ce0abd 100644 --- a/renderer/app.css +++ b/renderer/app.css @@ -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; } diff --git a/renderer/app.js b/renderer/app.js index 88e8fd5..597dd00 100644 --- a/renderer/app.js +++ b/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 - ? `
${items.map((it) => - `
${escapeHtml(it.primary)}${ - it.secondary ? `${escapeHtml(it.secondary)}` : ''}
`).join('')}
` + ? `
${items.map((it, i) => + `
${escapeHtml(it.primary)}${ + it.secondary ? `${escapeHtml(it.secondary)}` : ''}${ + it.onClick ? ' ' : ''}
`).join('')}
` : ''; overlay.innerHTML = ` + + +