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 '';
+ 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 '';
+ 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')}
+
+
Upgrade toLoading firmware…
+
Upgrade this ONU →
+
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 = '— choose firmware — ';
+ 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) };