ponfw/web/server.js
Jon Vanvik 2046fad0f8 onu detail polish + clickable OLT detail modal
- Fix alarm card overflow: alarm rows stack (severity+text on one line that
  wraps, meta below) instead of squeezing the text to ~0 width next to the
  nowrap meta — which had made long alarm text render one char per line in
  narrow columns. Also min-width:0 on cards/kv so long IPv6/descr strings
  don't blow out the grid track.
- Combine Parent OLT + Upstream switch into one "Parent OLT & uplink" card.
- ONU list/search de-compacted: add equipment/version + PON-mode filters;
  two-line rows (serial+status, then equipment · version · last-seen).
- UNI card labels the speed as configured ("cfg Auto/Auto"); negotiated
  speed isn't in the payloads — link up/down still comes from LAN-LOS.

Clickable parent OLT -> OLT detail modal (showOltModal):
- new src/oltdetail.js (pure, shared; reuses summarizeAlarms) + getOltState
  / getOltAlarms client methods + api:getOltDetail (main + web + bridges).
- shows identity, traffic + util, ASIC temperature, ONU counts, laser,
  alarms, upstream switch; plus a connected-ONU list with per-ONU
  FEC/optical built from the loaded fleet (filtered by _status.oltMac),
  each row clickable back into that ONU's detail.

Docs: CLAUDE.md §17 + IPC table.

