+### 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.
+
+
+
+Clicking an entity highlights its neighbours and opens a details panel from which you can drill further:
+
+
+
+Filters can be combined to focus on a subset of the graph:
+
+
+
### 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)));