From 5e3d9271a8587a778e1db3cd6ca7cbf83b244438 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 14:57:37 +0200 Subject: [PATCH] olt: add bulk DHCPv4/DHCPv6 filter edit alongside flood mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises the OLT bulk tool from flood-mode-only to arbitrary NNI-service edits. DHCP modes live nested under each NNI entry's Filter object (Filter.DHCPv4 / Filter.DHCPv6, e.g. "pass" <-> "umt"), confirmed from a real OLT-CFG paste-back. - src/mcms-api.js: planFloodChange -> planNniEdit(oltCfg, {tagPattern, flood, dhcpv4, dhcpv6}); each concern optional. DHCP is value-replacement, replace-only-when-present (never invents the key; leaves EAPOL/PPPoE/NDP). Still non-mutating (clones the entry AND the Filter). Returns changes with per-entry edits[]. summarizeOltNniNetworks surfaces dhcpv4/dhcpv6; new dhcpModeOfNni helper. - main.js + web/server.js: executeFloodChange handler passes flood/dhcpv4/ dhcpv6 ops through (channel name kept for wire compat). - renderer: OLT inspector now has three action dropdowns (Flooding / DHCPv4 / DHCPv6, defaults flood private->auto + DHCP pass->umt); table shows DHCP chips + per-service "will change (flood, DHCPv4, …)"; flood-mode filter defaults to Any so DHCP-on-pass auto-flood OLTs still show; CSV report lists every per-NNI edit (pon-olt-nni-*.csv). Verified: node --check; planNniEdit unit-tested against the real fixture + a pass variant (13/13: no-mutation, EAPOL/PPPoE/NDP preserved, replace-when-present, flood/DHCP independence); OLT view rendered offscreen showing correct per-service "will change" markers. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 61 +++++++++++++----------- README.md | 35 +++++++++----- main.js | 17 ++++--- renderer/app.js | 112 ++++++++++++++++++++++++++++++++------------ renderer/index.html | 28 ++++++++--- src/mcms-api.js | 89 +++++++++++++++++++++++------------ web/server.js | 8 ++-- 7 files changed, 232 insertions(+), 118 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f148ef1..453f68c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,8 @@ fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between: - **Dashboard** — read-only network telemetry (see §15). - **ONU** — the fleet table + firmware-upgrade campaign + verify flows (the original purpose: bulk ONU firmware upgrades). -- **OLT** — bulk OLT PON flooding-mode changes (private↔auto, see §14). +- **OLT** — bulk OLT NNI-service edits: PON flooding mode (private↔auto) and + DHCPv4/v6 filter (pass↔umt), see §14. Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest (EV051xxR / EV052xxR firmware family) behind Tibit OLTs. @@ -655,48 +656,54 @@ Key procedure citations: --- -## 14. OLT PON flooding mode (bulk private↔auto) +## 14. OLT NNI-service edits (bulk flood mode + DHCP filter) 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. +edit OLT NNI services across many OLTs at once. Two concerns, applied +together in one GET→edit→PUT per OLT: -### The mutation rule (the whole feature in one fact) +### The two mutation rules -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: +Both live on each `OLT-CFG["NNI Networks"][i]` entry, both confirmed +empirically from operator paste-backs of `s0.c76.c0`: -- **`PON FLOOD ID` key present** (any value, including `0`) → **private** -- **`PON FLOOD ID` key absent** → **auto** +1. **Flooding mode** — the **presence/absence of the `PON FLOOD ID` key**: + present (any value, incl. `0`) → **private**; absent → **auto**. So + private→auto is `delete entry['PON FLOOD ID']`; auto→private is + `entry['PON FLOOD ID'] = 0`. +2. **DHCP filter mode** — a **value** under the nested `Filter` object: + `entry.Filter.DHCPv4` / `entry.Filter.DHCPv6`, e.g. `"pass"` ↔ `"umt"` + (siblings `EAPOL`/`PPPoE`/`NDP` are left alone). Value replacement, not + key presence. **Replace-only-when-present**: an entry with no `Filter` + (or no such key) is skipped, never invented. -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. +Everything else on the entry and the rest of the OLT-CFG is preserved. +Default actions: TAG MATCH `s0.c76.c0`, flood `private→auto`, DHCPv4/v6 +`pass→umt`. All directions are wired, so changes roll back from the same UI. ### Data flow ``` listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure) -executeFloodChange ─► per-OLT: getOltConfig ─► planFloodChange (pure, no-mutate) - ─► putOltConfig (full-doc PUT) +executeFloodChange ─► per-OLT: getOltConfig ─► planNniEdit (pure, no-mutate) + (channel name kept for wire compat) ─► 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 }`. +- **Pure helpers** at the bottom of `src/mcms-api.js`: `floodModeOfNni`, + `dhcpModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`, + `planNniEdit`. `planNniEdit(oltCfg, { tagPattern, flood, dhcpv4, dhcpv6 })` + clones the `NNI Networks` array (and any edited `Filter`) and never mutates + its input. Returns `{ newDoc, changes, unchanged }`, where a change is + `{ index, tagMatch, edits: [{ type, from, to }] }` — `type` is `'flood'`, + `'DHCPv4'`, or `'DHCPv6'`, so one entry can carry several edits. +- Each concern is optional (`flood`/`dhcpv4`/`dhcpv6` may be `null`); the UI + has three independent action dropdowns. - **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. +- **`from` guards**: only entries currently on the `from` mode/value are + flipped; entries already at target are left 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 diff --git a/README.md b/README.md index 9ca0a21..52ee530 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ System (MCMS) 6.2** PON Manager. Top tabs split it into: registration-state breakdown (with an opt-in per-ONU Rx/FEC scan). Clicking a state jumps to the ONU tab pre-filtered. 2. **ONU** — the fleet table plus **bulk ONU firmware upgrades** (below). -3. **OLT** — **bulk OLT PON flooding-mode changes** (private↔auto on NNI - services — see "OLT PON flooding mode" below). +3. **OLT** — **bulk OLT NNI-service edits**: PON flooding mode (private↔auto) + and DHCPv4/v6 filter (pass↔umt) — see "OLT NNI-service edits" below. ## ONU firmware upgrades @@ -104,27 +104,36 @@ pon-fleet-upgrader/ └── app.js # UI logic ``` -## OLT PON flooding mode +## OLT NNI-service edits From the fleet view, **Open OLT inspector…** opens a second workflow for -bulk-checking and changing the PON flooding mode of OLT NNI services. +bulk-checking and editing OLT NNI services. Two independent edits can be +applied together to every matching service: -On Tibit OLTs the flooding mode is encoded by the presence of a single key -on each `OLT-CFG["NNI Networks"]` entry: +**Flooding mode** — 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`. +**DHCP filter mode** — values nested under each entry's `Filter` object +(`DHCPv4` / `DHCPv6`), e.g. `pass` ↔ `umt`. Only entries that already have +the field are touched (the field is never invented); `EAPOL`/`PPPoE`/`NDP` +are left alone. -Both directions are supported, so a change can be rolled back from the same -screen. The default action targets `s0.c76.c0`, `private` → `auto`. +Workflow: enter a **TAG MATCH** pattern (exact like `s0.c76.c0`, or a `*` +glob like `s0.c76.*`), **Load OLTs**, pick any of the three actions +(Flooding / DHCPv4 / DHCPv6), select the OLTs — each matching service shows +"→ will change (…)" for the edits that apply — then **Execute changes…** +(two-step confirm). Each OLT is re-fetched, the matching NNI entries are +edited in one pass, and the full OLT-CFG is PUT back. A result CSV is +auto-saved to `~/Downloads/pon-olt-nni-*.csv`. + +All directions are supported (e.g. `umt → pass`), so changes roll back from +the same screen. Defaults: `s0.c76.c0`, flood `private → auto`, DHCPv4/v6 +`pass → umt`. ## Known limitations diff --git a/main.js b/main.js index f00cce4..a9adcf1 100644 --- a/main.js +++ b/main.js @@ -10,7 +10,7 @@ const path = require('path'); const fs = require('fs'); const { McmsClient, McmsApiError, extractOnuHealth, - summarizeOltNniNetworks, planFloodChange, + summarizeOltNniNetworks, planNniEdit, } = require('./src/mcms-api'); const { summarizeDashboard } = require('./src/dashboard'); const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); @@ -654,9 +654,11 @@ ipcMain.handle('api:listOlts', async (_ev, opts) => { }); /** - * 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. + * Apply a set of NNI-service edits (flood mode and/or DHCPv4/DHCPv6 filter) + * to a list of OLT IDs. For each OLT we GET the current CFG (to avoid + * stomping concurrent edits), run planNniEdit, and PUT the result back. + * Streams progress. (Channel name kept as executeFloodChange for wire + * compatibility.) * * Concurrency is kept low (default 3) — OLT writes carry a much larger * blast radius than ONU reads, so we deliberately stay below the ONU @@ -666,8 +668,7 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => { try { const c = requireClient(); const { - oltIds, tagPattern, fromMode = 'private', - targetMode = 'auto', ponFloodIdValue = 0, + oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null, concurrency = 3, } = opts || {}; const queue = [...oltIds]; @@ -683,9 +684,7 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => { try { const cfg = await c.getOltConfig(id); if (!cfg) throw new Error('OLT not found'); - const plan = planFloodChange(cfg, { - tagPattern, fromMode, targetMode, ponFloodIdValue, - }); + const plan = planNniEdit(cfg, { tagPattern, flood, dhcpv4, dhcpv6 }); if (plan.changes.length === 0) { entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; } else { diff --git a/renderer/app.js b/renderer/app.js index 1b3939b..88e8fd5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1366,7 +1366,7 @@ function confirmTyped({ title, body, requireWord, confirmLabel = 'Confirm', dang }); } -// -------- OLT inspector: bulk PON flooding mode change -------- +// -------- OLT inspector: bulk NNI-service edits (flood + DHCP) -------- $('#btn-to-olts').addEventListener('click', () => { showView('#view-olts'); @@ -1379,6 +1379,10 @@ $('#btn-load-olts').addEventListener('click', loadOlts); $('#olt-tag-pattern').addEventListener('input', applyOltFilters); $('#olt-mode-filter').addEventListener('change', applyOltFilters); $('#olt-name-filter').addEventListener('input', applyOltFilters); +// Changing an action re-renders the per-service "→ will change" preview. +$('#olt-flood-action').addEventListener('change', renderOlts); +$('#olt-dhcpv4-action').addEventListener('change', renderOlts); +$('#olt-dhcpv6-action').addEventListener('change', renderOlts); $('#btn-olt-select-all').addEventListener('click', () => { for (const r of state.oltFiltered) state.oltSelected.add(r._id); renderOlts(); @@ -1406,8 +1410,44 @@ async function loadOlts() { $('#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); + const totalDhcpPass = state.olts.reduce( + (acc, r) => acc + r.matchingNni.filter((n) => n.dhcpv4 === 'pass' || n.dhcpv6 === 'pass').length, 0); $('#olt-status').textContent = - `${state.olts.length} OLTs loaded. ${totalMatching} service(s) match TAG pattern; ${totalPriv} currently in private mode.`; + `${state.olts.length} OLTs loaded. ${totalMatching} matching service(s); ` + + `${totalPriv} private flood, ${totalDhcpPass} with DHCP on "pass".`; +} + +// Read the three action dropdowns into a planNniEdit-shaped ops object. +function oltActionOps() { + const dir = (v) => (v ? { from: v.split('2')[0], to: v.split('2')[1] } : null); + const flood = $('#olt-flood-action').value; + return { + flood: flood ? { fromMode: flood.split('2')[0], targetMode: flood.split('2')[1], ponFloodIdValue: 0 } : null, + dhcpv4: dir($('#olt-dhcpv4-action').value), + dhcpv6: dir($('#olt-dhcpv6-action').value), + }; +} + +// Client-side mirror of planNniEdit: which edits would hit this NNI summary +// given the selected actions. DHCP edits only count when the field is present. +function nniEditsFor(n, ops) { + const edits = []; + if (ops.flood && ops.flood.targetMode) { + const okFrom = !ops.flood.fromMode || n.mode === ops.flood.fromMode; + if (okFrom && n.mode !== ops.flood.targetMode) edits.push('flood'); + } + for (const [op, key, label] of [[ops.dhcpv4, 'dhcpv4', 'DHCPv4'], [ops.dhcpv6, 'dhcpv6', 'DHCPv6']]) { + if (!op || n[key] == null) continue; + const okFrom = !op.from || n[key] === op.from; + if (okFrom && n[key] !== op.to) edits.push(label); + } + return edits; +} + +function dhcpChip(label, val) { + if (val == null) return ''; + const cls = val === 'umt' ? 'status-ok' : val === 'pass' ? 'status-warn' : 'muted'; + return ` ${escapeHtml(label)}=${escapeHtml(String(val))}`; } function applyOltFilters() { @@ -1424,20 +1464,19 @@ function applyOltFilters() { function renderOlts() { const tbody = $('#olt-tbody'); tbody.innerHTML = ''; - const mode = $('#olt-mode-filter').value; + const ops = oltActionOps(); 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}
`; + const edits = selected ? nniEditsFor(n, ops) : []; + if (edits.length) totalSvcThatWillChange += 1; + const willFlag = edits.length ? ` → will change (${escapeHtml(edits.join(', '))})` : ''; + const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (id=${escapeHtml(String(n.ponFloodId))})` : ''; + return `
${escapeHtml(n.tagMatch)} ${escapeHtml(n.mode)}${escapeHtml(fid)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}
`; }).join(''); tr.innerHTML = ` @@ -1466,20 +1505,28 @@ function 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 ops = oltActionOps(); const tagPattern = $('#olt-tag-pattern').value.trim(); + + const actionLines = []; + if (ops.flood) actionLines.push(`Flooding: ${ops.flood.fromMode} → ${ops.flood.targetMode}`); + if (ops.dhcpv4) actionLines.push(`DHCPv4: ${ops.dhcpv4.from} → ${ops.dhcpv4.to}`); + if (ops.dhcpv6) actionLines.push(`DHCPv6: ${ops.dhcpv6.from} → ${ops.dhcpv6.to}`); + if (!actionLines.length) { + $('#olt-exec-status').textContent = 'Select at least one action (Flooding / DHCPv4 / DHCPv6).'; + return; + } + 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).`; + `Apply to NNI services matching "${tagPattern}" on ${ids.length} OLT(s):\n • ` + + actionLines.join('\n • ') + + `\n\nEach OLT-CFG is fetched, the matching NNI entries edited, and the full ` + + `document PUT back. No undo from this app.`; const confirmed = await confirmTyped({ - title: 'Apply PON flooding-mode change', + title: 'Apply OLT NNI changes', body: summary, requireWord: 'APPLY', - confirmLabel: 'Execute change', + confirmLabel: 'Execute changes', danger: true, }); if (!confirmed) { @@ -1488,15 +1535,13 @@ $('#btn-olt-execute').addEventListener('click', async () => { } $('#btn-olt-execute').disabled = true; - $('#olt-exec-status').textContent = `Applying change to 0/${ids.length}…`; + $('#olt-exec-status').textContent = `Applying 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)` : ''; + : (changes && changes.length) ? ` (${oltId}: ${changes.length} svc updated)` : ''; $('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`; }); - const res = await window.api.executeFloodChange({ - oltIds: ids, tagPattern, fromMode, targetMode, - }); + const res = await window.api.executeFloodChange({ oltIds: ids, tagPattern, ...ops }); unbind(); if (!res.ok) { $('#olt-exec-status').textContent = `Bulk apply failed: ${res.error?.message}`; @@ -1513,20 +1558,23 @@ $('#btn-olt-execute').addEventListener('click', async () => { } $('#olt-exec-status').textContent = msg; - // Auto-save a result CSV alongside the upgrade/verify ones. + // Auto-save a result CSV alongside the upgrade/verify ones. Each change row + // lists every per-NNI edit, e.g. "s0.c76.c0: flood private→auto, DHCPv4 pass→umt". + const actionsStr = actionLines.join(' | '); const headers = [ - 'OLT MAC', 'OLT name', 'Action', 'TAG pattern', + 'OLT MAC', 'OLT name', 'Actions', '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('; '); + const changeDetail = (r.changes || []).map((c) => + `${c.tagMatch}: ${(c.edits || []).map((e) => `${e.type} ${e.from}→${e.to}`).join(', ')}`).join('; '); + const skipDetail = (r.skipped || []).map((sk) => `${sk.tagMatch}: ${sk.reason}`).join('; '); return { 'OLT MAC': r.oltId, 'OLT name': olt?.name || '', - 'Action': `${fromMode}→${targetMode}`, + 'Actions': actionsStr, 'TAG pattern': tagPattern, 'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed', 'Services changed': (r.changes || []).length, @@ -1536,10 +1584,14 @@ $('#btn-olt-execute').addEventListener('click', async () => { 'Error': r.error ? r.error.message : '', }; }); + const hintParts = []; + if (ops.flood) hintParts.push(`flood-${ops.flood.targetMode}`); + if (ops.dhcpv4) hintParts.push(`dhcpv4-${ops.dhcpv4.to}`); + if (ops.dhcpv6) hintParts.push(`dhcpv6-${ops.dhcpv6.to}`); 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', + filenameHint: `${hintParts.join('-')}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`, + prefix: 'pon-olt-nni', }); if (saveRes.ok) { $('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`; diff --git a/renderer/index.html b/renderer/index.html index 175354b..68b303a 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -288,11 +288,11 @@ -