Add bulk OLT PON flooding-mode tool; restore README; docs

Adds a second bulk workflow alongside the firmware upgrader: changing
PON flooding mode (private<->auto) on OLT NNI services across many OLTs.

Flooding mode is encoded by the presence of the "PON FLOOD ID" key on
each OLT-CFG["NNI Networks"] entry (present = private, absent = auto).
private->auto deletes the key; auto->private sets it to 0. The rest of
the OLT-CFG is preserved (PUT-as-replace, same as ONU-CFG).

- src/mcms-api.js: listAllOltConfigs / getOltConfig / putOltConfig, plus
  pure helpers floodModeOfNni, summarizeOltNniNetworks, planFloodChange
  (planFloodChange clones and never mutates its input)
- main.js: api:listOlts + api:executeFloodChange IPC handlers
  (concurrency 3, GET->plan->PUT, streams olt-flood:progress)
- preload.js: expose both channels + onFloodProgress
- renderer: OLT inspector view (#view-olts), two-step APPLY confirm,
  auto-saved pon-olt-flood-*.csv result report
- Restore README.md (lost to a filesystem error) and document the new
  feature in README.md and CLAUDE.md (new section 14)

Verified: npm run check passes; pure helpers unit-tested for the
no-mutation, idempotency, and rollback invariants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 12:54:06 +02:00
parent 0eb9b55717
commit 80fd7e8d37
7 changed files with 612 additions and 7 deletions

View file

@ -11,6 +11,9 @@ const state = {
fecHealth: new Map(), // onuId -> { flag, detail, sampleTime, error? }
campaignMode: 'task', // 'task' | 'peronu'
verify: null, // { sourcePath, beforeRows, snapshotById, comparisons }
olts: [], // result of listOlts
oltFiltered: [], // current visible OLTs
oltSelected: new Set(),
};
// -------- Helpers --------
@ -1280,3 +1283,181 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// -------- OLT inspector: bulk PON flooding mode change --------
$('#btn-to-olts').addEventListener('click', () => {
showView('#view-olts');
$('#olt-status').textContent = '';
$('#olt-exec-status').textContent = '';
});
$('#btn-olts-back').addEventListener('click', () => showView('#view-fleet'));
$('#btn-load-olts').addEventListener('click', loadOlts);
$('#olt-tag-pattern').addEventListener('input', applyOltFilters);
$('#olt-mode-filter').addEventListener('change', applyOltFilters);
$('#olt-name-filter').addEventListener('input', applyOltFilters);
$('#btn-olt-select-all').addEventListener('click', () => {
for (const r of state.oltFiltered) state.oltSelected.add(r._id);
renderOlts();
});
$('#btn-olt-select-none').addEventListener('click', () => {
state.oltSelected.clear();
renderOlts();
});
$('#olt-th-check').addEventListener('change', (e) => {
if (e.target.checked) for (const r of state.oltFiltered) state.oltSelected.add(r._id);
else for (const r of state.oltFiltered) state.oltSelected.delete(r._id);
renderOlts();
});
async function loadOlts() {
const tagPattern = $('#olt-tag-pattern').value.trim();
$('#olt-status').textContent = 'Loading OLT configs…';
const res = await window.api.listOlts({ tagPattern });
if (!res.ok) {
$('#olt-status').textContent = `Error: ${res.error?.message || 'unknown'}`;
return;
}
state.olts = res.data || [];
applyOltFilters();
$('#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);
$('#olt-status').textContent =
`${state.olts.length} OLTs loaded. ${totalMatching} service(s) match TAG pattern; ${totalPriv} currently in private mode.`;
}
function applyOltFilters() {
const mode = $('#olt-mode-filter').value;
const nameQ = $('#olt-name-filter').value.trim().toLowerCase();
state.oltFiltered = state.olts.filter((r) => {
if (nameQ && !(r.name || '').toLowerCase().includes(nameQ) && !(r._id || '').toLowerCase().includes(nameQ)) return false;
if (mode === 'any') return r.matchingNni.length > 0;
return r.matchingNni.some((n) => n.mode === mode);
});
renderOlts();
}
function renderOlts() {
const tbody = $('#olt-tbody');
tbody.innerHTML = '';
const mode = $('#olt-mode-filter').value;
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>`;
}).join('');
tr.innerHTML = `
<td><input type="checkbox" data-id="${escapeHtml(r._id)}" ${selected ? 'checked' : ''}/></td>
<td>${escapeHtml(r._id)}</td>
<td>${escapeHtml(r.name)}</td>
<td>${escapeHtml(r.ponMode)}</td>
<td>${escapeHtml(r.activeFwVersion)}</td>
<td class="fec-cell">${matchingHtml || '<span class="muted">no matching service</span>'}</td>
`;
tbody.appendChild(tr);
}
$('#olt-count').textContent = state.oltFiltered.length;
$('#olt-sel-count').textContent = state.oltSelected.size;
$('#olt-svc-count').textContent = totalSvcThatWillChange;
$('#btn-olt-execute').disabled = totalSvcThatWillChange === 0;
for (const cb of tbody.querySelectorAll('input[type="checkbox"]')) {
cb.addEventListener('change', () => {
const id = cb.getAttribute('data-id');
if (cb.checked) state.oltSelected.add(id); else state.oltSelected.delete(id);
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 tagPattern = $('#olt-tag-pattern').value.trim();
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).\n\nClick OK to continue to the final confirmation.`;
if (!confirm(summary)) return;
const typed = prompt(`Type APPLY to confirm bulk mode change on ${ids.length} OLT(s):`);
if (typed !== 'APPLY') {
$('#olt-exec-status').textContent = 'Cancelled.';
return;
}
$('#btn-olt-execute').disabled = true;
$('#olt-exec-status').textContent = `Applying change 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)` : '';
$('#olt-exec-status').textContent = `Applying ${done}/${total}${tail}`;
});
const res = await window.api.executeFloodChange({
oltIds: ids, tagPattern, fromMode, targetMode,
});
unbind();
if (!res.ok) {
$('#olt-exec-status').textContent = `Bulk apply failed: ${res.error?.message}`;
$('#btn-olt-execute').disabled = false;
return;
}
const okCount = res.data.filter((r) => r.ok && !r.noop).length;
const noopCount = res.data.filter((r) => r.noop).length;
const failed = res.data.filter((r) => !r.ok);
let msg = `Done. ${okCount} OLT(s) updated, ${noopCount} no-op, ${failed.length} failed.`;
if (failed.length) {
msg += ' Failures: ' + failed.slice(0, 3).map((f) => `${f.oltId} (${f.error?.message || '?'})`).join(', ');
if (failed.length > 3) msg += ` and ${failed.length - 3} more`;
}
$('#olt-exec-status').textContent = msg;
// Auto-save a result CSV alongside the upgrade/verify ones.
const headers = [
'OLT MAC', 'OLT name', 'Action', '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('; ');
return {
'OLT MAC': r.oltId,
'OLT name': olt?.name || '',
'Action': `${fromMode}${targetMode}`,
'TAG pattern': tagPattern,
'Result': r.ok ? (r.noop ? 'noop' : 'updated') : 'failed',
'Services changed': (r.changes || []).length,
'Services skipped': (r.skipped || []).length,
'Change detail': changeDetail,
'Skip detail': skipDetail,
'Error': r.error ? r.error.message : '',
};
});
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',
});
if (saveRes.ok) {
$('#olt-exec-status').textContent += ` Report → ${saveRes.data.path}`;
}
// Refresh the table so the user sees the new state.
await loadOlts();
});

