From de126daf43683ab2c9bc7b0be26ef4ec833f362d Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 12 Jul 2026 08:38:19 +0200 Subject: [PATCH] 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 --- cmd/nomos/agent.go | 4 +- cmd/nomos/store.go | 51 ++++- internal/mcp/server.go | 43 ++++- web/src/App.svelte | 17 +- .../lib/components/EntityDetailContent.svelte | 171 ++++++++++++++--- web/src/pages/Agent.svelte | 138 -------------- web/src/pages/Audit.svelte | 134 ------------- web/src/pages/Events.svelte | 180 ------------------ 8 files changed, 237 insertions(+), 501 deletions(-) delete mode 100644 web/src/pages/Agent.svelte delete mode 100644 web/src/pages/Audit.svelte delete mode 100644 web/src/pages/Events.svelte diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index ae8744d..40bc451 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -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 diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 3880683..2d61265 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -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) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1aa84b3..0ad6b6d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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 { diff --git a/web/src/App.svelte b/web/src/App.svelte index f433b8e..9fd7efb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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 } ] @@ -220,16 +211,10 @@ {:else if page === 'signals'} - {:else if page === 'events'} - - {:else if page === 'agent'} - {:else if page === 'knowledge'} {:else if page === 'learning'} - {:else if page === 'audit'} - {:else} {/if} diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index ce71454..0986253 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -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([]) let knowledge = $state([]) let checks = $state([]) + let agentActivity = $state([]) + let auditEntries = $state([]) let loading = $state(true) + let actingSignal = $state(null) let chartContainers: Record = {} 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 @@
- Open signals + Signals - + {#each signals as signal (signal.id)} -
- {signal.kind} - {signal.severity} +
+
+ {signal.kind} +
+ {signal.severity} + {signal.state} +
+
+ {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} +
+ {#if signal.state === 'raised'} + + {/if} + + +
+ {/if}
{:else}

None.

@@ -278,20 +361,62 @@
- - - Recent events - - - {#each events as ev (ev.id)} -
- {new Date(ev.ts).toLocaleString()} - {ev.type} -
- {:else} -

No events yet.

- {/each} -
-
+
+ + + Recent events + + + {#each events as ev (ev.id)} +
+ {new Date(ev.ts).toLocaleString()} + {ev.type} +
+ {:else} +

No events yet.

+ {/each} +
+
+ + + + Agent activity + + + {#each agentActivity as activity (activity.id)} +
+
+ {new Date(activity.ts).toLocaleString()} + {activity.activity_type} +
+ {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} +
+ {:else} +

No agent activity.

+ {/each} +
+
+ + + + Audit trail + + + {#each auditEntries as entry (entry.id)} +
+
+ {new Date(entry.ts).toLocaleString()} + {entry.actor_type} +
+ {entry.actor_id ?? '—'} · {entry.action} +
+ {:else} +

No audit entries.

+ {/each} +
+
+
{/if}
diff --git a/web/src/pages/Agent.svelte b/web/src/pages/Agent.svelte deleted file mode 100644 index 3c94747..0000000 --- a/web/src/pages/Agent.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -
-
-

Agent activity

- {activities.length} entries -
- -
- - load()}> - - {typeFilter === 'all' ? 'All types' : typeFilter} - - - All types - Tool call - Reasoning - Decision - MCP query - Escalation - - - -
- -
- - - - - Time - Agent - Type - Tool / Entity - Summary - Status - Duration - - - - {#each activities as a (a.id)} - - {new Date(a.ts).toLocaleString()} - {a.agent_id} - {a.activity_type} - - {#if a.tool_name} - {a.tool_name} - {:else if a.entity_id} - {a.entity_id} - {:else} - - {/if} - - {a.input_summary ?? a.output_summary ?? '—'} - - {#if a.success !== undefined && a.success !== null} - {a.success ? 'ok' : 'fail'} - {:else} - - {/if} - - - {#if a.duration_ms} - {(a.duration_ms / 1000).toFixed(1)}s - {:else} - — - {/if} - - - {:else} - - No agent activity yet. - - {/each} - - - -
-
diff --git a/web/src/pages/Audit.svelte b/web/src/pages/Audit.svelte deleted file mode 100644 index d6b8747..0000000 --- a/web/src/pages/Audit.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
-
-

Audit trail

- {entries.length} entries -
- -
- load()}> - - {actorFilter === 'all' ? 'All actors' : actorFilter} - - - All actors - Agent - Operator - System - Scheduler - - - - - -
- -
- - - - - Time - Actor - Action - Entity - Method - Code - Correlation - - - - {#each entries as entry (entry.id)} - - {new Date(entry.ts).toLocaleString()} - -
- {entry.actor_type} - {#if entry.actor_id} - {entry.actor_id} - {/if} -
-
- {entry.action} - {entry.entity_id ?? '—'} - - {#if methodBadge(entry.method)} - {methodBadge(entry.method)} - {:else} - - {/if} - - - {#if entry.status_code} - {entry.status_code} - {:else} - - {/if} - - {entry.correlation_id ?? '—'} -
- {:else} - - No audit entries. - - {/each} -
-
-
-
-
diff --git a/web/src/pages/Events.svelte b/web/src/pages/Events.svelte deleted file mode 100644 index 42dd259..0000000 --- a/web/src/pages/Events.svelte +++ /dev/null @@ -1,180 +0,0 @@ - - -
-
-

Live event feed

- - stream: {$connectionState} - -
- -
- - - - - -
- -
- - {#if groupByCorrelation && clustered} -
- {#each clustered as group (group.corr ?? '__none')} - {@const key = group.corr ?? '__none'} - {@const isExpanded = expandedCorrelations.has(key)} - - {#if isExpanded} - {#each group.events as ev (ev.id)} -
- {new Date(ev.ts).toLocaleTimeString()} - {ev.severity} - {ev.type} - {ev.source} -
- {/each} - {/if} - {/each} -
- {:else} - - - - Time - Severity - Type - Source - Correlation - - - - {#each feed as ev (ev.id)} - - {new Date(ev.ts).toLocaleTimeString()} - {ev.severity} - {ev.type} - {ev.source} - {ev.correlation_id ?? '—'} - - {:else} - - No events yet. - - {/each} - - - {/if} -
-
-