From 276c3e9faa0ff0a2d48c79fbde9969a34bd765f8 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 15:47:50 +0200 Subject: [PATCH] onu detail: parent OLT + LLDP switch + traffic history + alarms + UNI; single-ONU upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-compacts the ONU info page into a responsive card grid and enriches it with one new endpoint (api:getOnuDetail) summarized by the pure, shared src/onudetail.js: - Parent OLT (name/model/FW/PON) — follows the OLT MAC from the ONU alarm-history doc to its OLT-CFG. - Upstream switch (LLDP neighbour of the OLT NNI) from the alarm-history Switch block: system name, port, IPv4, chassis. - Traffic (last ~hour) with current + peak down/up and inline SVG sparklines, from /onus/stats/ (down = OLT TX rate, up = OLT RX rate). - UNI-ETH ports (speed/duplex/enable) + last Ethernet LOS (LAN-LOS alarm). - Alarms: active-first, severity-sorted with raised counts + last raised. - CPE card folded into the same single fetch. New client methods getOnuAlarms + getOnuStatsSeries; api:getOnuDetail in main.js + web/server.js, exposed in both bridges. Not TTL-cached (live). Single-ONU upgrade: the Firmware card gets a firmware picker + "Upgrade this ONU" button reusing the bulk per-ONU path (executePerOnu with one id, Procedure 7 inactive-bank write), behind a typed UPGRADE confirm; refreshes the detail on success. Docs: CLAUDE.md §17 + §5.11 (stats endpoint now used for the sparkline) + IPC table. Verified: node --check; onudetail.js unit-tested 11/11 against the real alarm-history / cfg / stats / OLT-cfg fixtures (firmware banks, UNI, alarm sort, LAN-LOS, OLT active-bank FW, LLDP switch, traffic skip-nontraffic + down/up mapping + current/peak); card grid rendered offscreen (9 cards, 2 sparklines, upgrade button). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 40 ++++++- main.js | 27 +++++ preload.js | 1 + renderer/app.css | 15 ++- renderer/app.js | 252 +++++++++++++++++++++++++++++++++++------- src/mcms-api.js | 29 +++++ src/onudetail.js | 136 +++++++++++++++++++++++ web/public/api-web.js | 1 + web/server.js | 19 ++++ 9 files changed, 474 insertions(+), 46 deletions(-) create mode 100644 src/onudetail.js diff --git a/CLAUDE.md b/CLAUDE.md index 2aedad6..c951fca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ stream progress events back to the renderer. | `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:getOnuDetail` | rich ONU detail: firmware/UNI/alarms/OLT/switch/traffic/CPE | — | | `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — | | `api:planUpgrade` | dry-run plan for selected ONUs | — | | `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` | @@ -355,8 +356,12 @@ state_collection.STATS["OLT-PON"]["RX Post-FEC BER"] → The pre-flight fetch (`api:fetchFecHealth`) and the verify fetch (`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via -`extractOnuHealth`. The time-series stats endpoint is **not used** -anywhere — earlier code that hit it has been removed. +`extractOnuHealth`. For *current* FEC/optical, prefer ONU-STATE. + +The time-series endpoint **is** used, but only for the ONU detail page's +traffic sparkline (`getOnuStatsSeries` → `api:getOnuDetail`, §17): it pulls +the last ~hour of samples for the down/up rate history. Don't use it for the +live FEC pill — that stays on ONU-STATE. `extractOnuHealth` accepts both shapes: the bare state doc (`{STATS: ...}`) and the UI-helper wrapped form @@ -843,9 +848,34 @@ now lands on Info; campaign/verify keep the Fleet sub-tab lit. 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`. +renders the detail as a **responsive card grid** (`.detail-grid`, auto-fill +columns — this is the de-compaction). Identity + link/optical paint instantly +from `state.fleet` (`onuClientStats`); the rest streams from one +`api:getOnuDetail` call (`src/onudetail.js`, pure, shared): + +- **Traffic** — current + peak down/up with inline SVG sparklines, from the + `/onus/stats/` time-series (down = OLT TX rate, up = OLT RX rate). +- **UNI ports** — UNI-ETH config (speed/duplex/enable) + last Ethernet LOS + (from the `LAN-LOS` / `PptpEthernetUni` alarm). +- **Parent OLT** — name/model/FW/PON, resolved by following the OLT MAC in + the alarm-history doc to its OLT-CFG. +- **Upstream switch** — the OLT NNI's LLDP neighbour (`Switch` block on the + alarm-history doc): system name, port, IPv4, chassis. +- **Alarms** — active-first, severity-sorted (`N-LABEL`; rank ≤3 red, 4 + amber, 5 cyan, ≥6 muted) with raised counts and last-raised. +- **CPE** (§ above) and **Firmware** (banks + single-ONU upgrade). + +`api:getOnuDetail` fans out `getOnuConfig` + `getOnuAlarms` + +`getOnuStatsSeries` (parallel) → then `getOltConfig(oltMac)` → `getCpe`; it is +**not** TTL-cached (live detail). Dashboard abnormal-Rx/Tx drill-downs (§15) +deep-link here via `openOnuInfo`. + +### Single-ONU upgrade + +The Firmware card has a firmware picker + "Upgrade this ONU" button that +reuses the bulk per-ONU path — `executePerOnu({ onuIds: [id], … })` +(Procedure 7, inactive-bank write via `bank-strategy`). Two-step `UPGRADE` +typed confirm; streams `upgrade:progress`; refreshes the detail on success. ### CPE (customer router) diff --git a/main.js b/main.js index 12ca3e4..4d27b3b 100644 --- a/main.js +++ b/main.js @@ -15,6 +15,7 @@ const { const { summarizeDashboard } = require('./src/dashboard'); const { summarizeCpe } = require('./src/cpe'); const { lookupOui } = require('./src/oui'); +const { summarizeOnuDetail } = require('./src/onudetail'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); /** @type {McmsClient | null} */ @@ -238,6 +239,32 @@ ipcMain.handle('api:getOnuCpe', async (_ev, { onuId }) => { } }); +/** + * Rich per-ONU detail for the info page: firmware banks, UNI-ETH ports + + * last Ethernet LOS, alarms, parent OLT, upstream switch (LLDP), a traffic + * time-series, and the CPE summary — one round of fetches summarized via + * src/onudetail.js + src/cpe.js. + */ +ipcMain.handle('api:getOnuDetail', async (_ev, { onuId, windowMin = 60 }) => { + try { + const c = requireClient(); + const [cfg, alarmsDoc, statsSeries] = await Promise.all([ + c.getOnuConfig(onuId).catch(() => null), + c.getOnuAlarms(onuId).catch(() => null), + c.getOnuStatsSeries(onuId, { minutes: windowMin }).catch(() => []), + ]); + const oltMac = alarmsDoc?.OLT?.['MAC Address']; + const oltCfg = oltMac ? await c.getOltConfig(oltMac).catch(() => null) : null; + const detail = summarizeOnuDetail({ cfg, alarmsDoc, statsSeries, oltCfg, windowMin }); + const cpeDoc = await c.getCpe(onuId).catch(() => null); + detail.cpe = summarizeCpe(cpeDoc); + if (detail.cpe) detail.cpe.cpeVendor = await lookupOui(detail.cpe.cpeMac); + return { ok: true, data: detail }; + } 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 e51a15d..0ce792b 100644 --- a/preload.js +++ b/preload.js @@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld('api', { fetchStates: (opts) => call('api:fetchStates', opts), getOnuConfig: (opts) => call('api:getOnuConfig', opts), getOnuCpe: (opts) => call('api:getOnuCpe', opts), + getOnuDetail: (opts) => call('api:getOnuDetail', opts), listOnuFirmware: () => call('api:listOnuFirmware'), uploadFirmware: () => call('api:uploadFirmware'), diff --git a/renderer/app.css b/renderer/app.css index 0ce0abd..a8af02f 100644 --- a/renderer/app.css +++ b/renderer/app.css @@ -533,8 +533,21 @@ button.danger:hover:not(:disabled) { .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 { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 6px; } .onu-detail h2 { margin: 0; font-size: 17px; color: var(--neon); text-shadow: var(--glow-neon); } +/* De-compacted: cards flow into as many columns as fit the width. */ +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 14px; + align-items: start; +} +svg.spark { vertical-align: middle; opacity: 0.9; } +.alarm-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); font-size: 12px; } +.alarm-row:last-child { border-bottom: none; } +.alarm-row .alarm-text { flex: 1; } +.alarm-row .alarm-meta { color: var(--fg-dim); font-size: 11px; white-space: nowrap; } +.fw-upg { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; } .detail-card { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/renderer/app.js b/renderer/app.js index 597dd00..be81343 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1975,50 +1975,222 @@ async function renderOnuDetail(onuId) { const cfg = state.fleet.find((c) => c._id === onuId); if (!cfg) { el.innerHTML = '

ONU not found in the loaded fleet — reload the fleet.

'; return; } + const head = `

