Add multi-user web server variant (browser UI behind a reverse proxy)
New web/ subfolder: a Node http server that serves the SAME tooling as the Electron app to any browser, with per-session MCMS credentials so several operators can use it at once behind a TLS reverse proxy. Mobile-responsive. Not a fork — it reuses the core and the UI: - serves renderer/app.js + app.css verbatim and rewrites index.html on the fly (mobile viewport + browser window.api shim + responsive overlay) - web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming; Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob - web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry, routes mirroring the IPC handlers, NDJSON for the 6 streaming ops - pure Node http, no new deps (borrows ../node_modules tough-cookie) Shared improvements (benefit both apps): - src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses it); adds abnormal-Tx detection alongside abnormal-Rx - renderer: dashboard health stats are now clickable — abnormal Rx / Tx / lasers-off pop a list of the offending devices (like the iOS app), via a new showListModal Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16. Verified: node --check all; started the server and confirmed transformed index, shared-asset serving, 401 auth gate, login (per-session client), and NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9106e5a071
commit
96b0264179
10 changed files with 1096 additions and 109 deletions
99
web/README.md
Normal file
99
web/README.md
Normal file
|
|
@ -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 <http://127.0.0.1:8080>. 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.
|
||||
12
web/package.json
Normal file
12
web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
190
web/public/api-web.js
Normal file
190
web/public/api-web.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// Browser drop-in for the Electron preload's `window.api`. Same method
|
||||
// signatures and the same streaming-callback contract (onXProgress + an
|
||||
// awaitable trigger), implemented over fetch + NDJSON streaming. This lets
|
||||
// the shared renderer/app.js run unchanged in any browser.
|
||||
//
|
||||
// Differences absorbed here so the renderer doesn't have to care:
|
||||
// - Electron dialogs -> hidden <input type=file> pickers
|
||||
// - CSV "save to ~/Downloads" -> client-built Blob download
|
||||
(function () {
|
||||
const listeners = { fec: [], verify: [], upgrade: [], states: [], delete: [], 'olt-flood': [] };
|
||||
|
||||
async function postJSON(pathName, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/' + pathName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: { message: 'network error: ' + e.message } };
|
||||
}
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) { /* non-JSON */ }
|
||||
if (data && typeof data === 'object' && 'ok' in data) return data;
|
||||
return { ok: res.ok, error: (data && data.error) || { message: 'HTTP ' + res.status } };
|
||||
}
|
||||
|
||||
// Streaming POST. The server replies NDJSON: progress objects, then a
|
||||
// single {__final:true, ok, data|error} line. Progress objects are routed
|
||||
// to the registered listeners for `channel` (mirrors ipcRenderer events).
|
||||
async function streamJSON(pathName, body, channel) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/' + pathName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: { message: 'network error: ' + e.message } };
|
||||
}
|
||||
if (res.status === 401) return { ok: false, error: { message: 'Not logged in.' } };
|
||||
if (!res.body || !res.body.getReader) {
|
||||
// No streaming support — fall back to a plain JSON read.
|
||||
const data = await res.json().catch(() => null);
|
||||
return data || { ok: false, error: { message: 'no response body' } };
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let final = null;
|
||||
|
||||
const handleLine = (line) => {
|
||||
const t = line.trim();
|
||||
if (!t) return;
|
||||
let obj;
|
||||
try { obj = JSON.parse(t); } catch (_) { return; }
|
||||
if (obj && obj.__final) { final = obj; return; }
|
||||
if (channel && listeners[channel]) {
|
||||
for (const h of listeners[channel].slice()) { try { h(obj); } catch (_) { /* ignore */ } }
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
handleLine(buf.slice(0, nl));
|
||||
buf = buf.slice(nl + 1);
|
||||
}
|
||||
}
|
||||
if (buf) handleLine(buf);
|
||||
|
||||
if (final) return { ok: final.ok, data: final.data, error: final.error };
|
||||
return { ok: false, error: { message: 'stream ended without a result' } };
|
||||
}
|
||||
|
||||
function sub(channel) {
|
||||
return (handler) => {
|
||||
listeners[channel].push(handler);
|
||||
return () => {
|
||||
const i = listeners[channel].indexOf(handler);
|
||||
if (i >= 0) listeners[channel].splice(i, 1);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Browser file pickers (replace Electron dialog.*) ----
|
||||
function pickFile(accept) {
|
||||
return new Promise((resolve) => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
if (accept) inp.accept = accept;
|
||||
inp.style.position = 'fixed';
|
||||
inp.style.left = '-9999px';
|
||||
document.body.appendChild(inp);
|
||||
let settled = false;
|
||||
const finish = (file) => { if (settled) return; settled = true; resolve(file); inp.remove(); };
|
||||
inp.addEventListener('change', () => finish(inp.files[0] || null));
|
||||
// Cancel has no reliable event; when the window regains focus and no
|
||||
// file was chosen shortly after, treat it as a cancel.
|
||||
window.addEventListener('focus', () => {
|
||||
setTimeout(() => { if (!settled && (!inp.files || !inp.files.length)) finish(null); }, 400);
|
||||
}, { once: true });
|
||||
inp.click();
|
||||
});
|
||||
}
|
||||
const readText = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = no; r.readAsText(file); });
|
||||
const readBase64 = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = no; r.readAsDataURL(file); });
|
||||
|
||||
// ---- CSV -> browser download (a server can't write to the user's disk) ----
|
||||
function buildCsv(headers, rows) {
|
||||
const cell = (v) => '"' + (v === null || v === undefined ? '' : String(v)).replace(/"/g, '""') + '"';
|
||||
const line = (cells) => cells.map(cell).join(',');
|
||||
const lines = [line(headers)];
|
||||
for (const row of rows) lines.push(line(headers.map((h) => row[h])));
|
||||
return '' + lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
function downloadCsv(prefix, hint, headers, rows) {
|
||||
const ts = new Date();
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
const stamp = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`;
|
||||
const safeHint = String(hint || 'csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60);
|
||||
const safePrefix = String(prefix || 'pon-csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40);
|
||||
const filename = `${safePrefix}-${safeHint}-${stamp}.csv`;
|
||||
const blob = new Blob([buildCsv(headers, rows)], { type: 'text/csv;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
|
||||
return { ok: true, data: { path: filename, filename, rowCount: rows.length } };
|
||||
}
|
||||
|
||||
window.api = {
|
||||
login: (o) => postJSON('login', o),
|
||||
logout: () => postJSON('logout', {}),
|
||||
|
||||
listOnuConfigs: (o) => postJSON('listOnuConfigs', o),
|
||||
listFleet: (o) => postJSON('listFleet', o),
|
||||
fetchStates: (o) => streamJSON('fetchStates', o, 'states'),
|
||||
getOnuConfig: (o) => postJSON('getOnuConfig', o),
|
||||
listOnuFirmware: () => postJSON('listOnuFirmware', {}),
|
||||
uploadFirmware: async () => {
|
||||
const file = await pickFile('.bin');
|
||||
if (!file) return { ok: false, error: { message: 'cancelled' } };
|
||||
const base64 = await readBase64(file);
|
||||
return postJSON('uploadFirmware', { filename: file.name, base64 });
|
||||
},
|
||||
|
||||
planUpgrade: (o) => postJSON('planUpgrade', o),
|
||||
executePerOnu: (o) => streamJSON('executePerOnu', o, 'upgrade'),
|
||||
executeBulkTask: (o) => postJSON('executeBulkTask', o),
|
||||
getTaskConfig: (o) => postJSON('getTaskConfig', o),
|
||||
getUpgradeStatus: (o) => postJSON('getUpgradeStatus', o),
|
||||
|
||||
deleteOnus: (o) => streamJSON('deleteOnus', o, 'delete'),
|
||||
|
||||
dashboardStats: (o) => postJSON('dashboardStats', o),
|
||||
|
||||
listOlts: (o) => postJSON('listOlts', o),
|
||||
executeFloodChange: (o) => streamJSON('executeFloodChange', o, 'olt-flood'),
|
||||
|
||||
fetchFecHealth: (o) => streamJSON('fetchFecHealth', o, 'fec'),
|
||||
fetchOnuSnapshot: (o) => streamJSON('fetchOnuSnapshot', o, 'verify'),
|
||||
|
||||
// CSV saves become browser downloads (path field kept for UI messages).
|
||||
savePlanCsv: async ({ rows, filenameHint, headers }) => downloadCsv('pon-upgrade', filenameHint, headers, rows),
|
||||
saveCsvFile: async ({ rows, filenameHint, headers, prefix }) => downloadCsv(prefix || 'pon-csv', filenameHint, headers, rows),
|
||||
openCsvFile: async () => {
|
||||
const file = await pickFile('.csv');
|
||||
if (!file) return { ok: false, error: { message: 'cancelled' } };
|
||||
return { ok: true, data: { path: file.name, text: await readText(file) } };
|
||||
},
|
||||
|
||||
onFecProgress: sub('fec'),
|
||||
onVerifyProgress: sub('verify'),
|
||||
onUpgradeProgress: sub('upgrade'),
|
||||
onStatesProgress: sub('states'),
|
||||
onDeleteProgress: sub('delete'),
|
||||
onFloodProgress: sub('olt-flood'),
|
||||
};
|
||||
})();
|
||||
53
web/public/web.css
Normal file
53
web/public/web.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* Web-only overlay, loaded AFTER the shared renderer app.css. Two jobs:
|
||||
(1) let the page scroll normally in a browser tab (the Electron layout
|
||||
pins everything to 100vh), and (2) make it usable on phones/tablets. */
|
||||
|
||||
/* Tap targets a bit comfier for touch without changing the desktop look. */
|
||||
button, .tab, .dash-row.tappable, .list-modal-item { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
/* ----- Tablet / phone ----- */
|
||||
@media (max-width: 860px) {
|
||||
html, body { height: auto; min-height: 100%; }
|
||||
|
||||
/* Topbar wraps; tabs drop to their own full-width row and scroll. */
|
||||
.topbar { flex-wrap: wrap; gap: 8px 12px; padding: 10px 12px; }
|
||||
.topbar .session { order: 2; font-size: 11px; }
|
||||
#btn-logout { order: 2; }
|
||||
.tabs {
|
||||
order: 3;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tabs .tab { flex: 1 0 auto; }
|
||||
|
||||
/* Stack the two-pane layout vertically and let the document scroll. */
|
||||
.view { flex-direction: column; height: auto; min-height: calc(100vh - 52px); }
|
||||
.panel-left {
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow: visible;
|
||||
}
|
||||
.panel-main { overflow: visible; }
|
||||
|
||||
/* Tables: keep columns readable by scrolling sideways, not squishing. */
|
||||
.fleet-table-wrap { max-height: 70vh; }
|
||||
.fleet-table { min-width: 720px; }
|
||||
|
||||
/* Dashboard tiles go single-column; modals near full-width. */
|
||||
.dash-grid { grid-template-columns: 1fr; }
|
||||
.dash-scroll { overflow: visible; }
|
||||
.dash-traffic { gap: 24px; }
|
||||
.modal, .list-modal { width: 94vw; }
|
||||
#view-login { padding-top: 24px; }
|
||||
.card { width: min(440px, 92vw); }
|
||||
}
|
||||
|
||||
/* ----- Small phones ----- */
|
||||
@media (max-width: 520px) {
|
||||
.tile .tile-value { font-size: 28px; }
|
||||
.dash-traffic .t-val { font-size: 22px; }
|
||||
.topbar .brand { font-size: 13px; letter-spacing: 1px; }
|
||||
}
|
||||
507
web/server.js
Normal file
507
web/server.js
Normal file
|
|
@ -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(/<meta charset="UTF-8"\s*\/?>/i,
|
||||
'<meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />');
|
||||
}
|
||||
html = html.replace(/<link rel="stylesheet" href="app\.css"\s*\/?>/i,
|
||||
'<link rel="stylesheet" href="/shared/app.css" />\n <link rel="stylesheet" href="/web.css" />');
|
||||
html = html.replace(/<script src="app\.js"><\/script>/i,
|
||||
'<script src="/api-web.js"></script>\n <script src="/shared/app.js"></script>');
|
||||
|
||||
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.');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue