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:
parent
96b0264179
commit
6ee1d37a79
7 changed files with 309 additions and 35 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -796,9 +796,22 @@ Consequences for editing:
|
||||||
browser session (cookie `pfw_sid`), vs. the Electron app's single global
|
browser session (cookie `pfw_sid`), vs. the Electron app's single global
|
||||||
`client`. CSV writes become browser downloads; file dialogs become
|
`client`. CSV writes become browser downloads; file dialogs become
|
||||||
`<input type=file>`. Dashboard aggregation is shared via `src/dashboard.js`.
|
`<input type=file>`. 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/*`
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,38 +42,96 @@ cookie jar) per session.
|
||||||
| Var | Default | Meaning |
|
| Var | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `8080` | listen port |
|
| `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_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_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) |
|
| `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
|
The server keeps a small in-memory cache so multiple operators don't each
|
||||||
response buffering** so the NDJSON progress streams (bulk delete, FEC
|
hammer MCMS. The heavy bulk **reads** — ONU configs, ONU states, OLT states,
|
||||||
pre-flight, flood change, …) arrive live.
|
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.
|
||||||
|
|
||||||
```
|
What is **not** cached (always live, by design):
|
||||||
mcms-tools.example.net {
|
|
||||||
reverse_proxy 127.0.0.1:8080 {
|
- Per-ONU `getOnuConfig` used right before a firmware PUT (read-modify-write
|
||||||
flush_interval -1 # don't buffer streamed responses
|
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
|
**To update:** just re-pull and restart —
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
```bash
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
sudo /opt/ponfw/web/deploy/update.sh # git pull + npm install + restart
|
||||||
proxy_buffering off; # stream NDJSON progress live
|
|
||||||
proxy_read_timeout 300s; # bulk ops can run a while
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## Security notes
|
||||||
|
|
||||||
- The server holds each logged-in user's **live MCMS session cookie in
|
- The server holds each logged-in user's **live MCMS session cookie in
|
||||||
|
|
|
||||||
79
web/cache.js
Normal file
79
web/cache.js
Normal file
|
|
@ -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 "<mcms-base-url>|<dataset>": 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 };
|
||||||
30
web/deploy/pon-fleet-web.env.example
Normal file
30
web/deploy/pon-fleet-web.env.example
Normal file
|
|
@ -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
|
||||||
35
web/deploy/pon-fleet-web.service
Normal file
35
web/deploy/pon-fleet-web.service
Normal file
|
|
@ -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
|
||||||
22
web/deploy/update.sh
Executable file
22
web/deploy/update.sh
Executable file
|
|
@ -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."
|
||||||
|
|
@ -28,11 +28,14 @@ const {
|
||||||
} = require('../src/mcms-api');
|
} = require('../src/mcms-api');
|
||||||
const { planUpgrade } = require('../src/bank-strategy');
|
const { planUpgrade } = require('../src/bank-strategy');
|
||||||
const { summarizeDashboard } = require('../src/dashboard');
|
const { summarizeDashboard } = require('../src/dashboard');
|
||||||
|
const { TtlCache } = require('./cache');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 8080;
|
const PORT = Number(process.env.PORT) || 8080;
|
||||||
const HOST = process.env.HOST || '127.0.0.1';
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000;
|
const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000;
|
||||||
const VERBOSE = process.env.PFW_VERBOSE === '1';
|
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 ROOT = __dirname;
|
||||||
const RENDERER = path.join(ROOT, '..', 'renderer');
|
const RENDERER = path.join(ROOT, '..', 'renderer');
|
||||||
const PUBLIC = path.join(ROOT, 'public');
|
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 SMALL_BODY = 8 * 1024 * 1024; // 8 MB for normal JSON requests
|
||||||
const UPLOAD_BODY = 256 * 1024 * 1024; // 256 MB for base64 firmware uploads
|
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 --------------------------------------------------------
|
// ---- Sessions --------------------------------------------------------
|
||||||
// sid -> { client, baseUrl, user, lastSeen, sid }
|
// sid -> { client, baseUrl, user, lastSeen, sid }
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
@ -210,13 +228,14 @@ const handlers = {
|
||||||
return { ok: true, data };
|
return { ok: true, data };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listFleet(s) {
|
async listFleet(s, body) {
|
||||||
const c = s.client;
|
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;
|
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;
|
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();
|
const statusByOnu = new Map();
|
||||||
for (const olt of oltStates) {
|
for (const olt of oltStates) {
|
||||||
|
|
@ -249,8 +268,9 @@ const handlers = {
|
||||||
return { ok: true, data: await s.client.getOnuConfig(body.onuId) };
|
return { ok: true, data: await s.client.getOnuConfig(body.onuId) };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listOnuFirmware(s) {
|
async listOnuFirmware(s, body) {
|
||||||
return { ok: true, data: await s.client.listOnuFirmware() };
|
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).
|
// Browser sent the .bin as base64 (no server-side file dialog).
|
||||||
|
|
@ -264,6 +284,7 @@ const handlers = {
|
||||||
Version: versionMatch ? versionMatch[1] : '',
|
Version: versionMatch ? versionMatch[1] : '',
|
||||||
};
|
};
|
||||||
const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata);
|
const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata);
|
||||||
|
invalidate(s, ['firmware']);
|
||||||
return { ok: true, data: { filename, metadata, body: bodyResp } };
|
return { ok: true, data: { filename, metadata, body: bodyResp } };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -283,9 +304,15 @@ const handlers = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async executeBulkTask(s, body) {
|
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) {
|
async getTaskConfig(s, body) {
|
||||||
return { ok: true, data: await s.client.getTaskConfig(body.taskId) };
|
return { ok: true, data: await s.client.getTaskConfig(body.taskId) };
|
||||||
},
|
},
|
||||||
|
|
@ -297,17 +324,21 @@ const handlers = {
|
||||||
async dashboardStats(s, body) {
|
async dashboardStats(s, body) {
|
||||||
const c = s.client;
|
const c = s.client;
|
||||||
const extraStats = !!(body && body.extraStats);
|
const extraStats = !!(body && body.extraStats);
|
||||||
const olts = await c.listAllOltStates();
|
const fresh = !!(body && body.fresh);
|
||||||
const onuStates = extraStats ? await c.listAllOnuStates() : null;
|
const olts = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh);
|
||||||
|
const onuStates = extraStats
|
||||||
|
? await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh)
|
||||||
|
: null;
|
||||||
let controllerCount = 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 }) };
|
return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listOlts(s, body) {
|
async listOlts(s, body) {
|
||||||
const c = s.client;
|
const c = s.client;
|
||||||
const tagPattern = (body && body.tagPattern) || '';
|
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 rows = cfgs.map((cfg) => {
|
||||||
const summary = summarizeOltNniNetworks(cfg, tagPattern);
|
const summary = summarizeOltNniNetworks(cfg, tagPattern);
|
||||||
const counts = { private: 0, auto: 0, unknown: 0 };
|
const counts = { private: 0, auto: 0, unknown: 0 };
|
||||||
|
|
@ -355,6 +386,7 @@ const streamHandlers = {
|
||||||
results.push({ onuId, ok: false, error: toWireError(err) });
|
results.push({ onuId, ok: false, error: toWireError(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
invalidate(s, ['onuConfigs', 'onuStates']);
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -396,7 +428,7 @@ const streamHandlers = {
|
||||||
|
|
||||||
async deleteOnus(s, body, write) {
|
async deleteOnus(s, body, write) {
|
||||||
const { onuIds, concurrency = 5 } = body;
|
const { onuIds, concurrency = 5 } = body;
|
||||||
return runPool(onuIds, concurrency, async (id) => {
|
const results = await runPool(onuIds, concurrency, async (id) => {
|
||||||
let ok = false, error = null;
|
let ok = false, error = null;
|
||||||
try {
|
try {
|
||||||
await s.client.deleteOnuConfig(id);
|
await s.client.deleteOnuConfig(id);
|
||||||
|
|
@ -405,6 +437,8 @@ const streamHandlers = {
|
||||||
} catch (e) { error = toWireError(e); }
|
} catch (e) { error = toWireError(e); }
|
||||||
return { onuId: id, ok, error };
|
return { onuId: id, ok, error };
|
||||||
}, (entry, done, total) => write({ onuId: entry.onuId, ok: entry.ok, error: entry.error, done, total }));
|
}, (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) {
|
async executeFloodChange(s, body, write) {
|
||||||
|
|
@ -412,7 +446,7 @@ const streamHandlers = {
|
||||||
oltIds, tagPattern, fromMode = 'private', targetMode = 'auto',
|
oltIds, tagPattern, fromMode = 'private', targetMode = 'auto',
|
||||||
ponFloodIdValue = 0, concurrency = 3,
|
ponFloodIdValue = 0, concurrency = 3,
|
||||||
} = body;
|
} = body;
|
||||||
return runPool(oltIds, concurrency, async (id) => {
|
const results = await runPool(oltIds, concurrency, async (id) => {
|
||||||
try {
|
try {
|
||||||
const cfg = await s.client.getOltConfig(id);
|
const cfg = await s.client.getOltConfig(id);
|
||||||
if (!cfg) throw new Error('OLT not found');
|
if (!cfg) throw new Error('OLT not found');
|
||||||
|
|
@ -424,6 +458,8 @@ const streamHandlers = {
|
||||||
return { oltId: id, ok: false, error: toWireError(e) };
|
return { oltId: id, ok: false, error: toWireError(e) };
|
||||||
}
|
}
|
||||||
}, (entry, done, total) => write({ ...entry, done, total }));
|
}, (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, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`);
|
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] 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.');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue