initial commit
This commit is contained in:
commit
0eb9b55717
11 changed files with 3969 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
dist/
|
||||
out/
|
||||
.DS_Store
|
||||
648
CLAUDE.md
Normal file
648
CLAUDE.md
Normal 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
114
README.md
Normal 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
619
main.js
Normal 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
18
package.json
Normal 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
69
preload.js
Normal 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
213
renderer/app.css
Normal 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
1282
renderer/app.js
Normal file
File diff suppressed because it is too large
Load diff
251
renderer/index.html
Normal file
251
renderer/index.html
Normal 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 < 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
102
src/bank-strategy.js
Normal 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
648
src/mcms-api.js
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue