feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
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:
134
web/src/pages/Agent.svelte
Normal file
134
web/src/pages/Agent.svelte
Normal 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
131
web/src/pages/Audit.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
122
web/src/pages/Entities.svelte
Normal file
122
web/src/pages/Entities.svelte
Normal 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>
|
||||
226
web/src/pages/EntityDetail.svelte
Normal file
226
web/src/pages/EntityDetail.svelte
Normal 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
177
web/src/pages/Events.svelte
Normal 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
506
web/src/pages/Graph.svelte
Normal 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>
|
||||
91
web/src/pages/Knowledge.svelte
Normal file
91
web/src/pages/Knowledge.svelte
Normal 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
203
web/src/pages/Ops.svelte
Normal 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>
|
||||
173
web/src/pages/Overview.svelte
Normal file
173
web/src/pages/Overview.svelte
Normal 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>
|
||||
171
web/src/pages/Signals.svelte
Normal file
171
web/src/pages/Signals.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user