View file

@ -100,6 +100,10 @@
<h2>After-upgrade verification</h2>
<p class="muted small">Open a saved <code>pon-upgrade-*.csv</code> to compare the recorded FEC + optical readings against the current state of those ONUs.</p>
<button id="btn-to-verify" class="ghost">Verify previous upgrade…</button>
<h2>OLT flooding mode</h2>
<p class="muted small">Bulk-check or fix PON flooding mode on OLT NNI services. Default rule: match <code>s0.c76.c0</code> with mode <code>private</code> and switch to <code>auto</code>.</p>
<button id="btn-to-olts" class="ghost">Open OLT inspector…</button>
</div>
<div class="panel-main">
@ -246,6 +250,75 @@
</div>
</section>
<!-- ================= OLT inspector view ================= -->
<section id="view-olts" class="view hidden">
<div class="panel-left">
<h2>Filters</h2>
<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
<select id="olt-mode-filter">
<option value="private" selected>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
<input id="olt-name-filter" type="search" placeholder="e.g. OLT5" />
</label>
<button id="btn-load-olts" class="primary">Load OLTs</button>
<p class="muted small" id="olt-status"></p>
<h2>Selection</h2>
<div class="selected-summary">
<div><strong id="olt-sel-count">0</strong> OLT(s) selected · <strong id="olt-svc-count">0</strong> service slot(s) will change</div>
<div class="row">
<button id="btn-olt-select-all">Select all matching</button>
<button id="btn-olt-select-none" class="ghost">Clear</button>
</div>
</div>
<h2>Action</h2>
<label>Change matching services
<select id="olt-action">
<option value="private2auto" selected>Private → Auto (remove PON FLOOD ID)</option>
<option value="auto2private">Auto → Private (set PON FLOOD ID = 0)</option>
</select>
</label>
<div class="row">
<button id="btn-olts-back" class="ghost">← Back to fleet</button>
<button id="btn-olt-execute" class="danger" disabled>Execute change…</button>
</div>
<p id="olt-exec-status" class="small muted"></p>
</div>
<div class="panel-main">
<div class="fleet-head">
<h2>OLTs (<span id="olt-count">0</span> shown, <span id="olt-total">0</span> total)</h2>
<div class="row small muted">
<span>Each row shows the matching NNI services. Selecting an OLT queues every matching service on it for change.</span>
</div>
</div>
<div class="fleet-table-wrap">
<table class="fleet-table" id="olt-table">
<thead>
<tr>
<th><input type="checkbox" id="olt-th-check" /></th>
<th>OLT MAC</th>
<th>Name</th>
<th>PON mode</th>
<th>FW</th>
<th>Matching NNI services</th>
</tr>
</thead>
<tbody id="olt-tbody"></tbody>
</table>
</div>
</div>
</section>
<script src="app.js"></script>
</body>
</html>