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:
Jon Vanvik 2026-06-23 14:07:23 +02:00
parent 9106e5a071
commit 96b0264179
10 changed files with 1096 additions and 109 deletions

View file

@ -1574,14 +1574,45 @@ function optionExists(selectSel, value) {
return Array.from($(selectSel).options).some((o) => o.value === value);
}
function dashHealthRow(title, detail, count) {
function fmtDbm(n) {
return typeof n === 'number' ? n.toFixed(1) : '—';
}
function dashHealthRow(title, detail, count, drillKey) {
const cls = count > 0 ? 'status-warn' : 'status-ok';
return `<div class="dash-row">
const tappable = drillKey && count > 0;
const chevron = tappable ? ' <span class="dash-chevron"></span>' : '';
return `<div class="dash-row${tappable ? ' tappable' : ''}"${drillKey ? ` data-drill="${drillKey}"` : ''}>
<span class="dash-row-main">${escapeHtml(title)}${detail ? `<span class="dash-row-sub">${escapeHtml(detail)}</span>` : ''}</span>
<span class="dash-num ${cls}">${count}</span>
<span class="dash-num ${cls}">${count}${chevron}</span>
</div>`;
}
// Scrollable list/menu popped from a clickable dashboard stat (e.g. the
// list of ONUs with abnormal Rx). `items` is [{primary, secondary}].
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="modal-body muted">Nothing to show — all clear.</div>';
overlay.innerHTML = `
<div class="modal list-modal" role="dialog" aria-modal="true">
<h3>${escapeHtml(title)} <span class="muted">(${items.length})</span></h3>
${list}
<div class="modal-actions"><button class="primary modal-ok" type="button">Close</button></div>
</div>`;
document.body.appendChild(overlay);
const close = () => { document.removeEventListener('keydown', onKey, true); overlay.remove(); };
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
overlay.querySelector('.modal-ok').addEventListener('click', close);
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', onKey, true);
overlay.querySelector('.modal-ok').focus();
}
async function loadDashboard() {
const extra = $('#dash-extra').checked;
$('#dash-status').textContent = extra
@ -1633,12 +1664,13 @@ function renderDashboard(d) {
</div>`);
}
// --- Health ---
// --- Health (rows with a count drill down to the offenders) ---
const healthRows = [];
if (d.optical && d.optical.available) {
healthRows.push(dashHealthRow('ONUs with abnormal Rx', '< 28 or > 10 dBm', d.optical.abnormalRxCount));
healthRows.push(dashHealthRow('ONUs with abnormal Rx', '< 28 or > 10 dBm', d.optical.abnormalRxCount, 'rx'));
healthRows.push(dashHealthRow('ONUs with abnormal Tx', '< 3 dBm', d.optical.abnormalTxCount, 'tx'));
}
healthRows.push(dashHealthRow('OLTs with laser off', null, (d.lasersOff || []).length));
healthRows.push(dashHealthRow('OLTs with laser off', null, (d.lasersOff || []).length, 'laser'));
sections.push(`
<div class="dash-section">
<h3>Health</h3>
@ -1675,13 +1707,34 @@ function renderDashboard(d) {
$('#dash-sections').innerHTML = sections.join('');
// Drill-down: clicking an ONU-state row jumps to the ONU tab pre-filtered.
// Drill-down: ONU-state rows jump to the ONU tab pre-filtered; health
// rows pop a list of the offending devices (like the iOS app).
for (const row of $$('#dash-sections .dash-row.tappable')) {
row.addEventListener('click', () => {
const status = row.dataset.status;
$('#filter-status').value = optionExists('#filter-status', status) ? status : '';
showView('#view-fleet');
if (!state.fleet.length) loadFleet(); else applyFilters();
if (row.dataset.status !== undefined) {
const status = row.dataset.status;
$('#filter-status').value = optionExists('#filter-status', status) ? status : '';
showView('#view-fleet');
if (!state.fleet.length) loadFleet(); else applyFilters();
return;
}
const drill = row.dataset.drill;
if (drill === 'rx') {
showListModal('ONUs with abnormal Rx', (d.optical.abnormalRx || []).map((x) => ({
primary: x.name || x.onuId,
secondary: `${fmtDbm(x.rx)} dBm${x.name ? ' · ' + 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 : ''}`,
})));
} else if (drill === 'laser') {
showListModal('OLTs with laser off', (d.lasersOff || []).map((x) => ({
primary: x.name || x.oltId,
secondary: `${x.laser} · ${x.oltId}`,
})));
}
});
}