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

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
async function load() {
summary = await fetchDashboardSummary()
loading = false
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
const interval = setInterval(load, 15000)
return () => {
unsubscribe()
clearInterval(interval)
}
})
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
const degradedTypes = $derived(
summary
? [
{ label: 'Degraded', count: summary.health.degraded, tone: 'text-warning' as const },
{ label: 'Down', count: summary.health.down, tone: 'text-destructive' as const }
].filter((t) => t.count > 0)
: []
)
const maxEventRate = $derived(
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
)
function formatEventLabel(ev: OikosEvent) {
return ev.type
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
<h1 class="text-lg font-semibold">Overview</h1>
{#if loading}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each Array(4) as _}
<Skeleton class="h-28 w-full" />
{/each}
</div>
{:else if summary}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card.Root>
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
{#each Object.entries(summary.entities_by_type) as [type, count]}
<Badge variant="outline">{type}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Health</Card.Description>
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
<Badge>healthy: {summary.health.healthy}</Badge>
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
<Badge variant="destructive">down: {summary.health.down}</Badge>
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.executions_by_state) as [state, count]}
<Badge variant="outline">{state}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
</div>
{#if degradedTypes.length}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attention needed</Card.Title>
</Card.Header>
<Card.Content class="flex gap-2">
{#each degradedTypes as t}
<Badge variant={t.tone === 'text-destructive' ? 'destructive' : 'secondary'}
>{t.label}: {t.count}</Badge
>
{/each}
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Event rate (6h, 5m buckets)</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex h-16 items-end gap-0.5">
{#each summary.event_rate as bucket}
<div
class="flex-1 rounded-t bg-primary/60"
style="height: {Math.max((bucket.count / maxEventRate) * 100, 2)}%"
title="{bucket.bucket}: {bucket.count} events"
></div>
{/each}
</div>
</Card.Content>
</Card.Root>
{/if}
<Card.Root class="flex-1">
<Card.Header>
<Card.Title class="text-sm">Live event ticker</Card.Title>
</Card.Header>
<Card.Content class="p-0">
<ScrollArea class="h-64 px-4 pb-4">
<div class="flex flex-col gap-1">
{#each $liveEvents as ev (ev.id)}
<div class="flex items-center gap-2 text-xs">
<Badge variant={severityVariant(ev.severity)} class="shrink-0"
>{ev.severity}</Badge
>
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
<span>{formatEventLabel(ev)}</span>
<span class="truncate text-muted-foreground">{ev.source}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">Waiting for events…</p>
{/each}
</div>
</ScrollArea>
</Card.Content>
</Card.Root>
</div>