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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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