commit 0eb9b55717740d0a3d42b9b4697bb5d097e0d14a Author: Jon Vanvik Date: Tue May 26 11:16:18 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..148703c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +dist/ +out/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0eaac1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,648 @@ +# 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 bulk ONU firmware upgrades against **Ciena +MCMS 6.2** PON Manager. Reference deployment: GNXS / Tibit MicroPlug ONUs +running Interos Everest (EV051xxR / EV052xxR firmware family). + +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: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: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, accept-self-signed-TLS toggle (essential + for lab MCMS installs). +- Bulk fleet load: `listAllOnuConfigs` + `listAllOnuStates` + + `listAllOltStates`, all paginated via the `next` cursor at page size + 100 (see §5). +- 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//` per ONU at concurrency 5, + then best-effort `DELETE /v1/onus/states//`. 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---onus-.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--.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//` 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//` 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`. The time-series stats endpoint is **not used** +anywhere — earlier code that hit it has been removed. + +`extractOnuHealth` accepts both shapes: the bare state doc +(`{STATS: ...}`) and the UI-helper wrapped form +(`{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\\Downloads` on Windows). + +### Plan CSV — `pon-upgrade--.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 `--onus`. + +### Verify CSV — `pon-verify--.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)` | post→pre | +| `regressed (now post-FEC errors)` | `ok → post` | +| `regressed (now buggy)` | `ok → buggy` | +| `regressed (now marginal)` | `ok → pre` | +| `regressed (pre→post)` | pre→post | +| `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//`. + 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//upgrade/status/`** is referenced in some MCMS spec + documents but returns 404 on this build. Fallback: poll + `GET /v1/onus/configs//` 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//` 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//` 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. + +--- + +*Last updated: 2026-05-26.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..3950c01 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# PON Fleet Upgrader + +An Electron desktop app for bulk ONU firmware upgrades against a **Ciena +MicroClimate Management System (MCMS) 6.2** PON Manager. + +Filters a fleet of ONUs, previews the planned writes, and stages new +firmware to each device's **inactive bank** so the forced reboot is +deferred until the next natural restart window. + +Two execution modes: + +- **Bulk task (Procedure 8, recommended)** — creates a single + `AUTO-TASK-CFG` via `PUT /v3/tasks/configs//`. MCMS owns the rollout + (retries, pacing, scheduled start). +- **Per-ONU PUT (Procedure 7)** — iterates the selected ONUs, fetches each + `ONU-CFG`, mutates `FW Bank {Ptr,Files,Versions}`, and `PUT`s the full + document back. Slower, but you see errors live per device. + +## Prerequisites + +- Node.js 18+ and npm +- Network reachability to your MCMS host +- An MCMS user with ONU write + Files write permissions +- A compatible ONU firmware `.bin` file (already uploaded via this tool + or the vendor UI into `/files/onu-firmware/`) + +## Install & run + +```bash +cd pon-fleet-upgrader +npm install +npm start +``` + +This launches an Electron window. Use `npm run check` to syntax-check all +source files without launching the app. + +## Bank strategy + +MCMS stores firmware in two slots per ONU (`FW Bank Files[0/1]`, +`FW Bank Versions[0/1]`) with `FW Bank Ptr` pointing at the active one. + +| Current `FW Bank Ptr` | Tool writes to slot | Why | +|-----------------------|---------------------|-----| +| `0` | `1` | Don't overwrite active image | +| `1` | `0` | Don't overwrite active image | +| `65535` (unset) | `1` | Matches Procedure 7 example; slot 0 stays as factory fallback | + +The tool computes this per ONU and buckets the selection by target slot +when submitting a bulk task (MCMS's `AUTO-TASK-CFG.Task Details.ONU.FW +Bank Ptr` accepts a single slot number, so each bucket becomes one task). + +## Safety notes + +- **Always validate on one ONU first.** Select a single device, run in + per-ONU mode, confirm the ONU recovers on slot N before using bulk + task on the rest of the fleet. +- **Don't blank the active slot.** The planner preserves both slots' + existing `Files`/`Versions` and only writes the target slot. +- **Scheduled start is UTC.** The datetime-local picker converts from + your local timezone to UTC before submitting the `AUTO-TASK-CFG`. +- **Leave the session short.** Log out when done — MCMS sessions don't + auto-expire and a stale cookie on a shared workstation is an + unnecessary exposure. + +## Filters + +The left panel supports: + +- **Name / address contains** — substring match over + `ONU.Name`, `ONU.Address`, and `_id` +- **PON mode** — server-side Mongo filter on `ONU.PON Mode` +- **Active version matches** — substring match on the version string in + whichever slot `FW Bank Ptr` points to +- **Model / version family** — substring match across *both* slots' + version strings, useful for fleet cuts like `EV051` (all Everest 5.1x) + +The server-side projection is narrow (serial, name/address, PON mode, bank +state). The full ONU-CFG is only re-fetched at plan time. + +## Files + +``` +pon-fleet-upgrader/ +├── package.json +├── main.js # Electron main process, IPC handlers +├── preload.js # contextBridge exposing window.api.* +├── src/ +│ ├── mcms-api.js # HTTPS client with tough-cookie jar + CSRF +│ └── bank-strategy.js # inactive-bank selection + plan computation +└── renderer/ + ├── index.html # Login / fleet / campaign views + ├── app.css + └── app.js # UI logic +``` + +## Known limitations + +- The `/v1/onus//upgrade/status/` path used by the original question + isn't part of the dev-guide-documented surface; if your MCMS returns + 404 for it, fall back to polling `GET /v1/onus/configs//` and + watching `FW Bank Versions` + `FW Bank Ptr` change. +- Progress of a bulk task is reported back by MCMS in its own task + status collection — the tool currently submits and then leaves + monitoring to the PON Manager UI. Follow-up work: poll + `/v3/tasks/states//`. +- No offline mode / cached fleet snapshot. Each `Load fleet` hits the + API. +- PATCH endpoints aren't used — MCMS requires full-document PUTs for + `ONU-CFG` updates (the dev guide is explicit about this). + +## License + +Internal use only. diff --git a/main.js b/main.js new file mode 100644 index 0000000..f739887 --- /dev/null +++ b/main.js @@ -0,0 +1,619 @@ +// Electron main process for pon-fleet-upgrader. +// +// Responsibilities: +// - Own the MCMS session (cookies stay out of the renderer). +// - Expose IPC handlers that the renderer calls via window.api.*. +// - Centralize TLS cert handling for self-signed internal MCMS installs. + +const { app, BrowserWindow, ipcMain, dialog } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { McmsClient, McmsApiError, extractOnuHealth } = require('./src/mcms-api'); +const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); + +/** @type {McmsClient | null} */ +let client = null; +let mainWindow = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + title: 'PON Fleet Upgrader', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); +} + +app.whenReady().then(() => { + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +// ---- IPC handlers ---------------------------------------------------- + +function requireClient() { + if (!client) throw new Error('Not logged in — call api.login first.'); + return client; +} + +// Format MCMS API errors for the renderer without leaking stack traces. +function toWireError(err) { + if (err instanceof McmsApiError) { + return { message: err.message, status: err.status, body: err.body }; + } + return { message: err?.message || String(err) }; +} + +ipcMain.handle('api:login', async (_ev, { baseUrl, username, password, acceptSelfSigned }) => { + try { + client = new McmsClient({ + baseUrl, + rejectUnauthorized: !acceptSelfSigned, + // Verbose logging goes to the Electron terminal (npm start output). + // Turn off by setting PON_FLEET_VERBOSE=0 in the env. + verbose: process.env.PON_FLEET_VERBOSE !== '0', + }); + const result = await client.login(username, password); + return { ok: true, data: result }; + } catch (err) { + client = null; + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:logout', async () => { + if (client) await client.logout(); + client = null; + return { ok: true }; +}); + +ipcMain.handle('api:listOnuConfigs', async (_ev, opts) => { + try { + const c = requireClient(); + // No projection and no query — MCMS's schema validator is picky about + // projection paths that don't exist on every document version, and its + // URL-param encoding of Mongo-style queries has been unreliable across + // builds (spaces in field names, JSON escaping). We load the full fleet + // (bounded by `limit`) and filter entirely client-side. + const data = await c.listOnuConfigs({ + limit: opts?.limit, + skip: opts?.skip, + }); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Fast fleet load: configs + onu-states + olt-states. Tries the bulk + * state endpoint first; if that fails, the renderer can follow up with + * api:fetchStates to fetch states per-ONU. OLT-STATE carries the + * registration bucket (Registered / Deregistered / Dying Gasp / etc.) + * per ONU — see 323-1961-306 Procedure 2 — so we load it here and tag + * each merged ONU with its `_status.status`. + */ +ipcMain.handle('api:listFleet', async (_ev) => { + try { + const c = requireClient(); + const configs = await c.listAllOnuConfigs(); + let states = []; + let statesError = null; + try { + states = await c.listAllOnuStates(); + } catch (e) { + statesError = toWireError(e); + } + // OLT-STATE fetch is best-effort. If it fails the UI falls back to + // "unknown" status and the down-duration filter is simply unavailable. + let oltStates = []; + let oltStatesError = null; + try { + oltStates = await c.listAllOltStates(); + } catch (e) { + oltStatesError = toWireError(e); + } + // Build onuId -> { status, oltMac } from OLT-STATE["ONU States"]. + // An ONU should appear in exactly one bucket on exactly one OLT; in + // the rare case of duplicates we prefer a non-"Registered" bucket + // since that's the interesting signal (the newer OLT wins otherwise). + 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((s) => [s._id, s])); + 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, // null on success, error object on failure + oltStatesError, // same, for OLT-STATE fetch + }, + }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Phase 2: fetch ONU-STATE per-ONU with bounded concurrency. The web + * UI on this MCMS build hits /v3/onus/states// individually rather + * than the bulk list endpoint, which matches what we do here. Progress + * events stream back on 'states:progress' so the UI can fill in the + * Equipment ID column as results arrive. + */ +ipcMain.handle('api:fetchStates', async (event, { onuIds, concurrency = 20 }) => { + try { + const c = requireClient(); + const queue = [...onuIds]; + const results = []; + let done = 0; + const total = onuIds.length; + + async function worker() { + for (;;) { + const id = queue.shift(); + if (id === undefined) return; + let state = null; + try { + state = await c.getOnuState(id); + } catch (e) { + // Per-ONU failure is non-fatal; the UI will just show a blank + // Equipment ID for this row. + state = null; + } + results.push({ onuId: id, state }); + done += 1; + event.sender.send('states:progress', { onuId: id, state, done, total }); + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker()); + await Promise.all(workers); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:getOnuConfig', async (_ev, { onuId }) => { + try { + const c = requireClient(); + const data = await c.getOnuConfig(onuId); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:listOnuFirmware', async () => { + try { + const c = requireClient(); + const data = await c.listOnuFirmware(); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:uploadFirmware', async () => { + try { + const c = requireClient(); + const picked = await dialog.showOpenDialog(mainWindow, { + title: 'Select ONU firmware (.bin)', + filters: [{ name: 'Firmware', extensions: ['bin'] }], + properties: ['openFile'], + }); + if (picked.canceled || picked.filePaths.length === 0) { + return { ok: false, error: { message: 'cancelled' } }; + } + const filePath = picked.filePaths[0]; + const buf = fs.readFileSync(filePath); + const filename = path.basename(filePath); + const base64 = buf.toString('base64'); + // Infer version from filename (e.g. Interos-Everest-5.12.0-R-EV05120R.bin + // -> version "EV05120R"). Users can fix this up later if wrong. + const versionMatch = filename.match(/([A-Z]{2}\d{5}[A-Z]?)/); + const metadata = { + 'Compatible Manufacturer': 'TIBITCOM', + 'Compatible Model': ['MicroPlug ONU'], + Version: versionMatch ? versionMatch[1] : '', + }; + const body = await c.uploadOnuFirmware(filename, base64, metadata); + return { ok: true, data: { filename, metadata, body } }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Dry-run planner: given a set of ONUs and a target firmware, return the + * planned write-slot + before/after for each ONU *without* mutating anything. + */ +ipcMain.handle('api:planUpgrade', async (_ev, { onuIds, targetFile, targetVersion }) => { + try { + const c = requireClient(); + const plans = []; + for (const onuId of onuIds) { + const cfg = await c.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 }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Per-ONU execution (Procedure 7) — iterates, fetches the doc, mutates the + * FW bank fields, PUTs the full document back. This is the "one-at-a-time, + * see errors live" path. + */ +ipcMain.handle('api:executePerOnu', async (event, { onuIds, targetFile, targetVersion }) => { + try { + const c = requireClient(); + const results = []; + for (let i = 0; i < onuIds.length; i++) { + const onuId = onuIds[i]; + // Fire per-ONU progress back to the renderer. + event.sender.send('upgrade:progress', { + index: i, + total: onuIds.length, + onuId, + phase: 'fetching', + }); + try { + const cfg = await c.getOnuConfig(onuId); + if (!cfg) throw new Error('ONU config not found'); + + const plan = planUpgrade(cfg, { targetFile, targetVersion }); + // Apply the mutation in place on the fetched document so we PUT the + // full doc back (MCMS uses PUT = replace, not partial). + const mutated = { ...cfg, ONU: { ...cfg.ONU, ...plan.fwFields } }; + + event.sender.send('upgrade:progress', { + index: i, + total: onuIds.length, + onuId, + phase: 'writing', + writeSlot: plan.writeSlot, + }); + await c.putOnuConfig(onuId, mutated); + results.push({ onuId, ok: true, writeSlot: plan.writeSlot }); + } catch (err) { + results.push({ onuId, ok: false, error: toWireError(err) }); + } + } + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Bulk execution (Procedure 8) — single AUTO-TASK-CFG that MCMS schedules + * across all selected serials. Recommended path for large fleets. + */ +ipcMain.handle('api:executeBulkTask', async (_ev, opts) => { + try { + const c = requireClient(); + const body = await c.createFirmwareTask(opts); + return { ok: true, data: body }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:getTaskConfig', async (_ev, { taskId }) => { + try { + const c = requireClient(); + const data = await c.getTaskConfig(taskId); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +ipcMain.handle('api:getUpgradeStatus', async (_ev, { onuId }) => { + try { + const c = requireClient(); + const data = await c.getOnuUpgradeStatus(onuId); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Pre-flight link-health check. For each selected ONU, re-fetch the + * ONU-STATE document (so the snapshot is fresh at decision time, not + * stale from the original fleet load) and reduce it to a single + * health flag (ok / pre / post / buggy / nodata / error) plus the + * raw FEC counters and optical levels. + * + * Source fields (observed on MCMS 6.2 + Interos Everest 5.x ONUs): + * STATE.STATS["ONU-PON"]["RX Pre-FEC BER" | "RX Post-FEC BER" + * | "RX Optical Level" | "TX Optical Level"] + * STATE.STATS["OLT-PON"]["RX Pre-FEC BER" | "RX Post-FEC BER"] + * + * Streams progress on 'fec:progress' as each ONU resolves so the + * preview table can paint its pill the moment its result lands. + */ +ipcMain.handle('api:fetchFecHealth', async (event, { onuIds, concurrency = 8 }) => { + try { + const c = requireClient(); + const queue = [...onuIds]; + const results = []; + let done = 0; + const total = onuIds.length; + + async function worker() { + for (;;) { + const id = queue.shift(); + if (id === undefined) return; + let entry = { onuId: id, flag: 'nodata' }; + try { + const stateDoc = await c.getOnuState(id); + const health = extractOnuHealth(stateDoc); + entry = { + onuId: id, + flag: health.flag, + detail: health.detail || '', + counters: health.counters || [], + optical: health.optical || {}, + sampleTime: stateDoc?.Time || '', + }; + } catch (e) { + entry = { onuId: id, flag: 'error', error: toWireError(e) }; + } + results.push(entry); + done += 1; + event.sender.send('fec:progress', { ...entry, done, total }); + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker()); + await Promise.all(workers); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Internal CSV writer used by both the plan-save (on Execute) and the + * verify-save (after a comparison run). Always quotes every cell — + * MCMS values are full of CSV-hostile characters (commas in addresses, + * embedded quotes in operator-entered names, occasional newlines). + * Always prepends a UTF-8 BOM so Excel auto-detects the encoding and + * Norwegian Æ/Ø/Å survive on Windows. + */ +function writeCsvFile({ prefix, filenameHint, headers, rows }) { + const downloadsDir = app.getPath('downloads'); + const ts = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const stamp = + `${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}` + + `-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}`; + const safeHint = (filenameHint || 'csv') + .toString() + .replace(/[^A-Za-z0-9._-]+/g, '_') + .slice(0, 60); + const filename = `${prefix}-${safeHint}-${stamp}.csv`; + const fullPath = path.join(downloadsDir, filename); + + const csvCell = (v) => { + if (v === null || v === undefined) return ''; + const s = typeof v === 'string' ? v : String(v); + return '"' + s.replace(/"/g, '""') + '"'; + }; + const csvRow = (cells) => cells.map(csvCell).join(','); + const lines = [csvRow(headers)]; + for (const row of rows) lines.push(csvRow(headers.map((h) => row[h]))); + const body = '' + lines.join('\r\n') + '\r\n'; + fs.writeFileSync(fullPath, body, { encoding: 'utf8' }); + return { path: fullPath, filename, rowCount: rows.length }; +} + +/** + * Save an upgrade plan to a timestamped CSV in the user's Downloads + * folder. Called automatically by the renderer the moment the operator + * clicks Execute, so there's always a paper trail of what got + * scheduled — even if the MCMS write later fails. + */ +ipcMain.handle('api:savePlanCsv', async (_ev, { rows, filenameHint, headers }) => { + try { + if (!Array.isArray(rows) || !Array.isArray(headers)) { + throw new Error('rows and headers are required arrays'); + } + const data = writeCsvFile({ + prefix: 'pon-upgrade', filenameHint, headers, rows, + }); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Generic CSV save. Used by the verify flow with prefix='pon-verify'. + * Same writeCsvFile semantics as the plan save. + */ +ipcMain.handle('api:saveCsvFile', async (_ev, { rows, filenameHint, headers, prefix = 'pon-csv' }) => { + try { + if (!Array.isArray(rows) || !Array.isArray(headers)) { + throw new Error('rows and headers are required arrays'); + } + const safePrefix = String(prefix).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40) || 'pon-csv'; + const data = writeCsvFile({ prefix: safePrefix, filenameHint, headers, rows }); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Open an existing CSV from disk (default: Downloads). Returns the raw + * text so the renderer can parse it with its own CSV parser. We don't + * parse here because the renderer already needs to know the column + * layout to build comparisons. + */ +ipcMain.handle('api:openCsvFile', async () => { + try { + const picked = await dialog.showOpenDialog(mainWindow, { + title: 'Open upgrade plan CSV', + defaultPath: app.getPath('downloads'), + filters: [{ name: 'CSV', extensions: ['csv'] }], + properties: ['openFile'], + }); + if (picked.canceled || !picked.filePaths.length) { + return { ok: false, error: { message: 'cancelled' } }; + } + const filePath = picked.filePaths[0]; + const text = fs.readFileSync(filePath, 'utf8'); + return { ok: true, data: { path: filePath, text } }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Per-ONU snapshot fetch for the verify flow: pulls BOTH the ONU-STATE + * (for current FEC counters and optical levels) AND the ONU-CFG (so we + * can verify the active firmware version actually flipped to the + * target). Streams progress on 'verify:progress' so the table can + * update incrementally on big lists. + */ +ipcMain.handle('api:fetchOnuSnapshot', async (event, { onuIds, concurrency = 8 }) => { + try { + const c = requireClient(); + const queue = [...onuIds]; + const results = []; + let done = 0; + const total = onuIds.length; + + async function worker() { + for (;;) { + const id = queue.shift(); + if (id === undefined) return; + let entry = { onuId: id }; + try { + const [stateDoc, cfgDoc] = await Promise.all([ + c.getOnuState(id).catch(() => null), + c.getOnuConfig(id).catch(() => null), + ]); + const health = extractOnuHealth(stateDoc); + entry = { + onuId: id, + flag: health.flag, + counters: health.counters || [], + optical: health.optical || {}, + sampleTime: stateDoc?.Time || '', + cfg: cfgDoc, + }; + } catch (e) { + entry = { onuId: id, flag: 'error', error: toWireError(e) }; + } + results.push(entry); + done += 1; + event.sender.send('verify:progress', { onuId: id, done, total, flag: entry.flag }); + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker()); + await Promise.all(workers); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); + +/** + * Bulk delete ONUs from MCMS. For each ONU we delete the CFG document + * (Procedure 43 in the PON Manager User Guide), then best-effort delete + * the STATE document so stale Equipment-ID/registration entries don't + * hang around. STATE deletion failures are ignored — the OLT will + * rewrite it on next registration anyway, but CFG deletion is the + * authoritative action. + * + * Progress streams back on 'delete:progress' so the renderer can tick + * through a large selection without locking up. Concurrency is kept low + * (default 5) because DELETE is more disruptive than GET and we don't + * want to stampede the MCMS backend. + */ +ipcMain.handle('api:deleteOnus', async (event, { onuIds, concurrency = 5 }) => { + try { + const c = requireClient(); + const queue = [...onuIds]; + const results = []; + let done = 0; + const total = onuIds.length; + + async function worker() { + for (;;) { + const id = queue.shift(); + if (id === undefined) return; + let ok = false; + let error = null; + try { + await c.deleteOnuConfig(id); + ok = true; + // State deletion is best-effort and does not affect `ok`. + try { await c.deleteOnuState(id); } catch (_) { /* ignore */ } + } catch (e) { + error = toWireError(e); + } + results.push({ onuId: id, ok, error }); + done += 1; + event.sender.send('delete:progress', { onuId: id, ok, error, done, total }); + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker()); + await Promise.all(workers); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: toWireError(err) }; + } +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b148dc1 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "pon-fleet-upgrader", + "version": "0.1.0", + "description": "Bulk firmware upgrades for Ciena MCMS PON fleets — filter by model, write to inactive bank.", + "main": "main.js", + "scripts": { + "start": "electron .", + "check": "node --check main.js && node --check preload.js && node --check src/mcms-api.js && node --check src/bank-strategy.js && node --check renderer/app.js" + }, + "author": "", + "license": "UNLICENSED", + "devDependencies": { + "electron": "^31.0.0" + }, + "dependencies": { + "tough-cookie": "^4.1.4" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..e501205 --- /dev/null +++ b/preload.js @@ -0,0 +1,69 @@ +// Preload: the only bridge between renderer and main. Everything the UI +// can do is enumerated here — the renderer has no direct node access. + +const { contextBridge, ipcRenderer } = require('electron'); + +function call(channel, args) { + return ipcRenderer.invoke(channel, args); +} + +contextBridge.exposeInMainWorld('api', { + login: (opts) => call('api:login', opts), + logout: () => call('api:logout'), + + listOnuConfigs: (opts) => call('api:listOnuConfigs', opts), + listFleet: (opts) => call('api:listFleet', opts), + fetchStates: (opts) => call('api:fetchStates', opts), + getOnuConfig: (opts) => call('api:getOnuConfig', opts), + listOnuFirmware: () => call('api:listOnuFirmware'), + uploadFirmware: () => call('api:uploadFirmware'), + + planUpgrade: (opts) => call('api:planUpgrade', opts), + executePerOnu: (opts) => call('api:executePerOnu', opts), + executeBulkTask: (opts) => call('api:executeBulkTask', opts), + getTaskConfig: (opts) => call('api:getTaskConfig', opts), + getUpgradeStatus: (opts) => call('api:getUpgradeStatus', opts), + + deleteOnus: (opts) => call('api:deleteOnus', opts), + + fetchFecHealth: (opts) => call('api:fetchFecHealth', opts), + fetchOnuSnapshot: (opts) => call('api:fetchOnuSnapshot', opts), + savePlanCsv: (opts) => call('api:savePlanCsv', opts), + saveCsvFile: (opts) => call('api:saveCsvFile', opts), + openCsvFile: () => call('api:openCsvFile'), + + // Streaming FEC pre-flight progress (one event per ONU resolved). + onFecProgress: (handler) => { + const fn = (_ev, payload) => handler(payload); + ipcRenderer.on('fec:progress', fn); + return () => ipcRenderer.removeListener('fec:progress', fn); + }, + + // Streaming verify-snapshot progress. + onVerifyProgress: (handler) => { + const fn = (_ev, payload) => handler(payload); + ipcRenderer.on('verify:progress', fn); + return () => ipcRenderer.removeListener('verify:progress', fn); + }, + + // Live progress events from the main process while executePerOnu runs. + onUpgradeProgress: (handler) => { + const fn = (_ev, payload) => handler(payload); + ipcRenderer.on('upgrade:progress', fn); + return () => ipcRenderer.removeListener('upgrade:progress', fn); + }, + + // Streaming state-fetch progress (Equipment ID population). + onStatesProgress: (handler) => { + const fn = (_ev, payload) => handler(payload); + ipcRenderer.on('states:progress', fn); + return () => ipcRenderer.removeListener('states:progress', fn); + }, + + // Streaming delete progress. + onDeleteProgress: (handler) => { + const fn = (_ev, payload) => handler(payload); + ipcRenderer.on('delete:progress', fn); + return () => ipcRenderer.removeListener('delete:progress', fn); + }, +}); diff --git a/renderer/app.css b/renderer/app.css new file mode 100644 index 0000000..28565a2 --- /dev/null +++ b/renderer/app.css @@ -0,0 +1,213 @@ +/* Simple, dense, operator-oriented UI. + No framework — just hand-tuned CSS. */ + +: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; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + 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; } +.muted { color: var(--fg-dim); } +.small { font-size: 12px; } +.hidden { display: none !important; } +.error { color: var(--danger); } +.row { display: flex; gap: 8px; align-items: center; } +.inline { display: inline-flex; align-items: center; gap: 6px; } + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); +} +.topbar .brand { font-weight: 600; letter-spacing: 0.3px; } +.topbar .session { margin-left: auto; color: var(--fg-dim); font-family: var(--mono); font-size: 12px; } + +.view { display: flex; gap: 0; height: calc(100vh - 45px); } +#view-login { justify-content: center; align-items: flex-start; padding-top: 60px; } + +.card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 24px; + width: 440px; + display: flex; + flex-direction: column; + gap: 12px; +} +.card h1 { margin: 0 0 4px; font-size: 18px; } +.card p { margin: 0 0 8px; } + +label { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--fg-dim); + font-size: 12px; +} +label.inline { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); } + +input[type="text"], input[type="password"], input[type="url"], +input[type="number"], input[type="search"], input[type="datetime-local"], +select { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--fg); + padding: 7px 9px; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + outline: none; +} +input:focus, select:focus { border-color: var(--accent); } + +button { + font-family: inherit; + font-size: 13px; + padding: 7px 14px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-elev-2); + color: var(--fg); + cursor: pointer; +} +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); } + +.panel-left { + width: 320px; + padding: 16px; + border-right: 1px solid var(--border); + background: var(--bg-elev); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; +} +.panel-left h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--fg-dim); margin: 12px 0 0; } +.panel-left h2:first-child { margin-top: 0; } + +.panel-main { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} +.panel-main h2 { margin: 0; font-size: 15px; } +.fleet-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } + +.selected-summary { + background: var(--bg-elev-2); + padding: 8px 10px; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.fleet-table-wrap { + flex: 1; + overflow: auto; + border: 1px solid var(--border); + border-radius: 4px; +} +.fleet-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.fleet-table thead th { + position: sticky; + top: 0; + background: var(--bg-elev); + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + font-weight: 600; + z-index: 1; +} +.fleet-table tbody td { + padding: 6px 10px; + border-bottom: 1px solid var(--border); + font-family: var(--mono); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + 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); } + +.status-ok { color: var(--ok); } +.status-warn { color: var(--warn); } +.status-err { color: var(--danger); } +.status-info { color: var(--info); } +.status-pending { color: var(--fg-dim); } + +/* Compact FEC counter readout under the health pill in the preview + table. Each line is "tail=value" so the operator can correlate with + the same names used by the PON Manager UI (Pre-FEC BER etc.). */ +.fec-cell { white-space: normal; max-width: 280px; } + +/* 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 .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-counters { + display: block; + margin-top: 3px; + color: var(--fg-dim); + font-family: var(--mono); + font-size: 11px; + line-height: 1.35; +} +.fec-counters .pre { color: var(--warn); } +.fec-counters .post { color: var(--danger); } +.fec-counters .ok { color: var(--fg-dim); } + +.slot-arrow { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-elev-2); + border: 1px solid var(--border); +} diff --git a/renderer/app.js b/renderer/app.js new file mode 100644 index 0000000..f6f07ba --- /dev/null +++ b/renderer/app.js @@ -0,0 +1,1282 @@ +// Renderer-side app logic. Uses window.api exposed by preload.js. +// No framework — vanilla DOM. + +// -------- State -------- +const state = { + fleet: [], // array of ONU-CFG documents (shallow — see main.js projection) + filtered: [], + selected: new Set(), + firmwareList: [], // GET /files/onu-firmware/ result + plan: [], // result of api.planUpgrade + fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? } + campaignMode: 'task', // 'task' | 'peronu' + verify: null, // { sourcePath, beforeRows, snapshotById, comparisons } +}; + +// -------- Helpers -------- +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => document.querySelectorAll(sel); + +function showView(id) { + for (const v of $$('.view')) v.classList.add('hidden'); + $(id).classList.remove('hidden'); +} + +function activeBank(cfg) { + const ptr = cfg?.ONU?.['FW Bank Ptr']; + return typeof ptr === 'number' ? ptr : null; +} +function versionAt(cfg, slot) { + return cfg?.ONU?.['FW Bank Versions']?.[slot] || ''; +} +function fileAt(cfg, slot) { + return cfg?.ONU?.['FW Bank Files']?.[slot] || ''; +} +function activeVersion(cfg) { + const ptr = activeBank(cfg); + if (ptr === null || ptr === 65535) return '(unset)'; + return versionAt(cfg, ptr) || '(empty)'; +} +function inactiveVersion(cfg) { + const ptr = activeBank(cfg); + if (ptr === null || ptr === 65535) return '(unset)'; + const other = ptr === 0 ? 1 : 0; + return versionAt(cfg, other) || '(empty)'; +} + +// Equipment ID is device-reported and lives on ONU-STATE (joined into +// `_state` by main.js). Some deployments also surface it under ONU-CFG +// directly; check both paths so the filter works regardless. +function equipmentId(cfg) { + return ( + cfg?._state?.ONU?.['Equipment ID'] || + cfg?._state?.['Equipment ID'] || + cfg?.ONU?.['Equipment ID'] || + '' + ); +} +function vendor(cfg) { + return ( + cfg?._state?.ONU?.Vendor || + cfg?._state?.Vendor || + cfg?.ONU?.Vendor || + '' + ); +} + +// Registration bucket from OLT-STATE["ONU States"] (see 323-1961-306 +// Procedure 2). null means the ONU didn't appear in any OLT bucket — +// usually a preprovisioned ONU that has never registered. +function onuStatus(cfg) { + return cfg?._status?.status || null; +} + +// Statuses we consider "down" for the bulk-down filter + delete workflow. +// "Disallowed *" are admin/config states rather than fault states, but +// from a "this ONU isn't carrying traffic" perspective they behave the +// same — group them in when the user picks the "Down" aggregate. +const DOWN_STATUSES = new Set([ + 'Deregistered', + 'Dying Gasp', + 'Disabled', + 'Disallowed Admin', + 'Disallowed Error', + 'Disallowed Reg ID', +]); + +// Timestamp of the most recent MCMS update to this ONU's STATE doc. For +// a down ONU this is a proxy for "last heard from" — the OLT refreshes +// ONU-STATE on registration events, alarms, and periodic stats, so an +// ONU that's been dark for a month will have a correspondingly old Time. +function lastSeenDate(cfg) { + const t = cfg?._state?.Time; + if (!t) return null; + // MCMS emits timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC. + // Replace the space with 'T' and append 'Z' so Date parses as UTC. + const iso = String(t).replace(' ', 'T') + (String(t).endsWith('Z') ? '' : 'Z'); + const d = new Date(iso); + return isNaN(d.getTime()) ? null : d; +} +function daysSince(date) { + if (!date) return null; + return (Date.now() - date.getTime()) / 86400000; +} +function formatRelative(days) { + if (days === null || days === undefined) return ''; + if (days < 1) return `${Math.round(days * 24)}h ago`; + if (days < 60) return `${Math.round(days)}d ago`; + return `${Math.round(days / 30)}mo ago`; +} + +// FEC health pill. The mapping mirrors the user-guide green-LED +// criteria (323-1961-302 p149): post-FEC nonzero is the bright red +// signal, pre-FEC alone (post=0) is "FEC is doing its job, marginal" +// and shows yellow. Blue ("buggy") is added on top of the user-guide +// rules: when pre-FEC == post-FEC and both > 0, the ONU firmware is +// almost certainly mirroring one counter into the other rather than +// the link being identically broken — surface it distinctly so the +// operator doesn't waste time troubleshooting a phantom. +const FEC_LABELS = { + ok: { text: 'OK', cls: 'status-ok' }, + pre: { text: 'Pre-FEC errors', cls: 'status-warn' }, + post: { text: 'Post-FEC errors', cls: 'status-err' }, + buggy: { text: 'Pre==Post (ONU bug?)', cls: 'status-info' }, + nodata: { text: 'no recent stats', cls: 'muted' }, + error: { text: 'fetch failed', cls: 'muted' }, + pending: { text: 'checking…', cls: 'muted' }, +}; + +// Mirror of formatFecNumber in src/mcms-api.js. Kept in the renderer so +// we don't have to round-trip through IPC to format a number — and so +// we can re-format if the structure ever changes. +function formatFecNumber(n) { + if (n === 0) return '0'; + const abs = Math.abs(n); + if (Number.isInteger(n)) return String(n); + if (abs < 0.01 || abs >= 1e6) return n.toExponential(2); + return Number(n.toPrecision(3)).toString(); +} + +function fecCountersHtml(health) { + const list = Array.isArray(health?.counters) ? health.counters : []; + const optical = health?.optical || {}; + if (!list.length && optical.rx == null && optical.tx == null) return ''; + + const lines = []; + + // FEC counters: ONU side first (most relevant — the ONU is the device + // we're about to upgrade), then OLT side. Skip counters whose scope + // we don't recognise. + const order = (c) => (c.scope === 'onu' ? 0 : c.scope === 'olt' ? 1 : 2); + const sorted = [...list].sort((a, b) => { + const so = order(a) - order(b); + if (so) return so; + return a.kind === b.kind ? 0 : a.kind === 'pre' ? -1 : 1; + }); + for (const c of sorted) { + const cls = c.value > 0 ? c.kind : 'ok'; + lines.push( + `${escapeHtml(c.tail)}=${escapeHtml(formatFecNumber(c.value))}`, + ); + } + + // Optical levels (dBm). User-guide green-LED threshold is RX >= -30 + // dBm and TX >= 3 dBm (323-1961-302 p149). We highlight RX in red + // below -30 and yellow as it approaches the floor (-28 dBm), so the + // operator sees the marginal links during the FEC sweep. + if (optical.rx != null || optical.tx != null) { + const opticalParts = []; + if (optical.rx != null) { + const rxCls = optical.rx < -30 ? 'post' : optical.rx < -28 ? 'pre' : 'ok'; + opticalParts.push( + `RX ${escapeHtml(optical.rx.toFixed(1))} dBm`, + ); + } + if (optical.tx != null) { + const txCls = optical.tx < 3 ? 'post' : 'ok'; + opticalParts.push( + `TX ${escapeHtml(optical.tx.toFixed(1))} dBm`, + ); + } + lines.push(opticalParts.join(' ')); + } + + return `${lines.join('
')}
`; +} + +function fecPillHtml(health) { + const f = (health && health.flag) || 'pending'; + const label = FEC_LABELS[f] || FEC_LABELS.pending; + const title = health?.detail || health?.error?.message || ''; + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; + return ( + `${escapeHtml(label.text)}` + + fecCountersHtml(health) + ); +} + +// -------- Login -------- +$('#btn-login').addEventListener('click', async () => { + $('#login-error').textContent = ''; + const baseUrl = $('#in-host').value.trim(); + const username = $('#in-user').value.trim(); + const password = $('#in-pass').value; + const acceptSelfSigned = $('#in-self-signed').checked; + if (!baseUrl || !username || !password) { + $('#login-error').textContent = 'Host, username and password are required.'; + return; + } + $('#btn-login').disabled = true; + $('#btn-login').textContent = 'Connecting…'; + const res = await window.api.login({ baseUrl, username, password, acceptSelfSigned }); + $('#btn-login').disabled = false; + $('#btn-login').textContent = 'Connect'; + if (!res.ok) { + $('#login-error').textContent = res.error?.message || 'Login failed'; + return; + } + $('#session-label').textContent = `${username} @ ${baseUrl}`; + $('#btn-logout').classList.remove('hidden'); + showView('#view-fleet'); +}); + +$('#btn-logout').addEventListener('click', async () => { + await window.api.logout(); + $('#session-label').textContent = 'Not connected'; + $('#btn-logout').classList.add('hidden'); + state.fleet = []; state.filtered = []; state.selected.clear(); + showView('#view-login'); +}); + +// -------- Fleet loading + filtering -------- +$('#btn-refresh').addEventListener('click', loadFleet); + +async function loadFleet() { + $('#fleet-status').textContent = 'Loading fleet…'; + const res = await window.api.listFleet({}); + if (!res.ok) { + $('#fleet-status').textContent = `Error: ${res.error?.message || 'unknown'}`; + return; + } + state.fleet = res.data || []; + applyFilters(); + const meta = res.meta || {}; + const withEq = state.fleet.filter((c) => equipmentId(c)).length; + + if (meta.statesError) { + // Bulk state fetch failed — fall back to per-ONU streaming fetch. + $('#fleet-status').textContent = + `Loaded ${state.fleet.length} ONUs. Fetching hardware info per-ONU…`; + const byId = new Map(state.fleet.map((c) => [c._id, c])); + const unbind = window.api.onStatesProgress(({ onuId, state: s, done, total }) => { + const cfg = byId.get(onuId); + if (cfg && s) cfg._state = s; + // Throttle full re-renders: update every 50 results (or on last). + if (done === total || done % 50 === 0) { + applyFilters(); + const eq = state.fleet.filter((c) => equipmentId(c)).length; + $('#fleet-status').textContent = + `Loaded ${state.fleet.length} ONUs. Hardware info: ${done}/${total} (${eq} with Equipment ID).`; + } + }); + const fetchRes = await window.api.fetchStates({ + onuIds: state.fleet.map((c) => c._id), + }); + unbind(); + applyFilters(); + const eq = state.fleet.filter((c) => equipmentId(c)).length; + if (!fetchRes.ok) { + $('#fleet-status').textContent = + `Loaded ${state.fleet.length} ONUs. Hardware info fetch failed: ${fetchRes.error?.message}`; + } else { + $('#fleet-status').textContent = + `Loaded ${state.fleet.length} ONUs (${eq} with Equipment ID).`; + } + return; + } + + const stateNote = meta.stateCount === 0 + ? ' — no ONU states returned (Equipment ID will be blank)' + : withEq + ? ` (${withEq} with Equipment ID)` + : ''; + const oltNote = meta.oltStatesError + ? ` — OLT-STATE fetch failed (${meta.oltStatesError.message}); status filter unavailable` + : meta.oltStateCount + ? `, ${meta.oltStateCount} OLT(s) for status` + : ''; + $('#fleet-status').textContent = + `Loaded ${state.fleet.length} ONUs${stateNote}${oltNote}.`; +} + +function applyFilters() { + const text = $('#filter-text').value.trim().toLowerCase(); + const equipment = $('#filter-equipment').value.trim().toLowerCase(); + const ponMode = $('#filter-pon').value.trim(); + const version = $('#filter-version').value.trim().toLowerCase(); + const excludeVersion = $('#filter-exclude-version').value.trim().toLowerCase(); + const model = $('#filter-model').value.trim().toLowerCase(); + const statusFilter = $('#filter-status').value; + const downDaysRaw = $('#filter-down-days').value.trim(); + const downDays = downDaysRaw ? Number(downDaysRaw) : null; + + state.filtered = state.fleet.filter((cfg) => { + if (text) { + const hay = `${cfg?.ONU?.Name || ''} ${cfg?.ONU?.Address || ''} ${cfg?._id || ''}`.toLowerCase(); + if (!hay.includes(text)) return false; + } + if (equipment) { + const eid = equipmentId(cfg).toLowerCase(); + if (!eid.includes(equipment)) return false; + } + if (ponMode) { + // PON Mode is a CFG field — client-side exact match now. + if ((cfg?.ONU?.['PON Mode'] || '') !== ponMode) return false; + } + if (version) { + const v = activeVersion(cfg).toLowerCase(); + if (!v.includes(version)) return false; + } + if (excludeVersion) { + // Drop devices whose active version matches — useful for "already + // upgraded" devices during a rolling fleet rollout. + const v = activeVersion(cfg).toLowerCase(); + if (v.includes(excludeVersion)) return false; + } + if (model) { + // "model" here is matched against either slot's version string, so a + // prefix like "EV051" catches the whole Everest 5.1x family whether + // the device is currently on 5.11 or 5.12. + const v0 = versionAt(cfg, 0).toLowerCase(); + const v1 = versionAt(cfg, 1).toLowerCase(); + if (!v0.includes(model) && !v1.includes(model)) return false; + } + if (statusFilter) { + const s = onuStatus(cfg); + if (statusFilter === '__down__') { + if (!DOWN_STATUSES.has(s)) return false; + } else if (statusFilter === '__unknown__') { + if (s !== null) return false; + } else { + if (s !== statusFilter) return false; + } + } + if (downDays !== null && !Number.isNaN(downDays)) { + // "Down for at least N days" — only meaningful for ONUs that are + // actually down AND have a last-seen timestamp. Registered ONUs and + // those without an ONU-STATE doc are excluded when this filter is + // active (otherwise we'd show a bunch of online ONUs whose Time + // field happens to be old just because they're idle). + const s = onuStatus(cfg); + if (!DOWN_STATUSES.has(s)) return false; + const d = daysSince(lastSeenDate(cfg)); + if (d === null || d < downDays) return false; + } + return true; + }); + renderFleet(); +} + +for (const el of [ + $('#filter-text'), + $('#filter-equipment'), + $('#filter-version'), + $('#filter-exclude-version'), + $('#filter-model'), + $('#filter-down-days'), +]) { + el.addEventListener('input', applyFilters); +} +$('#filter-pon').addEventListener('change', applyFilters); +$('#filter-status').addEventListener('change', applyFilters); + +function renderFleet() { + const tbody = $('#fleet-tbody'); + tbody.innerHTML = ''; + for (const cfg of state.filtered) { + const tr = document.createElement('tr'); + const id = cfg._id; + if (state.selected.has(id)) tr.classList.add('selected'); + const ptr = activeBank(cfg); + const s = onuStatus(cfg); + const statusCls = s === 'Registered' + ? 'status-ok' + : DOWN_STATUSES.has(s) + ? 'status-err' + : s + ? 'status-pending' + : 'muted'; + const statusText = s || 'unknown'; + const seen = lastSeenDate(cfg); + const seenDays = daysSince(seen); + tr.innerHTML = ` + + ${escapeHtml(id)} + ${escapeHtml(cfg?.ONU?.Name || cfg?.ONU?.Address || '')} + ${escapeHtml(equipmentId(cfg))} + ${escapeHtml(cfg?.ONU?.['PON Mode'] || '')} + ${escapeHtml(statusText)} + ${seenDays === null ? '' : escapeHtml(formatRelative(seenDays))} + ${ptr === null || ptr === 65535 ? 'unset' : ptr} + ${escapeHtml(activeVersion(cfg))} + ${escapeHtml(inactiveVersion(cfg))} + `; + tbody.appendChild(tr); + } + $('#fleet-count').textContent = state.filtered.length; + updateSelectionSummary(); + // Wire checkboxes + for (const cb of tbody.querySelectorAll('input[type="checkbox"]')) { + cb.addEventListener('change', () => { + const id = cb.getAttribute('data-id'); + if (cb.checked) state.selected.add(id); else state.selected.delete(id); + cb.closest('tr').classList.toggle('selected', cb.checked); + updateSelectionSummary(); + }); + } +} + +$('#th-check').addEventListener('change', (e) => { + if (e.target.checked) { + for (const cfg of state.filtered) state.selected.add(cfg._id); + } else { + for (const cfg of state.filtered) state.selected.delete(cfg._id); + } + renderFleet(); +}); + +$('#btn-select-all').addEventListener('click', () => { + for (const cfg of state.filtered) state.selected.add(cfg._id); + renderFleet(); +}); +$('#btn-select-none').addEventListener('click', () => { + state.selected.clear(); + renderFleet(); +}); + +function updateSelectionSummary() { + $('#sel-count').textContent = state.selected.size; + $('#btn-to-campaign').disabled = state.selected.size === 0; + $('#btn-delete-selected').disabled = state.selected.size === 0; +} + +// -------- Delete -------- +$('#btn-delete-selected').addEventListener('click', async () => { + const ids = Array.from(state.selected); + if (!ids.length) return; + // Two-step confirmation: summary dialog first, then require typing + // DELETE to proceed. This is destructive (Procedure 43 removes the + // ONU-CFG document from MongoDB) and there's no undo from inside this + // app — a returning ONU will re-register but service-config is gone. + const downCount = ids.filter((id) => { + const cfg = state.fleet.find((c) => c._id === id); + return DOWN_STATUSES.has(onuStatus(cfg)); + }).length; + const summary = + `You're about to DELETE ${ids.length} ONU(s) from MCMS ` + + `(${downCount} currently down).\n\n` + + `This removes the ONU-CFG document and service configuration. ` + + `Physical ONUs that come back online will re-register from scratch.\n\n` + + `Click OK to continue to the final confirmation.`; + if (!confirm(summary)) return; + const typed = prompt(`Type DELETE to confirm removal of ${ids.length} ONU(s):`); + if (typed !== 'DELETE') { + $('#delete-status').textContent = 'Cancelled.'; + return; + } + + $('#btn-delete-selected').disabled = true; + $('#btn-to-campaign').disabled = true; + $('#delete-status').textContent = `Deleting 0/${ids.length}…`; + + const unbind = window.api.onDeleteProgress(({ done, total, ok, onuId, error }) => { + $('#delete-status').textContent = + `Deleting ${done}/${total}${error ? ` (last error on ${onuId}: ${error.message})` : ''}`; + }); + const res = await window.api.deleteOnus({ onuIds: ids }); + unbind(); + if (!res.ok) { + $('#delete-status').textContent = `Delete failed: ${res.error?.message}`; + $('#btn-delete-selected').disabled = false; + return; + } + const okCount = res.data.filter((r) => r.ok).length; + const failed = res.data.filter((r) => !r.ok); + // Drop successfully-deleted ONUs from local state so the table reflects + // the new reality without needing a full reload. + const deletedIds = new Set(res.data.filter((r) => r.ok).map((r) => r.onuId)); + state.fleet = state.fleet.filter((c) => !deletedIds.has(c._id)); + for (const id of deletedIds) state.selected.delete(id); + applyFilters(); + + let msg = `Deleted ${okCount}/${res.data.length}.`; + if (failed.length) { + msg += ` Failures: ` + failed.slice(0, 3).map((f) => `${f.onuId} (${f.error?.message || '?'})`).join(', '); + if (failed.length > 3) msg += ` and ${failed.length - 3} more`; + } + $('#delete-status').textContent = msg; +}); + +// -------- Campaign -------- +$('#btn-to-campaign').addEventListener('click', async () => { + showView('#view-campaign'); + await refreshFirmwareList(); +}); +$('#btn-back').addEventListener('click', () => showView('#view-fleet')); +$('#btn-reload-fw').addEventListener('click', refreshFirmwareList); + +$('#btn-upload-fw').addEventListener('click', async () => { + $('#exec-status').textContent = 'Uploading firmware…'; + const res = await window.api.uploadFirmware(); + if (!res.ok) { + if (res.error?.message === 'cancelled') { + $('#exec-status').textContent = ''; + } else { + $('#exec-status').textContent = `Upload failed: ${res.error?.message}`; + } + return; + } + $('#exec-status').textContent = `Uploaded ${res.data.filename}.`; + await refreshFirmwareList(); +}); + +async function refreshFirmwareList() { + const res = await window.api.listOnuFirmware(); + if (!res.ok) { + $('#exec-status').textContent = `Firmware list error: ${res.error?.message}`; + return; + } + state.firmwareList = res.data || []; + const sel = $('#fw-select'); + sel.innerHTML = ''; + for (const fw of state.firmwareList) { + const opt = document.createElement('option'); + // The firmware list entries are GridFS file metadata; shape varies + // slightly by MCMS version but _id / filename is always present. + const filename = fw.filename || fw._id || JSON.stringify(fw); + const version = fw?.metadata?.Version || fw?.Version || ''; + opt.value = filename; + opt.textContent = version ? `${filename} — ${version}` : filename; + opt.dataset.version = version; + sel.appendChild(opt); + } + if (state.firmwareList.length) { + sel.selectedIndex = 0; + $('#fw-version').value = sel.options[0].dataset.version || ''; + } +} + +$('#fw-select').addEventListener('change', () => { + const opt = $('#fw-select').selectedOptions[0]; + if (opt) $('#fw-version').value = opt.dataset.version || ''; +}); + +$('#mode-select').addEventListener('change', () => { + state.campaignMode = $('#mode-select').value; + $('#row-schedule').style.display = state.campaignMode === 'task' ? '' : 'none'; +}); + +$('#btn-preview').addEventListener('click', async () => { + const targetFile = $('#fw-select').value; + const targetVersion = $('#fw-version').value.trim(); + if (!targetFile || !targetVersion) { + $('#exec-status').textContent = 'Pick a firmware file and fill in the version string.'; + return; + } + $('#exec-status').textContent = 'Computing plan…'; + const onuIds = Array.from(state.selected); + // Fresh FEC results for each preview — link health can change between + // hitting Preview and hitting Execute, so don't carry stale flags. + state.fecHealth = new Map(); + const res = await window.api.planUpgrade({ onuIds, targetFile, targetVersion }); + if (!res.ok) { + $('#exec-status').textContent = `Plan failed: ${res.error?.message}`; + return; + } + state.plan = res.data; + renderPreview(); + $('#btn-execute').disabled = false; + $('#exec-status').textContent = + `Previewed ${state.plan.length} ONUs. Checking FEC health…`; + + // Kick off the FEC pre-flight in parallel. Each result paints its row + // immediately so the operator can spot "Post-FEC errors" rows before + // committing the upgrade. The MCMS /onus/stats// endpoint is + // per-ONU, so this is bounded to the selected set rather than the + // whole fleet (per Jon's preference: "before committing the firmware + // update, not for the entire fleet"). + const planIds = state.plan.filter((p) => !p.error).map((p) => p.onuId); + if (!planIds.length) return; + const unbind = window.api.onFecProgress((p) => { + state.fecHealth.set(p.onuId, p); + paintFecCell(p.onuId); + if (p.done === p.total) { + const counts = countFecFlags(); + $('#exec-status').textContent = + `FEC pre-flight complete: ` + + `${counts.ok} OK, ${counts.pre} pre-FEC, ${counts.post} post-FEC, ` + + `${counts.buggy} pre==post (ONU bug?), ` + + `${counts.nodata + counts.error} unknown.`; + } + }); + const fecRes = await window.api.fetchFecHealth({ onuIds: planIds }); + unbind(); + if (!fecRes.ok) { + $('#exec-status').textContent = + `Plan ready (FEC fetch failed: ${fecRes.error?.message}). ` + + `You can still execute, but pre-flight health is unavailable.`; + return; + } + // Final paint in case any progress events were missed. + for (const r of fecRes.data) state.fecHealth.set(r.onuId, r); + for (const r of fecRes.data) paintFecCell(r.onuId); +}); + +function countFecFlags() { + const c = { ok: 0, pre: 0, post: 0, buggy: 0, nodata: 0, error: 0 }; + for (const v of state.fecHealth.values()) { + if (c[v.flag] !== undefined) c[v.flag] += 1; + } + return c; +} + +function paintFecCell(onuId) { + const tbody = $('#preview-tbody'); + const tr = tbody.querySelector(`tr[data-onu="${cssEscape(onuId)}"]`); + if (!tr) return; + const cell = tr.querySelector('td.fec-cell'); + if (!cell) return; + cell.innerHTML = fecPillHtml(state.fecHealth.get(onuId)); +} + +function cssEscape(s) { + // Minimal CSS attribute selector escaping. ONU IDs are MAC addresses / + // hex strings on this MCMS build so this is mostly belt-and-braces. + return String(s).replace(/(["\\])/g, '\\$1'); +} + +function renderPreview() { + const tbody = $('#preview-tbody'); + tbody.innerHTML = ''; + for (const p of state.plan) { + const tr = document.createElement('tr'); + tr.setAttribute('data-onu', p.onuId); + if (p.error) { + tr.innerHTML = ` + ${escapeHtml(p.onuId)} + + — + ${escapeHtml(p.error)}`; + } else { + const prevPtr = p.previousActiveSlot; + const arrow = `${prevPtr} → ${p.writeSlot}`; + const fec = state.fecHealth.get(p.onuId); + tr.innerHTML = ` + ${escapeHtml(p.onuId)} + ${escapeHtml(p.name || p.address || '')} + ${arrow} + ${escapeHtml(p.currentVersionInSlot0)} + ${escapeHtml(p.currentVersionInSlot1)} + ${escapeHtml(p.targetVersion)} + ${fecPillHtml(fec)} + ready`; + } + tbody.appendChild(tr); + } +} + +$('#btn-execute').addEventListener('click', async () => { + const onuIds = state.plan.filter((p) => !p.error).map((p) => p.onuId); + const targetFile = $('#fw-select').value; + const targetVersion = $('#fw-version').value.trim(); + if (!onuIds.length) { + $('#exec-status').textContent = 'No valid ONUs in plan.'; + return; + } + // Warn (but don't block) if any ONU shows post-FEC errors or the + // pre==post buggy signature. Operators sometimes intentionally + // upgrade marginal links — that's exactly what the new firmware + // might be fixing — and the buggy-counter case is itself a reason + // to upgrade. So this is a soft gate, not a hard one. + const counts = countFecFlags(); + const warnings = []; + if (counts.post) { + warnings.push( + `${counts.post} ONU(s) currently report Post-FEC errors ` + + `(uncorrected bits hitting the user).`, + ); + } + if (counts.buggy) { + warnings.push( + `${counts.buggy} ONU(s) report identical pre/post FEC values ` + + `— likely an ONU firmware bug. Upgrading may be the fix.`, + ); + } + const fecWarning = warnings.length ? `\n\nWARNING:\n • ${warnings.join('\n • ')}` : ''; + if (!confirm( + `About to stage ${targetVersion} to the inactive bank on ${onuIds.length} ONU(s). ` + + `Proceed?${fecWarning}`, + )) { + return; + } + $('#btn-execute').disabled = true; + + // Save the plan to CSV BEFORE writing anything to MCMS, so even if + // the API call dies mid-way we have a record of what we were about + // to do (and which links looked sketchy at the time). + const csvHeaders = [ + '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', + ]; + // Helper: pluck a counter by (scope, kind) from the structured array. + const counterValue = (counters, scope, kind) => { + if (!Array.isArray(counters)) return ''; + const c = counters.find((x) => x.scope === scope && x.kind === kind); + return c ? c.value : ''; + }; + const csvRows = state.plan.map((p) => { + const fec = state.fecHealth.get(p.onuId) || {}; + const label = FEC_LABELS[fec.flag]?.text || ''; + return { + 'ONU ID': p.onuId, + 'Name': p.name || '', + 'Address': p.address || '', + 'PON Mode': p.ponMode || '', + 'Active slot (before)': p.error ? '' : p.previousActiveSlot, + 'Write slot (target)': p.error ? '' : p.writeSlot, + 'Slot 0 version (before)': p.error ? '' : p.currentVersionInSlot0, + 'Slot 1 version (before)': p.error ? '' : p.currentVersionInSlot1, + 'Target version': targetVersion, + 'Target file': targetFile, + 'FEC health': label, + 'ONU RX Pre-FEC BER': counterValue(fec.counters, 'onu', 'pre'), + 'ONU RX Post-FEC BER': counterValue(fec.counters, 'onu', 'post'), + 'OLT RX Pre-FEC BER': counterValue(fec.counters, 'olt', 'pre'), + 'OLT RX Post-FEC BER': counterValue(fec.counters, 'olt', 'post'), + 'ONU RX Optical (dBm)': fec.optical?.rx ?? '', + 'ONU TX Optical (dBm)': fec.optical?.tx ?? '', + 'FEC sample time': fec.sampleTime || '', + 'Mode': state.campaignMode, + 'Notes': p.error || (fec.error ? `FEC fetch: ${fec.error.message}` : ''), + }; + }); + const csvSave = await window.api.savePlanCsv({ + rows: csvRows, + headers: csvHeaders, + filenameHint: `${targetVersion || 'unknown'}-${state.campaignMode}-${onuIds.length}onus`, + }); + if (csvSave.ok) { + $('#exec-status').textContent = `Plan saved → ${csvSave.data.path}. Submitting upgrade…`; + } else { + $('#exec-status').textContent = + `(CSV save failed: ${csvSave.error?.message}) Submitting upgrade anyway…`; + } + + if (state.campaignMode === 'task') { + // Procedure 8 — single scheduled task. + const scheduleLocal = $('#in-schedule').value; + // Convert local datetime to MCMS "YYYY-MM-DD HH:MM:SS" in UTC. If the + // field is blank, start immediately (now). + const when = scheduleLocal ? new Date(scheduleLocal) : new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const scheduledStart = + `${when.getUTCFullYear()}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())} ` + + `${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}`; + + // All ONUs in the plan currently share a single inactive bank target + // because we compute per-ONU. For the task endpoint we need ONE bank + // number, so we bucket by writeSlot and create one task per slot. + const buckets = { 0: [], 1: [] }; + for (const p of state.plan) { + if (p.error) continue; + buckets[p.writeSlot].push(p.onuId); + } + const results = []; + for (const [slotStr, serials] of Object.entries(buckets)) { + if (!serials.length) continue; + const slot = Number(slotStr); + const taskId = `FwUp-${targetVersion}-slot${slot}-${Date.now()}`; + $('#exec-status').textContent = `Creating task ${taskId} (${serials.length} ONUs)…`; + const res = await window.api.executeBulkTask({ + taskId, + serials, + scheduledStart, + fwBankPtr: slot, + targetFile, + targetVersion, + }); + results.push({ taskId, slot, count: serials.length, res }); + } + $('#exec-status').textContent = + `Submitted ${results.length} task(s): ` + + results.map((r) => `${r.taskId} (${r.count} ONUs, slot ${r.slot}, ${r.res.ok ? 'ok' : 'FAILED'})`).join('; '); + markAllPreviewRows(results.every((r) => r.res.ok) ? 'submitted' : 'error'); + } else { + // Procedure 7 — per-ONU PUTs. Slower but gives immediate feedback. + const unbind = window.api.onUpgradeProgress((p) => { + $('#exec-status').textContent = `[${p.index + 1}/${p.total}] ${p.phase} ${p.onuId}` + + (p.writeSlot !== undefined ? ` (slot ${p.writeSlot})` : ''); + }); + const res = await window.api.executePerOnu({ onuIds, targetFile, targetVersion }); + unbind(); + if (!res.ok) { + $('#exec-status').textContent = `Execution failed: ${res.error?.message}`; + $('#btn-execute').disabled = false; + return; + } + // Apply per-row status back onto the preview table. + const byId = new Map(res.data.map((r) => [r.onuId, r])); + const rows = $('#preview-tbody').querySelectorAll('tr'); + let i = 0; + for (const p of state.plan) { + const row = rows[i++]; + const cell = row?.querySelector('td:last-child'); + if (!cell) continue; + if (p.error) continue; + const r = byId.get(p.onuId); + if (!r) { cell.textContent = 'skipped'; continue; } + if (r.ok) { cell.textContent = `wrote slot ${r.writeSlot}`; cell.className = 'status-ok'; } + else { cell.textContent = r.error?.message || 'failed'; cell.className = 'status-err'; } + } + const okCount = res.data.filter((r) => r.ok).length; + $('#exec-status').textContent = `Done: ${okCount}/${res.data.length} succeeded.`; + } +}); + +function markAllPreviewRows(state) { + const rows = $('#preview-tbody').querySelectorAll('tr'); + for (const r of rows) { + const cell = r.querySelector('td:last-child'); + if (!cell) continue; + if (state === 'submitted') { cell.textContent = 'submitted to MCMS'; cell.className = 'status-ok'; } + else if (state === 'error') { cell.textContent = 'task submission failed'; cell.className = 'status-err'; } + } +} + +// -------- Verify view: open saved plan CSV, re-fetch, compare, save -------- + +$('#btn-to-verify').addEventListener('click', () => { + showView('#view-verify'); + resetVerifyView(); +}); +$('#btn-verify-back').addEventListener('click', () => showView('#view-fleet')); +$('#btn-pick-plan').addEventListener('click', runVerifyFlow); +$('#btn-save-verify').addEventListener('click', saveVerifyReport); + +function resetVerifyView() { + $('#verify-status').textContent = ''; + $('#verify-save-status').textContent = ''; + $('#verify-tbody').innerHTML = ''; + $('#verify-count').textContent = '0'; + $('#btn-save-verify').disabled = true; + $('#verify-summary').innerHTML = '
Open a CSV to start.
'; + state.verify = null; +} + +async function runVerifyFlow() { + $('#verify-status').textContent = 'Picking file…'; + const fileRes = await window.api.openCsvFile(); + if (!fileRes.ok) { + $('#verify-status').textContent = fileRes.error?.message === 'cancelled' + ? '' : `Open failed: ${fileRes.error?.message || 'unknown'}`; + return; + } + const { path: sourcePath, text } = fileRes.data; + + let beforeRows; + try { + beforeRows = parsePlanCsv(text); + } catch (e) { + $('#verify-status').textContent = `CSV parse failed: ${e.message}`; + return; + } + if (!beforeRows.length) { + $('#verify-status').textContent = 'No ONU rows found in CSV.'; + return; + } + const ids = beforeRows.map((r) => r.onuId).filter(Boolean); + $('#verify-status').textContent = + `Loaded ${beforeRows.length} rows from ${sourcePath}. Fetching current state…`; + + const unbind = window.api.onVerifyProgress(({ done, total }) => { + $('#verify-status').textContent = + `Loaded ${beforeRows.length} from ${sourcePath}. Fetching current state… ${done}/${total}`; + }); + const snap = await window.api.fetchOnuSnapshot({ onuIds: ids }); + unbind(); + if (!snap.ok) { + $('#verify-status').textContent = `Fetch failed: ${snap.error?.message}`; + return; + } + const snapshotById = new Map(snap.data.map((r) => [r.onuId, r])); + const comparisons = beforeRows.map((b) => buildComparison(b, snapshotById.get(b.onuId))); + state.verify = { sourcePath, beforeRows, snapshotById, comparisons }; + renderVerifyTable(comparisons); + $('#btn-save-verify').disabled = false; + $('#verify-status').textContent = + `Compared ${comparisons.length} ONUs from ${sourcePath}. Saving report…`; + + // Auto-save the verification report immediately, mirroring the + // auto-save behavior on Execute. + await saveVerifyReport(); +} + +// CSV parsing ----------------------------------------------------------- + +// Lenient CSV reader. Handles quoted cells with embedded commas, escaped +// quotes ("" inside quoted strings), CRLF or LF line endings, and a +// leading UTF-8 BOM. Returns a 2-D array of strings. +function parseCsvText(text) { + if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); + const rows = []; + let row = []; + let cell = ''; + let inQuotes = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inQuotes) { + if (ch === '"') { + if (text[i + 1] === '"') { cell += '"'; i++; } + else { inQuotes = false; } + } else { + cell += ch; + } + } else { + if (ch === '"') inQuotes = true; + else if (ch === ',') { row.push(cell); cell = ''; } + else if (ch === '\r') { /* swallow; \n closes the row */ } + else if (ch === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } + else cell += ch; + } + } + if (cell !== '' || row.length) { row.push(cell); rows.push(row); } + return rows; +} + +// Locate a column in a header row by trying a list of candidate names +// (case-insensitive, trimmed). Returns -1 if none match. +function findColumn(headers, candidates) { + const norm = headers.map((h) => String(h).trim().toLowerCase()); + for (const cand of candidates) { + const idx = norm.indexOf(String(cand).toLowerCase()); + if (idx >= 0) return idx; + } + return -1; +} + +// Parse the saved plan CSV into structured "before" rows. Tolerant of +// missing columns (a CSV authored by an older version of this app). +function parsePlanCsv(text) { + const matrix = parseCsvText(text); + if (matrix.length < 2) return []; + const headers = matrix[0]; + const idCol = findColumn(headers, ['ONU ID', 'Serial', 'serial']); + if (idCol < 0) throw new Error('No "ONU ID" column found in CSV header'); + + const cellOf = (row, ...names) => { + const i = findColumn(headers, names); + return i >= 0 ? (row[i] ?? '') : ''; + }; + const numOf = (row, ...names) => { + const v = String(cellOf(row, ...names)).trim(); + if (v === '' || v === '—') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + + const rows = []; + for (let r = 1; r < matrix.length; r++) { + const row = matrix[r]; + if (!row || row.length === 0) continue; + const id = (row[idCol] || '').trim(); + if (!id) continue; + rows.push({ + onuId: id, + name: cellOf(row, 'Name'), + address: cellOf(row, 'Address'), + ponMode: cellOf(row, 'PON Mode'), + writeSlot: cellOf(row, 'Write slot (target)'), + targetVersion: cellOf(row, 'Target version'), + targetFile: cellOf(row, 'Target file'), + fecHealthBefore: cellOf(row, 'FEC health'), + onuPreBefore: numOf(row, 'ONU RX Pre-FEC BER'), + onuPostBefore: numOf(row, 'ONU RX Post-FEC BER'), + oltPreBefore: numOf(row, 'OLT RX Pre-FEC BER'), + oltPostBefore: numOf(row, 'OLT RX Post-FEC BER'), + rxBefore: numOf(row, 'ONU RX Optical (dBm)'), + txBefore: numOf(row, 'ONU TX Optical (dBm)'), + }); + } + return rows; +} + +// Comparison logic ------------------------------------------------------ + +// Map the before-row's "FEC health" label string back to a flag so the +// verdict logic can compare against the live `after.flag`. The labels +// come from FEC_LABELS so this is the inverse of that lookup. +function flagFromLabel(label) { + if (!label) return 'unknown'; + const l = String(label).trim().toLowerCase(); + for (const [flag, lab] of Object.entries(FEC_LABELS)) { + if (lab.text.toLowerCase() === l) return flag; + } + return 'unknown'; +} + +function buildComparison(before, after) { + const cfg = after?.cfg || null; + const activeNow = cfg ? activeVersion(cfg) : ''; + const target = before.targetVersion; + let upgradeApplied = null; // null = unknown + if (target && activeNow && activeNow !== '(unset)' && activeNow !== '(empty)') { + upgradeApplied = activeNow === target; + } + + const counterAfter = (scope, kind) => { + if (!after?.counters) return null; + const c = after.counters.find((x) => x.scope === scope && x.kind === kind); + return c ? c.value : null; + }; + + const beforeFlag = flagFromLabel(before.fecHealthBefore); + const afterFlag = after?.flag || 'error'; + + return { + onuId: before.onuId, + name: before.name, + address: before.address, + target, + activeNow, + upgradeApplied, + + beforeFlag, + afterFlag, + fecBeforeLabel: before.fecHealthBefore, + fecAfterLabel: FEC_LABELS[afterFlag]?.text || afterFlag, + + onuPreBefore: before.onuPreBefore, + onuPreAfter: counterAfter('onu', 'pre'), + onuPostBefore: before.onuPostBefore, + onuPostAfter: counterAfter('onu', 'post'), + oltPreBefore: before.oltPreBefore, + oltPreAfter: counterAfter('olt', 'pre'), + oltPostBefore: before.oltPostBefore, + oltPostAfter: counterAfter('olt', 'post'), + + rxBefore: before.rxBefore, + rxAfter: after?.optical?.rx ?? null, + txBefore: before.txBefore, + txAfter: after?.optical?.tx ?? null, + + afterError: after?.error || null, + sampleTime: after?.sampleTime || '', + verdict: computeVerdict(beforeFlag, afterFlag, upgradeApplied, after), + }; +} + +// Single-line verdict per ONU. The verdict is mostly about the FEC flag +// transition because the raw counters reset on the upgrade reboot, so +// "delta is negative" is normal and not informative. +function computeVerdict(beforeFlag, afterFlag, upgradeApplied, after) { + if (!after) return 'fetch failed'; + if (afterFlag === 'error') return 'fetch failed'; + if (upgradeApplied === false) return 'upgrade NOT applied (active version unchanged)'; + if (afterFlag === 'nodata') return 'no current FEC data'; + + const wasGood = beforeFlag === 'ok'; + const isGood = afterFlag === 'ok'; + const wasBuggy = beforeFlag === 'buggy'; + const isBuggy = afterFlag === 'buggy'; + + if (wasBuggy && isBuggy) return 'still buggy (firmware bug persists)'; + if (wasBuggy && isGood) return 'fixed (was buggy)'; + if (wasBuggy && afterFlag === 'pre') return 'improved (no longer mirroring counters)'; + if (wasBuggy && afterFlag === 'post') return 'changed: now reporting genuine post-FEC errors'; + + if (wasGood && isGood) return 'still healthy'; + if (wasGood && afterFlag === 'pre') return 'regressed (now marginal)'; + if (wasGood && afterFlag === 'post') return 'regressed (now post-FEC errors)'; + if (wasGood && afterFlag === 'buggy') return 'regressed (now buggy)'; + + if (!wasGood && isGood) return 'improved'; + if (beforeFlag === 'post' && afterFlag === 'pre') return 'improved (post→pre)'; + if (beforeFlag === 'pre' && afterFlag === 'post') return 'regressed (pre→post)'; + + if (afterFlag === 'post') return 'still has post-FEC errors'; + if (afterFlag === 'pre') return 'still marginal (pre-FEC only)'; + return 'unchanged'; +} + +const VERDICT_CLASSES = [ + { match: /fixed|improved|still healthy/i, cls: 'status-ok' }, + { match: /still buggy/i, cls: 'status-info' }, + { match: /regressed|NOT applied|fetch failed|post-FEC/i, cls: 'status-err' }, + { match: /marginal|pre-FEC/i, cls: 'status-warn' }, +]; +function verdictCls(v) { + for (const r of VERDICT_CLASSES) if (r.match.test(v)) return r.cls; + return 'muted'; +} + +// Render ----------------------------------------------------------------- + +function renderVerifyTable(comparisons) { + const tbody = $('#verify-tbody'); + tbody.innerHTML = ''; + + // Counts for the side-panel summary. + const tally = { applied: 0, notApplied: 0, fixed: 0, stillBuggy: 0, regressed: 0, healthy: 0, other: 0 }; + + for (const c of comparisons) { + const tr = document.createElement('tr'); + tr.setAttribute('data-onu', c.onuId); + + if (c.upgradeApplied === true) tally.applied++; + else if (c.upgradeApplied === false) tally.notApplied++; + if (/fixed|improved/.test(c.verdict)) tally.fixed++; + else if (/still buggy/.test(c.verdict)) tally.stillBuggy++; + else if (/regressed|NOT applied|fetch failed/.test(c.verdict)) tally.regressed++; + else if (/still healthy/.test(c.verdict)) tally.healthy++; + else tally.other++; + + const appliedCls = c.upgradeApplied === true ? 'status-ok' + : c.upgradeApplied === false ? 'status-err' + : 'muted'; + const appliedTxt = c.upgradeApplied === true ? 'yes' + : c.upgradeApplied === false ? 'no' + : '?'; + + const beforeFecBlock = + `${escapeHtml(c.fecBeforeLabel || c.beforeFlag)}` + + `` + + `pre=${escapeHtml(formatFecOrDash(c.onuPreBefore))}
` + + `post=${escapeHtml(formatFecOrDash(c.onuPostBefore))}` + + `
`; + + const afterFecBlock = + `${escapeHtml(c.fecAfterLabel)}` + + `` + + `pre=${escapeHtml(formatFecOrDash(c.onuPreAfter))}
` + + `post=${escapeHtml(formatFecOrDash(c.onuPostAfter))}` + + `
`; + + const opticalBlock = renderOpticalCompare(c); + + const targetCol = c.target + ? `
${escapeHtml(c.target)}
now: ${escapeHtml(c.activeNow || '?')}
` + : `
${escapeHtml(c.activeNow || '?')}
`; + + tr.innerHTML = ` + ${escapeHtml(c.onuId)} + ${escapeHtml(c.name || '')} + ${targetCol} + ${appliedTxt} + ${beforeFecBlock} + ${afterFecBlock} + ${opticalBlock} + ${escapeHtml(c.verdict)} + `; + tbody.appendChild(tr); + } + $('#verify-count').textContent = comparisons.length; + + $('#verify-summary').innerHTML = ` +
${tally.applied} upgrade applied · ${tally.notApplied} not applied
+
${tally.fixed} improved/fixed
+
${tally.healthy} still healthy
+
${tally.stillBuggy} still buggy
+
${tally.regressed} regressed/failed
+
${tally.other} other
+ `; +} + +function renderOpticalCompare(c) { + const fmt = (n) => n == null ? '—' : `${n.toFixed(1)} dBm`; + const cls = (n) => n == null ? 'muted' : n < -30 ? 'status-err' : n < -28 ? 'status-warn' : 'status-ok'; + const rxBefore = `RX before: ${escapeHtml(fmt(c.rxBefore))}`; + const rxAfter = `RX now: ${escapeHtml(fmt(c.rxAfter))}`; + const txBefore = `TX before: ${escapeHtml(fmt(c.txBefore))}`; + const txAfter = `TX now: ${escapeHtml(fmt(c.txAfter))}`; + return rxBefore + rxAfter + txBefore + txAfter; +} + +function formatFecOrDash(n) { + if (n === null || n === undefined) return '—'; + return formatFecNumber(n); +} + +// Save ------------------------------------------------------------------ + +async function saveVerifyReport() { + if (!state.verify) return; + const headers = [ + 'ONU ID', 'Name', 'Address', + 'Target version', 'Active version (after)', 'Upgrade applied?', + 'FEC health (before)', 'FEC health (after)', + 'ONU RX Pre-FEC BER (before)', 'ONU RX Pre-FEC BER (after)', + 'ONU RX Post-FEC BER (before)', 'ONU RX Post-FEC BER (after)', + 'OLT RX Pre-FEC BER (before)', 'OLT RX Pre-FEC BER (after)', + 'OLT RX Post-FEC BER (before)', 'OLT RX Post-FEC BER (after)', + 'ONU RX Optical dBm (before)', 'ONU RX Optical dBm (after)', + 'ONU TX Optical dBm (before)', 'ONU TX Optical dBm (after)', + 'After sample time', 'Verdict', 'Fetch error', + ]; + const num = (n) => (n === null || n === undefined ? '' : n); + const rows = state.verify.comparisons.map((c) => ({ + 'ONU ID': c.onuId, + 'Name': c.name || '', + 'Address': c.address || '', + 'Target version': c.target || '', + 'Active version (after)': c.activeNow || '', + 'Upgrade applied?': c.upgradeApplied === true ? 'yes' : c.upgradeApplied === false ? 'no' : 'unknown', + 'FEC health (before)': c.fecBeforeLabel || '', + 'FEC health (after)': c.fecAfterLabel || '', + 'ONU RX Pre-FEC BER (before)': num(c.onuPreBefore), + 'ONU RX Pre-FEC BER (after)': num(c.onuPreAfter), + 'ONU RX Post-FEC BER (before)': num(c.onuPostBefore), + 'ONU RX Post-FEC BER (after)': num(c.onuPostAfter), + 'OLT RX Pre-FEC BER (before)': num(c.oltPreBefore), + 'OLT RX Pre-FEC BER (after)': num(c.oltPreAfter), + 'OLT RX Post-FEC BER (before)': num(c.oltPostBefore), + 'OLT RX Post-FEC BER (after)': num(c.oltPostAfter), + 'ONU RX Optical dBm (before)': num(c.rxBefore), + 'ONU RX Optical dBm (after)': num(c.rxAfter), + 'ONU TX Optical dBm (before)': num(c.txBefore), + 'ONU TX Optical dBm (after)': num(c.txAfter), + 'After sample time': c.sampleTime || '', + 'Verdict': c.verdict, + 'Fetch error': c.afterError ? c.afterError.message : '', + })); + + // Derive a filename hint from the source CSV: keep its trailing + // descriptor (e.g. "EV05120R-task-12onus") so the verify file + // sits next to the original lexically when sorted. + const baseName = (state.verify.sourcePath || '') + .split(/[/\\]/).pop() + .replace(/\.csv$/i, '') + .replace(/^pon-upgrade-/, ''); + + const res = await window.api.saveCsvFile({ + rows, + headers, + filenameHint: baseName || 'comparison', + prefix: 'pon-verify', + }); + if (res.ok) { + $('#verify-save-status').textContent = `Comparison saved → ${res.data.path}`; + $('#verify-status').textContent = + `Compared ${rows.length} ONUs from ${state.verify.sourcePath}.`; + } else { + $('#verify-save-status').textContent = `Save failed: ${res.error?.message}`; + } +} + +// -------- Utility -------- +function escapeHtml(s) { + if (s === null || s === undefined) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/renderer/index.html b/renderer/index.html new file mode 100644 index 0000000..aba1974 --- /dev/null +++ b/renderer/index.html @@ -0,0 +1,251 @@ + + + + + PON Fleet Upgrader + + + +
+
PON Fleet Upgrader
+
Not connected
+ +
+ + +
+
+

Connect to MCMS

+

+ Session-cookie auth against a Ciena MicroClimate Management System. + The host should be the root URL of the PON Manager web UI + (e.g. https://mcms.example.net). +

+ + + + +
+ + +
+
+
+ + + + + + + + + + + + + diff --git a/src/bank-strategy.js b/src/bank-strategy.js new file mode 100644 index 0000000..cae23cc --- /dev/null +++ b/src/bank-strategy.js @@ -0,0 +1,102 @@ +// Bank-selection helpers for MCMS ONU firmware upgrades. +// +// MCMS stores firmware in two slots on the ONU: +// ONU.FW Bank Files: [slot 0 filename, slot 1 filename] +// ONU.FW Bank Versions: [slot 0 version, slot 1 version] +// ONU.FW Bank Ptr: 0 | 1 | 65535 (unset) +// +// FW Bank Ptr identifies the *active* bank. To avoid forcing a reboot mid-campaign, +// we stage new firmware to the inactive bank. MCMS still flips the pointer after +// a successful download, which triggers one reboot per ONU — but because we're +// only committing to the *other* bank, the ONU boots into the new image on its +// next natural reboot window. If you want to stage-only (no activation), clear +// the new file from the target slot *after* the download completes; that mode +// is out of scope for the initial tool and called out in the UI. + +const UNSET_PTR = 65535; + +/** + * Given a current FW Bank Ptr, decide which slot we should write to so we're + * not overwriting the active image. + * + * @param {number | undefined | null} activePtr Current ONU.FW Bank Ptr + * @returns {0 | 1} + */ +function inactiveBank(activePtr) { + if (activePtr === 0) return 1; + if (activePtr === 1) return 0; + // 65535 / null / undefined — ONU has never been upgraded via MCMS. + // Slot 0 is the factory image location; writing to slot 1 keeps slot 0 as + // a safety fallback, which matches Procedure 7's example in the dev guide. + return 1; +} + +/** + * Read the version string in a given slot, tolerating sparse arrays that + * MCMS returns when only one slot is populated. + */ +function versionInSlot(onuCfg, slot) { + const versions = onuCfg?.ONU?.['FW Bank Versions'] || []; + return versions[slot] || ''; +} + +function filenameInSlot(onuCfg, slot) { + const files = onuCfg?.ONU?.['FW Bank Files'] || []; + return files[slot] || ''; +} + +function activeBankPtr(onuCfg) { + const ptr = onuCfg?.ONU?.['FW Bank Ptr']; + return typeof ptr === 'number' ? ptr : UNSET_PTR; +} + +/** + * Compute the mutation we'll apply to an ONU config to stage `targetFile` + * (with `targetVersion`) into the inactive bank. Returns a new ONU-CFG + * object with only the FW fields changed — caller should merge into the + * existing document fetched via GET /v1/onus/configs// before PUTting. + */ +function planUpgrade(onuCfg, { targetFile, targetVersion }) { + const activePtr = activeBankPtr(onuCfg); + const writeSlot = inactiveBank(activePtr); + + // Preserve existing slot contents; only overwrite the slot we're targeting. + const existingFiles = onuCfg?.ONU?.['FW Bank Files'] || ['', '']; + const existingVersions = onuCfg?.ONU?.['FW Bank Versions'] || ['', '']; + + const newFiles = [existingFiles[0] || '', existingFiles[1] || '']; + const newVersions = [existingVersions[0] || '', existingVersions[1] || '']; + newFiles[writeSlot] = targetFile; + newVersions[writeSlot] = targetVersion; + + return { + writeSlot, + activePtr, + // MCMS triggers a download by writing the target slot's Files/Versions AND + // flipping FW Bank Ptr to that slot. Per Procedure 7 in the dev guide, + // setting FW Bank Ptr to the new slot is what initiates the upgrade. + fwFields: { + 'FW Bank Files': newFiles, + 'FW Bank Versions': newVersions, + 'FW Bank Ptr': writeSlot, + }, + // Human-readable summary for the dry-run preview. + summary: { + willWriteSlot: writeSlot, + previousActiveSlot: activePtr === UNSET_PTR ? 'unset' : activePtr, + currentVersionInSlot0: existingVersions[0] || '(empty)', + currentVersionInSlot1: existingVersions[1] || '(empty)', + targetVersion, + targetFile, + }, + }; +} + +module.exports = { + UNSET_PTR, + inactiveBank, + versionInSlot, + filenameInSlot, + activeBankPtr, + planUpgrade, +}; diff --git a/src/mcms-api.js b/src/mcms-api.js new file mode 100644 index 0000000..eee89f3 --- /dev/null +++ b/src/mcms-api.js @@ -0,0 +1,648 @@ +// MCMS 6.2 REST API client. +// +// Sequence (from 323-1961-306, Chapter 3 "Request Sequence"): +// 1. POST /api/v1/users/authenticate/ -> Set-Cookie: sessionid + csrftoken +// 2. (optional) PUT /api/v1/databases/selection/ +// 3. GETs/PUTs with Cookie + X-CSRFToken + Referer headers +// 4. GET /api/v1/users/logout/ +// +// Notes: +// - All HTTP paths are prefixed with `/api`. The dev guide's sequence +// diagrams write "/v1/users/authenticate/" as shorthand, but the actual +// HTTP route (see the curl examples in the same guide) is +// "/api/v1/users/authenticate/". Paths below include the prefix. +// - MCMS deployments commonly use self-signed certs on an internal IP. We +// expose `rejectUnauthorized` as a per-session toggle rather than globally +// neutering TLS. +// - All PUT/POST bodies wrap the payload in a `data` envelope: { "data": {...} }. +// - Response envelope is { "status": "success"|"fail"|..., "data"?: ..., "details"?: ... }. + +const https = require('https'); +const { URL } = require('url'); +const { CookieJar } = require('tough-cookie'); + +// ---------- Time helpers ---------------------------------------------- +// MCMS emits and accepts timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC. +// (Example: ONU-STATE.Time, OLT alarm timestamps, AUTO-TASK Scheduled Start.) +// We use the same format on outgoing query params to /onus/stats/. + +function formatMcmsTime(d) { + const pad = (n) => String(n).padStart(2, '0'); + return ( + `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}` + ); +} + +function parseMcmsTime(s) { + if (!s) return 0; + // Accept "YYYY-MM-DD HH:MM:SS[.ffffff]" (UTC). Date() treats the space-form + // as local in some Node versions, so coerce to ISO with a Z suffix. + const iso = String(s).replace(' ', 'T') + (String(s).endsWith('Z') ? '' : 'Z'); + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +// ---------- ONU link-health reduction --------------------------------- +// Reduce an ONU-STATE document to one of: +// 'ok' | 'pre' | 'post' | 'buggy' | 'nodata' +// +// Field locations are based on observed traffic from MCMS 6.2 + +// Interos Everest ONUs. The ONU summary endpoint (and the bulk +// /v3/onus/states/ list) returns: +// +// STATE.STATS["ONU-PON"]["RX Pre-FEC BER"] +// STATE.STATS["ONU-PON"]["RX Post-FEC BER"] +// STATE.STATS["ONU-PON"]["RX Optical Level"] (dBm) +// STATE.STATS["ONU-PON"]["TX Optical Level"] (dBm) +// STATE.STATS["OLT-PON"]["RX Pre-FEC BER"] (upstream view) +// STATE.STATS["OLT-PON"]["RX Post-FEC BER"] +// +// On at least one MCMS UI helper the document is wrapped as +// `{ state_collection: { STATS: ... } }` — accept that shape too. +// +// Health rules (matching the user-guide green-LED criteria on p149 +// plus a sanity check for ONU firmware bugs): +// ONU pre>0 AND post>0 AND post/pre >= 0.95 -> 'buggy' +// Real FEC essentially always reduces errors. A working ONU +// shows post=0 (or near-0) even with pre>0; if post is within +// 5% of pre there's no correction happening. Jon observed an +// example pair like 22.388e9/22.379e9 (post/pre = 0.9996) on +// a GNXS Everest ONU, which is the firmware bug we want to +// surface separately from a genuinely broken link. +// ONU or OLT post-FEC > 0 -> 'post' (uncorrected errors) +// ONU or OLT pre-FEC > 0 -> 'pre' (marginal link, FEC working) +// All four counters present and zero -> 'ok' +// No FEC counters found -> 'nodata' +// +// Returns: +// { flag, counters: [{tail, value, kind, scope}], optical: {rx, tx}, +// detail } + +const NEAR_IDENTICAL_RATIO = 0.95; + +function extractOnuHealth(stateDoc) { + if (!stateDoc || typeof stateDoc !== 'object') { + return { flag: 'nodata', counters: [], optical: {} }; + } + // Accept both the bare state doc and the `{ state_collection: {...} }` + // wrapper exposed by /api/onu/summary. + const stats = + stateDoc.STATS || + stateDoc?.state_collection?.STATS || + null; + if (!stats || typeof stats !== 'object') { + return { flag: 'nodata', counters: [], optical: {} }; + } + + const onuPon = stats['ONU-PON'] || {}; + const oltPon = stats['OLT-PON'] || {}; + + const onuPre = numOrNull(onuPon['RX Pre-FEC BER']); + const onuPost = numOrNull(onuPon['RX Post-FEC BER']); + const oltPre = numOrNull(oltPon['RX Pre-FEC BER']); + const oltPost = numOrNull(oltPon['RX Post-FEC BER']); + const rxOpt = numOrNull(onuPon['RX Optical Level']); + const txOpt = numOrNull(onuPon['TX Optical Level']); + + /** @type {{tail:string, value:number, kind:string, scope:string}[]} */ + const counters = []; + if (onuPre !== null) counters.push({ tail: 'ONU RX Pre-FEC BER', value: onuPre, kind: 'pre', scope: 'onu' }); + if (onuPost !== null) counters.push({ tail: 'ONU RX Post-FEC BER', value: onuPost, kind: 'post', scope: 'onu' }); + if (oltPre !== null) counters.push({ tail: 'OLT RX Pre-FEC BER', value: oltPre, kind: 'pre', scope: 'olt' }); + if (oltPost !== null) counters.push({ tail: 'OLT RX Post-FEC BER', value: oltPost, kind: 'post', scope: 'olt' }); + + if (counters.length === 0) { + return { flag: 'nodata', counters: [], optical: { rx: rxOpt, tx: txOpt } }; + } + + // Buggy detector: ONU side only — that's the side that's known to + // mis-report on Interos Everest ONUs. The OLT side is computed by + // the OLT firmware which we trust. + const buggy = + onuPre !== null && onuPost !== null && + onuPre > 0 && onuPost > 0 && + onuPost / onuPre >= NEAR_IDENTICAL_RATIO; + + const anyPostNonzero = (onuPost ?? 0) > 0 || (oltPost ?? 0) > 0; + const anyPreNonzero = (onuPre ?? 0) > 0 || (oltPre ?? 0) > 0; + + let flag; + if (buggy) flag = 'buggy'; + else if (anyPostNonzero) flag = 'post'; + else if (anyPreNonzero) flag = 'pre'; + else flag = 'ok'; + + // Compact tooltip / CSV detail. Show all four counters with the + // exact field names from the API so an operator can correlate + // with the PON Manager UI by eye. + const detail = counters + .map((c) => `${c.tail}=${formatFecNumber(c.value)}`) + .join('; '); + + return { flag, counters, optical: { rx: rxOpt, tx: txOpt }, detail }; +} + +// Backwards-compatible alias for any older callers / tests that still +// reach for extractFecHealth. The new code path runs through +// extractOnuHealth, but calling extractFecHealth on a raw STATS sub- +// document still works because we look for a `STATS` wrapper first. +function extractFecHealth(doc) { + if (doc && typeof doc === 'object' && (doc.STATS || doc.state_collection)) { + return extractOnuHealth(doc); + } + // Old callers passed a STATS sub-doc directly — wrap it. + return extractOnuHealth({ STATS: doc }); +} + +function numOrNull(v) { + if (v === null || v === undefined) return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string' && /^-?[\d.]+(?:[eE][+-]?\d+)?$/.test(v)) { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +// Format a number for the FEC display: keep integers as-is, render very +// small or very large floats in scientific notation, otherwise show 3 +// significant digits. BER values are typically in the 1e-12 .. 1e-3 range +// while error *counts* are integers — both should look readable. +function formatFecNumber(n) { + if (n === 0) return '0'; + const abs = Math.abs(n); + if (Number.isInteger(n)) return String(n); + if (abs < 0.01 || abs >= 1e6) return n.toExponential(2); + return Number(n.toPrecision(3)).toString(); +} + +class McmsApiError extends Error { + constructor(message, { status, body } = {}) { + super(message); + this.name = 'McmsApiError'; + this.status = status; + this.body = body; + } +} + +class McmsClient { + /** + * @param {object} opts + * @param {string} opts.baseUrl e.g. "https://mcms.example.internal" + * @param {boolean} [opts.rejectUnauthorized=true] set false for self-signed + * @param {boolean} [opts.verbose=false] console.log each request + response + */ + constructor({ baseUrl, rejectUnauthorized = true, verbose = false }) { + if (!baseUrl) throw new Error('baseUrl is required'); + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.rejectUnauthorized = rejectUnauthorized; + this.verbose = verbose; + this.jar = new CookieJar(); + this.csrfToken = null; + } + + // --- Low-level request ------------------------------------------------ + + _resolvePath(path) { + // MCMS exposes the REST API under /api/. The guide writes paths as + // /v1/... but the actual HTTP route is /api/v1/... . Accept either and + // normalize here so callers don't have to care. + if (path.startsWith('/api/')) return path; + if (path.startsWith('/')) return '/api' + path; + return '/api/' + path; + } + + /** + * Build a query string using percent-encoding that MCMS accepts. Node's + * URLSearchParams encodes spaces as `+` (form-urlencoded), but MCMS's + * URL parser requires `%20`. encodeURIComponent gives us `%20` for + * spaces in both keys and values. + */ + _encodeQuery(params) { + const parts = []; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue; + const val = typeof v === 'string' ? v : String(v); + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(val)}`); + } + return parts.join('&'); + } + + async _request(method, path, { body, contentType = 'application/json', query } = {}) { + const resolvedPath = this._resolvePath(path); + const qs = query ? this._encodeQuery(query) : ''; + const fullUrl = new URL(this.baseUrl + resolvedPath + (qs ? '?' + qs : '')); + + const cookieHeader = await this.jar.getCookieString(fullUrl.toString()); + const headers = { + 'Accept': 'application/json', + 'Referer': this.baseUrl + '/', + // Node's https.request sends no User-Agent by default. Some MCMS + // middlewares (and third-party WAF layers) deref HTTP_USER_AGENT + // without a guard and 500 when it's absent. Set an explicit UA so + // we look like a normal HTTP client. + 'User-Agent': 'pon-fleet-upgrader/0.1.0', + }; + if (cookieHeader) headers['Cookie'] = cookieHeader; + if (this.csrfToken) headers['X-CSRFToken'] = this.csrfToken; + + let payload; + if (body !== undefined) { + headers['Content-Type'] = contentType; + payload = typeof body === 'string' ? body : JSON.stringify(body); + headers['Content-Length'] = Buffer.byteLength(payload).toString(); + } + + if (this.verbose) { + const cookieNames = cookieHeader + ? cookieHeader.split(';').map((c) => c.split('=')[0].trim()).join(',') + : '(none)'; + console.log( + `[mcms] -> ${method} ${fullUrl.pathname}${fullUrl.search} ` + + `cookies=[${cookieNames}] csrf=${this.csrfToken ? 'yes' : 'no'}`, + ); + // Dump all outgoing headers so we can diff against a working curl. + // Cookie values are redacted — only names are interesting here. + const safeHeaders = { ...headers }; + if (safeHeaders.Cookie) safeHeaders.Cookie = `<${cookieNames}>`; + if (safeHeaders['X-CSRFToken']) safeHeaders['X-CSRFToken'] = ''; + console.log('[mcms] headers:', safeHeaders); + } + const startedAt = Date.now(); + + return new Promise((resolve, reject) => { + const req = https.request( + { + method, + hostname: fullUrl.hostname, + port: fullUrl.port || 443, + path: fullUrl.pathname + fullUrl.search, + headers, + rejectUnauthorized: this.rejectUnauthorized, + timeout: 60000, // 60s — MCMS lists can be slow over a big fleet + }, + async (res) => { + // Record Set-Cookie headers in our jar. + const setCookie = res.headers['set-cookie'] || []; + for (const c of setCookie) { + try { + await this.jar.setCookie(c, fullUrl.toString()); + } catch (e) { + // non-fatal + } + } + // Capture CSRF token when it's refreshed. MCMS 6.2 sets the + // cookie as `__Host-csrftoken` (RFC 6265bis __Host- prefix for + // Secure + Path=/ cookies); older builds may use bare `csrftoken`. + const jarCookies = await this.jar.getCookies(fullUrl.toString()); + const csrf = jarCookies.find( + (c) => c.key === 'csrftoken' || c.key === '__Host-csrftoken', + ); + if (csrf) this.csrfToken = csrf.value; + + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + parsed = raw; + } + if (this.verbose) { + const ms = Date.now() - startedAt; + const size = raw ? raw.length : 0; + console.log( + `[mcms] <- ${res.statusCode} ${method} ${fullUrl.pathname} ` + + `(${ms}ms, ${size}B)`, + ); + // On any non-2xx, dump both the headers and the raw body so + // we can see what openresty/Apache/Django actually said. We + // want the WHOLE body, not a 200-char truncation, because + // proxy errors are often short HTML snippets that tell you + // exactly which middleware rejected the request. + if (res.statusCode < 200 || res.statusCode >= 300) { + console.log('[mcms] response headers:', res.headers); + console.log('[mcms] response body:', raw); + } + } + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ status: res.statusCode, body: parsed, headers: res.headers }); + } else { + // Surface whatever the server put in the response — often + // details.message or details.error describes the real cause + // (bad credentials, stale CSRF, missing header, etc.). + let detail = ''; + if (parsed && typeof parsed === 'object') { + const d = parsed.details || parsed; + if (typeof d === 'string') detail = d; + else if (d.message) detail = d.message; + else if (d.detail) detail = d.detail; + else if (d.error) detail = typeof d.error === 'string' ? d.error : JSON.stringify(d.error); + else detail = JSON.stringify(d).slice(0, 200); + } else if (typeof parsed === 'string' && parsed) { + detail = parsed.slice(0, 200); + } + const suffix = detail ? ` — ${detail}` : ''; + reject( + new McmsApiError( + `MCMS ${method} ${fullUrl.pathname} failed: HTTP ${res.statusCode}${suffix}`, + { status: res.statusCode, body: parsed }, + ), + ); + } + }); + }, + ); + req.on('error', (err) => { + if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} error:`, err?.message || err); + reject(err); + }); + req.on('timeout', () => { + if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} TIMEOUT after 60s`); + req.destroy(new Error('Request timeout (60s)')); + }); + if (payload) req.write(payload); + req.end(); + }); + } + + // --- Auth ------------------------------------------------------------- + + async login(username, password) { + // The PON Manager web app POSTs { "data": { "email":..., "password":... } } + // directly — no CSRF prime GET needed, and this is the body shape that + // actually establishes a session on observed MCMS builds. If the server + // rejects it with 400, try the dev-guide-documented unwrapped shape. + const credentials = { email: username, password }; + try { + const res = await this._request('POST', '/v1/users/authenticate/', { + body: { data: credentials }, + }); + return res.body; + } catch (e) { + if (e instanceof McmsApiError && e.status === 400) { + const res = await this._request('POST', '/v1/users/authenticate/', { + body: credentials, + }); + return res.body; + } + throw e; + } + } + + async logout() { + try { + await this._request('GET', '/v1/users/logout/'); + } catch (_) { + // best-effort + } + } + + async selectDatabase(databaseId) { + return this._request('PUT', '/v1/databases/selection/', { + body: { data: databaseId }, + }); + } + + // --- ONUs ------------------------------------------------------------- + + /** + * List ONU configurations with an optional server-side Mongo-style filter. + * MCMS supports query/projection/sort/limit URL params; we pass them + * through as-is (the server JSON-parses them). + */ + async listOnuConfigs({ query, projection, sort, limit, skip } = {}) { + const res = await this._request('GET', '/v1/onus/configs/', { + query: { query, projection, sort, limit, skip }, + }); + return res.body?.data || []; + } + + async getOnuConfig(onuId) { + const res = await this._request('GET', `/v1/onus/configs/${encodeURIComponent(onuId)}/`); + return res.body?.data; + } + + async listOnuStates({ query, projection, limit } = {}) { + const res = await this._request('GET', '/v1/onus/states/', { + query: { query, projection, limit }, + }); + return res.body?.data || []; + } + + /** + * Paginate a list endpoint using the `next` cursor (not `skip` — `skip` + * has been observed 500'ing on this MCMS build). Each page passes the + * last document's _id as `next`, which excludes all IDs up to and + * including that value. + */ + async _listPaginatedByNext(path, { pageSize = 1000, maxTotal = 100000 } = {}) { + const all = []; + let next; + for (;;) { + const query = { limit: pageSize }; + if (next) query.next = next; + const res = await this._request('GET', path, { query }); + const batch = Array.isArray(res.body?.data) ? res.body.data : []; + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < pageSize) break; + next = batch[batch.length - 1]?._id; + if (!next) break; + if (all.length >= maxTotal) break; + } + return all; + } + + /** + * Pull ALL ONU-CFG documents using `next`-cursor pagination at a small + * page size. Large single-page fetches have been observed timing out + * at Apache (before Django logs the request) on ~1500-ONU fleets + * because full ONU-CFG docs are ~30KB each. 100/page keeps each + * response under ~3MB and well under the proxy timeout. + */ + async listAllOnuConfigs({ pageSize = 100 } = {}) { + return this._listPaginatedByNext('/v3/onus/configs/', { pageSize }); + } + + /** + * Fetch a single ONU-STATE document. Uses v3 (the only path observed + * working on this MCMS build for per-ONU state). + */ + async getOnuState(onuId) { + try { + const res = await this._request('GET', `/v3/onus/states/${encodeURIComponent(onuId)}/`); + return res.body?.data; + } catch (e) { + if (e instanceof McmsApiError && e.status === 404) return null; + throw e; + } + } + + /** + * Bulk-fetch ALL ONU-STATE documents. Same pagination strategy as + * configs — page size 100 via `next` cursor keeps each response + * small enough for the proxy not to time out. + */ + async listAllOnuStates({ pageSize = 100 } = {}) { + return this._listPaginatedByNext('/v3/onus/states/', { pageSize }); + } + + /** + * Replace an ONU-CFG document. The caller supplies the full document + * (typically fetched, mutated, then passed here) — MCMS does not accept + * partial updates on this endpoint. + */ + async putOnuConfig(onuId, fullDoc) { + const res = await this._request('PUT', `/v1/onus/configs/${encodeURIComponent(onuId)}/`, { + body: { data: fullDoc }, + }); + return res.body; + } + + /** + * Delete an ONU-CFG document. This is what Procedure 43 "Deleting an ONU" + * in the PON Manager User Guide does. The STATE document may linger until + * its own TTL — call `deleteOnuState` too if you want a clean sweep. + */ + async deleteOnuConfig(onuId) { + const res = await this._request('DELETE', `/v1/onus/configs/${encodeURIComponent(onuId)}/`); + return res.body; + } + + /** + * Delete the ONU-STATE document. Best-effort — the OLT will rewrite it + * if the ONU comes back online, so this is only useful in combination + * with deleting the CFG (or if you know the ONU is physically gone). + */ + async deleteOnuState(onuId) { + const res = await this._request('DELETE', `/v1/onus/states/${encodeURIComponent(onuId)}/`); + return res.body; + } + + // --- OLTs (for ONU registration status) ------------------------------ + + /** + * OLT-STATE documents carry an `ONU States` field that buckets every + * known ONU by its current registration bucket: + * Registered | Deregistered | Dying Gasp | Disabled | + * Disallowed Admin | Disallowed Error | Disallowed Reg ID | + * Unspecified | Unprovisioned + * See 323-1961-306 Procedure 2 ("Determining the registration status of + * an ONU"). The PON Manager web UI uses this same data to label ONUs. + */ + async listAllOltStates({ pageSize = 100 } = {}) { + return this._listPaginatedByNext('/v3/olts/states/', { pageSize }); + } + + // --- Firmware inventory ---------------------------------------------- + + async listOnuFirmware() { + const res = await this._request('GET', '/v1/files/onu-firmware/'); + return res.body?.data || []; + } + + async uploadOnuFirmware(filename, base64Contents, metadata) { + const res = await this._request( + 'POST', + `/v1/files/onu-firmware/${encodeURIComponent(filename)}/`, + { + body: { + data: { + file: base64Contents, + metadata: metadata || {}, + }, + }, + }, + ); + return res.body; + } + + // --- Bulk upgrade task (Procedure 8) --------------------------------- + + /** + * Create or replace an AUTO-TASK-CFG that schedules a firmware upgrade + * across many ONUs. MCMS owns retries/staging once this is written. + * + * @param {object} opts + * @param {string} opts.taskId Unique task ID (caller-generated). + * @param {string[]} opts.serials ONU serial numbers. + * @param {string} opts.scheduledStart UTC, "YYYY-MM-DD HH:MM:SS" + * @param {number} opts.fwBankPtr 0 | 1 — target (inactive) bank. + * @param {string} opts.targetFile Filename as it appears in onu-firmware. + * @param {string} opts.targetVersion Matching version string. + * @param {string} [opts.companionFile] Existing file name for the *other* slot + * (so we don't blank it by sending []). + * @param {string} [opts.companionVersion] + */ + async createFirmwareTask({ + taskId, + serials, + scheduledStart, + fwBankPtr, + targetFile, + targetVersion, + companionFile = '', + companionVersion = '', + }) { + const files = ['', '']; + const versions = ['', '']; + files[fwBankPtr] = targetFile; + versions[fwBankPtr] = targetVersion; + const otherSlot = fwBankPtr === 0 ? 1 : 0; + files[otherSlot] = companionFile; + versions[otherSlot] = companionVersion; + + const doc = { + _id: taskId, + Task: { + 'Device Type': 'ONU', + 'Operation': 'Firmware Upgrade', + 'Scheduled Start Time': scheduledStart, + }, + 'Task Details': { + Devices: serials, + ONU: { + 'FW Bank Ptr': fwBankPtr, + 'FW Bank Files': { 0: files[0], 1: files[1] }, + 'FW Bank Versions': { 0: versions[0], 1: versions[1] }, + }, + }, + }; + const res = await this._request('PUT', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`, { + body: { data: doc }, + }); + return res.body; + } + + async getTaskConfig(taskId) { + const res = await this._request('GET', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`); + return res.body?.data; + } + + // --- Per-ONU upgrade status ------------------------------------------ + + /** + * The status endpoint the user pasted in the original spec. Path differs + * from /v1/... — it's a helper MCMS exposes for polling an in-flight + * download. If the deployment doesn't expose this shape, prefer polling + * the ONU-CFG document to watch FW Bank Versions/Ptr flip. + */ + async getOnuUpgradeStatus(onuId) { + const res = await this._request('GET', `/v1/onus/${encodeURIComponent(onuId)}/upgrade/status/`); + return res.body?.data || res.body; + } +} + +module.exports = { + McmsClient, + McmsApiError, + formatMcmsTime, + parseMcmsTime, + extractOnuHealth, + extractFecHealth, // legacy alias — calls extractOnuHealth + formatFecNumber, +};