Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,22 @@ Allows a user to save the ACL configuration to the headscale server or load a ne

<img width="1000" alt="image" src="./img/HA-ACL-Config-Load.png">

### 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.

<img width="1000" alt="visualise page" src="./img/HA-Visualise.png">

Clicking an entity highlights its neighbours and opens a details panel from which you can drill further:

<img width="1000" alt="visualise page with a user selected" src="./img/HA-Visualise-Selected.png">

Filters can be combined to focus on a subset of the graph:

<img width="1000" alt="visualise page with filtered entities" src="./img/HA-Visualise-Filtered.png">

### 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.
Expand Down
10 changes: 7 additions & 3 deletions e2e/mock-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});

Expand Down
2 changes: 1 addition & 1 deletion e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
142 changes: 142 additions & 0 deletions e2e/visualise.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Binary file added img/HA-Visualise-Filtered.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/HA-Visualise-Selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/HA-Visualise.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions src/lib/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,18 @@
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';
import { page } from '$app/state';
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();

Expand Down Expand Up @@ -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)));
</script>

<nav class="list-nav pt-0">
Expand Down
Loading