initial commit

This commit is contained in:
Jon Vanvik 2026-05-26 11:16:18 +02:00
commit 0eb9b55717
11 changed files with 3969 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
package-lock.json
dist/
out/
.DS_Store

648
CLAUDE.md Normal file
View file

@ -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/<id>/` per ONU at concurrency 5,
then best-effort `DELETE /v1/onus/states/<id>/`. Two-step confirm
(alert + typed `DELETE`). Streams progress per ONU.
### Campaign view
- Firmware picker bound to `/files/onu-firmware/`, inferring version
from filename (regex `[A-Z]{2}\d{5}[A-Z]?`).
- Upload firmware via Electron `dialog.showOpenDialog`.
- Execution mode toggle: **bulk task** (Procedure 8 — recommended) or
**per-ONU PUT** (Procedure 7 — per-device feedback).
- Scheduled-start datetime-local picker (converts local → UTC string
`YYYY-MM-DD HH:MM:SS`).
- **Dry-run preview** with FEC pre-flight: each selected ONU is
re-fetched (`getOnuState`) at preview time, and the resulting FEC
pill + optical levels + raw counters are rendered in a new column.
- **CSV auto-save on Execute** — writes
`~/Downloads/pon-upgrade-<version>-<mode>-<count>onus-<stamp>.csv`
the moment Execute is clicked, *before* any MCMS write. See §10.
- Soft FEC gate in the Execute confirmation dialog: warns (but doesn't
block) when any selected ONU is showing post-FEC errors or the
pre==post buggy signature.
### Verify view (after-upgrade comparison)
- **Open plan CSV…** — file dialog defaulted to `~/Downloads`. Parser
is lenient — needs only an `ONU ID` column, everything else is
optional.
- For each ONU listed, fetches **both** ONU-STATE (fresh FEC + optical)
and ONU-CFG (to confirm `FW Bank Ptr` flipped and the active version
now equals the recorded target). Concurrency 8.
- Renders a comparison table with target→active, "Applied?" badge,
before/after FEC pills + counters, before/after optical RX/TX, and
a one-line verdict per ONU.
- **Auto-saves** the comparison to
`~/Downloads/pon-verify-<trailing-of-source>-<stamp>.csv` the moment
the comparison finishes, so it sorts next to the original.
- Side-panel summary: counts of applied/not-applied, fixed/improved,
still-buggy, regressed, still-healthy.
### FEC health rules
(`extractOnuHealth` in `src/mcms-api.js`)
| Flag | Meaning | Trigger |
|---|---|---|
| `ok` | green | All four FEC counters present and 0 |
| `pre` | yellow | Any pre-FEC counter > 0, all post-FEC = 0 |
| `post` | red | Any post-FEC counter > 0 (uncorrected errors) |
| `buggy` | blue | ONU pre>0 AND post>0 AND `post/pre >= 0.95` |
| `nodata` | grey | No FEC counters found in STATS |
| `error` | grey | Per-ONU fetch threw |
**Buggy detection note.** Real-world example from a GNXS Everest ONU:
`Pre-FEC BER = 22,388,041,291`, `Post-FEC BER = 22,379,111,198`. Those
differ by 0.04%, which is well into "FEC isn't doing anything" territory.
Exact-equality detection would miss this; the 0.95 ratio threshold
catches it. The condition is intentionally one-sided — we treat ONU
side as the bug source (the OLT side counters are computed by the OLT
firmware which we trust).
### Optical thresholds
From the PON Manager User Guide green-LED criteria (323-1961-302 p149):
| Field | Green | Yellow | Red |
|---|---|---|---|
| ONU RX | ≥ 28 dBm | 28 to 30 dBm | < 30 dBm |
| ONU TX | ≥ 3 dBm | — | < 3 dBm |
Yellow is a margin band we added — the user guide is binary (≥ 30 vs
< 30), but operators want a warning before the link is actually broken.
---
## 5. MCMS API quirks
These are the gotchas worth knowing. Many were diagnosed empirically
against MCMS 6.2 + Interos Everest 5.11/5.12. **If something behaves
oddly, check this section first.**
### 5.1 User-Agent header is required
Node's `https.request` doesn't send a User-Agent by default. **Some MCMS
middleware dereferences `HTTP_USER_AGENT` without a guard and 500s when
it's absent.** Reproducible via `curl -A '' https://mcms/api/v3/...`.
`src/mcms-api.js` sends `User-Agent: pon-fleet-upgrader/0.1.0` on
every request. Do not remove.
### 5.2 CSRF cookie name is `__Host-csrftoken`
MCMS 6.2 uses RFC 6265bis `__Host-` cookie prefix (Secure + Path=/).
The cookie names are:
- `__Host-csrftoken`
- `__Host-sessionid`
- `__Host-sessionexpire`
Older MCMS builds shipped bare `csrftoken`. The client looks for both:
```js
const csrf = jarCookies.find(
(c) => c.key === 'csrftoken' || c.key === '__Host-csrftoken',
);
```
The token value still goes in the `X-CSRFToken` header (no prefix).
### 5.3 Pagination: use `next`, not `skip`
`/v3/onus/configs/?skip=N` has been observed returning HTTP 500 on this
MCMS build. Use the `next` cursor: pass the last document's `_id` as
the `next` query param to get the page that *excludes* all IDs up to
and including that one.
→ See `McmsClient._listPaginatedByNext`.
### 5.4 Bulk-list page size 100
`/v3/onus/configs/?limit=1000` times out at the Apache front-end on
~1500-ONU fleets, before Django even logs the request. Full ONU-CFG
documents are ~30 KB each, so a 1000-row page is ~30 MB and the proxy
gives up.
→ Default page size is 100. Each response is ~3 MB and lands in well
under the proxy timeout.
### 5.5 Path prefix is `/api/v1/...`
The dev guide writes "GET `/v1/users/authenticate/`" as shorthand. The
real HTTP path is `/api/v1/users/authenticate/`. The client normalises
this internally — pass either form.
### 5.6 Request body envelope
PUT/POST bodies are wrapped: `{ "data": { ...payload } }`. The login
endpoint accepts both wrapped and unwrapped on observed builds (the
client tries wrapped first, falls back on 400). Everything else is
strictly wrapped.
### 5.7 PUT is full-document replace, not patch
`PUT /v1/onus/configs/<id>/` replaces the entire `ONU-CFG`. The client
fetches the doc, mutates the FW fields in place, and PUTs the result.
**Do not use PATCH** — MCMS PATCH endpoints are not used by this app
and the dev guide is explicit about PUT-as-replace for CFG updates.
### 5.8 Timestamp format
MCMS emits and accepts: `"YYYY-MM-DD HH:MM:SS[.ffffff]"` in **UTC**
with a literal space separator (not ISO-8601 `T`).
Used on: `ONU-STATE.Time`, alarm timestamps,
`AUTO-TASK-CFG.Task.Scheduled Start Time`, query-param timestamps.
→ Helpers in `src/mcms-api.js`: `formatMcmsTime(date)` and
`parseMcmsTime(str)`. The renderer also coerces the space-form to ISO
before passing to `new Date()` to avoid Node's local-time
interpretation.
### 5.9 Server-side filtering on `query`/`projection` is brittle
Mongo-style server filters get rejected (or silently return nothing)
when the URL-encoded JSON has spaces in field names, certain escape
patterns, or projection paths that don't exist on every document
version. **We load the full fleet and filter entirely client-side.**
If you need server filtering, the safest pattern is no quotes around
field names and `%20` for spaces (not `+`). The client's
`_encodeQuery` uses `encodeURIComponent` for exactly this reason.
### 5.10 ONU registration status lives on OLT-STATE, not ONU-STATE
The 9 registration buckets (Registered, Deregistered, Dying Gasp,
Disabled, Disallowed Admin/Error/Reg ID, Unspecified, Unprovisioned)
appear under `OLT-STATE["ONU States"][bucket] = [onuId, ...]`. See
323-1961-306 Procedure 2.
`listAllOltStates` pulls these, and `api:listFleet` builds a
`onuId → { status, oltMac }` map from the buckets. On duplicates we
prefer a non-Registered bucket (the interesting signal).
### 5.11 FEC counters live on ONU-STATE, not `/onus/stats/`
`/v1/onus/stats/<id>/` is the time-series PM collection — `start-time`
is required, returns historical samples. **The current FEC/optical
values are already in ONU-STATE:**
```
state_collection.STATS["ONU-PON"]["RX Pre-FEC BER"]
state_collection.STATS["ONU-PON"]["RX Post-FEC BER"]
state_collection.STATS["ONU-PON"]["RX Optical Level"] # dBm
state_collection.STATS["ONU-PON"]["TX Optical Level"] # dBm
state_collection.STATS["OLT-PON"]["RX Pre-FEC BER"]
state_collection.STATS["OLT-PON"]["RX Post-FEC BER"]
```
→ The pre-flight fetch (`api:fetchFecHealth`) and the verify fetch
(`api:fetchOnuSnapshot`) both call `getOnuState(id)` and extract via
`extractOnuHealth`. 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\<you>\Downloads` on Windows).
### Plan CSV — `pon-upgrade-<hint>-<stamp>.csv`
Auto-saved when Execute is clicked, **before** any MCMS write — so
even if the API call fails mid-way there's a record of intent.
Columns (20): ONU ID, Name, Address, PON Mode, Active slot (before),
Write slot (target), Slot 0 version (before), Slot 1 version (before),
Target version, Target file, FEC health, ONU RX Pre-FEC BER,
ONU RX Post-FEC BER, OLT RX Pre-FEC BER, OLT RX Post-FEC BER,
ONU RX Optical (dBm), ONU TX Optical (dBm), FEC sample time, Mode, Notes.
The hint is derived from `<targetVersion>-<mode>-<count>onus`.
### Verify CSV — `pon-verify-<sourcehint>-<stamp>.csv`
Auto-saved after the verify comparison finishes. Hint reuses the
trailing descriptor of the source CSV (strip `pon-upgrade-` prefix and
`.csv` suffix), so the verify file sorts next to its source in the
filesystem.
Columns (23): identifiers, Target version, Active version (after),
Upgrade applied?, FEC health (before), FEC health (after), all four
BER counters before+after (ONU + OLT, pre + post), ONU RX/TX optical
before+after, After sample time, Verdict, Fetch error.
### Verdict strings (in the Verify CSV)
| Verdict | Trigger |
|---|---|
| `fixed (was buggy)` | `buggy → ok` |
| `still buggy (firmware bug persists)` | `buggy → buggy` |
| `improved (no longer mirroring counters)` | `buggy → pre` |
| `changed: now reporting genuine post-FEC errors` | `buggy → post` |
| `still healthy` | `ok → ok` |
| `improved` | `pre|post → ok` |
| `improved (post→pre)` | 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/<id>/`.
Operators use the verify view (or the PON Manager UI) to confirm.
- **No post-execute results CSV.** The plan CSV captures intent
pre-write. Per-ONU success/failure (in Procedure 7 mode) and task
submission errors (in Procedure 8 mode) are visible on screen but
not dumped to disk. Add-on candidate.
- **No offline / cached fleet snapshot.** Every `Load fleet` hits MCMS.
- **`/v1/onus/<id>/upgrade/status/`** is referenced in some MCMS spec
documents but returns 404 on this build. Fallback: poll
`GET /v1/onus/configs/<id>/` and watch `FW Bank Versions` /
`FW Bank Ptr` flip.
- **PATCH not used** — MCMS requires full-document PUT for CFG updates
(dev guide is explicit about this).
- **Server-side query/projection not used** — see §5.9.
- **No time-series PM data shown.** We use the live snapshot from
ONU-STATE (§5.11). `GET /onus/stats/<id>/` exists but is not wired
up.
- **No retries on transient HTTP errors** beyond the per-ONU
best-effort delete-state. Bulk delete and per-ONU PUTs surface
errors per row but don't auto-retry.
- **Schedule field is UTC only.** The datetime-local picker converts
from the browser's local TZ. Operators in different TZs see different
inputs producing the same `Scheduled Start Time` value in the task —
intentional, but worth noting.
---
## 10. Conventions
### Adding a new IPC channel
Touch three files in order:
1. **`src/mcms-api.js`** — add the method on `McmsClient` if it talks
to MCMS.
2. **`main.js`** — `ipcMain.handle('api:foo', ...)`. Wrap errors via
`toWireError` so the renderer never sees stack traces.
3. **`preload.js`** — add `foo: (opts) => call('api:foo', opts)` to
the `window.api` contract. **The renderer cannot call channels not
listed here.**
4. **`renderer/app.js`** — use `window.api.foo(...)`.
For streaming progress events, also add an `onFooProgress(handler)`
function in the preload (returns an unsubscribe function).
### CSS conventions
Status colour classes (declared in `app.css`):
| Class | Meaning | CSS var |
|---|---|---|
| `.status-ok` | success / healthy | `--ok` (green) |
| `.status-warn` | margin / pre-FEC | `--warn` (yellow) |
| `.status-err` | failure / post-FEC / regressed | `--danger` (red) |
| `.status-info` | informational / buggy ONU | `--info` (blue) |
| `.status-pending` | in-flight / unknown | `--fg-dim` (grey) |
| `.muted` | secondary text | `--fg-dim` (grey) |
`.fec-cell` and `#verify-table tbody td` override
`white-space: nowrap` to allow stacked content (pill on top, counters
underneath).
### Verbose logging
`McmsClient(verbose: true)` (default in this app) logs every request:
- Method + path + cookie names (cookie values redacted)
- All outgoing headers (`X-CSRFToken` redacted)
- On non-2xx: full response headers + full response body
The combination is enough to diff against a working `curl` for
"why is this 500?" investigations. **Turn off via
`PON_FLEET_VERBOSE=0`** if logs are leaking secrets to a shared
terminal.
---
## 11. Testing
There's no formal test runner. Two cheap signals:
1. `npm run check``node --check` on every JS file. Catches syntax
errors before launching Electron.
2. Inline `node -e '...'` fixtures for pure functions (FEC extractor,
CSV roundtrip, verdict logic). Vendor the function bodies into the
test snippet rather than importing from `renderer/app.js` — that
file expects `window` / `document`.
When refactoring `extractOnuHealth`, re-test against the GNXS
real-world fixture (post=22379111198, pre=22388041291): it must flag
as `buggy`. That ratio (0.9996) is the edge case the 95% threshold
was designed to catch.
---
## 12. Reference documents
Stored alongside the project in the parent `PON-API` folder:
- `323-1961-306_mcms_6_2_rest_api_developer_guide.pdf` — REST API
reference. Cited as "the dev guide".
- `323-1961-302_mcms_6_2_pon_manager_user_guide.pdf` — UI guide.
Procedures 7, 8, 38, 43 referenced from code comments.
- `323-1963-105_mcms_6_2_onu_planning_guide.pdf` — ONU planning
context (PON design, link budgets).
Key procedure citations:
- **Procedure 2** (REST API dev guide) — determining ONU registration
status via OLT-STATE buckets.
- **Procedure 7** (REST API dev guide) — per-ONU firmware upgrade via
full-doc PUT.
- **Procedure 8** (REST API dev guide) — bulk firmware upgrade via
`AUTO-TASK-CFG`.
- **Procedure 38** (PON Manager User Guide) — Resetting bit error
rate values; identifies the four FEC counters in the UI.
- **Procedure 43** (PON Manager User Guide) — Deleting an ONU.
---
## 13. History / non-obvious design choices
- **CSV is auto-saved BEFORE the API write, not after.** Intentional:
capture intent even if the write fails. A "results CSV" with
per-ONU success/failure is an obvious add-on but not yet wired.
- **Plan CSV uses 20 columns even for trivial cases.** Wide is fine
for spreadsheets; narrow forces splits later.
- **FEC pre-flight only runs on the selection, not the fleet.**
Originally implemented to query the whole fleet at preview time,
but operator preference is "right before commit, not for the
entire fleet" — keeps `/v3/onus/states/<id>/` calls bounded to N
selected.
- **Buggy detection uses ratio, not equality.** See §4 — real
observed buggy pairs aren't bit-identical (timing windows differ),
so `post == pre` misses the case. `post / pre >= 0.95` catches it.
- **Optical thresholds add a yellow margin band** beyond the user
guide's binary green/red. Operators want a warning before a link
actually fails.
- **`__Host-` cookie prefix support was added retroactively** —
initial code only matched `csrftoken`, causing intermittent 500s
on session refresh.
- **User-Agent header was the root cause of one prolonged debugging
session.** Documented in §5.1 so we don't lose that knowledge.
---
*Last updated: 2026-05-26.*

