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 ## 1. What this app does
Electron desktop client for operating a **Ciena MCMS 6.2** PON Manager Electron desktop client for two bulk operations against **Ciena
fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between: MCMS 6.2** PON Manager:
- **Dashboard** — read-only network telemetry (see §15). 1. **Bulk ONU firmware upgrades** (the original purpose).
- **ONU** — two sub-tabs: an **Info / stats** page (per-ONU optical/FEC plus 2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
the CPE router's DHCPv4/v6 lease health, §17) and the **Fleet & upgrade** see §14).
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.
Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest
(EV051xxR / EV052xxR firmware family) behind Tibit OLTs. (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:listFleet` | ONU-CFG + ONU-STATE + OLT-STATE merged | — |
| `api:fetchStates` | per-ONU state fallback if bulk failed | `states:progress` | | `api:fetchStates` | per-ONU state fallback if bulk failed | `states:progress` |
| `api:listOnuConfigs` / `api:getOnuConfig` | direct CFG access | — | | `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:listOnuFirmware` / `api:uploadFirmware` | firmware inventory | — |
| `api:planUpgrade` | dry-run plan for selected ONUs | — | | `api:planUpgrade` | dry-run plan for selected ONUs | — |
| `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` | | `api:executePerOnu` | Procedure 7 — per-ONU PUTs | `upgrade:progress` |
| `api:executeBulkTask` | Procedure 8 — single AUTO-TASK-CFG | — | | `api:executeBulkTask` | Procedure 8 — single AUTO-TASK-CFG | — |
| `api:getTaskConfig` / `api:getUpgradeStatus` | poll helpers | — | | `api:getTaskConfig` / `api:getUpgradeStatus` | poll helpers | — |
| `api:deleteOnus` | bulk delete (CFG + best-effort STATE) | `delete:progress` | | `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:listOlts` | OLT-CFG list + per-OLT NNI flood-mode summary | — |
| `api:executeFloodChange` | bulk PON flooding-mode flip (GET→plan→PUT) | `olt-flood:progress` | | `api:executeFloodChange` | bulk PON flooding-mode flip (GET→plan→PUT) | `olt-flood:progress` |
| `api:fetchFecHealth` | pre-flight FEC pre/post + optical | `fec:progress` | | `api:fetchFecHealth` | pre-flight FEC pre/post + optical | `fec:progress` |
@ -143,10 +136,8 @@ stream progress events back to the renderer.
### Fleet view ### Fleet view
- Login with email / password. TLS to MCMS is always verified - Login with email / password, accept-self-signed-TLS toggle (essential
(`rejectUnauthorized: true`); there is no accept-self-signed option — for lab MCMS installs).
MCMS must present a valid certificate. The web server can prefill the host
field from `PFW_MCMS_URL`.
- Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` + - Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` +
`listAllOltStates`, all paginated via the `next` cursor at page size `listAllOltStates`, all paginated via the `next` cursor at page size
100 (see §5). 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 → The pre-flight fetch (`api:fetchFecHealth`) and the verify fetch
(`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via (`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via
`extractOnuHealth`. For *current* FEC/optical, prefer ONU-STATE. `extractOnuHealth`. The time-series stats endpoint is **not used**
anywhere — earlier code that hit it has been removed.
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` accepts both shapes: the bare state doc `extractOnuHealth` accepts both shapes: the bare state doc
(`{STATS: ...}`) and the UI-helper wrapped form (`{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 Second bulk feature, added after the firmware upgrader. Lets an operator
edit OLT NNI services across many OLTs at once. Two concerns, applied flip the PON flooding mode of OLT NNI services across many OLTs at once.
together in one GET→edit→PUT per OLT:
### 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 On Tibit OLTs, a service's flooding mode is encoded by the **presence or
empirically from operator paste-backs of `s0.c76.c0`: absence of one key** on its `OLT-CFG["NNI Networks"][i]` entry:
1. **Flooding mode** — the **presence/absence of the `PON FLOOD ID` key**: - **`PON FLOOD ID` key present** (any value, including `0`) → **private**
present (any value, incl. `0`) → **private**; absent → **auto**. So - **`PON FLOOD ID` key absent** → **auto**
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.
Everything else on the entry and the rest of the OLT-CFG is preserved. So private→auto is `delete entry['PON FLOOD ID']`; auto→private is
Default actions: TAG MATCH `s0.c76.c0`, flood `private→auto`, DHCPv4/v6 `entry['PON FLOOD ID'] = 0`. Everything else on the entry (`TAG MATCH`,
`pass→umt`. All directions are wired, so changes roll back from the same UI. `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 ### Data flow
``` ```
listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure) listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure)
executeFloodChange ─► per-OLT: getOltConfig ─► planNniEdit (pure, no-mutate) executeFloodChange ─► per-OLT: getOltConfig ─► planFloodChange (pure, no-mutate)
(channel name kept for wire compat) ─► putOltConfig (full-doc PUT) ─► putOltConfig (full-doc PUT)
``` ```
- **Pure helpers** at the bottom of `src/mcms-api.js`: `floodModeOfNni`, - **Pure helpers** live at the bottom of `src/mcms-api.js`:
`dhcpModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`, `floodModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
`planNniEdit`. `planNniEdit(oltCfg, { tagPattern, flood, dhcpv4, dhcpv6 })` `planFloodChange`. `planFloodChange` clones the `NNI Networks` array and
clones the `NNI Networks` array (and any edited `Filter`) and never mutates never mutates its input — it returns `{ newDoc, changes, unchanged }`.
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.
- **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and - **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and
`*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars `*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars
then turns `*` into `.*`. then turns `*` into `.*`.
- **`from` guards**: only entries currently on the `from` mode/value are - **`fromMode` guard**: only entries currently in `fromMode` are flipped;
flipped; entries already at target are left in `unchanged` (the per-OLT entries already in the target mode are recorded in `unchanged` (the
`noop` case), so re-running is idempotent. per-OLT `noop` case), so re-running is idempotent.
- **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state - **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state
fetch) — OLT writes carry far more blast radius. fetch) — OLT writes carry far more blast radius.
- **PUT-as-replace**, same gotcha as ONU-CFG (§5.7): the handler GETs the - **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.* *Last updated: 2026-06-23.*

View file

@ -1,18 +1,12 @@
# PON Fleet Upgrader # PON Fleet Upgrader
An Electron desktop app for operating a **Ciena MicroClimate Management An Electron desktop app for bulk operations against a **Ciena
System (MCMS) 6.2** PON Manager. Top tabs split it into: MicroClimate Management System (MCMS) 6.2** PON Manager. It does two
things:
1. **Dashboard** — read-only network telemetry: ONU/OLT/controller counts, 1. **Bulk ONU firmware upgrades** (below).
aggregate up/down traffic, health (abnormal Rx, lasers off), and ONU 2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
registration-state breakdown (with an opt-in per-ONU Rx/FEC scan). see "OLT PON flooding mode" below).
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.
## ONU firmware upgrades ## ONU firmware upgrades
@ -107,36 +101,27 @@ pon-fleet-upgrader/
└── app.js # UI logic └── app.js # UI logic
``` ```
## OLT NNI-service edits ## OLT PON flooding mode
From the fleet view, **Open OLT inspector…** opens a second workflow for From the fleet view, **Open OLT inspector…** opens a second workflow for
bulk-checking and editing OLT NNI services. Two independent edits can be bulk-checking and changing the PON flooding mode of OLT NNI services.
applied together to every matching service:
**Flooding mode** — encoded by the presence of a single key on each On Tibit OLTs the flooding mode is encoded by the presence of a single key
`OLT-CFG["NNI Networks"]` entry: on each `OLT-CFG["NNI Networks"]` entry:
| Mode | `PON FLOOD ID` key | Change | | Mode | `PON FLOOD ID` key | Change |
|------|--------------------|--------| |------|--------------------|--------|
| **private** | present (any value, incl. `0`) | → auto: delete the key | | **private** | present (any value, incl. `0`) | → auto: delete the key |
| **auto** | absent | → private: set the key to `0` | | **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 `*` 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 glob like `s0.c76.*`), **Load OLTs**, select the OLTs to change, pick a
(Flooding / DHCPv4 / DHCPv6), select the OLTs — each matching service shows direction, then **Execute change…** (two-step confirm). Each OLT is
"→ will change (…)" for the edits that apply — then **Execute changes…** re-fetched, the matching NNI entries are flipped, and the full OLT-CFG is
(two-step confirm). Each OLT is re-fetched, the matching NNI entries are PUT back. A result CSV is auto-saved to `~/Downloads/pon-olt-flood-*.csv`.
edited in one pass, and the full OLT-CFG is PUT back. A result CSV is
auto-saved to `~/Downloads/pon-olt-nni-*.csv`.
All directions are supported (e.g. `umt → pass`), so changes roll back from Both directions are supported, so a change can be rolled back from the same
the same screen. Defaults: `s0.c76.c0`, flood `private → auto`, DHCPv4/v6 screen. The default action targets `s0.c76.c0`, `private``auto`.
`pass → umt`.
## Known limitations ## Known limitations

110
main.js
View file

@ -10,13 +10,8 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { const {
McmsClient, McmsApiError, extractOnuHealth, McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planNniEdit, summarizeOltNniNetworks, planFloodChange,
} = require('./src/mcms-api'); } = 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'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */ /** @type {McmsClient | null} */
@ -63,12 +58,11 @@ function toWireError(err) {
return { message: err?.message || String(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 { try {
client = new McmsClient({ client = new McmsClient({
baseUrl, baseUrl,
// TLS is always verified — MCMS must present a valid certificate. rejectUnauthorized: !acceptSelfSigned,
rejectUnauthorized: true,
// Verbose logging goes to the Electron terminal (npm start output). // Verbose logging goes to the Electron terminal (npm start output).
// Turn off by setting PON_FLEET_VERBOSE=0 in the env. // Turn off by setting PON_FLEET_VERBOSE=0 in the env.
verbose: process.env.PON_FLEET_VERBOSE !== '0', 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 () => { ipcMain.handle('api:listOnuFirmware', async () => {
try { try {
const c = requireClient(); 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) * Apply a planned flooding mode change to a list of OLT IDs. For each
* to a list of OLT IDs. For each OLT we GET the current CFG (to avoid * OLT we GET the current CFG (to avoid stomping concurrent edits),
* stomping concurrent edits), run planNniEdit, and PUT the result back. * run planFloodChange, and PUT the result back. Streams progress.
* Streams progress. (Channel name kept as executeFloodChange for wire
* compatibility.)
* *
* Concurrency is kept low (default 3) OLT writes carry a much larger * Concurrency is kept low (default 3) OLT writes carry a much larger
* blast radius than ONU reads, so we deliberately stay below the ONU * blast radius than ONU reads, so we deliberately stay below the ONU
@ -734,7 +665,8 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try { try {
const c = requireClient(); const c = requireClient();
const { const {
oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null, oltIds, tagPattern, fromMode = 'private',
targetMode = 'auto', ponFloodIdValue = 0,
concurrency = 3, concurrency = 3,
} = opts || {}; } = opts || {};
const queue = [...oltIds]; const queue = [...oltIds];
@ -750,7 +682,9 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try { try {
const cfg = await c.getOltConfig(id); const cfg = await c.getOltConfig(id);
if (!cfg) throw new Error('OLT not found'); 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) { if (plan.changes.length === 0) {
entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
} else { } else {
@ -772,25 +706,3 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
return { ok: false, error: toWireError(err) }; 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), listFleet: (opts) => call('api:listFleet', opts),
fetchStates: (opts) => call('api:fetchStates', opts), fetchStates: (opts) => call('api:fetchStates', opts),
getOnuConfig: (opts) => call('api:getOnuConfig', 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'), listOnuFirmware: () => call('api:listOnuFirmware'),
uploadFirmware: () => call('api:uploadFirmware'), uploadFirmware: () => call('api:uploadFirmware'),
@ -29,8 +26,6 @@ contextBridge.exposeInMainWorld('api', {
deleteOnus: (opts) => call('api:deleteOnus', opts), deleteOnus: (opts) => call('api:deleteOnus', opts),
dashboardStats: (opts) => call('api:dashboardStats', opts),
listOlts: (opts) => call('api:listOlts', opts), listOlts: (opts) => call('api:listOlts', opts),
executeFloodChange: (opts) => call('api:executeFloodChange', opts), executeFloodChange: (opts) => call('api:executeFloodChange', opts),

View file

@ -1,41 +1,21 @@
/* Neon / terminal "hackertyper" theme electric-blue variant. /* Simple, dense, operator-oriented UI.
Dense, operator-oriented UI no framework, hand-tuned CSS. No framework just hand-tuned CSS. */
Class names are load-bearing (HTML + renderer/app.js reference them);
this file only restyles, it doesn't rename anything. */
:root { :root {
/* Blue-black base, like a cold CRT. */ --bg: #0e1116;
--bg: #02050c; --bg-elev: #161b22;
--bg-elev: #060c16; --bg-elev-2: #1f2630;
--bg-elev-2: #0b1422; --fg: #e7edf3;
--fg-dim: #8a94a3;
/* Blue-white text. */ --border: #2a313c;
--fg: #d6ecff; --accent: #3ea6ff;
--fg-dim: #6f90b5; --accent-hover: #65baff;
--border: #163457; --ok: #3fb950;
--warn: #d29922;
/* Neon accents — blue-forward. */ --danger: #f85149;
--neon: #21c8ff; /* primary — electric blue/cyan */ --danger-hover: #ff6a62;
--neon-soft: #7ce0ff; --info: #58a6ff;
--cyan: #46e6ff; --mono: "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
--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);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -46,70 +26,16 @@ html, body {
height: 100%; height: 100%;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
/* Monospace everywhere — terminal feel. */ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: var(--mono);
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.45;
} }
/* Ambient neon vignette behind everything. */ code { font-family: var(--mono); background: var(--bg-elev-2); padding: 1px 4px; border-radius: 3px; font-size: 12px; }
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);
}
.muted { color: var(--fg-dim); } .muted { color: var(--fg-dim); }
.small { font-size: 12px; } .small { font-size: 12px; }
.hidden { display: none !important; } .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; } .row { display: flex; gap: 8px; align-items: center; }
.inline { display: inline-flex; align-items: center; gap: 6px; } .inline { display: inline-flex; align-items: center; gap: 6px; }
@ -118,41 +44,26 @@ code {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 10px 16px; padding: 10px 16px;
background: linear-gradient(180deg, rgba(33, 200, 255, 0.08), transparent), var(--bg-elev); background: var(--bg-elev);
border-bottom: 1px solid var(--neon); border-bottom: 1px solid var(--border);
box-shadow: 0 1px 0 rgba(33, 200, 255, 0.3), 0 8px 24px -10px rgba(33, 200, 255, 0.5);
} }
.topbar .brand { .topbar .brand { font-weight: 600; letter-spacing: 0.3px; }
font-weight: 700; .topbar .session { margin-left: auto; color: var(--fg-dim); font-family: var(--mono); font-size: 12px; }
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); }
.view { display: flex; gap: 0; height: calc(100vh - 45px); } .view { display: flex; gap: 0; height: calc(100vh - 45px); }
#view-login { justify-content: center; align-items: flex-start; padding-top: 60px; } #view-login { justify-content: center; align-items: flex-start; padding-top: 60px; }
.card { .card {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--neon); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 24px; padding: 24px;
width: 440px; width: 440px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; 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; } .card p { margin: 0 0 8px; }
label { label {
@ -161,10 +72,8 @@ label {
gap: 4px; gap: 4px;
color: var(--fg-dim); color: var(--fg-dim);
font-size: 12px; 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="text"], input[type="password"], input[type="url"],
input[type="number"], input[type="search"], input[type="datetime-local"], input[type="number"], input[type="search"], input[type="datetime-local"],
@ -175,89 +84,40 @@ select {
padding: 7px 9px; padding: 7px 9px;
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
font-family: var(--mono); font-family: inherit;
outline: none; outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
} }
input::placeholder { color: #43607f; } input:focus, select:focus { border-color: var(--accent); }
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); }
button { button {
font-family: var(--mono); font-family: inherit;
font-size: 13px; font-size: 13px;
letter-spacing: 0.5px;
padding: 7px 14px; padding: 7px 14px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-elev-2); background: var(--bg-elev-2);
color: var(--fg); color: var(--fg);
cursor: pointer; 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 { background: transparent; }
button.ghost:hover:not(:disabled) { border-color: var(--cyan); color: var(--cyan); box-shadow: var(--glow-cyan); } 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); }
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;
}
.panel-left { .panel-left {
width: 320px; width: 320px;
padding: 16px; padding: 16px;
border-right: 1px solid var(--border); 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; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.panel-left h2 { .panel-left h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--fg-dim); margin: 12px 0 0; }
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:first-child { margin-top: 0; } .panel-left h2:first-child { margin-top: 0; }
.panel-main { .panel-main {
@ -268,27 +128,23 @@ button.danger:hover:not(:disabled) {
gap: 12px; gap: 12px;
overflow: hidden; 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 { 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 { .selected-summary {
background: var(--bg-elev-2); background: var(--bg-elev-2);
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.selected-summary strong { color: var(--neon); text-shadow: var(--glow-neon); }
.fleet-table-wrap { .fleet-table-wrap {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 0 30px rgba(33, 200, 255, 0.05);
} }
.fleet-table { .fleet-table {
width: 100%; width: 100%;
@ -301,12 +157,8 @@ button.danger:hover:not(:disabled) {
background: var(--bg-elev); background: var(--bg-elev);
text-align: left; text-align: left;
padding: 8px 10px; padding: 8px 10px;
border-bottom: 1px solid var(--neon); border-bottom: 1px solid var(--border);
color: var(--neon); font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
text-shadow: var(--glow-neon);
z-index: 1; z-index: 1;
} }
.fleet-table tbody td { .fleet-table tbody td {
@ -319,16 +171,13 @@ button.danger:hover:not(:disabled) {
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 320px; max-width: 320px;
} }
.fleet-table tbody tr:hover { background: rgba(33, 200, 255, 0.08); } .fleet-table tbody tr:hover { background: var(--bg-elev-2); }
.fleet-table tbody tr.selected { .fleet-table tbody tr.selected { background: rgba(62, 166, 255, 0.12); }
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);
}
.status-ok { color: var(--ok); text-shadow: 0 0 7px rgba(43, 255, 158, 0.6); } .status-ok { color: var(--ok); }
.status-warn { color: var(--warn); text-shadow: 0 0 7px rgba(255, 196, 77, 0.55); } .status-warn { color: var(--warn); }
.status-err { color: var(--danger); text-shadow: var(--glow-danger); } .status-err { color: var(--danger); }
.status-info { color: var(--info); text-shadow: var(--glow-cyan); } .status-info { color: var(--info); }
.status-pending { color: var(--fg-dim); } .status-pending { color: var(--fg-dim); }
/* Compact FEC counter readout under the health pill in the preview /* 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 /* Verify-view table cells contain stacked before/after blocks; let
them wrap and align to the top so before/after lines line up. */ 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 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, #verify-table .after-line { display: block; }
#verify-table .before-line { color: var(--fg-dim); font-size: 11px; } #verify-table .before-line { color: var(--fg-dim); font-size: 11px; }
.fec-pill { font-weight: 700; } .fec-pill { font-weight: 600; }
.fec-counters { .fec-counters {
display: block; display: block;
margin-top: 3px; margin-top: 3px;
@ -360,332 +209,5 @@ button.danger:hover:not(:disabled) {
padding: 1px 6px; padding: 1px 6px;
border-radius: 3px; border-radius: 3px;
background: var(--bg-elev-2); 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); 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> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">PON Fleet Upgrader</div> <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> <div class="session" id="session-label">Not connected</div>
<button id="btn-logout" class="ghost hidden">Disconnect</button> <button id="btn-logout" class="ghost hidden">Disconnect</button>
</header> </header>
@ -29,6 +24,10 @@
<label>Host URL<input id="in-host" type="url" placeholder="https://mcms.example.net" /></label> <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>Email / username<input id="in-user" type="text" autocomplete="username" /></label>
<label>Password<input id="in-pass" type="password" autocomplete="current-password" /></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"> <div class="row">
<button id="btn-login" class="primary">Connect</button> <button id="btn-login" class="primary">Connect</button>
<span id="login-error" class="error"></span> <span id="login-error" class="error"></span>
@ -36,81 +35,9 @@
</div> </div>
</section> </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 ================= --> <!-- ================= Fleet view ================= -->
<section id="view-fleet" class="view hidden"> <section id="view-fleet" class="view hidden">
<div class="panel-left"> <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> <h2>Filters</h2>
<label>Name / address contains <label>Name / address contains
<input id="filter-text" type="search" placeholder="e.g. ORKANGER" /> <input id="filter-text" type="search" placeholder="e.g. ORKANGER" />
@ -330,11 +257,11 @@
<label>NNI TAG MATCH pattern <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.*" /> <input id="olt-tag-pattern" type="text" value="s0.c76.c0" placeholder="e.g. s0.c76.c0 or s0.c76.*" />
</label> </label>
<label>Flood-mode filter <label>Current mode
<select id="olt-mode-filter"> <select id="olt-mode-filter">
<option value="any" selected>Any</option> <option value="private" selected>Private (PON FLOOD ID set)</option>
<option value="private">Private (PON FLOOD ID set)</option>
<option value="auto">Auto (no PON FLOOD ID)</option> <option value="auto">Auto (no PON FLOOD ID)</option>
<option value="any">Any</option>
</select> </select>
</label> </label>
<label>OLT name contains <label>OLT name contains
@ -352,29 +279,13 @@
</div> </div>
</div> </div>
<h2>Actions</h2> <h2>Action</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>Change matching services
<label>Flooding mode <select id="olt-action">
<select id="olt-flood-action">
<option value="">No change</option>
<option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option> <option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option>
<option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option> <option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
</select> </select>
</label> </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"> <div class="row">
<button id="btn-olts-back" class="ghost">← Back to fleet</button> <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; 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) ------------------------------ // --- OLTs (for ONU registration status) ------------------------------
/** /**
@ -585,28 +538,6 @@ class McmsClient {
return this._listPaginatedByNext('/v3/olts/states/', { pageSize }); 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) --------------------------- // --- OLT configuration (PON flooding mode) ---------------------------
/** /**
@ -645,17 +576,6 @@ class McmsClient {
return res.body; 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 ---------------------------------------------- // --- Firmware inventory ----------------------------------------------
async listOnuFirmware() { async listOnuFirmware() {
@ -782,17 +702,6 @@ function floodModeOfNni(nniNet) {
: 'auto'; : '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, * Build a uniform summary of every NNI Networks entry on an OLT-CFG,
* filtered by a TAG MATCH pattern. Pattern can be: * filtered by a TAG MATCH pattern. Pattern can be:
@ -813,8 +722,6 @@ function summarizeOltNniNetworks(oltCfg, tagPattern = '') {
tagMatch: tag, tagMatch: tag,
mode: floodModeOfNni(n), mode: floodModeOfNni(n),
ponFloodId: Object.prototype.hasOwnProperty.call(n, 'PON FLOOD ID') ? n['PON FLOOD ID'] : null, 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, 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 * Apply a PON flooding mode change to a CLONE of an OLT-CFG doc. Does
* the input doc is never touched (the matched entries and any edited `Filter` * not mutate the input. Returns { newDoc, changes, unchanged }.
* 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.
* *
* ops: * `targetMode` is 'auto' (delete PON FLOOD ID) or 'private' (set
* tagPattern which NNI entries to touch (exact / "*" / glob) * PON FLOOD ID to ponFloodIdValue, default 0).
* 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.
*/ */
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 networks = Array.isArray(oltCfg?.['NNI Networks']) ? oltCfg['NNI Networks'] : [];
const re = tagPatternToRegex(tagPattern); const re = tagPatternToRegex(tagPattern);
const newNetworks = networks.map((n) => ({ ...n })); const newNetworks = networks.map((n) => ({ ...n }));
const changes = []; const changes = [];
const unchanged = []; const unchanged = [];
for (let i = 0; i < newNetworks.length; i++) { for (let i = 0; i < newNetworks.length; i++) {
const n = newNetworks[i]; const n = newNetworks[i];
const tag = n?.['TAG MATCH'] || ''; const tag = n?.['TAG MATCH'] || '';
if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; } if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; }
const current = floodModeOfNni(n);
const edits = []; if (fromMode && current !== fromMode) {
unchanged.push({ index: i, tagMatch: tag, reason: `already in mode "${current}", not "${fromMode}"` });
// Flood mode — presence of PON FLOOD ID. continue;
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 });
} }
if (current === targetMode) {
unchanged.push({ index: i, tagMatch: tag, reason: `already in target mode "${targetMode}"` });
continue;
} }
let removedPonFloodId;
// DHCP filter modes — Filter.DHCPv4 / Filter.DHCPv6, replace-when-present. if (targetMode === 'auto') {
for (const [op, fieldKey] of [[dhcpv4, 'DHCPv4'], [dhcpv6, 'DHCPv6']]) { removedPonFloodId = n['PON FLOOD ID'];
if (!op || !op.to) continue; delete n['PON FLOOD ID'];
const filter = n.Filter; } else if (targetMode === 'private') {
if (!filter || typeof filter !== 'object' || !Object.prototype.hasOwnProperty.call(filter, fieldKey)) continue; n['PON FLOOD ID'] = ponFloodIdValue;
const cur = filter[fieldKey]; } else {
const okFrom = !op.from || cur === op.from; unchanged.push({ index: i, tagMatch: tag, reason: `unknown target mode "${targetMode}"` });
if (okFrom && cur !== op.to) { continue;
n.Filter = { ...filter, [fieldKey]: op.to }; // clone Filter; never mutate the input
edits.push({ type: fieldKey, from: cur, to: op.to });
} }
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
} }
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)' });
}
const newDoc = { ...oltCfg, 'NNI Networks': newNetworks }; const newDoc = { ...oltCfg, 'NNI Networks': newNetworks };
return { newDoc, changes, unchanged }; return { newDoc, changes, unchanged };
} }
@ -901,7 +791,6 @@ module.exports = {
extractFecHealth, // legacy alias — calls extractOnuHealth extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber, formatFecNumber,
floodModeOfNni, floodModeOfNni,
dhcpModeOfNni,
summarizeOltNniNetworks, 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.');
});