From 96b026417913a59c33d22efde87bf43ed2f01056 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 14:07:23 +0200 Subject: [PATCH] Add multi-user web server variant (browser UI behind a reverse proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> , 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 --- CLAUDE.md | 28 +++ main.js | 106 +-------- renderer/app.css | 25 +++ renderer/app.js | 75 ++++++- src/dashboard.js | 110 +++++++++ web/README.md | 99 +++++++++ web/package.json | 12 + web/public/api-web.js | 190 ++++++++++++++++ web/public/web.css | 53 +++++ web/server.js | 507 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1096 insertions(+), 109 deletions(-) create mode 100644 src/dashboard.js create mode 100644 web/README.md create mode 100644 web/package.json create mode 100644 web/public/api-web.js create mode 100644 web/public/web.css create mode 100644 web/server.js diff --git a/CLAUDE.md b/CLAUDE.md index 050aa24..5695be6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -774,4 +774,32 @@ Respects `prefers-reduced-motion`. z-index sits below the scanline overlay --- +## 16. Web server variant (`web/`) + +A multi-user, browser-based deployment of the *same* tooling lives in +`web/` — see `web/README.md`. It is **not a fork**: it reuses `src/*` and +serves `renderer/app.js` + `renderer/app.css` verbatim, rewriting +`renderer/index.html` on the fly to swap in a browser `window.api` shim +(`web/public/api-web.js`) and a responsive overlay (`web/public/web.css`). + +Consequences for editing: + +- **The renderer is shared.** Changes to `renderer/*` and `src/*` flow to + both the Electron app and the web app automatically. Keep `app.js` + free of Electron/Node globals (it already only touches `window.api` + + DOM) so the shim stays sufficient. +- **`window.api` is the contract.** If you add an IPC channel (see §10), + also add it to `web/server.js` (a route) **and** `web/public/api-web.js` + (the shim method) or the web app won't have it. Streaming channels use + NDJSON (progress lines + a `{__final:…}` line) instead of IPC events. +- **Per-session isolation.** `web/server.js` keeps one `McmsClient` per + browser session (cookie `pfw_sid`), vs. the Electron app's single global + `client`. CSV writes become browser downloads; file dialogs become + ``. Dashboard aggregation is shared via `src/dashboard.js`. + +The web app must stay a subdirectory of this repo — it `require`s `../src/*` +and borrows `../node_modules` (`tough-cookie`); no separate install. + +--- + *Last updated: 2026-06-23.* diff --git a/main.js b/main.js index a6c7cfe..f00cce4 100644 --- a/main.js +++ b/main.js @@ -12,6 +12,7 @@ const { McmsClient, McmsApiError, extractOnuHealth, summarizeOltNniNetworks, planFloodChange, } = require('./src/mcms-api'); +const { summarizeDashboard } = require('./src/dashboard'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); /** @type {McmsClient | null} */ @@ -709,112 +710,21 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => { // ---- Dashboard telemetry -------------------------------------------- -function toNum(v) { - if (typeof v === 'number') return Number.isFinite(v) ? v : null; - if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v); - return null; -} - /** - * Network-wide telemetry for the Dashboard tab, modeled on the PONGo iOS - * app's DashboardViewModel. The "light" path is cheap — everything below - * comes from the small OLT-STATE docs: - * - OLT count - * - ONU total + registration-state breakdown (OLT-STATE "ONU States" - * buckets, dev guide Procedure 2) - * - aggregate traffic: OLT TX BW = downstream, RX BW = upstream - * (STATS["OLT-PON"]["TX|RX BW Ethernet Rate bps"]) - * - OLTs with the laser shut down (OLT["Laser Shutdown"] != "Laser ON") - * Controllers count is best-effort (not on every build). - * - * The heavy per-ONU optical/FEC scan (pull every ONU-STATE) runs only - * when `extraStats` is set — same opt-in as the iOS "Rx scan" toggle. + * Network-wide telemetry for the Dashboard tab. Aggregation lives in the + * shared, pure src/dashboard.js (so the web server computes identically); + * this handler just fetches the inputs. Light by default (small OLT-STATE + * docs); the per-ONU optical/FEC scan runs only when `extraStats` is set. */ ipcMain.handle('api:dashboardStats', async (_ev, opts) => { try { const c = requireClient(); const extraStats = !!(opts && opts.extraStats); - const olts = await c.listAllOltStates(); - const oltCount = olts.length; - - // Registration map: onuId -> bucket. Prefer a non-Registered bucket on - // duplicates (the interesting signal), matching api:listFleet. - const stateByOnu = new Map(); - for (const olt of olts) { - const buckets = olt?.['ONU States'] || {}; - for (const [bucket, ids] of Object.entries(buckets)) { - if (!Array.isArray(ids)) continue; - for (const id of ids) { - const existing = stateByOnu.get(id); - if (!existing || existing === 'Registered') stateByOnu.set(id, bucket); - } - } - } - const onuTotal = stateByOnu.size; - const stateCounts = {}; - for (const s of stateByOnu.values()) stateCounts[s] = (stateCounts[s] || 0) + 1; - const onuCounts = Object.entries(stateCounts) - .map(([state, count]) => ({ state, count })) - .sort((a, b) => b.count - a.count); - - // Aggregate traffic + laser state from OLT-STATE. - let downBps = 0, upBps = 0, trafficSeen = false; - const lasersOff = []; - for (const olt of olts) { - const pon = olt?.STATS?.['OLT-PON'] || {}; - const tx = toNum(pon['TX BW Ethernet Rate bps']); - const rx = toNum(pon['RX BW Ethernet Rate bps']); - if (tx !== null) { downBps += tx; trafficSeen = true; } - if (rx !== null) { upBps += rx; trafficSeen = true; } - const laser = olt?.OLT?.['Laser Shutdown']; - if (typeof laser === 'string' && laser.toLowerCase() !== 'laser on') { - lasersOff.push({ oltId: olt._id, name: olt?.OLT?.Name || '', laser }); - } - } - - // Controllers — best-effort (some builds don't expose this). + const onuStates = extraStats ? await c.listAllOnuStates() : null; let controllerCount = null; - try { - controllerCount = (await c.listAllControllerConfigs()).length; - } catch (_) { controllerCount = null; } - - // Opt-in heavy scan: every ONU-STATE for optical + FEC health. - let optical = { available: false, abnormalRx: [], abnormalRxCount: 0 }; - let fecCounts = null; - if (extraStats) { - const states = await c.listAllOnuStates(); - const RX_LOW = -28, RX_HIGH = -10; // PONGo OpticalThreshold.rxAbnormal - const abnormalRx = []; - let available = false; - fecCounts = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 }; - for (const st of states) { - const health = extractOnuHealth(st); - if (fecCounts[health.flag] !== undefined) fecCounts[health.flag] += 1; - const rx = health.optical && typeof health.optical.rx === 'number' ? health.optical.rx : null; - if (rx !== null) { - available = true; - if (rx < RX_LOW || rx > RX_HIGH) { - abnormalRx.push({ onuId: st._id, rx, name: st?.ONU?.Name || '' }); - } - } - } - optical = { available, abnormalRx, abnormalRxCount: abnormalRx.length }; - } - - return { - ok: true, - data: { - onuTotal, oltCount, controllerCount, - onuCounts, - traffic: trafficSeen ? { downBps, upBps } : null, - lasersOff, - optical, - fecCounts, - extraStats, - sampledAt: new Date().toISOString(), - }, - }; + try { controllerCount = (await c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ } + return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) }; } catch (err) { return { ok: false, error: toWireError(err) }; } diff --git a/renderer/app.css b/renderer/app.css index b92c0a8..9687926 100644 --- a/renderer/app.css +++ b/renderer/app.css @@ -556,3 +556,28 @@ button.danger:hover:not(:disabled) { font-weight: 700; letter-spacing: 0.5px; } +.dash-chevron { color: var(--neon); margin-left: 8px; font-weight: 700; } + +/* List/menu popped from a clickable dashboard stat. */ +.list-modal { width: 540px; } +.list-modal-items { + max-height: 52vh; + overflow-y: auto; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04); +} +.list-modal-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.list-modal-item:last-child { border-bottom: none; } +.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; } diff --git a/renderer/app.js b/renderer/app.js index 5bf7a7e..de18db0 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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 `
+ const tappable = drillKey && count > 0; + const chevron = tappable ? ' ' : ''; + return `
${escapeHtml(title)}${detail ? `${escapeHtml(detail)}` : ''} - ${count} + ${count}${chevron}
`; } +// 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 + ? `
${items.map((it) => + `
${escapeHtml(it.primary)}${ + it.secondary ? `${escapeHtml(it.secondary)}` : ''}
`).join('')}
` + : ''; + overlay.innerHTML = ` + `; + 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) {
`); } - // --- 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(`

