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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user