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

@@ -11,19 +11,27 @@
fetchEntityExecutions,
fetchEntityKnowledge,
fetchChecksForTarget,
fetchAgentActivity,
fetchAudit,
patchCheck,
ackSignal,
resolveSignal,
muteSignal,
type Entity,
type Relationship,
type MetricSeries,
type Signal,
type Execution,
type KnowledgeHit,
type Check
type Check,
type AgentActivity,
type AuditEntry
} from '$lib/api'
import { relativeTime } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
@@ -37,7 +45,10 @@
let executions = $state<Execution[]>([])
let knowledge = $state<KnowledgeHit[]>([])
let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
let auditEntries = $state<AuditEntry[]>([])
let loading = $state(true)
let actingSignal = $state<string | null>(null)
let chartContainers: Record<string, HTMLDivElement> = {}
async function load(s: string) {
@@ -47,14 +58,16 @@
loading = false
return
}
const [graphView, m, ev, sig, exec, kh, ch] = await Promise.all([
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id),
fetchEntityKnowledge(entity.id),
fetchChecksForTarget(entity.slug)
fetchChecksForTarget(entity.slug),
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
fetchAudit({ entity_id: entity.id, limit: 50 })
])
relations = graphView?.edges ?? []
metrics = m
@@ -63,12 +76,51 @@
executions = exec
knowledge = kh
checks = ch
agentActivity = aa
auditEntries = au
loading = false
await tick()
renderCharts()
}
async function ackOpenSignal(id: string) {
actingSignal = id
const result = await ackSignal(id)
actingSignal = null
if (result) {
toast.success('Signal acknowledged')
signals = signals.map((s) => (s.id === id ? result : s))
} else {
toast.error('Acknowledge failed')
}
}
async function resolveOpenSignal(id: string) {
actingSignal = id
const result = await resolveSignal(id)
actingSignal = null
if (result) {
toast.success('Signal resolved')
signals = signals.map((s) => (s.id === id ? result : s))
} else {
toast.error('Resolve failed')
}
}
async function muteOpenSignal(id: string) {
actingSignal = id
const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const result = await muteSignal(id, muteUntil)
actingSignal = null
if (result) {
toast.success('Signal muted for 1h')
signals = signals.map((s) => (s.id === id ? result : s))
} else {
toast.error('Mute failed')
}
}
onMount(() => {
load(slug)
})
@@ -232,13 +284,44 @@
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Open signals</Card.Title>
<Card.Title class="text-sm">Signals</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
<Card.Content class="flex flex-col gap-2">
{#each signals as signal (signal.id)}
<div class="flex items-center justify-between text-xs">
<span>{signal.kind}</span>
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<Badge variant="outline">{signal.state}</Badge>
</div>
</div>
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
<div class="flex justify-end gap-1.5">
{#if signal.state === 'raised'}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
>
{/if}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
>
<Button
size="sm"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
>
</div>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
@@ -278,20 +361,62 @@
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Recent events</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each events as ev (ev.id)}
<div class="flex items-center gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span>{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</Card.Content>
</Card.Root>
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Recent events</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span class="truncate">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Agent activity</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
</div>
<span class="truncate text-muted-foreground"
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
>
</div>
{:else}
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Audit trail</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
</div>
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{/if}
</div>