${escapeHtml(cfg._id)}

`; + // Identity + link render instantly from the loaded fleet; the rest streams + // in from one getOnuDetail call. + el.innerHTML = `${head}
+ ${detailIdentityCard(cfg, null)} + ${detailLinkCard(cfg)} +

Loading…

Fetching OLT, traffic, UNI, alarms, CPE…

+
`; + + const res = await window.api.getOnuDetail({ onuId }); + if (state.onuInfoSelected !== onuId) return; // selection moved on + if (!res.ok) { + el.innerHTML = `${head}
+ ${detailIdentityCard(cfg, null)}${detailLinkCard(cfg)} +

Detail

Error: ${escapeHtml(res.error?.message || 'failed')}

+
`; + return; + } + const d = res.data; + el.innerHTML = `${head}
+ ${detailIdentityCard(cfg, d)} + ${detailLinkCard(cfg)} + ${detailTrafficCard(d)} + ${detailUniCard(d)} + ${detailOltCard(d)} + ${detailSwitchCard(d)} + ${detailAlarmsCard(d)} +
${renderCpeCard(d.cpe)}
+ ${detailFirmwareCard(d)} +
`; + wireFirmwareUpgrade(onuId); +} + +function detailIdentityCard(cfg, d) { 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); + const activeV = (d && d.firmware && d.firmware.activeVersion) || activeVersion(cfg); + const inactiveV = (d && d.firmware && d.firmware.inactiveVersion) || inactiveVersion(cfg); + return `

