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}${escapeHtml(n.tagMatch)} ${escapeHtml(n.mode)}${escapeHtml(fid)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}