diff --git a/README.md b/README.md index 46c2f19..8d3b686 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,22 @@ Allows a user to save the ACL configuration to the headscale server or load a ne image +### Visualise Page (Experimental) + +Renders an entity-relationship style graph of Users, Groups, Tags and Nodes, with ACL policies drawn as connecting arrows. Use the sidebar to toggle which entity kinds and relation types are shown, search by label, and click any entity to drill down into its direct relationships. + +This page is marked **Experimental** as it is still being refined — complex ACLs (for example `autogroup:*` or CIDR endpoints) are not yet represented as distinct graph nodes. + +visualise page + +Clicking an entity highlights its neighbours and opens a details panel from which you can drill further: + +visualise page with a user selected + +Filters can be combined to focus on a subset of the graph: + +visualise page with filtered entities + ### Settings Page Store API URL and API Key information in the browser's LocalStorage. Set API refresh interval (how frequently users, preauth keys, nodes, and routes are updated) and toggle console debugging. diff --git a/e2e/mock-api.mjs b/e2e/mock-api.mjs index 7f3a486..41a1e1e 100644 --- a/e2e/mock-api.mjs +++ b/e2e/mock-api.mjs @@ -152,10 +152,14 @@ function resetState() { resetState(); const policy = JSON.stringify({ - groups: { 'group:admin': ['alice'] }, + groups: { 'group:admin': ['alice'], 'group:dev': ['bob'] }, hosts: {}, - acls: [{ action: 'accept', src: ['group:admin'], dst: ['*:*'] }], - tagOwners: {}, + acls: [ + { action: 'accept', src: ['group:admin'], dst: ['*:*'] }, + { action: 'accept', src: ['tag:server'], dst: ['tag:infra:22'] }, + { action: 'accept', src: ['bob'], dst: ['tag:server:443'] }, + ], + tagOwners: { 'tag:server': ['group:admin'], 'tag:infra': ['alice'] }, ssh: [], }); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index d197bb5..69b0487 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -272,7 +272,7 @@ test.describe('direct URL access (no white screen)', () => { await seedAuth(page); }); - const routes = ['/', '/nodes/', '/users/', '/tags/', '/settings/', '/deploy/', '/routes/', '/acls/']; + const routes = ['/', '/nodes/', '/users/', '/tags/', '/settings/', '/deploy/', '/routes/', '/acls/', '/visualise/']; for (const route of routes) { test(`${route} renders content (not blank)`, async ({ page }) => { diff --git a/e2e/visualise.spec.ts b/e2e/visualise.spec.ts new file mode 100644 index 0000000..2d1591d --- /dev/null +++ b/e2e/visualise.spec.ts @@ -0,0 +1,142 @@ +import { test, expect, type Page } from '@playwright/test'; + +const MOCK_URL = 'http://localhost:8081'; +const API_KEY = 'test-api-key'; + +async function seedAuth(page: Page) { + await page.goto('/'); + await page.evaluate( + ([url, key]) => { + localStorage.setItem('apiUrl', JSON.stringify(url)); + localStorage.setItem('apiKey', JSON.stringify(key)); + localStorage.setItem( + 'apiKeyInfo', + JSON.stringify({ + authorized: true, + expires: new Date(Date.now() + 86_400_000 * 90).toISOString(), + informedUnauthorized: false, + informedExpiringSoon: false, + }), + ); + }, + [MOCK_URL, API_KEY], + ); + await page.reload(); + await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 }); +} + +test.describe('visualise page', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page); + await page.goto('/visualise/'); + // wait for SVG (policy must have loaded too) + await page.locator('[data-testid="visualise-svg"]').waitFor({ timeout: 10000 }); + }); + + test('is reachable from the sidebar navigation', async ({ page }) => { + await page.goto('/'); + const link = page.getByRole('link', { name: /visualise/i }).first(); + await expect(link).toBeVisible({ timeout: 10000 }); + await link.click(); + await expect(page).toHaveURL(/\/visualise\/?/); + await expect(page.getByRole('img', { name: /acl node graph/i })).toBeVisible({ + timeout: 10000, + }); + }); + + test('shows an Experimental banner', async ({ page }) => { + await expect(page.locator('[data-testid="experimental-banner"]')).toBeVisible(); + await expect(page.locator('[data-testid="experimental-banner"]')).toContainText( + /experimental/i, + ); + }); + + test('renders a node per user, node, group, tag and the wildcard', async ({ page }) => { + const kinds = await page + .locator('[data-testid="graph-node"]') + .evaluateAll((els) => els.map((e) => (e as HTMLElement).dataset.nodeKind)); + expect(kinds).toEqual(expect.arrayContaining(['user', 'node', 'group', 'tag', 'wildcard'])); + }); + + test('renders ACL accept edges', async ({ page }) => { + await expect(page.locator('[data-testid="edge-acl-accept"]').first()).toBeVisible(); + const count = await page.locator('[data-testid="edge-acl-accept"]').count(); + expect(count).toBeGreaterThan(0); + }); + + test('search input filters nodes by label', async ({ page }) => { + const allCount = await page.locator('[data-testid="graph-node"]').count(); + await page.locator('[data-testid="visualise-search"]').fill('alice'); + // Give Svelte a tick to apply reactive updates + await expect + .poll(async () => await page.locator('[data-testid="graph-node"]').count()) + .toBeLessThan(allCount); + // alice user should still be present + await expect(page.locator('[data-testid="graph-node"][data-node-id="user:alice"]')) + .toBeVisible(); + }); + + test('kind toggle hides entities of that kind', async ({ page }) => { + await expect( + page.locator('[data-testid="graph-node"][data-node-kind="tag"]').first(), + ).toBeVisible(); + await page.locator('[data-testid="kind-toggle-tag"]').uncheck(); + await expect(page.locator('[data-testid="graph-node"][data-node-kind="tag"]')).toHaveCount(0); + }); + + test('relation toggle hides edges of that kind', async ({ page }) => { + await expect(page.locator('[data-testid="edge-acl-accept"]').first()).toBeVisible(); + await page.locator('[data-testid="edge-toggle-acl-accept"]').uncheck(); + await expect(page.locator('[data-testid="edge-acl-accept"]')).toHaveCount(0); + }); + + test('reset filters restores all entities and relations', async ({ page }) => { + await page.locator('[data-testid="kind-toggle-tag"]').uncheck(); + await page.locator('[data-testid="edge-toggle-acl-accept"]').uncheck(); + await page.locator('[data-testid="visualise-search"]').fill('zzzzz'); + await expect(page.locator('[data-testid="visualise-empty"]')).toBeVisible(); + + await page.locator('[data-testid="visualise-reset"]').click(); + await expect(page.locator('[data-testid="visualise-svg"]')).toBeVisible(); + await expect( + page.locator('[data-testid="graph-node"][data-node-kind="tag"]').first(), + ).toBeVisible(); + await expect(page.locator('[data-testid="edge-acl-accept"]').first()).toBeVisible(); + }); + + test('clicking a node opens the details panel and highlights neighbours', async ({ page }) => { + // Before selection, no-selection hint should be visible + await expect(page.locator('[data-testid="visualise-no-selection"]')).toBeVisible(); + + // click alice + await page.locator('[data-testid="graph-node"][data-node-id="user:alice"]').click(); + const details = page.locator('[data-testid="visualise-details"]'); + await expect(details).toContainText('alice'); + await expect(details.getByText('Connected to:')).toBeVisible(); + // alice should be connected to at least one other entity (her laptop, her group, etc.) + const chips = page.locator('[data-testid="neighbour-chip"]'); + await expect(chips.first()).toBeVisible(); + expect(await chips.count()).toBeGreaterThan(0); + }); + + test('drill-down: clicking a neighbour chip changes selection', async ({ page }) => { + await page.locator('[data-testid="graph-node"][data-node-id="user:alice"]').click(); + const firstChip = page.locator('[data-testid="neighbour-chip"]').first(); + const chipText = (await firstChip.innerText()).trim(); + await firstChip.click(); + const details = page.locator('[data-testid="visualise-details"]'); + await expect(details).toContainText(chipText); + }); + + test('clear selection button deselects', async ({ page }) => { + await page.locator('[data-testid="graph-node"][data-node-id="user:alice"]').click(); + await expect(page.locator('[data-testid="visualise-clear-selection"]')).toBeVisible(); + await page.locator('[data-testid="visualise-clear-selection"]').click(); + await expect(page.locator('[data-testid="visualise-no-selection"]')).toBeVisible(); + }); + + test('shows empty state when filters match nothing', async ({ page }) => { + await page.locator('[data-testid="visualise-search"]').fill('no-such-entity-xyz'); + await expect(page.locator('[data-testid="visualise-empty"]')).toBeVisible(); + }); +}); diff --git a/img/HA-Visualise-Filtered.png b/img/HA-Visualise-Filtered.png new file mode 100644 index 0000000..fa03cf3 Binary files /dev/null and b/img/HA-Visualise-Filtered.png differ diff --git a/img/HA-Visualise-Selected.png b/img/HA-Visualise-Selected.png new file mode 100644 index 0000000..dc589fb Binary files /dev/null and b/img/HA-Visualise-Selected.png differ diff --git a/img/HA-Visualise.png b/img/HA-Visualise.png new file mode 100644 index 0000000..a36caa6 Binary files /dev/null and b/img/HA-Visualise.png differ diff --git a/src/lib/Navigation.svelte b/src/lib/Navigation.svelte index 33a3373..bcbea23 100644 --- a/src/lib/Navigation.svelte +++ b/src/lib/Navigation.svelte @@ -11,6 +11,7 @@ import RawMdiSettings from '~icons/mdi/settings'; import RawMdiTag from '~icons/mdi/tag'; import RawMdiKey from '~icons/mdi/key'; + import RawMdiGraphOutline from '~icons/mdi/graph-outline'; // import { ApiKeyInfoStore, ApiKeyStore, hasValidApi } from './Stores'; import { onMount, type Component } from 'svelte'; @@ -18,12 +19,10 @@ import { App } from '$lib/States.svelte'; type NavigationProps = { - labels?: boolean - } + labels?: boolean; + }; - let { - labels = true, - }: NavigationProps = $props() + let { labels = true }: NavigationProps = $props(); const DrawerStore = getDrawerStore(); @@ -52,10 +51,11 @@ { path: '/deploy', name: 'Deploy', logo: RawMdiHomeGroupPlus }, { path: '/routes', name: 'Routes', logo: RawMdiRouter }, { path: '/acls', name: 'ACLs', logo: RawMdiSecurity }, + { path: '/visualise', name: 'Visualise', logo: RawMdiGraphOutline }, { path: '/settings', name: 'Settings', logo: RawMdiSettings }, ].filter((p) => p != undefined); - const pages = $derived.by(() => App.hasValidApi ? allPages : allPages.slice(-1)); + const pages = $derived.by(() => (App.hasValidApi ? allPages : allPages.slice(-1)));