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

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