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).
- **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

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).
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

17
main.js
View file

@ -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 {

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', () => {
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 ` <span class="${cls}">${escapeHtml(label)}=${escapeHtml(String(val))}</span>`;
}
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 ? ' <span class="status-info">→ will change</span>' : '';
const fid = n.ponFloodId !== null && n.ponFloodId !== undefined ? ` (PON FLOOD ID=${escapeHtml(String(n.ponFloodId))})` : '';
return `<div><code>${escapeHtml(n.tagMatch)}</code> <span class="${modeCls}">${escapeHtml(n.mode)}</span>${escapeHtml(fid)}${willFlag}</div>`;
const edits = selected ? nniEditsFor(n, ops) : [];
if (edits.length) totalSvcThatWillChange += 1;
const willFlag = edits.length ? ` <span class="status-info">→ will change (${escapeHtml(edits.join(', '))})</span>` : '';
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)}${dhcpChip('v4', n.dhcpv4)}${dhcpChip('v6', n.dhcpv6)}${willFlag}</div>`;
}).join('');
tr.innerHTML = `
<td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td>
@ -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}`;

View file

@ -288,11 +288,11 @@
<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.*" />
</label>
<label>Current mode
<label>Flood-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="any">Any</option>
</select>
</label>
<label>OLT name contains
@ -310,13 +310,29 @@
</div>
</div>
<h2>Action</h2>
<label>Change matching services
<select id="olt-action">
<h2>Actions</h2>
<p class="muted small">Applied together to every matching NNI service on the selected OLTs, in one GET→edit→PUT per OLT.</p>
<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="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
</select>
</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">
<button id="btn-olts-back" class="ghost">← Back to fleet</button>

View file

@ -713,6 +713,17 @@ function floodModeOfNni(nniNet) {
: '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,
* filtered by a TAG MATCH pattern. Pattern can be:
@ -733,6 +744,8 @@ function summarizeOltNniNetworks(oltCfg, tagPattern = '') {
tagMatch: tag,
mode: floodModeOfNni(n),
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,
});
}
@ -747,48 +760,65 @@ function tagPatternToRegex(pattern) {
}
/**
* Apply a PON flooding mode change to a CLONE of an OLT-CFG doc. Does
* not mutate the input. Returns { newDoc, changes, unchanged }.
* Apply a set of NNI-service edits to a CLONE of an OLT-CFG. Non-mutating
* 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
* PON FLOOD ID to ponFloodIdValue, default 0).
* ops:
* 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, {
tagPattern,
fromMode = 'private', // only flip entries currently in this mode (null = any)
targetMode = 'auto',
ponFloodIdValue = 0,
}) {
function planNniEdit(oltCfg, { tagPattern, flood = null, dhcpv4 = null, dhcpv6 = null } = {}) {
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;
const edits = [];
// 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}"` });
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;
// DHCP filter modes — Filter.DHCPv4 / Filter.DHCPv6, replace-when-present.
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 });
}
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
}
if (edits.length) changes.push({ index: i, tagMatch: tag, edits });
else unchanged.push({ index: i, tagMatch: tag, reason: 'nothing to change (already at target / from-mode mismatch / field absent)' });
}
const newDoc = { ...oltCfg, 'NNI Networks': newNetworks };
return { newDoc, changes, unchanged };
}
@ -802,6 +832,7 @@ module.exports = {
extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber,
floodModeOfNni,
dhcpModeOfNni,
summarizeOltNniNetworks,
planFloodChange,
planNniEdit,
};

View file

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