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(/