ONU

+ ${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', `${escapeHtml(st || 'unknown')}`)} + ${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))} + ${kvRow('Active version', escapeHtml(activeV || '—'))} + ${kvRow('Inactive version', escapeHtml(inactiveV || '—'))} +
`; +} - el.innerHTML = ` -

${escapeHtml(cfg._id)}

-
-

ONU

-
- ${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', `${escapeHtml(st || 'unknown')}`)} - ${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))} - ${kvRow('Active version', escapeHtml(activeVersion(cfg)))} - ${kvRow('Inactive version', escapeHtml(inactiveVersion(cfg)))} -
-
-
-

Link / optical

-
- ${kvRow('FEC', `${escapeHtml(ftxt)}`)} - ${kvRow('ONU RX', `${stats.rx == null ? '—' : escapeHtml(stats.rx.toFixed(2)) + ' dBm'}`)} - ${kvRow('ONU TX', `${stats.tx == null ? '—' : escapeHtml(stats.tx.toFixed(2)) + ' dBm'}`)} - ${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 ?? '—'))}`)} -
-
-
-

CPE (customer router)

-

Loading lease info…

-
`; +function detailLinkCard(cfg) { + const s = onuClientStats(cfg); + const [fc, ftxt] = fecVerdict(s); + return `

Link / optical

+ ${kvRow('FEC', `${escapeHtml(ftxt)}`)} + ${kvRow('ONU RX', `${s.rx == null ? '—' : escapeHtml(s.rx.toFixed(2)) + ' dBm'}`)} + ${kvRow('ONU TX', `${s.tx == null ? '—' : escapeHtml(s.tx.toFixed(2)) + ' dBm'}`)} + ${kvRow('ONU pre/post FEC', `${escapeHtml(String(s.onuPre ?? '—'))} / ${escapeHtml(String(s.onuPost ?? '—'))}`)} + ${kvRow('OLT pre/post FEC', `${escapeHtml(String(s.oltPre ?? '—'))} / ${escapeHtml(String(s.oltPost ?? '—'))}`)} +
`; +} - // 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 = `

CPE (customer router)

Error: ${escapeHtml(res.error?.message || 'failed')}

`; return; } - card.innerHTML = renderCpeCard(res.data); +// Tiny inline SVG sparkline normalised to its own min/max. +function sparklineSvg(values, stroke, w = 120, h = 26) { + const nums = values.filter((v) => typeof v === 'number'); + if (nums.length < 2) return ''; + const max = Math.max(...nums); const min = Math.min(...nums); + const span = (max - min) || 1; + const n = values.length; + const pts = values.map((v, i) => { + const x = (i / (n - 1)) * w; + const val = typeof v === 'number' ? v : min; + const y = h - (((val - min) / span) * (h - 2)) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function detailTrafficCard(d) { + const t = d.traffic || {}; + const cur = t.current; + const series = t.series || []; + const win = t.windowMin || 60; + if (!series.length) { + return `

Traffic (last ${win} min)

No PM samples in the window.

`; + } + return `

Traffic (last ${win} min)

+ ${kvRow('↓ Down now', `${escapeHtml(fmtBps(cur ? cur.downBps : null))} ${sparklineSvg(series.map((x) => x.downBps), 'var(--neon)')}`)} + ${kvRow('↑ Up now', `${escapeHtml(fmtBps(cur ? cur.upBps : null))} ${sparklineSvg(series.map((x) => x.upBps), 'var(--cyan)')}`)} + ${kvRow('Peak down/up', `${escapeHtml(fmtBps(t.peak.downBps))} / ${escapeHtml(fmtBps(t.peak.upBps))}`)} + ${kvRow('Samples', String(series.length))} +
`; +} + +function detailUniCard(d) { + const uni = d.uni || []; + const rows = uni.map((u) => kvRow( + escapeHtml(u.name), + `${u.enable ? 'enabled' : 'disabled'} · ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`, + )).join(''); + let losRows = ''; + if (d.ethLos) { + const los = d.ethLos; + const cls = los.active ? 'status-err' : 'status-ok'; + losRows = kvRow('Eth link', `${los.active ? 'DOWN (LAN-LOS active)' : 'up'}`) + + kvRow('Last LOS', `${escapeHtml(los.lastRaised || '—')}${los.raisedCount ? ` (×${los.raisedCount})` : ''}`); + } + return `

UNI ports

${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}
`; +} + +function detailOltCard(d) { + const o = d.olt; + if (!o) return '

Parent OLT

Unknown.

'; + return `

Parent OLT

+ ${kvRow('Name', escapeHtml(o.name || '—'))} + ${kvRow('MAC', escapeHtml(o.mac || '—'))} + ${o.model ? kvRow('Model', escapeHtml(o.model)) : ''} + ${kvRow('FW', escapeHtml(o.fwVersion || '—'))} + ${kvRow('PON mode', escapeHtml(o.ponMode || '—'))} + ${o.location ? kvRow('Location', escapeHtml(o.location)) : ''} +
`; +} + +function detailSwitchCard(d) { + const s = d.sw; + if (!s) return ''; + return `

