feat(web): fold Events/Agent/Audit into EntityDetail; tag agent_activity with entity_id
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Events, Agent, and Audit were standalone read-only pages that never
cross-referenced the entity they related to. Fold them into EntityDetail
as entity-scoped cards (Agent activity, Audit trail) alongside the
existing Signals/Executions/Knowledge cards, and give the Signals card
real Ack/Mute/Resolve actions. Signals stays a standalone page since
it's the only one with cross-entity triage value (badge count, actions).

Also fixes the underlying reason those new cards would've stayed empty:
agent_activity rows were never tagged with entity_id at insert time
(cmd/nomos/store.go, internal/mcp/server.go), even though the column
and the API filter both support it. Added a best-effort resolver that
checks common tool-arg keys (target, entity_slug, slug, ...) against
the entities table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 08:38:19 +02:00
parent c8b479d565
commit de126daf43
8 changed files with 237 additions and 501 deletions

View File

@@ -1,138 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fetchAgentActivity, type AgentActivity } from '$lib/api'
import { debounce } from '$lib/utils'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import * as Select from '$lib/components/ui/select'
import { ScrollArea } from '$lib/components/ui/scroll-area'
let activities = $state<AgentActivity[]>([])
let typeFilter = $state('all')
let agentFilter = $state('')
async function load() {
activities = await fetchAgentActivity({
activity_type: typeFilter !== 'all' ? typeFilter : undefined,
agent_id: agentFilter || undefined
})
}
const loadDebounced = debounce(load, 300)
onMount(() => {
load()
const unsubscribe = subscribeEvents()
const interval = setInterval(load, 5000)
return () => {
unsubscribe()
clearInterval(interval)
}
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('execution.') || ev.type.startsWith('approval.')) load()
})
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
if (type === 'tool_call') return 'default'
if (type === 'decision') return 'secondary'
if (type === 'escalation') return 'secondary'
return 'outline'
}
function successVariant(success?: boolean | null): 'default' | 'destructive' | 'outline' {
if (success === true) return 'default'
if (success === false) return 'destructive'
return 'outline'
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Agent activity</h1>
<span class="text-xs text-muted-foreground">{activities.length} entries</span>
</div>
<div class="flex gap-2">
<Input placeholder="Filter by agent_id…" bind:value={agentFilter} class="max-w-xs" oninput={loadDebounced} />
<Select.Root type="single" bind:value={typeFilter} onvalueChange={() => load()}>
<Select.Trigger class="w-40">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
<Select.Item value="tool_call">Tool call</Select.Item>
<Select.Item value="reasoning">Reasoning</Select.Item>
<Select.Item value="decision">Decision</Select.Item>
<Select.Item value="mcp_query">MCP query</Select.Item>
<Select.Item value="escalation">Escalation</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" onclick={load}>Refresh</Button>
</div>
<div class="flex-1 overflow-hidden rounded-md border">
<ScrollArea class="h-full">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-36">Time</Table.Head>
<Table.Head>Agent</Table.Head>
<Table.Head class="w-24">Type</Table.Head>
<Table.Head>Tool / Entity</Table.Head>
<Table.Head>Summary</Table.Head>
<Table.Head class="w-16">Status</Table.Head>
<Table.Head class="w-20">Duration</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each activities as a (a.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs text-muted-foreground"
>{new Date(a.ts).toLocaleString()}</Table.Cell
>
<Table.Cell class="font-mono text-xs">{a.agent_id}</Table.Cell>
<Table.Cell><Badge variant={typeVariant(a.activity_type)}>{a.activity_type}</Badge></Table.Cell>
<Table.Cell class="text-xs">
{#if a.tool_name}
<span class="font-mono">{a.tool_name}</span>
{:else if a.entity_id}
<span class="font-mono text-muted-foreground">{a.entity_id}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="max-w-64 truncate text-xs text-muted-foreground"
>{a.input_summary ?? a.output_summary ?? '—'}</Table.Cell
>
<Table.Cell>
{#if a.success !== undefined && a.success !== null}
<Badge variant={successVariant(a.success)}>{a.success ? 'ok' : 'fail'}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">
{#if a.duration_ms}
{(a.duration_ms / 1000).toFixed(1)}s
{:else}
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={7} class="text-center text-muted-foreground">No agent activity yet.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</ScrollArea>
</div>
</div>

View File

@@ -1,134 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchAudit, type AuditEntry } from '$lib/api'
import { debounce } from '$lib/utils'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
let entries = $state<AuditEntry[]>([])
let actorFilter = $state('all')
let actionFilter = $state('')
let entityFilter = $state('')
async function load() {
entries = await fetchAudit({
actor_type: actorFilter !== 'all' ? actorFilter : undefined,
action: actionFilter || undefined,
entity_id: entityFilter || undefined
})
}
const loadDebounced = debounce(load, 300)
onMount(() => {
load()
const interval = setInterval(load, 30000)
return () => clearInterval(interval)
})
function actorVariant(actor: string): 'default' | 'secondary' | 'outline' {
if (actor === 'agent') return 'secondary'
if (actor === 'operator') return 'default'
if (actor === 'scheduler') return 'outline'
return 'outline'
}
function methodBadge(method?: string | null): string {
if (!method) return ''
if (method === 'GET' || method === 'POST' || method === 'PATCH' || method === 'DELETE') return method
return ''
}
function statusVariant(code?: number | null): 'default' | 'destructive' | 'secondary' | 'outline' {
if (!code) return 'outline'
if (code >= 200 && code < 300) return 'default'
if (code >= 400 && code < 500) return 'secondary'
if (code >= 500) return 'destructive'
return 'outline'
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Audit trail</h1>
<span class="text-xs text-muted-foreground">{entries.length} entries</span>
</div>
<div class="flex gap-2">
<Select.Root type="single" bind:value={actorFilter} onvalueChange={() => load()}>
<Select.Trigger class="w-36">
{actorFilter === 'all' ? 'All actors' : actorFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All actors</Select.Item>
<Select.Item value="agent">Agent</Select.Item>
<Select.Item value="operator">Operator</Select.Item>
<Select.Item value="system">System</Select.Item>
<Select.Item value="scheduler">Scheduler</Select.Item>
</Select.Content>
</Select.Root>
<Input placeholder="Action…" bind:value={actionFilter} class="max-w-32" oninput={loadDebounced} />
<Input placeholder="Entity…" bind:value={entityFilter} class="max-w-48" oninput={loadDebounced} />
<Button variant="outline" onclick={load}>Refresh</Button>
</div>
<div class="flex-1 overflow-hidden rounded-md border">
<ScrollArea class="h-full">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-36">Time</Table.Head>
<Table.Head class="w-24">Actor</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Entity</Table.Head>
<Table.Head class="w-20">Method</Table.Head>
<Table.Head class="w-16">Code</Table.Head>
<Table.Head>Correlation</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each entries as entry (entry.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs text-muted-foreground"
>{new Date(entry.ts).toLocaleString()}</Table.Cell
>
<Table.Cell>
<div class="flex flex-col gap-0.5">
<Badge variant={actorVariant(entry.actor_type)}>{entry.actor_type}</Badge>
{#if entry.actor_id}
<span class="font-mono text-xs text-muted-foreground">{entry.actor_id}</span>
{/if}
</div>
</Table.Cell>
<Table.Cell class="text-xs">{entry.action}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.entity_id ?? '—'}</Table.Cell>
<Table.Cell>
{#if methodBadge(entry.method)}
<Badge variant="outline">{methodBadge(entry.method)}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if entry.status_code}
<Badge variant={statusVariant(entry.status_code)}>{entry.status_code}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.correlation_id ?? '—'}</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={7} class="text-center text-muted-foreground">No audit entries.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</ScrollArea>
</div>
</div>

View File

@@ -1,180 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEvents } from '$lib/api'
import { debounce } from '$lib/utils'
import { liveEvents, connectionState, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
let history = $state<OikosEvent[]>([])
let paused = $state(false)
let typeFilter = $state('')
let severityFilter = $state('')
let groupByCorrelation = $state(false)
let expandedCorrelations = $state<Set<string>>(new Set())
async function loadHistory() {
history = await fetchEvents({ type: typeFilter || undefined, severity: severityFilter || undefined })
}
const loadHistoryDebounced = debounce(loadHistory, 300)
onMount(() => {
loadHistory()
const unsubscribe = subscribeEvents()
return unsubscribe
})
const feed = $derived.by(() => {
if (paused) return history
const seen = new Set(history.map((e) => e.id))
const merged = [...$liveEvents.filter((e) => !seen.has(e.id)), ...history]
return merged
.filter((e) => (!typeFilter || e.type.startsWith(typeFilter)) && (!severityFilter || e.severity === severityFilter))
.slice(0, 300)
})
const clustered = $derived.by(() => {
if (!groupByCorrelation) return null
const groups: { corr: string | null; events: OikosEvent[]; latest: number }[] = []
const seen = new Map<string | null, OikosEvent[]>()
for (const ev of feed) {
const key = ev.correlation_id ?? null
if (!seen.has(key)) seen.set(key, [])
seen.get(key)!.push(ev)
}
for (const [corr, events] of seen) {
groups.push({ corr, events, latest: Math.max(...events.map((e) => e.id)) })
}
groups.sort((a, b) => b.latest - a.latest)
return groups
})
function toggleCorrelation(corr: string | null) {
const key = corr ?? '__none'
expandedCorrelations = new Set(expandedCorrelations)
if (expandedCorrelations.has(key)) {
expandedCorrelations.delete(key)
} else {
expandedCorrelations.add(key)
}
}
function correlationLabel(corr: string | null): string {
if (!corr) return 'ungrouped'
return corr.slice(0, 12)
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
function mostSevere(events: OikosEvent[]): 'info' | 'warning' | 'critical' {
if (events.some((e) => e.severity === 'critical')) return 'critical'
if (events.some((e) => e.severity === 'warning')) return 'warning'
return 'info'
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Live event feed</h1>
<span class="text-xs text-muted-foreground">
stream: {$connectionState}
</span>
</div>
<div class="flex items-center gap-2">
<Input placeholder="Type prefix (e.g. entity.)" bind:value={typeFilter} class="max-w-xs" oninput={loadHistoryDebounced} />
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" oninput={loadHistoryDebounced} />
<Button variant={paused ? 'default' : 'outline'} onclick={() => (paused = !paused)}>
{paused ? 'Resume' : 'Pause'}
</Button>
<Button variant={groupByCorrelation ? 'default' : 'outline'} onclick={() => (groupByCorrelation = !groupByCorrelation)}>
Groups
</Button>
<Button variant="outline" onclick={loadHistory}>Refresh</Button>
</div>
<div class="flex-1 overflow-hidden rounded-md border">
<ScrollArea class="h-full">
{#if groupByCorrelation && clustered}
<div class="flex flex-col">
{#each clustered as group (group.corr ?? '__none')}
{@const key = group.corr ?? '__none'}
{@const isExpanded = expandedCorrelations.has(key)}
<button
type="button"
class="flex items-center gap-2 border-b px-4 py-2 text-left text-xs hover:bg-muted/50"
onclick={() => toggleCorrelation(group.corr)}
>
{#if isExpanded}
<ChevronDownIcon class="size-3 text-muted-foreground" />
{:else}
<ChevronRightIcon class="size-3 text-muted-foreground" />
{/if}
<Badge variant={severityVariant(mostSevere(group.events))} class="shrink-0"
>{mostSevere(group.events)}</Badge
>
<span class="font-mono">{correlationLabel(group.corr)}</span>
<span class="text-muted-foreground">{group.events.length} events</span>
<span class="truncate text-muted-foreground">{group.events[0]?.type ?? ''}</span>
<span class="grow"></span>
<span class="text-muted-foreground">{new Date(group.events[0]?.ts ?? '').toLocaleTimeString()}</span>
</button>
{#if isExpanded}
{#each group.events as ev (ev.id)}
<div class="flex items-center gap-3 border-b py-1 pl-10 pr-4 text-xs">
<span class="w-20 shrink-0 font-mono text-muted-foreground"
>{new Date(ev.ts).toLocaleTimeString()}</span
>
<Badge variant={severityVariant(ev.severity)} class="shrink-0">{ev.severity}</Badge>
<span class="font-mono">{ev.type}</span>
<span class="truncate text-muted-foreground">{ev.source}</span>
</div>
{/each}
{/if}
{/each}
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-32">Time</Table.Head>
<Table.Head class="w-24">Severity</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Source</Table.Head>
<Table.Head>Correlation</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each feed as ev (ev.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs text-muted-foreground"
>{new Date(ev.ts).toLocaleTimeString()}</Table.Cell
>
<Table.Cell><Badge variant={severityVariant(ev.severity)}>{ev.severity}</Badge></Table.Cell>
<Table.Cell class="font-mono text-xs">{ev.type}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{ev.source}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground"
>{ev.correlation_id ?? '—'}</Table.Cell
>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={5} class="text-center text-muted-foreground">No events yet.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</ScrollArea>
</div>
</div>