onu detail: parent OLT + LLDP switch + traffic history + alarms + UNI; single-ONU upgrade
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 <noreply@anthropic.com>
This commit is contained in:
parent
55e95ed467
commit
276c3e9faa
9 changed files with 474 additions and 46 deletions
|
|
@ -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);
|
||||
|
|
|
|||
252
renderer/app.js
252
renderer/app.js
|
|
@ -1975,50 +1975,222 @@ async function renderOnuDetail(onuId) {
|
|||
const cfg = state.fleet.find((c) => c._id === onuId);
|
||||
if (!cfg) { el.innerHTML = '<p class="muted">ONU not found in the loaded fleet — reload the fleet.</p>'; return; }
|
||||
|
||||
const head = `<h2>${escapeHtml(cfg._id)}</h2>`;
|
||||
// Identity + link render instantly from the loaded fleet; the rest streams
|
||||
// in from one getOnuDetail call.
|
||||
el.innerHTML = `${head}<div class="detail-grid">
|
||||
${detailIdentityCard(cfg, null)}
|
||||
${detailLinkCard(cfg)}
|
||||
<div class="detail-card"><h3>Loading…</h3><p class="muted small">Fetching OLT, traffic, UNI, alarms, CPE…</p></div>
|
||||
</div>`;
|
||||
|
||||
const res = await window.api.getOnuDetail({ onuId });
|
||||
if (state.onuInfoSelected !== onuId) return; // selection moved on
|
||||
if (!res.ok) {
|
||||
el.innerHTML = `${head}<div class="detail-grid">
|
||||
${detailIdentityCard(cfg, null)}${detailLinkCard(cfg)}
|
||||
<div class="detail-card"><h3>Detail</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p></div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
el.innerHTML = `${head}<div class="detail-grid">
|
||||
${detailIdentityCard(cfg, d)}
|
||||
${detailLinkCard(cfg)}
|
||||
${detailTrafficCard(d)}
|
||||
${detailUniCard(d)}
|
||||
${detailOltCard(d)}
|
||||
${detailSwitchCard(d)}
|
||||
${detailAlarmsCard(d)}
|
||||
<div class="detail-card">${renderCpeCard(d.cpe)}</div>
|
||||
${detailFirmwareCard(d)}
|
||||
</div>`;
|
||||
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 `<div class="detail-card"><h3>ONU</h3><dl class="kv">
|
||||
${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', `<span class="${stCls}">${escapeHtml(st || 'unknown')}</span>`)}
|
||||
${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))}
|
||||
${kvRow('Active version', escapeHtml(activeV || '—'))}
|
||||
${kvRow('Inactive version', escapeHtml(inactiveV || '—'))}
|
||||
</dl></div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<h2>${escapeHtml(cfg._id)}</h2>
|
||||
<div class="detail-card">
|
||||
<h3>ONU</h3>
|
||||
<dl class="kv">
|
||||
${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', `<span class="${stCls}">${escapeHtml(st || 'unknown')}</span>`)}
|
||||
${kvRow('Last seen', escapeHtml(formatRelative(daysSince(lastSeenDate(cfg))) || '—'))}
|
||||
${kvRow('Active version', escapeHtml(activeVersion(cfg)))}
|
||||
${kvRow('Inactive version', escapeHtml(inactiveVersion(cfg)))}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<h3>Link / optical</h3>
|
||||
<dl class="kv">
|
||||
${kvRow('FEC', `<span class="${fc}">${escapeHtml(ftxt)}</span>`)}
|
||||
${kvRow('ONU RX', `<span class="${rxCls(stats.rx)}">${stats.rx == null ? '—' : escapeHtml(stats.rx.toFixed(2)) + ' dBm'}</span>`)}
|
||||
${kvRow('ONU TX', `<span class="${txCls(stats.tx)}">${stats.tx == null ? '—' : escapeHtml(stats.tx.toFixed(2)) + ' dBm'}</span>`)}
|
||||
${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 ?? '—'))}`)}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-card" id="onu-cpe-card">
|
||||
<h3>CPE (customer router)</h3>
|
||||
<p class="muted small">Loading lease info…</p>
|
||||
</div>`;
|
||||
function detailLinkCard(cfg) {
|
||||
const s = onuClientStats(cfg);
|
||||
const [fc, ftxt] = fecVerdict(s);
|
||||
return `<div class="detail-card"><h3>Link / optical</h3><dl class="kv">
|
||||
${kvRow('FEC', `<span class="${fc}">${escapeHtml(ftxt)}</span>`)}
|
||||
${kvRow('ONU RX', `<span class="${rxCls(s.rx)}">${s.rx == null ? '—' : escapeHtml(s.rx.toFixed(2)) + ' dBm'}</span>`)}
|
||||
${kvRow('ONU TX', `<span class="${txCls(s.tx)}">${s.tx == null ? '—' : escapeHtml(s.tx.toFixed(2)) + ' dBm'}</span>`)}
|
||||
${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 ?? '—'))}`)}
|
||||
</dl></div>`;
|
||||
}
|
||||
|
||||
// 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 = `<h3>CPE (customer router)</h3><p class="status-err">Error: ${escapeHtml(res.error?.message || 'failed')}</p>`; 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 `<svg class="spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="1.5"/></svg>`;
|
||||
}
|
||||
|
||||
function detailTrafficCard(d) {
|
||||
const t = d.traffic || {};
|
||||
const cur = t.current;
|
||||
const series = t.series || [];
|
||||
const win = t.windowMin || 60;
|
||||
if (!series.length) {
|
||||
return `<div class="detail-card"><h3>Traffic (last ${win} min)</h3><p class="muted small">No PM samples in the window.</p></div>`;
|
||||
}
|
||||
return `<div class="detail-card"><h3>Traffic (last ${win} min)</h3><dl class="kv">
|
||||
${kvRow('↓ Down now', `<span class="status-ok">${escapeHtml(fmtBps(cur ? cur.downBps : null))}</span> ${sparklineSvg(series.map((x) => x.downBps), 'var(--neon)')}`)}
|
||||
${kvRow('↑ Up now', `<span class="status-info">${escapeHtml(fmtBps(cur ? cur.upBps : null))}</span> ${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))}
|
||||
</dl></div>`;
|
||||
}
|
||||
|
||||
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 || '?')}`,
|
||||
)).join('');
|
||||
let losRows = '';
|
||||
if (d.ethLos) {
|
||||
const los = d.ethLos;
|
||||
const cls = los.active ? 'status-err' : 'status-ok';
|
||||
losRows = kvRow('Eth link', `<span class="${cls}">${los.active ? 'DOWN (LAN-LOS active)' : 'up'}</span>`)
|
||||
+ kvRow('Last LOS', `${escapeHtml(los.lastRaised || '—')}${los.raisedCount ? ` (×${los.raisedCount})` : ''}`);
|
||||
}
|
||||
return `<div class="detail-card"><h3>UNI ports</h3><dl class="kv">${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}</dl></div>`;
|
||||
}
|
||||
|
||||
function detailOltCard(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 || '—'))}
|
||||
${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>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 '<div class="detail-card"><h3>Alarms</h3><p class="muted small">No alarm history.</p></div>';
|
||||
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>`;
|
||||
}).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>`;
|
||||
}
|
||||
|
||||
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 ? ` <span class="muted small">${escapeHtml(b.file)}</span>` : ''}`)).join('');
|
||||
return `<div class="detail-card"><h3>Firmware</h3><dl class="kv">${bankRows || kvRow('—', 'no banks')}</dl>
|
||||
<div class="fw-upg">
|
||||
<label>Upgrade to<select id="onu-upg-select"><option value="">Loading firmware…</option></select></label>
|
||||
<div class="row"><button id="onu-upg-btn" class="danger" disabled>Upgrade this ONU →</button></div>
|
||||
<p class="small muted" id="onu-upg-status">Writes to the inactive bank (Procedure 7), same as the bulk tool. Reboot applies it.</p>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// 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 = '<option value="">— choose firmware —</option>';
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue