onu: info/stats sub-tab with CPE lease health + OUI vendor
Adds an ONU info page and splits the ONU tab into two sub-tabs (Info / stats, Fleet & upgrade). The ONU tab now lands on Info; campaign/verify keep the Fleet sub-tab lit. ONU info page (#view-onu-info): master-detail over the already-loaded fleet (search + status filter, no new bulk fetch). Per-ONU detail shows identity, link/optical + FEC (read from the merged ONU-STATE), firmware, and a CPE card. CPE (customer router): - McmsClient.getCpe — GET /api/cpe/onu/<id>/ (unversioned, [status,doc] tuple). - src/cpe.js (pure, shared): summarizeCpe + leaseHealth. Renewal rule — <50% of the lease remaining => "renewal overdue" (amber), past end => expired. v4 expiry = DHCP "Remove Time"; v6 spans Last Success -> Preferred Lifetime (T1 midpoint), with Prefix Delegation surfaced. - api:getOnuCpe (main.js + web/server.js, exposed in both bridges). OUI vendor (src/oui.js, pure, shared): lazy fetch of the IEEE oui.csv, prefix->org map, cached in memory ~30 days + best-effort to disk (PFW_CACHE_DIR / temp; PrivateTmp makes /tmp writable). Resolved server-side in getOnuCpe; every failure degrades to "unknown OUI". CPE itself is not TTL-cached (lease must be live); only the OUI list is. Dashboard abnormal Rx/Tx rows are now clickable -> deep-link to that ONU's info page (showListModal gained per-item onClick). Docs: CLAUDE.md §17 + IPC table + intro; README. Verified: node --check; cpe.js/oui.js unit-tested (15/15: lease ok/warn/ expired/unknown + 50% boundary, v6 prefix parse, OUI quoted-CSV parse + lookup); ONU info page + CPE card rendered offscreen over HTTP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5e3d9271a8
commit
55e95ed467
12 changed files with 677 additions and 7 deletions
|
|
@ -480,6 +480,94 @@ button.danger:hover:not(:disabled) {
|
|||
box-shadow: 0 0 12px rgba(33, 200, 255, 0.45), inset 0 0 14px rgba(33, 200, 255, 0.12);
|
||||
}
|
||||
|
||||
/* -------- Sub-tabs (within the ONU tab) -------- */
|
||||
.subtabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: -4px 0 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.subtabs .subtab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.subtabs .subtab:hover:not(.active) { color: var(--neon); border-color: transparent; box-shadow: none; }
|
||||
.subtabs .subtab.active {
|
||||
color: var(--neon);
|
||||
border-color: var(--neon);
|
||||
background: rgba(33, 200, 255, 0.1);
|
||||
text-shadow: var(--glow-neon);
|
||||
box-shadow: inset 0 0 12px rgba(33, 200, 255, 0.12);
|
||||
}
|
||||
|
||||
/* -------- ONU info / stats page -------- */
|
||||
.onu-info-list {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
}
|
||||
.onu-info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.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-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 h2 { margin: 0; font-size: 17px; color: var(--neon); text-shadow: var(--glow-neon); }
|
||||
.detail-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
|
||||
}
|
||||
.detail-card > h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: var(--neon);
|
||||
text-shadow: var(--glow-neon);
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv dt { color: var(--fg-dim); }
|
||||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; word-break: break-all; }
|
||||
.lease-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* -------- Dashboard -------- */
|
||||
.dash-wrap { overflow: hidden; }
|
||||
.dash-scroll {
|
||||
|
|
@ -592,6 +680,7 @@ button.danger:hover:not(:disabled) {
|
|||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.list-modal-item:last-child { border-bottom: none; }
|
||||
.list-modal-item.tappable { cursor: pointer; }
|
||||
.list-modal-item:hover { background: rgba(33, 200, 255, 0.06); }
|
||||
.lm-primary { font-weight: 600; }
|
||||
.lm-secondary { color: var(--fg-dim); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
|
|
|||
226
renderer/app.js
226
renderer/app.js
|
|
@ -15,6 +15,7 @@ const state = {
|
|||
oltFiltered: [], // current visible OLTs
|
||||
oltSelected: new Set(),
|
||||
dashboard: null, // result of dashboardStats (null until first load)
|
||||
onuInfoSelected: null, // onuId currently shown on the ONU info page
|
||||
};
|
||||
|
||||
// -------- Helpers --------
|
||||
|
|
@ -25,6 +26,7 @@ const $$ = (sel) => document.querySelectorAll(sel);
|
|||
// ONU tab highlighted and showView() callers don't each have to know.
|
||||
const VIEW_TO_TAB = {
|
||||
'#view-dashboard': 'dashboard',
|
||||
'#view-onu-info': 'onu',
|
||||
'#view-fleet': 'onu',
|
||||
'#view-campaign': 'onu',
|
||||
'#view-verify': 'onu',
|
||||
|
|
@ -36,6 +38,10 @@ function showView(id) {
|
|||
$(id).classList.remove('hidden');
|
||||
const tab = VIEW_TO_TAB[id] || null;
|
||||
for (const t of $$('.tab')) t.classList.toggle('active', t.dataset.tab === tab);
|
||||
// Sub-tabs (within ONU): the two switchable views are Info and Fleet;
|
||||
// campaign/verify are sub-flows reached from Fleet, so keep Fleet lit there.
|
||||
const subId = (id === '#view-campaign' || id === '#view-verify') ? '#view-fleet' : id;
|
||||
for (const st of $$('.subtab')) st.classList.toggle('active', st.dataset.subview === subId);
|
||||
}
|
||||
|
||||
function activeBank(cfg) {
|
||||
|
|
@ -1682,13 +1688,16 @@ function dashHealthRow(title, detail, count, drillKey) {
|
|||
|
||||
// Scrollable list/menu popped from a clickable dashboard stat (e.g. the
|
||||
// list of ONUs with abnormal Rx). `items` is [{primary, secondary}].
|
||||
// `items` is [{ primary, secondary, onClick? }]. Items with onClick render
|
||||
// as clickable rows that close the modal then fire the handler.
|
||||
function showListModal(title, items) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
const list = items.length
|
||||
? `<div class="list-modal-items">${items.map((it) =>
|
||||
`<div class="list-modal-item"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
|
||||
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</span>` : ''}</div>`).join('')}</div>`
|
||||
? `<div class="list-modal-items">${items.map((it, i) =>
|
||||
`<div class="list-modal-item${it.onClick ? ' tappable' : ''}" data-idx="${i}"><span class="lm-primary">${escapeHtml(it.primary)}</span>${
|
||||
it.secondary ? `<span class="lm-secondary">${escapeHtml(it.secondary)}</span>` : ''}${
|
||||
it.onClick ? ' <span class="dash-chevron">›</span>' : ''}</div>`).join('')}</div>`
|
||||
: '<div class="modal-body muted">Nothing to show — all clear.</div>';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal list-modal" role="dialog" aria-modal="true">
|
||||
|
|
@ -1699,6 +1708,10 @@ function showListModal(title, items) {
|
|||
document.body.appendChild(overlay);
|
||||
const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
||||
for (const el of overlay.querySelectorAll('.list-modal-item.tappable')) {
|
||||
const it = items[Number(el.dataset.idx)];
|
||||
el.addEventListener('click', () => { close(); it.onClick(); });
|
||||
}
|
||||
overlay.querySelector('.modal-ok').addEventListener('click', close);
|
||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
|
|
@ -1815,11 +1828,13 @@ function renderDashboard(d) {
|
|||
showListModal('ONUs with abnormal Rx', (d.optical.abnormalRx || []).map((x) => ({
|
||||
primary: x.name || x.onuId,
|
||||
secondary: `${fmtDbm(x.rx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
|
||||
onClick: () => openOnuInfo(x.onuId),
|
||||
})));
|
||||
} else if (drill === 'tx') {
|
||||
showListModal('ONUs with abnormal Tx', (d.optical.abnormalTx || []).map((x) => ({
|
||||
primary: x.name || x.onuId,
|
||||
secondary: `${fmtDbm(x.tx)} dBm${x.name ? ' · ' + x.onuId : ''}`,
|
||||
onClick: () => openOnuInfo(x.onuId),
|
||||
})));
|
||||
} else if (drill === 'laser') {
|
||||
showListModal('OLTs with laser off', (d.lasersOff || []).map((x) => ({
|
||||
|
|
@ -1836,3 +1851,208 @@ function renderDashboard(d) {
|
|||
|
||||
renderCacheBadge(d.cache);
|
||||
}
|
||||
|
||||
// -------- Sub-tab navigation (ONU: Info / Fleet) --------
|
||||
for (const st of $$('.subtab')) {
|
||||
st.addEventListener('click', () => {
|
||||
const view = st.dataset.subview;
|
||||
showView(view);
|
||||
if (view === '#view-onu-info' && state.fleet.length) renderOnuInfoList();
|
||||
});
|
||||
}
|
||||
|
||||
// -------- ONU info / stats page --------
|
||||
|
||||
$('#onu-info-load').addEventListener('click', async () => {
|
||||
await loadFleet();
|
||||
renderOnuInfoList();
|
||||
});
|
||||
$('#onu-info-search').addEventListener('input', renderOnuInfoList);
|
||||
$('#onu-info-status').addEventListener('change', renderOnuInfoList);
|
||||
|
||||
// Jump straight to one ONU's info page (used by dashboard drill-downs).
|
||||
async function openOnuInfo(onuId) {
|
||||
showView('#view-onu-info');
|
||||
if (!state.fleet.length) await loadFleet();
|
||||
renderOnuInfoList();
|
||||
selectOnuInfo(onuId);
|
||||
}
|
||||
|
||||
function onuInfoMatches() {
|
||||
const q = $('#onu-info-search').value.trim().toLowerCase();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOnuInfoList() {
|
||||
const list = $('#onu-info-list');
|
||||
if (!state.fleet.length) {
|
||||
list.innerHTML = '<div class="muted small" style="padding:10px">Load the fleet to list ONUs.</div>';
|
||||
$('#onu-info-status-line').textContent = 'Load the fleet, then pick an ONU.';
|
||||
return;
|
||||
}
|
||||
const matched = onuInfoMatches().slice(0, 400); // cap the DOM for huge fleets
|
||||
list.innerHTML = matched.map((cfg) => {
|
||||
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' : '';
|
||||
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>
|
||||
</div>`;
|
||||
}).join('') || '<div class="muted small" style="padding:10px">No ONUs match.</div>';
|
||||
const total = onuInfoMatches().length;
|
||||
$('#onu-info-status-line').textContent =
|
||||
`${total} ONU(s) match${total > matched.length ? ` (showing first ${matched.length})` : ''}.`;
|
||||
for (const row of list.querySelectorAll('.onu-info-row')) {
|
||||
row.addEventListener('click', () => selectOnuInfo(row.dataset.id));
|
||||
}
|
||||
}
|
||||
|
||||
function selectOnuInfo(onuId) {
|
||||
state.onuInfoSelected = onuId;
|
||||
for (const row of $$('#onu-info-list .onu-info-row')) {
|
||||
row.classList.toggle('selected', row.dataset.id === onuId);
|
||||
}
|
||||
renderOnuDetail(onuId);
|
||||
}
|
||||
|
||||
// Read optical + FEC straight from the merged ONU-STATE (no extra fetch).
|
||||
function onuClientStats(cfg) {
|
||||
const s = cfg?._state?.STATS || cfg?._state?.state_collection?.STATS || {};
|
||||
const onu = s['ONU-PON'] || {};
|
||||
const olt = s['OLT-PON'] || {};
|
||||
const num = (v) => (typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null));
|
||||
return {
|
||||
rx: num(onu['RX Optical Level']), tx: num(onu['TX Optical Level']),
|
||||
onuPre: num(onu['RX Pre-FEC BER']), onuPost: num(onu['RX Post-FEC BER']),
|
||||
oltPre: num(olt['RX Pre-FEC BER']), oltPost: num(olt['RX Post-FEC BER']),
|
||||
};
|
||||
}
|
||||
function rxCls(rx) { return rx == null ? 'muted' : rx >= -28 ? 'status-ok' : rx >= -30 ? 'status-warn' : 'status-err'; }
|
||||
function txCls(tx) { return tx == null ? 'muted' : tx >= 3 ? 'status-ok' : 'status-err'; }
|
||||
function fecVerdict(s) {
|
||||
if ((s.onuPost ?? 0) > 0 || (s.oltPost ?? 0) > 0) return ['status-err', 'post-FEC errors'];
|
||||
if ((s.onuPre ?? 0) > 0 || (s.oltPre ?? 0) > 0) return ['status-warn', 'pre-FEC only'];
|
||||
return ['status-ok', 'clean'];
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
const neg = ms < 0; let s = Math.round(Math.abs(ms) / 1000);
|
||||
const d = Math.floor(s / 86400); s -= d * 86400;
|
||||
const h = Math.floor(s / 3600); s -= h * 3600;
|
||||
const m = Math.floor(s / 60);
|
||||
const parts = d ? [`${d}d`, `${h}h`] : h ? [`${h}h`, `${m}m`] : [`${m}m`];
|
||||
return (neg ? '-' : '') + parts.join(' ');
|
||||
}
|
||||
function leasePill(health) {
|
||||
const h = health || {};
|
||||
const map = {
|
||||
ok: ['status-ok', 'OK'],
|
||||
warn: ['status-warn', 'renewal overdue'],
|
||||
expired: ['status-err', 'EXPIRED'],
|
||||
unknown: ['muted', 'unknown'],
|
||||
};
|
||||
const [cls, label] = map[h.level] || map.unknown;
|
||||
const rem = h.remainingMs != null
|
||||
? (h.remainingMs > 0 ? ` · ${fmtDuration(h.remainingMs)} left` : ` · ${fmtDuration(h.remainingMs)} ago`)
|
||||
: '';
|
||||
return `<span class="lease-pill ${cls}">${label}${rem}</span>`;
|
||||
}
|
||||
|
||||
const kvRow = (k, v) => `<dt>${escapeHtml(k)}</dt><dd>${v}</dd>`;
|
||||
|
||||
async function renderOnuDetail(onuId) {
|
||||
const el = $('#onu-info-detail');
|
||||
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 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);
|
||||
|
||||
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>`;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function renderCpeCard(cpe) {
|
||||
if (!cpe) return '<h3>CPE (customer router)</h3><p class="muted">No CPE / DHCP lease record for this ONU.</p>';
|
||||
const vendor = cpe.cpeVendor ? escapeHtml(cpe.cpeVendor) : '<span class="muted">unknown OUI</span>';
|
||||
const head = `<dl class="kv">
|
||||
${kvRow('CPE MAC', escapeHtml(cpe.cpeMac || '—'))}
|
||||
${kvRow('Vendor (OUI)', vendor)}
|
||||
${cpe.fwVersion ? kvRow('CPE FW', escapeHtml(cpe.fwVersion)) : ''}
|
||||
</dl>`;
|
||||
|
||||
const v4 = cpe.v4 ? `
|
||||
<h3 style="margin-top:14px">DHCPv4 ${leasePill(cpe.v4.health)}</h3>
|
||||
<dl class="kv">
|
||||
${kvRow('State', escapeHtml(cpe.v4.state || '—'))}
|
||||
${kvRow('IP', escapeHtml(cpe.v4.ip || '—'))}
|
||||
${kvRow('Lease', cpe.v4.leaseSeconds ? fmtDuration(cpe.v4.leaseSeconds * 1000) : '—')}
|
||||
${kvRow('Last renew', escapeHtml(cpe.v4.lastSuccess || '—'))}
|
||||
${kvRow('Expires', escapeHtml(cpe.v4.expiry || '—'))}
|
||||
${cpe.v4.circuitId ? kvRow('Circuit ID', escapeHtml(cpe.v4.circuitId)) : ''}
|
||||
</dl>` : '<h3 style="margin-top:14px">DHCPv4 <span class="lease-pill muted">none</span></h3>';
|
||||
|
||||
const v6 = cpe.v6 ? `
|
||||
<h3 style="margin-top:14px">DHCPv6 ${leasePill(cpe.v6.health)}</h3>
|
||||
<dl class="kv">
|
||||
${kvRow('State', escapeHtml(cpe.v6.state || '—'))}
|
||||
${kvRow('IP', escapeHtml(cpe.v6.ip || '—'))}
|
||||
${cpe.v6.prefixes && cpe.v6.prefixes.length ? kvRow('Prefix deleg.', escapeHtml(cpe.v6.prefixes.join(', '))) : ''}
|
||||
${kvRow('Renew (T1)', escapeHtml(cpe.v6.t1 || '—'))}
|
||||
${kvRow('Preferred till', escapeHtml(cpe.v6.preferred || '—'))}
|
||||
${kvRow('Valid till', escapeHtml(cpe.v6.valid || '—'))}
|
||||
</dl>` : '<h3 style="margin-top:14px">DHCPv6 <span class="lease-pill muted">none</span></h3>';
|
||||
|
||||
const ppp = cpe.pppoe ? `<h3 style="margin-top:14px">PPPoE</h3><dl class="kv">${kvRow('State', escapeHtml(cpe.pppoe.state))}${kvRow('Session', escapeHtml(String(cpe.pppoe.sessionId)))}</dl>` : '';
|
||||
|
||||
return `<h3>CPE (customer router)</h3>${head}${v4}${v6}${ppp}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="brand">PON Fleet Upgrader</div>
|
||||
<nav id="tabs" class="tabs hidden">
|
||||
<button class="tab" data-tab="dashboard" data-view="#view-dashboard">Dashboard</button>
|
||||
<button class="tab" data-tab="onu" data-view="#view-fleet">ONU</button>
|
||||
<button class="tab" data-tab="onu" data-view="#view-onu-info">ONU</button>
|
||||
<button class="tab" data-tab="olt" data-view="#view-olts">OLT</button>
|
||||
</nav>
|
||||
<div class="session" id="session-label">Not connected</div>
|
||||
|
|
@ -66,9 +66,47 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= ONU info / stats view ================= -->
|
||||
<section id="view-onu-info" class="view hidden">
|
||||
<div class="panel-left">
|
||||
<nav class="subtabs">
|
||||
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
|
||||
<button class="subtab" data-subview="#view-fleet">Fleet & upgrade</button>
|
||||
</nav>
|
||||
<h2>Find ONU</h2>
|
||||
<label>Search (name / address / serial)
|
||||
<input id="onu-info-search" type="search" placeholder="e.g. ORKANGER or GNXS05…" />
|
||||
</label>
|
||||
<label>Registration status
|
||||
<select id="onu-info-status">
|
||||
<option value="">Any</option>
|
||||
<option value="__down__">Down (Dereg / Dying Gasp / Disabled)</option>
|
||||
<option value="Registered">Registered</option>
|
||||
<option value="Deregistered">Deregistered</option>
|
||||
<option value="Dying Gasp">Dying Gasp</option>
|
||||
<option value="Disabled">Disabled</option>
|
||||
<option value="__unknown__">Unknown (no OLT state)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="onu-info-load" class="primary">Load fleet</button>
|
||||
<p class="muted small" id="onu-info-status-line">Load the fleet, then pick an ONU.</p>
|
||||
<div class="onu-info-list" id="onu-info-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-main">
|
||||
<div id="onu-info-detail" class="onu-detail">
|
||||
<p class="muted">Select an ONU on the left to see its stats and the CPE (customer router) behind it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Fleet view ================= -->
|
||||
<section id="view-fleet" class="view hidden">
|
||||
<div class="panel-left">
|
||||
<nav class="subtabs">
|
||||
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
|
||||
<button class="subtab" data-subview="#view-fleet">Fleet & upgrade</button>
|
||||
</nav>
|
||||
<h2>Filters</h2>
|
||||
<label>Name / address contains
|
||||
<input id="filter-text" type="search" placeholder="e.g. ORKANGER" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue