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('')}
`
+ : '
Nothing to show — all clear.
';
+ overlay.innerHTML = `
+
+
${escapeHtml(title)} (${items.length})
+ ${list}
+
Close
+
`;
+ 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.');
+});