Health

@@ -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}`, + }))); + } }); } diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..9851c5b --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,110 @@ +// Shared dashboard telemetry aggregation. Pure — no Electron, no HTTP — so +// both the Electron main process (api:dashboardStats) and the web server +// (/api/dashboardStats) compute identical numbers. Modeled on the PONGo +// iOS app's DashboardViewModel. + +const { extractOnuHealth } = require('./mcms-api'); + +function toNum(v) { + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v); + return null; +} + +// PONGo OpticalThreshold (dBm): Rx in-spec is -28..-10; Tx floor is 3. +const RX_LOW = -28, RX_HIGH = -10, TX_LOW = 3; + +/** + * @param {object} args + * @param {object[]} args.olts OLT-STATE docs (listAllOltStates). + * @param {object[]|null} [args.onuStates] ONU-STATE docs for the heavy + * optical/FEC scan, or null/undefined to skip it (the light default). + * @param {number|null} [args.controllerCount] + */ +function summarizeDashboard({ olts = [], onuStates = null, controllerCount = null } = {}) { + const oltCount = olts.length; + + // Registration map: onuId -> bucket (prefer a non-Registered bucket on + // duplicates — the interesting signal), matching api:listFleet. + const stateByOnu = new Map(); + for (const olt of olts) { + const buckets = olt?.['ONU States'] || {}; + for (const [bucket, ids] of Object.entries(buckets)) { + if (!Array.isArray(ids)) continue; + for (const id of ids) { + const existing = stateByOnu.get(id); + if (!existing || existing === 'Registered') stateByOnu.set(id, bucket); + } + } + } + const onuTotal = stateByOnu.size; + const stateCounts = {}; + for (const s of stateByOnu.values()) stateCounts[s] = (stateCounts[s] || 0) + 1; + const onuCounts = Object.entries(stateCounts) + .map(([state, count]) => ({ state, count })) + .sort((a, b) => b.count - a.count); + + // Aggregate traffic + laser state from OLT-STATE. + // OLT TX BW = downstream (OLT→ONU), RX BW = upstream (ONU→OLT). + let downBps = 0, upBps = 0, trafficSeen = false; + const lasersOff = []; + for (const olt of olts) { + const pon = olt?.STATS?.['OLT-PON'] || {}; + const tx = toNum(pon['TX BW Ethernet Rate bps']); + const rx = toNum(pon['RX BW Ethernet Rate bps']); + if (tx !== null) { downBps += tx; trafficSeen = true; } + if (rx !== null) { upBps += rx; trafficSeen = true; } + const laser = olt?.OLT?.['Laser Shutdown']; + if (typeof laser === 'string' && laser.toLowerCase() !== 'laser on') { + lasersOff.push({ oltId: olt._id, name: olt?.OLT?.Name || '', laser }); + } + } + + // Opt-in heavy scan: every ONU-STATE for optical + FEC health. Keeps the + // actual offender lists so the dashboard can drill down without re-fetch. + let optical = { + available: false, + abnormalRx: [], abnormalRxCount: 0, + abnormalTx: [], abnormalTxCount: 0, + }; + let fecCounts = null; + if (Array.isArray(onuStates)) { + const abnormalRx = [], abnormalTx = []; + let available = false; + fecCounts = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 }; + for (const st of onuStates) { + const health = extractOnuHealth(st); + if (fecCounts[health.flag] !== undefined) fecCounts[health.flag] += 1; + const o = health.optical || {}; + const rx = typeof o.rx === 'number' ? o.rx : null; + const tx = typeof o.tx === 'number' ? o.tx : null; + const name = st?.ONU?.Name || ''; + if (rx !== null) { + available = true; + if (rx < RX_LOW || rx > RX_HIGH) abnormalRx.push({ onuId: st._id, rx, name }); + } + if (tx !== null) { + available = true; + if (tx < TX_LOW) abnormalTx.push({ onuId: st._id, tx, name }); + } + } + optical = { + available, + abnormalRx, abnormalRxCount: abnormalRx.length, + abnormalTx, abnormalTxCount: abnormalTx.length, + }; + } + + return { + onuTotal, oltCount, controllerCount, + onuCounts, + traffic: trafficSeen ? { downBps, upBps } : null, + lasersOff, + optical, + fecCounts, + extraStats: Array.isArray(onuStates), + sampledAt: new Date().toISOString(), + }; +} + +module.exports = { summarizeDashboard }; diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..183b8ef --- /dev/null +++ b/web/README.md @@ -0,0 +1,99 @@ +# PON Fleet — web server + +A multi-user, browser-based front end for the same tooling as the Electron +**PON Fleet Upgrader** (Dashboard / ONU firmware upgrades / OLT PON +flooding mode). Built to sit behind a TLS-terminating reverse proxy so +several operators can use it at once, **each with their own MCMS login**. + +## How it relates to the Electron app + +It does **not** duplicate the app — it reuses it: + +- **Core logic** comes straight from `../src/` (`mcms-api.js`, + `bank-strategy.js`, `dashboard.js`). One source of truth for every MCMS + quirk. +- **The UI is the same files.** The server serves `../renderer/app.js` and + `../renderer/app.css` as-is and rewrites `../renderer/index.html` on the + fly (adds a mobile viewport, swaps in the browser API shim + a small + responsive overlay). So the Electron app and the web app never drift. +- The only web-specific code is `server.js`, `public/api-web.js` (a + `window.api` shim: Electron IPC → `fetch` + NDJSON streaming, Electron + file dialogs → browser file pickers, CSV-to-Downloads → Blob download), + and `public/web.css` (responsive overlay). + +Because of the `../` references this folder **must** live inside +`pon-fleet-upgrader/` (it borrows the already-installed `tough-cookie` from +`../node_modules`). No separate `npm install` is needed. + +## Run + +```bash +cd pon-fleet-upgrader/web +npm start # node server.js — listens on 127.0.0.1:8080 +npm run check # node --check on server.js + api-web.js +``` + +Then browse to . Each browser logs in with its own +MCMS host + credentials; the server keeps a separate `McmsClient` (separate +cookie jar) per session. + +### Configuration (env) + +| Var | Default | Meaning | +|---|---|---| +| `PORT` | `8080` | listen port | +| `HOST` | `127.0.0.1` | bind address — keep on localhost behind a proxy | +| `PFW_IDLE_MINUTES` | `30` | idle-session expiry (logs the MCMS session out) | +| `PFW_SECURE_COOKIE` | off | force the `Secure` cookie flag (else inferred from `X-Forwarded-Proto: https`) | +| `PFW_VERBOSE` | off | verbose MCMS request logging (very noisy with many users) | + +## Reverse proxy + +Terminate TLS at the proxy and forward to `127.0.0.1:8080`. **Disable +response buffering** so the NDJSON progress streams (bulk delete, FEC +pre-flight, flood change, …) arrive live. + +Caddy: + +``` +mcms-tools.example.net { + reverse_proxy 127.0.0.1:8080 { + flush_interval -1 # don't buffer streamed responses + } +} +``` + +nginx: + +```nginx +location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; # stream NDJSON progress live + proxy_read_timeout 300s; # bulk ops can run a while +} +``` + +## Security notes + +- The server holds each logged-in user's **live MCMS session cookie in + memory**. Run it behind HTTPS, keep `HOST=127.0.0.1`, and rely on the + idle timeout. The session cookie is `HttpOnly`, `SameSite=Lax`, and + `Secure` behind TLS. +- "Accept self-signed TLS certificate" disables verification **to MCMS** + for that session only (same as the desktop app). +- There's no app-level user directory — anyone who can reach the page and + has valid MCMS credentials can log in. Restrict network access at the + proxy (mTLS / SSO / IP allow-list) if you need more than MCMS auth. +- Several operators running bulk operations at once multiply load on MCMS; + the page-size-100 / timeout mitigations from the main `CLAUDE.md` §5 + still apply per session. + +## What differs from the desktop app + +- **CSV files download through the browser** instead of auto-saving to + `~/Downloads` (a server can't write to each user's machine). Same + filenames, same BOM/CRLF/quoted format. +- Firmware `.bin` and plan-CSV selection use the browser file picker. +- Everything else — views, filters, FEC logic, flood-mode rules, the + dashboard and its drill-downs — is the shared renderer code. diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b6182f0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,12 @@ +{ + "name": "pon-fleet-web", + "version": "0.1.0", + "private": true, + "description": "Multi-user, reverse-proxy-friendly web server for the PON fleet tools. Reuses ../src core and ../renderer UI; per-session MCMS credentials.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "check": "node --check server.js && node --check public/api-web.js" + }, + "license": "UNLICENSED" +} diff --git a/web/public/api-web.js b/web/public/api-web.js new file mode 100644 index 0000000..2c77cfa --- /dev/null +++ b/web/public/api-web.js @@ -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 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'), + }; +})(); diff --git a/web/public/web.css b/web/public/web.css new file mode 100644 index 0000000..9a0cae3 --- /dev/null +++ b/web/public/web.css @@ -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; } +} diff --git a/web/server.js b/web/server.js new file mode 100644 index 0000000..bbb30db --- /dev/null +++ b/web/server.js @@ -0,0 +1,507 @@ +// PON Fleet web server — a multi-user, reverse-proxy-friendly front end for +// the same tools as the Electron app. It reuses the Electron app's core +// (../src/*) and serves the Electron app's renderer (../renderer/*) with an +// on-the-fly transformed index.html + a browser window.api shim. +// +// Pure Node `http` — the only runtime dependency is tough-cookie, pulled in +// transitively by ../src/mcms-api (already installed in ../node_modules). +// +// Each browser session gets its OWN McmsClient (its own cookie jar), keyed +// by an httpOnly session cookie, so multiple operators can use it at once on +// separate MCMS credentials. Bind to localhost and put TLS on the proxy. +// +// Env: +// PORT (default 8080), HOST (default 127.0.0.1) +// PFW_IDLE_MINUTES (default 30) — idle session expiry +// PFW_SECURE_COOKIE=1 — force the Secure cookie flag (else inferred from +// X-Forwarded-Proto: https) +// PFW_VERBOSE=1 — verbose MCMS request logging (off by default; noisy) + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const { + McmsClient, McmsApiError, extractOnuHealth, + summarizeOltNniNetworks, planFloodChange, +} = require('../src/mcms-api'); +const { planUpgrade } = require('../src/bank-strategy'); +const { summarizeDashboard } = require('../src/dashboard'); + +const PORT = Number(process.env.PORT) || 8080; +const HOST = process.env.HOST || '127.0.0.1'; +const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000; +const VERBOSE = process.env.PFW_VERBOSE === '1'; +const ROOT = __dirname; +const RENDERER = path.join(ROOT, '..', 'renderer'); +const PUBLIC = path.join(ROOT, 'public'); + +const SMALL_BODY = 8 * 1024 * 1024; // 8 MB for normal JSON requests +const UPLOAD_BODY = 256 * 1024 * 1024; // 256 MB for base64 firmware uploads + +// ---- Sessions -------------------------------------------------------- +// sid -> { client, baseUrl, user, lastSeen, sid } +const sessions = new Map(); + +function toWireError(err) { + if (err instanceof McmsApiError) return { message: err.message, status: err.status, body: err.body }; + return { message: err?.message || String(err) }; +} + +function parseCookies(req) { + const out = {}; + const raw = req.headers.cookie; + if (!raw) return out; + for (const part of raw.split(';')) { + const i = part.indexOf('='); + if (i < 0) continue; + out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); + } + return out; +} + +function isSecure(req) { + if (process.env.PFW_SECURE_COOKIE === '1') return true; + return String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https'; +} + +function setSidCookie(res, sid, req) { + const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax']; + if (isSecure(req)) flags.push('Secure'); + res.setHeader('Set-Cookie', `pfw_sid=${sid}; ${flags.join('; ')}`); +} +function clearSidCookie(res, req) { + const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; + if (isSecure(req)) flags.push('Secure'); + res.setHeader('Set-Cookie', `pfw_sid=; ${flags.join('; ')}`); +} + +function getSession(req) { + const sid = parseCookies(req).pfw_sid; + if (!sid) return null; + const s = sessions.get(sid); + if (!s) return null; + if (Date.now() - s.lastSeen > IDLE_MS) { disposeSession(sid); return null; } + return s; +} + +function disposeSession(sid) { + const s = sessions.get(sid); + if (!s) return; + sessions.delete(sid); + if (s.client) s.client.logout().catch(() => {}); +} + +// Idle sweep. +setInterval(() => { + const now = Date.now(); + for (const [sid, s] of sessions) if (now - s.lastSeen > IDLE_MS) disposeSession(sid); +}, 60 * 1000).unref(); + +// ---- HTTP helpers ---------------------------------------------------- + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(body); +} + +function readBody(req, limit) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > limit) { reject(new Error('request body too large')); req.destroy(); return; } + chunks.push(c); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { resolve(JSON.parse(raw)); } catch (e) { reject(new Error('invalid JSON body')); } + }); + req.on('error', reject); + }); +} + +// Bounded-concurrency worker pool over a queue, used by the streaming +// endpoints. `task(id)` resolves to a result entry; `onProgress(entry, done, +// total)` streams each as it lands. Returns the results array. +async function runPool(ids, concurrency, task, onProgress) { + const queue = [...ids]; + const results = []; + let done = 0; + const total = ids.length; + async function worker() { + for (;;) { + const id = queue.shift(); + if (id === undefined) return; + const entry = await task(id); + results.push(entry); + done += 1; + onProgress(entry, done, total); + } + } + const workers = Array.from({ length: Math.min(concurrency, total) || 1 }, () => worker()); + await Promise.all(workers); + return results; +} + +// ---- Static serving -------------------------------------------------- + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +function sendFile(res, filePath, contentType) { + fs.readFile(filePath, (err, buf) => { + if (err) { res.writeHead(404); res.end('not found'); return; } + res.writeHead(200, { 'Content-Type': contentType || MIME[path.extname(filePath)] || 'application/octet-stream' }); + res.end(buf); + }); +} + +// Serve the Electron renderer's index.html, rewired for the browser: +// - add a mobile viewport +// - load shared app.css from /shared/ plus the web overlay /web.css +// - load the window.api shim (/api-web.js) before the shared app.js +function serveIndex(res) { + let html; + try { html = fs.readFileSync(path.join(RENDERER, 'index.html'), 'utf8'); } + catch (e) { res.writeHead(500); res.end('renderer/index.html missing'); return; } + + if (!html.includes('name="viewport"')) { + html = html.replace(//i, + '\n '); + } + html = html.replace(//i, + '\n '); + html = html.replace(/\n '); + + res.writeHead(200, { 'Content-Type': MIME['.html'] }); + res.end(html); +} + +function serveStatic(pathname, res) { + if (pathname === '/' || pathname === '/index.html') return serveIndex(res); + if (pathname === '/shared/app.js') return sendFile(res, path.join(RENDERER, 'app.js'), MIME['.js']); + if (pathname === '/shared/app.css') return sendFile(res, path.join(RENDERER, 'app.css'), MIME['.css']); + if (pathname === '/api-web.js') return sendFile(res, path.join(PUBLIC, 'api-web.js'), MIME['.js']); + if (pathname === '/web.css') return sendFile(res, path.join(PUBLIC, 'web.css'), MIME['.css']); + if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } + res.writeHead(404); res.end('not found'); +} + +// ---- Non-streaming API handlers (session required) ------------------- +// Each returns the same { ok, data } / { ok:false, error } envelope the +// renderer already expects. + +const handlers = { + async listOnuConfigs(s, body) { + const data = await s.client.listOnuConfigs({ limit: body?.limit, skip: body?.skip }); + return { ok: true, data }; + }, + + async listFleet(s) { + const c = s.client; + const configs = await c.listAllOnuConfigs(); + let states = [], statesError = null; + try { states = await c.listAllOnuStates(); } catch (e) { statesError = toWireError(e); } + let oltStates = [], oltStatesError = null; + try { oltStates = await c.listAllOltStates(); } catch (e) { oltStatesError = toWireError(e); } + + const statusByOnu = new Map(); + for (const olt of oltStates) { + const buckets = olt?.['ONU States'] || {}; + const oltMac = olt?._id; + for (const [bucket, ids] of Object.entries(buckets)) { + if (!Array.isArray(ids)) continue; + for (const id of ids) { + const existing = statusByOnu.get(id); + if (!existing || existing.status === 'Registered') statusByOnu.set(id, { status: bucket, oltMac }); + } + } + } + const stateById = new Map(states.map((st) => [st._id, st])); + const merged = configs.map((cfg) => ({ + ...cfg, + _state: stateById.get(cfg._id) || null, + _status: statusByOnu.get(cfg._id) || null, + })); + return { + ok: true, data: merged, + meta: { + configCount: configs.length, stateCount: states.length, + oltStateCount: oltStates.length, statesError, oltStatesError, + }, + }; + }, + + async getOnuConfig(s, body) { + return { ok: true, data: await s.client.getOnuConfig(body.onuId) }; + }, + + async listOnuFirmware(s) { + return { ok: true, data: await s.client.listOnuFirmware() }; + }, + + // Browser sent the .bin as base64 (no server-side file dialog). + async uploadFirmware(s, body) { + const { filename, base64 } = body || {}; + if (!filename || !base64) throw new Error('filename and base64 are required'); + const versionMatch = String(filename).match(/([A-Z]{2}\d{5}[A-Z]?)/); + const metadata = { + 'Compatible Manufacturer': 'TIBITCOM', + 'Compatible Model': ['MicroPlug ONU'], + Version: versionMatch ? versionMatch[1] : '', + }; + const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata); + return { ok: true, data: { filename, metadata, body: bodyResp } }; + }, + + async planUpgrade(s, body) { + const { onuIds, targetFile, targetVersion } = body; + const plans = []; + for (const onuId of onuIds) { + const cfg = await s.client.getOnuConfig(onuId); + if (!cfg) { plans.push({ onuId, error: 'ONU config not found' }); continue; } + const plan = planUpgrade(cfg, { targetFile, targetVersion }); + plans.push({ + onuId, name: cfg?.ONU?.Name || '', address: cfg?.ONU?.Address || '', + ponMode: cfg?.ONU?.['PON Mode'] || '', ...plan.summary, writeSlot: plan.writeSlot, + }); + } + return { ok: true, data: plans }; + }, + + async executeBulkTask(s, body) { + return { ok: true, data: await s.client.createFirmwareTask(body) }; + }, + + async getTaskConfig(s, body) { + return { ok: true, data: await s.client.getTaskConfig(body.taskId) }; + }, + + async getUpgradeStatus(s, body) { + return { ok: true, data: await s.client.getOnuUpgradeStatus(body.onuId) }; + }, + + async dashboardStats(s, body) { + const c = s.client; + const extraStats = !!(body && body.extraStats); + const olts = await c.listAllOltStates(); + const onuStates = extraStats ? await c.listAllOnuStates() : null; + let controllerCount = null; + try { controllerCount = (await c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ } + return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) }; + }, + + async listOlts(s, body) { + const c = s.client; + const tagPattern = (body && body.tagPattern) || ''; + const cfgs = await c.listAllOltConfigs(); + const rows = cfgs.map((cfg) => { + const summary = summarizeOltNniNetworks(cfg, tagPattern); + const counts = { private: 0, auto: 0, unknown: 0 }; + for (const x of summary) counts[x.mode] = (counts[x.mode] || 0) + 1; + return { + _id: cfg._id, name: cfg?.OLT?.Name || '', location: cfg?.OLT?.Location || '', + ponMode: cfg?.OLT?.['PON Mode'] || '', + activeFwVersion: cfg?.OLT?.['FW Bank Versions']?.[cfg?.OLT?.['FW Bank Ptr']] || '', + matchingNni: summary, counts, + }; + }); + return { ok: true, data: rows }; + }, +}; + +// ---- Streaming API handlers (NDJSON: progress lines + final) --------- +// `write(obj)` emits one progress line; the dispatcher appends the final +// result line. Returns the results array. + +const streamHandlers = { + async fetchStates(s, body, write) { + const { onuIds, concurrency = 20 } = body; + return runPool(onuIds, concurrency, async (id) => { + let st = null; + try { st = await s.client.getOnuState(id); } catch (_) { st = null; } + return { onuId: id, state: st }; + }, (entry, done, total) => write({ onuId: entry.onuId, state: entry.state, done, total })); + }, + + async executePerOnu(s, body, write) { + const { onuIds, targetFile, targetVersion } = body; + const results = []; + for (let i = 0; i < onuIds.length; i++) { + const onuId = onuIds[i]; + write({ index: i, total: onuIds.length, onuId, phase: 'fetching' }); + try { + const cfg = await s.client.getOnuConfig(onuId); + if (!cfg) throw new Error('ONU config not found'); + const plan = planUpgrade(cfg, { targetFile, targetVersion }); + const mutated = { ...cfg, ONU: { ...cfg.ONU, ...plan.fwFields } }; + write({ index: i, total: onuIds.length, onuId, phase: 'writing', writeSlot: plan.writeSlot }); + await s.client.putOnuConfig(onuId, mutated); + results.push({ onuId, ok: true, writeSlot: plan.writeSlot }); + } catch (err) { + results.push({ onuId, ok: false, error: toWireError(err) }); + } + } + return results; + }, + + async fetchFecHealth(s, body, write) { + const { onuIds, concurrency = 8 } = body; + return runPool(onuIds, concurrency, async (id) => { + try { + const stateDoc = await s.client.getOnuState(id); + const health = extractOnuHealth(stateDoc); + return { + onuId: id, flag: health.flag, detail: health.detail || '', + counters: health.counters || [], optical: health.optical || {}, + sampleTime: stateDoc?.Time || '', + }; + } catch (e) { + return { onuId: id, flag: 'error', error: toWireError(e) }; + } + }, (entry, done, total) => write({ ...entry, done, total })); + }, + + async fetchOnuSnapshot(s, body, write) { + const { onuIds, concurrency = 8 } = body; + return runPool(onuIds, concurrency, async (id) => { + try { + const [stateDoc, cfgDoc] = await Promise.all([ + s.client.getOnuState(id).catch(() => null), + s.client.getOnuConfig(id).catch(() => null), + ]); + const health = extractOnuHealth(stateDoc); + return { + onuId: id, flag: health.flag, counters: health.counters || [], + optical: health.optical || {}, sampleTime: stateDoc?.Time || '', cfg: cfgDoc, + }; + } catch (e) { + return { onuId: id, flag: 'error', error: toWireError(e) }; + } + }, (entry, done, total) => write({ onuId: entry.onuId, done, total, flag: entry.flag })); + }, + + async deleteOnus(s, body, write) { + const { onuIds, concurrency = 5 } = body; + return runPool(onuIds, concurrency, async (id) => { + let ok = false, error = null; + try { + await s.client.deleteOnuConfig(id); + ok = true; + try { await s.client.deleteOnuState(id); } catch (_) { /* best-effort */ } + } catch (e) { error = toWireError(e); } + return { onuId: id, ok, error }; + }, (entry, done, total) => write({ onuId: entry.onuId, ok: entry.ok, error: entry.error, done, total })); + }, + + async executeFloodChange(s, body, write) { + const { + oltIds, tagPattern, fromMode = 'private', targetMode = 'auto', + ponFloodIdValue = 0, concurrency = 3, + } = body; + return runPool(oltIds, concurrency, async (id) => { + try { + const cfg = await s.client.getOltConfig(id); + if (!cfg) throw new Error('OLT not found'); + const plan = planFloodChange(cfg, { tagPattern, fromMode, targetMode, ponFloodIdValue }); + if (plan.changes.length === 0) return { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; + await s.client.putOltConfig(id, plan.newDoc); + return { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged }; + } catch (e) { + return { oltId: id, ok: false, error: toWireError(e) }; + } + }, (entry, done, total) => write({ ...entry, done, total })); + }, +}; + +// ---- Request router -------------------------------------------------- + +const server = http.createServer(async (req, res) => { + try { + const pathname = new URL(req.url, 'http://localhost').pathname; + + if (req.method === 'GET') return serveStatic(pathname, res); + if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); + if (!pathname.startsWith('/api/')) return sendJson(res, 404, { ok: false, error: { message: 'not found' } }); + const name = pathname.slice(5).replace(/\/$/, ''); + + // Login establishes a session + its own McmsClient. + if (name === 'login') { + const body = await readBody(req, SMALL_BODY); + const { baseUrl, username, password, acceptSelfSigned } = body || {}; + const client = new McmsClient({ baseUrl, rejectUnauthorized: !acceptSelfSigned, verbose: VERBOSE }); + try { + const result = await client.login(username, password); + const sid = crypto.randomUUID(); + sessions.set(sid, { client, baseUrl, user: username, lastSeen: Date.now(), sid }); + setSidCookie(res, sid, req); + return sendJson(res, 200, { ok: true, data: result }); + } catch (err) { + return sendJson(res, 200, { ok: false, error: toWireError(err) }); + } + } + + // Everything else requires a live session. + const session = getSession(req); + if (!session) return sendJson(res, 401, { ok: false, error: { message: 'Not logged in — session expired or missing.' } }); + session.lastSeen = Date.now(); + + if (name === 'logout') { + try { await session.client.logout(); } catch (_) { /* best-effort */ } + sessions.delete(session.sid); + clearSidCookie(res, req); + return sendJson(res, 200, { ok: true }); + } + + if (handlers[name]) { + const limit = name === 'uploadFirmware' ? UPLOAD_BODY : SMALL_BODY; + const body = await readBody(req, limit); + try { + return sendJson(res, 200, await handlers[name](session, body)); + } catch (err) { + return sendJson(res, 200, { ok: false, error: toWireError(err) }); + } + } + + if (streamHandlers[name]) { + const body = await readBody(req, SMALL_BODY); + res.writeHead(200, { + 'Content-Type': 'application/x-ndjson; charset=utf-8', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', // ask nginx not to buffer the stream + }); + const write = (obj) => { try { res.write(JSON.stringify(obj) + '\n'); } catch (_) { /* client gone */ } }; + try { + const data = await streamHandlers[name](session, body, write); + write({ __final: true, ok: true, data }); + } catch (err) { + write({ __final: true, ok: false, error: toWireError(err) }); + } + return res.end(); + } + + return sendJson(res, 404, { ok: false, error: { message: 'unknown endpoint: ' + name } }); + } catch (err) { + if (!res.headersSent) sendJson(res, 500, { ok: false, error: { message: err?.message || String(err) } }); + else try { res.end(); } catch (_) { /* ignore */ } + } +}); + +server.listen(PORT, HOST, () => { + console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`); + console.log(`[pon-fleet-web] idle session timeout: ${IDLE_MS / 60000} min · verbose MCMS: ${VERBOSE}`); + console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx) in front for remote use.'); +});