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)
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
<span>{signal.kind}</span>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
<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>
|
</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>
|
||||||
|
|
||||||
<Card.Root>
|
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||||
<Card.Header>
|
<Card.Root>
|
||||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
<Card.Header>
|
||||||
</Card.Header>
|
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||||
<Card.Content class="flex flex-col gap-1">
|
</Card.Header>
|
||||||
{#each events as ev (ev.id)}
|
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||||
<div class="flex items-center gap-2 text-xs">
|
{#each events as ev (ev.id)}
|
||||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
<div class="flex items-center justify-between gap-2 text-xs">
|
||||||
<span>{ev.type}</span>
|
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||||
</div>
|
<span class="truncate">{ev.type}</span>
|
||||||
{:else}
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
{:else}
|
||||||
{/each}
|
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||||
</Card.Content>
|
{/each}
|
||||||
</Card.Root>
|
</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}
|
{/if}
|
||||||
</div>
|
</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