diff --git a/CLAUDE.md b/CLAUDE.md index c951fca..3b7d493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,7 @@ stream progress events back to the renderer. | `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:getOltDetail` | OLT detail: traffic/temp/ONU-counts/laser/alarms/switch | — | | `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — | | `api:planUpgrade` | dry-run plan for selected ONUs | — | | `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) 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 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` 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) `api:getOnuCpe` → `McmsClient.getCpe` (`GET /api/cpe/onu//` — unversioned, diff --git a/main.js b/main.js index 4d27b3b..9f8b083 100644 --- a/main.js +++ b/main.js @@ -16,6 +16,7 @@ const { summarizeDashboard } = require('./src/dashboard'); const { summarizeCpe } = require('./src/cpe'); const { lookupOui } = require('./src/oui'); const { summarizeOnuDetail } = require('./src/onudetail'); +const { summarizeOltDetail } = require('./src/oltdetail'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); /** @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 () => { try { const c = requireClient(); diff --git a/preload.js b/preload.js index 0ce792b..b1f6f76 100644 --- a/preload.js +++ b/preload.js @@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('api', { getOnuConfig: (opts) => call('api:getOnuConfig', opts), getOnuCpe: (opts) => call('api:getOnuCpe', opts), getOnuDetail: (opts) => call('api:getOnuDetail', opts), + getOltDetail: (opts) => call('api:getOltDetail', opts), listOnuFirmware: () => call('api:listOnuFirmware'), uploadFirmware: () => call('api:uploadFirmware'), diff --git a/renderer/app.css b/renderer/app.css index a8af02f..b42f6b8 100644 --- a/renderer/app.css +++ b/renderer/app.css @@ -530,8 +530,10 @@ button.danger:hover:not(:disabled) { .onu-info-row:last-child { border-bottom: none; } .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 .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-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 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; 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; } -.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 .alarm-text { flex: 1; } -.alarm-row .alarm-meta { color: var(--fg-dim); font-size: 11px; white-space: nowrap; } +.alarm-row .alarm-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; } +.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; } +.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 { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/renderer/app.js b/renderer/app.js index be81343..11e0155 100644 --- a/renderer/app.js +++ b/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(' · '); return `
- ${escapeHtml(cfg._id)} - ${escapeHtml(st || 'unknown')}${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')} + ${escapeHtml(cfg._id)} + ${name ? `${escapeHtml(name)}` : ''} + ${meta ? `${meta}` : ''}
`; }).join('') || '
No ONUs match.
'; 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)}
${renderCpeCard(d.cpe)}
${detailFirmwareCard(d)} `; + // 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 ? 'enabled' : 'disabled'} · ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`, + `${u.enable ? 'enabled' : 'disabled'} · cfg ${escapeHtml(u.speed || '?')}/${escapeHtml(u.duplex || '?')}`, )).join(''); let losRows = ''; if (d.ethLos) { @@ -2086,30 +2108,92 @@ function detailUniCard(d) { return `

UNI ports

${rows || kvRow('—', 'no UNI-ETH ports')}${losRows}
`; } -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 '

Parent OLT

Unknown.

'; - return `

Parent OLT

- ${kvRow('Name', escapeHtml(o.name || '—'))} + const s = d.sw; + if (!o && !s) return '

Parent OLT & uplink

Unknown.

'; + const oltRows = o ? ` + ${kvRow('OLT', `${escapeHtml(o.name || o.mac)}`)} ${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)) : ''} -
`; + ${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', `${escapeHtml(s.systemDesc)}`) : ''}` : ''; + return `

Parent OLT & uplink

${oltRows}${swRows}
+ ${o ? `
` : ''}
`; } -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)}`) : ''} -
`; +// 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 = ``; + 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 = `

OLT ${escapeHtml(oltMac)}

Error: ${escapeHtml(res.error?.message || 'failed')}

`; + 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 `
+ ${escapeHtml(c._id)} + ${escapeHtml(c?.ONU?.Name || '')} · ${escapeHtml(ftxt)} · RX ${cs.rx == null ? '—' : escapeHtml(cs.rx.toFixed(1))} / TX ${cs.tx == null ? '—' : escapeHtml(cs.tx.toFixed(1))} dBm +
`; + }).join(''); + + box.innerHTML = ` +

OLT ${escapeHtml(d.name || oltMac)} ${escapeHtml(oltMac)}

+
+

OLT

+ ${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', `${escapeHtml(d.laser.value)}`) : ''} +
+

Traffic / health

+ ${tr ? kvRow('↓ Down / ↑ Up', `${escapeHtml(fmtBps(tr.downBps))} / ${escapeHtml(fmtBps(tr.upBps))}`) : kvRow('Traffic', '—')} + ${tr && tr.rxUtil != null ? kvRow('Util RX/TX', `${escapeHtml(String(tr.rxUtil))}% / ${escapeHtml(String(tr.txUtil))}%`) : ''} + ${kvRow('Temp (ASIC)', d.tempC == null ? '—' : `${escapeHtml(String(d.tempC))} °C`)} + ${kvRow('ONUs', `${d.onuCounts.total ?? '—'} total · ${d.onuCounts.online ?? '—'} online · ${d.onuCounts.offline ?? '—'} offline`)} +
+ ${detailAlarmsCard(d)} +
+

Connected ONUs ${state.fleet.length ? `(${onus.length})` : ''}

+ ${state.fleet.length + ? `
${onuRows || ''}
` + : '

Load the fleet (ONU tab) to list per-ONU FEC/optical here.

'} + `; + 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 ? '' : ''; - return `
${dot} ${escapeHtml(a.sevLabel || a.severity)} ${escapeHtml(a.text || a.type)}×${a.raisedCount} · ${escapeHtml(a.active ? `since ${a.lastRaised}` : `last ${a.lastRaised}`)}
`; + 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

` : ''}
`; diff --git a/renderer/index.html b/renderer/index.html index 074937c..fd55a93 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -77,6 +77,17 @@ + +