Verified: node --check; oltdetail.js unit-tested 9/9 (fw active-bank,
traffic down=TX/up=RX, util, ASIC temp, ONU counts, laser, LLDP, alarms);
rendered the OLT modal + richer list offscreen and confirmed the alarm-text
wrap fix (alarm head ~1-2 lines, not vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:10:34 +02:00

594 lines
24 KiB
JavaScript

// 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, planNniEdit,
} = require('../src/mcms-api');
const { planUpgrade } = require('../src/bank-strategy');
const { summarizeDashboard } = require('../src/dashboard');
const { summarizeCpe } = require('../src/cpe');
const { lookupOui } = require('../src/oui');
const { summarizeOnuDetail } = require('../src/onudetail');
const { summarizeOltDetail } = require('../src/oltdetail');
const { TtlCache } = require('./cache');
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 CACHE_TTL_MS = (process.env.PFW_CACHE_TTL_SECONDS !== undefined
? Number(process.env.PFW_CACHE_TTL_SECONDS) : 60) * 1000;
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
// ---- Shared upstream cache ------------------------------------------
// Collapses the heavy MCMS bulk reads across all sessions on the same MCMS
// host. Per-ONU read-modify-write fetches and the FEC pre-flight stay live
// (uncached). Writes invalidate the affected datasets.
const cache = new TtlCache(CACHE_TTL_MS);
setInterval(() => cache.sweep(Math.max(CACHE_TTL_MS * 5, 10 * 60 * 1000)), 5 * 60 * 1000).unref();
const ckey = (session, name) => `${session.baseUrl}|${name}`;
function cachedBulk(session, name, producer, fresh) {
return cache.get(ckey(session, name), producer, { fresh });
}
function invalidate(session, names) {
for (const n of names) cache.invalidate(ckey(session, n));
}
// ---- 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, body) {
const c = s.client;
const fresh = !!(body && body.fresh);
const configs = await cachedBulk(s, 'onuConfigs', () => c.listAllOnuConfigs(), fresh);
let states = [], statesError = null;
try { states = await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh); } catch (e) { statesError = toWireError(e); }
let oltStates = [], oltStatesError = null;
try { oltStates = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh); } 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 getOnuCpe(s, body) {
const doc = await s.client.getCpe(body.onuId);
const cpe = summarizeCpe(doc);
if (cpe) cpe.cpeVendor = await lookupOui(cpe.cpeMac);
return { ok: true, data: cpe };
},
async getOnuDetail(s, body) {
const c = s.client;
const onuId = body.onuId;
const windowMin = body.windowMin || 60;
const [cfg, alarmsDoc, statsSeries] = await Promise.all([
c.getOnuConfig(onuId).catch(() => null),
c.getOnuAlarms(onuId).catch(() => null),
c.getOnuStatsSeries(onuId, { minutes: windowMin }).catch(() => []),
]);
const oltMac = alarmsDoc?.OLT?.['MAC Address'];
const oltCfg = oltMac ? await c.getOltConfig(oltMac).catch(() => null) : null;
const detail = summarizeOnuDetail({ cfg, alarmsDoc, statsSeries, oltCfg, windowMin });
const cpeDoc = await c.getCpe(onuId).catch(() => null);
detail.cpe = summarizeCpe(cpeDoc);
if (detail.cpe) detail.cpe.cpeVendor = await lookupOui(detail.cpe.cpeMac);
return { ok: true, data: detail };
},
async getOltDetail(s, body) {
const c = s.client;
const [oltCfg, oltState, alarmsDoc] = await Promise.all([
c.getOltConfig(body.oltMac).catch(() => null),
c.getOltState(body.oltMac).catch(() => null),
c.getOltAlarms(body.oltMac).catch(() => null),
]);
return { ok: true, data: summarizeOltDetail({ oltCfg, oltState, alarmsDoc }) };
},
async listOnuFirmware(s, body) {
const fresh = !!(body && body.fresh);
return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) };
},
// 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);
invalidate(s, ['firmware']);
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) {
const data = await s.client.createFirmwareTask(body);
invalidate(s, ['onuConfigs', 'onuStates']);
return { ok: true, data };
},
// Ops/debug: cache visibility + manual clear for the caller's MCMS host.
async _cacheStats() { return { ok: true, data: cache.stats() }; },
async _cacheClear(s) { return { ok: true, data: { cleared: cache.invalidate(`${s.baseUrl}|`) } }; },
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 fresh = !!(body && body.fresh);
const olts = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh);
const onuStates = extraStats
? await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh)
: null;
let controllerCount = null;
try { controllerCount = (await cachedBulk(s, 'controllerConfigs', () => c.listAllControllerConfigs(), fresh)).length; } catch (_) { /* best-effort */ }
const data = summarizeDashboard({ olts, onuStates, controllerCount });
// Tell the client how stale the cached inputs are. Use the OLDEST dataset
// feeding this view so the indicator never understates staleness.
const ages = [cache.ageOf(ckey(s, 'oltStates'))];
if (extraStats) ages.push(cache.ageOf(ckey(s, 'onuStates')));
const known = ages.filter((a) => a !== null);
data.cache = {
enabled: cache.enabled(),
ttlMs: CACHE_TTL_MS,
ageMs: known.length ? Math.max(...known) : 0,
};
return { ok: true, data };
},
async listOlts(s, body) {
const c = s.client;
const tagPattern = (body && body.tagPattern) || '';
const fresh = !!(body && body.fresh);
const cfgs = await cachedBulk(s, 'oltConfigs', () => c.listAllOltConfigs(), fresh);
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) });
}
}
invalidate(s, ['onuConfigs', 'onuStates']);
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;
const results = await 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 }));
invalidate(s, ['onuConfigs', 'onuStates', 'oltStates']);
return results;
},
async executeFloodChange(s, body, write) {
const {
oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null,
concurrency = 3,
} = body;
const results = await runPool(oltIds, concurrency, async (id) => {
try {
const cfg = await s.client.getOltConfig(id);
if (!cfg) throw new Error('OLT not found');
const plan = planNniEdit(cfg, { tagPattern, flood, dhcpv4, dhcpv6 });
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 }));
invalidate(s, ['oltConfigs']);
return results;
},
};
// ---- 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] upstream cache: ${cache.enabled() ? (CACHE_TTL_MS / 1000) + 's TTL (shared per MCMS host)' : 'disabled'}`);
console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx/NPM) in front for remote use.');
});