Add multi-user web server variant (browser UI behind a reverse proxy)
New web/ subfolder: a Node http server that serves the SAME tooling as the Electron app to any browser, with per-session MCMS credentials so several operators can use it at once behind a TLS reverse proxy. Mobile-responsive. Not a fork — it reuses the core and the UI: - serves renderer/app.js + app.css verbatim and rewrites index.html on the fly (mobile viewport + browser window.api shim + responsive overlay) - web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming; Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob - web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry, routes mirroring the IPC handlers, NDJSON for the 6 streaming ops - pure Node http, no new deps (borrows ../node_modules tough-cookie) Shared improvements (benefit both apps): - src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses it); adds abnormal-Tx detection alongside abnormal-Rx - renderer: dashboard health stats are now clickable — abnormal Rx / Tx / lasers-off pop a list of the offending devices (like the iOS app), via a new showListModal Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16. Verified: node --check all; started the server and confirmed transformed index, shared-asset serving, 401 auth gate, login (per-session client), and NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9106e5a071
commit
96b0264179
10 changed files with 1096 additions and 109 deletions
190
web/public/api-web.js
Normal file
190
web/public/api-web.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// 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),
|
||||
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'),
|
||||
};
|
||||
})();
|
||||
53
web/public/web.css
Normal file
53
web/public/web.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* Web-only overlay, loaded AFTER the shared renderer app.css. Two jobs:
|
||||
(1) let the page scroll normally in a browser tab (the Electron layout
|
||||
pins everything to 100vh), and (2) make it usable on phones/tablets. */
|
||||
|
||||
/* Tap targets a bit comfier for touch without changing the desktop look. */
|
||||
button, .tab, .dash-row.tappable, .list-modal-item { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
/* ----- Tablet / phone ----- */
|
||||
@media (max-width: 860px) {
|
||||
html, body { height: auto; min-height: 100%; }
|
||||
|
||||
/* Topbar wraps; tabs drop to their own full-width row and scroll. */
|
||||
.topbar { flex-wrap: wrap; gap: 8px 12px; padding: 10px 12px; }
|
||||
.topbar .session { order: 2; font-size: 11px; }
|
||||
#btn-logout { order: 2; }
|
||||
.tabs {
|
||||
order: 3;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tabs .tab { flex: 1 0 auto; }
|
||||
|
||||
/* Stack the two-pane layout vertically and let the document scroll. */
|
||||
.view { flex-direction: column; height: auto; min-height: calc(100vh - 52px); }
|
||||
.panel-left {
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow: visible;
|
||||
}
|
||||
.panel-main { overflow: visible; }
|
||||
|
||||
/* Tables: keep columns readable by scrolling sideways, not squishing. */
|
||||
.fleet-table-wrap { max-height: 70vh; }
|
||||
.fleet-table { min-width: 720px; }
|
||||
|
||||
/* Dashboard tiles go single-column; modals near full-width. */
|
||||
.dash-grid { grid-template-columns: 1fr; }
|
||||
.dash-scroll { overflow: visible; }
|
||||
.dash-traffic { gap: 24px; }
|
||||
.modal, .list-modal { width: 94vw; }
|
||||
#view-login { padding-top: 24px; }
|
||||
.card { width: min(440px, 92vw); }
|
||||
}
|
||||
|
||||
/* ----- Small phones ----- */
|
||||
@media (max-width: 520px) {
|
||||
.tile .tile-value { font-size: 28px; }
|
||||
.dash-traffic .t-val { font-size: 22px; }
|
||||
.topbar .brand { font-size: 13px; letter-spacing: 1px; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue