onu detail polish + clickable OLT detail modal

- Fix alarm card overflow: alarm rows stack (severity+text on one line that
  wraps, meta below) instead of squeezing the text to ~0 width next to the
  nowrap meta — which had made long alarm text render one char per line in
  narrow columns. Also min-width:0 on cards/kv so long IPv6/descr strings
  don't blow out the grid track.
- Combine Parent OLT + Upstream switch into one "Parent OLT & uplink" card.
- ONU list/search de-compacted: add equipment/version + PON-mode filters;
  two-line rows (serial+status, then equipment · version · last-seen).
- UNI card labels the speed as configured ("cfg Auto/Auto"); negotiated
  speed isn't in the payloads — link up/down still comes from LAN-LOS.

Clickable parent OLT -> OLT detail modal (showOltModal):
- new src/oltdetail.js (pure, shared; reuses summarizeAlarms) + getOltState
  / getOltAlarms client methods + api:getOltDetail (main + web + bridges).
- shows identity, traffic + util, ASIC temperature, ONU counts, laser,
  alarms, upstream switch; plus a connected-ONU list with per-ONU
  FEC/optical built from the loaded fleet (filtered by _status.oltMac),
  each row clickable back into that ONU's detail.

Docs: CLAUDE.md §17 + IPC table.

Verified: node --check; oltdetail.js unit-tested 9/9 (fw active-bank,
traffic down=TX/up=RX, util, ASIC temp, ONU counts, laser, LLDP, alarms);
rendered the OLT modal + richer list offscreen and confirmed the alarm-text
wrap fix (alarm head ~1-2 lines, not vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 16:10:34 +02:00
parent 276c3e9faa
commit 2046fad0f8
11 changed files with 321 additions and 59 deletions

View file

@ -121,6 +121,7 @@ stream progress events back to the renderer.
| `api:listOnuConfigs` / `api:getOnuConfig` | direct CFG access | — | | `api:listOnuConfigs` / `api:getOnuConfig` | direct CFG access | — |
| `api:getOnuCpe` | CPE (router) DHCP lease health + OUI vendor | — | | `api:getOnuCpe` | CPE (router) DHCP lease health + OUI vendor | — |
| `api:getOnuDetail` | rich ONU detail: firmware/UNI/alarms/OLT/switch/traffic/CPE | — | | `api:getOnuDetail` | rich ONU detail: firmware/UNI/alarms/OLT/switch/traffic/CPE | — |
| `api:getOltDetail` | OLT detail: traffic/temp/ONU-counts/laser/alarms/switch | — |
| `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — | | `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — |
| `api:planUpgrade` | dry-run plan for selected ONUs | — | | `api:planUpgrade` | dry-run plan for selected ONUs | — |
| `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` | | `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` |
@ -870,6 +871,11 @@ from `state.fleet` (`onuClientStats`); the rest streams from one
**not** TTL-cached (live detail). Dashboard abnormal-Rx/Tx drill-downs (§15) **not** TTL-cached (live detail). Dashboard abnormal-Rx/Tx drill-downs (§15)
deep-link here via `openOnuInfo`. deep-link here via `openOnuInfo`.
The left list/search supports name/address/serial + equipment/version +
PON-mode + status filters; rows are two-line (serial+status, then
equipment · version · last-seen). Parent OLT and upstream switch are one
combined card.
### Single-ONU upgrade ### Single-ONU upgrade
The Firmware card has a firmware picker + "Upgrade this ONU" button that The Firmware card has a firmware picker + "Upgrade this ONU" button that
@ -877,6 +883,24 @@ reuses the bulk per-ONU path — `executePerOnu({ onuIds: [id], … })`
(Procedure 7, inactive-bank write via `bank-strategy`). Two-step `UPGRADE` (Procedure 7, inactive-bank write via `bank-strategy`). Two-step `UPGRADE`
typed confirm; streams `upgrade:progress`; refreshes the detail on success. typed confirm; streams `upgrade:progress`; refreshes the detail on success.
### Clickable parent OLT → OLT detail modal
The OLT name (and "View OLT details") opens `showOltModal`
`api:getOltDetail``src/oltdetail.js` (pure, shared; reuses
`summarizeAlarms`): identity, traffic (OLT TX=down / RX=up) + util,
**ASIC temperature**, ONU counts (total/online/offline), laser, alarms, and
the upstream switch. The **connected-ONU list** (per-ONU FEC/optical, each
clickable back into the ONU detail) is built **client-side from the loaded
`state.fleet`** filtered by `_status.oltMac` — no extra per-ONU fetch; it
prompts to load the fleet if empty. `getOltDetail` fans out `getOltConfig` +
`getOltState` + `getOltAlarms`.
**Caveat — UNI link speed is the *configured* value** (`UNI-ETH n`.Speed,
usually `Auto`), shown as "cfg Auto/Auto". A negotiated link speed isn't in
the ONU-CFG / alarm / stats payloads inspected so far; link up/down comes
from the `LAN-LOS` alarm instead. Wire negotiated speed if a future
ONU-STATE sample exposes a per-UNI operational field.
### CPE (customer router) ### CPE (customer router)
`api:getOnuCpe``McmsClient.getCpe` (`GET /api/cpe/onu/<id>/` — unversioned, `api:getOnuCpe``McmsClient.getCpe` (`GET /api/cpe/onu/<id>/` — unversioned,

19
main.js
View file

@ -16,6 +16,7 @@ const { summarizeDashboard } = require('./src/dashboard');
const { summarizeCpe } = require('./src/cpe'); const { summarizeCpe } = require('./src/cpe');
const { lookupOui } = require('./src/oui'); const { lookupOui } = require('./src/oui');
const { summarizeOnuDetail } = require('./src/onudetail'); const { summarizeOnuDetail } = require('./src/onudetail');
const { summarizeOltDetail } = require('./src/oltdetail');
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */ /** @type {McmsClient | null} */
@ -265,6 +266,24 @@ ipcMain.handle('api:getOnuDetail', async (_ev, { onuId, windowMin = 60 }) => {
} }
}); });
/**
* Rich OLT detail (clicked from an ONU's Parent OLT card): identity +
* traffic + temperature + ONU counts + laser + alarms + upstream switch.
*/
ipcMain.handle('api:getOltDetail', async (_ev, { oltMac }) => {
try {
const c = requireClient();
const [oltCfg, oltState, alarmsDoc] = await Promise.all([
c.getOltConfig(oltMac).catch(() => null),
c.getOltState(oltMac).catch(() => null),
c.getOltAlarms(oltMac).catch(() => null),
]);
return { ok: true, data: summarizeOltDetail({ oltCfg, oltState, alarmsDoc }) };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:listOnuFirmware', async () => { ipcMain.handle('api:listOnuFirmware', async () => {
try { try {
const c = requireClient(); const c = requireClient();

View file

@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('api', {
getOnuConfig: (opts) => call('api:getOnuConfig', opts), getOnuConfig: (opts) => call('api:getOnuConfig', opts),
getOnuCpe: (opts) => call('api:getOnuCpe', opts), getOnuCpe: (opts) => call('api:getOnuCpe', opts),
getOnuDetail: (opts) => call('api:getOnuDetail', opts), getOnuDetail: (opts) => call('api:getOnuDetail', opts),
getOltDetail: (opts) => call('api:getOltDetail', opts),
listOnuFirmware: () => call('api:listOnuFirmware'), listOnuFirmware: () => call('api:listOnuFirmware'),
uploadFirmware: () => call('api:uploadFirmware'), uploadFirmware: () => call('api:uploadFirmware'),

View file

@ -530,8 +530,10 @@ button.danger:hover:not(:disabled) {
.onu-info-row:last-child { border-bottom: none; } .onu-info-row:last-child { border-bottom: none; }
.onu-info-row:hover { background: rgba(33, 200, 255, 0.06); } .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.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-serial { font-weight: 600; font-size: 12px; display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.onu-info-row .oir-sub { font-size: 11px; color: var(--fg-dim); display: flex; gap: 8px; align-items: center; } .onu-info-row .oir-sub { font-size: 11px; color: var(--fg-dim); display: flex; gap: 8px; align-items: center; }
.onu-info-row .oir-meta { font-size: 11px; color: var(--fg-dim); overflow-wrap: anywhere; }
.onu-info-row .oir-meta .sep { opacity: 0.5; }
.onu-detail { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; 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); } .onu-detail h2 { margin: 0; font-size: 17px; color: var(--neon); text-shadow: var(--glow-neon); }
@ -542,12 +544,24 @@ button.danger:hover:not(:disabled) {
gap: 14px; gap: 14px;
align-items: start; align-items: start;
} }
/* min-width:0 stops a long unbreakable string (system descriptions, IPv6,
alarm text) from blowing the grid track out past the card. */
.detail-card { min-width: 0; }
.kv dd { min-width: 0; overflow-wrap: anywhere; }
svg.spark { vertical-align: middle; opacity: 0.9; } 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 { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.alarm-row:last-child { border-bottom: none; } .alarm-row:last-child { border-bottom: none; }
.alarm-row .alarm-text { flex: 1; } .alarm-row .alarm-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
.alarm-row .alarm-meta { color: var(--fg-dim); font-size: 11px; white-space: nowrap; } .alarm-row .alarm-text { overflow-wrap: anywhere; }
.alarm-row .alarm-meta { color: var(--fg-dim); font-size: 11px; overflow-wrap: anywhere; }
.fw-upg { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; } .fw-upg { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.olt-link { color: var(--neon); text-decoration: none; border-bottom: 1px dotted currentColor; cursor: pointer; }
.olt-link:hover { text-shadow: var(--glow-neon); }
/* OLT detail modal */
.olt-modal { width: min(960px, 95vw); }
.olt-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
.olt-onu-list { max-height: 42vh; margin-top: 6px; }
.detail-card { .detail-card {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);

View file

@ -1868,6 +1868,8 @@ $('#onu-info-load').addEventListener('click', async () => {
renderOnuInfoList(); renderOnuInfoList();
}); });
$('#onu-info-search').addEventListener('input', renderOnuInfoList); $('#onu-info-search').addEventListener('input', renderOnuInfoList);
$('#onu-info-eq').addEventListener('input', renderOnuInfoList);
$('#onu-info-pon').addEventListener('change', renderOnuInfoList);
$('#onu-info-status').addEventListener('change', renderOnuInfoList); $('#onu-info-status').addEventListener('change', renderOnuInfoList);
// Jump straight to one ONU's info page (used by dashboard drill-downs). // Jump straight to one ONU's info page (used by dashboard drill-downs).
@ -1880,14 +1882,24 @@ async function openOnuInfo(onuId) {
function onuInfoMatches() { function onuInfoMatches() {
const q = $('#onu-info-search').value.trim().toLowerCase(); const q = $('#onu-info-search').value.trim().toLowerCase();
const eq = $('#onu-info-eq').value.trim().toLowerCase();
const pon = $('#onu-info-pon').value;
const status = $('#onu-info-status').value; const status = $('#onu-info-status').value;
return state.fleet.filter((cfg) => { return state.fleet.filter((cfg) => {
if (status === '__down__') { if (!DOWN_STATUSES.has(onuStatus(cfg))) return false; } if (status === '__down__') { if (!DOWN_STATUSES.has(onuStatus(cfg))) return false; }
else if (status === '__unknown__') { if (onuStatus(cfg) !== null) return false; } else if (status === '__unknown__') { if (onuStatus(cfg) !== null) return false; }
else if (status) { if (onuStatus(cfg) !== status) return false; } else if (status) { if (onuStatus(cfg) !== status) return false; }
if (!q) return true; if (pon && (cfg?.ONU?.['PON Mode'] || '') !== pon) return false;
const hay = `${cfg._id} ${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''}`.toLowerCase(); if (q) {
return hay.includes(q); const hay = `${cfg._id} ${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (eq) {
const versions = (cfg?.ONU?.['FW Bank Versions'] || []).join(' ');
const hay = `${equipmentId(cfg)} ${versions}`.toLowerCase();
if (!hay.includes(eq)) return false;
}
return true;
}); });
} }
@ -1903,9 +1915,16 @@ function renderOnuInfoList() {
const st = onuStatus(cfg); const st = onuStatus(cfg);
const cls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending'; const cls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending';
const sel = cfg._id === state.onuInfoSelected ? ' selected' : ''; const sel = cfg._id === state.onuInfoSelected ? ' selected' : '';
const name = cfg?.ONU?.Name || cfg?.ONU?.Address || '';
const eqid = equipmentId(cfg);
const ver = activeVersion(cfg);
const seen = formatRelative(daysSince(lastSeenDate(cfg)));
const meta = [eqid, ver && ver !== '(unset)' ? ver : '', seen]
.filter(Boolean).map(escapeHtml).join(' <span class="sep">·</span> ');
return `<div class="onu-info-row${sel}" data-id="${escapeHtml(cfg._id)}"> return `<div class="onu-info-row${sel}" data-id="${escapeHtml(cfg._id)}">
<span class="oir-serial">${escapeHtml(cfg._id)}</span> <span class="oir-serial">${escapeHtml(cfg._id)} <span class="${cls}"></span></span>
<span class="oir-sub"><span class="${cls}">${escapeHtml(st || 'unknown')}</span><span>${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')}</span></span> ${name ? `<span class="oir-sub">${escapeHtml(name)}</span>` : ''}
${meta ? `<span class="oir-meta">${meta}</span>` : ''}
</div>`; </div>`;
}).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>'; }).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>';
const total = onuInfoMatches().length; const total = onuInfoMatches().length;
@ -1999,12 +2018,15 @@ async function renderOnuDetail(onuId) {
${detailLinkCard(cfg)} ${detailLinkCard(cfg)}
${detailTrafficCard(d)} ${detailTrafficCard(d)}
${detailUniCard(d)} ${detailUniCard(d)}
${detailOltCard(d)} ${detailOltUplinkCard(d)}
${detailSwitchCard(d)}
${detailAlarmsCard(d)} ${detailAlarmsCard(d)}
<div class="detail-card">${renderCpeCard(d.cpe)}</div> <div class="detail-card">${renderCpeCard(d.cpe)}</div>
${detailFirmwareCard(d)} ${detailFirmwareCard(d)}
</div>`; </div>`;
// Clickable parent OLT -> OLT detail modal.
for (const a of el.querySelectorAll('.olt-link, .olt-detail-btn')) {
a.addEventListener('click', (e) => { e.preventDefault(); showOltModal(a.dataset.olt); });
}
wireFirmwareUpgrade(onuId); wireFirmwareUpgrade(onuId);
} }
@ -2074,7 +2096,7 @@ function detailUniCard(d) {
const uni = d.uni || []; const uni = d.uni || [];
const rows = uni.map((u) => kvRow( const rows = uni.map((u) => kvRow(
escapeHtml(u.name), escapeHtml(u.name),
`${u.enable ? '<span class="status-ok">enabled</span>' : '<span class="muted">disabled</span>'} · ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`, `${u.enable ? '<span class="status-ok">enabled</span>' : '<span class="muted">disabled</span>'} · <span class="muted">cfg</span> ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`,
)).join(''); )).join('');
let losRows = ''; let losRows = '';
if (d.ethLos) { if (d.ethLos) {
@ -2086,30 +2108,92 @@ function detailUniCard(d) {
return `<div class="detail-card"><h3>UNI ports</h3><dl class="kv">${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}</dl></div>`; return `<div class="detail-card"><h3>UNI ports</h3><dl class="kv">${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}</dl></div>`;
} }
function detailOltCard(d) { // Combined parent-OLT + upstream-switch (LLDP) card. The OLT name links to a
// full OLT detail modal (showOltModal).
function detailOltUplinkCard(d) {
const o = d.olt; const o = d.olt;
if (!o) return '<div class="detail-card"><h3>Parent OLT</h3><p class="muted small">Unknown.</p></div>'; const s = d.sw;
return `<div class="detail-card"><h3>Parent OLT</h3><dl class="kv"> if (!o && !s) return '<div class="detail-card"><h3>Parent OLT &amp; uplink</h3><p class="muted small">Unknown.</p></div>';
${kvRow('Name', escapeHtml(o.name || '—'))} const oltRows = o ? `
${kvRow('OLT', `<a class="olt-link" data-olt="${escapeHtml(o.mac)}">${escapeHtml(o.name || o.mac)}</a>`)}
${kvRow('MAC', escapeHtml(o.mac || '—'))} ${kvRow('MAC', escapeHtml(o.mac || '—'))}
${o.model ? kvRow('Model', escapeHtml(o.model)) : ''} ${o.model ? kvRow('Model', escapeHtml(o.model)) : ''}
${kvRow('FW', escapeHtml(o.fwVersion || '—'))} ${kvRow('FW / PON', `${escapeHtml(o.fwVersion || '—')} · ${escapeHtml(o.ponMode || '—')}`)}
${kvRow('PON mode', escapeHtml(o.ponMode || '—'))} ${o.location ? kvRow('Location', escapeHtml(o.location)) : ''}` : '';
${o.location ? kvRow('Location', escapeHtml(o.location)) : ''} const swRows = s ? `
</dl></div>`; ${kvRow('Uplink sw', escapeHtml(s.systemName || '—'))}
${kvRow('Sw port', escapeHtml(s.portDesc || s.portId || '—'))}
${s.ipv4 ? kvRow('Sw IPv4', escapeHtml(s.ipv4)) : ''}
${s.systemDesc ? kvRow('Sw descr', `<span class="small muted">${escapeHtml(s.systemDesc)}</span>`) : ''}` : '';
return `<div class="detail-card"><h3>Parent OLT &amp; uplink</h3><dl class="kv">${oltRows}${swRows}</dl>
${o ? `<div class="row" style="margin-top:10px"><button class="ghost olt-detail-btn" data-olt="${escapeHtml(o.mac)}">View OLT details →</button></div>` : ''}</div>`;
} }
function detailSwitchCard(d) { // Full OLT detail in a modal: identity, traffic/temp/ONU-counts, alarms, and
const s = d.sw; // (from the loaded fleet, no extra fetch) the per-ONU FEC/optical list.
if (!s) return ''; async function showOltModal(oltMac) {
return `<div class="detail-card"><h3>Upstream switch (LLDP)</h3><dl class="kv"> const overlay = document.createElement('div');
${kvRow('System', escapeHtml(s.systemName || '—'))} overlay.className = 'modal-overlay';
${kvRow('Port', escapeHtml(s.portDesc || s.portId || '—'))} overlay.innerHTML = `<div class="modal olt-modal"><h3>OLT ${escapeHtml(oltMac)}</h3><p class="muted small">Loading…</p><div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div></div>`;
${s.ipv4 ? kvRow('IPv4', escapeHtml(s.ipv4)) : ''} document.body.appendChild(overlay);
${kvRow('Chassis', escapeHtml(s.chassisId || '—'))} const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
${kvRow('Port ID', escapeHtml(s.portId || '—'))} const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
${s.systemDesc ? kvRow('Descr', `<span class="small muted">${escapeHtml(s.systemDesc)}</span>`) : ''} overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
</dl></div>`; overlay.querySelector('.modal-ok').addEventListener('click', close);
document.addEventListener('keydown', onKey, true);
const res = await window.api.getOltDetail({ oltMac });
if (!document.body.contains(overlay)) return;
const box = overlay.querySelector('.olt-modal');
if (!res.ok) {
box.innerHTML = `<h3>OLT ${escapeHtml(oltMac)}</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p><div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>`;
box.querySelector('.modal-ok').addEventListener('click', close);
return;
}
const d = res.data;
const tr = d.traffic;
const tempCls = d.tempC == null ? 'muted' : d.tempC >= 80 ? 'status-err' : d.tempC >= 70 ? 'status-warn' : 'status-ok';
const onus = state.fleet.filter((c) => c?._status?.oltMac === oltMac);
const onuRows = onus.slice(0, 300).map((c) => {
const cs = onuClientStats(c);
const [fc, ftxt] = fecVerdict(cs);
const st = onuStatus(c);
const stc = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending';
return `<div class="list-modal-item tappable" data-onu="${escapeHtml(c._id)}">
<span class="lm-primary">${escapeHtml(c._id)} <span class="${stc}"></span></span>
<span class="lm-secondary">${escapeHtml(c?.ONU?.Name || '')} · <span class="${fc}">${escapeHtml(ftxt)}</span> · RX ${cs.rx == null ? '—' : escapeHtml(cs.rx.toFixed(1))} / TX ${cs.tx == null ? '—' : escapeHtml(cs.tx.toFixed(1))} dBm</span>
</div>`;
}).join('');
box.innerHTML = `
<h3>OLT ${escapeHtml(d.name || oltMac)} <span class="muted">${escapeHtml(oltMac)}</span></h3>
<div class="olt-modal-grid">
<div class="detail-card"><h3>OLT</h3><dl class="kv">
${kvRow('Model', escapeHtml(d.model || '—'))}
${kvRow('FW', escapeHtml(d.fwVersion || '—'))}
${kvRow('PON mode', escapeHtml(d.ponMode || '—'))}
${d.location ? kvRow('Location', escapeHtml(d.location)) : ''}
${d.serialNumber ? kvRow('Serial', escapeHtml(d.serialNumber)) : ''}
${d.laser ? kvRow('Laser', `<span class="${d.laser.off ? 'status-err' : 'status-ok'}">${escapeHtml(d.laser.value)}</span>`) : ''}
</dl></div>
<div class="detail-card"><h3>Traffic / health</h3><dl class="kv">
${tr ? kvRow('↓ Down / ↑ Up', `<span class="status-ok">${escapeHtml(fmtBps(tr.downBps))}</span> / <span class="status-info">${escapeHtml(fmtBps(tr.upBps))}</span>`) : kvRow('Traffic', '—')}
${tr && tr.rxUtil != null ? kvRow('Util RX/TX', `${escapeHtml(String(tr.rxUtil))}% / ${escapeHtml(String(tr.txUtil))}%`) : ''}
${kvRow('Temp (ASIC)', d.tempC == null ? '—' : `<span class="${tempCls}">${escapeHtml(String(d.tempC))} °C</span>`)}
${kvRow('ONUs', `${d.onuCounts.total ?? '—'} total · ${d.onuCounts.online ?? '—'} online · ${d.onuCounts.offline ?? '—'} offline`)}
</dl></div>
${detailAlarmsCard(d)}
</div>
<h3 style="margin-top:14px">Connected ONUs ${state.fleet.length ? `<span class="muted">(${onus.length})</span>` : ''}</h3>
${state.fleet.length
? `<div class="list-modal-items olt-onu-list">${onuRows || '<div class="modal-body muted">None on this OLT in the loaded fleet.</div>'}</div>`
: '<p class="muted small">Load the fleet (ONU tab) to list per-ONU FEC/optical here.</p>'}
<div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>`;
box.querySelector('.modal-ok').addEventListener('click', close);
for (const el of box.querySelectorAll('.list-modal-item.tappable')) {
el.addEventListener('click', () => { close(); openOnuInfo(el.dataset.onu); });
}
} }
function sevPillClass(rank) { function sevPillClass(rank) {
@ -2122,7 +2206,10 @@ function detailAlarmsCard(d) {
const shown = al.slice(0, 12); const shown = al.slice(0, 12);
const rows = shown.map((a) => { const rows = shown.map((a) => {
const dot = a.active ? '<span class="status-err">●</span>' : '<span class="muted">○</span>'; const dot = a.active ? '<span class="status-err">●</span>' : '<span class="muted">○</span>';
return `<div class="alarm-row">${dot} <span class="lease-pill ${sevPillClass(a.sevRank)}">${escapeHtml(a.sevLabel || a.severity)}</span> <span class="alarm-text">${escapeHtml(a.text || a.type)}</span><span class="alarm-meta">×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}</span></div>`; return `<div class="alarm-row">
<span class="alarm-head">${dot} <span class="lease-pill ${sevPillClass(a.sevRank)}">${escapeHtml(a.sevLabel || a.severity)}</span> <span class="alarm-text">${escapeHtml(a.text || a.type)}</span></span>
<span class="alarm-meta">×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}</span>
</div>`;
}).join(''); }).join('');
const cls = d.alarmActiveCount ? 'status-err' : 'muted'; const cls = d.alarmActiveCount ? 'status-err' : 'muted';
return `<div class="detail-card"><h3>Alarms <span class="${cls}">(${d.alarmActiveCount} active / ${al.length})</span></h3>${rows}${al.length > shown.length ? `<p class="muted small">+${al.length - shown.length} more</p>` : ''}</div>`; return `<div class="detail-card"><h3>Alarms <span class="${cls}">(${d.alarmActiveCount} active / ${al.length})</span></h3>${rows}${al.length > shown.length ? `<p class="muted small">+${al.length - shown.length} more</p>` : ''}</div>`;

View file

@ -77,6 +77,17 @@
<label>Search (name / address / serial) <label>Search (name / address / serial)
<input id="onu-info-search" type="search" placeholder="e.g. ORKANGER or GNXS05…" /> <input id="onu-info-search" type="search" placeholder="e.g. ORKANGER or GNXS05…" />
</label> </label>
<label>Equipment / version contains
<input id="onu-info-eq" type="search" placeholder="e.g. FT-XGS2110 or EV051" />
</label>
<label>PON mode
<select id="onu-info-pon">
<option value="">Any</option>
<option value="GPON">GPON</option>
<option value="XGS-PON">XGS-PON</option>
<option value="10G-EPON">10G-EPON</option>
</select>
</label>
<label>Registration status <label>Registration status
<select id="onu-info-status"> <select id="onu-info-status">
<option value="">Any</option> <option value="">Any</option>

View file

@ -585,6 +585,28 @@ class McmsClient {
return this._listPaginatedByNext('/v3/olts/states/', { pageSize }); return this._listPaginatedByNext('/v3/olts/states/', { pageSize });
} }
/** Single OLT-STATE doc (traffic / temperature / ONU counts / laser). */
async getOltState(oltId) {
try {
const res = await this._request('GET', `/v3/olts/states/${encodeURIComponent(oltId)}/`);
return res.body?.data ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
/** OLT alarm-history doc (alarms + upstream-switch LLDP). */
async getOltAlarms(oltId) {
try {
const res = await this._request('GET', `/v3/olts/alarm-histories/${encodeURIComponent(oltId)}/`);
return res.body?.data ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
// --- OLT configuration (PON flooding mode) --------------------------- // --- OLT configuration (PON flooding mode) ---------------------------
/** /**

67
src/oltdetail.js Normal file
View file

@ -0,0 +1,67 @@
// OLT detail summarizer for the clickable OLT info card. Pure — shared by the
// Electron main process and the web server. Combines OLT-CFG (identity) +
// OLT-STATE (traffic / temperature / ONU counts / laser) + alarm-histories
// (alarms + upstream-switch LLDP).
const { summarizeAlarms } = require('./onudetail');
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;
}
function summarizeOltDetail({ oltCfg = null, oltState = null, alarmsDoc = null } = {}) {
const cOlt = oltCfg?.OLT || {};
const sOlt = oltState?.OLT || {};
const pon = oltState?.STATS?.['OLT-PON'] || {};
const optr = cOlt['FW Bank Ptr'];
const trafficSeen = pon['TX BW Ethernet Rate bps'] != null || pon['RX BW Ethernet Rate bps'] != null;
const traffic = trafficSeen ? {
downBps: num(pon['TX BW Ethernet Rate bps']) || 0, // OLT TX = downstream
upBps: num(pon['RX BW Ethernet Rate bps']) || 0, // OLT RX = upstream
txUtil: num(pon['TX BW Total Util']),
rxUtil: num(pon['RX BW Total Util']),
} : null;
const onuCounts = {
total: num(pon['Total ONUs Count']),
online: num(pon['Online ONUs Count']),
offline: num(pon['Offline ONUs Count']),
};
const laserStr = sOlt['Laser Shutdown'];
const laser = (typeof laserStr === 'string')
? { value: laserStr, off: laserStr.toLowerCase() !== 'laser on' }
: null;
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;
const { alarms, alarmActiveCount } = summarizeAlarms(alarmsDoc?.Alarms);
return {
mac: oltCfg?._id || oltState?._id || alarmsDoc?._id || '',
name: cOlt.Name || sOlt.Name || '',
model: cOlt.Model || sOlt.Model || '',
fwVersion: (Array.isArray(cOlt['FW Bank Versions']) && typeof optr === 'number') ? (cOlt['FW Bank Versions'][optr] || '') : (sOlt['FW Version'] || ''),
ponMode: cOlt['PON Mode'] || sOlt['PON Mode'] || '',
location: cOlt.Location || '',
serialNumber: sOlt['Serial Number'] || '',
onlineTime: sOlt['Online Time'] || '',
traffic,
tempC: num(oltState?.STATS?.['OLT-TEMP']?.ASIC),
onuCounts,
laser,
sw,
alarms,
alarmActiveCount,
};
}
module.exports = { summarizeOltDetail };

View file

@ -9,6 +9,36 @@ function num(v) {
return null; return null;
} }
// Normalize an alarm-history "Alarms" array (shared by ONU and OLT detail):
// active-first, then severity rank ("N-LABEL"), then most-recently-raised.
function summarizeAlarms(rawAlarms) {
const list = (Array.isArray(rawAlarms) ? 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)));
return { alarms: list, alarmActiveCount: list.filter((a) => a.active).length };
}
/** /**
* @param {object} a * @param {object} a
* @param {object} [a.cfg] ONU-CFG doc (firmware banks, UNI-ETH, ) * @param {object} [a.cfg] ONU-CFG doc (firmware banks, UNI-ETH, )
@ -42,32 +72,7 @@ function summarizeOnuDetail({ cfg = null, alarmsDoc = null, statsSeries = [], ol
.map((k) => ({ name: k, enable: !!(cfg[k] && cfg[k].Enable) })); .map((k) => ({ name: k, enable: !!(cfg[k] && cfg[k].Enable) }));
// --- Alarms --- // --- Alarms ---
const rawAlarms = Array.isArray(alarmsDoc?.Alarms) ? alarmsDoc.Alarms : []; const { alarms, alarmActiveCount } = summarizeAlarms(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) --- // --- Ethernet UNI loss-of-signal (last disconnect) ---
const los = alarms const los = alarms
@ -133,4 +138,4 @@ function summarizeOnuDetail({ cfg = null, alarmsDoc = null, statsSeries = [], ol
}; };
} }
module.exports = { summarizeOnuDetail }; module.exports = { summarizeOnuDetail, summarizeAlarms };

View file

@ -149,6 +149,7 @@
getOnuConfig: (o) => postJSON('getOnuConfig', o), getOnuConfig: (o) => postJSON('getOnuConfig', o),
getOnuCpe: (o) => postJSON('getOnuCpe', o), getOnuCpe: (o) => postJSON('getOnuCpe', o),
getOnuDetail: (o) => postJSON('getOnuDetail', o), getOnuDetail: (o) => postJSON('getOnuDetail', o),
getOltDetail: (o) => postJSON('getOltDetail', o),
listOnuFirmware: () => postJSON('listOnuFirmware', {}), listOnuFirmware: () => postJSON('listOnuFirmware', {}),
uploadFirmware: async () => { uploadFirmware: async () => {
const file = await pickFile('.bin'); const file = await pickFile('.bin');

View file

@ -31,6 +31,7 @@ const { summarizeDashboard } = require('../src/dashboard');
const { summarizeCpe } = require('../src/cpe'); const { summarizeCpe } = require('../src/cpe');
const { lookupOui } = require('../src/oui'); const { lookupOui } = require('../src/oui');
const { summarizeOnuDetail } = require('../src/onudetail'); const { summarizeOnuDetail } = require('../src/onudetail');
const { summarizeOltDetail } = require('../src/oltdetail');
const { TtlCache } = require('./cache'); const { TtlCache } = require('./cache');
const PORT = Number(process.env.PORT) || 8080; const PORT = Number(process.env.PORT) || 8080;
@ -296,6 +297,16 @@ const handlers = {
return { ok: true, data: detail }; return { ok: true, data: detail };
}, },
async getOltDetail(s, body) {
const c = s.client;
const [oltCfg, oltState, alarmsDoc] = await Promise.all([
c.getOltConfig(body.oltMac).catch(() => null),
c.getOltState(body.oltMac).catch(() => null),
c.getOltAlarms(body.oltMac).catch(() => null),
]);
return { ok: true, data: summarizeOltDetail({ oltCfg, oltState, alarmsDoc }) };
},
async listOnuFirmware(s, body) { async listOnuFirmware(s, body) {
const fresh = !!(body && body.fresh); const fresh = !!(body && body.fresh);
return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) }; return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) };