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

@@ -442,7 +442,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
inputStr := string(inputJSON) inputStr := string(inputJSON)
if callErr != nil { if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID) a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
emit(agentEvent{ emit(agentEvent{
Type: "tool_result", Type: "tool_result",
@@ -456,7 +456,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
} }
resultJSON, _ := json.Marshal(result) resultJSON, _ := json.Marshal(result)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID) a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
// Link any execution this tool queued/started back to this // Link any execution this tool queued/started back to this
// session, so the auto-continuation worker can feed its result // session, so the auto-continuation worker can feed its result

View File

@@ -1027,18 +1027,59 @@ func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string {
return slug return slug
} }
// entityArgKeys lists tool-argument keys, in priority order, that commonly
// carry the target entity's slug or UUID. Tool input schemas aren't
// consistent about naming this (target, entity_slug, slug, service_slug,
// lxc_slug, entity_id all appear across the MCP tool registrations in
// internal/mcp/server.go), so this is a best-effort lookup used to tag
// agent_activity rows with the entity a tool call acted on.
var entityArgKeys = []string{
"target", "entity_slug", "slug", "slug_or_id",
"service_slug", "lxc_slug", "entity_id", "about",
}
// resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity.
func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID {
if s == nil {
return uuid.Nil
}
for _, key := range entityArgKeys {
v, _ := args[key].(string)
if v == "" {
continue
}
if u, err := uuid.Parse(v); err == nil {
return u
}
var id uuid.UUID
if err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
return id
}
}
return uuid.Nil
}
// logActivity records a tool call. agent_id is the agent entity UUID and is // logActivity records a tool call. agent_id is the agent entity UUID and is
// NOT NULL in the schema, so we skip logging when it can't be resolved. // NOT NULL in the schema, so we skip logging when it can't be resolved.
// The (nullable) session_id column carries the conversation id. // The (nullable) session_id column carries the conversation id. args is the
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) { // tool call's own arguments, used to best-effort tag the row with the
// entity it acted on (see resolveArgEntityID).
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
if s == nil || agentID == uuid.Nil { if s == nil || agentID == uuid.Nil {
return return
} }
entityID := s.resolveArgEntityID(ctx, args)
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
s.pool.Exec(ctx, ` s.pool.Exec(ctx, `
INSERT INTO agent_activity INSERT INTO agent_activity
(agent_id, session_id, activity_type, tool_name, input_summary, output_summary, (agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id) duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary, agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
durationMs, success, correlationID) durationMs, success, correlationID)
} }

View File

@@ -939,12 +939,18 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
correlationID := uuid.New().String() correlationID := uuid.New().String()
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
_, logErr := pool.Exec(ctx, ` _, logErr := pool.Exec(ctx, `
INSERT INTO agent_activity INSERT INTO agent_activity
(agent_id, activity_type, tool_name, input_summary, output_summary, (agent_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id) duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, "tool_call", toolName, inputSummary, outputSummary, agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
duration, success, correlationID) duration, success, correlationID)
if logErr != nil { if logErr != nil {
slog.Warn("mcp: log agent_activity", "error", logErr) slog.Warn("mcp: log agent_activity", "error", logErr)
@@ -954,6 +960,37 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
} }
} }
// entityArgKeys lists tool-argument keys, in priority order, that commonly
// carry the target entity's slug or UUID. Tool input schemas aren't
// consistent about naming this (target, entity_slug, slug, service_slug,
// lxc_slug, entity_id all appear across server.go's tool registrations), so
// this is a best-effort lookup used to tag agent_activity rows with the
// entity a tool call acted on.
var entityArgKeys = []string{
"target", "entity_slug", "slug", "slug_or_id",
"service_slug", "lxc_slug", "entity_id", "about",
}
// resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity.
func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID {
for _, key := range entityArgKeys {
v, _ := args[key].(string)
if v == "" {
continue
}
if u, err := uuid.Parse(v); err == nil {
return u
}
var id uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
return id
}
}
return uuid.Nil
}
// ─── Helpers ────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────
func argsMap(req *mcp.CallToolRequest) map[string]any { func argsMap(req *mcp.CallToolRequest) map[string]any {

View File

@@ -3,15 +3,12 @@
import Tasks from './pages/Tasks.svelte' import Tasks from './pages/Tasks.svelte'
import Overview from './pages/Overview.svelte' import Overview from './pages/Overview.svelte'
import Entities from './pages/Entities.svelte' import Entities from './pages/Entities.svelte'
import Events from './pages/Events.svelte'
import Ops from './pages/Ops.svelte' import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte' import Signals from './pages/Signals.svelte'
import Graph from './pages/Graph.svelte' import Graph from './pages/Graph.svelte'
import EntityDetail from './pages/EntityDetail.svelte' import EntityDetail from './pages/EntityDetail.svelte'
import Agent from './pages/Agent.svelte'
import Knowledge from './pages/Knowledge.svelte' import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte' import Learning from './pages/Learning.svelte'
import Audit from './pages/Audit.svelte'
import { newChat } from '$lib/stores/chat' import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events' import { connectionState } from '$lib/stores/events'
@@ -27,14 +24,11 @@
import MessageSquareIcon from '@lucide/svelte/icons/message-square' import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard' import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database' import DatabaseIcon from '@lucide/svelte/icons/database'
import ActivityIcon from '@lucide/svelte/icons/activity'
import PanelRightIcon from '@lucide/svelte/icons/panel-right' import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren' import SirenIcon from '@lucide/svelte/icons/siren'
import NetworkIcon from '@lucide/svelte/icons/share-2' import NetworkIcon from '@lucide/svelte/icons/share-2'
import BotIcon from '@lucide/svelte/icons/bot'
import SearchIcon from '@lucide/svelte/icons/search' import SearchIcon from '@lucide/svelte/icons/search'
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up' import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
let page = $state('tasks') let page = $state('tasks')
@@ -71,11 +65,8 @@
{ id: 'graph', label: 'Graph', icon: NetworkIcon }, { id: 'graph', label: 'Graph', icon: NetworkIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending }, { id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals }, { id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'events', label: 'Events', icon: ActivityIcon },
{ id: 'agent', label: 'Agent', icon: BotIcon },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon }, { id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }, { id: 'learning', label: 'Learning', icon: TrendingUpIcon }
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
] ]
</script> </script>
@@ -220,16 +211,10 @@
<Ops /> <Ops />
{:else if page === 'signals'} {:else if page === 'signals'}
<Signals /> <Signals />
{:else if page === 'events'}
<Events />
{:else if page === 'agent'}
<Agent />
{:else if page === 'knowledge'} {:else if page === 'knowledge'}
<Knowledge /> <Knowledge />
{:else if page === 'learning'} {:else if page === 'learning'}
<Learning /> <Learning />
{:else if page === 'audit'}
<Audit />
{:else} {:else}
<Chat /> <Chat />
{/if} {/if}

