web: shared MCMS cache + systemd/clone-to-run deploy + NPM docs

Caching (reduce MCMS API load with multiple operators):
- web/cache.js: in-memory TTL cache with single-flight, keyed by MCMS host.
  Concurrent identical bulk reads collapse into ONE upstream fetch; everyone
  on the same MCMS shares the snapshot.
- Wired into the bulk reads only (ONU/OLT config+state lists, controllers,
  firmware). Per-ONU read-modify-write and the FEC pre-flight stay live.
- Writes (delete / per-ONU + bulk upgrade / flood change / firmware upload)
  invalidate the affected datasets for that host, so changes show on the
  next load instead of waiting out the TTL.
- PFW_CACHE_TTL_SECONDS (default 60, 0 disables). Authenticated
  _cacheStats / _cacheClear endpoints for ops.

Deploy (Debian 13 LXC, clone-to-run) in web/deploy/:
- pon-fleet-web.service (runs as unprivileged ponfw, hardened, repo
  read-only, no disk writes), pon-fleet-web.env.example, update.sh
  (git pull + npm install --omit=dev + restart; uses install not ci since
  package-lock.json is gitignored).
- README: full LXC setup, Nginx Proxy Manager proxy-host + access-list
  notes. Streams already send X-Accel-Buffering: no so NPM/nginx don't
  buffer the NDJSON progress.

Verified: node --check; cache unit-tested (single-flight, TTL hit/expiry,
fresh, invalidate, key isolation, disable — 9/9); server boots and reports
the cache TTL; _cacheStats gated to logged-in sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 14:21:04 +02:00
parent 96b0264179
commit 6ee1d37a79
7 changed files with 309 additions and 35 deletions

View file

@ -28,11 +28,14 @@ const {
} = require('../src/mcms-api');
const { planUpgrade } = require('../src/bank-strategy');
const { summarizeDashboard } = require('../src/dashboard');
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');
@ -40,6 +43,21 @@ 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();
@ -210,13 +228,14 @@ const handlers = {
return { ok: true, data };
},
async listFleet(s) {
async listFleet(s, body) {
const c = s.client;
const configs = await c.listAllOnuConfigs();
const fresh = !!(body && body.fresh);
const configs = await cachedBulk(s, 'onuConfigs', () => c.listAllOnuConfigs(), fresh);
let states = [], statesError = null;
try { states = await c.listAllOnuStates(); } catch (e) { statesError = toWireError(e); }
try { states = await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh); } catch (e) { statesError = toWireError(e); }
let oltStates = [], oltStatesError = null;
try { oltStates = await c.listAllOltStates(); } catch (e) { oltStatesError = toWireError(e); }
try { oltStates = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh); } catch (e) { oltStatesError = toWireError(e); }
const statusByOnu = new Map();
for (const olt of oltStates) {
@ -249,8 +268,9 @@ const handlers = {
return { ok: true, data: await s.client.getOnuConfig(body.onuId) };
},
async listOnuFirmware(s) {
return { ok: true, data: await s.client.listOnuFirmware() };
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).
@ -264,6 +284,7 @@ const handlers = {
Version: versionMatch ? versionMatch[1] : '',
};
const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata);
invalidate(s, ['firmware']);
return { ok: true, data: { filename, metadata, body: bodyResp } };
},
@ -283,9 +304,15 @@ const handlers = {
},
async executeBulkTask(s, body) {
return { ok: true, data: await s.client.createFirmwareTask(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) };
},
@ -297,17 +324,21 @@ const handlers = {
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;
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 c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ }
try { controllerCount = (await cachedBulk(s, 'controllerConfigs', () => c.listAllControllerConfigs(), fresh)).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 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 };
@ -355,6 +386,7 @@ const streamHandlers = {
results.push({ onuId, ok: false, error: toWireError(err) });
}
}
invalidate(s, ['onuConfigs', 'onuStates']);
return results;
},
@ -396,7 +428,7 @@ const streamHandlers = {
async deleteOnus(s, body, write) {
const { onuIds, concurrency = 5 } = body;
return runPool(onuIds, concurrency, async (id) => {
const results = await runPool(onuIds, concurrency, async (id) => {
let ok = false, error = null;
try {
await s.client.deleteOnuConfig(id);
@ -405,6 +437,8 @@ const streamHandlers = {
} 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) {
@ -412,7 +446,7 @@ const streamHandlers = {
oltIds, tagPattern, fromMode = 'private', targetMode = 'auto',
ponFloodIdValue = 0, concurrency = 3,
} = body;
return runPool(oltIds, concurrency, async (id) => {
const results = await runPool(oltIds, concurrency, async (id) => {
try {
const cfg = await s.client.getOltConfig(id);
if (!cfg) throw new Error('OLT not found');
@ -424,6 +458,8 @@ const streamHandlers = {
return { oltId: id, ok: false, error: toWireError(e) };
}
}, (entry, done, total) => write({ ...entry, done, total }));
invalidate(s, ['oltConfigs']);
return results;
},
};
@ -503,5 +539,6 @@ const server = http.createServer(async (req, res) => {
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.');
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.');
});