From 80fd7e8d374ca896e85a44f0820a3717121941b0 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 12:54:06 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 84 +++++++++++++++++++- README.md | 33 +++++++- main.js | 91 +++++++++++++++++++++- preload.js | 9 +++ renderer/app.js | 181 ++++++++++++++++++++++++++++++++++++++++++++ renderer/index.html | 73 ++++++++++++++++++ src/mcms-api.js | 148 ++++++++++++++++++++++++++++++++++++ 7 files changed, 612 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0eaac1e..fdf8301 100644 --- a/CLAUDE.md +++ b/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.* diff --git a/README.md b/README.md index 3950c01..b3e4df0 100644 --- a/README.md +++ b/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//upgrade/status/` path used by the original question diff --git a/main.js b/main.js index f739887..40310d5 100644 --- a/main.js +++ b/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) }; + } +}); diff --git a/preload.js b/preload.js index e501205..831d08e 100644 --- a/preload.js +++ b/preload.js @@ -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), diff --git a/renderer/app.js b/renderer/app.js index f6f07ba..e400e06 100644 --- a/renderer/app.js +++ b/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 ? ' → will change' : ''; + const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (PON FLOOD ID=${escapeHtml(String(n.ponFloodId))})` : ''; + return `
${escapeHtml(n.tagMatch)} ${escapeHtml(n.mode)}${escapeHtml(fid)}${willFlag}
`; + }).join(''); + tr.innerHTML = ` + + ${escapeHtml(r._id)} + ${escapeHtml(r.name)} + ${escapeHtml(r.ponMode)} + ${escapeHtml(r.activeFwVersion)} + ${matchingHtml || 'no matching service'} + `; + 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(); +}); diff --git a/renderer/index.html b/renderer/index.html index aba1974..2ff911a 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -100,6 +100,10 @@

After-upgrade verification

Open a saved pon-upgrade-*.csv to compare the recorded FEC + optical readings against the current state of those ONUs.

+ +

OLT flooding mode

+

Bulk-check or fix PON flooding mode on OLT NNI services. Default rule: match s0.c76.c0 with mode private and switch to auto.

+
@@ -246,6 +250,75 @@
+ + + diff --git a/src/mcms-api.js b/src/mcms-api.js index eee89f3..6cb6370 100644 --- a/src/mcms-api.js +++ b/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, }; -- 2.47.3