mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 20:24:43 +00:00
Red commit: 35e1f46b36 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25367951904)
Fixes #1085
## What changed
The "Roles" page is a stats slice — counts + breakdown by node role. It
belongs in Analytics, not as a top-level nav peer of Map / Channels /
Nodes. This PR folds it in and frees nav space.
### Frontend
- `public/index.html` — drop the `<a data-route="roles">` from the top
nav and the legacy `<script src="roles-page.js">` tag.
- `public/app.js` — backward-compat redirect added at the top of
`navigate()`: `#/roles` (and `#/roles?…`, `#/roles/…`) →
`#/analytics?tab=roles`. Old bookmarks keep working.
- `public/analytics.js` — new `<button data-tab="roles">Roles</button>`
in the tab strip + `case 'roles': await renderRolesTab(el)` in
`renderTab()`. The render function (distribution table + per-role
clock-skew posture) is moved over verbatim from the old standalone page.
- `public/roles-page.js` — deleted; its only consumer was the
now-removed route.
The Analytics tab strip already supports deep-linking via `?tab=…`, so
the redirect target is reached and the Roles tab activates on initial
load with no extra wiring.
## Acceptance criteria (from #1085)
- [x] No "Roles" link in top nav
- [x] Analytics page has a "Roles" tab with the same content
- [x] Old `#/roles` URLs redirect (don't 404)
- [x] Frees nav space for higher-priority pages
## Tests
E2E assertion added: test-e2e-playwright.js:2386 (3 assertions covering
all 3 acceptance criteria).
Also replaces the legacy "Roles page renders distribution table" E2E
test (added for issue #818), which assumed a standalone `/#/roles` SPA
page. The replacement assertions exercise the new fold-in path: nav
scan, Analytics tab click, redirect verification.
## TDD trail
- Red commit `35e1f46` — adds the three failing E2E assertions before
any production change. CI run on the red branch (linked above) shows the
assertions fail when production code hasn't been updated.
- Green commit `2b5715d` — minimal production change to satisfy the
assertions: nav link removed, redirect added, Roles tab + render
function moved into Analytics.
---------
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
This commit is contained in:
+107
-2
@@ -4,7 +4,29 @@
|
||||
(function () {
|
||||
let _analyticsData = {};
|
||||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; }
|
||||
|
||||
// #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not
|
||||
// re-allocated per render).
|
||||
function _rolesEmoji(role) {
|
||||
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
|
||||
return '•';
|
||||
}
|
||||
function _rolesFmtSec(v) {
|
||||
if (!v && v !== 0) return '—';
|
||||
var abs = Math.abs(v);
|
||||
if (abs < 1) return v.toFixed(2) + 's';
|
||||
if (abs < 60) return v.toFixed(1) + 's';
|
||||
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
|
||||
return (v / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
// #1085 — auto-refresh timer for the Roles tab. Started when the Roles
|
||||
// tab is rendered, cleared on tab switch and destroy.
|
||||
var _rolesRefreshTimer = null;
|
||||
function _stopRolesRefresh() {
|
||||
if (_rolesRefreshTimer) { clearInterval(_rolesRefreshTimer); _rolesRefreshTimer = null; }
|
||||
}
|
||||
|
||||
// --- Status color helpers (read from CSS variables for theme support) ---
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
@@ -98,6 +120,10 @@
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="rf-health">RF Health</button>
|
||||
<button class="tab-btn" data-tab="clock-health">Clock Health</button>
|
||||
<!-- #1085 — Roles tab folded in from former /#/roles standalone page.
|
||||
Placed after Clock Health (clock-skew posture is shown per-role
|
||||
inside this tab) and before Prefix Tool (utility tabs trail). -->
|
||||
<button class="tab-btn" data-tab="roles">Roles</button>
|
||||
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +159,8 @@
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_currentTab = btn.dataset.tab;
|
||||
// #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch.
|
||||
if (_currentTab !== 'roles') _stopRolesRefresh();
|
||||
_updateAnalyticsUrl();
|
||||
renderTab(_currentTab);
|
||||
});
|
||||
@@ -235,6 +263,7 @@
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
case 'rf-health': await renderRFHealthTab(el); break;
|
||||
case 'clock-health': await renderClockHealthTab(el); break;
|
||||
case 'roles': await renderRolesTab(el); break;
|
||||
case 'prefix-tool': await renderPrefixTool(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
@@ -2203,7 +2232,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -3746,5 +3775,81 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
}
|
||||
}
|
||||
|
||||
// #1085 — Roles tab (folded in from former /#/roles page).
|
||||
// Renders distribution of node roles + per-role clock-skew posture.
|
||||
// Auto-refreshes every 60s while the Roles tab is active (matches the
|
||||
// behavior of the former standalone roles-page.js).
|
||||
async function renderRolesTab(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading roles…</div>';
|
||||
await _renderRolesTabBody(el);
|
||||
// (Re)start the 60s auto-refresh.
|
||||
_stopRolesRefresh();
|
||||
_rolesRefreshTimer = setInterval(function () {
|
||||
// Bail if the user navigated away from the Roles tab.
|
||||
if (_currentTab !== 'roles') { _stopRolesRefresh(); return; }
|
||||
var cur = document.getElementById('analyticsContent');
|
||||
if (!cur) { _stopRolesRefresh(); return; }
|
||||
_renderRolesTabBody(cur);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
async function _renderRolesTabBody(el) {
|
||||
try {
|
||||
var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF });
|
||||
var roles = (data && data.roles) || [];
|
||||
var total = (data && data.totalNodes) || 0;
|
||||
if (!roles.length) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
|
||||
return;
|
||||
}
|
||||
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
|
||||
var rows = roles.map(function (r) {
|
||||
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
|
||||
var barW = Math.round((r.nodeCount / maxCount) * 100);
|
||||
var sevCells =
|
||||
'<span title="OK (skew < 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
|
||||
'<span title="Warning (5min – 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
|
||||
'<span title="Critical (1h – 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
|
||||
'<span title="Absurd (> 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
|
||||
'<span title="No clock (> 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
|
||||
return '' +
|
||||
'<tr data-role="' + esc(r.role) + '">' +
|
||||
'<td>' + _rolesEmoji(r.role) + ' <strong>' + esc(r.role) + '</strong></td>' +
|
||||
'<td style="text-align:right">' + r.nodeCount + '</td>' +
|
||||
'<td style="text-align:right">' + pct + '%</td>' +
|
||||
'<td style="min-width:140px">' +
|
||||
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
|
||||
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + _rolesFmtSec(r.medianAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + _rolesFmtSec(r.meanAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="white-space:nowrap">' + sevCells + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
el.innerHTML =
|
||||
'<p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
|
||||
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
|
||||
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
|
||||
'</div>' +
|
||||
'<table id="rolesTable" class="data-table analytics-table" style="width:100%">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Role</th>' +
|
||||
'<th style="text-align:right">Count</th>' +
|
||||
'<th style="text-align:right">Share</th>' +
|
||||
'<th>Distribution</th>' +
|
||||
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
|
||||
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
|
||||
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
|
||||
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="text-center" style="color:var(--status-red);padding:40px">Failed to load roles: ' + esc(String(err.message || err)) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -721,6 +721,14 @@ function navigate() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backward-compat redirect: #/roles → #/analytics?tab=roles (issue #1085).
|
||||
// The Roles page was folded into the Analytics tab strip; old links and
|
||||
// bookmarks must keep working.
|
||||
if (location.hash === '#/roles' || location.hash.startsWith('#/roles?') || location.hash.startsWith('#/roles/')) {
|
||||
location.hash = '#/analytics?tab=roles';
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = location.hash.replace('#/', '') || 'packets';
|
||||
const route = hash.split('?')[0];
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
|
||||
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
|
||||
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
|
||||
<a href="#/roles" class="nav-link" data-route="roles">Roles</a>
|
||||
<a href="#/tools" class="nav-link" data-route="tools">Tools</a>
|
||||
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
|
||||
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
|
||||
@@ -126,7 +125,6 @@
|
||||
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles-page.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/* === CoreScope — roles-page.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
let refreshTimer = null;
|
||||
|
||||
function init(app) {
|
||||
app.innerHTML =
|
||||
'<div class="roles-page" data-page="roles">' +
|
||||
' <div class="page-header">' +
|
||||
' <h2>Roles</h2>' +
|
||||
' <button class="btn-icon" data-action="roles-refresh" title="Refresh" aria-label="Refresh roles">🔄</button>' +
|
||||
' </div>' +
|
||||
' <p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
|
||||
' <div id="rolesContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>' +
|
||||
'</div>';
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action="roles-refresh"]');
|
||||
if (btn) load();
|
||||
});
|
||||
load();
|
||||
refreshTimer = setInterval(load, 60000);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var container = document.getElementById('rolesContent');
|
||||
if (!container) return;
|
||||
try {
|
||||
var resp = await fetch('/api/analytics/roles');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
render(container, data);
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="text-center" style="padding:40px;color:var(--color-error,#c00)">Failed to load roles: ' + escapeHtml(String(err.message || err)) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function fmtSec(v) {
|
||||
if (!v && v !== 0) return '—';
|
||||
var abs = Math.abs(v);
|
||||
if (abs < 1) return v.toFixed(2) + 's';
|
||||
if (abs < 60) return v.toFixed(1) + 's';
|
||||
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
|
||||
return (v / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function roleEmoji(role) {
|
||||
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
|
||||
return '•';
|
||||
}
|
||||
|
||||
function render(container, data) {
|
||||
var roles = (data && data.roles) || [];
|
||||
var total = (data && data.totalNodes) || 0;
|
||||
if (roles.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
|
||||
return;
|
||||
}
|
||||
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
|
||||
|
||||
var rows = roles.map(function (r) {
|
||||
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
|
||||
var barW = Math.round((r.nodeCount / maxCount) * 100);
|
||||
var sevCells =
|
||||
'<span title="OK (skew < 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
|
||||
'<span title="Warning (5min – 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
|
||||
'<span title="Critical (1h – 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
|
||||
'<span title="Absurd (> 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
|
||||
'<span title="No clock (> 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
|
||||
return '' +
|
||||
'<tr data-role="' + escapeHtml(r.role) + '">' +
|
||||
'<td>' + roleEmoji(r.role) + ' <strong>' + escapeHtml(r.role) + '</strong></td>' +
|
||||
'<td style="text-align:right">' + r.nodeCount + '</td>' +
|
||||
'<td style="text-align:right">' + pct + '%</td>' +
|
||||
'<td style="min-width:140px">' +
|
||||
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
|
||||
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + fmtSec(r.medianAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + fmtSec(r.meanAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="white-space:nowrap">' + sevCells + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
|
||||
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
|
||||
'</div>' +
|
||||
'<table id="rolesTable" class="data-table" style="width:100%">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Role</th>' +
|
||||
'<th style="text-align:right">Count</th>' +
|
||||
'<th style="text-align:right">Share</th>' +
|
||||
'<th>Distribution</th>' +
|
||||
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
|
||||
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
|
||||
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
|
||||
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
registerPage('roles', { init: init, destroy: destroy });
|
||||
})();
|
||||
+46
-33
@@ -2382,40 +2382,53 @@ async function run() {
|
||||
assert(hasHslPolyline, 'At least one live-packet-trace polyline should have hsl() stroke color from hash');
|
||||
});
|
||||
|
||||
// --- Roles page (issue #818): renders distribution + per-role skew ---
|
||||
await test('Roles page renders distribution table from /api/analytics/roles', async () => {
|
||||
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
|
||||
// Wait for roles-page.js to mount and the table to render.
|
||||
await page.waitForSelector('.roles-page[data-page="roles"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
var el = document.querySelector('#rolesContent');
|
||||
if (!el) return false;
|
||||
// Either the table renders, or the empty-state message appears.
|
||||
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load/.test(el.textContent);
|
||||
}, { timeout: 10000 });
|
||||
var hasTable = await page.$('#rolesTable');
|
||||
if (!hasTable) {
|
||||
// Empty fixture is acceptable; at least the page must NOT show the
|
||||
// generic "Page not yet implemented" placeholder (the bug we fixed).
|
||||
var bodyText = await page.evaluate(() => document.body.innerText);
|
||||
assert(!/Page not yet implemented/i.test(bodyText), 'Roles page must not show "Page not yet implemented" placeholder');
|
||||
return;
|
||||
}
|
||||
// With data: header columns and at least one body row must be present.
|
||||
var headers = await page.$$eval('#rolesTable thead th', ths => ths.map(t => t.textContent.trim()));
|
||||
assert(headers.includes('Role'), 'Roles table must have a Role column, got ' + JSON.stringify(headers));
|
||||
assert(headers.some(h => /Median/.test(h)), 'Roles table must have a Median |skew| column, got ' + JSON.stringify(headers));
|
||||
var rowCount = await page.$$eval('#rolesTable tbody tr', rs => rs.length);
|
||||
assert(rowCount > 0, 'Roles table should have at least one row when API returns data');
|
||||
// API contract sanity check: shape matches the page's expectations.
|
||||
var apiOk = await page.evaluate(async () => {
|
||||
var r = await fetch('/api/analytics/roles');
|
||||
if (!r.ok) return { ok: false, status: r.status };
|
||||
var j = await r.json();
|
||||
return { ok: true, hasRoles: Array.isArray(j.roles), hasTotal: typeof j.totalNodes === 'number' };
|
||||
// --- Roles folded into Analytics (issue #1085) ---
|
||||
// Acceptance criteria:
|
||||
// 1. "Roles" link does NOT exist in top nav
|
||||
// 2. Analytics page has a "Roles" tab with the same content
|
||||
// 3. Old #/roles URL redirects to #/analytics?tab=roles
|
||||
await test('Roles fold-in (#1085): no "Roles" link in top nav', async () => {
|
||||
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav.top-nav .nav-links', { timeout: 10000 });
|
||||
var hasRolesLink = await page.evaluate(() => {
|
||||
var links = document.querySelectorAll('nav.top-nav .nav-links a.nav-link[data-route="roles"]');
|
||||
return links.length > 0;
|
||||
});
|
||||
assert(apiOk.ok, '/api/analytics/roles must return 200, got ' + JSON.stringify(apiOk));
|
||||
assert(apiOk.hasRoles && apiOk.hasTotal, '/api/analytics/roles response must have {roles:[], totalNodes:n}, got ' + JSON.stringify(apiOk));
|
||||
assert(!hasRolesLink, 'Top nav must NOT contain a "Roles" link (data-route="roles")');
|
||||
});
|
||||
|
||||
await test('Roles fold-in (#1085): Analytics page has a "Roles" tab', async () => {
|
||||
await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#analyticsTabs', { timeout: 10000 });
|
||||
var rolesTab = await page.$('#analyticsTabs .tab-btn[data-tab="roles"]');
|
||||
assert(rolesTab, 'Analytics tabs must include a [data-tab="roles"] button');
|
||||
var label = await page.evaluate(el => el.textContent.trim(), rolesTab);
|
||||
assert(/roles/i.test(label), 'Roles tab label must say "Roles", got ' + JSON.stringify(label));
|
||||
// Click the tab and verify the same Roles content renders.
|
||||
await page.click('#analyticsTabs [data-tab="roles"]');
|
||||
// Wait for the tab to settle on real content: either the populated
|
||||
// table (#rolesTable) or the explicit empty-state. "Loading" and
|
||||
// "Failed to load" are NOT acceptable terminal states (#1085 polish).
|
||||
await page.waitForFunction(() => {
|
||||
var el = document.getElementById('analyticsContent');
|
||||
if (!el) return false;
|
||||
if (el.querySelector('#rolesTable')) return true;
|
||||
if (/No roles to show/i.test(el.textContent)) return true;
|
||||
return false;
|
||||
}, { timeout: 10000 });
|
||||
var bodyText = await page.evaluate(() => document.getElementById('analyticsContent').innerText);
|
||||
assert(!/Page not yet implemented/i.test(bodyText), 'Roles tab must not show SPA placeholder');
|
||||
assert(!/Failed to load/i.test(bodyText), 'Roles tab must not show "Failed to load" terminal state');
|
||||
assert(!/Loading…/.test(bodyText), 'Roles tab must not be stuck on "Loading…"');
|
||||
});
|
||||
|
||||
await test('Roles fold-in (#1085): old #/roles URL redirects to #/analytics?tab=roles', async () => {
|
||||
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
|
||||
// Allow router to process the redirect.
|
||||
await page.waitForFunction(() => /^#\/analytics(\?|$)/.test(location.hash), { timeout: 5000 });
|
||||
var hash = await page.evaluate(() => location.hash);
|
||||
assert(/^#\/analytics\?/.test(hash), 'After visiting #/roles, hash must redirect to #/analytics?…, got ' + hash);
|
||||
assert(/[?&]tab=roles(&|$)/.test(hash), 'Redirect must carry tab=roles, got ' + hash);
|
||||
});
|
||||
|
||||
// --- Geofilter draft: save/load/download buttons (issue #819, rule 18) ---
|
||||
|
||||
Reference in New Issue
Block a user