114
README.md Normal file
View file

@ -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/<id>/`. 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/<id>/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/<id>/` 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/<taskId>/`.
- 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.

619
main.js Normal file
View file

@ -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/<id>/ 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) };
}
});

18
package.json Normal file
View file

@ -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"
}
}

69
preload.js Normal file
View file

@ -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);
},
});

213
renderer/app.css Normal file
View file

@ -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);
}

1282
renderer/app.js Normal file

File diff suppressed because it is too large Load diff

251
renderer/index.html Normal file
View file

@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>PON Fleet Upgrader</title>
<link rel="stylesheet" href="app.css" />
</head>
<body>
<header class="topbar">
<div class="brand">PON Fleet Upgrader</div>
<div class="session" id="session-label">Not connected</div>
<button id="btn-logout" class="ghost hidden">Disconnect</button>
</header>
<!-- ================= Login view ================= -->
<section id="view-login" class="view">
<div class="card">
<h1>Connect to MCMS</h1>
<p class="muted">
Session-cookie auth against a Ciena MicroClimate Management System.
The host should be the root URL of the PON Manager web UI
(e.g. <code>https://mcms.example.net</code>).
</p>
<label>Host URL<input id="in-host" type="url" placeholder="https://mcms.example.net" /></label>
<label>Email / username<input id="in-user" type="text" autocomplete="username" /></label>
<label>Password<input id="in-pass" type="password" autocomplete="current-password" /></label>
<label class="inline">
<input id="in-self-signed" type="checkbox" checked />
Accept self-signed TLS certificate
</label>
<div class="row">
<button id="btn-login" class="primary">Connect</button>
<span id="login-error" class="error"></span>
</div>
</div>
</section>
<!-- ================= Fleet view ================= -->
<section id="view-fleet" class="view hidden">
<div class="panel-left">
<h2>Filters</h2>
<label>Name / address contains
<input id="filter-text" type="search" placeholder="e.g. ORKANGER" />
</label>
<label>Equipment ID contains
<input id="filter-equipment" type="text" placeholder="e.g. FT-XGS2110" />
</label>
<label>PON mode
<select id="filter-pon">
<option value="">Any</option>
<option value="GPON">GPON</option>
<option value="XGS-PON">XGS-PON</option>
<option value="10G-EPON">10G-EPON</option>
</select>
</label>
<label>Active version matches
<input id="filter-version" type="text" placeholder="e.g. EV05110R" />
</label>
<label>Exclude active version
<input id="filter-exclude-version" type="text" placeholder="e.g. EV05120R (hide already-upgraded)" />
</label>
<label>Model / version family
<input id="filter-model" type="text" placeholder="e.g. EV051 (Interos Everest 5.11.x)" />
</label>
<label>Registration status
<select id="filter-status">
<option value="">Any</option>
<option value="__down__">Down (Deregistered / Dying Gasp / Disabled)</option>
<option value="Registered">Registered</option>
<option value="Deregistered">Deregistered</option>
<option value="Dying Gasp">Dying Gasp</option>
<option value="Disabled">Disabled</option>
<option value="Disallowed Admin">Disallowed Admin</option>
<option value="Disallowed Error">Disallowed Error</option>
<option value="Disallowed Reg ID">Disallowed Reg ID</option>
<option value="Unspecified">Unspecified</option>
<option value="Unprovisioned">Unprovisioned</option>
<option value="__unknown__">Unknown (no OLT state)</option>
</select>
</label>
<label>Down for at least (days)
<input id="filter-down-days" type="number" min="0" step="1" placeholder="e.g. 30" />
</label>
<button id="btn-refresh" class="primary">Load fleet</button>
<p class="muted small" id="fleet-status"></p>
<h2>Selection</h2>
<div class="selected-summary">
<div><strong id="sel-count">0</strong> ONUs selected</div>
<div class="row">
<button id="btn-select-all">Select all matching</button>
<button id="btn-select-none" class="ghost">Clear</button>
</div>
</div>
<button id="btn-to-campaign" class="primary" disabled>Continue to upgrade →</button>
<button id="btn-delete-selected" class="danger" disabled>Delete selected from MCMS…</button>
<p class="muted small" id="delete-status"></p>
<h2>After-upgrade verification</h2>
<p class="muted small">Open a saved <code>pon-upgrade-*.csv</code> to compare the recorded FEC + optical readings against the current state of those ONUs.</p>
<button id="btn-to-verify" class="ghost">Verify previous upgrade…</button>
</div>
<div class="panel-main">
<div class="fleet-head">
<h2>Fleet (<span id="fleet-count">0</span>)</h2>
<div class="row small muted">
<span>Tip: use filters to narrow to one model before selecting all.</span>
</div>
</div>
<div class="fleet-table-wrap">
<table class="fleet-table" id="fleet-table">
<thead>
<tr>
<th><input type="checkbox" id="th-check" /></th>
<th>Serial</th>
<th>Name / address</th>
<th>Equipment ID</th>
<th>PON</th>
<th>Status</th>
<th>Last seen</th>
<th>Active bank</th>
<th>Active version</th>
<th>Inactive version</th>
</tr>
</thead>
<tbody id="fleet-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ================= Upgrade campaign view ================= -->
<section id="view-campaign" class="view hidden">
<div class="panel-left">
<h2>Campaign</h2>
<label>Target firmware file
<select id="fw-select"></select>
</label>
<label>Target version
<input id="fw-version" type="text" placeholder="e.g. EV05120R" />
</label>
<div class="row">
<button id="btn-upload-fw" class="ghost">Upload .bin…</button>
<button id="btn-reload-fw" class="ghost">Refresh list</button>
</div>
<h2>Execution</h2>
<label>Mode
<select id="mode-select">
<option value="task">Bulk task (Procedure 8, recommended)</option>
<option value="peronu">Per-ONU PUT (Procedure 7)</option>
</select>
</label>
<label id="row-schedule">Scheduled start (UTC)
<input id="in-schedule" type="datetime-local" />
</label>
<p class="muted small">
Writes always go to the inactive bank (FW Bank Ptr toggled to <code>1 active</code>, or <code>1</code> if unset).
MCMS flips the pointer when the download completes; the ONU reboots into the new image on its next reboot.
</p>
<div class="row">
<button id="btn-back" class="ghost">← Back to fleet</button>
<button id="btn-preview" class="primary">Preview plan</button>
</div>
<div class="row">
<button id="btn-execute" class="danger" disabled>Execute upgrade</button>
</div>
<p id="exec-status" class="small muted"></p>
</div>
<div class="panel-main">
<h2>Dry-run preview</h2>
<p class="muted small">Nothing has been written yet. Review the plan before clicking Execute.</p>
<div class="fleet-table-wrap">
<table class="fleet-table">
<thead>
<tr>
<th>Serial</th>
<th>Name</th>
<th>Active → new active slot</th>
<th>Current slot 0</th>
<th>Current slot 1</th>
<th>Target version</th>
<th>FEC health</th>
<th>Status</th>
</tr>
</thead>
<tbody id="preview-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ================= Verify view ================= -->
<section id="view-verify" class="view hidden">
<div class="panel-left">
<h2>Verify previous upgrade</h2>
<p class="muted small">
Pick a saved upgrade-plan CSV (defaults to <code>~/Downloads</code>).
The app re-fetches each ONU's current FEC counters, optical levels,
and active firmware version, then computes a before/after comparison
and saves a verification CSV alongside the original.
</p>
<button id="btn-pick-plan" class="primary">Open plan CSV…</button>
<p class="muted small" id="verify-status"></p>
<h2>Summary</h2>
<div class="selected-summary" id="verify-summary">
<div class="muted small">Open a CSV to start.</div>
</div>
<div class="row">
<button id="btn-verify-back" class="ghost">← Back to fleet</button>
<button id="btn-save-verify" class="primary" disabled>Re-save report</button>
</div>
<p id="verify-save-status" class="small muted"></p>
</div>
<div class="panel-main">
<div class="fleet-head">
<h2>Comparison (<span id="verify-count">0</span>)</h2>
<div class="row small muted">
<span>Note: ONU FEC counters typically reset on the upgrade reboot, so "after &lt; before" is normal. Look at the FEC flag and verdict columns for the actual signal.</span>
</div>
</div>
<div class="fleet-table-wrap">
<table class="fleet-table" id="verify-table">
<thead>
<tr>
<th>Serial</th>
<th>Name</th>
<th>Target → Active now</th>
<th>Applied?</th>
<th>FEC before</th>
<th>FEC now</th>
<th>Optical (RX/TX dBm)</th>
<th>Verdict</th>
</tr>
</thead>
<tbody id="verify-tbody"></tbody>
</table>
</div>
</div>
</section>
<script src="app.js"></script>
</body>
</html>

102
src/bank-strategy.js Normal file
View file

@ -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/<id>/ 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,
};

648
src/mcms-api.js Normal file
View file

@ -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'] = '<redacted>';
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,
};