Merge remote-tracking branch 'origin/main'
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

This commit is contained in:
2026-07-12 09:18:23 +02:00
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)
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{
Type: "tool_result",
@@ -456,7 +456,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}
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
// 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
}
// 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
// NOT NULL in the schema, so we skip logging when it can't be resolved.
// The (nullable) session_id column carries the conversation id.
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
// The (nullable) session_id column carries the conversation id. args is the
// 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 {
return
}
entityID := s.resolveArgEntityID(ctx, args)
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
s.pool.Exec(ctx, `
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
durationMs, success, correlationID)
}

View File

@@ -939,12 +939,18 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
correlationID := uuid.New().String()
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
_, logErr := pool.Exec(ctx, `
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
agentID, "tool_call", toolName, inputSummary, outputSummary,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
duration, success, correlationID)
if logErr != nil {
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 ──────────────────────────────────────────────────────────
func argsMap(req *mcp.CallToolRequest) map[string]any {

View File

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

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">
<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>
<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 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)}
<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>{ev.type}</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>

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>