Compare commits
14 commits
feat/olt-f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ea251c3498 | |||
| 6f4a315947 | |||
| 2046fad0f8 | |||
| 276c3e9faa | |||
| 55e95ed467 | |||
| 5e3d9271a8 | |||
| de037c8795 | |||
| c2206ad5a7 | |||
| 6ee1d37a79 | |||
| 96b0264179 | |||
| 9106e5a071 | |||
| 7cc9649f03 | |||
| 49a0e1cba1 | |||
| 581e16e7ed |
23 changed files with 3899 additions and 196 deletions
282
CLAUDE.md
282
CLAUDE.md
|
|
@ -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.*
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -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
110
main.js
|
|
@ -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) };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
576
renderer/app.css
576
renderer/app.css
|
|
@ -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; }
|
||||
|
|
|
|||
958
renderer/app.js
958
renderer/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -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 & 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 & 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
105
src/cpe.js
Normal 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
110
src/dashboard.js
Normal 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 };
|
||||
169
src/mcms-api.js
169
src/mcms-api.js
|
|
@ -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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
let removedPonFloodId;
|
||||
if (targetMode === 'auto') {
|
||||
removedPonFloodId = n['PON FLOOD ID'];
|
||||
delete n['PON FLOOD ID'];
|
||||
} else if (targetMode === 'private') {
|
||||
n['PON FLOOD ID'] = ponFloodIdValue;
|
||||
} else {
|
||||
unchanged.push({ index: i, tagMatch: tag, reason: `unknown target mode "${targetMode}"` });
|
||||
continue;
|
||||
}
|
||||
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
|
||||
|
||||
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
67
src/oltdetail.js
Normal 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
141
src/onudetail.js
Normal 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
118
src/oui.js
Normal 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
163
web/README.md
Normal 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
85
web/cache.js
Normal 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
96
web/deploy/install.sh
Executable 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
|
||||
34
web/deploy/pon-fleet-web.env.example
Normal file
34
web/deploy/pon-fleet-web.env.example
Normal 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
|
||||
35
web/deploy/pon-fleet-web.service
Normal file
35
web/deploy/pon-fleet-web.service
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[Unit]
|
||||
Description=PON Fleet web server (MCMS tools, multi-user)
|
||||
Documentation=https://git.vanvikinternet.no/Svorka/ponfw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ponfw
|
||||
Group=ponfw
|
||||
# The repo is cloned to /opt/ponfw; the server lives in its web/ subdir.
|
||||
WorkingDirectory=/opt/ponfw/web
|
||||
ExecStart=/usr/bin/env node server.js
|
||||
# Defaults; override anything in /etc/pon-fleet-web.env (optional).
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=-/etc/pon-fleet-web.env
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
# --- Hardening (the app reads its repo, writes nothing to disk) ---
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
# V8 needs writable+executable memory for its JIT, so do NOT deny W^X.
|
||||
MemoryDenyWriteExecute=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
22
web/deploy/update.sh
Executable file
22
web/deploy/update.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
# Update the PON Fleet web server in place: pull the repo, reinstall runtime
|
||||
# deps (tough-cookie only — Electron is a devDep and is skipped), restart.
|
||||
# Run as root (or via sudo) on the LXC. Override the path with PONFW_DIR.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${PONFW_DIR:-/opt/ponfw}"
|
||||
SERVICE="pon-fleet-web"
|
||||
|
||||
echo "==> Updating $REPO"
|
||||
cd "$REPO"
|
||||
git pull --ff-only
|
||||
|
||||
echo "==> Installing runtime dependencies (omit dev / Electron)"
|
||||
# `npm install` (not `npm ci`) because this repo gitignores package-lock.json.
|
||||
npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
echo "==> Restarting $SERVICE"
|
||||
systemctl restart "$SERVICE"
|
||||
sleep 1
|
||||
systemctl --no-pager --lines=8 status "$SERVICE" || true
|
||||
echo "==> Done."
|
||||
12
web/package.json
Normal file
12
web/package.json
Normal 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
193
web/public/api-web.js
Normal 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
53
web/public/web.css
Normal 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
603
web/server.js
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
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.');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue