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