olt: add bulk DHCPv4/DHCPv6 filter edit alongside flood mode

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 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 14:57:37 +02:00
parent de037c8795
commit 5e3d9271a8
7 changed files with 232 additions and 118 deletions

View file

@ -18,7 +18,8 @@ fleet. Top-level **tabs** (Dashboard / ONU / OLT) switch between:
- **Dashboard** — read-only network telemetry (see §15). - **Dashboard** — read-only network telemetry (see §15).
- **ONU** — the fleet table + firmware-upgrade campaign + verify flows - **ONU** — the fleet table + firmware-upgrade campaign + verify flows
(the original purpose: bulk ONU firmware upgrades). (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 Reference deployment: GNXS / Tibit MicroPlug ONUs running Interos Everest
(EV051xxR / EV052xxR firmware family) behind Tibit OLTs. (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 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 Both live on each `OLT-CFG["NNI Networks"][i]` entry, both confirmed
absence of one key** on its `OLT-CFG["NNI Networks"][i]` entry: empirically from operator paste-backs of `s0.c76.c0`:
- **`PON FLOOD ID` key present** (any value, including `0`) → **private** 1. **Flooding mode** — the **presence/absence of the `PON FLOOD ID` key**:
- **`PON FLOOD ID` key absent** → **auto** 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 Everything else on the entry and the rest of the OLT-CFG is preserved.
`entry['PON FLOOD ID'] = 0`. Everything else on the entry (`TAG MATCH`, Default actions: TAG MATCH `s0.c76.c0`, flood `private→auto`, DHCPv4/v6
`Learning Limit`, …) and the rest of the OLT-CFG is preserved. This was `pass→umt`. All directions are wired, so changes roll back from the same UI.
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 ### Data flow
``` ```
listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure) listOlts ─► McmsClient.listAllOltConfigs ─► summarizeOltNniNetworks (pure)
executeFloodChange ─► per-OLT: getOltConfig ─► planFloodChange (pure, no-mutate) executeFloodChange ─► per-OLT: getOltConfig ─► planNniEdit (pure, no-mutate)
─► putOltConfig (full-doc PUT) (channel name kept for wire compat) ─► putOltConfig (full-doc PUT)
``` ```
- **Pure helpers** live at the bottom of `src/mcms-api.js`: - **Pure helpers** at the bottom of `src/mcms-api.js`: `floodModeOfNni`,
`floodModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`, `dhcpModeOfNni`, `summarizeOltNniNetworks`, `tagPatternToRegex`,
`planFloodChange`. `planFloodChange` clones the `NNI Networks` array and `planNniEdit`. `planNniEdit(oltCfg, { tagPattern, flood, dhcpv4, dhcpv6 })`
never mutates its input — it returns `{ newDoc, changes, unchanged }`. 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 - **TAG MATCH pattern** supports exact strings, `*`/`""` (match all), and
`*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars `*` globs (e.g. `s0.c76.*`). `tagPatternToRegex` escapes regex metachars
then turns `*` into `.*`. then turns `*` into `.*`.
- **`fromMode` guard**: only entries currently in `fromMode` are flipped; - **`from` guards**: only entries currently on the `from` mode/value are
entries already in the target mode are recorded in `unchanged` (the flipped; entries already at target are left in `unchanged` (the per-OLT
per-OLT `noop` case), so re-running is idempotent. `noop` case), so re-running is idempotent.
- **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state - **Concurrency 3** on the write path (vs 5 for ONU delete, 8 for state
fetch) — OLT writes carry far more blast radius. fetch) — OLT writes carry far more blast radius.
- **PUT-as-replace**, same gotcha as ONU-CFG (§5.7): the handler GETs the - **PUT-as-replace**, same gotcha as ONU-CFG (§5.7): the handler GETs the

View file

@ -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). registration-state breakdown (with an opt-in per-ONU Rx/FEC scan).
Clicking a state jumps to the ONU tab pre-filtered. Clicking a state jumps to the ONU tab pre-filtered.
2. **ONU** — the fleet table plus **bulk ONU firmware upgrades** (below). 2. **ONU** — the fleet table plus **bulk ONU firmware upgrades** (below).
3. **OLT****bulk OLT PON flooding-mode changes** (private↔auto on NNI 3. **OLT****bulk OLT NNI-service edits**: PON flooding mode (private↔auto)
services — see "OLT PON flooding mode" below). and DHCPv4/v6 filter (pass↔umt) — see "OLT NNI-service edits" below.
## ONU firmware upgrades ## ONU firmware upgrades
@ -104,27 +104,36 @@ pon-fleet-upgrader/
└── app.js # UI logic └── app.js # UI logic
``` ```
## OLT PON flooding mode ## OLT NNI-service edits
From the fleet view, **Open OLT inspector…** opens a second workflow for 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 **Flooding mode** — encoded by the presence of a single key on each
on each `OLT-CFG["NNI Networks"]` entry: `OLT-CFG["NNI Networks"]` entry:
| Mode | `PON FLOOD ID` key | Change | | Mode | `PON FLOOD ID` key | Change |
|------|--------------------|--------| |------|--------------------|--------|
| **private** | present (any value, incl. `0`) | → auto: delete the key | | **private** | present (any value, incl. `0`) | → auto: delete the key |
| **auto** | absent | → private: set the key to `0` | | **auto** | absent | → private: set the key to `0` |
Workflow: enter a **TAG MATCH** pattern (exact like `s0.c76.c0`, or a `*` **DHCP filter mode** — values nested under each entry's `Filter` object
glob like `s0.c76.*`), **Load OLTs**, select the OLTs to change, pick a (`DHCPv4` / `DHCPv6`), e.g. `pass``umt`. Only entries that already have
direction, then **Execute change…** (two-step confirm). Each OLT is the field are touched (the field is never invented); `EAPOL`/`PPPoE`/`NDP`
re-fetched, the matching NNI entries are flipped, and the full OLT-CFG is are left alone.
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 Workflow: enter a **TAG MATCH** pattern (exact like `s0.c76.c0`, or a `*`
screen. The default action targets `s0.c76.c0`, `private``auto`. 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 ## Known limitations

17
main.js
View file

@ -10,7 +10,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { const {
McmsClient, McmsApiError, extractOnuHealth, McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planFloodChange, summarizeOltNniNetworks, planNniEdit,
} = require('./src/mcms-api'); } = require('./src/mcms-api');
const { summarizeDashboard } = require('./src/dashboard'); const { summarizeDashboard } = require('./src/dashboard');
const { planUpgrade, activeBankPtr, inactiveBank } = require('./src/bank-strategy'); 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 * Apply a set of NNI-service edits (flood mode and/or DHCPv4/DHCPv6 filter)
* OLT we GET the current CFG (to avoid stomping concurrent edits), * to a list of OLT IDs. For each OLT we GET the current CFG (to avoid
* run planFloodChange, and PUT the result back. Streams progress. * 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 * Concurrency is kept low (default 3) OLT writes carry a much larger
* blast radius than ONU reads, so we deliberately stay below the ONU * blast radius than ONU reads, so we deliberately stay below the ONU
@ -666,8 +668,7 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try { try {
const c = requireClient(); const c = requireClient();
const { const {
oltIds, tagPattern, fromMode = 'private', oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null,
targetMode = 'auto', ponFloodIdValue = 0,
concurrency = 3, concurrency = 3,
} = opts || {}; } = opts || {};
const queue = [...oltIds]; const queue = [...oltIds];
@ -683,9 +684,7 @@ ipcMain.handle('api:executeFloodChange', async (event, opts) => {
try { try {
const cfg = await c.getOltConfig(id); const cfg = await c.getOltConfig(id);
if (!cfg) throw new Error('OLT not found'); if (!cfg) throw new Error('OLT not found');
const plan = planFloodChange(cfg, { const plan = planNniEdit(cfg, { tagPattern, flood, dhcpv4, dhcpv6 });
tagPattern, fromMode, targetMode, ponFloodIdValue,
});
if (plan.changes.length === 0) { if (plan.changes.length === 0) {
entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; entry = { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
} else { } else {

View file

@ -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', () => { $('#btn-to-olts').addEventListener('click', () => {
showView('#view-olts'); showView('#view-olts');
@ -1379,6 +1379,10 @@ $('#btn-load-olts').addEventListener('click', loadOlts);
$('#olt-tag-pattern').addEventListener('input', applyOltFilters); $('#olt-tag-pattern').addEventListener('input', applyOltFilters);
$('#olt-mode-filter').addEventListener('change', applyOltFilters); $('#olt-mode-filter').addEventListener('change', applyOltFilters);
$('#olt-name-filter').addEventListener('input', 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', () => { $('#btn-olt-select-all').addEventListener('click', () => {
for (const r of state.oltFiltered) state.oltSelected.add(r._id); for (const r of state.oltFiltered) state.oltSelected.add(r._id);
renderOlts(); renderOlts();
@ -1406,8 +1410,44 @@ async function loadOlts() {
$('#olt-total').textContent = state.olts.length; $('#olt-total').textContent = state.olts.length;
const totalMatching = state.olts.reduce((acc, r) => acc + r.matchingNni.length, 0); 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 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 = $('#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 ` <span class="${cls}">${escapeHtml(label)}=${escapeHtml(String(val))}</span>`;
} }
function applyOltFilters() { function applyOltFilters() {
@ -1424,20 +1464,19 @@ function applyOltFilters() {
function renderOlts() { function renderOlts() {
const tbody = $('#olt-tbody'); const tbody = $('#olt-tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
const mode = $('#olt-mode-filter').value; const ops = oltActionOps();
let totalSvcThatWillChange = 0; let totalSvcThatWillChange = 0;
for (const r of state.oltFiltered) { for (const r of state.oltFiltered) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const selected = state.oltSelected.has(r._id); const selected = state.oltSelected.has(r._id);
if (selected) tr.classList.add('selected'); if (selected) tr.classList.add('selected');
const willChangeMode = mode === 'auto' ? 'auto' : 'private';
const matchingHtml = r.matchingNni.map((n) => { const matchingHtml = r.matchingNni.map((n) => {
const modeCls = n.mode === 'private' ? 'status-warn' : n.mode === 'auto' ? 'status-ok' : 'muted'; const modeCls = n.mode === 'private' ? 'status-warn' : n.mode === 'auto' ? 'status-ok' : 'muted';
const willChange = selected && n.mode === willChangeMode; const edits = selected ? nniEditsFor(n, ops) : [];
if (willChange) totalSvcThatWillChange += 1; if (edits.length) totalSvcThatWillChange += 1;
const willFlag = willChange ? ' <span class="status-info">→ will change</span>' : ''; const willFlag = edits.length ? ` <span class="status-info">→ will change (${escapeHtml(edits.join(', '))})</span>` : '';
const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (PON FLOOD ID=${escapeHtml(String(n.ponFloodId))})` : ''; const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (id=${escapeHtml(String(n.ponFloodId))})` : '';
return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${willFlag}</div>`; return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}</div>`;
}).join(''); }).join('');
tr.innerHTML = ` tr.innerHTML = `
<td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td> <td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td>
@ -1466,20 +1505,28 @@ function renderOlts() {
$('#btn-olt-execute').addEventListener('click', async () => { $('#btn-olt-execute').addEventListener('click', async () => {
const ids = Array.from(state.oltSelected); const ids = Array.from(state.oltSelected);
if (!ids.length) return; if (!ids.length) return;
const action = $('#olt-action').value; const ops = oltActionOps();
const fromMode = action === 'auto2private' ? 'auto' : 'private';
const targetMode = action === 'auto2private' ? 'private' : 'auto';
const tagPattern = $('#olt-tag-pattern').value.trim(); 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 = const summary =
`Apply "${fromMode}${targetMode}" to NNI services matching "${tagPattern}" ` + `Apply to NNI services matching "${tagPattern}" on ${ids.length} OLT(s):\n ` +
`on ${ids.length} OLT(s).\n\nThis GETs each OLT-CFG, mutates the matching ` + actionLines.join('\n • ') +
`NNI Networks entries, and PUTs the full document back. The operation is ` + `\n\nEach OLT-CFG is fetched, the matching NNI entries edited, and the full ` +
`irreversible from this app (no undo).`; `document PUT back. No undo from this app.`;
const confirmed = await confirmTyped({ const confirmed = await confirmTyped({
title: 'Apply PON flooding-mode change', title: 'Apply OLT NNI changes',
body: summary, body: summary,
requireWord: 'APPLY', requireWord: 'APPLY',
confirmLabel: 'Execute change', confirmLabel: 'Execute changes',
danger: true, danger: true,
}); });
if (!confirmed) { if (!confirmed) {
@ -1488,15 +1535,13 @@ $('#btn-olt-execute').addEventListener('click', async () => {
} }
$('#btn-olt-execute').disabled = true; $('#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 unbind = window.api.onFloodProgress(({ done, total, oltId, error, changes }) => {
const tail = error ? ` (last error on ${oltId}: ${error.message})` 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}`; $('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`;
}); });
const res = await window.api.executeFloodChange({ const res = await window.api.executeFloodChange({ oltIds: ids, tagPattern, ...ops });
oltIds: ids, tagPattern, fromMode, targetMode,
});
unbind(); unbind();
if (!res.ok) { if (!res.ok) {
$('#olt-exec-status').textContent = `Bulk apply failed: ${res.error?.message}`; $('#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; $('#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 = [ const headers = [
'OLT MAC', 'OLT name', 'Action', 'TAG pattern', 'OLT MAC', 'OLT name', 'Actions', 'TAG pattern',
'Result', 'Services changed', 'Services skipped', 'Result', 'Services changed', 'Services skipped',
'Change detail', 'Skip detail', 'Error', 'Change detail', 'Skip detail', 'Error',
]; ];
const rows = res.data.map((r) => { const rows = res.data.map((r) => {
const olt = state.olts.find((o) => o._id === r.oltId); const olt = state.olts.find((o) => o._id === r.oltId);
const changeDetail = (r.changes || []).map((c) => `${c.tagMatch}: ${c.fromMode}${c.toMode}`).join('; '); const changeDetail = (r.changes || []).map((c) =>
const skipDetail = (r.skipped || []).map((s) => `${s.tagMatch}: ${s.reason}`).join('; '); `${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 { return {
'OLT MAC': r.oltId, 'OLT MAC': r.oltId,
'OLT name': olt?.name || '', 'OLT name': olt?.name || '',
'Action': `${fromMode}${targetMode}`, 'Actions': actionsStr,
'TAG pattern': tagPattern, 'TAG pattern': tagPattern,
'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed', 'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed',
'Services changed': (r.changes || []).length, 'Services changed': (r.changes || []).length,
@ -1536,10 +1584,14 @@ $('#btn-olt-execute').addEventListener('click', async () => {
'Error': r.error ? r.error.message : '', '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({ const saveRes = await window.api.saveCsvFile({
rows, headers, rows, headers,
filenameHint: `${fromMode}-to-${targetMode}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`, filenameHint: `${hintParts.join('-')}-${tagPattern.replace(/[^A-Za-z0-9]+/g, '_')}-${ids.length}olts`,
prefix: 'pon-olt-flood', prefix: 'pon-olt-nni',
}); });
if (saveRes.ok) { if (saveRes.ok) {
$('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`; $('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`;

View file

@ -288,11 +288,11 @@
<label>NNI TAG MATCH pattern <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.*" /> <input id="olt-tag-pattern" type="text" value="s0.c76.c0" placeholder="e.g. s0.c76.c0 or s0.c76.*" />
</label> </label>
<label>Current mode <label>Flood-mode filter
<select id="olt-mode-filter"> <select id="olt-mode-filter">
<option value="private" selected>Private (PON FLOOD ID set)</option> <option value="any" selected>Any</option>
<option value="private">Private (PON FLOOD ID set)</option>
<option value="auto">Auto (no PON FLOOD ID)</option> <option value="auto">Auto (no PON FLOOD ID)</option>
<option value="any">Any</option>
</select> </select>
</label> </label>
<label>OLT name contains <label>OLT name contains
@ -310,13 +310,29 @@
</div> </div>
</div> </div>
<h2>Action</h2> <h2>Actions</h2>
<label>Change matching services <p class="muted small">Applied together to every matching NNI service on the selected OLTs, in one GET→edit→PUT per OLT.</p>
<select id="olt-action"> <label>Flooding mode
<select id="olt-flood-action">
<option value="">No change</option>
<option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option> <option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option>
<option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option> <option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
</select> </select>
</label> </label>
<label>DHCPv4 filter
<select id="olt-dhcpv4-action">
<option value="">No change</option>
<option value="pass2umt" selected>pass → umt</option>
<option value="umt2pass">umt → pass</option>
</select>
</label>
<label>DHCPv6 filter
<select id="olt-dhcpv6-action">
<option value="">No change</option>
<option value="pass2umt" selected>pass → umt</option>
<option value="umt2pass">umt → pass</option>
</select>
</label>
<div class="row"> <div class="row">
<button id="btn-olts-back" class="ghost">← Back to fleet</button> <button id="btn-olts-back" class="ghost">← Back to fleet</button>

View file

@ -713,6 +713,17 @@ function floodModeOfNni(nniNet) {
: 'auto'; : 'auto';
} }
// DHCP relay/filter mode for an NNI entry. On Tibit OLTs these live under a
// nested `Filter` object alongside EAPOL/PPPoE/NDP, e.g.
// "Filter": { "DHCPv4": "umt", "DHCPv6": "umt", "EAPOL": "pass", ... }
// `ver` is 'DHCPv4' | 'DHCPv6'. Returns the value (e.g. "umt" / "pass") or
// null if there is no Filter or the key is absent.
function dhcpModeOfNni(nniNet, ver) {
const filter = nniNet && typeof nniNet.Filter === 'object' && nniNet.Filter ? nniNet.Filter : null;
if (!filter) return null;
return Object.prototype.hasOwnProperty.call(filter, ver) ? filter[ver] : null;
}
/** /**
* Build a uniform summary of every NNI Networks entry on an OLT-CFG, * Build a uniform summary of every NNI Networks entry on an OLT-CFG,
* filtered by a TAG MATCH pattern. Pattern can be: * filtered by a TAG MATCH pattern. Pattern can be:
@ -733,6 +744,8 @@ function summarizeOltNniNetworks(oltCfg, tagPattern = '') {
tagMatch: tag, tagMatch: tag,
mode: floodModeOfNni(n), mode: floodModeOfNni(n),
ponFloodId: Object.prototype.hasOwnProperty.call(n, 'PON FLOOD ID') ? n['PON FLOOD ID'] : null, ponFloodId: Object.prototype.hasOwnProperty.call(n, 'PON FLOOD ID') ? n['PON FLOOD ID'] : null,
dhcpv4: dhcpModeOfNni(n, 'DHCPv4'),
dhcpv6: dhcpModeOfNni(n, 'DHCPv6'),
learningLimit: n?.['Learning Limit'] ?? null, learningLimit: n?.['Learning Limit'] ?? null,
}); });
} }
@ -747,48 +760,65 @@ function tagPatternToRegex(pattern) {
} }
/** /**
* Apply a PON flooding mode change to a CLONE of an OLT-CFG doc. Does * Apply a set of NNI-service edits to a CLONE of an OLT-CFG. Non-mutating
* not mutate the input. Returns { newDoc, changes, unchanged }. * the input doc is never touched (the matched entries and any edited `Filter`
* are cloned before mutation). Returns { newDoc, changes, unchanged } where a
* change is `{ index, tagMatch, edits: [{ type, from, to }] }` (an entry can
* carry several edits) and unchanged entries record a reason.
* *
* `targetMode` is 'auto' (delete PON FLOOD ID) or 'private' (set * ops:
* PON FLOOD ID to ponFloodIdValue, default 0). * tagPattern which NNI entries to touch (exact / "*" / glob)
* flood { fromMode='private'|'auto'|null, targetMode, ponFloodIdValue=0 } | null
* targetMode 'auto' deletes PON FLOOD ID; 'private' sets it.
* dhcpv4 { from='pass'|null, to='umt' } | null (Filter.DHCPv4)
* dhcpv6 { from='pass'|null, to='umt' } | null (Filter.DHCPv6)
*
* DHCP edits are replace-only-when-present: an entry with no `Filter` (or no
* such key) is left alone rather than inventing the field. A null `from` means
* "from any current value"; a set `from` only flips entries currently on it.
*/ */
function planFloodChange(oltCfg, { function planNniEdit(oltCfg, { tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null } = {}) {
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 networks = Array.isArray(oltCfg?.['NNI Networks']) ? oltCfg['NNI Networks'] : [];
const re = tagPatternToRegex(tagPattern); const re = tagPatternToRegex(tagPattern);
const newNetworks = networks.map((n) => ({ ...n })); const newNetworks = networks.map((n) => ({ ...n }));
const changes = []; const changes = [];
const unchanged = []; const unchanged = [];
for (let i = 0; i < newNetworks.length; i++) { for (let i = 0; i < newNetworks.length; i++) {
const n = newNetworks[i]; const n = newNetworks[i];
const tag = n?.['TAG MATCH'] || ''; const tag = n?.['TAG MATCH'] || '';
if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; } if (!re.test(tag)) { unchanged.push({ index: i, tagMatch: tag, reason: 'tag does not match' }); continue; }
const current = floodModeOfNni(n);
if (fromMode && current !== fromMode) { const edits = [];
unchanged.push({ index: i, tagMatch: tag, reason: `already in mode "${current}", not "${fromMode}"` });
continue; // Flood mode — presence of PON FLOOD ID.
if (flood && flood.targetMode) {
const cur = floodModeOfNni(n);
const okFrom = !flood.fromMode || cur === flood.fromMode;
if (okFrom && cur !== flood.targetMode) {
if (flood.targetMode === 'auto') delete n['PON FLOOD ID'];
else if (flood.targetMode === 'private') n['PON FLOOD ID'] = flood.ponFloodIdValue ?? 0;
edits.push({ type: 'flood', from: cur, to: flood.targetMode });
}
} }
if (current === targetMode) {
unchanged.push({ index: i, tagMatch: tag, reason: `already in target mode "${targetMode}"` }); // DHCP filter modes — Filter.DHCPv4 / Filter.DHCPv6, replace-when-present.
continue; for (const [op, fieldKey] of [[dhcpv4, 'DHCPv4'], [dhcpv6, 'DHCPv6']]) {
if (!op || !op.to) continue;
const filter = n.Filter;
if (!filter || typeof filter !== 'object' || !Object.prototype.hasOwnProperty.call(filter, fieldKey)) continue;
const cur = filter[fieldKey];
const okFrom = !op.from || cur === op.from;
if (okFrom && cur !== op.to) {
n.Filter = { ...filter, [fieldKey]: op.to }; // clone Filter; never mutate the input
edits.push({ type: fieldKey, from: cur, to: op.to });
}
} }
let removedPonFloodId;
if (targetMode === 'auto') { if (edits.length) changes.push({ index: i, tagMatch: tag, edits });
removedPonFloodId = n['PON FLOOD ID']; else unchanged.push({ index: i, tagMatch: tag, reason: 'nothing to change (already at target / from-mode mismatch / field absent)' });
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 }; const newDoc = { ...oltCfg, 'NNI Networks': newNetworks };
return { newDoc, changes, unchanged }; return { newDoc, changes, unchanged };
} }
@ -802,6 +832,7 @@ module.exports = {
extractFecHealth, // legacy alias — calls extractOnuHealth extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber, formatFecNumber,
floodModeOfNni, floodModeOfNni,
dhcpModeOfNni,
summarizeOltNniNetworks, summarizeOltNniNetworks,
planFloodChange, planNniEdit,
}; };

View file

@ -24,7 +24,7 @@ const crypto = require('crypto');
const { const {
McmsClient, McmsApiError, extractOnuHealth, McmsClient, McmsApiError, extractOnuHealth,
summarizeOltNniNetworks, planFloodChange, summarizeOltNniNetworks, planNniEdit,
} = require('../src/mcms-api'); } = require('../src/mcms-api');
const { planUpgrade } = require('../src/bank-strategy'); const { planUpgrade } = require('../src/bank-strategy');
const { summarizeDashboard } = require('../src/dashboard'); const { summarizeDashboard } = require('../src/dashboard');
@ -454,14 +454,14 @@ const streamHandlers = {
async executeFloodChange(s, body, write) { async executeFloodChange(s, body, write) {
const { const {
oltIds, tagPattern, fromMode = 'private', targetMode = 'auto', oltIds, tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null,
ponFloodIdValue = 0, concurrency = 3, concurrency = 3,
} = body; } = body;
const results = await runPool(oltIds, concurrency, async (id) => { const results = await runPool(oltIds, concurrency, async (id) => {
try { try {
const cfg = await s.client.getOltConfig(id); const cfg = await s.client.getOltConfig(id);
if (!cfg) throw new Error('OLT not found'); 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) return { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; if (plan.changes.length === 0) return { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged };
await s.client.putOltConfig(id, plan.newDoc); await s.client.putOltConfig(id, plan.newDoc);
return { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged }; return { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged };