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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:49:54 +02:00

936 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
Reference document for future Claude sessions (and humans reading the
repo cold). Captures what's actually built, the MCMS API quirks
discovered along the way, and the things that don't work or were
deliberately skipped.
For run instructions and user-facing scope, see `README.md` — this file
is the engineering counterpart.
---
## 1. What this app does
Electron desktop client for operating a **Ciena MCMS 6.2** PON Manager
fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between:
- **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.
End-to-end operator workflow:
1. Connect with email + password against the MCMS web URL.
2. Browse the ONU fleet with rich filters (status, down-duration,
version, equipment ID, PON mode).
3. Either select ONUs and continue to the upgrade campaign view, **or**
delete stale ONUs (Deregistered / Dying Gasp / etc.) from MCMS.
4. In the campaign view: pick a firmware `.bin` already uploaded to
`/files/onu-firmware/` (or upload one), preview the planned writes,
and commit. The Preview step also runs a FEC pre-flight health
check on every selected ONU.
5. On Execute, the plan is auto-saved as a CSV to `~/Downloads/`.
6. After the upgrade has had time to roll, open the saved CSV in the
verify view to compare before/after and auto-save a verification
CSV alongside.
---
## 2. Quickstart
```bash
cd pon-fleet-upgrader
npm install
npm start # launch Electron
npm run check # node --check on every JS file (cheap smoke test)
```
Environment:
- `PON_FLEET_VERBOSE=0` — silence the per-request log lines in the
Electron terminal. Default is verbose because the log dumps headers
and full response bodies on non-2xx, which has been invaluable for
debugging.
DevTools: **Ctrl+Shift+I** (Cmd+Opt+I on macOS). Reload renderer after
editing `renderer/`: **Ctrl+R**.
---
## 3. Architecture
Standard Electron three-process model with strict isolation:
```
+-----------+ IPC invoke +-----------+ HTTPS +-----------+
| Renderer | ──────────────► | Main | ────────► | MCMS |
| (browser) | via preload | (Node) | | (web UI) |
+-----------+ ◄───────────── +-----------+ ◄──────── +-----------+
streaming events / responses
```
- **Renderer** (`renderer/app.js`, `index.html`, `app.css`) — vanilla
DOM, no framework. Calls into `window.api.*` exposed by the preload.
Has no Node access and never sees raw cookies.
- **Preload** (`preload.js`) — `contextBridge` whitelist; the only
bridge from renderer to main. Adding a new IPC channel requires
touching this file.
- **Main** (`main.js`) — owns the `McmsClient`, the cookie jar, file
IO, and the Electron `dialog` / `app.getPath()` APIs.
- **MCMS client** (`src/mcms-api.js`) — raw `https` + `tough-cookie`.
No third-party HTTP client; the only npm dep is `tough-cookie`. See
§5 for the quirks this client works around.
- **Bank strategy** (`src/bank-strategy.js`) — pure logic for picking
the inactive bank to write to. See §7.
### File layout
```
pon-fleet-upgrader/
├── package.json # electron, tough-cookie, npm scripts
├── main.js # Electron main process + IPC handlers
├── preload.js # contextBridge whitelist
├── README.md # user-facing run docs
├── CLAUDE.md # this file
├── src/
│ ├── mcms-api.js # HTTPS client, FEC extractor, time helpers
│ └── bank-strategy.js # inactive-bank + plan computation
└── renderer/
├── index.html # login / fleet / campaign / verify views
├── app.css # dark theme, status pill classes
└── app.js # UI state, all renderer logic
```
### IPC channel list
All channels are `invoke`-style (request/response). A few additionally
stream progress events back to the renderer.
| Channel | Purpose | Streams |
|---|---|---|
| `api:login` / `api:logout` | session auth | — |
| `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` |
| `api:savePlanCsv` | auto-save plan on Execute | — |
| `api:saveCsvFile` | generic CSV writer | — |
| `api:openCsvFile` | open dialog → return text | — |
| `api:fetchOnuSnapshot` | verify-flow: state + cfg per ONU | `verify:progress` |
---
## 4. Features actually implemented
### Fleet view
- Login with email / password. TLS to MCMS is always verified
(`rejectUnauthorized: true`); there is no accept-self-signed option —
MCMS must present a valid certificate. The web server can prefill the host
field from `PFW_MCMS_URL`.
- Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` +
`listAllOltStates`, all paginated via the `next` cursor at page size
100 (see §5).
- Filters: text (name/address/serial), Equipment ID, PON mode,
active version, exclude active version, model/version family,
registration status (incl. "Down" aggregate and "Unknown" buckets),
down-for-at-least-N-days.
- Columns: serial, name/address, equipment ID, PON mode,
**status** (colour-coded from OLT-STATE bucket), **last seen**
(relative time from `ONU-STATE.Time`), active slot, active version,
inactive version.
- Bulk select / clear / select-all-matching.
- **Delete** — `DELETE /v1/onus/configs/<id>/` per ONU at concurrency 5,
then best-effort `DELETE /v1/onus/states/<id>/`. Two-step confirm
(alert + typed `DELETE`). Streams progress per ONU.
### Campaign view
- Firmware picker bound to `/files/onu-firmware/`, inferring version
from filename (regex `[A-Z]{2}\d{5}[A-Z]?`).
- Upload firmware via Electron `dialog.showOpenDialog`.
- Execution mode toggle: **bulk task** (Procedure 8 — recommended) or
**per-ONU PUT** (Procedure 7 — per-device feedback).
- Scheduled-start datetime-local picker (converts local → UTC string
`YYYY-MM-DD HH:MM:SS`).
- **Dry-run preview** with FEC pre-flight: each selected ONU is
re-fetched (`getOnuState`) at preview time, and the resulting FEC
pill + optical levels + raw counters are rendered in a new column.
- **CSV auto-save on Execute** — writes
`~/Downloads/pon-upgrade-<version>-<mode>-<count>onus-<stamp>.csv`
the moment Execute is clicked, *before* any MCMS write. See §10.
- Soft FEC gate in the Execute confirmation dialog: warns (but doesn't
block) when any selected ONU is showing post-FEC errors or the
pre==post buggy signature.
### Verify view (after-upgrade comparison)
- **Open plan CSV…** — file dialog defaulted to `~/Downloads`. Parser
is lenient — needs only an `ONU ID` column, everything else is
optional.
- For each ONU listed, fetches **both** ONU-STATE (fresh FEC + optical)
and ONU-CFG (to confirm `FW Bank Ptr` flipped and the active version
now equals the recorded target). Concurrency 8.
- Renders a comparison table with target→active, "Applied?" badge,
before/after FEC pills + counters, before/after optical RX/TX, and
a one-line verdict per ONU.
- **Auto-saves** the comparison to
`~/Downloads/pon-verify-<trailing-of-source>-<stamp>.csv` the moment
the comparison finishes, so it sorts next to the original.
- Side-panel summary: counts of applied/not-applied, fixed/improved,
still-buggy, regressed, still-healthy.
### FEC health rules
(`extractOnuHealth` in `src/mcms-api.js`)
| Flag | Meaning | Trigger |
|---|---|---|
| `ok` | green | All four FEC counters present and 0 |
| `pre` | yellow | Any pre-FEC counter > 0, all post-FEC = 0 |
| `post` | red | Any post-FEC counter > 0 (uncorrected errors) |
| `buggy` | blue | ONU pre>0 AND post>0 AND `post/pre >= 0.95` |
| `nodata` | grey | No FEC counters found in STATS |
| `error` | grey | Per-ONU fetch threw |
**Buggy detection note.** Real-world example from a GNXS Everest ONU:
`Pre-FEC BER = 22,388,041,291`, `Post-FEC BER = 22,379,111,198`. Those
differ by 0.04%, which is well into "FEC isn't doing anything" territory.
Exact-equality detection would miss this; the 0.95 ratio threshold
catches it. The condition is intentionally one-sided — we treat ONU
side as the bug source (the OLT side counters are computed by the OLT
firmware which we trust).
### Optical thresholds
From the PON Manager User Guide green-LED criteria (323-1961-302 p149):
| Field | Green | Yellow | Red |
|---|---|---|---|
| ONU RX | ≥ 28 dBm | 28 to 30 dBm | < 30 dBm |
| ONU TX | ≥ 3 dBm | — | < 3 dBm |
Yellow is a margin band we added the user guide is binary (≥ 30 vs
< 30), but operators want a warning before the link is actually broken.
---
## 5. MCMS API quirks
These are the gotchas worth knowing. Many were diagnosed empirically
against MCMS 6.2 + Interos Everest 5.11/5.12. **If something behaves
oddly, check this section first.**
### 5.1 User-Agent header is required
Node's `https.request` doesn't send a User-Agent by default. **Some MCMS
middleware dereferences `HTTP_USER_AGENT` without a guard and 500s when
it's absent.** Reproducible via `curl -A '' https://mcms/api/v3/...`.
`src/mcms-api.js` sends `User-Agent: pon-fleet-upgrader/0.1.0` on
every request. Do not remove.
### 5.2 CSRF cookie name is `__Host-csrftoken`
MCMS 6.2 uses RFC 6265bis `__Host-` cookie prefix (Secure + Path=/).
The cookie names are:
- `__Host-csrftoken`
- `__Host-sessionid`
- `__Host-sessionexpire`
Older MCMS builds shipped bare `csrftoken`. The client looks for both:
```js
const csrf = jarCookies.find(
(c) => c.key === 'csrftoken' || c.key === '__Host-csrftoken',
);
```
The token value still goes in the `X-CSRFToken` header (no prefix).
### 5.3 Pagination: use `next`, not `skip`
`/v3/onus/configs/?skip=N` has been observed returning HTTP 500 on this
MCMS build. Use the `next` cursor: pass the last document's `_id` as
the `next` query param to get the page that *excludes* all IDs up to
and including that one.
See `McmsClient._listPaginatedByNext`.
### 5.4 Bulk-list page size 100
`/v3/onus/configs/?limit=1000` times out at the Apache front-end on
~1500-ONU fleets, before Django even logs the request. Full ONU-CFG
documents are ~30 KB each, so a 1000-row page is ~30 MB and the proxy
gives up.
Default page size is 100. Each response is ~3 MB and lands in well
under the proxy timeout.
### 5.5 Path prefix is `/api/v1/...`
The dev guide writes "GET `/v1/users/authenticate/`" as shorthand. The
real HTTP path is `/api/v1/users/authenticate/`. The client normalises
this internally pass either form.
### 5.6 Request body envelope
PUT/POST bodies are wrapped: `{ "data": { ...payload } }`. The login
endpoint accepts both wrapped and unwrapped on observed builds (the
client tries wrapped first, falls back on 400). Everything else is
strictly wrapped.
### 5.7 PUT is full-document replace, not patch
`PUT /v1/onus/configs/<id>/` replaces the entire `ONU-CFG`. The client
fetches the doc, mutates the FW fields in place, and PUTs the result.
**Do not use PATCH** MCMS PATCH endpoints are not used by this app
and the dev guide is explicit about PUT-as-replace for CFG updates.
### 5.8 Timestamp format
MCMS emits and accepts: `"YYYY-MM-DD HH:MM:SS[.ffffff]"` in **UTC**
with a literal space separator (not ISO-8601 `T`).
Used on: `ONU-STATE.Time`, alarm timestamps,
`AUTO-TASK-CFG.Task.Scheduled Start Time`, query-param timestamps.
Helpers in `src/mcms-api.js`: `formatMcmsTime(date)` and
`parseMcmsTime(str)`. The renderer also coerces the space-form to ISO
before passing to `new Date()` to avoid Node's local-time
interpretation.
### 5.9 Server-side filtering on `query`/`projection` is brittle
Mongo-style server filters get rejected (or silently return nothing)
when the URL-encoded JSON has spaces in field names, certain escape
patterns, or projection paths that don't exist on every document
version. **We load the full fleet and filter entirely client-side.**
If you need server filtering, the safest pattern is no quotes around
field names and `%20` for spaces (not `+`). The client's
`_encodeQuery` uses `encodeURIComponent` for exactly this reason.
### 5.10 ONU registration status lives on OLT-STATE, not ONU-STATE
The 9 registration buckets (Registered, Deregistered, Dying Gasp,
Disabled, Disallowed Admin/Error/Reg ID, Unspecified, Unprovisioned)
appear under `OLT-STATE["ONU States"][bucket] = [onuId, ...]`. See
323-1961-306 Procedure 2.
`listAllOltStates` pulls these, and `api:listFleet` builds a
`onuId → { status, oltMac }` map from the buckets. On duplicates we
prefer a non-Registered bucket (the interesting signal).
### 5.11 FEC counters live on ONU-STATE, not `/onus/stats/`
`/v1/onus/stats/<id>/` is the time-series PM collection `start-time`
is required, returns historical samples. **The current FEC/optical
values are already in ONU-STATE:**
```
state_collection.STATS["ONU-PON"]["RX Pre-FEC BER"]
state_collection.STATS["ONU-PON"]["RX Post-FEC BER"]
state_collection.STATS["ONU-PON"]["RX Optical Level"] # dBm
state_collection.STATS["ONU-PON"]["TX Optical Level"] # dBm
state_collection.STATS["OLT-PON"]["RX Pre-FEC BER"]
state_collection.STATS["OLT-PON"]["RX Post-FEC BER"]
```
The pre-flight fetch (`api:fetchFecHealth`) and the verify fetch
(`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via
`extractOnuHealth`. For *current* FEC/optical, prefer ONU-STATE.
The time-series endpoint **is** used, but only for the ONU detail page's
traffic sparkline (`getOnuStatsSeries` `api:getOnuDetail`, §17): it pulls
the last ~hour of samples for the down/up rate history. Don't use it for the
live FEC pill that stays on ONU-STATE.
`extractOnuHealth` accepts both shapes: the bare state doc
(`{STATS: ...}`) and the UI-helper wrapped form
(`{state_collection: {STATS: ...}}`), since `/api/onu/summary` returns
the wrapped shape.
### 5.12 Login body shape
The PON Manager web app POSTs `{"data": {"email": ..., "password": ...}}`
to `/v1/users/authenticate/`. Some build variants accept the unwrapped
form too the client tries wrapped first.
---
## 6. ONU-STATE schema cheat-sheet (observed, not specced)
The MCMS dev guide doesn't formally document the ONU-STATE shape on this
build. From observation on GNXS / Interos Everest ONUs:
```jsonc
{
"_id": "GNXS05057fee", // serial / device ID
"Time": "2026-05-04 21:51:57.394127", // last update (UTC)
"ONU": {
"Equipment ID": "FT-XGS2110",
"Vendor": "GNXS",
"PON Mode": "GPON",
"FW Bank Files": ["...bin", "...bin"],
"FW Bank Versions": ["EV05110R", "EV05100R"],
"FW Bank Ptr": 0, // active slot (0 | 1 | 65535)
"FW Version": "EV05110R",
"FW Upgrade Status": { /* progress, status, etc */ },
// ...
},
"STATS": {
"ONU-PON": {
"RX Pre-FEC BER": 22388041291,
"RX Post-FEC BER": 22379111198,
"RX Optical Level": -17.544, // dBm
"TX Optical Level": 5.532 // dBm
},
"OLT-PON": {
"RX Pre-FEC BER": 0,
"RX Post-FEC BER": 0,
"TX Optical Level": 5.6,
"RX Optical Level": -18.3
},
"ONU-EnhancedFecPmHistData-32769": { // NOT used by extractor —
"uncorrectable_code_words": 1829, // these are corrected/
"corrected_bytes": 181, // uncorrectable code-word
"corrected_code_words": 181 // counts, not BER values
},
// ... many more sub-objects
}
}
```
The renderer's display field `Equipment ID` is sometimes also on the CFG,
not just the STATE `equipmentId(cfg)` in `app.js` checks both paths.
---
## 7. Firmware bank semantics
(Full version of the table in `README.md`.)
ONU has two flash slots. `FW Bank Ptr` identifies the active one.
MCMS triggers a firmware download by:
1. Writing the new file + version into the target slot's
`FW Bank Files[slot]` / `FW Bank Versions[slot]`.
2. Setting `FW Bank Ptr` to that slot.
The ONU then downloads, and on completion MCMS flips `FW Bank Ptr` to
the new slot (so the ONU boots into the new image on its next reboot).
The bank-strategy module always writes to the **inactive** slot. If
`FW Bank Ptr` is unset (65535) it writes to slot 1, leaving slot 0 as
the factory fallback.
For the bulk-task path (Procedure 8), MCMS needs a single bank number
per task. So if a selection has ONUs with different active banks, we
bucket by `writeSlot` and create one task per bucket each task has
a Procedure-8-shaped `AUTO-TASK-CFG.Task Details.ONU.FW Bank Ptr`.
---
## 8. CSV file formats
Both kinds of CSV the app produces are written with the same
`writeCsvFile` helper in `main.js`:
- Always UTF-8 BOM (Excel auto-detects encoding Norwegian Æ/Ø/Å
survive on Windows).
- Always CRLF line endings.
- Every cell quoted (defensive against commas in addresses, embedded
quotes in operator-typed names, embedded newlines from copy/paste).
- Saved to `app.getPath('downloads')` (`~/Downloads` on macOS/Linux,
`C:\Users\<you>\Downloads` on Windows).
### Plan CSV — `pon-upgrade-<hint>-<stamp>.csv`
Auto-saved when Execute is clicked, **before** any MCMS write so
even if the API call fails mid-way there's a record of intent.
Columns (20): ONU ID, Name, Address, PON Mode, Active slot (before),
Write slot (target), Slot 0 version (before), Slot 1 version (before),
Target version, Target file, FEC health, ONU RX Pre-FEC BER,
ONU RX Post-FEC BER, OLT RX Pre-FEC BER, OLT RX Post-FEC BER,
ONU RX Optical (dBm), ONU TX Optical (dBm), FEC sample time, Mode, Notes.
The hint is derived from `<targetVersion>-<mode>-<count>onus`.
### Verify CSV — `pon-verify-<sourcehint>-<stamp>.csv`
Auto-saved after the verify comparison finishes. Hint reuses the
trailing descriptor of the source CSV (strip `pon-upgrade-` prefix and
`.csv` suffix), so the verify file sorts next to its source in the
filesystem.
Columns (23): identifiers, Target version, Active version (after),
Upgrade applied?, FEC health (before), FEC health (after), all four
BER counters before+after (ONU + OLT, pre + post), ONU RX/TX optical
before+after, After sample time, Verdict, Fetch error.
### Verdict strings (in the Verify CSV)
| Verdict | Trigger |
|---|---|
| `fixed (was buggy)` | `buggy → ok` |
| `still buggy (firmware bug persists)` | `buggy → buggy` |
| `improved (no longer mirroring counters)` | `buggy → pre` |
| `changed: now reporting genuine post-FEC errors` | `buggy → post` |
| `still healthy` | `ok → ok` |
| `improved` | `pre|post → ok` |
| `improved (post→pre)` | postpre |
| `regressed (now post-FEC errors)` | `ok → post` |
| `regressed (now buggy)` | `ok → buggy` |
| `regressed (now marginal)` | `ok → pre` |
| `regressed (pre→post)` | prepost |
| `still has post-FEC errors` | `post → post` |
| `still marginal (pre-FEC only)` | `pre → pre` |
| `upgrade NOT applied (active version unchanged)` | `cfg.activeVersion !== target` |
| `fetch failed` | per-ONU snapshot threw |
| `no current FEC data` | flag=nodata |
**The raw before/after counter values are misleading on their own**
because ONU FEC counters typically reset on the upgrade reboot. The
verdict is computed from the **flag transition**, not the delta.
---
## 9. Known limitations / not implemented
- **No task-progress polling.** Once a bulk task is submitted, MCMS
owns the rollout and the app doesn't poll `/v3/tasks/states/<id>/`.
Operators use the verify view (or the PON Manager UI) to confirm.
- **No post-execute results CSV.** The plan CSV captures intent
pre-write. Per-ONU success/failure (in Procedure 7 mode) and task
submission errors (in Procedure 8 mode) are visible on screen but
not dumped to disk. Add-on candidate.
- **No offline / cached fleet snapshot.** Every `Load fleet` hits MCMS.
- **`/v1/onus/<id>/upgrade/status/`** is referenced in some MCMS spec
documents but returns 404 on this build. Fallback: poll
`GET /v1/onus/configs/<id>/` and watch `FW Bank Versions` /
`FW Bank Ptr` flip.
- **PATCH not used** MCMS requires full-document PUT for CFG updates
(dev guide is explicit about this).
- **Server-side query/projection not used** see §5.9.
- **No time-series PM data shown.** We use the live snapshot from
ONU-STATE 5.11). `GET /onus/stats/<id>/` exists but is not wired
up.
- **No retries on transient HTTP errors** beyond the per-ONU
best-effort delete-state. Bulk delete and per-ONU PUTs surface
errors per row but don't auto-retry.
- **Schedule field is UTC only.** The datetime-local picker converts
from the browser's local TZ. Operators in different TZs see different
inputs producing the same `Scheduled Start Time` value in the task
intentional, but worth noting.
---
## 10. Conventions
### Adding a new IPC channel
Touch three files in order:
1. **`src/mcms-api.js`** add the method on `McmsClient` if it talks
to MCMS.
2. **`main.js`** `ipcMain.handle('api:foo', ...)`. Wrap errors via
`toWireError` so the renderer never sees stack traces.
3. **`preload.js`** add `foo: (opts) => call('api:foo', opts)` to
the `window.api` contract. **The renderer cannot call channels not
listed here.**
4. **`renderer/app.js`** use `window.api.foo(...)`.
For streaming progress events, also add an `onFooProgress(handler)`
function in the preload (returns an unsubscribe function).
### CSS conventions
Status colour classes (declared in `app.css`):
| Class | Meaning | CSS var |
|---|---|---|
| `.status-ok` | success / healthy | `--ok` (green) |
| `.status-warn` | margin / pre-FEC | `--warn` (yellow) |
| `.status-err` | failure / post-FEC / regressed | `--danger` (red) |
| `.status-info` | informational / buggy ONU | `--info` (blue) |
| `.status-pending` | in-flight / unknown | `--fg-dim` (grey) |
| `.muted` | secondary text | `--fg-dim` (grey) |
`.fec-cell` and `#verify-table tbody td` override
`white-space: nowrap` to allow stacked content (pill on top, counters
underneath).
### Verbose logging
`McmsClient(verbose: true)` (default in this app) logs every request:
- Method + path + cookie names (cookie values redacted)
- All outgoing headers (`X-CSRFToken` redacted)
- On non-2xx: full response headers + full response body
The combination is enough to diff against a working `curl` for
"why is this 500?" investigations. **Turn off via
`PON_FLEET_VERBOSE=0`** if logs are leaking secrets to a shared
terminal.
---
## 11. Testing
There's no formal test runner. Two cheap signals:
1. `npm run check` `node --check` on every JS file. Catches syntax
errors before launching Electron.
2. Inline `node -e '...'` fixtures for pure functions (FEC extractor,
CSV roundtrip, verdict logic). Vendor the function bodies into the
test snippet rather than importing from `renderer/app.js` that
file expects `window` / `document`.
When refactoring `extractOnuHealth`, re-test against the GNXS
real-world fixture (post=22379111198, pre=22388041291): it must flag
as `buggy`. That ratio (0.9996) is the edge case the 95% threshold
was designed to catch.
---
## 12. Reference documents
Stored alongside the project in the parent `PON-API` folder:
- `323-1961-306_mcms_6_2_rest_api_developer_guide.pdf` REST API
reference. Cited as "the dev guide".
- `323-1961-302_mcms_6_2_pon_manager_user_guide.pdf` UI guide.
Procedures 7, 8, 38, 43 referenced from code comments.
- `323-1963-105_mcms_6_2_onu_planning_guide.pdf` ONU planning
context (PON design, link budgets).
Key procedure citations:
- **Procedure 2** (REST API dev guide) determining ONU registration
status via OLT-STATE buckets.
- **Procedure 7** (REST API dev guide) per-ONU firmware upgrade via
full-doc PUT.
- **Procedure 8** (REST API dev guide) bulk firmware upgrade via
`AUTO-TASK-CFG`.
- **Procedure 38** (PON Manager User Guide) Resetting bit error
rate values; identifies the four FEC counters in the UI.
- **Procedure 43** (PON Manager User Guide) Deleting an ONU.
---
## 13. History / non-obvious design choices
- **CSV is auto-saved BEFORE the API write, not after.** Intentional:
capture intent even if the write fails. A "results CSV" with
per-ONU success/failure is an obvious add-on but not yet wired.
- **Plan CSV uses 20 columns even for trivial cases.** Wide is fine
for spreadsheets; narrow forces splits later.
- **FEC pre-flight only runs on the selection, not the fleet.**
Originally implemented to query the whole fleet at preview time,
but operator preference is "right before commit, not for the
entire fleet" keeps `/v3/onus/states/<id>/` calls bounded to N
selected.
- **Buggy detection uses ratio, not equality.** See §4 real
observed buggy pairs aren't bit-identical (timing windows differ),
so `post == pre` misses the case. `post / pre >= 0.95` catches it.
- **Optical thresholds add a yellow margin band** beyond the user
guide's binary green/red. Operators want a warning before a link
actually fails.
- **`__Host-` cookie prefix support was added retroactively**
initial code only matched `csrftoken`, causing intermittent 500s
on session refresh.
- **User-Agent header was the root cause of one prolonged debugging
session.** Documented in §5.1 so we don't lose that knowledge.
---
## 14. OLT NNI-service edits (bulk flood mode + DHCP filter)
Second bulk feature, added after the firmware upgrader. Lets an operator
edit OLT NNI services across many OLTs at once. Two concerns, applied
together in one GETeditPUT per OLT:
### The two mutation rules
Both live on each `OLT-CFG["NNI Networks"][i]` entry, both confirmed
empirically from operator paste-backs of `s0.c76.c0`:
1. **Flooding mode** the **presence/absence of the `PON FLOOD ID` key**:
present (any value, incl. `0`) **private**; absent **auto**. So
privateauto is `delete entry['PON FLOOD ID']`; autoprivate is
`entry['PON FLOOD ID'] = 0`.
2. **DHCP filter mode** a **value** under the nested `Filter` object:
`entry.Filter.DHCPv4` / `entry.Filter.DHCPv6`, e.g. `"pass"` `"umt"`
(siblings `EAPOL`/`PPPoE`/`NDP` are left alone). Value replacement, not
key presence. **Replace-only-when-present**: an entry with no `Filter`
(or no such key) is skipped, never invented.
Everything else on the entry and the rest of the OLT-CFG is preserved.
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 ─► planNniEdit (pure, no-mutate)
(channel name kept for wire compat) ─► putOltConfig (full-doc PUT)
```
- **Pure helpers** at the bottom of `src/mcms-api.js`: `floodModeOfNni`,
`dhcpModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
`planNniEdit`. `planNniEdit(oltCfg, { tagPattern, flood, dhcpv4, dhcpv6 })`
clones the `NNI Networks` array (and any edited `Filter`) and never mutates
its input. Returns `{ newDoc, changes, unchanged }`, where a change is
`{ index, tagMatch, edits: [{ type, from, to }] }` `type` is `'flood'`,
`'DHCPv4'`, or `'DHCPv6'`, so one entry can carry several edits.
- Each concern is optional (`flood`/`dhcpv4`/`dhcpv6` may be `null`); the UI
has three independent action dropdowns.
- **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and
`*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars
then turns `*` into `.*`.
- **`from` guards**: only entries currently on the `from` mode/value are
flipped; entries already at target are left in `unchanged` (the per-OLT
`noop` case), so re-running is idempotent.
- **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
live doc immediately before each PUT to avoid stomping a concurrent
edit, then writes the whole document back.
### UI (OLT inspector view, `#view-olts`)
Reached from the fleet view's "Open OLT inspector…" button. Filters by TAG
pattern + current mode + OLT name; selecting an OLT queues every matching
service on it. Two-step confirm (summary `confirm` + typed `APPLY`) before
writing. Auto-saves a result CSV to `~/Downloads/pon-olt-flood-…csv` with
per-OLT result, change detail, and skip reasons same `writeCsvFile`
helper and conventions as the upgrade/verify CSVs 8).
### Testing the pure helpers
`floodModeOfNni` / `summarizeOltNniNetworks` / `planFloodChange` are
exported from `src/mcms-api.js` and have no DOM/Electron deps, so they're
directly `require`-able in a `node -e` fixture. The invariants worth
re-checking after any edit: (1) `planFloodChange` does **not** mutate its
input, (2) re-applying the same change is a no-op, (3) non-matching NNI
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.*