Upstream switch (LLDP)

+ ${kvRow('System', escapeHtml(s.systemName || '—'))} + ${kvRow('Port', escapeHtml(s.portDesc || s.portId || '—'))} + ${s.ipv4 ? kvRow('IPv4', escapeHtml(s.ipv4)) : ''} + ${kvRow('Chassis', escapeHtml(s.chassisId || '—'))} + ${kvRow('Port ID', escapeHtml(s.portId || '—'))} + ${s.systemDesc ? kvRow('Descr', `${escapeHtml(s.systemDesc)}`) : ''} +
`; +} + +function sevPillClass(rank) { + return rank <= 3 ? 'status-err' : rank === 4 ? 'status-warn' : rank === 5 ? 'status-info' : 'muted'; +} + +function detailAlarmsCard(d) { + const al = d.alarms || []; + if (!al.length) return '

Alarms

No alarm history.

'; + const shown = al.slice(0, 12); + const rows = shown.map((a) => { + const dot = a.active ? '' : ''; + return `
${dot} ${escapeHtml(a.sevLabel || a.severity)} ${escapeHtml(a.text || a.type)}×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}
`; + }).join(''); + const cls = d.alarmActiveCount ? 'status-err' : 'muted'; + return `

Alarms (${d.alarmActiveCount} active / ${al.length})

${rows}${al.length > shown.length ? `

+${al.length - shown.length} more

` : ''}
`; +} + +function detailFirmwareCard(d) { + const banks = (d.firmware && d.firmware.banks) || []; + const bankRows = banks.map((b) => + kvRow(`Bank ${b.slot}${b.active ? ' (active)' : ''}`, `${escapeHtml(b.version || '—')}${b.file ? ` ${escapeHtml(b.file)}` : ''}`)).join(''); + return `

Firmware

${bankRows || kvRow('—', 'no banks')}
+
+ +
+

Writes to the inactive bank (Procedure 7), same as the bulk tool. Reboot applies it.

