feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Add three new pages completing the control-room web UI:
- Agent activity: polls /agent-activity every 5s, filterable by type/agent
- Knowledge search: FTS over /knowledge/search with snippet + entity links
- Audit trail: browseable audit log with actor/action/entity filters

Enhanced live events page with correlation-id clustering (Groups toggle).
Added fetchAgentActivity/searchKnowledge/fetchAudit to the API client.
11 nav items now cover all planned control-room views.
This commit is contained in:
2026-07-08 17:02:09 +02:00
parent cff05c0768
commit 2908b0a377
162 changed files with 7857 additions and 553 deletions

134
web/src/pages/Agent.svelte Normal file
View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fetchAgentActivity, type AgentActivity } from '$lib/api'
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 * 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
})
}
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-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" onchange={load} />
<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 type="button" class="rounded-md border px-3 py-1.5 text-xs" 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>

131
web/src/pages/Audit.svelte Normal file
View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchAudit, type AuditEntry } from '$lib/api'
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
})
}
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-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" onchange={load} />
<Input placeholder="Entity…" bind:value={entityFilter} class="max-w-48" onchange={load} />
<Button variant="outline" onclick={load}>Refresh</Button>
</div>
<div class="flex-1 overflow-hidden rounded-md border">
<ScrollArea class="h-full">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-36">Time</Table.Head>
<Table.Head class="w-24">Actor</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Entity</Table.Head>
<Table.Head class="w-20">Method</Table.Head>
<Table.Head class="w-16">Code</Table.Head>
<Table.Head>Correlation</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each entries as entry (entry.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs text-muted-foreground"
>{new Date(entry.ts).toLocaleString()}</Table.Cell
>
<Table.Cell>
<div class="flex flex-col gap-0.5">
<Badge variant={actorVariant(entry.actor_type)}>{entry.actor_type}</Badge>
{#if entry.actor_id}
<span class="font-mono text-xs text-muted-foreground">{entry.actor_id}</span>
{/if}
</div>
</Table.Cell>
<Table.Cell class="text-xs">{entry.action}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.entity_id ?? '—'}</Table.Cell>
<Table.Cell>
{#if methodBadge(entry.method)}
<Badge variant="outline">{methodBadge(entry.method)}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if entry.status_code}
<Badge variant={statusVariant(entry.status_code)}>{entry.status_code}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.correlation_id ?? '—'}</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={7} class="text-center text-muted-foreground">No audit entries.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</ScrollArea>
</div>
</div>

View File

