ponfw/web/public/api-web.js
Jon Vanvik 55e95ed467 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>
2026-06-23 15:19:28 +02:00

191 lines
8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Browser drop-in for the Electron preload's `window.api`. Same method
// signatures and the same streaming-callback contract (onXProgress + an
// awaitable trigger), implemented over fetch + NDJSON streaming. This lets
// the shared renderer/app.js run unchanged in any browser.
//
// Differences absorbed here so the renderer doesn't have to care:
// - Electron dialogs -> hidden <input type=file> pickers
// - CSV "save to ~/Downloads" -> client-built Blob download
(function () {
const listeners = { fec: [], verify: [], upgrade: [], states: [], delete: [], 'olt-flood': [] };
async function postJSON(pathName, body) {
let res;
try {
res = await fetch('/api/' + pathName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
credentials: 'same-origin',
});
} catch (e) {
return { ok: false, error: { message: 'network error: ' + e.message } };
}
let data = null;
try { data = await res.json(); } catch (_) { /* non-JSON */ }
if (data && typeof data === 'object' && 'ok' in data) return data;
return { ok: res.ok, error: (data && data.error) || { message: 'HTTP ' + res.status } };
}
// Streaming POST. The server replies NDJSON: progress objects, then a
// single {__final:true, ok, data|error} line. Progress objects are routed
// to the registered listeners for `channel` (mirrors ipcRenderer events).
async function streamJSON(pathName, body, channel) {
let res;
try {
res = await fetch('/api/' + pathName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
credentials: 'same-origin',
});
} catch (e) {
return { ok: false, error: { message: 'network error: ' + e.message } };
}
if (res.status === 401) return { ok: false, error: { message: 'Not logged in.' } };
if (!res.body || !res.body.getReader) {
// No streaming support — fall back to a plain JSON read.
const data = await res.json().catch(() => null);
return data || { ok: false, error: { message: 'no response body' } };
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let final = null;
const handleLine = (line) => {
const t = line.trim();
if (!t) return;
let obj;
try { obj = JSON.parse(t); } catch (_) { return; }
if (obj && obj.__final) { final = obj; return; }
if (channel && listeners[channel]) {
for (const h of listeners[channel].slice()) { try { h(obj); } catch (_) { /* ignore */ } }
}
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
handleLine(buf.slice(0, nl));
buf = buf.slice(nl + 1);
}
}
if (buf) handleLine(buf);
if (final) return { ok: final.ok, data: final.data, error: final.error };
return { ok: false, error: { message: 'stream ended without a result' } };
}
function sub(channel) {
return (handler) => {
listeners[channel].push(handler);
return () => {
const i = listeners[channel].indexOf(handler);
if (i >= 0) listeners[channel].splice(i, 1);
};
};
}
// ---- Browser file pickers (replace Electron dialog.*) ----
function pickFile(accept) {
return new Promise((resolve) => {
const inp = document.createElement('input');
inp.type = 'file';
if (accept) inp.accept = accept;
inp.style.position = 'fixed';
inp.style.left = '-9999px';
document.body.appendChild(inp);
let settled = false;
const finish = (file) => { if (settled) return; settled = true; resolve(file); inp.remove(); };
inp.addEventListener('change', () => finish(inp.files[0] || null));
// Cancel has no reliable event; when the window regains focus and no
// file was chosen shortly after, treat it as a cancel.
window.addEventListener('focus', () => {
setTimeout(() => { if (!settled && (!inp.files || !inp.files.length)) finish(null); }, 400);
}, { once: true });
inp.click();
});
}
const readText = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = no; r.readAsText(file); });
const readBase64 = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = no; r.readAsDataURL(file); });
// ---- CSV -> browser download (a server can't write to the user's disk) ----
function buildCsv(headers, rows) {
const cell = (v) => '"' + (v === null || v === undefined ? '' : String(v)).replace(/"/g, '""') + '"';
const line = (cells) => cells.map(cell).join(',');
const lines = [line(headers)];
for (const row of rows) lines.push(line(headers.map((h) => row[h])));
return '' + lines.join('\r\n') + '\r\n';
}
function downloadCsv(prefix, hint, headers, rows) {
const ts = new Date();
const p = (n) => String(n).padStart(2, '0');
const stamp = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`;
const safeHint = String(hint || 'csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60);
const safePrefix = String(prefix || 'pon-csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40);
const filename = `${safePrefix}-${safeHint}-${stamp}.csv`;
const blob = new Blob([buildCsv(headers, rows)], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
return { ok: true, data: { path: filename, filename, rowCount: rows.length } };
}
window.api = {
login: (o) => postJSON('login', o),
logout: () => postJSON('logout', {}),
listOnuConfigs: (o) => postJSON('listOnuConfigs', o),
listFleet: (o) => postJSON('listFleet', o),
fetchStates: (o) => streamJSON('fetchStates', o, 'states'),
getOnuConfig: (o) => postJSON('getOnuConfig', o),
getOnuCpe: (o) => postJSON('getOnuCpe', o),
listOnuFirmware: () => postJSON('listOnuFirmware', {}),
uploadFirmware: async () => {
const file = await pickFile('.bin');
if (!file) return { ok: false, error: { message: 'cancelled' } };
const base64 = await readBase64(file);
return postJSON('uploadFirmware', { filename: file.name, base64 });
},
planUpgrade: (o) => postJSON('planUpgrade', o),
executePerOnu: (o) => streamJSON('executePerOnu', o, 'upgrade'),
executeBulkTask: (o) => postJSON('executeBulkTask', o),
getTaskConfig: (o) => postJSON('getTaskConfig', o),
getUpgradeStatus: (o) => postJSON('getUpgradeStatus', o),
deleteOnus: (o) => streamJSON('deleteOnus', o, 'delete'),
dashboardStats: (o) => postJSON('dashboardStats', o),
listOlts: (o) => postJSON('listOlts', o),
executeFloodChange: (o) => streamJSON('executeFloodChange', o, 'olt-flood'),
fetchFecHealth: (o) => streamJSON('fetchFecHealth', o, 'fec'),
fetchOnuSnapshot: (o) => streamJSON('fetchOnuSnapshot', o, 'verify'),
// CSV saves become browser downloads (path field kept for UI messages).
savePlanCsv: async ({ rows, filenameHint, headers }) => downloadCsv('pon-upgrade', filenameHint, headers, rows),
saveCsvFile: async ({ rows, filenameHint, headers, prefix }) => downloadCsv(prefix || 'pon-csv', filenameHint, headers, rows),
openCsvFile: async () => {
const file = await pickFile('.csv');
if (!file) return { ok: false, error: { message: 'cancelled' } };
return { ok: true, data: { path: file.name, text: await readText(file) } };
},
onFecProgress: sub('fec'),
onVerifyProgress: sub('verify'),
onUpgradeProgress: sub('upgrade'),
onStatesProgress: sub('states'),
onDeleteProgress: sub('delete'),
onFloodProgress: sub('olt-flood'),
};
})();