Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
ea251c3498 Remove edge glow; prefill MCMS URL from env; drop self-signed-cert option
- Remove the animated neon edge-glow entirely (HTML element + CSS) — it
  couldn't be made to follow the border reliably on iOS Safari.
- Add PFW_MCMS_URL: the web server prefills the login "Host URL" field with
  it (injected as the input's value in the served index; still editable).
  Documented in env.example + web README.
- Remove the "Accept self-signed TLS certificate" option: the checkbox is
  gone and both backends now always use rejectUnauthorized:true — MCMS must
  present a valid certificate. Dropped the acceptSelfSigned plumbing from the
  renderer and both login handlers.

Docs: CLAUDE.md (drop edge-glow section, update login note) + web README.

Verified: node --check; no stale refs (in-self-signed / acceptSelfSigned /
edge-glow / beamspin); served index carries value="…" only when
PFW_MCMS_URL is set, and no self-signed checkbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:49:54 +02:00
6f4a315947 mobile: fix edge beam (transform-rotate) + ONU master-detail layout
Edge glow: iOS Safari won't animate an @property-driven conic-gradient
angle, so the beam sat static / off the border on mobile. Reworked to the
robust technique: a .beam child masked to the border ring contains an
oversized conic-gradient ::before spun with transform: rotate 0→360deg; the
glow halo is a drop-shadow on the parent (survives the child mask). Works on
iOS/Safari; honors prefers-reduced-motion.

ONU info on narrow screens: was stacking the picker above the detail, so
selecting an ONU meant a long scroll to find it. Now master-detail — show
the picker OR the detail (not both), toggled by .show-detail on
#view-onu-info via a shared @media(max-width:860px) rule; selecting shows the
detail with a "← ONU list" back button, tab/sub-tab nav resets to the
picker. (Earlier commit already de-compacted the list: extra filters +
two-line rows.)

Docs: CLAUDE.md §15 (edge glow technique) + §17 (master-detail).

Verified offscreen at 414px: beam element present; panel-left/​panel-main
display toggles correctly on select/back; back button visible on mobile;
rendered both states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:33:07 +02:00
2046fad0f8 onu detail polish + clickable OLT detail modal
- Fix alarm card overflow: alarm rows stack (severity+text on one line that
  wraps, meta below) instead of squeezing the text to ~0 width next to the
  nowrap meta — which had made long alarm text render one char per line in
  narrow columns. Also min-width:0 on cards/kv so long IPv6/descr strings
  don't blow out the grid track.
- Combine Parent OLT + Upstream switch into one "Parent OLT & uplink" card.
- ONU list/search de-compacted: add equipment/version + PON-mode filters;
  two-line rows (serial+status, then equipment · version · last-seen).
- UNI card labels the speed as configured ("cfg Auto/Auto"); negotiated
  speed isn't in the payloads — link up/down still comes from LAN-LOS.

Clickable parent OLT -> OLT detail modal (showOltModal):
- new src/oltdetail.js (pure, shared; reuses summarizeAlarms) + getOltState
  / getOltAlarms client methods + api:getOltDetail (main + web + bridges).
- shows identity, traffic + util, ASIC temperature, ONU counts, laser,
  alarms, upstream switch; plus a connected-ONU list with per-ONU
  FEC/optical built from the loaded fleet (filtered by _status.oltMac),
  each row clickable back into that ONU's detail.

Docs: CLAUDE.md §17 + IPC table.

Verified: node --check; oltdetail.js unit-tested 9/9 (fw active-bank,
traffic down=TX/up=RX, util, ASIC temp, ONU counts, laser, LLDP, alarms);
rendered the OLT modal + richer list offscreen and confirmed the alarm-text
wrap fix (alarm head ~1-2 lines, not vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:10:34 +02:00
276c3e9faa onu detail: parent OLT + LLDP switch + traffic history + alarms + UNI; single-ONU upgrade
De-compacts the ONU info page into a responsive card grid and enriches it
with one new endpoint (api:getOnuDetail) summarized by the pure, shared
src/onudetail.js:
- Parent OLT (name/model/FW/PON) — follows the OLT MAC from the ONU
  alarm-history doc to its OLT-CFG.
- Upstream switch (LLDP neighbour of the OLT NNI) from the alarm-history
  Switch block: system name, port, IPv4, chassis.
- Traffic (last ~hour) with current + peak down/up and inline SVG
  sparklines, from /onus/stats/ (down = OLT TX rate, up = OLT RX rate).
- UNI-ETH ports (speed/duplex/enable) + last Ethernet LOS (LAN-LOS alarm).
- Alarms: active-first, severity-sorted with raised counts + last raised.
- CPE card folded into the same single fetch.

New client methods getOnuAlarms + getOnuStatsSeries; api:getOnuDetail in
main.js + web/server.js, exposed in both bridges. Not TTL-cached (live).

Single-ONU upgrade: the Firmware card gets a firmware picker + "Upgrade this
ONU" button reusing the bulk per-ONU path (executePerOnu with one id,
Procedure 7 inactive-bank write), behind a typed UPGRADE confirm; refreshes
the detail on success.

Docs: CLAUDE.md §17 + §5.11 (stats endpoint now used for the sparkline) +
IPC table.

Verified: node --check; onudetail.js unit-tested 11/11 against the real
alarm-history / cfg / stats / OLT-cfg fixtures (firmware banks, UNI, alarm
sort, LAN-LOS, OLT active-bank FW, LLDP switch, traffic skip-nontraffic +
down/up mapping + current/peak); card grid rendered offscreen (9 cards, 2
sparklines, upgrade button).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:47:50 +02:00
55e95ed467 onu: info/stats sub-tab with CPE lease health + OUI vendor
Adds an ONU info page and splits the ONU tab into two sub-tabs (Info / stats,
Fleet & upgrade). The ONU tab now lands on Info; campaign/verify keep the
Fleet sub-tab lit.

ONU info page (#view-onu-info): master-detail over the already-loaded fleet
(search + status filter, no new bulk fetch). Per-ONU detail shows identity,
link/optical + FEC (read from the merged ONU-STATE), firmware, and a CPE card.

CPE (customer router):
- McmsClient.getCpe — GET /api/cpe/onu/<id>/ (unversioned, [status,doc] tuple).
- src/cpe.js (pure, shared): summarizeCpe + leaseHealth. Renewal rule —
  <50% of the lease remaining => "renewal overdue" (amber), past end =>
  expired. v4 expiry = DHCP "Remove Time"; v6 spans Last Success ->
  Preferred Lifetime (T1 midpoint), with Prefix Delegation surfaced.
- api:getOnuCpe (main.js + web/server.js, exposed in both bridges).

OUI vendor (src/oui.js, pure, shared): lazy fetch of the IEEE oui.csv,
prefix->org map, cached in memory ~30 days + best-effort to disk
(PFW_CACHE_DIR / temp; PrivateTmp makes /tmp writable). Resolved server-side
in getOnuCpe; every failure degrades to "unknown OUI". CPE itself is not
TTL-cached (lease must be live); only the OUI list is.

Dashboard abnormal Rx/Tx rows are now clickable -> deep-link to that ONU's
info page (showListModal gained per-item onClick).

Docs: CLAUDE.md §17 + IPC table + intro; README.

Verified: node --check; cpe.js/oui.js unit-tested (15/15: lease ok/warn/
expired/unknown + 50% boundary, v6 prefix parse, OUI quoted-CSV parse +
lookup); ONU info page + CPE card rendered offscreen over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:19:28 +02:00
5e3d9271a8 olt: add bulk DHCPv4/DHCPv6 filter edit alongside flood mode
Generalises the OLT bulk tool from flood-mode-only to arbitrary NNI-service
edits. DHCP modes live nested under each NNI entry's Filter object
(Filter.DHCPv4 / Filter.DHCPv6, e.g. "pass" <-> "umt"), confirmed from a
real OLT-CFG paste-back.

- src/mcms-api.js: planFloodChange -> planNniEdit(oltCfg, {tagPattern, flood,
  dhcpv4, dhcpv6}); each concern optional. DHCP is value-replacement,
  replace-only-when-present (never invents the key; leaves EAPOL/PPPoE/NDP).
  Still non-mutating (clones the entry AND the Filter). Returns changes with
  per-entry edits[]. summarizeOltNniNetworks surfaces dhcpv4/dhcpv6; new
  dhcpModeOfNni helper.
- main.js + web/server.js: executeFloodChange handler passes flood/dhcpv4/
  dhcpv6 ops through (channel name kept for wire compat).
- renderer: OLT inspector now has three action dropdowns (Flooding / DHCPv4 /
  DHCPv6, defaults flood private->auto + DHCP pass->umt); table shows DHCP
  chips + per-service "will change (flood, DHCPv4, …)"; flood-mode filter
  defaults to Any so DHCP-on-pass auto-flood OLTs still show; CSV report
  lists every per-NNI edit (pon-olt-nni-*.csv).

Verified: node --check; planNniEdit unit-tested against the real fixture +
a pass variant (13/13: no-mutation, EAPOL/PPPoE/NDP preserved,
replace-when-present, flood/DHCP independence); OLT view rendered offscreen
showing correct per-service "will change" markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:57:37 +02:00
de037c8795 web/deploy: add one-shot install.sh
Adds web/deploy/install.sh — full LXC setup in one command. It derives the
repo path from its own location (clone anywhere; /opt/ponfw recommended),
installs git/node/npm if missing (checks Node >= 18), creates the ponfw
service user, runs npm install --omit=dev, writes /etc/pon-fleet-web.env on
first run, and installs+enables the systemd unit with WorkingDirectory set to
the checkout. Idempotent; warns if cloned under /home (ProtectHome blocks it).

README + CLAUDE.md §16 updated to lead with install.sh and clarify the clone
location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:30:06 +02:00
c2206ad5a7 dashboard: live cache-age indicator
The web server's dashboardStats now reports how stale its shared cache
snapshot is — data.cache = { enabled, ttlMs, ageMs }, using the age of the
oldest dataset feeding the view (OLT-STATE, plus ONU-STATE under detailed
scan). cache.js gains ageOf(key).

The dashboard renders a live-ticking badge from it (renderCacheBadge):
  - within TTL  -> "◷ cached Ns ago" (info)
  - past TTL    -> same, warn-coloured (next load will refetch)
  - no cache    -> "● live" (Electron app, or PFW_CACHE_TTL_SECONDS=0)
The Dashboard Refresh button now sends fresh:true to bypass the cache and
reset the age; the detailed-stats toggle and auto-loads use the cache.

Shared renderer change, so the Electron app shows "live" (it sends no cache
field). Verified offscreen over HTTP: cached / stale / live states render
with the right text + colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:25:46 +02:00
6ee1d37a79 web: shared MCMS cache + systemd/clone-to-run deploy + NPM docs
Caching (reduce MCMS API load with multiple operators):
- web/cache.js: in-memory TTL cache with single-flight, keyed by MCMS host.
  Concurrent identical bulk reads collapse into ONE upstream fetch; everyone
  on the same MCMS shares the snapshot.
- Wired into the bulk reads only (ONU/OLT config+state lists, controllers,
  firmware). Per-ONU read-modify-write and the FEC pre-flight stay live.
- Writes (delete / per-ONU + bulk upgrade / flood change / firmware upload)
  invalidate the affected datasets for that host, so changes show on the
  next load instead of waiting out the TTL.
- PFW_CACHE_TTL_SECONDS (default 60, 0 disables). Authenticated
  _cacheStats / _cacheClear endpoints for ops.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:21:04 +02:00
96b0264179 Add multi-user web server variant (browser UI behind a reverse proxy)
New web/ subfolder: a Node http server that serves the SAME tooling as the
Electron app to any browser, with per-session MCMS credentials so several
operators can use it at once behind a TLS reverse proxy. Mobile-responsive.

Not a fork — it reuses the core and the UI:
- serves renderer/app.js + app.css verbatim and rewrites index.html on the
  fly (mobile viewport + browser window.api shim + responsive overlay)
- web/public/api-web.js: a drop-in window.api over fetch + NDJSON streaming;
  Electron file dialogs -> <input type=file>, CSV-to-Downloads -> Blob
- web/server.js: per-session McmsClient (cookie pfw_sid), idle expiry,
  routes mirroring the IPC handlers, NDJSON for the 6 streaming ops
- pure Node http, no new deps (borrows ../node_modules tough-cookie)

Shared improvements (benefit both apps):
- src/dashboard.js: extracted, pure dashboard aggregation (main.js now uses
  it); adds abnormal-Tx detection alongside abnormal-Rx
- renderer: dashboard health stats are now clickable — abnormal Rx / Tx /
  lasers-off pop a list of the offending devices (like the iOS app), via a
  new showListModal

Docs: web/README.md (run + reverse-proxy + security), CLAUDE.md §16.

Verified: node --check all; started the server and confirmed transformed
index, shared-asset serving, 401 auth gate, login (per-session client), and
NDJSON wiring; rendered login + dashboard drilldown + mobile over HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:07:23 +02:00
9106e5a071 Add top tabs + telemetry Dashboard + animated edge glow
Restructure the UI behind top-level tabs (Dashboard / ONU / OLT) so more
features have room to land. showView() now drives the active tab via a
VIEW_TO_TAB map, so sub-views (campaign/verify) keep the ONU tab lit.
Tabs appear on login (landing on Dashboard) and hide on logout.

Dashboard: a read-only network telemetry view modeled on the PONGo iOS
app's DashboardViewModel. New api:dashboardStats aggregates, all in
main.js (renderer just formats):
- counts: ONUs, OLTs, controllers (controllers best-effort via
  /v3/controllers/configs/)
- aggregate traffic (OLT TX BW = downstream, RX BW = upstream)
- health: OLTs with laser off; abnormal-Rx count (rx < -28 || > -10)
- ONU registration-state breakdown (clicking a state deep-links to the
  ONU tab pre-filtered)
- opt-in detailed scan: per-ONU optical + FEC-health distribution
fmtBps is a port of the iOS Format.bps.

Edge glow: #edge-glow draws a neon conic-gradient beam masked to a 3px
border ring and spins an @property --beam-angle 0->360deg, so a glow
travels around the screen edge. Honors prefers-reduced-motion.

mcms-api: add listAllControllerConfigs.
Docs: CLAUDE.md section 15 + README + IPC table.

Verified: npm run check passes; rendered the Dashboard + tabs + edge beam
offscreen (beam frozen on the right edge to confirm it follows the border).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:45:24 +02:00
7cc9649f03 Fix unsupported prompt() confirm; bluer neon theme + themed modal
Bug: the OLT flooding-mode and ONU-delete flows gated on window.prompt()
for the typed APPLY/DELETE confirmation, but Electron does not support
prompt() — it returns null and shows nothing, so clicking OK on the first
dialog silently fell through to "Cancelled." and nothing happened.

Fix: replace window.confirm + window.prompt with an in-app confirmTyped()
modal (Promise<boolean>). It combines the summary and the typed-word gate
into one dialog: the confirm button stays disabled until the operator
types the exact word; Enter confirms, Escape/backdrop cancels. Wired into
all three confirmations (OLT flood change, ONU delete, firmware execute).

Theme: shift the palette electric-blue, turn up the glow (stronger
multi-layer box/text shadows, bluer ambient vignette), and style the new
modal to match (neon border, red glow for destructive variant).

Verified: npm run check passes; rendered the login + modal offscreen and
functionally confirmed the typed-word gate enables the button and resolves
true on confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:13:42 +02:00
49a0e1cba1 Restyle UI with neon/terminal "hackertyper" theme
Darker phosphor-black background with ambient neon vignette, monospace
everywhere, glowing neon-green accents, and CRT scanline + brand-flicker
effects. Restyle only — every class name HTML/app.js depends on is
preserved (status pills, fec-cell, slot-arrow, table classes, etc.).

- Neon-green primary / cyan secondary / magenta-red danger, all with
  text-shadow + box-shadow glow
- Glowing focus rings on inputs, glowing buttons and selected rows
- CRT scanline overlay (body::after, non-interactive) + subtle flicker
- Neon scrollbars and selection highlight

Verified by rendering the login view offscreen via Electron.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:59:22 +02:00
581e16e7ed Merge pull request 'Add bulk OLT PON flooding-mode tool; restore README; docs' (#1) from feat/olt-flooding-mode into main
Reviewed-on: #1
2026-06-23 10:55:03 +00:00
23 changed files with 3899 additions and 196 deletions

282
CLAUDE.md
View file

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

View file

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

110
main.js
View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

105
src/cpe.js Normal file
View file

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

110
src/dashboard.js Normal file
View file

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

67
src/oltdetail.js Normal file
View file

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

141
src/onudetail.js Normal file
View file

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

118
src/oui.js Normal file
View file

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

163
web/README.md Normal file
View file

@ -0,0 +1,163 @@
# 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.

85
web/cache.js Normal file
View file

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

96
web/deploy/install.sh Executable file
View file

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

@ -0,0 +1,34 @@
# 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

@ -0,0 +1,35 @@
[Unit]
Description=PON Fleet web server (MCMS tools, multi-user)
Documentation=https://git.vanvikinternet.no/Svorka/ponfw
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ponfw
Group=ponfw
# The repo is cloned to /opt/ponfw; the server lives in its web/ subdir.
WorkingDirectory=/opt/ponfw/web
ExecStart=/usr/bin/env node server.js
# Defaults; override anything in /etc/pon-fleet-web.env (optional).
Environment=NODE_ENV=production
EnvironmentFile=-/etc/pon-fleet-web.env
Restart=on-failure
RestartSec=3
# --- Hardening (the app reads its repo, writes nothing to disk) ---
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
# V8 needs writable+executable memory for its JIT, so do NOT deny W^X.
MemoryDenyWriteExecute=false
[Install]
WantedBy=multi-user.target

22
web/deploy/update.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Update the PON Fleet web server in place: pull the repo, reinstall runtime
# deps (tough-cookie only — Electron is a devDep and is skipped), restart.
# Run as root (or via sudo) on the LXC. Override the path with PONFW_DIR.
set -euo pipefail
REPO="${PONFW_DIR:-/opt/ponfw}"
SERVICE="pon-fleet-web"
echo "==> Updating $REPO"
cd "$REPO"
git pull --ff-only
echo "==> Installing runtime dependencies (omit dev / Electron)"
# `npm install` (not `npm ci`) because this repo gitignores package-lock.json.
npm install --omit=dev --no-audit --no-fund
echo "==> Restarting $SERVICE"
systemctl restart "$SERVICE"
sleep 1
systemctl --no-pager --lines=8 status "$SERVICE" || true
echo "==> Done."

12
web/package.json Normal file
View file

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

193
web/public/api-web.js Normal file
View file

@ -0,0 +1,193 @@
// 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'),
};
})();

53
web/public/web.css Normal file
View file

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

603
web/server.js Normal file
View file

@ -0,0 +1,603 @@
// 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.');
});