Merge pull request 'Add bulk OLT PON flooding-mode tool; restore README; docs' (#1) from feat/olt-flooding-mode into main

Reviewed-on: #1
This commit is contained in:
jvanvik 2026-06-23 10:55:03 +00:00
commit 581e16e7ed
7 changed files with 612 additions and 7 deletions

View file

@ -12,9 +12,15 @@ is the engineering counterpart.
## 1. What this app does ## 1. What this app does
Electron desktop client for bulk ONU firmware upgrades against **Ciena Electron desktop client for two bulk operations against **Ciena
MCMS 6.2** PON Manager. Reference deployment: GNXS / Tibit MicroPlug ONUs MCMS 6.2** PON Manager:
running Interos Everest (EV051xxR / EV052xxR firmware family).
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: 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:executeBulkTask` | Procedure 8 — single AUTO-TASK-CFG | — |
| `api:getTaskConfig` / `api:getUpgradeStatus` | poll helpers | — | | `api:getTaskConfig` / `api:getUpgradeStatus` | poll helpers | — |
| `api:deleteOnus` | bulk delete (CFG + best-effort STATE) | `delete:progress` | | `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:fetchFecHealth` | pre-flight FEC pre/post + optical | `fec:progress` |
| `api:savePlanCsv` | auto-save plan on Execute | — | | `api:savePlanCsv` | auto-save plan on Execute | — |
| `api:saveCsvFile` | generic CSV writer | — | | `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.*

View file

@ -1,7 +1,14 @@
# PON Fleet Upgrader # PON Fleet Upgrader
An Electron desktop app for bulk ONU firmware upgrades against a **Ciena An Electron desktop app for bulk operations against a **Ciena
MicroClimate Management System (MCMS) 6.2** PON Manager. 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 Filters a fleet of ONUs, previews the planned writes, and stages new
firmware to each device's **inactive bank** so the forced reboot is firmware to each device's **inactive bank** so the forced reboot is
@ -94,6 +101,28 @@ pon-fleet-upgrader/
└── app.js # UI logic └── 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 ## Known limitations
- The `/v1/onus/<id>/upgrade/status/` path used by the original question - The `/v1/onus/<id>/upgrade/status/` path used by the original question

91
main.js
View file

@ -8,7 +8,10 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); 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'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy');
/** @type {McmsClient | null} */ /** @type {McmsClient | null} */
@ -617,3 +620,89 @@ ipcMain.handle('api:deleteOnus', async (event, { onuIds, concurrency = 5 }) => {
return { ok: false, error: toWireError(err) }; 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) };
}
});

View file

@ -26,6 +26,15 @@ contextBridge.exposeInMainWorld('api', {
deleteOnus: (opts) => call('api:deleteOnus', opts), 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), fetchFecHealth: (opts) => call('api:fetchFecHealth', opts),
fetchOnuSnapshot: (opts) => call('api:fetchOnuSnapshot', opts), fetchOnuSnapshot: (opts) => call('api:fetchOnuSnapshot', opts),
savePlanCsv: (opts) => call('api:savePlanCsv', opts), savePlanCsv: (opts) => call('api:savePlanCsv', opts),

View file

@ -11,6 +11,9 @@ const state = {
fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? } fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? }
campaignMode: 'task', // 'task' | 'peronu' campaignMode: 'task', // 'task' | 'peronu'
verify: null, // { sourcePath, beforeRows, snapshotById, comparisons } verify: null, // { sourcePath, beforeRows, snapshotById, comparisons }
olts: [], // result of listOlts
oltFiltered: [], // current visible OLTs
oltSelected: new Set(),
}; };
// -------- Helpers -------- // -------- Helpers --------
@ -1280,3 +1283,181 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
// -------- 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();
});

View file

@ -100,6 +100,10 @@
<h2>After-upgrade verification</h2> <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> <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> <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>
<div class="panel-main"> <div class="panel-main">
@ -246,6 +250,75 @@
</div> </div>
</section> </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> <script src="app.js"></script>
</body> </body>
</html> </html>

View file

@ -538,6 +538,44 @@ class McmsClient {
return this._listPaginatedByNext('/v3/olts/states/', { pageSize }); 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 ---------------------------------------------- // --- Firmware inventory ----------------------------------------------
async listOnuFirmware() { 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 = { module.exports = {
McmsClient, McmsClient,
McmsApiError, McmsApiError,
@ -645,4 +790,7 @@ module.exports = {
extractOnuHealth, extractOnuHealth,
extractFecHealth, // legacy alias — calls extractOnuHealth extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber, formatFecNumber,
floodModeOfNni,
summarizeOltNniNetworks,
planFloodChange,
}; };