Compare commits

..

No commits in common. "main" and "feat/olt-flooding-mode" have entirely different histories.

23 changed files with 194 additions and 3897 deletions

282
CLAUDE.md
View file

@ -12,15 +12,12 @@ is the engineering counterpart.
## 1. What this app does
Electron desktop client for operating a **Ciena MCMS 6.2** PON Manager
fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between:
Electron desktop client for two bulk operations against **Ciena
MCMS 6.2** PON Manager:
- **Dashboard** — read-only network telemetry (see §15).
- **ONU** — two sub-tabs: an **Info / stats** page (per-ONU optical/FEC plus
the CPE router's DHCPv4/v6 lease health, §17) and the **Fleet & upgrade**
flow (the original purpose: bulk ONU firmware upgrades + verify).
- **OLT** — bulk OLT NNI-service edits: PON flooding mode (private↔auto) and
DHCPv4/v6 filter (pass↔umt), see §14.
1. **Bulk ONU firmware upgrades** (the original purpose).
2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
see §14).
Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest
(EV051xxR / EV052xxR firmware family) behind Tibit OLTs.
@ -119,16 +116,12 @@ stream progress events back to the renderer.
| `api:listFleet` | ONU-CFG + ONU-STATE + OLT-STATE merged | — |
| `api:fetchStates` | per-ONU state fallback if bulk failed | `states:progress` |
| `api:listOnuConfigs` / `api:getOnuConfig` | direct CFG access | — |
| `api:getOnuCpe` | CPE (router) DHCP lease health + OUI vendor | — |
| `api:getOnuDetail` | rich ONU detail: firmware/UNI/alarms/OLT/switch/traffic/CPE | — |
| `api:getOltDetail` | OLT detail: traffic/temp/ONU-counts/laser/alarms/switch | — |
| `api:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — |
| `api:planUpgrade` | dry-run plan for selected ONUs | — |
| `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` |
| `api:executeBulkTask` | Procedure 8 — single AUTO-TASK-CFG | — |
| `api:getTaskConfig` / `api:getUpgradeStatus` | poll helpers | — |
| `api:deleteOnus` | bulk delete (CFG + best-effort STATE) | `delete:progress` |
| `api:dashboardStats` | network telemetry aggregates (Dashboard tab) | — |
| `api:listOlts` | OLT-CFG list + per-OLT NNI flood-mode summary | — |
| `api:executeFloodChange` | bulk PON flooding-mode flip (GET→plan→PUT) | `olt-flood:progress` |
| `api:fetchFecHealth` | pre-flight FEC pre/post + optical | `fec:progress` |
@ -143,10 +136,8 @@ stream progress events back to the renderer.
### Fleet view
- Login with email / password. TLS to MCMS is always verified
(`rejectUnauthorized: true`); there is no accept-self-signed option —
MCMS must present a valid certificate. The web server can prefill the host
field from `PFW_MCMS_URL`.
- Login with email / password, accept-self-signed-TLS toggle (essential
for lab MCMS installs).
- Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` +
`listAllOltStates`, all paginated via the `next` cursor at page size
100 (see §5).
@ -359,12 +350,8 @@ state_collection.STATS["OLT-PON"]["RX Post-FEC BER"]
→ The pre-flight fetch (`api:fetchFecHealth`) and the verify fetch
(`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via
`extractOnuHealth`. For *current* FEC/optical, prefer ONU-STATE.
The time-series endpoint **is** used, but only for the ONU detail page's
traffic sparkline (`getOnuStatsSeries``api:getOnuDetail`, §17): it pulls
the last ~hour of samples for the down/up rate history. Don't use it for the
live FEC pill — that stays on ONU-STATE.
`extractOnuHealth`. The time-series stats endpoint is **not used**
anywhere — earlier code that hit it has been removed.
`extractOnuHealth` accepts both shapes: the bare state doc
(`{STATS: ...}`) and the UI-helper wrapped form
@ -666,54 +653,48 @@ Key procedure citations:
---
## 14. OLT NNI-service edits (bulk flood mode + DHCP filter)
## 14. OLT PON flooding mode (bulk private↔auto)
Second bulk feature, added after the firmware upgrader. Lets an operator
edit OLT NNI services across many OLTs at once. Two concerns, applied
together in one GET→edit→PUT per OLT:
flip the PON flooding mode of OLT NNI services across many OLTs at once.
### The two mutation rules
### The mutation rule (the whole feature in one fact)
Both live on each `OLT-CFG["NNI Networks"][i]` entry, both confirmed
empirically from operator paste-backs of `s0.c76.c0`:
On Tibit OLTs, a service's flooding mode is encoded by the **presence or
absence of one key** on its `OLT-CFG["NNI Networks"][i]` entry:
1. **Flooding mode** — the **presence/absence of the `PON FLOOD ID` key**:
present (any value, incl. `0`) → **private**; absent → **auto**. So
private→auto is `delete entry['PON FLOOD ID']`; auto→private is
`entry['PON FLOOD ID'] = 0`.
2. **DHCP filter mode** — a **value** under the nested `Filter` object:
`entry.Filter.DHCPv4` / `entry.Filter.DHCPv6`, e.g. `"pass"``"umt"`
(siblings `EAPOL`/`PPPoE`/`NDP` are left alone). Value replacement, not
key presence. **Replace-only-when-present**: an entry with no `Filter`
(or no such key) is skipped, never invented.
- **`PON FLOOD ID` key present** (any value, including `0`) → **private**
- **`PON FLOOD ID` key absent** → **auto**
Everything else on the entry and the rest of the OLT-CFG is preserved.
Default actions: TAG MATCH `s0.c76.c0`, flood `private→auto`, DHCPv4/v6
`pass→umt`. All directions are wired, so changes roll back from the same UI.
So private→auto is `delete entry['PON FLOOD ID']`; auto→private is
`entry['PON FLOOD ID'] = 0`. Everything else on the entry (`TAG MATCH`,
`Learning Limit`, …) and the rest of the OLT-CFG is preserved. This was
confirmed empirically by diffing two paste-backs of the same OLT
(`24:65:e1:cc:af:1e`, NNI `s0.c76.c0`) in private vs auto — the *only*
difference was that key.
The default action ships as: TAG MATCH = `s0.c76.c0`, mode `private`
`auto`. Both directions are wired so a change can be rolled back from the
same UI.
### Data flow
```
listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure)
executeFloodChange ─► per-OLT: getOltConfig ─► planNniEdit (pure, no-mutate)
(channel name kept for wire compat) ─► putOltConfig (full-doc PUT)
executeFloodChange ─► per-OLT: getOltConfig ─► planFloodChange (pure, no-mutate)
─► putOltConfig (full-doc PUT)
```
- **Pure helpers** at the bottom of `src/mcms-api.js`: `floodModeOfNni`,
`dhcpModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
`planNniEdit`. `planNniEdit(oltCfg, { tagPattern, flood, dhcpv4, dhcpv6 })`
clones the `NNI Networks` array (and any edited `Filter`) and never mutates
its input. Returns `{ newDoc, changes, unchanged }`, where a change is
`{ index, tagMatch, edits: [{ type, from, to }] }``type` is `'flood'`,
`'DHCPv4'`, or `'DHCPv6'`, so one entry can carry several edits.
- Each concern is optional (`flood`/`dhcpv4`/`dhcpv6` may be `null`); the UI
has three independent action dropdowns.
- **Pure helpers** live at the bottom of `src/mcms-api.js`:
`floodModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
`planFloodChange`. `planFloodChange` clones the `NNI Networks` array and
never mutates its input — it returns `{ newDoc, changes, unchanged }`.
- **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and
`*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars
then turns `*` into `.*`.
- **`from` guards**: only entries currently on the `from` mode/value are
flipped; entries already at target are left in `unchanged` (the per-OLT
`noop` case), so re-running is idempotent.
- **`fromMode` guard**: only entries currently in `fromMode` are flipped;
entries already in the target mode are recorded in `unchanged` (the
per-OLT `noop` case), so re-running is idempotent.
- **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state
fetch) — OLT writes carry far more blast radius.
- **PUT-as-replace**, same gotcha as ONU-CFG (§5.7): the handler GETs the
@ -740,197 +721,4 @@ entries pass through untouched.
---
## 15. Tabbed navigation & Dashboard
### Tabs
The topbar holds a `<nav id="tabs">` (Dashboard / ONU / OLT), hidden until
login. Navigation goes through the existing `showView(id)` — it now also
sets the active tab via the `VIEW_TO_TAB` map, so sub-views keep the right
tab lit (campaign/verify → ONU). To add a tab: add a `.tab` button with
`data-view="#view-x"` in `index.html`, a `#view-x` section, and a
`VIEW_TO_TAB` entry. Login lands on Dashboard; logout hides the tabs and
clears `state.dashboard`.
### Dashboard telemetry
Modeled on the PONGo iOS app's `DashboardViewModel`
(`ssh://…/Svorka/PONGo_ios.git`). One IPC call, `api:dashboardStats`,
returns aggregates computed in `main.js` — the renderer only formats them.
**Light path (default)** — everything from the small OLT-STATE docs, no
per-ONU fetches:
- OLT count; ONU total + registration-state breakdown (the same
`OLT-STATE["ONU States"]` buckets as `api:listFleet`, Procedure 2).
- Aggregate traffic — **OLT TX BW = downstream, RX BW = upstream**, summed
over `STATS["OLT-PON"]["TX|RX BW Ethernet Rate bps"]`.
- OLTs with laser off — `OLT["Laser Shutdown"] != "Laser ON"`.
- Controllers count via `listAllControllerConfigs` (`/v3/controllers/
configs/`) — **best-effort**, wrapped in try/catch (returns `null` if the
build doesn't expose it; UI shows "—").
**Detailed path (`extraStats`, opt-in toggle)** — pulls every ONU-STATE
(heavy, like a fleet load) to add: abnormal-Rx count (`rx < 28 || rx >
10` dBm, the iOS `OpticalThreshold.rxAbnormal` rule) and the FEC-health
distribution via `extractOnuHealth`.
The ONU-state rows drill down: clicking one sets `#filter-status` and jumps
to the ONU tab (loading the fleet if needed). `fmtBps` in `app.js` is a
port of the iOS `Format.bps`.
(An animated neon edge-glow beam was tried and removed — it couldn't be made
to track the border reliably on iOS Safari. The static scanline overlay
remains.)
---
## 16. Web server variant (`web/`)
A multi-user, browser-based deployment of the *same* tooling lives in
`web/` — see `web/README.md`. It is **not a fork**: it reuses `src/*` and
serves `renderer/app.js` + `renderer/app.css` verbatim, rewriting
`renderer/index.html` on the fly to swap in a browser `window.api` shim
(`web/public/api-web.js`) and a responsive overlay (`web/public/web.css`).
Consequences for editing:
- **The renderer is shared.** Changes to `renderer/*` and `src/*` flow to
both the Electron app and the web app automatically. Keep `app.js`
free of Electron/Node globals (it already only touches `window.api` +
DOM) so the shim stays sufficient.
- **`window.api` is the contract.** If you add an IPC channel (see §10),
also add it to `web/server.js` (a route) **and** `web/public/api-web.js`
(the shim method) or the web app won't have it. Streaming channels use
NDJSON (progress lines + a `{__final:…}` line) instead of IPC events.
- **Per-session isolation.** `web/server.js` keeps one `McmsClient` per
browser session (cookie `pfw_sid`), vs. the Electron app's single global
`client`. CSV writes become browser downloads; file dialogs become
`<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.
`dashboardStats` attaches `data.cache = { enabled, ttlMs, ageMs }` (age of
the oldest dataset feeding the view); the dashboard renders a live-ticking
age badge from it (`renderCacheBadge`), and the Dashboard **Refresh** button
sends `fresh:true` to bypass the cache. The Electron app sends no cache
field, so the badge shows "live".
- **Deploy** lives in `web/deploy/`: `install.sh` (one-shot, idempotent —
derives the repo path from its own location, so clone anywhere; points the
systemd unit at that checkout), `update.sh` (git pull + npm install +
restart), the unit, and the env template. Debian-13-LXC steps + Nginx Proxy
Manager setup are in `web/README.md`. Clone to `/opt/ponfw` — the hardened
unit's `ProtectHome=true` blocks a checkout under `/home`. 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. Deploy
uses `npm install --omit=dev` (not `npm ci``package-lock.json` is
gitignored) which pulls `tough-cookie` and skips the Electron devDep.
---
## 17. ONU info / stats page (CPE + OUI)
The **ONU** top tab has two sub-tabs (`.subtab` buttons in each ONU view's
panel-left; `showView` lights the right one — see §15): **Info / stats** and
**Fleet & upgrade** (the original fleet/campaign/verify flow). The ONU tab
now lands on Info; campaign/verify keep the Fleet sub-tab lit.
### Info page (`#view-onu-info`)
Master-detail over the already-loaded `state.fleet` (no new bulk fetch):
search (name/address/serial) + status filter on the left; clicking an ONU
renders the detail as a **responsive card grid** (`.detail-grid`, auto-fill
columns — this is the de-compaction). Identity + link/optical paint instantly
from `state.fleet` (`onuClientStats`); the rest streams from one
`api:getOnuDetail` call (`src/onudetail.js`, pure, shared):
- **Traffic** — current + peak down/up with inline SVG sparklines, from the
`/onus/stats/` time-series (down = OLT TX rate, up = OLT RX rate).
- **UNI ports** — UNI-ETH config (speed/duplex/enable) + last Ethernet LOS
(from the `LAN-LOS` / `PptpEthernetUni` alarm).
- **Parent OLT** — name/model/FW/PON, resolved by following the OLT MAC in
the alarm-history doc to its OLT-CFG.
- **Upstream switch** — the OLT NNI's LLDP neighbour (`Switch` block on the
alarm-history doc): system name, port, IPv4, chassis.
- **Alarms** — active-first, severity-sorted (`N-LABEL`; rank ≤3 red, 4
amber, 5 cyan, ≥6 muted) with raised counts and last-raised.
- **CPE** (§ above) and **Firmware** (banks + single-ONU upgrade).
`api:getOnuDetail` fans out `getOnuConfig` + `getOnuAlarms` +
`getOnuStatsSeries` (parallel) → then `getOltConfig(oltMac)``getCpe`; it is
**not** TTL-cached (live detail). Dashboard abnormal-Rx/Tx drill-downs (§15)
deep-link here via `openOnuInfo`.
The left list/search supports name/address/serial + equipment/version +
PON-mode + status filters; rows are two-line (serial+status, then
equipment · version · last-seen). Parent OLT and upstream switch are one
combined card. On narrow screens it's **master-detail**: the picker and the
detail don't stack (which buried the detail under a long list) — selecting an
ONU swaps to the detail (`.show-detail` on `#view-onu-info`, via a
`@media (max-width:860px)` rule in the shared CSS) with a "← ONU list" back
button; navigating in via the tab/sub-tab resets to the picker.
### Single-ONU upgrade
The Firmware card has a firmware picker + "Upgrade this ONU" button that
reuses the bulk per-ONU path — `executePerOnu({ onuIds: [id], … })`
(Procedure 7, inactive-bank write via `bank-strategy`). Two-step `UPGRADE`
typed confirm; streams `upgrade:progress`; refreshes the detail on success.
### Clickable parent OLT → OLT detail modal
The OLT name (and "View OLT details") opens `showOltModal`
`api:getOltDetail``src/oltdetail.js` (pure, shared; reuses
`summarizeAlarms`): identity, traffic (OLT TX=down / RX=up) + util,
**ASIC temperature**, ONU counts (total/online/offline), laser, alarms, and
the upstream switch. The **connected-ONU list** (per-ONU FEC/optical, each
clickable back into the ONU detail) is built **client-side from the loaded
`state.fleet`** filtered by `_status.oltMac` — no extra per-ONU fetch; it
prompts to load the fleet if empty. `getOltDetail` fans out `getOltConfig` +
`getOltState` + `getOltAlarms`.
**Caveat — UNI link speed is the *configured* value** (`UNI-ETH n`.Speed,
usually `Auto`), shown as "cfg Auto/Auto". A negotiated link speed isn't in
the ONU-CFG / alarm / stats payloads inspected so far; link up/down comes
from the `LAN-LOS` alarm instead. Wire negotiated speed if a future
ONU-STATE sample exposes a per-UNI operational field.
### CPE (customer router)
`api:getOnuCpe``McmsClient.getCpe` (`GET /api/cpe/onu/<id>/` — unversioned,
returns a `[statusCode, doc]` tuple) → `summarizeCpe` (`src/cpe.js`, pure,
shared). The lease-health rule (`leaseHealth`): a DHCP client renews at T1 =
50% of the lease, so **< 50% remaining renewal overdue** (amber), past end
⇒ expired (red). Field mapping confirmed from a real CPE doc:
- **DHCPv4**: expiry = `DHCP["Remove Time"]` (= `Last Success Time` +
`Lease Time`); health spans `Last Success Time``Remove Time`.
- **DHCPv6**: `DHCPV6` carries `T1/T2/Preferred/Valid Lifetime` + nested
`Prefix Delegation[].Prefixes[]`. Health spans `Last Success Time`
`Preferred Lifetime` (T1 is the midpoint), so the same < 50% rule fires
once now passes T1.
### OUI vendor (`src/oui.js`)
The CPE MAC (`_id` / `Client MAC`) resolves to a vendor via the IEEE registry
(`oui.csv`), fetched lazily on first lookup, parsed to a prefix→org map,
cached **in memory ~30 days + best-effort to disk** (`PFW_CACHE_DIR` or the
temp dir; the web server's `PrivateTmp` makes `/tmp` writable). Every failure
path returns `null` → "unknown OUI"; a missing list never breaks the page.
The lookup runs server-side in `getOnuCpe` so the renderer gets a vendor
string. Needs outbound HTTPS from whoever runs the backend (override the URL
with `PFW_OUI_URL`). The ONU `GNXS…` id is a serial, **not** a MAC — OUI only
applies to the CPE/OLT MACs.
CPE is **not** cached by the web server's TTL cache (lease status must be
live); only the OUI list is cached.
---
*Last updated: 2026-06-23.*

View file

@ -1,18 +1,12 @@
# PON Fleet Upgrader
An Electron desktop app for operating a **Ciena MicroClimate Management
System (MCMS) 6.2** PON Manager. Top tabs split it into:
An Electron desktop app for bulk operations against a **Ciena
MicroClimate Management System (MCMS) 6.2** PON Manager. It does two
things:
1. **Dashboard** — read-only network telemetry: ONU/OLT/controller counts,
aggregate up/down traffic, health (abnormal Rx, lasers off), and ONU
registration-state breakdown (with an opt-in per-ONU Rx/FEC scan).
Clicking a state jumps to the ONU tab pre-filtered.
2. **ONU** — two sub-tabs: an **Info / stats** page (per-ONU optical/FEC plus
the CPE router's DHCPv4/v6 lease status, with a warning when a lease is
past its renew point) and **Fleet & upgrade** (**bulk ONU firmware
upgrades**, below).
3. **OLT****bulk OLT NNI-service edits**: PON flooding mode (private↔auto)
and DHCPv4/v6 filter (pass↔umt) — see "OLT NNI-service edits" below.
1. **Bulk ONU firmware upgrades** (below).
2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
see "OLT PON flooding mode" below).
## ONU firmware upgrades
@ -107,36 +101,27 @@ pon-fleet-upgrader/
└── app.js # UI logic
```
## OLT NNI-service edits
## OLT PON flooding mode
From the fleet view, **Open OLT inspector…** opens a second workflow for
bulk-checking and editing OLT NNI services. Two independent edits can be
applied together to every matching service:
bulk-checking and changing the PON flooding mode of OLT NNI services.
**Flooding mode** — encoded by the presence of a single key on each
`OLT-CFG["NNI Networks"]` entry:
On Tibit OLTs the flooding mode is encoded by the presence of a single key
on each `OLT-CFG["NNI Networks"]` entry:
| Mode | `PON FLOOD ID` key | Change |
|------|--------------------|--------|
| **private** | present (any value, incl. `0`) | → auto: delete the key |
| **auto** | absent | → private: set the key to `0` |
**DHCP filter mode** — values nested under each entry's `Filter` object
(`DHCPv4` / `DHCPv6`), e.g. `pass``umt`. Only entries that already have
the field are touched (the field is never invented); `EAPOL`/`PPPoE`/`NDP`
are left alone.
Workflow: enter a **TAG MATCH** pattern (exact like `s0.c76.c0`, or a `*`
glob like `s0.c76.*`), **Load OLTs**, pick any of the three actions
(Flooding / DHCPv4 / DHCPv6), select the OLTs — each matching service shows
"→ will change (…)" for the edits that apply — then **Execute changes…**
(two-step confirm). Each OLT is re-fetched, the matching NNI entries are
edited in one pass, and the full OLT-CFG is PUT back. A result CSV is
auto-saved to `~/Downloads/pon-olt-nni-*.csv`.
glob like `s0.c76.*`), **Load OLTs**, select the OLTs to change, pick a
direction, then **Execute change…** (two-step confirm). Each OLT is
re-fetched, the matching NNI entries are flipped, and the full OLT-CFG is
PUT back. A result CSV is auto-saved to `~/Downloads/pon-olt-flood-*.csv`.
All directions are supported (e.g. `umt → pass`), so changes roll back from
the same screen. Defaults: `s0.c76.c0`, flood `private → auto`, DHCPv4/v6
`pass → umt`.
Both directions are supported, so a change can be rolled back from the same
screen. The default action targets `s0.c76.c0`, `private``auto`.
## Known limitations

110
main.js
View file

@ -10,13 +10,8 @@ const path = require('path');
const fs = require('fs');
const {
McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planNniEdit,
summarizeOltNniNetworks, planFloodChange,
} = require('./src/mcms-api');
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 { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */
@ -63,12 +58,11 @@ function toWireError(err) {
return { message: err?.message || String(err) };
}
ipcMain.handle('api:login', async (_ev, { baseUrl, username, password }) => {
ipcMain.handle('api:login', async (_ev, { baseUrl, username, password, acceptSelfSigned }) => {
try {
client = new McmsClient({
baseUrl,
// TLS is always verified — MCMS must present a valid certificate.
rejectUnauthorized: true,
rejectUnauthorized: !acceptSelfSigned,
// Verbose logging goes to the Electron terminal (npm start output).
// Turn off by setting PON_FLEET_VERBOSE=0 in the env.
verbose: process.env.PON_FLEET_VERBOSE !== '0',
@ -224,67 +218,6 @@ ipcMain.handle('api:getOnuConfig', async (_ev, { onuId }) => {
}
});
/**
* CPE (customer router) behind an ONU, for the ONU info page: DHCPv4/v6
* lease state + renewal health, plus the CPE MAC's vendor (OUI lookup,
* cached ~1 month). Returns null when there's no CPE record.
*/
ipcMain.handle('api:getOnuCpe', async (_ev, { onuId }) => {
try {
const c = requireClient();
const doc = await c.getCpe(onuId);
const cpe = summarizeCpe(doc);
if (cpe) cpe.cpeVendor = await lookupOui(cpe.cpeMac);
return { ok: true, data: cpe };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Rich per-ONU detail for the info page: firmware banks, UNI-ETH ports +
* last Ethernet LOS, alarms, parent OLT, upstream switch (LLDP), a traffic
* time-series, and the CPE summary one round of fetches summarized via
* src/onudetail.js + src/cpe.js.
*/
ipcMain.handle('api:getOnuDetail', async (_ev, { onuId, windowMin = 60 }) => {
try {
const c = requireClient();
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 };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
/**
* Rich OLT detail (clicked from an ONU's Parent OLT card): identity +
* traffic + temperature + ONU counts + laser + alarms + upstream switch.
*/
ipcMain.handle('api:getOltDetail', async (_ev, { oltMac }) => {
try {
const c = requireClient();
const [oltCfg, oltState, alarmsDoc] = await Promise.all([
c.getOltConfig(oltMac).catch(() => null),
c.getOltState(oltMac).catch(() => null),
c.getOltAlarms(oltMac).catch(() => null),
]);
return { ok: true, data: summarizeOltDetail({ oltCfg, oltState, alarmsDoc }) };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});
ipcMain.handle('api:listOnuFirmware', async () => {
try {
const c = requireClient();
@ -720,11 +653,9 @@ ipcMain.handle('api:listOlts', async (_ev, opts) => {
});
/**
* Apply a set of NNI-service edits (flood mode and/or DHCPv4/DHCPv6 filter)
* to a list of OLT IDs. For each OLT we GET the current CFG (to avoid
* stomping concurrent edits), run planNniEdit, and PUT the result back.
* Streams progress. (Channel name kept as executeFloodChange for wire
* compatibility.)
* Apply a planned flooding mode change to a list of OLT IDs. For each
* OLT we GET the current CFG (to avoid stomping concurrent edits),
* run planFloodChange, and PUT the result back. Streams progress.
*
* Concurrency is kept low (default 3) OLT writes carry a much larger
* blast radius than ONU reads, so we deliberately stay below the ONU
@ -734,7 +665,8 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try {
const c = requireClient();
const {
oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null,
oltIds, tagPattern, fromMode = 'private',
targetMode = 'auto', ponFloodIdValue = 0,
concurrency = 3,
} = opts || {};
const queue = [...oltIds];
@ -750,7 +682,9 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try {
const cfg = await c.getOltConfig(id);
if (!cfg) throw new Error('OLT not found');
const plan = planNniEdit(cfg, { tagPattern, flood, dhcpv4, dhcpv6 });
const plan = planFloodChange(cfg, {
tagPattern, fromMode, targetMode, ponFloodIdValue,
});
if (plan.changes.length === 0) {
entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
} else {
@ -772,25 +706,3 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
return { ok: false, error: toWireError(err) };
}
});
// ---- Dashboard telemetry --------------------------------------------
/**
* Network-wide telemetry for the Dashboard tab. Aggregation lives in the
* shared, pure src/dashboard.js (so the web server computes identically);
* this handler just fetches the inputs. Light by default (small OLT-STATE
* docs); the per-ONU optical/FEC scan runs only when `extraStats` is set.
*/
ipcMain.handle('api:dashboardStats', async (_ev, opts) => {
try {
const c = requireClient();
const extraStats = !!(opts && opts.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 }) };
} catch (err) {
return { ok: false, error: toWireError(err) };
}
});

View file

@ -15,9 +15,6 @@ contextBridge.exposeInMainWorld('api', {
listFleet: (opts) => call('api:listFleet', opts),
fetchStates: (opts) => call('api:fetchStates', opts),
getOnuConfig: (opts) => call('api:getOnuConfig', opts),
getOnuCpe: (opts) => call('api:getOnuCpe', opts),
getOnuDetail: (opts) => call('api:getOnuDetail', opts),
getOltDetail: (opts) => call('api:getOltDetail', opts),
listOnuFirmware: () => call('api:listOnuFirmware'),
uploadFirmware: () => call('api:uploadFirmware'),
@ -29,8 +26,6 @@ contextBridge.exposeInMainWorld('api', {
deleteOnus: (opts) => call('api:deleteOnus', opts),
dashboardStats: (opts) => call('api:dashboardStats', opts),
listOlts: (opts) => call('api:listOlts', opts),
executeFloodChange: (opts) => call('api:executeFloodChange', opts),

View file

@ -1,41 +1,21 @@
/* Neon / terminal "hackertyper" theme electric-blue variant.
Dense, operator-oriented UI no framework, hand-tuned CSS.
Class names are load-bearing (HTML + renderer/app.js reference them);
this file only restyles, it doesn't rename anything. */
/* Simple, dense, operator-oriented UI.
No framework just hand-tuned CSS. */
:root {
/* Blue-black base, like a cold CRT. */
--bg: #02050c;
--bg-elev: #060c16;
--bg-elev-2: #0b1422;
/* Blue-white text. */
--fg: #d6ecff;
--fg-dim: #6f90b5;
--border: #163457;
/* Neon accents — blue-forward. */
--neon: #21c8ff; /* primary — electric blue/cyan */
--neon-soft: #7ce0ff;
--cyan: #46e6ff;
--blue: #2a7bff;
--magenta: #ff3df0;
--accent: var(--neon);
--accent-hover: var(--neon-soft);
--ok: #2bff9e; /* health "ok" stays green — it's semantic */
--warn: #ffc44d;
--danger: #ff3b6b;
--danger-hover: #ff5d85;
--info: var(--cyan);
--mono: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
/* Reusable glow shadows — turned up. */
--glow-neon: 0 0 8px rgba(33, 200, 255, 0.65);
--glow-neon-strong: 0 0 12px rgba(33, 200, 255, 0.85), 0 0 30px rgba(33, 200, 255, 0.5), 0 0 56px rgba(33, 200, 255, 0.22);
--glow-danger: 0 0 10px rgba(255, 59, 107, 0.7), 0 0 24px rgba(255, 59, 107, 0.35);
--glow-cyan: 0 0 10px rgba(70, 230, 255, 0.6);
--bg: #0e1116;
--bg-elev: #161b22;
--bg-elev-2: #1f2630;
--fg: #e7edf3;
--fg-dim: #8a94a3;
--border: #2a313c;
--accent: #3ea6ff;
--accent-hover: #65baff;
--ok: #3fb950;
--warn: #d29922;
--danger: #f85149;
--danger-hover: #ff6a62;
--info: #58a6ff;
--mono: "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
@ -46,70 +26,16 @@ html, body {
height: 100%;
background: var(--bg);
color: var(--fg);
/* Monospace everywhere — terminal feel. */
font-family: var(--mono);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
line-height: 1.45;
}
/* Ambient neon vignette behind everything. */
body {
background:
radial-gradient(1200px 600px at 50% -10%, rgba(33, 200, 255, 0.12), transparent 60%),
radial-gradient(900px 520px at 100% 110%, rgba(42, 123, 255, 0.1), transparent 60%),
radial-gradient(700px 400px at 0% 100%, rgba(70, 230, 255, 0.07), transparent 60%),
var(--bg);
}
/* CRT scanline + flicker overlay. Non-interactive, sits above content. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.16) 3px,
rgba(0, 0, 0, 0) 4px
);
mix-blend-mode: multiply;
opacity: 0.5;
animation: scanflicker 6s steps(60) infinite;
}
@keyframes scanflicker {
0%, 97%, 100% { opacity: 0.5; }
98% { opacity: 0.62; }
99% { opacity: 0.42; }
}
::selection { background: rgba(33, 200, 255, 0.32); color: #fff; text-shadow: none; }
/* Neon scrollbars (WebKit / Chromium — Electron). */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-elev); }
::-webkit-scrollbar-thumb {
background: #14385c;
border-radius: 6px;
border: 1px solid var(--border);
}
::-webkit-scrollbar-thumb:hover { background: #1c5d92; box-shadow: var(--glow-neon); }
code {
font-family: var(--mono);
background: var(--bg-elev-2);
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
color: var(--neon-soft);
border: 1px solid var(--border);
}
code { font-family: var(--mono); background: var(--bg-elev-2); padding: 1px 4px; border-radius: 3px; font-size: 12px; }
.muted { color: var(--fg-dim); }
.small { font-size: 12px; }
.hidden { display: none !important; }
.error { color: var(--danger); text-shadow: var(--glow-danger); }
.error { color: var(--danger); }
.row { display: flex; gap: 8px; align-items: center; }
.inline { display: inline-flex; align-items: center; gap: 6px; }
@ -118,41 +44,26 @@ code {
align-items: center;
gap: 16px;
padding: 10px 16px;
background: linear-gradient(180deg, rgba(33, 200, 255, 0.08), transparent), var(--bg-elev);
border-bottom: 1px solid var(--neon);
box-shadow: 0 1px 0 rgba(33, 200, 255, 0.3), 0 8px 24px -10px rgba(33, 200, 255, 0.5);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.topbar .brand {
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--neon);
text-shadow: var(--glow-neon-strong);
animation: brandflicker 4.5s infinite;
}
.topbar .brand::before { content: "▌ "; color: var(--neon); opacity: 0.85; }
@keyframes brandflicker {
0%, 19%, 21%, 55%, 57%, 100% { opacity: 1; text-shadow: var(--glow-neon-strong); }
20% { opacity: 0.7; }
56% { opacity: 0.85; text-shadow: var(--glow-neon); }
}
.topbar .session { margin-left: auto; color: var(--cyan); font-family: var(--mono); font-size: 12px; text-shadow: var(--glow-cyan); }
.topbar .brand { font-weight: 600; letter-spacing: 0.3px; }
.topbar .session { margin-left: auto; color: var(--fg-dim); font-family: var(--mono); font-size: 12px; }
.view { display: flex; gap: 0; height: calc(100vh - 45px); }
#view-login { justify-content: center; align-items: flex-start; padding-top: 60px; }
.card {
background: var(--bg-elev);
border: 1px solid var(--neon);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
width: 440px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--glow-neon-strong), inset 0 0 40px rgba(33, 200, 255, 0.07);
}
.card h1 { margin: 0 0 4px; font-size: 18px; color: var(--neon); text-shadow: var(--glow-neon-strong); letter-spacing: 1px; }
.card h1 { margin: 0 0 4px; font-size: 18px; }
.card p { margin: 0 0 8px; }
label {
@ -161,10 +72,8 @@ label {
gap: 4px;
color: var(--fg-dim);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
label.inline { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); text-transform: none; letter-spacing: 0; }
label.inline { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); }
input[type="text"], input[type="password"], input[type="url"],
input[type="number"], input[type="search"], input[type="datetime-local"],
@ -175,89 +84,40 @@ select {
padding: 7px 9px;
border-radius: 4px;
font-size: 13px;
font-family: var(--mono);
font-family: inherit;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
input::placeholder { color: #43607f; }
input:focus, select:focus {
border-color: var(--neon);
box-shadow: var(--glow-neon), inset 0 0 10px rgba(33, 200, 255, 0.15);
caret-color: var(--neon);
}
/* Glowing checkbox accent. */
input[type="checkbox"] { accent-color: var(--neon); }
input:focus, select:focus { border-color: var(--accent); }
button {
font-family: var(--mono);
font-family: inherit;
font-size: 13px;
letter-spacing: 0.5px;
padding: 7px 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-elev-2);
color: var(--fg);
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease, color 0.12s ease, background 0.12s ease;
}
button:hover:not(:disabled) { border-color: var(--neon); color: var(--neon); box-shadow: var(--glow-neon); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary {
background: rgba(33, 200, 255, 0.12);
color: var(--neon);
border-color: var(--neon);
font-weight: 700;
text-transform: uppercase;
text-shadow: var(--glow-neon);
box-shadow: var(--glow-neon), inset 0 0 12px rgba(33, 200, 255, 0.15);
}
button.primary:hover:not(:disabled) {
background: var(--neon);
color: #00121f;
box-shadow: var(--glow-neon-strong);
text-shadow: none;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary { background: var(--accent); color: #001828; border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
button.ghost { background: transparent; }
button.ghost:hover:not(:disabled) { border-color: var(--cyan); color: var(--cyan); box-shadow: var(--glow-cyan); }
button.danger {
background: rgba(255, 59, 107, 0.14);
color: var(--danger);
border-color: var(--danger);
font-weight: 700;
text-transform: uppercase;
text-shadow: var(--glow-danger);
box-shadow: var(--glow-danger), inset 0 0 12px rgba(255, 59, 107, 0.14);
}
button.danger:hover:not(:disabled) {
background: var(--danger);
color: #1a0008;
border-color: var(--danger-hover);
box-shadow: 0 0 14px rgba(255, 59, 107, 0.85), 0 0 30px rgba(255, 59, 107, 0.45);
text-shadow: none;
}
button.danger { background: var(--danger); color: #fff; border-color: var(--danger); font-weight: 600; }
button.danger:hover:not(:disabled) { background: var(--danger-hover); border-color: var(--danger-hover); }
.panel-left {
width: 320px;
padding: 16px;
border-right: 1px solid var(--border);
background: linear-gradient(180deg, rgba(33, 200, 255, 0.05), transparent 260px), var(--bg-elev);
background: var(--bg-elev);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.panel-left h2 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--neon);
text-shadow: var(--glow-neon);
margin: 12px 0 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.panel-left h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--fg-dim); margin: 12px 0 0; }
.panel-left h2:first-child { margin-top: 0; }
.panel-main {
@ -268,27 +128,23 @@ button.danger:hover:not(:disabled) {
gap: 12px;
overflow: hidden;
}
.panel-main h2 { margin: 0; font-size: 15px; color: var(--fg); letter-spacing: 0.5px; }
.panel-main h2 { margin: 0; font-size: 15px; }
.fleet-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.fleet-head h2 span { color: var(--neon); text-shadow: var(--glow-neon); }
.selected-summary {
background: var(--bg-elev-2);
padding: 8px 10px;
border-radius: 4px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 6px;
}
.selected-summary strong { color: var(--neon); text-shadow: var(--glow-neon); }
.fleet-table-wrap {
flex: 1;
overflow: auto;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: inset 0 0 30px rgba(33, 200, 255, 0.05);
}
.fleet-table {
width: 100%;
@ -301,12 +157,8 @@ button.danger:hover:not(:disabled) {
background: var(--bg-elev);
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--neon);
color: var(--neon);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
text-shadow: var(--glow-neon);
border-bottom: 1px solid var(--border);
font-weight: 600;
z-index: 1;
}
.fleet-table tbody td {
@ -319,16 +171,13 @@ button.danger:hover:not(:disabled) {
text-overflow: ellipsis;
max-width: 320px;
}
.fleet-table tbody tr:hover { background: rgba(33, 200, 255, 0.08); }
.fleet-table tbody tr.selected {
background: rgba(33, 200, 255, 0.14);
box-shadow: inset 3px 0 0 var(--neon), inset 0 0 22px rgba(33, 200, 255, 0.16);
}
.fleet-table tbody tr:hover { background: var(--bg-elev-2); }
.fleet-table tbody tr.selected { background: rgba(62, 166, 255, 0.12); }
.status-ok { color: var(--ok); text-shadow: 0 0 7px rgba(43, 255, 158, 0.6); }
.status-warn { color: var(--warn); text-shadow: 0 0 7px rgba(255, 196, 77, 0.55); }
.status-err { color: var(--danger); text-shadow: var(--glow-danger); }
.status-info { color: var(--info); text-shadow: var(--glow-cyan); }
.status-ok { color: var(--ok); }
.status-warn { color: var(--warn); }
.status-err { color: var(--danger); }
.status-info { color: var(--info); }
.status-pending { color: var(--fg-dim); }
/* Compact FEC counter readout under the health pill in the preview
@ -339,10 +188,10 @@ button.danger:hover:not(:disabled) {
/* Verify-view table cells contain stacked before/after blocks; let
them wrap and align to the top so before/after lines line up. */
#verify-table tbody td { white-space: normal; vertical-align: top; }
#verify-table .delta-arrow { color: var(--neon); font-size: 11px; }
#verify-table .delta-arrow { color: var(--fg-dim); font-size: 11px; }
#verify-table .before-line, #verify-table .after-line { display: block; }
#verify-table .before-line { color: var(--fg-dim); font-size: 11px; }
.fec-pill { font-weight: 700; }
.fec-pill { font-weight: 600; }
.fec-counters {
display: block;
margin-top: 3px;
@ -360,332 +209,5 @@ button.danger:hover:not(:disabled) {
padding: 1px 6px;
border-radius: 3px;
background: var(--bg-elev-2);
border: 1px solid var(--neon);
color: var(--neon);
text-shadow: var(--glow-neon);
}
/* -------- Themed confirmation modal (confirmTyped) -------- */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 5, 12, 0.74);
backdrop-filter: blur(2px);
animation: modalfade 0.12s ease;
}
@keyframes modalfade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: 480px;
max-width: calc(100vw - 40px);
background: var(--bg-elev);
border: 1px solid var(--neon);
border-radius: 8px;
padding: 22px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--glow-neon-strong), inset 0 0 40px rgba(33, 200, 255, 0.06);
}
.modal.modal-danger {
border-color: var(--danger);
box-shadow: 0 0 14px rgba(255, 59, 107, 0.8), 0 0 34px rgba(255, 59, 107, 0.45), inset 0 0 40px rgba(255, 59, 107, 0.06);
}
.modal h3 {
margin: 0;
font-size: 15px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--neon);
text-shadow: var(--glow-neon-strong);
}
.modal.modal-danger h3 { color: var(--danger); text-shadow: var(--glow-danger); }
.modal-body { color: var(--fg); font-size: 13px; line-height: 1.55; }
.modal-type {
display: flex;
flex-direction: column;
gap: 6px;
text-transform: none;
letter-spacing: 0;
color: var(--fg-dim);
}
.modal-type-label { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
/* -------- Top tabs -------- */
.tabs { display: flex; gap: 4px; margin-left: 18px; align-self: stretch; align-items: flex-end; }
.tabs .tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 7px 7px 0 0;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 1.5px;
font-size: 12px;
font-weight: 700;
padding: 8px 18px;
box-shadow: none;
}
.tabs .tab:hover:not(.active) {
color: var(--neon);
border-color: transparent;
text-shadow: var(--glow-neon);
box-shadow: none;
}
.tabs .tab.active {
color: var(--neon);
border-color: var(--neon);
background: rgba(33, 200, 255, 0.1);
text-shadow: var(--glow-neon);
box-shadow: 0 0 12px rgba(33, 200, 255, 0.45), inset 0 0 14px rgba(33, 200, 255, 0.12);
}
/* -------- Sub-tabs (within the ONU tab) -------- */
.subtabs {
display: flex;
gap: 6px;
margin: -4px 0 4px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.subtabs .subtab {
flex: 1;
background: transparent;
border: 1px solid var(--border);
color: var(--fg-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
padding: 6px 8px;
}
.subtabs .subtab:hover:not(.active) { color: var(--neon); border-color: transparent; box-shadow: none; }
.subtabs .subtab.active {
color: var(--neon);
border-color: var(--neon);
background: rgba(33, 200, 255, 0.1);
text-shadow: var(--glow-neon);
box-shadow: inset 0 0 12px rgba(33, 200, 255, 0.12);
}
/* -------- ONU info / stats page -------- */
.onu-info-list {
margin-top: 4px;
display: flex;
flex-direction: column;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
flex: 1;
min-height: 120px;
}
.onu-info-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.onu-info-row:last-child { border-bottom: none; }
.onu-info-row:hover { background: rgba(33, 200, 255, 0.06); }
.onu-info-row.selected { background: rgba(33, 200, 255, 0.14); box-shadow: inset 3px 0 0 var(--neon); }
.onu-info-row .oir-serial { font-weight: 600; font-size: 12px; display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.onu-info-row .oir-sub { font-size: 11px; color: var(--fg-dim); display: flex; gap: 8px; align-items: center; }
.onu-info-row .oir-meta { font-size: 11px; color: var(--fg-dim); overflow-wrap: anywhere; }
.onu-info-row .oir-meta .sep { opacity: 0.5; }
.onu-detail { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 6px; }
.onu-detail h2 { margin: 0; font-size: 17px; color: var(--neon); text-shadow: var(--glow-neon); }
/* De-compacted: cards flow into as many columns as fit the width. */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 14px;
align-items: start;
}
/* min-width:0 stops a long unbreakable string (system descriptions, IPv6,
alarm text) from blowing the grid track out past the card. */
.detail-card { min-width: 0; }
.kv dd { min-width: 0; overflow-wrap: anywhere; }
svg.spark { vertical-align: middle; opacity: 0.9; }
.alarm-row { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.alarm-row:last-child { border-bottom: none; }
.alarm-row .alarm-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
.alarm-row .alarm-text { overflow-wrap: anywhere; }
.alarm-row .alarm-meta { color: var(--fg-dim); font-size: 11px; overflow-wrap: anywhere; }
.fw-upg { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.olt-link { color: var(--neon); text-decoration: none; border-bottom: 1px dotted currentColor; cursor: pointer; }
.olt-link:hover { text-shadow: var(--glow-neon); }
/* OLT detail modal */
.olt-modal { width: min(960px, 95vw); }
.olt-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
.olt-onu-list { max-height: 42vh; margin-top: 6px; }
/* "Back to list" button only matters in the narrow master-detail layout. */
.onu-detail-back { display: none; align-self: flex-start; }
/* Narrow screens: the ONU info page is master-detail show the picker OR
the selected ONU's detail, never both stacked (which buried the detail
under a long list). `.show-detail` is toggled in app.js on select / back. */
@media (max-width: 860px) {
#view-onu-info .panel-main { display: none; }
#view-onu-info.show-detail .panel-left { display: none; }
#view-onu-info.show-detail .panel-main { display: flex; flex-direction: column; }
#view-onu-info .onu-detail-back { display: inline-block; }
}
.detail-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
}
.detail-card > h3 {
margin: 0 0 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--neon);
text-shadow: var(--glow-neon);
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 16px;
font-size: 13px;
}
.kv dt { color: var(--fg-dim); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; word-break: break-all; }
.lease-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* -------- Dashboard -------- */
.dash-wrap { overflow: hidden; }
.dash-scroll {
flex: 1;
overflow-y: auto;
padding-right: 8px;
display: flex;
flex-direction: column;
gap: 22px;
}
.dash-toggle { text-transform: none; letter-spacing: 0; color: var(--fg-dim); font-size: 12px; }
/* Cache-age badge in the dashboard header. Colour comes from a status-*
class set in JS (info = within TTL, warn = stale, ok = live/no cache). */
.dash-cache {
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.3px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid currentColor;
white-space: nowrap;
cursor: default;
}
.dash-cache:empty { display: none; }
.dash-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.tile {
background: linear-gradient(180deg, rgba(33, 200, 255, 0.07), transparent 70%), var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
box-shadow: inset 0 0 26px rgba(33, 200, 255, 0.05);
}
.tile .tile-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--fg-dim); }
.tile .tile-value { font-size: 34px; font-weight: 700; line-height: 1.05; color: var(--neon); text-shadow: var(--glow-neon-strong); font-variant-numeric: tabular-nums; }
.tile .tile-sub { font-size: 11px; color: var(--fg-dim); }
.tile.tile-teal .tile-value { color: var(--cyan); text-shadow: var(--glow-cyan); }
.tile.tile-indigo .tile-value { color: var(--blue); text-shadow: 0 0 10px rgba(42, 123, 255, 0.75), 0 0 22px rgba(42, 123, 255, 0.4); }
.dash-section { display: flex; flex-direction: column; gap: 8px; }
.dash-section > h3 {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--neon);
text-shadow: var(--glow-neon);
}
.dash-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 2px 14px;
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
}
.dash-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid var(--border);
}
.dash-row:last-child { border-bottom: none; }
.dash-row.tappable { cursor: pointer; }
.dash-row.tappable:hover { background: rgba(33, 200, 255, 0.06); }
.dash-row .dash-row-main { display: flex; flex-direction: column; gap: 2px; }
.dash-row .dash-row-sub { font-size: 11px; color: var(--fg-dim); }
.dash-num { font-variant-numeric: tabular-nums; font-weight: 700; }
.dash-traffic { display: flex; gap: 36px; padding: 6px 2px; }
.dash-traffic .t-block { display: flex; flex-direction: column; gap: 4px; }
.dash-traffic .t-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--fg-dim); }
.dash-traffic .t-val { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
.dash-traffic .t-down { color: var(--neon); text-shadow: var(--glow-neon); }
.dash-traffic .t-up { color: var(--cyan); text-shadow: var(--glow-cyan); }
.dash-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
.dash-chevron { color: var(--neon); margin-left: 8px; font-weight: 700; }
/* List/menu popped from a clickable dashboard stat. */
.list-modal { width: 540px; }
.list-modal-items {
max-height: 52vh;
overflow-y: auto;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: inset 0 0 22px rgba(33, 200, 255, 0.04);
}
.list-modal-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.list-modal-item:last-child { border-bottom: none; }
.list-modal-item.tappable { cursor: pointer; }
.list-modal-item:hover { background: rgba(33, 200, 255, 0.06); }
.lm-primary { font-weight: 600; }
.lm-secondary { color: var(--fg-dim); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,6 @@
<body>
<header class="topbar">
<div class="brand">PON Fleet Upgrader</div>
<nav id="tabs" class="tabs hidden">
<button class="tab" data-tab="dashboard" data-view="#view-dashboard">Dashboard</button>
<button class="tab" data-tab="onu" data-view="#view-onu-info">ONU</button>
<button class="tab" data-tab="olt" data-view="#view-olts">OLT</button>
</nav>
<div class="session" id="session-label">Not connected</div>
<button id="btn-logout" class="ghost hidden">Disconnect</button>
</header>
@ -29,6 +24,10 @@
<label>Host URL<input id="in-host" type="url" placeholder="https://mcms.example.net" /></label>
<label>Email / username<input id="in-user" type="text" autocomplete="username" /></label>
<label>Password<input id="in-pass" type="password" autocomplete="current-password" /></label>
<label class="inline">
<input id="in-self-signed" type="checkbox" checked />
Accept self-signed TLS certificate
</label>
<div class="row">
<button id="btn-login" class="primary">Connect</button>
<span id="login-error" class="error"></span>
@ -36,81 +35,9 @@
</div>
</section>
<!-- ================= Dashboard view ================= -->
<section id="view-dashboard" class="view hidden">
<div class="panel-main dash-wrap">
<div class="fleet-head">
<h2>Dashboard</h2>
<div class="row">
<span id="dash-cache" class="dash-cache" title=""></span>
<label class="inline dash-toggle">
<input id="dash-extra" type="checkbox" />
Detailed stats (Rx / FEC scan)
</label>
<button id="dash-refresh" class="ghost">↻ Refresh</button>
</div>
</div>
<p class="muted small" id="dash-status">Connect and the network overview loads here.</p>
<div class="dash-scroll">
<div class="dash-grid" id="dash-tiles"></div>
<div id="dash-sections"></div>
</div>
</div>
</section>
<!-- ================= ONU info / stats view ================= -->
<section id="view-onu-info" class="view hidden">
<div class="panel-left">
<nav class="subtabs">
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
<button class="subtab" data-subview="#view-fleet">Fleet &amp; upgrade</button>
</nav>
<h2>Find ONU</h2>
<label>Search (name / address / serial)
<input id="onu-info-search" type="search" placeholder="e.g. ORKANGER or GNXS05…" />
</label>
<label>Equipment / version contains
<input id="onu-info-eq" type="search" placeholder="e.g. FT-XGS2110 or EV051" />
</label>
<label>PON mode
<select id="onu-info-pon">
<option value="">Any</option>
<option value="GPON">GPON</option>
<option value="XGS-PON">XGS-PON</option>
<option value="10G-EPON">10G-EPON</option>
</select>
</label>
<label>Registration status
<select id="onu-info-status">
<option value="">Any</option>
<option value="__down__">Down (Dereg / Dying Gasp / Disabled)</option>
<option value="Registered">Registered</option>
<option value="Deregistered">Deregistered</option>
<option value="Dying Gasp">Dying Gasp</option>
<option value="Disabled">Disabled</option>
<option value="__unknown__">Unknown (no OLT state)</option>
</select>
</label>
<button id="onu-info-load" class="primary">Load fleet</button>
<p class="muted small" id="onu-info-status-line">Load the fleet, then pick an ONU.</p>
<div class="onu-info-list" id="onu-info-list"></div>
</div>
<div class="panel-main">
<div id="onu-info-detail" class="onu-detail">
<p class="muted">Select an ONU on the left to see its stats and the CPE (customer router) behind it.</p>
</div>
</div>
</section>
<!-- ================= Fleet view ================= -->
<section id="view-fleet" class="view hidden">
<div class="panel-left">
<nav class="subtabs">
<button class="subtab" data-subview="#view-onu-info">Info / stats</button>
<button class="subtab" data-subview="#view-fleet">Fleet &amp; upgrade</button>
</nav>
<h2>Filters</h2>
<label>Name / address contains
<input id="filter-text" type="search" placeholder="e.g. ORKANGER" />
@ -330,11 +257,11 @@
<label>NNI TAG MATCH pattern
<input id="olt-tag-pattern" type="text" value="s0.c76.c0" placeholder="e.g. s0.c76.c0 or s0.c76.*" />
</label>
<label>Flood-mode filter
<label>Current mode
<select id="olt-mode-filter">
<option value="any" selected>Any</option>
<option value="private">Private (PON FLOOD ID set)</option>
<option value="private" selected>Private (PON FLOOD ID set)</option>
<option value="auto">Auto (no PON FLOOD ID)</option>
<option value="any">Any</option>
</select>
</label>
<label>OLT name contains
@ -352,29 +279,13 @@
</div>
</div>
<h2>Actions</h2>
<p class="muted small">Applied together to every matching NNI service on the selected OLTs, in one GET→edit→PUT per OLT.</p>
<label>Flooding mode
<select id="olt-flood-action">
<option value="">No change</option>
<h2>Action</h2>
<label>Change matching services
<select id="olt-action">
<option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option>
<option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
</select>
</label>
<label>DHCPv4 filter
<select id="olt-dhcpv4-action">
<option value="">No change</option>
<option value="pass2umt" selected>pass → umt</option>
<option value="umt2pass">umt → pass</option>
</select>
</label>
<label>DHCPv6 filter
<select id="olt-dhcpv6-action">
<option value="">No change</option>
<option value="pass2umt" selected>pass → umt</option>
<option value="umt2pass">umt → pass</option>
</select>
</label>
<div class="row">
<button id="btn-olts-back" class="ghost">← Back to fleet</button>

View file

@ -1,105 +0,0 @@
// CPE (customer router) summarization. Pure — no Electron/HTTP. Shared by the
// Electron main process and the web server so both compute identical lease
// health. Reduces the raw /api/cpe/onu/<id>/ document to what the ONU info
// page shows: DHCPv4 / DHCPv6 lease state, IPs, prefix delegation, and a
// renewal-health verdict.
const { parseMcmsTime } = require('./mcms-api');
function ms(t) {
const v = parseMcmsTime(t);
return v > 0 ? v : null;
}
/**
* Lease renewal health. A DHCP client renews at T1 = 50% of the lease, so if
* more than half the lease has elapsed and the client is still on the old
* lease, renewal is failing.
* ok past start, > 50% of the lease still remaining
* warn < 50% remaining (past the renew point; renewal likely failed)
* expired lease end is in the past
* unknown missing/unparseable timestamps
*/
function leaseHealth(startMs, endMs, now = Date.now()) {
if (!startMs || !endMs || endMs <= startMs) return { level: 'unknown', remainingMs: null, fraction: null };
const total = endMs - startMs;
const remainingMs = endMs - now;
const fraction = remainingMs / total;
let level;
if (remainingMs <= 0) level = 'expired';
else if (fraction < 0.5) level = 'warn';
else level = 'ok';
return { level, remainingMs, fraction };
}
/**
* Reduce a raw CPE document to display fields. Accepts either the bare doc or
* the `[statusCode, doc]` tuple the endpoint returns.
*/
function summarizeCpe(raw) {
const doc = Array.isArray(raw) ? raw[1] : raw;
if (!doc || typeof doc !== 'object') return null;
const v4 = doc.DHCP || null;
const v6 = doc.DHCPV6 || null;
const ppp = doc.PPPoE || null;
const out = {
cpeMac: doc._id || v4?.['Client MAC'] || v6?.['Client MAC'] || '',
oltMac: doc.OLT?.['MAC Address'] || '',
fwVersion: doc.CNTL?.Version || '',
v4: null,
v6: null,
pppoe: null,
};
if (v4 && (v4['Client State'] || v4['Client IP Addr'])) {
const start = ms(v4['Last Success Time']) || ms(v4['Create Time']);
// Expiry is Remove Time (= Last Success + Lease Time); fall back to the sum.
const end = ms(v4['Remove Time'])
|| (start && Number(v4['Lease Time']) ? start + Number(v4['Lease Time']) * 1000 : null);
out.v4 = {
state: v4['Client State'] || v4.State || '',
ip: v4['Client IP Addr'] || '',
leaseSeconds: Number(v4['Lease Time']) || null,
lastSuccess: v4['Last Success Time'] || '',
expiry: v4['Remove Time'] || '',
serverId: v4['Server ID'] || '',
circuitId: v4['Circuit ID'] || '',
health: leaseHealth(start, end),
};
}
if (v6 && (v6['Client State'] || v6['Client IP Addr'])) {
const start = ms(v6['Last Success Time']) || ms(v6['Create Time']);
// Preferred Lifetime is the renewal-relevant horizon (T1 = midpoint);
// fall back to Valid Lifetime.
const end = ms(v6['Preferred Lifetime']) || ms(v6['Valid Lifetime']);
const prefixes = Array.isArray(v6['Prefix Delegation'])
? v6['Prefix Delegation'].flatMap((pd) =>
(Array.isArray(pd?.Prefixes) ? pd.Prefixes : [])
.map((p) => `${p.Prefix || '?'}/${p['Prefix Length'] ?? '?'}`))
: [];
out.v6 = {
state: v6['Client State'] || v6.State || '',
ip: v6['Client IP Addr'] || '',
t1: v6['T1 Time'] || '',
t2: v6['T2 Time'] || '',
preferred: v6['Preferred Lifetime'] || '',
valid: v6['Valid Lifetime'] || '',
lastSuccess: v6['Last Success Time'] || '',
serverId: v6['Server ID'] || '',
circuitId: v6['Circuit ID'] || '',
prefixes,
health: leaseHealth(start, end),
};
}
if (ppp && ppp.State && ppp.State !== 'Unknown') {
out.pppoe = { state: ppp.State, sessionId: ppp['Session ID'] || 0 };
}
return out;
}
module.exports = { summarizeCpe, leaseHealth };

View file

@ -1,110 +0,0 @@
// Shared dashboard telemetry aggregation. Pure — no Electron, no HTTP — so
// both the Electron main process (api:dashboardStats) and the web server
// (/api/dashboardStats) compute identical numbers. Modeled on the PONGo
// iOS app's DashboardViewModel.
const { extractOnuHealth } = require('./mcms-api');
function toNum(v) {
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
}
// PONGo OpticalThreshold (dBm): Rx in-spec is -28..-10; Tx floor is 3.
const RX_LOW = -28, RX_HIGH = -10, TX_LOW = 3;
/**
* @param {object} args
* @param {object[]} args.olts OLT-STATE docs (listAllOltStates).
* @param {object[]|null} [args.onuStates] ONU-STATE docs for the heavy
* optical/FEC scan, or null/undefined to skip it (the light default).
* @param {number|null} [args.controllerCount]
*/
function summarizeDashboard({ olts = [], onuStates = null, controllerCount = null } = {}) {
const oltCount = olts.length;
// Registration map: onuId -> bucket (prefer a non-Registered bucket on
// duplicates — the interesting signal), matching api:listFleet.
const stateByOnu = new Map();
for (const olt of olts) {
const buckets = olt?.['ONU States'] || {};
for (const [bucket, ids] of Object.entries(buckets)) {
if (!Array.isArray(ids)) continue;
for (const id of ids) {
const existing = stateByOnu.get(id);
if (!existing || existing === 'Registered') stateByOnu.set(id, bucket);
}
}
}
const onuTotal = stateByOnu.size;
const stateCounts = {};
for (const s of stateByOnu.values()) stateCounts[s] = (stateCounts[s] || 0) + 1;
const onuCounts = Object.entries(stateCounts)
.map(([state, count]) => ({ state, count }))
.sort((a, b) => b.count - a.count);
// Aggregate traffic + laser state from OLT-STATE.
// OLT TX BW = downstream (OLT→ONU), RX BW = upstream (ONU→OLT).
let downBps = 0, upBps = 0, trafficSeen = false;
const lasersOff = [];
for (const olt of olts) {
const pon = olt?.STATS?.['OLT-PON'] || {};
const tx = toNum(pon['TX BW Ethernet Rate bps']);
const rx = toNum(pon['RX BW Ethernet Rate bps']);
if (tx !== null) { downBps += tx; trafficSeen = true; }
if (rx !== null) { upBps += rx; trafficSeen = true; }
const laser = olt?.OLT?.['Laser Shutdown'];
if (typeof laser === 'string' && laser.toLowerCase() !== 'laser on') {
lasersOff.push({ oltId: olt._id, name: olt?.OLT?.Name || '', laser });
}
}
// Opt-in heavy scan: every ONU-STATE for optical + FEC health. Keeps the
// actual offender lists so the dashboard can drill down without re-fetch.
let optical = {
available: false,
abnormalRx: [], abnormalRxCount: 0,
abnormalTx: [], abnormalTxCount: 0,
};
let fecCounts = null;
if (Array.isArray(onuStates)) {
const abnormalRx = [], abnormalTx = [];
let available = false;
fecCounts = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 };
for (const st of onuStates) {
const health = extractOnuHealth(st);
if (fecCounts[health.flag] !== undefined) fecCounts[health.flag] += 1;
const o = health.optical || {};
const rx = typeof o.rx === 'number' ? o.rx : null;
const tx = typeof o.tx === 'number' ? o.tx : null;
const name = st?.ONU?.Name || '';
if (rx !== null) {
available = true;
if (rx < RX_LOW || rx > RX_HIGH) abnormalRx.push({ onuId: st._id, rx, name });
}
if (tx !== null) {
available = true;
if (tx < TX_LOW) abnormalTx.push({ onuId: st._id, tx, name });
}
}
optical = {
available,
abnormalRx, abnormalRxCount: abnormalRx.length,
abnormalTx, abnormalTxCount: abnormalTx.length,
};
}
return {
onuTotal, oltCount, controllerCount,
onuCounts,
traffic: trafficSeen ? { downBps, upBps } : null,
lasersOff,
optical,
fecCounts,
extraStats: Array.isArray(onuStates),
sampledAt: new Date().toISOString(),
};
}
module.exports = { summarizeDashboard };

View file

@ -523,53 +523,6 @@ class McmsClient {
return res.body;
}
/**
* Fetch the CPE (customer router) record behind an ONU. The endpoint is
* unversioned (`/api/cpe/onu/<id>/`) and returns a `[statusCode, doc]`
* tuple rather than the usual `{status,data}` envelope return the doc
* (or null when absent / status != 0).
*/
async getCpe(onuId) {
try {
const res = await this._request('GET', `/cpe/onu/${encodeURIComponent(onuId)}/`);
const b = res.body;
if (Array.isArray(b)) return b[0] === 0 ? (b[1] || null) : null;
return b?.data ?? b ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
/**
* ONU alarm-history document: active/cleared alarms with severity, plus the
* parent OLT MAC and the OLT's upstream-switch LLDP neighbour. Used by the
* ONU detail page.
*/
async getOnuAlarms(onuId) {
try {
const res = await this._request('GET', `/v3/onus/alarm-histories/${encodeURIComponent(onuId)}/`);
return res.body?.data ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
/**
* ONU PM time-series for the last `minutes`, for the detail-page traffic
* sparkline. `start-time`/`end-time` are UTC "YYYY-MM-DD HH:MM:SS" (§5.8).
* Returns the raw sample array (mixed sample shapes the caller filters).
*/
async getOnuStatsSeries(onuId, { minutes = 60 } = {}) {
const end = new Date();
const start = new Date(end.getTime() - minutes * 60000);
const res = await this._request('GET', `/v3/onus/stats/${encodeURIComponent(onuId)}/`, {
query: { 'start-time': formatMcmsTime(start), 'end-time': formatMcmsTime(end) },
});
return Array.isArray(res.body?.data) ? res.body.data : [];
}
// --- OLTs (for ONU registration status) ------------------------------
/**
@ -585,28 +538,6 @@ class McmsClient {
return this._listPaginatedByNext('/v3/olts/states/', { pageSize });
}
/** Single OLT-STATE doc (traffic / temperature / ONU counts / laser). */
async getOltState(oltId) {
try {
const res = await this._request('GET', `/v3/olts/states/${encodeURIComponent(oltId)}/`);
return res.body?.data ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
/** OLT alarm-history doc (alarms + upstream-switch LLDP). */
async getOltAlarms(oltId) {
try {
const res = await this._request('GET', `/v3/olts/alarm-histories/${encodeURIComponent(oltId)}/`);
return res.body?.data ?? null;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
// --- OLT configuration (PON flooding mode) ---------------------------
/**
@ -645,17 +576,6 @@ class McmsClient {
return res.body;
}
// --- Controllers (dashboard counts) ----------------------------------
/**
* Bulk-fetch all PON Controller config documents (CNTL-CFG). Used only
* for the dashboard "Controllers" count. Best-effort: not every MCMS
* build exposes this, so callers should tolerate a throw / 404.
*/
async listAllControllerConfigs({ pageSize = 100 } = {}) {
return this._listPaginatedByNext('/v3/controllers/configs/', { pageSize });
}
// --- Firmware inventory ----------------------------------------------
async listOnuFirmware() {
@ -782,17 +702,6 @@ function floodModeOfNni(nniNet) {
: 'auto';
}
// DHCP relay/filter mode for an NNI entry. On Tibit OLTs these live under a
// nested `Filter` object alongside EAPOL/PPPoE/NDP, e.g.
// "Filter": { "DHCPv4": "umt", "DHCPv6": "umt", "EAPOL": "pass", ... }
// `ver` is 'DHCPv4' | 'DHCPv6'. Returns the value (e.g. "umt" / "pass") or
// null if there is no Filter or the key is absent.
function dhcpModeOfNni(nniNet, ver) {
const filter = nniNet && typeof nniNet.Filter === 'object' && nniNet.Filter ? nniNet.Filter : null;
if (!filter) return null;
return Object.prototype.hasOwnProperty.call(filter, ver) ? filter[ver] : null;
}
/**
* Build a uniform summary of every NNI Networks entry on an OLT-CFG,
* filtered by a TAG MATCH pattern. Pattern can be:
@ -813,8 +722,6 @@ function summarizeOltNniNetworks(oltCfg, tagPattern = '') {
tagMatch: tag,
mode: floodModeOfNni(n),
ponFloodId: Object.prototype.hasOwnProperty.call(n, 'PON FLOOD ID') ? n['PON FLOOD ID'] : null,
dhcpv4: dhcpModeOfNni(n, 'DHCPv4'),
dhcpv6: dhcpModeOfNni(n, 'DHCPv6'),
learningLimit: n?.['Learning Limit'] ?? null,
});
}
@ -829,65 +736,48 @@ function tagPatternToRegex(pattern) {
}
/**
* Apply a set of NNI-service edits to a CLONE of an OLT-CFG. Non-mutating
* the input doc is never touched (the matched entries and any edited `Filter`
* are cloned before mutation). Returns { newDoc, changes, unchanged } where a
* change is `{ index, tagMatch, edits: [{ type, from, to }] }` (an entry can
* carry several edits) and unchanged entries record a reason.
* Apply a PON flooding mode change to a CLONE of an OLT-CFG doc. Does
* not mutate the input. Returns { newDoc, changes, unchanged }.
*
* ops:
* tagPattern which NNI entries to touch (exact / "*" / glob)
* flood { fromMode='private'|'auto'|null, targetMode, ponFloodIdValue=0 } | null
* targetMode 'auto' deletes PON FLOOD ID; 'private' sets it.
* dhcpv4 { from='pass'|null, to='umt' } | null (Filter.DHCPv4)
* dhcpv6 { from='pass'|null, to='umt' } | null (Filter.DHCPv6)
*
* DHCP edits are replace-only-when-present: an entry with no `Filter` (or no
* such key) is left alone rather than inventing the field. A null `from` means
* "from any current value"; a set `from` only flips entries currently on it.
* `targetMode` is 'auto' (delete PON FLOOD ID) or 'private' (set
* PON FLOOD ID to ponFloodIdValue, default 0).
*/
function planNniEdit(oltCfg, { tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null } = {}) {
function planFloodChange(oltCfg, {
tagPattern,
fromMode = 'private', // only flip entries currently in this mode (null = any)
targetMode = 'auto',
ponFloodIdValue = 0,
}) {
const networks = Array.isArray(oltCfg?.['NNI Networks']) ? oltCfg['NNI Networks'] : [];
const re = tagPatternToRegex(tagPattern);
const newNetworks = networks.map((n) => ({ ...n }));
const changes = [];
const unchanged = [];
for (let i = 0; i < newNetworks.length; i++) {
const n = newNetworks[i];
const tag = n?.['TAG MATCH'] || '';
if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; }
const edits = [];
// Flood mode — presence of PON FLOOD ID.
if (flood && flood.targetMode) {
const cur = floodModeOfNni(n);
const okFrom = !flood.fromMode || cur === flood.fromMode;
if (okFrom && cur !== flood.targetMode) {
if (flood.targetMode === 'auto') delete n['PON FLOOD ID'];
else if (flood.targetMode === 'private') n['PON FLOOD ID'] = flood.ponFloodIdValue ?? 0;
edits.push({ type: 'flood', from: cur, to: flood.targetMode });
}
const current = floodModeOfNni(n);
if (fromMode && current !== fromMode) {
unchanged.push({ index: i, tagMatch: tag, reason: `already in mode "${current}", not "${fromMode}"` });
continue;
}
// DHCP filter modes — Filter.DHCPv4 / Filter.DHCPv6, replace-when-present.
for (const [op, fieldKey] of [[dhcpv4, 'DHCPv4'], [dhcpv6, 'DHCPv6']]) {
if (!op || !op.to) continue;
const filter = n.Filter;
if (!filter || typeof filter !== 'object' || !Object.prototype.hasOwnProperty.call(filter, fieldKey)) continue;
const cur = filter[fieldKey];
const okFrom = !op.from || cur === op.from;
if (okFrom && cur !== op.to) {
n.Filter = { ...filter, [fieldKey]: op.to }; // clone Filter; never mutate the input
edits.push({ type: fieldKey, from: cur, to: op.to });
}
if (current === targetMode) {
unchanged.push({ index: i, tagMatch: tag, reason: `already in target mode "${targetMode}"` });
continue;
}
if (edits.length) changes.push({ index: i, tagMatch: tag, edits });
else unchanged.push({ index: i, tagMatch: tag, reason: 'nothing to change (already at target / from-mode mismatch / field absent)' });
let removedPonFloodId;
if (targetMode === 'auto') {
removedPonFloodId = n['PON FLOOD ID'];
delete n['PON FLOOD ID'];
} else if (targetMode === 'private') {
n['PON FLOOD ID'] = ponFloodIdValue;
} else {
unchanged.push({ index: i, tagMatch: tag, reason: `unknown target mode "${targetMode}"` });
continue;
}
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
}
const newDoc = { ...oltCfg, 'NNI Networks': newNetworks };
return { newDoc, changes, unchanged };
}
@ -901,7 +791,6 @@ module.exports = {
extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber,
floodModeOfNni,
dhcpModeOfNni,
summarizeOltNniNetworks,
planNniEdit,
planFloodChange,
};

View file

@ -1,67 +0,0 @@
// OLT detail summarizer for the clickable OLT info card. Pure — shared by the
// Electron main process and the web server. Combines OLT-CFG (identity) +
// OLT-STATE (traffic / temperature / ONU counts / laser) + alarm-histories
// (alarms + upstream-switch LLDP).
const { summarizeAlarms } = require('./onudetail');
function num(v) {
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
}
function summarizeOltDetail({ oltCfg = null, oltState = null, alarmsDoc = null } = {}) {
const cOlt = oltCfg?.OLT || {};
const sOlt = oltState?.OLT || {};
const pon = oltState?.STATS?.['OLT-PON'] || {};
const optr = cOlt['FW Bank Ptr'];
const trafficSeen = pon['TX BW Ethernet Rate bps'] != null || pon['RX BW Ethernet Rate bps'] != null;
const traffic = trafficSeen ? {
downBps: num(pon['TX BW Ethernet Rate bps']) || 0, // OLT TX = downstream
upBps: num(pon['RX BW Ethernet Rate bps']) || 0, // OLT RX = upstream
txUtil: num(pon['TX BW Total Util']),
rxUtil: num(pon['RX BW Total Util']),
} : null;
const onuCounts = {
total: num(pon['Total ONUs Count']),
online: num(pon['Online ONUs Count']),
offline: num(pon['Offline ONUs Count']),
};
const laserStr = sOlt['Laser Shutdown'];
const laser = (typeof laserStr === 'string')
? { value: laserStr, off: laserStr.toLowerCase() !== 'laser on' }
: null;
const s = alarmsDoc?.Switch;
const sw = s ? {
chassisId: s['Chassis ID'] || '', portId: s['Port ID'] || '', portDesc: s['Port Description'] || '',
systemName: s['System Name'] || '', systemDesc: s['System Description'] || '',
ipv4: s['IPv4 Address'] || '', ipv6: s['IPv6 Address'] || '',
} : null;
const { alarms, alarmActiveCount } = summarizeAlarms(alarmsDoc?.Alarms);
return {
mac: oltCfg?._id || oltState?._id || alarmsDoc?._id || '',
name: cOlt.Name || sOlt.Name || '',
model: cOlt.Model || sOlt.Model || '',
fwVersion: (Array.isArray(cOlt['FW Bank Versions']) && typeof optr === 'number') ? (cOlt['FW Bank Versions'][optr] || '') : (sOlt['FW Version'] || ''),
ponMode: cOlt['PON Mode'] || sOlt['PON Mode'] || '',
location: cOlt.Location || '',
serialNumber: sOlt['Serial Number'] || '',
onlineTime: sOlt['Online Time'] || '',
traffic,
tempC: num(oltState?.STATS?.['OLT-TEMP']?.ASIC),
onuCounts,
laser,
sw,
alarms,
alarmActiveCount,
};
}
module.exports = { summarizeOltDetail };

View file

@ -1,141 +0,0 @@
// Rich per-ONU detail summarizer for the ONU info page. Pure — no Electron/
// HTTP — shared by the Electron main process and the web server. Reshapes the
// raw ONU-CFG + alarm-histories + stats time-series + parent OLT-CFG into the
// fields the detail cards render.
function num(v) {
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
}
// Normalize an alarm-history "Alarms" array (shared by ONU and OLT detail):
// active-first, then severity rank ("N-LABEL"), then most-recently-raised.
function summarizeAlarms(rawAlarms) {
const list = (Array.isArray(rawAlarms) ? rawAlarms : []).map((a) => {
const sev = String(a.Severity || '');
const dash = sev.indexOf('-');
const rank = dash > 0 ? Number(sev.slice(0, dash)) : NaN;
return {
id: a['Alarm ID'],
text: a.Text || a['Alarm Type'] || '',
type: a['Alarm Type'] || '',
objectType: a['Object Type'] || '',
objectInstance: a['Object Instance'] || '',
source: a.Source || '',
severity: sev,
sevRank: Number.isFinite(rank) ? rank : 99,
sevLabel: dash > 0 ? sev.slice(dash + 1) : sev,
active: !!a['Active State'],
raisedCount: a['Raised Count'] || 0,
firstRaised: a['Time First Raised'] || '',
lastRaised: a['Time Last Raised'] || '',
lastCleared: a['Time Last Cleared'] || '',
};
}).sort((x, y) =>
(Number(y.active) - Number(x.active))
|| (x.sevRank - y.sevRank)
|| String(y.lastRaised).localeCompare(String(x.lastRaised)));
return { alarms: list, alarmActiveCount: list.filter((a) => a.active).length };
}
/**
* @param {object} a
* @param {object} [a.cfg] ONU-CFG doc (firmware banks, UNI-ETH, )
* @param {object} [a.alarmsDoc] /onus/alarm-histories/<id>/ data (Alarms[], OLT, Switch)
* @param {object[]} [a.statsSeries] /onus/stats/<id>/ samples (time-series)
* @param {object} [a.oltCfg] parent OLT-CFG (name/model/fw/pon)
* @param {number} [a.windowMin] the stats window, for display
*/
function summarizeOnuDetail({ cfg = null, alarmsDoc = null, statsSeries = [], oltCfg = null, windowMin = 60 } = {}) {
const onu = cfg?.ONU || {};
// --- Firmware banks ---
const versions = Array.isArray(onu['FW Bank Versions']) ? onu['FW Bank Versions'] : [];
const files = Array.isArray(onu['FW Bank Files']) ? onu['FW Bank Files'] : [];
const ptr = typeof onu['FW Bank Ptr'] === 'number' ? onu['FW Bank Ptr'] : null;
const banks = versions.map((v, i) => ({ slot: i, version: v || '', file: files[i] || '', active: i === ptr }));
const other = ptr === 0 ? 1 : 0;
const firmware = {
ptr,
activeVersion: (ptr != null && versions[ptr]) ? versions[ptr] : '',
inactiveVersion: (ptr != null && versions[other] != null) ? (versions[other] || '') : '',
banks,
};
// --- UNI ports ---
const uni = Object.keys(cfg || {}).filter((k) => /^UNI-ETH /.test(k)).sort().map((k) => {
const u = cfg[k] || {};
return { name: k, enable: !!u.Enable, speed: u.Speed || '', duplex: u.Duplex || '' };
});
const pots = Object.keys(cfg || {}).filter((k) => /^UNI-POTS /.test(k)).sort()
.map((k) => ({ name: k, enable: !!(cfg[k] && cfg[k].Enable) }));
// --- Alarms ---
const { alarms, alarmActiveCount } = summarizeAlarms(alarmsDoc?.Alarms);
// --- Ethernet UNI loss-of-signal (last disconnect) ---
const los = alarms
.filter((a) => /LAN-?LOS/i.test(a.type) || /LAN-?LOS/i.test(a.text) || a.objectType === 'PptpEthernetUni')
.sort((x, y) => String(y.lastRaised).localeCompare(String(x.lastRaised)))[0];
const ethLos = los ? {
instance: los.objectInstance, active: los.active,
lastRaised: los.lastRaised, lastCleared: los.lastCleared, raisedCount: los.raisedCount,
} : null;
// --- Parent OLT ---
const oltMac = alarmsDoc?.OLT?.['MAC Address']
|| (Array.isArray(cfg?.OLT?.['MAC Address']) ? '' : (cfg?.OLT?.['MAC Address'] || ''));
let olt = null;
if (oltCfg) {
const o = oltCfg.OLT || {};
const optr = o['FW Bank Ptr'];
olt = {
mac: oltCfg._id || oltMac,
name: o.Name || '',
model: o.Model || '',
fwVersion: (Array.isArray(o['FW Bank Versions']) && typeof optr === 'number') ? (o['FW Bank Versions'][optr] || '') : '',
ponMode: o['PON Mode'] || '',
location: o.Location || '',
};
} else if (oltMac) {
olt = { mac: oltMac, name: '', model: '', fwVersion: '', ponMode: '', location: '' };
}
// --- Upstream switch (LLDP neighbour of the OLT NNI) ---
const s = alarmsDoc?.Switch;
const sw = s ? {
chassisId: s['Chassis ID'] || '', portId: s['Port ID'] || '', portDesc: s['Port Description'] || '',
systemName: s['System Name'] || '', systemDesc: s['System Description'] || '',
ipv4: s['IPv4 Address'] || '', ipv6: s['IPv6 Address'] || '',
} : null;
// --- Traffic time-series (samples that carry a per-service rate) ---
const series = [];
for (const smp of (Array.isArray(statsSeries) ? statsSeries : [])) {
const svc = smp['OLT-PON Service 0'];
if (!svc || typeof svc['RX Rate bps'] !== 'number') continue;
const onuPon = smp['ONU-PON'] || {};
series.push({
t: smp.Time || smp._id || '',
downBps: num(svc['TX Rate bps']) || 0, // OLT TX = downstream to the ONU
upBps: num(svc['RX Rate bps']) || 0, // OLT RX = upstream from the ONU
rxOpt: num(onuPon['RX Optical Level']),
txOpt: num(onuPon['TX Optical Level']),
});
}
series.sort((a, b) => String(a.t).localeCompare(String(b.t)));
const current = series.length ? series[series.length - 1] : null;
const peak = series.reduce((m, x) => ({
downBps: Math.max(m.downBps, x.downBps || 0),
upBps: Math.max(m.upBps, x.upBps || 0),
}), { downBps: 0, upBps: 0 });
return {
onuId: cfg?._id || alarmsDoc?._id || '',
firmware, uni, pots, alarms, alarmActiveCount, ethLos, olt, sw,
traffic: { current, peak, series, windowMin },
};
}
module.exports = { summarizeOnuDetail, summarizeAlarms };

View file

@ -1,118 +0,0 @@
// MAC OUI -> vendor lookup, backed by the IEEE registry. Pure Node (https +
// fs), no Electron. Shared by both apps.
//
// The full list (~5 MB CSV) is fetched lazily on first lookup, parsed into a
// prefix->org map, cached in memory for ~30 days, and best-effort persisted to
// a cache file so restarts within the month don't refetch. Every failure path
// degrades to null (callers show "unknown vendor") — a missing OUI list must
// never break the page.
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // ~1 month
const OUI_URL = process.env.PFW_OUI_URL || 'https://standards-oui.ieee.org/oui/oui.csv';
const CACHE_FILE = path.join(process.env.PFW_CACHE_DIR || os.tmpdir(), 'pon-oui-cache.json');
let memo = null; // { map: Map<hex6, org>, at: ms }
let loading = null; // single-flight promise
function normalizeMac(mac) {
return String(mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
}
// Split one CSV line, honouring quoted fields (org names contain commas).
function splitCsvLine(line) {
const out = [];
let cur = '';
let q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur);
return out;
}
// IEEE oui.csv columns: Registry,Assignment,Organization Name,Organization Address
function parseOuiCsv(text) {
const map = new Map();
for (const line of String(text).split(/\r?\n/)) {
if (!line || line.startsWith('Registry,')) continue;
const cols = splitCsvLine(line);
if (cols.length < 3) continue;
const hex = String(cols[1] || '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
if (hex.length !== 6) continue;
const org = String(cols[2] || '').trim();
if (org) map.set(hex, org);
}
return map;
}
function fetchText(url, redirectsLeft = 4) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'pon-fleet-upgrader/oui' }, timeout: 30000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
res.resume();
const next = new URL(res.headers.location, url).toString();
resolve(fetchText(next, redirectsLeft - 1));
return;
}
if (res.statusCode !== 200) { res.resume(); reject(new Error(`OUI HTTP ${res.statusCode}`)); return; }
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('OUI fetch timeout')));
});
}
function readDiskCache() {
try {
const obj = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
if (!obj || !obj.at || !Array.isArray(obj.entries)) return null;
if (Date.now() - obj.at >= TTL_MS) return null;
return { map: new Map(obj.entries), at: obj.at };
} catch (_) { return null; }
}
function writeDiskCache(map, at) {
try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ at, entries: [...map] }), 'utf8'); }
catch (_) { /* best-effort; e.g. read-only fs */ }
}
async function ensureLoaded() {
if (memo && Date.now() - memo.at < TTL_MS) return memo.map;
if (loading) return loading;
loading = (async () => {
const disk = readDiskCache();
if (disk) { memo = disk; return disk.map; }
const text = await fetchText(OUI_URL);
const map = parseOuiCsv(text);
if (map.size === 0) throw new Error('OUI list parsed empty');
memo = { map, at: Date.now() };
writeDiskCache(map, memo.at);
return map;
})().finally(() => { loading = null; });
return loading;
}
/** Resolve a MAC to its vendor org name, or null if unknown / list unavailable. */
async function lookupOui(mac) {
const hex = normalizeMac(mac);
if (hex.length < 6) return null;
try {
const map = await ensureLoaded();
return map.get(hex.slice(0, 6)) || null;
} catch (_) { return null; }
}
module.exports = { lookupOui, normalizeMac, parseOuiCsv, splitCsvLine };

View file

@ -1,163 +0,0 @@
# 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 — `0.0.0.0` if the proxy is on another host |
| `PFW_MCMS_URL` | — | prefill the login "Host URL" field (still editable) |
| `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) |
## Caching (fewer MCMS requests)
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:
- 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):
- 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/`: `install.sh` (one-shot setup), `update.sh`, a systemd
unit, and an env template.
**Where to clone:** anywhere — `install.sh` points the service at wherever it
finds itself. `/opt/ponfw` is recommended: it's the FHS spot for this kind of
software, and the hardened unit sets `ProtectHome=true`, which would block the
service from reading a checkout under `/home`.
```bash
# --- as root on the LXC, once ---
git clone ssh://forgejo@int.git.vanvikinternet.no/Svorka/ponfw.git /opt/ponfw
sudo /opt/ponfw/web/deploy/install.sh
$EDITOR /etc/pon-fleet-web.env # optional: HOST / PFW_SECURE_COOKIE / TTL
sudo systemctl restart pon-fleet-web # if you edited the env
```
`install.sh` is idempotent and:
- installs `git` / `nodejs` / `npm` if missing (and checks Node ≥ 18),
- creates the unprivileged `ponfw` system user,
- runs `npm install --omit=dev` (tough-cookie only; skips Electron),
- writes `/etc/pon-fleet-web.env` from the template on first run,
- installs the systemd unit with `WorkingDirectory` set to this checkout,
then enables + starts it,
- prints the LXC IP and the NPM forward target.
The checkout is root-owned and read-only to the service; the service runs as
`ponfw` and writes nothing to disk.
```bash
journalctl -u pon-fleet-web -f # logs
sudo /opt/ponfw/web/deploy/update.sh # update: 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
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.
- TLS to MCMS is **always verified** — MCMS must present a valid certificate
(there is no accept-self-signed option). If your MCMS uses an internal CA,
install that CA on the host running the server.
- 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.

View file

@ -1,85 +0,0 @@
// 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;
}
/** Age (ms) of the cached entry for `key`, or null if not cached. */
ageOf(key) {
const e = this.entries.get(key);
return e ? Date.now() - e.at : null;
}
/** 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 };

View file

@ -1,96 +0,0 @@
#!/usr/bin/env bash
# One-shot setup for the PON Fleet web server on a Debian 13 LXC.
#
# Clone the repo first (anywhere — /opt/ponfw recommended), then run this
# from inside the checkout as root:
#
# git clone ssh://forgejo@int.git.vanvikinternet.no/Svorka/ponfw.git /opt/ponfw
# sudo /opt/ponfw/web/deploy/install.sh
#
# It installs runtime deps (tough-cookie only; skips Electron), creates an
# unprivileged service user, writes /etc/pon-fleet-web.env on first run, and
# installs + enables a systemd unit pointed at THIS checkout. Safe to re-run.
set -euo pipefail
SERVICE="pon-fleet-web"
SERVICE_USER="ponfw"
ENV_FILE="/etc/pon-fleet-web.env"
# Repo root is two levels up from this script (web/deploy/install.sh).
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
DEPLOY="$REPO/web/deploy"
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
echo "==> Repo checkout: $REPO"
case "$REPO" in
/home/*)
echo "!! WARNING: the repo is under /home. The hardened systemd unit sets" >&2
echo "!! ProtectHome=true, which will stop the service from reading it." >&2
echo "!! Clone to /opt/ponfw instead, or remove ProtectHome from the unit." >&2
;;
esac
# --- dependencies (git, node, npm) ---
need=()
command -v git >/dev/null 2>&1 || need+=(git)
command -v node >/dev/null 2>&1 || need+=(nodejs)
command -v npm >/dev/null 2>&1 || need+=(npm)
if (( ${#need[@]} )); then
echo "==> apt-get install ${need[*]}"
apt-get update -y
apt-get install -y "${need[@]}"
fi
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
if (( NODE_MAJOR < 18 )); then
echo "!! Node $(node -v 2>/dev/null || echo '?') is too old (need >= 18)." >&2
echo "!! Install a newer Node (e.g. via NodeSource) and re-run." >&2
exit 1
fi
echo "==> Using $(node -v)"
# --- service user ---
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
echo "==> Creating system user $SERVICE_USER"
adduser --system --group --no-create-home "$SERVICE_USER"
fi
# --- runtime deps (npm install, not ci: package-lock.json is gitignored) ---
echo "==> Installing runtime dependencies"
( cd "$REPO" && npm install --omit=dev --no-audit --no-fund )
# --- env file (first run only; never clobber operator edits) ---
if [[ -f "$ENV_FILE" ]]; then
echo "==> $ENV_FILE already exists — leaving it untouched"
else
echo "==> Writing $ENV_FILE from the example (review it)"
install -m 0640 "$DEPLOY/pon-fleet-web.env.example" "$ENV_FILE"
fi
# --- systemd unit, with WorkingDirectory pointed at THIS checkout ---
echo "==> Installing systemd unit"
sed "s|^WorkingDirectory=.*|WorkingDirectory=$REPO/web|" \
"$DEPLOY/$SERVICE.service" > "/etc/systemd/system/$SERVICE.service"
systemctl daemon-reload
systemctl enable --now "$SERVICE"
sleep 1
systemctl --no-pager --lines=10 status "$SERVICE" || true
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<MSG
==> Done — $SERVICE is enabled and running.
Config: $ENV_FILE Logs: journalctl -u $SERVICE -f
Update: sudo $DEPLOY/update.sh
Point Nginx Proxy Manager at this LXC:
Forward Hostname/IP: ${IP:-<this-lxc-ip>} Forward Port: 8080 Scheme: http
Add your Access List, request an SSL cert, enable Block Common Exploits.
(If NPM runs on a different host, set HOST=0.0.0.0 in $ENV_FILE and
restart: systemctl restart $SERVICE)
MSG

View file

@ -1,34 +0,0 @@
# 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.
# Prefill the login "Host URL" field so operators don't type it each time.
# (Still editable in the form.) Use the MCMS PON Manager root URL.
#PFW_MCMS_URL=https://mcms.example.net
# 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

View file

@ -1,35 +0,0 @@
[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

View file

@ -1,22 +0,0 @@
#!/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."

View file

@ -1,12 +0,0 @@
{
"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"
}

View file

@ -1,193 +0,0 @@
// 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),
getOnuCpe: (o) => postJSON('getOnuCpe', o),
getOnuDetail: (o) => postJSON('getOnuDetail', o),
getOltDetail: (o) => postJSON('getOltDetail', 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'),
};
})();

View file

@ -1,53 +0,0 @@
/* 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; }
}

View file

@ -1,603 +0,0 @@
// 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 MCMS_URL = process.env.PFW_MCMS_URL || ''; // prefilled into the login host field
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>');
// Prefill the login host field from PFW_MCMS_URL (server-side default).
if (MCMS_URL) {
const v = MCMS_URL.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/(<input id="in-host"[^>]*?)\s*\/?>/i, `$1 value="${v}" />`);
}
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 } = body || {};
// TLS is always verified — MCMS must present a valid certificate.
const client = new McmsClient({ baseUrl, rejectUnauthorized: true, 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'}`);
if (MCMS_URL) console.log(`[pon-fleet-web] login host prefilled with: ${MCMS_URL}`);
console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx/NPM) in front for remote use.');
});