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

@ -1868,6 +1868,8 @@ $('#onu-info-load').addEventListener('click', async () => {
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);
// Jump straight to one ONU's info page (used by dashboard drill-downs).
@ -1880,14 +1882,24 @@ async function openOnuInfo(onuId) {
function onuInfoMatches() {
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;
return state.fleet.filter((cfg) => {
if (status === '__down__') { if (!DOWN_STATUSES.has(onuStatus(cfg))) return false; }
else if (status === '__unknown__') { if (onuStatus(cfg) !== null) return false; }
else if (status) { if (onuStatus(cfg) !== status) return false; }
if (!q) return true;
const hay = `${cfg._id} ${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''}`.toLowerCase();
return hay.includes(q);
if (pon && (cfg?.ONU?.['PON Mode'] || '') !== pon) return false;
if (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 cls = st === 'Registered' ? 'status-ok' : (st && DOWN_STATUSES.has(st)) ? 'status-err' : 'status-pending';
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)}">
<span class="oir-serial">${escapeHtml(cfg._id)}</span>
<span class="oir-sub"><span class="${cls}">${escapeHtml(st || 'unknown')}</span><span>${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')}</span></span>
<span class="oir-serial">${escapeHtml(cfg._id)} <span class="${cls}"></span></span>
${name ? `<span class="oir-sub">${escapeHtml(name)}</span>` : ''}
${meta ? `<span class="oir-meta">${meta}</span>` : ''}
</div>`;
}).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>';
const total = onuInfoMatches().length;
@ -1999,12 +2018,15 @@ async function renderOnuDetail(onuId) {
${detailLinkCard(cfg)}
${detailTrafficCard(d)}
${detailUniCard(d)}
${detailOltCard(d)}
${detailSwitchCard(d)}
${detailOltUplinkCard(d)}
${detailAlarmsCard(d)}
<div class="detail-card">${renderCpeCard(d.cpe)}</div>
${detailFirmwareCard(d)}
</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);
}
@ -2074,7 +2096,7 @@ function detailUniCard(d) {
const uni = d.uni || [];
const rows = uni.map((u) => kvRow(
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('');
let losRows = '';
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>`;
}
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;
if (!o) return '<div class="detail-card"><h3>Parent OLT</h3><p class="muted small">Unknown.</p></div>';
return `<div class="detail-card"><h3>Parent OLT</h3><dl class="kv">
${kvRow('Name', escapeHtml(o.name || '—'))}
const s = d.sw;
if (!o && !s) return '<div class="detail-card"><h3>Parent OLT &amp; uplink</h3><p class="muted small">Unknown.</p></div>';
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 || '—'))}
${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)) : ''}
</dl></div>`;
${kvRow('FW / PON', `${escapeHtml(o.fwVersion || '—')} · ${escapeHtml(o.ponMode || '—')}`)}
${o.location ? kvRow('Location', escapeHtml(o.location)) : ''}` : '';
const swRows = s ? `
${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) {
const s = d.sw;
if (!s) return '';
return `<div class="detail-card"><h3>Upstream switch (LLDP)</h3><dl class="kv">
${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', `<span class="small muted">${escapeHtml(s.systemDesc)}</span>`) : ''}
</dl></div>`;
// Full OLT detail in a modal: identity, traffic/temp/ONU-counts, alarms, and
// (from the loaded fleet, no extra fetch) the per-ONU FEC/optical list.
async function showOltModal(oltMac) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
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>`;
document.body.appendChild(overlay);
const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
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) {
@ -2122,7 +2206,10 @@ function detailAlarmsCard(d) {
const shown = al.slice(0, 12);
const rows = shown.map((a) => {
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('');
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>`;