feat(web): merge Entities + Graph into a single Knowledge Base page
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Replaces the separate Entities/Graph nav items with one Knowledge Base
page that browses all entities as either a table or a force-graph,
scoped by ontology layer (Infrastructure/Governance/Cognition), with a
resizable browse/detail split instead of a slide-over sheet.

- New KnowledgeBase.svelte: layer tabs, view toggle, resizable
  browse/detail split (pattern from Chat.svelte's rail).
- EntityTable/EntityGraph extracted as presentational sub-components;
  their search/filter/root/depth toolbars live in the shared page
  toolbar (not the resizable pane) so they don't truncate when the
  divider is dragged narrow, and both views start flush with the
  detail pane for consistent height.
- EntityTable columns are sortable (slug/type/name/state/health).
- EntityDetailContent redesigned as a single-column list of
  collapsible sections (DetailSection.svelte), collapsed by default
  when empty; relation entries are clickable and select the entity in
  the browse pane + detail pane (and drill in-place in EntitySheet
  wherever it's used elsewhere in the app).
- api.ts: add layer filter to fetchEntities, add fetchEntityTypes for
  client-side graph layer scoping (the graph endpoint has no layer
  param).

Old hash routes (#/entities, #/graph) redirect to #/kb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 22:21:29 +02:00
parent d80a394b7f
commit 94c94c0758
10 changed files with 1178 additions and 875 deletions

View File

@@ -29,13 +29,13 @@
} from '$lib/api'
import { relativeTime } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import DetailSection from '$lib/components/DetailSection.svelte'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
let { slug }: { slug: string } = $props()
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
let entity = $state<Entity | null>(null)
let relations = $state<Relationship[]>([])
@@ -176,13 +176,12 @@
}
</script>
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<div class="flex h-full flex-col gap-3 overflow-y-auto p-4 md:p-6">
{#if loading}
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">
<Skeleton class="h-40 w-full" />
<Skeleton class="h-40 w-full" />
</div>
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
{:else if !entity}
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
{:else}
@@ -198,11 +197,8 @@
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Monitoring ({checks.length})</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1.5">
<DetailSection title="Monitoring" count={checks.length} defaultOpen={checks.length > 0}>
<div class="flex flex-col gap-1.5">
{#each checks as check (check.id)}
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
<div class="flex items-center gap-2">
@@ -221,202 +217,175 @@
{:else}
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
</DetailSection>
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attributes</Card.Title>
</Card.Header>
<Card.Content>
{#if entity.attributes && Object.keys(entity.attributes).length}
<dl class="flex flex-col gap-1.5 text-xs">
{#each Object.entries(entity.attributes) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
</div>
{/each}
</dl>
{:else}
<p class="text-xs text-muted-foreground">No attributes.</p>
{/if}
</Card.Content>
</Card.Root>
<DetailSection title="Attributes" count={Object.keys(entity.attributes ?? {}).length} defaultOpen={!!entity.attributes && Object.keys(entity.attributes).length > 0}>
{#if entity.attributes && Object.keys(entity.attributes).length}
<dl class="flex flex-col gap-1.5 text-xs">
{#each Object.entries(entity.attributes) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
</div>
{/each}
</dl>
{:else}
<p class="text-xs text-muted-foreground">No attributes.</p>
{/if}
</DetailSection>
<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">
<DetailSection title="Relations" count={relations.length} defaultOpen={relations.length > 0}>
<div class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.source)}>{rel.source}</button>
<span class="text-muted-foreground">{rel.type}</span>
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.target)}>{rel.target}</button>
{:else}
<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}
</div>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
</div>
</DetailSection>
{#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 @2xl:grid-cols-2">
<DetailSection title="Metrics" count={metrics.length} defaultOpen={metrics.length > 0}>
{#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl: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>
{:else}
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
{/if}
</DetailSection>
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Signals</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
{#each signals as signal (signal.id)}
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<Badge variant="outline">{signal.state}</Badge>
</div>
<DetailSection title="Signals" count={signals.length} defaultOpen={signals.length > 0}>
<div class="flex flex-col gap-2">
{#each signals as signal (signal.id)}
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<Badge variant="outline">{signal.state}</Badge>
</div>
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
<div class="flex justify-end gap-1.5">
{#if signal.state === 'raised'}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
>
{/if}
</div>
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
<div class="flex justify-end gap-1.5">
{#if signal.state === 'raised'}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
>
<Button
size="sm"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
>
</div>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/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>
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Recent events</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span class="truncate">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Agent activity</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
{/if}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
>
<Button
size="sm"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
>
</div>
<span class="truncate text-muted-foreground"
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
>
</div>
{:else}
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</Card.Content>
</Card.Root>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</div>
</DetailSection>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Audit trail</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
</div>
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
<DetailSection title="Executions" count={executions.length} defaultOpen={executions.length > 0}>
<div 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}
</div>
</DetailSection>
<DetailSection title="Knowledge" count={knowledge.length} defaultOpen={knowledge.length > 0}>
<div 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}
</div>
</DetailSection>
<DetailSection title="Recent events" count={events.length} defaultOpen={events.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span class="truncate">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</div>
</DetailSection>
<DetailSection title="Agent activity" count={agentActivity.length} defaultOpen={agentActivity.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
<span class="truncate text-muted-foreground"
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
>
</div>
{:else}
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</div>
</DetailSection>
<DetailSection title="Audit trail" count={auditEntries.length} defaultOpen={auditEntries.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
</div>
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</div>
</DetailSection>
{/if}
</div>