feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
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:
173
web/src/pages/Overview.svelte
Normal file
173
web/src/pages/Overview.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user