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:
parent
276c3e9faa
commit
2046fad0f8
11 changed files with 321 additions and 59 deletions
143
renderer/app.js
143
renderer/app.js
|
|
@ -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 & 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 & 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>`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue