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

@ -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;
// 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 });
}
}
let removedPonFloodId;
if (targetMode === 'auto') {
removedPonFloodId = n['PON FLOOD ID'];
delete n['PON FLOOD ID'];
} else if (targetMode === 'private') {
n['PON FLOOD ID'] = ponFloodIdValue;
} else {
unchanged.push({ index: i, tagMatch: tag, reason: `unknown target mode "${targetMode}"` });
continue;
}
changes.push({ index: i, tagMatch: tag, fromMode: current, toMode: targetMode, removedPonFloodId });
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,
};