feat(web): fold Events/Agent/Audit into EntityDetail; tag agent_activity with entity_id
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user