Add bulk OLT PON flooding-mode tool; restore README; docs
Adds a second bulk workflow alongside the firmware upgrader: changing PON flooding mode (private<->auto) on OLT NNI services across many OLTs. Flooding mode is encoded by the presence of the "PON FLOOD ID" key on each OLT-CFG["NNI Networks"] entry (present = private, absent = auto). private->auto deletes the key; auto->private sets it to 0. The rest of the OLT-CFG is preserved (PUT-as-replace, same as ONU-CFG). - src/mcms-api.js: listAllOltConfigs / getOltConfig / putOltConfig, plus pure helpers floodModeOfNni, summarizeOltNniNetworks, planFloodChange (planFloodChange clones and never mutates its input) - main.js: api:listOlts + api:executeFloodChange IPC handlers (concurrency 3, GET->plan->PUT, streams olt-flood:progress) - preload.js: expose both channels + onFloodProgress - renderer: OLT inspector view (#view-olts), two-step APPLY confirm, auto-saved pon-olt-flood-*.csv result report - Restore README.md (lost to a filesystem error) and document the new feature in README.md and CLAUDE.md (new section 14) Verified: npm run check passes; pure helpers unit-tested for the no-mutation, idempotency, and rollback invariants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0eb9b55717
commit
80fd7e8d37
7 changed files with 612 additions and 7 deletions
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -12,9 +12,15 @@ 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).
|
||||
Electron desktop client for two bulk operations against **Ciena
|
||||
MCMS 6.2** PON Manager:
|
||||
|
||||
1. **Bulk ONU firmware upgrades** (the original purpose).
|
||||
2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
|
||||
see §14).
|
||||
|
||||
Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest
|
||||
(EV051xxR / EV052xxR firmware family) behind Tibit OLTs.
|
||||
|
||||
End-to-end operator workflow:
|
||||
|
||||
|
|
@ -116,6 +122,8 @@ stream progress events back to the renderer.
|
|||
| `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:listOlts` | OLT-CFG list + per-OLT NNI flood-mode summary | — |
|
||||
| `api:executeFloodChange` | bulk PON flooding-mode flip (GET→plan→PUT) | `olt-flood:progress` |
|
||||
| `api:fetchFecHealth` | pre-flight FEC pre/post + optical | `fec:progress` |
|
||||
| `api:savePlanCsv` | auto-save plan on Execute | — |
|
||||
| `api:saveCsvFile` | generic CSV writer | — |
|
||||
|
|
@ -645,4 +653,72 @@ Key procedure citations:
|
|||
|
||||
---
|
||||
|
||||
*Last updated: 2026-05-26.*
|
||||
## 14. OLT PON flooding mode (bulk private↔auto)
|
||||
|
||||
Second bulk feature, added after the firmware upgrader. Lets an operator
|
||||
flip the PON flooding mode of OLT NNI services across many OLTs at once.
|
||||
|
||||
### The mutation rule (the whole feature in one fact)
|
||||
|
||||
On Tibit OLTs, a service's flooding mode is encoded by the **presence or
|
||||
absence of one key** on its `OLT-CFG["NNI Networks"][i]` entry:
|
||||
|
||||
- **`PON FLOOD ID` key present** (any value, including `0`) → **private**
|
||||
- **`PON FLOOD ID` key absent** → **auto**
|
||||
|
||||
So private→auto is `delete entry['PON FLOOD ID']`; auto→private is
|
||||
`entry['PON FLOOD ID'] = 0`. Everything else on the entry (`TAG MATCH`,
|
||||
`Learning Limit`, …) and the rest of the OLT-CFG is preserved. This was
|
||||
confirmed empirically by diffing two paste-backs of the same OLT
|
||||
(`24:65:e1:cc:af:1e`, NNI `s0.c76.c0`) in private vs auto — the *only*
|
||||
difference was that key.
|
||||
|
||||
The default action ships as: TAG MATCH = `s0.c76.c0`, mode `private` →
|
||||
`auto`. Both directions are wired so a change can be rolled back from the
|
||||
same UI.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure)
|
||||
executeFloodChange ─► per-OLT: getOltConfig ─► planFloodChange (pure, no-mutate)
|
||||
─► putOltConfig (full-doc PUT)
|
||||
```
|
||||
|
||||
- **Pure helpers** live at the bottom of `src/mcms-api.js`:
|
||||
`floodModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
|
||||
`planFloodChange`. `planFloodChange` clones the `NNI Networks` array and
|
||||
never mutates its input — it returns `{ newDoc, changes, unchanged }`.
|
||||
- **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and
|
||||
`*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars
|
||||
then turns `*` into `.*`.
|
||||
- **`fromMode` guard**: only entries currently in `fromMode` are flipped;
|
||||
entries already in the target mode are recorded in `unchanged` (the
|
||||
per-OLT `noop` case), so re-running is idempotent.
|
||||
- **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state
|
||||
fetch) — OLT writes carry far more blast radius.
|
||||
- **PUT-as-replace**, same gotcha as ONU-CFG (§5.7): the handler GETs the
|
||||
live doc immediately before each PUT to avoid stomping a concurrent
|
||||
edit, then writes the whole document back.
|
||||
|
||||
### UI (OLT inspector view, `#view-olts`)
|
||||
|
||||
Reached from the fleet view's "Open OLT inspector…" button. Filters by TAG
|
||||
pattern + current mode + OLT name; selecting an OLT queues every matching
|
||||
service on it. Two-step confirm (summary `confirm` + typed `APPLY`) before
|
||||
writing. Auto-saves a result CSV to `~/Downloads/pon-olt-flood-…csv` with
|
||||
per-OLT result, change detail, and skip reasons — same `writeCsvFile`
|
||||
helper and conventions as the upgrade/verify CSVs (§8).
|
||||
|
||||
### Testing the pure helpers
|
||||
|
||||
`floodModeOfNni` / `summarizeOltNniNetworks` / `planFloodChange` are
|
||||
exported from `src/mcms-api.js` and have no DOM/Electron deps, so they're
|
||||
directly `require`-able in a `node -e` fixture. The invariants worth
|
||||
re-checking after any edit: (1) `planFloodChange` does **not** mutate its
|
||||
input, (2) re-applying the same change is a no-op, (3) non-matching NNI
|
||||
entries pass through untouched.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-06-23.*
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -1,7 +1,14 @@
|
|||
# PON Fleet Upgrader
|
||||
|
||||
An Electron desktop app for bulk ONU firmware upgrades against a **Ciena
|
||||
MicroClimate Management System (MCMS) 6.2** PON Manager.
|
||||
An Electron desktop app for bulk operations against a **Ciena
|
||||
MicroClimate Management System (MCMS) 6.2** PON Manager. It does two
|
||||
things:
|
||||
|
||||
1. **Bulk ONU firmware upgrades** (below).
|
||||
2. **Bulk OLT PON flooding-mode changes** (private↔auto on NNI services —
|
||||
see "OLT PON flooding mode" below).
|
||||
|
||||
## ONU firmware upgrades
|
||||
|
||||
Filters a fleet of ONUs, previews the planned writes, and stages new
|
||||
firmware to each device's **inactive bank** so the forced reboot is
|
||||
|
|
@ -94,6 +101,28 @@ pon-fleet-upgrader/
|
|||
└── app.js # UI logic
|
||||
```
|
||||
|
||||
## OLT PON flooding mode
|
||||
|
||||
From the fleet view, **Open OLT inspector…** opens a second workflow for
|
||||
bulk-checking and changing the PON flooding mode of OLT NNI services.
|
||||
|
||||
On Tibit OLTs the flooding mode is encoded by the presence of a single key
|
||||
on each `OLT-CFG["NNI Networks"]` entry:
|
||||
|
||||
| Mode | `PON FLOOD ID` key | Change |
|
||||
|------|--------------------|--------|
|
||||
| **private** | present (any value, incl. `0`) | → auto: delete the key |
|
||||
| **auto** | absent | → private: set the key to `0` |
|
||||
|
||||
Workflow: enter a **TAG MATCH** pattern (exact like `s0.c76.c0`, or a `*`
|
||||
glob like `s0.c76.*`), **Load OLTs**, select the OLTs to change, pick a
|
||||
direction, then **Execute change…** (two-step confirm). Each OLT is
|
||||
re-fetched, the matching NNI entries are flipped, and the full OLT-CFG is
|
||||
PUT back. A result CSV is auto-saved to `~/Downloads/pon-olt-flood-*.csv`.
|
||||
|
||||
Both directions are supported, so a change can be rolled back from the same
|
||||
screen. The default action targets `s0.c76.c0`, `private` → `auto`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The `/v1/onus/<id>/upgrade/status/` path used by the original question
|
||||
|
|
|
|||
91
main.js
91
main.js
|
|
@ -8,7 +8,10 @@
|
|||
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { McmsClient, McmsApiError, extractOnuHealth } = require('./src/mcms-api');
|
||||
const {
|
||||
McmsClient, McmsApiError, extractOnuHealth,
|
||||
summarizeOltNniNetworks, planFloodChange,
|
||||
} = require('./src/mcms-api');
|
||||
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
|
||||
|
||||
/** @type {McmsClient | null} */
|
||||
|
|
@ -617,3 +620,89 @@ ipcMain.handle('api:deleteOnus', async (event, { onuIds, concurrency = 5 }) => {
|
|||
return { ok: false, error: toWireError(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- OLT PON flooding mode -------------------------------------------
|
||||
|
||||
/**
|
||||
* Bulk-load every OLT-CFG, summarize each one's NNI Networks against
|
||||
* the given TAG MATCH pattern, and return a flat row per OLT.
|
||||
*/
|
||||
ipcMain.handle('api:listOlts', async (_ev, opts) => {
|
||||
try {
|
||||
const c = requireClient();
|
||||
const tagPattern = (opts && opts.tagPattern) || '';
|
||||
const cfgs = await c.listAllOltConfigs();
|
||||
const rows = cfgs.map((cfg) => {
|
||||
const summary = summarizeOltNniNetworks(cfg, tagPattern);
|
||||
const counts = { private: 0, auto: 0, unknown: 0 };
|
||||
for (const s of summary) counts[s.mode] = (counts[s.mode] || 0) + 1;
|
||||
return {
|
||||
_id: cfg._id,
|
||||
name: cfg?.OLT?.Name || '',
|
||||
location: cfg?.OLT?.Location || '',
|
||||
ponMode: cfg?.OLT?.['PON Mode'] || '',
|
||||
activeFwVersion: cfg?.OLT?.['FW Bank Versions']?.[cfg?.OLT?.['FW Bank Ptr']] || '',
|
||||
matchingNni: summary,
|
||||
counts,
|
||||
};
|
||||
});
|
||||
return { ok: true, data: rows };
|
||||
} catch (err) {
|
||||
return { ok: false, error: toWireError(err) };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a planned flooding mode change to a list of OLT IDs. For each
|
||||
* OLT we GET the current CFG (to avoid stomping concurrent edits),
|
||||
* run planFloodChange, and PUT the result back. Streams progress.
|
||||
*
|
||||
* Concurrency is kept low (default 3) — OLT writes carry a much larger
|
||||
* blast radius than ONU reads, so we deliberately stay below the ONU
|
||||
* delete (5) and state-fetch (8) limits.
|
||||
*/
|
||||
ipcMain.handle('api:executeFloodChange', async (event, opts) => {
|
||||
try {
|
||||
const c = requireClient();
|
||||
const {
|
||||
oltIds, tagPattern, fromMode = 'private',
|
||||
targetMode = 'auto', ponFloodIdValue = 0,
|
||||
concurrency = 3,
|
||||
} = opts || {};
|
||||
const queue = [...oltIds];
|
||||
const results = [];
|
||||
let done = 0;
|
||||
const total = oltIds.length;
|
||||
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const id = queue.shift();
|
||||
if (id === undefined) return;
|
||||
let entry = { oltId: id, ok: false };
|
||||
try {
|
||||
const cfg = await c.getOltConfig(id);
|
||||
if (!cfg) throw new Error('OLT not found');
|
||||
const plan = planFloodChange(cfg, {
|
||||
tagPattern, fromMode, targetMode, ponFloodIdValue,
|
||||
});
|
||||
if (plan.changes.length === 0) {
|
||||
entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
|
||||
} else {
|
||||
await c.putOltConfig(id, plan.newDoc);
|
||||
entry = { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged };
|
||||
}
|
||||
} catch (e) {
|
||||
entry = { oltId: id, ok: false, error: toWireError(e) };
|
||||
}
|
||||
results.push(entry);
|
||||
done += 1;
|
||||
event.sender.send('olt-flood: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) };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ contextBridge.exposeInMainWorld('api', {
|
|||
|
||||
deleteOnus: (opts) => call('api:deleteOnus', opts),
|
||||
|
||||
listOlts: (opts) => call('api:listOlts', opts),
|
||||
executeFloodChange: (opts) => call('api:executeFloodChange', opts),
|
||||
|
||||
onFloodProgress: (handler) => {
|
||||
const fn = (_ev, payload) => handler(payload);
|
||||
ipcRenderer.on('olt-flood:progress', fn);
|
||||
return () => ipcRenderer.removeListener('olt-flood:progress', fn);
|
||||
},
|
||||
|
||||
fetchFecHealth: (opts) => call('api:fetchFecHealth', opts),
|
||||
fetchOnuSnapshot: (opts) => call('api:fetchOnuSnapshot', opts),
|
||||
savePlanCsv: (opts) => call('api:savePlanCsv', opts),
|
||||
|
|
|
|||
181
renderer/app.js
181
renderer/app.js
|
|
@ -11,6 +11,9 @@ const state = {
|
|||
fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? }
|
||||
campaignMode: 'task', // 'task' | 'peronu'
|
||||
verify: null, // { sourcePath, beforeRows, snapshotById, comparisons }
|
||||
olts: [], // result of listOlts
|
||||
oltFiltered: [], // current visible OLTs
|
||||
oltSelected: new Set(),
|
||||
};
|
||||
|
||||
// -------- Helpers --------
|
||||
|
|
@ -1280,3 +1283,181 @@ function escapeHtml(s) {
|
|||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// -------- OLT inspector: bulk PON flooding mode change --------
|
||||
|
||||
$('#btn-to-olts').addEventListener('click', () => {
|
||||
showView('#view-olts');
|
||||
$('#olt-status').textContent = '';
|
||||
$('#olt-exec-status').textContent = '';
|
||||
});
|
||||
$('#btn-olts-back').addEventListener('click', () => showView('#view-fleet'));
|
||||
|
||||
$('#btn-load-olts').addEventListener('click', loadOlts);
|
||||
$('#olt-tag-pattern').addEventListener('input', applyOltFilters);
|
||||
$('#olt-mode-filter').addEventListener('change', applyOltFilters);
|
||||
$('#olt-name-filter').addEventListener('input', applyOltFilters);
|
||||
$('#btn-olt-select-all').addEventListener('click', () => {
|
||||
for (const r of state.oltFiltered) state.oltSelected.add(r._id);
|
||||
renderOlts();
|
||||
});
|
||||
$('#btn-olt-select-none').addEventListener('click', () => {
|
||||
state.oltSelected.clear();
|
||||
renderOlts();
|
||||
});
|
||||
$('#olt-th-check').addEventListener('change', (e) => {
|
||||
if (e.target.checked) for (const r of state.oltFiltered) state.oltSelected.add(r._id);
|
||||
else for (const r of state.oltFiltered) state.oltSelected.delete(r._id);
|
||||
renderOlts();
|
||||
});
|
||||
|
||||
async function loadOlts() {
|
||||
const tagPattern = $('#olt-tag-pattern').value.trim();
|
||||
$('#olt-status').textContent = 'Loading OLT configs…';
|
||||
const res = await window.api.listOlts({ tagPattern });
|
||||
if (!res.ok) {
|
||||
$('#olt-status').textContent = `Error: ${res.error?.message || 'unknown'}`;
|
||||
return;
|
||||
}
|
||||
state.olts = res.data || [];
|
||||
applyOltFilters();
|
||||
$('#olt-total').textContent = state.olts.length;
|
||||
const totalMatching = state.olts.reduce((acc, r) => acc + r.matchingNni.length, 0);
|
||||
const totalPriv = state.olts.reduce((acc, r) => acc + r.counts.private, 0);
|
||||
$('#olt-status').textContent =
|
||||
`${state.olts.length} OLTs loaded. ${totalMatching} service(s) match TAG pattern; ${totalPriv} currently in private mode.`;
|
||||
}
|
||||
|
||||
function applyOltFilters() {
|
||||
const mode = $('#olt-mode-filter').value;
|
||||
const nameQ = $('#olt-name-filter').value.trim().toLowerCase();
|
||||
state.oltFiltered = state.olts.filter((r) => {
|
||||
if (nameQ && !(r.name || '').toLowerCase().includes(nameQ) && !(r._id || '').toLowerCase().includes(nameQ)) return false;
|
||||
if (mode === 'any') return r.matchingNni.length > 0;
|
||||
return r.matchingNni.some((n) => n.mode === mode);
|
||||
});
|
||||
renderOlts();
|
||||
}
|
||||
|
||||
function renderOlts() {
|
||||
const tbody = $('#olt-tbody');
|
||||
tbody.innerHTML = '';
|
||||
const mode = $('#olt-mode-filter').value;
|
||||
let totalSvcThatWillChange = 0;
|
||||
for (const r of state.oltFiltered) {
|
||||
const tr = document.createElement('tr');
|
||||
const selected = state.oltSelected.has(r._id);
|
||||
if (selected) tr.classList.add('selected');
|
||||
const willChangeMode = mode === 'auto' ? 'auto' : 'private';
|
||||
const matchingHtml = r.matchingNni.map((n) => {
|
||||
const modeCls = n.mode === 'private' ? 'status-warn' : n.mode === 'auto' ? 'status-ok' : 'muted';
|
||||
const willChange = selected && n.mode === willChangeMode;
|
||||
if (willChange) totalSvcThatWillChange += 1;
|
||||
const willFlag = willChange ? ' <span class="status-info">→ will change</span>' : '';
|
||||
const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (PON FLOOD ID=${escapeHtml(String(n.ponFloodId))})` : '';
|
||||
return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${willFlag}</div>`;
|
||||
}).join('');
|
||||
tr.innerHTML = `
|
||||
<td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td>
|
||||
<td>${escapeHtml(r._id)}</td>
|
||||
<td>${escapeHtml(r.name)}</td>
|
||||
<td>${escapeHtml(r.ponMode)}</td>
|
||||
<td>${escapeHtml(r.activeFwVersion)}</td>
|
||||
<td class="fec-cell">${matchingHtml || '<span class="muted">no matching service</span>'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
$('#olt-count').textContent = state.oltFiltered.length;
|
||||
$('#olt-sel-count').textContent = state.oltSelected.size;
|
||||
$('#olt-svc-count').textContent = totalSvcThatWillChange;
|
||||
$('#btn-olt-execute').disabled = totalSvcThatWillChange === 0;
|
||||
|
||||
for (const cb of tbody.querySelectorAll('input[type="checkbox"]')) {
|
||||
cb.addEventListener('change', () => {
|
||||
const id = cb.getAttribute('data-id');
|
||||
if (cb.checked) state.oltSelected.add(id); else state.oltSelected.delete(id);
|
||||
renderOlts();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('#btn-olt-execute').addEventListener('click', async () => {
|
||||
const ids = Array.from(state.oltSelected);
|
||||
if (!ids.length) return;
|
||||
const action = $('#olt-action').value;
|
||||
const fromMode = action === 'auto2private' ? 'auto' : 'private';
|
||||
const targetMode = action === 'auto2private' ? 'private' : 'auto';
|
||||
const tagPattern = $('#olt-tag-pattern').value.trim();
|
||||
const summary =
|
||||
`Apply "${fromMode} → ${targetMode}" to NNI services matching "${tagPattern}" ` +
|
||||
`on ${ids.length} OLT(s).\n\nThis GETs each OLT-CFG, mutates the matching ` +
|
||||
`NNI Networks entries, and PUTs the full document back. The operation is ` +
|
||||
`irreversible from this app (no undo).\n\nClick OK to continue to the final confirmation.`;
|
||||
if (!confirm(summary)) return;
|
||||
const typed = prompt(`Type APPLY to confirm bulk mode change on ${ids.length} OLT(s):`);
|
||||
if (typed !== 'APPLY') {
|
||||
$('#olt-exec-status').textContent = 'Cancelled.';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#btn-olt-execute').disabled = true;
|
||||
$('#olt-exec-status').textContent = `Applying change to 0/${ids.length}…`;
|
||||
const unbind = window.api.onFloodProgress(({ done, total, oltId, error, changes }) => {
|
||||
const tail = error ? ` (last error on ${oltId}: ${error.message})`
|
||||
: changes ? ` (${oltId}: ${changes.length} svc updated)` : '';
|
||||
$('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`;
|
||||
});
|
||||
const res = await window.api.executeFloodChange({
|
||||
oltIds: ids, tagPattern, fromMode, targetMode,
|
||||
});
|
||||
unbind();
|
||||
if (!res.ok) {
|
||||
$('#olt-exec-status').textContent = `Bulk apply failed: ${res.error?.message}`;
|
||||
$('#btn-olt-execute').disabled = false;
|
||||
return;
|
||||
}
|
||||
const okCount = res.data.filter((r) => r.ok && !r.noop).length;
|
||||
const noopCount = res.data.filter((r) => r.noop).length;
|
||||
const failed = res.data.filter((r) => !r.ok);
|
||||
let msg = `Done. ${okCount} OLT(s) updated, ${noopCount} no-op, ${failed.length} failed.`;
|
||||
if (failed.length) {
|
||||
msg += ' Failures: ' + failed.slice(0, 3).map((f) => `${f.oltId} (${f.error?.message || '?'})`).join(', ');
|
||||
if (failed.length > 3) msg += ` and ${failed.length - 3} more`;
|
||||
}
|
||||
$('#olt-exec-status').textContent = msg;
|
||||
|
||||
// Auto-save a result CSV alongside the upgrade/verify ones.
|
||||
const headers = [
|
||||
'OLT MAC', 'OLT name', 'Action', 'TAG pattern',
|
||||
'Result', 'Services changed', 'Services skipped',
|
||||
'Change detail', 'Skip detail', 'Error',
|
||||
];
|
||||
const rows = res.data.map((r) => {
|
||||
const olt = state.olts.find((o) => o._id === r.oltId);
|
||||
const changeDetail = (r.changes || []).map((c) => `${c.tagMatch}: ${c.fromMode}→${c.toMode}`).join('; ');
|
||||
const skipDetail = (r.skipped || []).map((s) => `${s.tagMatch}: ${s.reason}`).join('; ');
|
||||
return {
|
||||
'OLT MAC': r.oltId,
|
||||
'OLT name': olt?.name || '',
|
||||
'Action': `${fromMode}→${targetMode}`,
|
||||
'TAG pattern': tagPattern,
|
||||
'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed',
|
||||
'Services changed': (r.changes || []).length,
|
||||
'Services skipped': (r.skipped || []).length,
|
||||
'Change detail': changeDetail,
|
||||
'Skip detail': skipDetail,
|
||||
'Error': r.error ? r.error.message : '',
|
||||
};
|
||||
});
|
||||
const saveRes = await window.api.saveCsvFile({
|
||||
rows, headers,
|
||||
filenameHint: `${fromMode}-to-${targetMode}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`,
|
||||
prefix: 'pon-olt-flood',
|
||||
});
|
||||
if (saveRes.ok) {
|
||||
$('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`;
|
||||
}
|
||||
|
||||
// Refresh the table so the user sees the new state.
|
||||
await loadOlts();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@
|
|||
<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>
|
||||
|
||||
<h2>OLT flooding mode</h2>
|
||||
<p class="muted small">Bulk-check or fix PON flooding mode on OLT NNI services. Default rule: match <code>s0.c76.c0</code> with mode <code>private</code> and switch to <code>auto</code>.</p>
|
||||
<button id="btn-to-olts" class="ghost">Open OLT inspector…</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-main">
|
||||
|
|
@ -246,6 +250,75 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= OLT inspector view ================= -->
|
||||
<section id="view-olts" class="view hidden">
|
||||
<div class="panel-left">
|
||||
<h2>Filters</h2>
|
||||
<label>NNI TAG MATCH pattern
|
||||
<input id="olt-tag-pattern" type="text" value="s0.c76.c0" placeholder="e.g. s0.c76.c0 or s0.c76.*" />
|
||||
</label>
|
||||
<label>Current mode
|
||||
<select id="olt-mode-filter">
|
||||
<option value="private" selected>Private (PON FLOOD ID set)</option>
|
||||
<option value="auto">Auto (no PON FLOOD ID)</option>
|
||||
<option value="any">Any</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>OLT name contains
|
||||
<input id="olt-name-filter" type="search" placeholder="e.g. OLT5" />
|
||||
</label>
|
||||
<button id="btn-load-olts" class="primary">Load OLTs</button>
|
||||
<p class="muted small" id="olt-status"></p>
|
||||
|
||||
<h2>Selection</h2>
|
||||
<div class="selected-summary">
|
||||
<div><strong id="olt-sel-count">0</strong> OLT(s) selected · <strong id="olt-svc-count">0</strong> service slot(s) will change</div>
|
||||
<div class="row">
|
||||
<button id="btn-olt-select-all">Select all matching</button>
|
||||
<button id="btn-olt-select-none" class="ghost">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Action</h2>
|
||||
<label>Change matching services
|
||||
<select id="olt-action">
|
||||
<option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option>
|
||||
<option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<button id="btn-olts-back" class="ghost">← Back to fleet</button>
|
||||
<button id="btn-olt-execute" class="danger" disabled>Execute change…</button>
|
||||
</div>
|
||||
<p id="olt-exec-status" class="small muted"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-main">
|
||||
<div class="fleet-head">
|
||||
<h2>OLTs (<span id="olt-count">0</span> shown, <span id="olt-total">0</span> total)</h2>
|
||||
<div class="row small muted">
|
||||
<span>Each row shows the matching NNI services. Selecting an OLT queues every matching service on it for change.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fleet-table-wrap">
|
||||
<table class="fleet-table" id="olt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="olt-th-check" /></th>
|
||||
<th>OLT MAC</th>
|
||||
<th>Name</th>
|
||||
<th>PON mode</th>
|
||||
<th>FW</th>
|
||||
<th>Matching NNI services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="olt-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
148
src/mcms-api.js
148
src/mcms-api.js
|
|
@ -538,6 +538,44 @@ class McmsClient {
|
|||
return this._listPaginatedByNext('/v3/olts/states/', { pageSize });
|
||||
}
|
||||
|
||||
// --- OLT configuration (PON flooding mode) ---------------------------
|
||||
|
||||
/**
|
||||
* Bulk-fetch all OLT-CFG documents. Same pagination as ONU configs
|
||||
* (page size 100 via `next` cursor — see §3.3 / §3.4 of MCMS_API.md).
|
||||
*/
|
||||
async listAllOltConfigs({ pageSize = 100 } = {}) {
|
||||
return this._listPaginatedByNext('/v3/olts/configs/', { pageSize });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single OLT-CFG document.
|
||||
*/
|
||||
async getOltConfig(oltId) {
|
||||
try {
|
||||
const res = await this._request(
|
||||
'GET', `/v3/olts/configs/${encodeURIComponent(oltId)}/`,
|
||||
);
|
||||
return res.body?.data;
|
||||
} catch (e) {
|
||||
if (e instanceof McmsApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an OLT-CFG document. PUT-as-replace semantics — caller MUST
|
||||
* GET first, mutate in place, and PUT the entire result. Partial
|
||||
* writes will blank out service config (same gotcha as ONU-CFG).
|
||||
*/
|
||||
async putOltConfig(oltId, fullDoc) {
|
||||
const res = await this._request(
|
||||
'PUT', `/v1/olts/configs/${encodeURIComponent(oltId)}/`,
|
||||
{ body: { data: fullDoc } },
|
||||
);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
// --- Firmware inventory ----------------------------------------------
|
||||
|
||||
async listOnuFirmware() {
|
||||
|
|
@ -637,6 +675,113 @@ class McmsClient {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- OLT PON flooding mode -------------------------------------
|
||||
// On Tibit OLTs, each NNI service entry under OLT-CFG["NNI Networks"] is
|
||||
// in "private" flood mode when the "PON FLOOD ID" key is *present* (any
|
||||
// value, including 0) and "auto" when it's *absent*. These pure helpers
|
||||
// classify, summarize, and compute a (non-mutating) flood-mode change so
|
||||
// main.js can GET → plan → PUT each OLT-CFG.
|
||||
|
||||
/**
|
||||
* PON flooding mode classification for an "NNI Networks[i]" entry on
|
||||
* an OLT-CFG. On Tibit OLTs, the *presence* of the "PON FLOOD ID" key
|
||||
* (with any value, including 0) puts the service into PRIVATE flood
|
||||
* mode; its *absence* leaves it on AUTO.
|
||||
*
|
||||
* Reference comparison (24:65:e1:cc:af:1e, NNI s0.c76.c0):
|
||||
*
|
||||
* private: { "TAG MATCH": "s0.c76.c0", ..., "PON FLOOD ID": 0, "Learning Limit": 2046 }
|
||||
* auto: { "TAG MATCH": "s0.c76.c0", ..., "Learning Limit": 2046 }
|
||||
*
|
||||
* The only diff is the presence/absence of "PON FLOOD ID".
|
||||
*/
|
||||
function floodModeOfNni(nniNet) {
|
||||
if (!nniNet || typeof nniNet !== 'object') return 'unknown';
|
||||
return Object.prototype.hasOwnProperty.call(nniNet, 'PON FLOOD ID')
|
||||
? 'private'
|
||||
: 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a uniform summary of every NNI Networks entry on an OLT-CFG,
|
||||
* filtered by a TAG MATCH pattern. Pattern can be:
|
||||
* - exact string (e.g. "s0.c76.c0")
|
||||
* - "*" / "" — match all
|
||||
* - glob with * wildcards (e.g. "s0.c76.*")
|
||||
*/
|
||||
function summarizeOltNniNetworks(oltCfg, tagPattern = '') {
|
||||
const networks = Array.isArray(oltCfg?.['NNI Networks']) ? oltCfg['NNI Networks'] : [];
|
||||
const re = tagPatternToRegex(tagPattern);
|
||||
const out = [];
|
||||
for (let i = 0; i < networks.length; i++) {
|
||||
const n = networks[i];
|
||||
const tag = n?.['TAG MATCH'] || '';
|
||||
if (!re.test(tag)) continue;
|
||||
out.push({
|
||||
index: i,
|
||||
tagMatch: tag,
|
||||
mode: floodModeOfNni(n),
|
||||
ponFloodId: Object.prototype.hasOwnProperty.call(n, 'PON FLOOD ID') ? n['PON FLOOD ID'] : null,
|
||||
learningLimit: n?.['Learning Limit'] ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tagPatternToRegex(pattern) {
|
||||
const p = String(pattern || '').trim();
|
||||
if (!p || p === '*') return /.*/;
|
||||
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
return new RegExp('^' + escaped + '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a PON flooding mode change to a CLONE of an OLT-CFG doc. Does
|
||||
* not mutate the input. Returns { newDoc, changes, unchanged }.
|
||||
*
|
||||
* `targetMode` is 'auto' (delete PON FLOOD ID) or 'private' (set
|
||||
* PON FLOOD ID to ponFloodIdValue, default 0).
|
||||
*/
|
||||
function planFloodChange(oltCfg, {
|
||||
tagPattern,
|
||||
fromMode = 'private', // only flip entries currently in this mode (null = any)
|
||||
targetMode = 'auto',
|
||||
ponFloodIdValue = 0,
|
||||
}) {
|
||||
const networks = Array.isArray(oltCfg?.['NNI Networks']) ? oltCfg['NNI Networks'] : [];
|
||||
const re = tagPatternToRegex(tagPattern);
|
||||
const newNetworks = networks.map((n) => ({ ...n }));
|
||||
const changes = [];
|
||||
const unchanged = [];
|
||||
for (let i = 0; i < newNetworks.length; i++) {
|
||||
const n = newNetworks[i];
|
||||
const tag = n?.['TAG MATCH'] || '';
|
||||
if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; }
|
||||
const current = floodModeOfNni(n);
|
||||
if (fromMode && current !== fromMode) {
|
||||
unchanged.push({ index: i, tagMatch: tag, reason: `already in mode "${current}", not "${fromMode}"` });
|
||||
continue;
|
||||
}
|
||||
if (current === targetMode) {
|
||||
unchanged.push({ index: i, tagMatch: tag, reason: `already in target mode "${targetMode}"` });
|
||||
continue;
|
||||
}
|
||||
let removedPonFloodId;
|
||||
if (targetMode === 'auto') {
|
||||
removedPonFloodId = n['PON FLOOD ID'];
|
||||
delete n['PON FLOOD ID'];
|
||||
} else if (targetMode === 'private') {
|
||||
n['PON FLOOD ID'] = ponFloodIdValue;
|
||||
} else {
|
||||
unchanged.push({ index: i, tagMatch: tag, reason: `unknown target mode "${targetMode}"` });
|
||||
continue;
|
||||
}
|
||||
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
|
||||
}
|
||||
const newDoc = { ...oltCfg, 'NNI Networks': newNetworks };
|
||||
return { newDoc, changes, unchanged };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
McmsClient,
|
||||
McmsApiError,
|
||||
|
|
@ -645,4 +790,7 @@ module.exports = {
|
|||
extractOnuHealth,
|
||||
extractFecHealth, // legacy alias — calls extractOnuHealth
|
||||
formatFecNumber,
|
||||
floodModeOfNni,
|
||||
summarizeOltNniNetworks,
|
||||
planFloodChange,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue