feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Add three new pages completing the control-room web UI:
- Agent activity: polls /agent-activity every 5s, filterable by type/agent
- Knowledge search: FTS over /knowledge/search with snippet + entity links
- Audit trail: browseable audit log with actor/action/entity filters

Enhanced live events page with correlation-id clustering (Groups toggle).
Added fetchAgentActivity/searchKnowledge/fetchAudit to the API client.
11 nav items now cover all planned control-room views.
This commit is contained in:
2026-07-08 17:02:09 +02:00
parent cff05c0768
commit 2908b0a377
162 changed files with 7857 additions and 553 deletions

506
web/src/pages/Graph.svelte Normal file
View File

@@ -0,0 +1,506 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchBlastRadius, type GraphView, type Entity, type BlastRadiusItem, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import * as Sheet from '$lib/components/ui/sheet'
import { Skeleton } from '$lib/components/ui/skeleton'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Link {
source: string | Node
target: string | Node
type: string
}
let graph = $state<GraphView | null>(null)
let loading = $state(true)
let root = $state('')
let depth = $state(2)
let nodes = $state<Node[]>([])
let links = $state<Link[]>([])
let selected = $state<Entity | null>(null)
let blastRadius = $state<BlastRadiusItem[]>([])
let sim: Simulation<Node, Link> | null = null
let search = $state('')
let activeNodeTypes = $state<Set<string>>(new Set())
let activeRelTypes = $state<Set<string>>(new Set())
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
let view = $state({ x: 0, y: 0, k: 1 })
let svgEl = $state<SVGSVGElement | null>(null)
const width = 1200
const height = 800
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
const relColorByType = $derived.by(() => {
const map = new Map<string, string>()
const types = Array.from(new Set(links.map((l) => l.type))).sort()
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
return map
})
function relColor(type: string): string {
return relColorByType.get(type) ?? '#30363d'
}
function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
}
function endpointId(end: string | Node): string {
return typeof end === 'object' ? end.id : end
}
async function load() {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
if (!graph) return
const byId = new Map(nodes.map((n) => [n.id, n]))
const degree = new Map<string, number>()
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
nodes = graph.nodes.map((n) => {
const prev = byId.get(n.id)
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
})
links = graph.edges.map((e) => ({
source: idBySlug.get(e.source) ?? e.source,
target: idBySlug.get(e.target) ?? e.target,
type: e.type
}))
activeNodeTypes = new Set(nodes.map((n) => n.type))
activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop()
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
.force('charge', forceManyBody().strength(-240).distanceMax(400))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
.force('x', forceX(width / 2).strength(0.04))
.force('y', forceY(height / 2).strength(0.04))
.velocityDecay(0.32)
.alphaDecay(0.035)
.on('tick', () => {
nodes = [...nodes]
})
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
return () => {
unsubscribe()
sim?.stop()
}
})
onDestroy(() => sim?.stop())
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load()
}
})
async function selectNode(node: Node) {
selected = node
blastRadius = await fetchBlastRadius(node.id)
}
function rerootTo(node: Node) {
root = node.slug
selected = null
load()
}
function nodeColor(node: Node): string {
const h = graph?.health?.[node.id]
return h ? healthColor[h] : '#58a6ff'
}
function nodeRadius(node: Node): number {
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
function toggleSet(set: Set<string>, value: string): Set<string> {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
const allNodeTypes = $derived(Array.from(new Set(nodes.map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
const matchedIds = $derived.by(() => {
if (!search.trim()) return null
const q = search.trim().toLowerCase()
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
const visibleNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
const adjacency = $derived.by(() => {
const adj = new Map<string, Set<string>>()
for (const l of links) {
const s = endpointId(l.source)
const t = endpointId(l.target)
if (!adj.has(s)) adj.set(s, new Set())
if (!adj.has(t)) adj.set(t, new Set())
adj.get(s)!.add(t)
adj.get(t)!.add(s)
}
return adj
})
const focusIds = $derived.by(() => {
const focus = hoveredId ?? selected?.id ?? null
if (!focus) return null
const set = new Set<string>([focus])
for (const n of adjacency.get(focus) ?? []) set.add(n)
return set
})
function nodeOpacity(node: Node): number {
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
return 1
}
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
const s = endpointId(link.source)
const t = endpointId(link.target)
const focus = hoveredId ?? selected?.id ?? null
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
return { opacity: 0.45, emphasized: false }
}
// ─── pan / zoom / drag ───────────────────────────────────────────────
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
const rect = svgEl!.getBoundingClientRect()
return {
x: ((clientX - rect.left) / rect.width) * width,
y: ((clientY - rect.top) / rect.height) * height
}
}
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
const p = toViewBox(clientX, clientY)
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
const k = Math.min(6, Math.max(0.25, view.k * factor))
const p = toViewBox(e.clientX, e.clientY)
const wx = (p.x - view.x) / view.k
const wy = (p.y - view.y) / view.k
view = { k, x: p.x - wx * k, y: p.y - wy * k }
}
let panState: { startX: number; startY: number; viewX: number; viewY: number } | null = null
let dragState: { node: Node; moved: boolean } | null = null
function onBackgroundPointerDown(e: PointerEvent) {
if (dragState) return
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toViewBox(e.clientX, e.clientY)
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y }
}
function onNodePointerDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.25).restart()
}
function onPointerMove(e: PointerEvent) {
if (dragState) {
const w = toWorld(e.clientX, e.clientY)
dragState.node.fx = w.x
dragState.node.fy = w.y
dragState.moved = true
return
}
if (panState) {
const p = toViewBox(e.clientX, e.clientY)
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
}
}
function onPointerUp(e: PointerEvent) {
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectNode(node)
return
}
panState = null
}
function resetView() {
view = { x: 0, y: 0, k: 1 }
root = ''
search = ''
load()
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex flex-wrap items-center gap-2">
<h1 class="mr-2 text-lg font-semibold">Graph</h1>
<Input placeholder="Root entity…" bind:value={root} class="h-8 max-w-44 text-xs" onchange={load} />
<Input type="number" min="1" max="5" bind:value={depth} class="h-8 w-16 text-xs" onchange={load} />
<Input placeholder="Search / highlight…" bind:value={search} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetView}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<div class="flex-1"></div>
<span class="text-xs text-muted-foreground">
{nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}%
</span>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if allNodeTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
{#each allNodeTypes as type}
<button type="button" onclick={() => (activeNodeTypes = toggleSet(activeNodeTypes, type))}>
<Badge variant={activeNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
</button>
{/each}
</div>
{/if}
{#if allRelTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
{#each allRelTypes as type}
<button type="button" onclick={() => (activeRelTypes = toggleSet(activeRelTypes, type))}>
<Badge
variant="outline"
class="h-5 cursor-pointer px-1.5 text-[10px] {activeRelTypes.has(type) ? '' : 'opacity-35'}"
style="border-color: {relColor(type)}; color: {relColor(type)};"
>
{type}
</Badge>
</button>
{/each}
</div>
{/if}
</div>
{#if loading && !nodes.length}
<Skeleton class="min-h-0 flex-1" />
{:else}
<div class="relative min-h-0 flex-1 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
<svg
bind:this={svgEl}
viewBox="0 0 {width} {height}"
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
role="application"
aria-label="Entity graph"
onwheel={onWheel}
onpointerdown={onBackgroundPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<defs>
{#each allRelTypes as type}
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
</marker>
{/each}
</defs>
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
{@const vs = linkVisualState(link)}
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (dx / len) * tr}
{@const ey = t.y - (dy / len) * tr}
<line
x1={s.x}
y1={s.y}
x2={ex}
y2={ey}
stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})"
>
<title>{link.type}</title>
</line>
{#if vs.emphasized && view.k >= 0.7}
<text
x={(s.x + ex) / 2}
y={(s.y + ey) / 2 - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}
opacity="0.95"
paint-order="stroke"
stroke="var(--background)"
stroke-width={3 / view.k}
>
{link.type}
</text>
{/if}
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.id)}
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
{@const r = nodeRadius(node)}
{@const op = nodeOpacity(node)}
{@const isFocus = hoveredId === node.id || selected?.id === node.id}
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
<g
transform="translate({node.x},{node.y})"
opacity={op}
class="cursor-pointer"
role="button"
tabindex="0"
onpointerdown={(e) => onNodePointerDown(e, node)}
onpointerenter={() => (hoveredId = node.id)}
onpointerleave={() => (hoveredId = null)}
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
ondblclick={() => rerootTo(node)}
>
{#if isFocus || isMatch}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
<text
y={r + 12}
text-anchor="middle"
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
fill={isFocus ? '#e6edf3' : '#8b949e'}
paint-order="stroke"
stroke="#0d1117"
stroke-width={3 / view.k}
class="pointer-events-none select-none"
>
{node.slug}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</g>
</svg>
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
</div>
</div>
{/if}
</div>
<Sheet.Root open={selected !== null} onOpenChange={(open) => { if (!open) selected = null }}>
<Sheet.Content side="right" class="w-[420px] sm:max-w-[420px]">
{#if selected}
<Sheet.Header>
<Sheet.Title class="font-mono text-sm">{selected.slug}</Sheet.Title>
<Sheet.Description>{selected.type}{selected.state ? ` — ${selected.state}` : ''}</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={() => (location.hash = '#/entity/' + encodeURIComponent(selected!.slug))}>
View entity detail
</Button>
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Attributes</p>
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(selected.attributes, null, 2)}</pre>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Relationships</p>
<div class="flex flex-col gap-1">
{#each links.filter((l) => endpointId(l.source) === selected!.id || endpointId(l.target) === selected!.id) as link}
{@const sId = endpointId(link.source)}
{@const other = endpoint(sId === selected!.id ? link.target : link.source)}
<div class="flex items-center gap-1 text-xs">
<Badge variant="outline" style="border-color: {relColor(link.type)}; color: {relColor(link.type)};">
{sId === selected!.id ? '→' : '←'} {link.type}
</Badge>
<span class="font-mono">{other?.slug ?? '—'}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No relationships in this view.</p>
{/each}
</div>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Blast radius ({blastRadius.length})</p>
<div class="flex flex-col gap-1">
{#each blastRadius as item (item.entity.id)}
<div class="flex items-center justify-between text-xs">
<span class="font-mono">{item.entity.slug}</span>
<Badge variant="outline">depth {item.depth}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">No downstream dependents.</p>
{/each}
</div>
</div>
</div>
{/if}
</Sheet.Content>
</Sheet.Root>