View File

@@ -11,19 +11,27 @@
fetchEntityExecutions, fetchEntityExecutions,
fetchEntityKnowledge, fetchEntityKnowledge,
fetchChecksForTarget, fetchChecksForTarget,
fetchAgentActivity,
fetchAudit,
patchCheck, patchCheck,
ackSignal,
resolveSignal,
muteSignal,
type Entity, type Entity,
type Relationship, type Relationship,
type MetricSeries, type MetricSeries,
type Signal, type Signal,
type Execution, type Execution,
type KnowledgeHit, type KnowledgeHit,
type Check type Check,
type AgentActivity,
type AuditEntry
} from '$lib/api' } from '$lib/api'
import { relativeTime } from '$lib/utils' import { relativeTime } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events' import type { OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card' import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge' import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
@@ -37,7 +45,10 @@
let executions = $state<Execution[]>([]) let executions = $state<Execution[]>([])
let knowledge = $state<KnowledgeHit[]>([]) let knowledge = $state<KnowledgeHit[]>([])
let checks = $state<Check[]>([]) let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
let auditEntries = $state<AuditEntry[]>([])
let loading = $state(true) let loading = $state(true)
let actingSignal = $state<string | null>(null)
let chartContainers: Record<string, HTMLDivElement> = {} let chartContainers: Record<string, HTMLDivElement> = {}
async function load(s: string) { async function load(s: string) {
@@ -47,14 +58,16 @@
loading = false loading = false
return 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 }), fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id), fetchMetrics(entity.id),
fetchEntityEvents(entity.id), fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id), fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id), fetchEntityExecutions(entity.id),
fetchEntityKnowledge(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 ?? [] relations = graphView?.edges ?? []
metrics = m metrics = m
@@ -63,12 +76,51 @@
executions = exec executions = exec
knowledge = kh knowledge = kh
checks = ch checks = ch
agentActivity = aa
auditEntries = au
loading = false loading = false
await tick() await tick()
renderCharts() 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(() => { onMount(() => {
load(slug) load(slug)
}) })
@@ -232,13 +284,44 @@
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3"> <div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title class="text-sm">Open signals</Card.Title> <Card.Title class="text-sm">Signals</Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col gap-1"> <Card.Content class="flex flex-col gap-2">
{#each signals as signal (signal.id)} {#each signals as signal (signal.id)}
<div class="flex items-center justify-between text-xs"> <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> <span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge> <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> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">None.</p> <p class="text-xs text-muted-foreground">None.</p>
@@ -278,20 +361,62 @@
</Card.Root> </Card.Root>
</div> </div>
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title class="text-sm">Recent events</Card.Title> <Card.Title class="text-sm">Recent events</Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col gap-1"> <Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each events as ev (ev.id)} {#each events as ev (ev.id)}
<div class="flex items-center gap-2 text-xs"> <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="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span>{ev.type}</span> <span class="truncate">{ev.type}</span>
</div> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">No events yet.</p> <p class="text-xs text-muted-foreground">No events yet.</p>
{/each} {/each}
</Card.Content> </Card.Content>
</Card.Root> </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} {/if}
</div> </div>

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>