+
`; +} + +// Populate the single-ONU firmware picker and wire the upgrade button. Reuses +// the bulk per-ONU path (executePerOnu) with a one-element selection. +async function wireFirmwareUpgrade(onuId) { + const sel = $('#onu-upg-select'); + const btn = $('#onu-upg-btn'); + const status = $('#onu-upg-status'); + if (!sel || !btn) return; + if (!state.firmwareList.length) { + const r = await window.api.listOnuFirmware(); + if (state.onuInfoSelected !== onuId) return; + if (r.ok) state.firmwareList = r.data || []; + } + sel.innerHTML = ''; + for (const fw of state.firmwareList) { + const filename = fw.filename || fw._id || ''; + const version = fw?.metadata?.Version || fw?.Version || ''; + const o = document.createElement('option'); + o.value = filename; o.dataset.version = version; + o.textContent = version ? `${filename} — ${version}` : filename; + sel.appendChild(o); + } + const sync = () => { btn.disabled = !sel.value; }; + sel.addEventListener('change', sync); + sync(); + + btn.addEventListener('click', async () => { + const targetFile = sel.value; + const targetVersion = sel.selectedOptions[0]?.dataset.version || ''; + if (!targetFile) return; + const ok = await confirmTyped({ + title: 'Upgrade this ONU', + body: `Stage ${targetVersion || targetFile} to the INACTIVE bank on ${onuId} (Procedure 7, per-ONU PUT). The ONU boots into the new image on its next reboot.`, + requireWord: 'UPGRADE', + confirmLabel: 'Upgrade', + danger: true, + }); + if (!ok) { status.textContent = 'Cancelled.'; return; } + btn.disabled = true; + status.textContent = 'Upgrading…'; + const unbind = window.api.onUpgradeProgress((p) => { + status.textContent = `${p.phase || 'working'} ${p.onuId || ''}${p.writeSlot != null ? ` → bank ${p.writeSlot}` : ''}`; + }); + const res = await window.api.executePerOnu({ onuIds: [onuId], targetFile, targetVersion }); + unbind(); + if (!res.ok) { status.textContent = `Failed: ${res.error?.message}`; btn.disabled = false; return; } + const r0 = (res.data || [])[0] || {}; + status.textContent = r0.ok + ? `Staged ${targetVersion || targetFile} to bank ${r0.writeSlot}. Reboot to apply.` + : `Failed: ${r0.error?.message || 'unknown'}`; + if (r0.ok) setTimeout(() => { if (state.onuInfoSelected === onuId) renderOnuDetail(onuId); }, 1000); + }); } function renderCpeCard(cpe) { diff --git a/src/mcms-api.js b/src/mcms-api.js index 140c471..c4bc760 100644 --- a/src/mcms-api.js +++ b/src/mcms-api.js @@ -541,6 +541,35 @@ class McmsClient { } } + /** + * ONU alarm-history document: active/cleared alarms with severity, plus the + * parent OLT MAC and the OLT's upstream-switch LLDP neighbour. Used by the + * ONU detail page. + */ + async getOnuAlarms(onuId) { + try { + const res = await this._request('GET', `/v3/onus/alarm-histories/${encodeURIComponent(onuId)}/`); + return res.body?.data ?? null; + } catch (e) { + if (e instanceof McmsApiError && e.status === 404) return null; + throw e; + } + } + + /** + * ONU PM time-series for the last `minutes`, for the detail-page traffic + * sparkline. `start-time`/`end-time` are UTC "YYYY-MM-DD HH:MM:SS" (§5.8). + * Returns the raw sample array (mixed sample shapes — the caller filters). + */ + async getOnuStatsSeries(onuId, { minutes = 60 } = {}) { + const end = new Date(); + const start = new Date(end.getTime() - minutes * 60000); + const res = await this._request('GET', `/v3/onus/stats/${encodeURIComponent(onuId)}/`, { + query: { 'start-time': formatMcmsTime(start), 'end-time': formatMcmsTime(end) }, + }); + return Array.isArray(res.body?.data) ? res.body.data : []; + } + // --- OLTs (for ONU registration status) ------------------------------ /** diff --git a/src/onudetail.js b/src/onudetail.js new file mode 100644 index 0000000..fa5f4cd --- /dev/null +++ b/src/onudetail.js @@ -0,0 +1,136 @@ +// Rich per-ONU detail summarizer for the ONU info page. Pure — no Electron/ +// HTTP — shared by the Electron main process and the web server. Reshapes the +// raw ONU-CFG + alarm-histories + stats time-series + parent OLT-CFG into the +// fields the detail cards render. + +function num(v) { + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v); + return null; +} + +/** + * @param {object} a + * @param {object} [a.cfg] ONU-CFG doc (firmware banks, UNI-ETH, …) + * @param {object} [a.alarmsDoc] /onus/alarm-histories// data (Alarms[], OLT, Switch) + * @param {object[]} [a.statsSeries] /onus/stats// samples (time-series) + * @param {object} [a.oltCfg] parent OLT-CFG (name/model/fw/pon) + * @param {number} [a.windowMin] the stats window, for display + */ +function summarizeOnuDetail({ cfg = null, alarmsDoc = null, statsSeries = [], oltCfg = null, windowMin = 60 } = {}) { + const onu = cfg?.ONU || {}; + + // --- Firmware banks --- + const versions = Array.isArray(onu['FW Bank Versions']) ? onu['FW Bank Versions'] : []; + const files = Array.isArray(onu['FW Bank Files']) ? onu['FW Bank Files'] : []; + const ptr = typeof onu['FW Bank Ptr'] === 'number' ? onu['FW Bank Ptr'] : null; + const banks = versions.map((v, i) => ({ slot: i, version: v || '', file: files[i] || '', active: i === ptr })); + const other = ptr === 0 ? 1 : 0; + const firmware = { + ptr, + activeVersion: (ptr != null && versions[ptr]) ? versions[ptr] : '', + inactiveVersion: (ptr != null && versions[other] != null) ? (versions[other] || '') : '', + banks, + }; + + // --- UNI ports --- + const uni = Object.keys(cfg || {}).filter((k) => /^UNI-ETH /.test(k)).sort().map((k) => { + const u = cfg[k] || {}; + return { name: k, enable: !!u.Enable, speed: u.Speed || '', duplex: u.Duplex || '' }; + }); + const pots = Object.keys(cfg || {}).filter((k) => /^UNI-POTS /.test(k)).sort() + .map((k) => ({ name: k, enable: !!(cfg[k] && cfg[k].Enable) })); + + // --- Alarms --- + const rawAlarms = Array.isArray(alarmsDoc?.Alarms) ? alarmsDoc.Alarms : []; + const alarms = rawAlarms.map((a) => { + const sev = String(a.Severity || ''); + const dash = sev.indexOf('-'); + const rank = dash > 0 ? Number(sev.slice(0, dash)) : NaN; + return { + id: a['Alarm ID'], + text: a.Text || a['Alarm Type'] || '', + type: a['Alarm Type'] || '', + objectType: a['Object Type'] || '', + objectInstance: a['Object Instance'] || '', + source: a.Source || '', + severity: sev, + sevRank: Number.isFinite(rank) ? rank : 99, + sevLabel: dash > 0 ? sev.slice(dash + 1) : sev, + active: !!a['Active State'], + raisedCount: a['Raised Count'] || 0, + firstRaised: a['Time First Raised'] || '', + lastRaised: a['Time Last Raised'] || '', + lastCleared: a['Time Last Cleared'] || '', + }; + }).sort((x, y) => + (Number(y.active) - Number(x.active)) + || (x.sevRank - y.sevRank) + || String(y.lastRaised).localeCompare(String(x.lastRaised))); + const alarmActiveCount = alarms.filter((a) => a.active).length; + + // --- Ethernet UNI loss-of-signal (last disconnect) --- + const los = alarms + .filter((a) => /LAN-?LOS/i.test(a.type) || /LAN-?LOS/i.test(a.text) || a.objectType === 'PptpEthernetUni') + .sort((x, y) => String(y.lastRaised).localeCompare(String(x.lastRaised)))[0]; + const ethLos = los ? { + instance: los.objectInstance, active: los.active, + lastRaised: los.lastRaised, lastCleared: los.lastCleared, raisedCount: los.raisedCount, + } : null; + + // --- Parent OLT --- + const oltMac = alarmsDoc?.OLT?.['MAC Address'] + || (Array.isArray(cfg?.OLT?.['MAC Address']) ? '' : (cfg?.OLT?.['MAC Address'] || '')); + let olt = null; + if (oltCfg) { + const o = oltCfg.OLT || {}; + const optr = o['FW Bank Ptr']; + olt = { + mac: oltCfg._id || oltMac, + name: o.Name || '', + model: o.Model || '', + fwVersion: (Array.isArray(o['FW Bank Versions']) && typeof optr === 'number') ? (o['FW Bank Versions'][optr] || '') : '', + ponMode: o['PON Mode'] || '', + location: o.Location || '', + }; + } else if (oltMac) { + olt = { mac: oltMac, name: '', model: '', fwVersion: '', ponMode: '', location: '' }; + } + + // --- Upstream switch (LLDP neighbour of the OLT NNI) --- + const s = alarmsDoc?.Switch; + const sw = s ? { + chassisId: s['Chassis ID'] || '', portId: s['Port ID'] || '', portDesc: s['Port Description'] || '', + systemName: s['System Name'] || '', systemDesc: s['System Description'] || '', + ipv4: s['IPv4 Address'] || '', ipv6: s['IPv6 Address'] || '', + } : null; + + // --- Traffic time-series (samples that carry a per-service rate) --- + const series = []; + for (const smp of (Array.isArray(statsSeries) ? statsSeries : [])) { + const svc = smp['OLT-PON Service 0']; + if (!svc || typeof svc['RX Rate bps'] !== 'number') continue; + const onuPon = smp['ONU-PON'] || {}; + series.push({ + t: smp.Time || smp._id || '', + downBps: num(svc['TX Rate bps']) || 0, // OLT TX = downstream to the ONU + upBps: num(svc['RX Rate bps']) || 0, // OLT RX = upstream from the ONU + rxOpt: num(onuPon['RX Optical Level']), + txOpt: num(onuPon['TX Optical Level']), + }); + } + series.sort((a, b) => String(a.t).localeCompare(String(b.t))); + const current = series.length ? series[series.length - 1] : null; + const peak = series.reduce((m, x) => ({ + downBps: Math.max(m.downBps, x.downBps || 0), + upBps: Math.max(m.upBps, x.upBps || 0), + }), { downBps: 0, upBps: 0 }); + + return { + onuId: cfg?._id || alarmsDoc?._id || '', + firmware, uni, pots, alarms, alarmActiveCount, ethLos, olt, sw, + traffic: { current, peak, series, windowMin }, + }; +} + +module.exports = { summarizeOnuDetail }; diff --git a/web/public/api-web.js b/web/public/api-web.js index 0e028aa..4f87a94 100644 --- a/web/public/api-web.js +++ b/web/public/api-web.js @@ -148,6 +148,7 @@ fetchStates: (o) => streamJSON('fetchStates', o, 'states'), getOnuConfig: (o) => postJSON('getOnuConfig', o), getOnuCpe: (o) => postJSON('getOnuCpe', o), + getOnuDetail: (o) => postJSON('getOnuDetail', o), listOnuFirmware: () => postJSON('listOnuFirmware', {}), uploadFirmware: async () => { const file = await pickFile('.bin'); diff --git a/web/server.js b/web/server.js index b2ed0c8..7b7f336 100644 --- a/web/server.js +++ b/web/server.js @@ -30,6 +30,7 @@ const { planUpgrade } = require('../src/bank-strategy'); const { summarizeDashboard } = require('../src/dashboard'); const { summarizeCpe } = require('../src/cpe'); const { lookupOui } = require('../src/oui'); +const { summarizeOnuDetail } = require('../src/onudetail'); const { TtlCache } = require('./cache'); const PORT = Number(process.env.PORT) || 8080; @@ -277,6 +278,24 @@ const handlers = { return { ok: true, data: cpe }; }, + async getOnuDetail(s, body) { + const c = s.client; + const onuId = body.onuId; + const windowMin = body.windowMin || 60; + const [cfg, alarmsDoc, statsSeries] = await Promise.all([ + c.getOnuConfig(onuId).catch(() => null), + c.getOnuAlarms(onuId).catch(() => null), + c.getOnuStatsSeries(onuId, { minutes: windowMin }).catch(() => []), + ]); + const oltMac = alarmsDoc?.OLT?.['MAC Address']; + const oltCfg = oltMac ? await c.getOltConfig(oltMac).catch(() => null) : null; + const detail = summarizeOnuDetail({ cfg, alarmsDoc, statsSeries, oltCfg, windowMin }); + const cpeDoc = await c.getCpe(onuId).catch(() => null); + detail.cpe = summarizeCpe(cpeDoc); + if (detail.cpe) detail.cpe.cpeVendor = await lookupOui(detail.cpe.cpeMac); + return { ok: true, data: detail }; + }, + async listOnuFirmware(s, body) { const fresh = !!(body && body.fresh); return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) };