@@ -1,13 +1,32 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import { fly } from 'svelte/transition'
import ContextRail from '$lib/components/ContextRail.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import SquareIcon from '@lucide/svelte/icons/square'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let input = ''
let messagesEnd: HTMLDivElement
$: $messages, $streaming, setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
let { showRail = true }: { showRail?: boolean } = $props()
function handleSubmit(e: Event) {
e.preventDefault()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
$effect(() => {
void $messages
void $streaming
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
@@ -17,247 +36,202 @@
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
submit()
}
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
<div class="chat">
<div class="messages">
{#each $messages as msg (msg.id)}
<div class="message {msg.role}">
<div class="role">{msg.role === 'user' ? 'You' : 'Nomos'}</div>
{#if msg.text}
<div class="text">{msg.text}</div>
{/if}
{#each msg.tools as tool (tool.id)}
<div class="tool-chip" class:tool-use={tool.type === 'tool_use'} class:tool-result={tool.type === 'tool_result'}>
<div class="tool-header" transition:fly={{ y: 4, duration: 150 }}>
<span class="tool-icon">{tool.type === 'tool_use' ? '⚙' : '✓'}</span>
<span class="tool-name">{tool.name}</span>
<div class="flex h-full min-h-0">
<div class="flex min-w-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
{#if $messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
{#if tool.type === 'tool_use' && tool.args}
<div class="tool-body">
<pre>{JSON.stringify(tool.args, null, 2)}</pre>
</div>
{/if}
{#if tool.type === 'tool_result'}
<div class="tool-body">
{#if tool.error}
<pre class="error">{tool.error}</pre>
{:else}
<pre>{JSON.stringify(tool.result, null, 2)}</pre>
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
</div>
{/if}
{#each $messages as msg (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#each msg.tools as tool (tool.id)}
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</summary>
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</details>
{/each}
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
{:else if msg.tools.length === 0}
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current"></span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if !msg.text && msg.tools.length === 0 && msg.role === 'assistant'}
<div class="thinking">Thinking<span class="dots"></span></div>
{/if}
<div bind:this={messagesEnd}></div>
</div>
{/each}
<div bind:this={messagesEnd}></div>
</div>
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{$error}
</div>
</div>
{/if}
<div class="border-t bg-card/50 p-3">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if $error}
<div class="error-banner" transition:fly={{ y: 8, duration: 200 }}>
{$error}
{#if showRail}
<div class="hidden xl:block">
<ContextRail />
</div>
{/if}
<form class="input-bar" onsubmit={handleSubmit}>
<textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything..."
rows={2}
disabled={$streaming}
></textarea>
{#if $streaming}
<button type="button" class="stop" onclick={cancelStream}>■</button>
{:else}
<button type="submit" disabled={!input.trim()}>→</button>
{/if}
</form>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
max-width: 720px;
margin: 0 auto;
/* Minimal markdown styling for assistant messages. */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.message {
display: flex;
flex-direction: column;
gap: 0.25rem;
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.message.user {
align-items: flex-end;
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.message.assistant {
align-items: flex-start;
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.role {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.text {
background: var(--bg-surface);
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
max-width: 100%;
line-height: 1.5;
white-space: pre-wrap;
}
.thinking {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem;
}
.dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
}
.tool-chip {
margin-top: 0.25rem;
border-radius: 6px;
border: 1px solid var(--border);
overflow: hidden;
font-size: 0.8125rem;
width: 100%;
}
.tool-chip.tool-use {
border-left: 3px solid var(--accent-blue);
}
.tool-chip.tool-result {
border-left: 3px solid var(--accent-green);
}
.tool-header {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: var(--bg-surface);
}
.tool-icon {
font-size: 0.75rem;
}
.tool-name {
font-weight: 600;
font-family: var(--font-mono);
font-size: 0.8125rem;
}
.tool-body {
padding: 0.5rem;
background: var(--bg-deeper);
max-height: 200px;
overflow-y: auto;
}
.tool-body pre {
margin: 0;
font-family: var(--font-mono);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
}
.tool-body pre.error {
color: var(--accent-red);
}
.error-banner {
background: var(--accent-red);
color: white;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
text-align: center;
}
.input-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.input-bar textarea {
flex: 1;
background: var(--bg-deeper);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.625rem 0.75rem;
font-family: inherit;
font-size: 0.875rem;
resize: none;
outline: none;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.input-bar textarea:focus {
border-color: var(--accent-blue);
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.input-bar button {
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background: var(--accent-blue);
color: white;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
align-self: flex-end;
transition: opacity 0.15s;
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.input-bar button:disabled {
opacity: 0.3;
cursor: default;
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.input-bar button.stop {
background: var(--accent-red);
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, type Entity } from '$lib/api'
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 * as Select from '$lib/components/ui/select'
import { Skeleton } from '$lib/components/ui/skeleton'
let entities = $state<Entity[]>([])
let loading = $state(true)
let query = $state('')
let typeFilter = $state('all')
async function load() {
loading = true
entities = await fetchEntities()
loading = false
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
return unsubscribe
})
// Live-patch on entity.* events instead of a full refetch.
$effect(() => {
const ev = $liveEvents[0]
if (!ev || !ev.type.startsWith('entity.')) return
load()
})
const types = $derived(Array.from(new Set(entities.map((e) => e.type))).sort())
const filtered = $derived(
entities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
if (query && !e.slug.includes(query) && !e.name.includes(query)) return false
return true
})
)
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
if (!state) return 'outline'
if (state === 'active' || state === 'healthy') return 'default'
return 'secondary'
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Entities</h1>
<span class="text-xs text-muted-foreground">{filtered.length} of {entities.length}</span>
</div>
<div class="flex gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="max-w-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="w-40">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each types as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if loading}
<div class="flex flex-col gap-2">
{#each Array(8) as _}
<Skeleton class="h-8 w-full" />
{/each}
</div>
{:else}
<div class="flex-1 overflow-auto rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Slug</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>State</Table.Head>
<Table.Head>Updated</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as entity (entity.id)}
<Table.Row
class="cursor-pointer"
onclick={() => (location.hash = '#/entity/' + encodeURIComponent(entity.slug))}
>
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
{#if entity.state}
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{new Date(entity.updated_at).toLocaleString()}</Table.Cell
>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={5} class="text-center text-muted-foreground"
>No entities match this filter.</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</div>

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import {
fetchEntity,
fetchGraph,
fetchMetrics,
fetchEntityEvents,
fetchEntitySignals,
fetchEntityExecutions,
fetchEntityKnowledge,
type Entity,
type Relationship,
type MetricSeries,
type Signal,
type Execution,
type KnowledgeHit
} from '$lib/api'
import type { OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
let { slug }: { slug: string } = $props()
let entity = $state<Entity | null>(null)
let relations = $state<Relationship[]>([])
let metrics = $state<MetricSeries[]>([])
let events = $state<OikosEvent[]>([])
let signals = $state<Signal[]>([])
let executions = $state<Execution[]>([])
let knowledge = $state<KnowledgeHit[]>([])
let loading = $state(true)
let chartContainers: Record<string, HTMLDivElement> = {}
async function load(s: string) {
loading = true
entity = await fetchEntity(s)
if (!entity) {
loading = false
return
}
const [graphView, m, ev, sig, exec, kh] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id),
fetchEntityKnowledge(entity.id)
])
relations = graphView?.edges ?? []
metrics = m
events = ev
signals = sig
executions = exec
knowledge = kh
loading = false
await tick()
renderCharts()
}
onMount(() => {
load(slug)
})
$effect(() => {
if (slug) load(slug)
})
function renderCharts() {
for (const series of metrics) {
const el = chartContainers[series.metric]
if (!el) continue
el.innerHTML = ''
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
new uPlot(
{
width: el.clientWidth || 400,
height: 160,
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
scales: { x: { time: true } },
legend: { show: false }
},
[xs, ys],
el
)
}
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
{#if loading}
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-2 gap-4">
<Skeleton class="h-40 w-full" />
<Skeleton class="h-40 w-full" />
</div>
{:else if !entity}
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
{:else}
<div class="flex items-center gap-2">
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
<Badge variant="outline">{entity.type}</Badge>
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attributes</Card.Title>
</Card.Header>
<Card.Content>
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(entity.attributes, null, 2)}</pre>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex items-center gap-1 font-mono text-xs">
<span>{rel.source}</span>
<span class="text-muted-foreground">{rel.type}</span>
<span>{rel.target}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{#if metrics.length}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Metrics</Card.Title>
</Card.Header>
<Card.Content class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each metrics as series (series.metric)}
<div>
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
<div bind:this={chartContainers[series.metric]}></div>
</div>
{/each}
</Card.Content>
</Card.Root>
{/if}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Open signals</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#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>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Executions</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each executions as execution (execution.id)}
<div class="flex items-center justify-between text-xs">
<span>{execution.action}</span>
<Badge variant="outline">{execution.status}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Knowledge</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each knowledge as hit (hit.id)}
<div class="text-xs">
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
</div>
{:else}
<p class="text-xs text-muted-foreground">None linked.</p>
{/each}
</Card.Content>
</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>
{/if}
</div>

177
web/src/pages/Events.svelte Normal file
View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEvents } from '$lib/api'
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 })
}
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-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" onchange={loadHistory} />
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" onchange={loadHistory} />
<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>

506
web/src/pages/Graph.svelte Normal file
View File

@@ -0,0 +1,506 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchBlastRadius, type GraphView, type Entity, type BlastRadiusItem, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import * as Sheet from '$lib/components/ui/sheet'
import { Skeleton } from '$lib/components/ui/skeleton'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Link {
source: string | Node
target: string | Node
type: string
}
let graph = $state<GraphView | null>(null)
let loading = $state(true)
let root = $state('')
let depth = $state(2)
let nodes = $state<Node[]>([])
let links = $state<Link[]>([])
let selected = $state<Entity | null>(null)
let blastRadius = $state<BlastRadiusItem[]>([])
let sim: Simulation<Node, Link> | null = null
let search = $state('')
let activeNodeTypes = $state<Set<string>>(new Set())
let activeRelTypes = $state<Set<string>>(new Set())
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
let view = $state({ x: 0, y: 0, k: 1 })
let svgEl = $state<SVGSVGElement | null>(null)
const width = 1200
const height = 800
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
const relColorByType = $derived.by(() => {
const map = new Map<string, string>()
const types = Array.from(new Set(links.map((l) => l.type))).sort()
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
return map
})
function relColor(type: string): string {
return relColorByType.get(type) ?? '#30363d'
}
function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
}
function endpointId(end: string | Node): string {
return typeof end === 'object' ? end.id : end
}
async function load() {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
if (!graph) return
const byId = new Map(nodes.map((n) => [n.id, n]))
const degree = new Map<string, number>()
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
nodes = graph.nodes.map((n) => {
const prev = byId.get(n.id)
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
})
links = graph.edges.map((e) => ({
source: idBySlug.get(e.source) ?? e.source,
target: idBySlug.get(e.target) ?? e.target,
type: e.type
}))
activeNodeTypes = new Set(nodes.map((n) => n.type))
activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop()
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
.force('charge', forceManyBody().strength(-240).distanceMax(400))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
.force('x', forceX(width / 2).strength(0.04))
.force('y', forceY(height / 2).strength(0.04))
.velocityDecay(0.32)
.alphaDecay(0.035)
.on('tick', () => {
nodes = [...nodes]
})
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
return () => {
unsubscribe()
sim?.stop()
}
})
onDestroy(() => sim?.stop())
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load()
}
})
async function selectNode(node: Node) {
selected = node
blastRadius = await fetchBlastRadius(node.id)
}
function rerootTo(node: Node) {
root = node.slug
selected = null
load()
}
function nodeColor(node: Node): string {
const h = graph?.health?.[node.id]
return h ? healthColor[h] : '#58a6ff'
}
function nodeRadius(node: Node): number {
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
function toggleSet(set: Set<string>, value: string): Set<string> {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
const allNodeTypes = $derived(Array.from(new Set(nodes.map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
const matchedIds = $derived.by(() => {
if (!search.trim()) return null
const q = search.trim().toLowerCase()
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
const visibleNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
const adjacency = $derived.by(() => {
const adj = new Map<string, Set<string>>()
for (const l of links) {
const s = endpointId(l.source)
const t = endpointId(l.target)
if (!adj.has(s)) adj.set(s, new Set())
if (!adj.has(t)) adj.set(t, new Set())
adj.get(s)!.add(t)
adj.get(t)!.add(s)
}
return adj
})
const focusIds = $derived.by(() => {
const focus = hoveredId ?? selected?.id ?? null
if (!focus) return null
const set = new Set<string>([focus])
for (const n of adjacency.get(focus) ?? []) set.add(n)
return set
})
function nodeOpacity(node: Node): number {
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
return 1
}
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
const s = endpointId(link.source)
const t = endpointId(link.target)
const focus = hoveredId ?? selected?.id ?? null
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
return { opacity: 0.45, emphasized: false }
}
// ─── pan / zoom / drag ───────────────────────────────────────────────
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
const rect = svgEl!.getBoundingClientRect()
return {
x: ((clientX - rect.left) / rect.width) * width,
y: ((clientY - rect.top) / rect.height) * height
}
}
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
const p = toViewBox(clientX, clientY)
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
const k = Math.min(6, Math.max(0.25, view.k * factor))
const p = toViewBox(e.clientX, e.clientY)
const wx = (p.x - view.x) / view.k
const wy = (p.y - view.y) / view.k
view = { k, x: p.x - wx * k, y: p.y - wy * k }
}
let panState: { startX: number; startY: number; viewX: number; viewY: number } | null = null
let dragState: { node: Node; moved: boolean } | null = null
function onBackgroundPointerDown(e: PointerEvent) {
if (dragState) return
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toViewBox(e.clientX, e.clientY)
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y }
}
function onNodePointerDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.25).restart()
}
function onPointerMove(e: PointerEvent) {
if (dragState) {
const w = toWorld(e.clientX, e.clientY)
dragState.node.fx = w.x
dragState.node.fy = w.y
dragState.moved = true
return
}
if (panState) {
const p = toViewBox(e.clientX, e.clientY)
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
}
}
function onPointerUp(e: PointerEvent) {
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectNode(node)
return
}
panState = null
}
function resetView() {
view = { x: 0, y: 0, k: 1 }
root = ''
search = ''
load()
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex flex-wrap items-center gap-2">
<h1 class="mr-2 text-lg font-semibold">Graph</h1>
<Input placeholder="Root entity…" bind:value={root} class="h-8 max-w-44 text-xs" onchange={load} />
<Input type="number" min="1" max="5" bind:value={depth} class="h-8 w-16 text-xs" onchange={load} />
<Input placeholder="Search / highlight…" bind:value={search} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetView}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<div class="flex-1"></div>
<span class="text-xs text-muted-foreground">
{nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}%
</span>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if allNodeTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
{#each allNodeTypes as type}
<button type="button" onclick={() => (activeNodeTypes = toggleSet(activeNodeTypes, type))}>
<Badge variant={activeNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
</button>
{/each}
</div>
{/if}
{#if allRelTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
{#each allRelTypes as type}
<button type="button" onclick={() => (activeRelTypes = toggleSet(activeRelTypes, type))}>
<Badge
variant="outline"
class="h-5 cursor-pointer px-1.5 text-[10px] {activeRelTypes.has(type) ? '' : 'opacity-35'}"
style="border-color: {relColor(type)}; color: {relColor(type)};"
>
{type}
</Badge>
</button>
{/each}
</div>
{/if}
</div>
{#if loading && !nodes.length}
<Skeleton class="min-h-0 flex-1" />
{:else}
<div class="relative min-h-0 flex-1 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
<svg
bind:this={svgEl}
viewBox="0 0 {width} {height}"
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
role="application"
aria-label="Entity graph"
onwheel={onWheel}
onpointerdown={onBackgroundPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<defs>
{#each allRelTypes as type}
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
</marker>
{/each}
</defs>
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
{@const vs = linkVisualState(link)}
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (dx / len) * tr}
{@const ey = t.y - (dy / len) * tr}
<line
x1={s.x}
y1={s.y}
x2={ex}
y2={ey}
stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})"
>
<title>{link.type}</title>
</line>
{#if vs.emphasized && view.k >= 0.7}
<text
x={(s.x + ex) / 2}
y={(s.y + ey) / 2 - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}
opacity="0.95"
paint-order="stroke"
stroke="var(--background)"
stroke-width={3 / view.k}
>
{link.type}
</text>
{/if}
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.id)}
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
{@const r = nodeRadius(node)}
{@const op = nodeOpacity(node)}
{@const isFocus = hoveredId === node.id || selected?.id === node.id}
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
<g
transform="translate({node.x},{node.y})"
opacity={op}
class="cursor-pointer"
role="button"
tabindex="0"
onpointerdown={(e) => onNodePointerDown(e, node)}
onpointerenter={() => (hoveredId = node.id)}
onpointerleave={() => (hoveredId = null)}
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
ondblclick={() => rerootTo(node)}
>
{#if isFocus || isMatch}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
<text
y={r + 12}
text-anchor="middle"
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
fill={isFocus ? '#e6edf3' : '#8b949e'}
paint-order="stroke"
stroke="#0d1117"
stroke-width={3 / view.k}
class="pointer-events-none select-none"
>
{node.slug}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</g>
</svg>
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
</div>
</div>
{/if}
</div>
<Sheet.Root open={selected !== null} onOpenChange={(open) => { if (!open) selected = null }}>
<Sheet.Content side="right" class="w-[420px] sm:max-w-[420px]">
{#if selected}
<Sheet.Header>
<Sheet.Title class="font-mono text-sm">{selected.slug}</Sheet.Title>
<Sheet.Description>{selected.type}{selected.state ? ` — ${selected.state}` : ''}</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={() => (location.hash = '#/entity/' + encodeURIComponent(selected!.slug))}>
View entity detail
</Button>
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Attributes</p>
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(selected.attributes, null, 2)}</pre>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Relationships</p>
<div class="flex flex-col gap-1">
{#each links.filter((l) => endpointId(l.source) === selected!.id || endpointId(l.target) === selected!.id) as link}
{@const sId = endpointId(link.source)}
{@const other = endpoint(sId === selected!.id ? link.target : link.source)}
<div class="flex items-center gap-1 text-xs">
<Badge variant="outline" style="border-color: {relColor(link.type)}; color: {relColor(link.type)};">
{sId === selected!.id ? '→' : '←'} {link.type}
</Badge>
<span class="font-mono">{other?.slug ?? '—'}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No relationships in this view.</p>
{/each}
</div>
</div>
<div>
<p class="mb-1 text-xs font-medium text-muted-foreground">Blast radius ({blastRadius.length})</p>
<div class="flex flex-col gap-1">
{#each blastRadius as item (item.entity.id)}
<div class="flex items-center justify-between text-xs">
<span class="font-mono">{item.entity.slug}</span>
<Badge variant="outline">depth {item.depth}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">No downstream dependents.</p>
{/each}
</div>
</div>
</div>
{/if}
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { searchKnowledge, type KnowledgeHit } from '$lib/api'
import * as Card from '$lib/components/ui/card'
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 SearchIcon from '@lucide/svelte/icons/search'
let query = $state('')
let results = $state<KnowledgeHit[]>([])
let loading = $state(false)
let searched = $state(false)
async function search() {
if (!query.trim()) return
loading = true
results = await searchKnowledge(query)
loading = false
searched = true
}
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
if (type === 'runbook') return 'secondary'
if (type === 'investigation') return 'default'
return 'outline'
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<h1 class="text-lg font-semibold">Knowledge search</h1>
<form
onsubmit={(e) => {
e.preventDefault()
search()
}}
class="flex gap-2"
>
<div class="relative flex-1 max-w-lg">
<SearchIcon class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search documents, runbooks, investigations…"
bind:value={query}
class="pl-8"
/>
</div>
<Button type="submit" disabled={loading || !query.trim()}>
{loading ? 'Searching…' : 'Search'}
</Button>
</form>
{#if searched}
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'}{query ? ` for "${query}"` : ''}</p>
{/if}
<ScrollArea class="flex-1">
<div class="flex flex-col gap-3 pr-4">
{#each results as hit (hit.id)}
<Card.Root class="cursor-pointer transition-colors hover:bg-muted/50">
<Card.Header>
<div class="flex items-center gap-2">
<Card.Title class="text-sm">{hit.title}</Card.Title>
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
</div>
{#if hit.snippet}
<Card.Description class="text-xs">{@html hit.snippet}</Card.Description>
{/if}
{#if hit.linked_entities?.length}
<div class="mt-1 flex flex-wrap gap-1">
{#each hit.linked_entities as slug}
<button
type="button"
class="font-mono text-xs text-muted-foreground underline"
onclick={() => (location.hash = '#/entity/' + encodeURIComponent(slug))}
>
{slug}
</button>
{/each}
</div>
{/if}
</Card.Header>
</Card.Root>
{:else}
{#if searched && !loading}
<p class="py-12 text-center text-muted-foreground">No results found.</p>
{/if}
{/each}
</div>
</ScrollArea>
</div>

203
web/src/pages/Ops.svelte Normal file
View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { onMount } from 'svelte'
import {
fetchApprovals,
decideApproval,
fetchExecutions,
cancelExecution,
type Approval,
type Execution
} from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Tabs from '$lib/components/ui/tabs'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
let approvals = $state<Approval[]>([])
let executions = $state<Execution[]>([])
let deciding = $state<string | null>(null)
async function loadApprovals() {
approvals = await fetchApprovals()
}
async function loadExecutions() {
executions = await fetchExecutions()
}
onMount(() => {
loadApprovals()
loadExecutions()
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('approval.')) loadApprovals()
if (ev.type.startsWith('execution.')) loadExecutions()
})
async function decide(id: string, decision: 'approve' | 'deny') {
deciding = id
const result = await decideApproval(id, decision)
deciding = null
if (result) {
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
loadApprovals()
} else {
toast.error('Decision failed')
}
}
async function cancel(id: string) {
const result = await cancelExecution(id)
if (result) {
toast.success('Execution cancelled')
loadExecutions()
} else {
toast.error('Cancel failed')
}
}
function riskVariant(risk: string): 'default' | 'secondary' | 'destructive' {
if (risk === 'high' || risk === 'critical') return 'destructive'
if (risk === 'medium') return 'secondary'
return 'default'
}
function execStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['failed', 'timed_out', 'rollback_failed', 'denied'].includes(status)) return 'destructive'
if (['verified', 'auto_approved'].includes(status)) return 'default'
if (['executing', 'verifying'].includes(status)) return 'secondary'
return 'outline'
}
const pendingApprovals = $derived(approvals.filter((a) => a.status === 'pending'))
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
</script>
<div class="flex h-full flex-col gap-4 p-6">
<h1 class="text-lg font-semibold">Operations ledger</h1>
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">
<Tabs.List>
<Tabs.Trigger value="approvals">
Approvals {#if pendingApprovals.length}<Badge variant="destructive" class="ml-1">{pendingApprovals.length}</Badge>{/if}
</Tabs.Trigger>
<Tabs.Trigger value="executions">Executions</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="approvals" class="flex-1 overflow-auto">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Subject</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Risk</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Expires</Table.Head>
<Table.Head class="text-right">Decision</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each pendingApprovals as approval (approval.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
<Table.Cell>{approval.action}</Table.Cell>
<Table.Cell><Badge variant={riskVariant(approval.risk_class)}>{approval.risk_class}</Badge></Table.Cell>
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{new Date(approval.expires_at).toLocaleString()}</Table.Cell
>
<Table.Cell class="flex justify-end gap-2">
<Button
size="sm"
disabled={deciding === approval.id}
onclick={() => decide(approval.id, 'approve')}
>
Approve
</Button>
<Button
size="sm"
variant="destructive"
disabled={deciding === approval.id}
onclick={() => decide(approval.id, 'deny')}
>
Deny
</Button>
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={6} class="text-center text-muted-foreground">No pending approvals.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if decidedApprovals.length}
<p class="mt-4 text-xs text-muted-foreground">Recently decided</p>
<div class="mt-1 rounded-md border">
<Table.Root>
<Table.Body>
{#each decidedApprovals.slice(0, 20) as approval (approval.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{approval.subject ?? '—'}</Table.Cell>
<Table.Cell>{approval.action}</Table.Cell>
<Table.Cell><Badge variant="outline">{approval.status}</Badge></Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{approval.decided_at ? new Date(approval.decided_at).toLocaleString() : '—'}</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="executions" class="flex-1 overflow-auto">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Target</Table.Head>
<Table.Head>Action</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Correlation</Table.Head>
<Table.Head>Started</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each executions as execution (execution.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{execution.target ?? '—'}</Table.Cell>
<Table.Cell>{execution.action}</Table.Cell>
<Table.Cell><Badge variant={execStatusVariant(execution.status)}>{execution.status}</Badge></Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">{execution.correlation_id}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{execution.started_at ? new Date(execution.started_at).toLocaleString() : '—'}</Table.Cell
>
<Table.Cell class="text-right">
{#if ['proposed', 'approved', 'auto_approved', 'executing'].includes(execution.status)}
<Button size="sm" variant="outline" onclick={() => cancel(execution.id)}>Cancel</Button>
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={6} class="text-center text-muted-foreground">No executions yet.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
async function load() {
summary = await fetchDashboardSummary()
loading = false
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
const interval = setInterval(load, 15000)
return () => {
unsubscribe()
clearInterval(interval)
}
})
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
const degradedTypes = $derived(
summary
? [
{ label: 'Degraded', count: summary.health.degraded, tone: 'text-warning' as const },
{ label: 'Down', count: summary.health.down, tone: 'text-destructive' as const }
].filter((t) => t.count > 0)
: []
)
const maxEventRate = $derived(
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
)
function formatEventLabel(ev: OikosEvent) {
return ev.type
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
<h1 class="text-lg font-semibold">Overview</h1>
{#if loading}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each Array(4) as _}
<Skeleton class="h-28 w-full" />
{/each}
</div>
{:else if summary}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card.Root>
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
{#each Object.entries(summary.entities_by_type) as [type, count]}
<Badge variant="outline">{type}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Health</Card.Description>
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
<Badge>healthy: {summary.health.healthy}</Badge>
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
<Badge variant="destructive">down: {summary.health.down}</Badge>
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.executions_by_state) as [state, count]}
<Badge variant="outline">{state}: {count}</Badge>
{/each}
</Card.Content>
</Card.Root>
</div>
{#if degradedTypes.length}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attention needed</Card.Title>
</Card.Header>
<Card.Content class="flex gap-2">
{#each degradedTypes as t}
<Badge variant={t.tone === 'text-destructive' ? 'destructive' : 'secondary'}
>{t.label}: {t.count}</Badge
>
{/each}
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Event rate (6h, 5m buckets)</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex h-16 items-end gap-0.5">
{#each summary.event_rate as bucket}
<div
class="flex-1 rounded-t bg-primary/60"
style="height: {Math.max((bucket.count / maxEventRate) * 100, 2)}%"
title="{bucket.bucket}: {bucket.count} events"
></div>
{/each}
</div>
</Card.Content>
</Card.Root>
{/if}
<Card.Root class="flex-1">
<Card.Header>
<Card.Title class="text-sm">Live event ticker</Card.Title>
</Card.Header>
<Card.Content class="p-0">
<ScrollArea class="h-64 px-4 pb-4">
<div class="flex flex-col gap-1">
{#each $liveEvents as ev (ev.id)}
<div class="flex items-center gap-2 text-xs">
<Badge variant={severityVariant(ev.severity)} class="shrink-0"
>{ev.severity}</Badge
>
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
<span>{formatEventLabel(ev)}</span>
<span class="truncate text-muted-foreground">{ev.source}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">Waiting for events…</p>
{/each}
</div>
</ScrollArea>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchSignals, ackSignal, resolveSignal, muteSignal, type Signal } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Tabs from '$lib/components/ui/tabs'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import * as Select from '$lib/components/ui/select'
import { toast } from 'svelte-sonner'
let signals = $state<Signal[]>([])
let severityFilter = $state('all')
let acting = $state<string | null>(null)
async function load() {
signals = await fetchSignals()
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev || !ev.type.startsWith('signal.')) return
load()
})
async function ack(id: string) {
acting = id
const result = await ackSignal(id)
acting = null
if (result) {
toast.success('Signal acknowledged')
load()
} else {
toast.error('Acknowledge failed')
}
}
async function resolve(id: string) {
acting = id
const result = await resolveSignal(id)
acting = null
if (result) {
toast.success('Signal resolved')
load()
} else {
toast.error('Resolve failed')
}
}
async function mute(id: string) {
acting = id
const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const result = await muteSignal(id, muteUntil)
acting = null
if (result) {
toast.success('Signal muted for 1h')
load()
} else {
toast.error('Mute failed')
}
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
function bySeverity(list: Signal[]) {
return severityFilter === 'all' ? list : list.filter((s) => s.severity === severityFilter)
}
const open = $derived(bySeverity(signals.filter((s) => ['raised', 'acknowledged', 'acting'].includes(s.state))))
const muted = $derived(bySeverity(signals.filter((s) => s.state === 'muted')))
const resolved = $derived(bySeverity(signals.filter((s) => ['resolved', 'failed'].includes(s.state))))
</script>
{#snippet signalTable(list: Signal[], showActions: boolean)}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Target</Table.Head>
<Table.Head>Kind</Table.Head>
<Table.Head>Severity</Table.Head>
<Table.Head>State</Table.Head>
<Table.Head>Occurrences</Table.Head>
<Table.Head>Last seen</Table.Head>
{#if showActions}
<Table.Head class="text-right">Actions</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each list as signal (signal.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{signal.target ?? '—'}</Table.Cell>
<Table.Cell>{signal.kind}</Table.Cell>
<Table.Cell><Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge></Table.Cell>
<Table.Cell><Badge variant="outline">{signal.state}</Badge></Table.Cell>
<Table.Cell>{signal.occurrence_count}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground"
>{new Date(signal.last_seen_at).toLocaleString()}</Table.Cell
>
{#if showActions}
<Table.Cell class="flex justify-end gap-2">
{#if signal.state === 'raised'}
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => ack(signal.id)}
>Ack</Button
>
{/if}
<Button size="sm" variant="outline" disabled={acting === signal.id} onclick={() => mute(signal.id)}
>Mute 1h</Button
>
<Button size="sm" disabled={acting === signal.id} onclick={() => resolve(signal.id)}>Resolve</Button>
</Table.Cell>
{/if}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={showActions ? 7 : 6} class="text-center text-muted-foreground"
>No signals.</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/snippet}
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Signals</h1>
<Select.Root type="single" bind:value={severityFilter}>
<Select.Trigger class="w-40">
{severityFilter === 'all' ? 'All severities' : severityFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All severities</Select.Item>
<Select.Item value="critical">Critical</Select.Item>
<Select.Item value="warning">Warning</Select.Item>
<Select.Item value="info">Info</Select.Item>
</Select.Content>
</Select.Root>
</div>
<Tabs.Root value="open" class="flex flex-1 flex-col overflow-hidden">
<Tabs.List>
<Tabs.Trigger value="open">
Open {#if open.length}<Badge variant="destructive" class="ml-1">{open.length}</Badge>{/if}
</Tabs.Trigger>
<Tabs.Trigger value="muted">Muted</Tabs.Trigger>
<Tabs.Trigger value="resolved">Resolved</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="open" class="flex-1 overflow-auto">
{@render signalTable(open, true)}
</Tabs.Content>
<Tabs.Content value="muted" class="flex-1 overflow-auto">
{@render signalTable(muted, true)}
</Tabs.Content>
<Tabs.Content value="resolved" class="flex-1 overflow-auto">
{@render signalTable(resolved, false)}
</Tabs.Content>
</Tabs.Root>
</div>