diff --git a/CLAUDE.md b/CLAUDE.md
index 5695be6..bb4250f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -796,9 +796,22 @@ Consequences for editing:
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`.
+- **Shared upstream cache** (`web/cache.js`). Heavy bulk *reads* (ONU/OLT
+ config+state lists, controllers, firmware) go through a TTL cache with
+ single-flight, keyed by MCMS host, so concurrent operators collapse into
+ one upstream fetch (`PFW_CACHE_TTL_SECONDS`, default 60). Per-ONU
+ read-modify-write fetches and the FEC pre-flight stay **uncached** (must be
+ live). Writes call `invalidate(session, [...])` for the affected datasets.
+ This cache is web-only; the single-user Electron app doesn't need it.
+- **Deploy** lives in `web/deploy/` (systemd unit + env template +
+ `update.sh`); Debian-13-LXC clone-to-run steps and the Nginx Proxy Manager
+ setup are in `web/README.md`. Note streams send `X-Accel-Buffering: no` so
+ nginx/NPM don't buffer the NDJSON progress.
The web app must stay a subdirectory of this repo — it `require`s `../src/*`
-and borrows `../node_modules` (`tough-cookie`); no separate install.
+and borrows `../node_modules` (`tough-cookie`); no separate install. Deploy
+uses `npm install --omit=dev` (not `npm ci` — `package-lock.json` is
+gitignored) which pulls `tough-cookie` and skips the Electron devDep.
---
diff --git a/web/README.md b/web/README.md
index 183b8ef..b4e205b 100644
--- a/web/README.md
+++ b/web/README.md
@@ -42,38 +42,96 @@ cookie jar) per session.
| Var | Default | Meaning |
|---|---|---|
| `PORT` | `8080` | listen port |
-| `HOST` | `127.0.0.1` | bind address — keep on localhost behind a proxy |
+| `HOST` | `127.0.0.1` | bind address — `0.0.0.0` if the proxy is on another host |
+| `PFW_CACHE_TTL_SECONDS` | `60` | shared upstream cache TTL; `0` disables (see Caching) |
| `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
+## Caching (fewer MCMS requests)
-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.
+The server keeps a small in-memory cache so multiple operators don't each
+hammer MCMS. The heavy bulk **reads** — ONU configs, ONU states, OLT states,
+OLT configs, controller configs, firmware inventory — are cached with a TTL
+(`PFW_CACHE_TTL_SECONDS`, default 60s) and **keyed by MCMS host**, so:
-Caddy:
+- Everyone pointed at the same MCMS shares one snapshot. Ten operators
+ loading the fleet within a minute trigger **one** upstream fetch, not ten.
+- A **single-flight** guard means even simultaneous cold loads collapse into
+ one upstream request (the rest await it).
+- Different MCMS hosts never share, and the dashboard's light path reuses the
+ same OLT-STATE snapshot the fleet view already fetched.
-```
-mcms-tools.example.net {
- reverse_proxy 127.0.0.1:8080 {
- flush_interval -1 # don't buffer streamed responses
- }
-}
+What is **not** cached (always live, by design):
+
+- Per-ONU `getOnuConfig` used right before a firmware PUT (read-modify-write
+ must see the current bank state).
+- The FEC pre-flight and verify per-ONU `getOnuState` (operators want the
+ reading at decision time).
+
+**Writes invalidate** the affected datasets for that MCMS host, so a delete /
+firmware push / flood-mode change is reflected on the next load instead of
+waiting out the TTL. Tune the TTL up to cut MCMS load further, or set
+`PFW_CACHE_TTL_SECONDS=0` to disable caching entirely. `POST /api/_cacheStats`
+(authenticated) shows hits/misses/coalesced; `POST /api/_cacheClear` drops the
+caller's MCMS-host snapshots.
+
+## Deploy on a Debian 13 LXC (clone-to-run)
+
+Files in `web/deploy/`: a systemd unit, an env template, and `update.sh`.
+
+```bash
+# --- as root on the LXC, once ---
+apt update && apt install -y nodejs npm git # Node 18+; or use NodeSource
+adduser --system --group --no-create-home ponfw
+
+git clone ssh://forgejo@int.git.vanvikinternet.no/Svorka/ponfw.git /opt/ponfw
+cd /opt/ponfw
+npm install --omit=dev --no-audit --no-fund # installs tough-cookie, skips Electron
+
+install -m 0644 web/deploy/pon-fleet-web.service /etc/systemd/system/
+install -m 0640 web/deploy/pon-fleet-web.env.example /etc/pon-fleet-web.env
+$EDITOR /etc/pon-fleet-web.env # set HOST / PFW_SECURE_COOKIE etc.
+
+systemctl daemon-reload
+systemctl enable --now pon-fleet-web
+systemctl status pon-fleet-web
+journalctl -u pon-fleet-web -f # logs
```
-nginx:
+The repo lives at `/opt/ponfw` (root-owned, read-only to the service); the
+service runs as the unprivileged `ponfw` user and writes nothing to disk.
-```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
-}
+**To update:** just re-pull and restart —
+
+```bash
+sudo /opt/ponfw/web/deploy/update.sh # git pull + npm install + restart
```
+## Behind Nginx Proxy Manager
+
+Point an NPM **Proxy Host** at the server:
+
+- Forward Hostname/IP: the LXC's address · Forward Port: `8080`
+- Scheme: `http` (NPM terminates TLS; request an SSL cert in NPM)
+- Enable **Block Common Exploits**; add your **Access List** for auth/IP
+ allow-listing (this is your access control — the app itself has no user
+ directory beyond MCMS login).
+- **Websockets Support** on is harmless. The progress streams (bulk delete,
+ FEC pre-flight, flood change) are plain NDJSON over HTTP and already send
+ `X-Accel-Buffering: no`, which nginx/NPM honour, so they stream live with
+ no extra config. If you ever see progress arrive only at the end, add to
+ the host's **Advanced** tab:
+
+ ```nginx
+ proxy_buffering off;
+ proxy_read_timeout 300s;
+ ```
+
+If NPM runs on a different host than this LXC, set `HOST=0.0.0.0` in
+`/etc/pon-fleet-web.env` and restrict reachability to the NPM host via the
+LXC firewall + the NPM access list.
+
## Security notes
- The server holds each logged-in user's **live MCMS session cookie in
diff --git a/web/cache.js b/web/cache.js
new file mode 100644
index 0000000..2e94073
--- /dev/null
+++ b/web/cache.js
@@ -0,0 +1,79 @@
+// In-memory TTL cache with single-flight, shared across all browser sessions,
+// to collapse repeated MCMS bulk fetches into one upstream request.
+//
+// Keyed by "|": operators pointed at the SAME MCMS
+// share a snapshot (the inventory is identical regardless of which
+// authenticated user fetched it), while different MCMS hosts never cross.
+//
+// Only slow-changing bulk *reads* are cached (ONU/OLT config + state lists,
+// firmware inventory). Per-ONU reads used for read-modify-write (getOnuConfig
+// before a PUT) and the FEC pre-flight are deliberately NOT cached — they must
+// be live. Writes invalidate the affected datasets so the next read refetches.
+
+class TtlCache {
+ constructor(ttlMs) {
+ this.ttl = ttlMs;
+ this.entries = new Map(); // key -> { value, at }
+ this.inflight = new Map(); // key -> Promise (single-flight)
+ this.hits = 0;
+ this.misses = 0;
+ this.coalesced = 0;
+ }
+
+ enabled() { return this.ttl > 0; }
+
+ /**
+ * Return the cached value for `key` if fresh; otherwise call `producer()`
+ * once (coalescing concurrent callers) and cache the result.
+ * @param {object} [opts]
+ * @param {boolean} [opts.fresh] bypass a still-fresh cached entry
+ */
+ async get(key, producer, { fresh = false } = {}) {
+ if (!this.enabled()) return producer();
+
+ const now = Date.now();
+ const e = this.entries.get(key);
+ if (!fresh && e && (now - e.at) < this.ttl) { this.hits++; return e.value; }
+
+ // Single-flight: if a fetch for this key is already running, join it
+ // (even a `fresh` request — it's fetching right now anyway).
+ const existing = this.inflight.get(key);
+ if (existing) { this.coalesced++; return existing; }
+
+ this.misses++;
+ const p = Promise.resolve()
+ .then(producer)
+ .then(
+ (value) => { this.entries.set(key, { value, at: Date.now() }); this.inflight.delete(key); return value; },
+ (err) => { this.inflight.delete(key); throw err; },
+ );
+ this.inflight.set(key, p);
+ return p;
+ }
+
+ /** Drop every entry whose key starts with `prefix` (e.g. one MCMS host's
+ * dataset). Returns how many were removed. */
+ invalidate(prefix) {
+ let n = 0;
+ for (const k of [...this.entries.keys()]) {
+ if (!prefix || k.startsWith(prefix)) { this.entries.delete(k); n++; }
+ }
+ return n;
+ }
+
+ /** Free memory: drop entries far older than the TTL (idle datasets). */
+ sweep(maxAgeMs) {
+ const cutoff = Date.now() - maxAgeMs;
+ for (const [k, e] of [...this.entries]) if (e.at < cutoff) this.entries.delete(k);
+ }
+
+ stats() {
+ return {
+ ttlMs: this.ttl, enabled: this.enabled(), size: this.entries.size,
+ hits: this.hits, misses: this.misses, coalesced: this.coalesced,
+ keys: [...this.entries.keys()],
+ };
+ }
+}
+
+module.exports = { TtlCache };
diff --git a/web/deploy/pon-fleet-web.env.example b/web/deploy/pon-fleet-web.env.example
new file mode 100644
index 0000000..18e7e0b
--- /dev/null
+++ b/web/deploy/pon-fleet-web.env.example
@@ -0,0 +1,30 @@
+# PON Fleet web server config.
+# Copy to /etc/pon-fleet-web.env and edit. All values are optional; the
+# defaults shown are what the server uses if a line is omitted.
+
+# Listen port.
+#PORT=8080
+
+# Bind address. Keep 127.0.0.1 if Nginx Proxy Manager runs on THIS host.
+# If NPM is a separate box/container, set 0.0.0.0 and restrict access at the
+# proxy (access lists) and/or the LXC firewall.
+#HOST=127.0.0.1
+
+# Shared upstream cache TTL (seconds). Repeated bulk reads (fleet load,
+# dashboard, OLT list) within this window are served from one cached MCMS
+# fetch, shared across all operators on the same MCMS host. 0 disables it.
+# Raise to cut MCMS load further; lower for fresher data.
+#PFW_CACHE_TTL_SECONDS=60
+
+# Idle session timeout (minutes). After this with no requests the server
+# logs that operator's MCMS session out and drops their client.
+#PFW_IDLE_MINUTES=30
+
+# Force the Secure flag on the session cookie. Recommended when always
+# behind HTTPS (NPM terminates TLS). If unset, Secure is inferred from the
+# X-Forwarded-Proto: https header NPM/nginx sends.
+#PFW_SECURE_COOKIE=1
+
+# Verbose MCMS request logging to the journal. Off by default — very noisy
+# with multiple users and dumps request metadata.
+#PFW_VERBOSE=0
diff --git a/web/deploy/pon-fleet-web.service b/web/deploy/pon-fleet-web.service
new file mode 100644
index 0000000..417b45a
--- /dev/null
+++ b/web/deploy/pon-fleet-web.service
@@ -0,0 +1,35 @@
+[Unit]
+Description=PON Fleet web server (MCMS tools, multi-user)
+Documentation=https://git.vanvikinternet.no/Svorka/ponfw
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=ponfw
+Group=ponfw
+# The repo is cloned to /opt/ponfw; the server lives in its web/ subdir.
+WorkingDirectory=/opt/ponfw/web
+ExecStart=/usr/bin/env node server.js
+# Defaults; override anything in /etc/pon-fleet-web.env (optional).
+Environment=NODE_ENV=production
+EnvironmentFile=-/etc/pon-fleet-web.env
+Restart=on-failure
+RestartSec=3
+
+# --- Hardening (the app reads its repo, writes nothing to disk) ---
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+RestrictSUIDSGID=true
+RestrictNamespaces=true
+LockPersonality=true
+# V8 needs writable+executable memory for its JIT, so do NOT deny W^X.
+MemoryDenyWriteExecute=false
+
+[Install]
+WantedBy=multi-user.target
diff --git a/web/deploy/update.sh b/web/deploy/update.sh
new file mode 100755
index 0000000..2ff63d4
--- /dev/null
+++ b/web/deploy/update.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Update the PON Fleet web server in place: pull the repo, reinstall runtime
+# deps (tough-cookie only — Electron is a devDep and is skipped), restart.
+# Run as root (or via sudo) on the LXC. Override the path with PONFW_DIR.
+set -euo pipefail
+
+REPO="${PONFW_DIR:-/opt/ponfw}"
+SERVICE="pon-fleet-web"
+
+echo "==> Updating $REPO"
+cd "$REPO"
+git pull --ff-only
+
+echo "==> Installing runtime dependencies (omit dev / Electron)"
+# `npm install` (not `npm ci`) because this repo gitignores package-lock.json.
+npm install --omit=dev --no-audit --no-fund
+
+echo "==> Restarting $SERVICE"
+systemctl restart "$SERVICE"
+sleep 1
+systemctl --no-pager --lines=8 status "$SERVICE" || true
+echo "==> Done."
diff --git a/web/server.js b/web/server.js
index bbb30db..d76b943 100644
--- a/web/server.js
+++ b/web/server.js
@@ -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.');
});