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:
Jon Vanvik 2026-06-23 12:54:06 +02:00
parent 0eb9b55717
commit 80fd7e8d37
7 changed files with 612 additions and 7 deletions

View file

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