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

@@ -1,159 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, type Entity, type EntityHealth } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { relativeTime } from '$lib/utils'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Skeleton } from '$lib/components/ui/skeleton'
import EntitySheet from '$lib/components/EntitySheet.svelte'
let sheetOpen = $state(false)
let selectedSlug = $state<string | null>(null)
function openEntity(slug: string) {
selectedSlug = slug
sheetOpen = true
}
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.by(() => {
const q = query.trim().toLowerCase()
return entities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) 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'
}
const healthDot: Record<EntityHealth, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
function healthTitle(entity: Entity): string {
if (!entity.health) return 'not monitored'
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">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>Health</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as entity (entity.id)}
<Table.Row
class="cursor-pointer"
role="button"
tabindex={0}
onclick={() => openEntity(entity.slug)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEntity(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>
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</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>
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />

View File

@@ -1,517 +0,0 @@
<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 EntitySheet from '$lib/components/EntitySheet.svelte'
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()
}
let entitySheetOpen = $state(false)
let entitySheetSlug = $state<string | null>(null)
function openEntityDetail(slug: string) {
entitySheetSlug = slug
entitySheetOpen = true
}
</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={() => openEntityDetail(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>
<EntitySheet slug={entitySheetSlug} bind:open={entitySheetOpen} />

View File

@@ -0,0 +1,288 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, type Entity } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import * as Select from '$lib/components/ui/select'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
type Layer = 'infrastructure' | 'governance' | 'cognition'
type View = 'graph' | 'table'
const layers: { id: Layer; label: string }[] = [
{ id: 'infrastructure', label: 'Infrastructure' },
{ id: 'governance', label: 'Governance' },
{ id: 'cognition', label: 'Cognition' }
]
function loadView(): View {
if (typeof localStorage === 'undefined') return 'graph'
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
}
let layer = $state<Layer>('infrastructure')
let view = $state<View>(loadView())
let selectedSlug = $state<string | null>(null)
function setView(v: View) {
view = v
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-view', v)
}
function select(slug: string) {
selectedSlug = slug
}
// ─── table data: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse
// pane, where its width is at the mercy of the divider and it would
// truncate. This also keeps the browse pane header-free, so it and the
// detail pane both start flush under the toolbar and end up the same height.
let tableEntities = $state<Entity[]>([])
let tableLoading = $state(true)
let query = $state('')
let typeFilter = $state('all')
async function loadTable() {
tableLoading = true
tableEntities = await fetchEntities({ layer })
tableLoading = false
}
onMount(() => {
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
if (view !== 'table') return
layer
loadTable()
})
$effect(() => {
const ev = $liveEvents[0]
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return
loadTable()
})
const tableTypes = $derived(Array.from(new Set(tableEntities.map((e) => e.type))).sort())
const filteredEntities = $derived.by(() => {
const q = query.trim().toLowerCase()
return tableEntities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
return true
})
})
// ─── graph controls: same reasoning as the table toolbar above — these
// live here instead of inside EntityGraph so they render at full toolbar
// width instead of being squeezed by the resizable browse pane.
let graphRoot = $state('')
let graphDepth = $state(2)
let graphSearch = $state('')
let graphReloadToken = $state(0)
let graphResetToken = $state(0)
let graphActiveNodeTypes = $state<Set<string>>(new Set())
let graphActiveRelTypes = $state<Set<string>>(new Set())
let graphInfo = $state<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
function commitGraphQuery() {
graphReloadToken++
}
function resetGraph() {
graphRoot = ''
graphSearch = ''
graphResetToken++
}
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
}
// ─── resizable browse/detail split (pattern from Chat.svelte) ─────────
const DETAIL_MIN = 320
const DETAIL_MAX = 900
function loadDetailWidth(): number {
if (typeof localStorage === 'undefined') return 420
const v = Number(localStorage.getItem('oikos-kb-detail-width'))
return v >= DETAIL_MIN && v <= DETAIL_MAX ? v : 420
}
let detailWidth = $state(loadDetailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const startX = e.clientX
const startW = detailWidth
function move(ev: PointerEvent) {
detailWidth = Math.min(DETAIL_MAX, Math.max(DETAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-detail-width', String(detailWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: layer perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3">
<Tabs.Root value={layer} onValueChange={(v) => (layer = v as Layer)}>
<Tabs.List class="h-8">
{#each layers as l}
<Tabs.Trigger value={l.id} class="text-xs">{l.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button
variant={view === 'graph' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
onclick={() => setView('graph')}
>
<NetworkIcon class="mr-1 size-3.5" /> Graph
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
onclick={() => setView('table')}
>
<TableIcon class="mr-1 size-3.5" /> Table
</Button>
</div>
</div>
{#if view === 'table'}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="h-8 max-w-xs text-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="h-8 w-40 text-xs">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each tableTypes as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-44 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-16 text-xs" onchange={commitGraphQuery} />
<Input placeholder="Search / highlight…" bind:value={graphSearch} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
</div>
{#if graphInfo.allNodeTypes.length || graphInfo.allRelTypes.length}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if graphInfo.allNodeTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
{#each graphInfo.allNodeTypes as type}
<button type="button" onclick={() => (graphActiveNodeTypes = toggleSet(graphActiveNodeTypes, type))}>
<Badge variant={graphActiveNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
</button>
{/each}
</div>
{/if}
{#if graphInfo.allRelTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
{#each graphInfo.allRelTypes as type}
{@const color = graphInfo.relColors.get(type) ?? '#30363d'}
<button type="button" onclick={() => (graphActiveRelTypes = toggleSet(graphActiveRelTypes, type))}>
<Badge
variant="outline"
class="h-5 cursor-pointer px-1.5 text-[10px] {graphActiveRelTypes.has(type) ? '' : 'opacity-35'}"
style="border-color: {color}; color: {color};"
>
{type}
</Badge>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
<!-- resizable browse | detail split -->
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{layer}
{selectedSlug}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} />
{/if}
</div>
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize detail panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px">
{#if selectedSlug}
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
{:else}
<div class="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select an entity to see its detail.
</div>
{/if}
</div>
</div>
</div>