From 36ee71d17ebe93fec6a129a1ed4b5192d89f478e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 5 May 2026 02:43:41 -0700 Subject: [PATCH] feat(#1085): fold Roles page into Analytics tab (#1088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red commit: 35e1f46b36fdda24970009bec1ac6a996ebef462 (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 `` from the top nav and the legacy ` - diff --git a/public/roles-page.js b/public/roles-page.js deleted file mode 100644 index 0cd2275d..00000000 --- a/public/roles-page.js +++ /dev/null @@ -1,119 +0,0 @@ -/* === CoreScope — roles-page.js === */ -'use strict'; - -(function () { - let refreshTimer = null; - - function init(app) { - app.innerHTML = - '
' + - ' ' + - '

Distribution of node roles across the mesh, with per-role clock-skew posture.

' + - '
Loading…
' + - '
'; - 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 = '
Failed to load roles: ' + escapeHtml(String(err.message || err)) + '
'; - } - } - - 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 = '
No roles to show.
'; - 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 = - '' + (r.okCount || 0) + ' / ' + - '' + (r.warningCount || 0) + ' / ' + - '' + (r.criticalCount || 0) + ' / ' + - '' + (r.absurdCount || 0) + ' / ' + - '' + (r.noClockCount || 0) + ''; - return '' + - '' + - '' + roleEmoji(r.role) + ' ' + escapeHtml(r.role) + '' + - '' + r.nodeCount + '' + - '' + pct + '%' + - '' + - '
' + - '
' + - '
' + - '' + - '' + (r.withSkew || 0) + '' + - '' + fmtSec(r.medianAbsSkewSec || 0) + '' + - '' + fmtSec(r.meanAbsSkewSec || 0) + '' + - '' + sevCells + '' + - ''; - }).join(''); - - container.innerHTML = - '
' + - '' + total + ' nodes across ' + roles.length + ' roles' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + rows + '' + - '
RoleCountShareDistributionw/ SkewMedian |skew|Mean |skew|Severity
'; - } - - registerPage('roles', { init: init, destroy: destroy }); -})(); diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 297c0e11..a2c997e8 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -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) ---