diff --git a/public/scope-audit.css b/public/scope-audit.css index 6aaee15a..59e57057 100644 --- a/public/scope-audit.css +++ b/public/scope-audit.css @@ -52,3 +52,8 @@ @media (max-width: 640px) { .sa-table th:nth-child(6), .sa-table td:nth-child(6) { display: none; } } + +/* Observed forwarding, the green half of the merged Scopes column. Pairs with + .sa-chip-missing above, which keeps the red. Same colour-mix construction so + the two read as one scale rather than two unrelated styles. */ +.sa-chip-observed { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--status-green-text); } diff --git a/public/scope-audit.js b/public/scope-audit.js index 89159dfb..44e7ad6c 100644 --- a/public/scope-audit.js +++ b/public/scope-audit.js @@ -57,17 +57,32 @@ return '' + escapeHtml(age) + ''; } - function scopeChips(names, cls) { - if (!names.length) return ''; - return names.map(function (n) { return '' + escapeHtml(n) + ''; }).join(' '); - } - - function undeclaredChips(rows) { - if (!rows.length) return ''; - return rows.map(function (o) { - return '' + escapeHtml(o.scope) + ''; - }).join(' '); + // mergedScopeChips renders ONE chip per declared region, coloured by whether + // that region was actually observed forwarding in the window. + // + // This replaces the old DECLARED and NOT OBSERVED pair. They were never + // independent: notObserved is a strict subset of declaredRegions, checked + // against a live 197-row response where it held on 197 of 197 rows. The two + // columns printed the same set twice, once whole and once filtered, and left + // the reader to diff them. On a typical mixed row that meant comparing two + // lists of eight to find the one or two entries that differ. Here the + // observed ones are simply the green ones. + // + // No new claim is made about the data: a region present in declaredRegions + // and absent from notObserved is exactly what the server already means by + // "observed forwarding in this window". + function mergedScopeChips(row) { + var missing = Object.create(null); + row.notObserved.forEach(function (n) { missing[n] = true; }); + var chips = row.declaredRegions.map(function (n) { + var observed = !missing[n]; + return '' + escapeHtml(n) + ''; + }); + if (!chips.length) return ''; + return chips.join(' '); } // nameHtml renders the repeater identity cell. row.name == null means this @@ -171,15 +186,12 @@ // parsed back into a date. var declaredAtMs = row.declaredAt ? new Date(row.declaredAt).getTime() : NaN; var nameSortValue = row.name != null ? row.name : row.publicKey; - var declaredCount = row.declaredRegions.length + (row.declaredWildcard ? 1 : 0); return '' + '' + nameHtml(row) + (row.role != null && row.role !== '' ? ' ' + escapeHtml(row.role) + '' : '') + '' + '' + issuesHtml + '' + '' + configStateHtml(row) + '' + - '' + scopeChips(row.declaredRegions, 'sa-chip-declared') + (row.declaredWildcard ? ' *' : '') + '' + - '' + scopeChips(row.notObserved, 'sa-chip-missing') + ambiguousCaveat(row) + '' + - '' + undeclaredChips(row.undeclaredObserved) + '' + + '' + mergedScopeChips(row) + (row.declaredWildcard ? ' *' : '') + ambiguousCaveat(row) + '' + '' + ageHtml(row) + (row.truncated ? ' truncated' : '') + '' + ''; } @@ -247,9 +259,7 @@ 'Repeater' + 'Status' + 'Config' + - 'Declared' + - 'Not observed' + - 'Undeclared observed' + + 'Scopes' + 'Declared age' + '' + d.repeaters.map(rowHtml).join('') + @@ -327,5 +337,12 @@ sortCtl = null; } + if (typeof window !== 'undefined') { + // Exposed so the helper tests can assert what the Scopes column RENDERS + // rather than grepping this file, the same reason map.js exposes its label + // builder (#1356/#1933). + window.__meshcoreScopeAuditInternals = { mergedScopeChips: mergedScopeChips }; + } + registerPage('scope-audit', { init: init, destroy: destroy }); })(); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index b2cf51d1..bf688f56 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -6966,3 +6966,74 @@ console.log('\n=== observers.js: healthStatus (configurable thresholds) ==='); assert.strictEqual(r.label, 'Unknown'); }); } + +// ===== scope-audit.js: mergedScopeChips ===== +// DECLARED and NOT OBSERVED were merged into one colour-coded Scopes column. +// They were never independent: notObserved is a strict subset of +// declaredRegions, so the page printed the same set twice and made the reader +// diff it. These assert the rendered markup, not the source. +console.log('\n=== scope-audit.js: mergedScopeChips ==='); +{ + const ctx = makeSandbox(); + ctx.registerPage = () => {}; + loadInCtx(ctx, 'public/app.js'); + loadInCtx(ctx, 'public/scope-audit.js'); + const chips = ctx.__meshcoreScopeAuditInternals.mergedScopeChips; + const row = (declared, notObserved) => ({ declaredRegions: declared, notObserved: notObserved }); + + test('a declared region absent from notObserved renders as observed', () => { + const h = chips(row(['be'], [])); + assert.ok(h.includes('sa-chip-observed'), 'should carry the observed class'); + assert.ok(!h.includes('sa-chip-missing'), 'and not the missing one'); + }); + + test('a declared region present in notObserved renders as missing', () => { + const h = chips(row(['be'], ['be'])); + assert.ok(h.includes('sa-chip-missing')); + assert.ok(!h.includes('sa-chip-observed')); + }); + + test('a mixed row renders both colours, one chip per declared region', () => { + // The case the merge exists for: on live data a typical mixed row declares + // 8 regions of which 6 are unobserved, so the observed ones are the needle. + const h = chips(row(['be', 'eu', 'nl'], ['eu', 'nl'])); + assert.strictEqual((h.match(/sa-chip-observed/g) || []).length, 1); + assert.strictEqual((h.match(/sa-chip-missing/g) || []).length, 2); + assert.strictEqual((h.match(/ { + // Regrouping would break the operator habit of scanning for a known + // region in the position it always sits. + const h = chips(row(['be', 'eu', 'nl'], ['eu'])); + assert.ok(h.indexOf('>be<') < h.indexOf('>eu<'), 'be before eu'); + assert.ok(h.indexOf('>eu<') < h.indexOf('>nl<'), 'eu before nl'); + }); + + test('no declared regions renders an em dash, not an empty cell', () => { + // 68 of 197 rows on live data declare nothing at all; an empty cell reads + // as a rendering fault rather than as an answer. + assert.ok(chips(row([], [])).includes('—')); + }); + + test('every chip explains its own colour in a title', () => { + const h = chips(row(['be', 'eu'], ['eu'])); + assert.ok(h.includes('observed forwarding in this window')); + assert.ok(h.includes('declared, but no forwarding observed in this window')); + }); + + test('region names are HTML-escaped', () => { + const h = chips(row([''], [])); + assert.ok(!h.includes(' { + // Defensive: the server guarantees notObserved is a subset (197 of 197 + // rows checked), but the column must not grow a phantom chip if that ever + // stops holding. + const h = chips(row(['be'], ['be', 'ghost'])); + assert.strictEqual((h.match(/