ponfw/web/public/api-web.js
Jon Vanvik 2046fad0f8 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>
2026-06-23 16:10:34 +02:00

193 lines
8.1 KiB
JavaScript
Raw Permalink 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),
getOnuDetail: (o) => postJSON('getOnuDetail', o),
getOltDetail: (o) => postJSON('getOltDetail', 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'),
};
})();