feat(web): merge Entities + Graph into a single Knowledge Base page
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:
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Chat from './pages/Chat.svelte'
|
||||
import Overview from './pages/Overview.svelte'
|
||||
import Entities from './pages/Entities.svelte'
|
||||
import KnowledgeBase from './pages/KnowledgeBase.svelte'
|
||||
import Ops from './pages/Ops.svelte'
|
||||
import Signals from './pages/Signals.svelte'
|
||||
import Graph from './pages/Graph.svelte'
|
||||
import EntityDetail from './pages/EntityDetail.svelte'
|
||||
import Knowledge from './pages/Knowledge.svelte'
|
||||
import Learning from './pages/Learning.svelte'
|
||||
@@ -26,7 +25,6 @@
|
||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
@@ -43,6 +41,11 @@
|
||||
function sync() {
|
||||
const path = location.hash.slice(2) || 'overview'
|
||||
const [head, ...rest] = path.split('/')
|
||||
// Entities + Graph were merged into Knowledge Base — keep old links working.
|
||||
if (head === 'entities' || head === 'graph') {
|
||||
location.hash = '#/kb'
|
||||
return
|
||||
}
|
||||
page = head || 'overview'
|
||||
routeParam = rest.join('/')
|
||||
}
|
||||
@@ -64,8 +67,7 @@
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
||||
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
|
||||
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
||||
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||
@@ -180,7 +182,7 @@
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="text-base font-medium">Conversation</span>
|
||||
{:else}
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
|
||||
{/if}
|
||||
<div class="ms-auto flex items-center gap-2.5">
|
||||
{#if $summary}
|
||||
@@ -209,10 +211,8 @@
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
<Overview />
|
||||
{:else if page === 'entities'}
|
||||
<Entities />
|
||||
{:else if page === 'graph'}
|
||||
<Graph />
|
||||
{:else if page === 'kb'}
|
||||
<KnowledgeBase />
|
||||
{:else if page === 'entity' && routeParam}
|
||||
<EntityDetail slug={routeParam} />
|
||||
{:else if page === 'ops'}
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface Entity {
|
||||
export interface EntityFilters {
|
||||
type?: string
|
||||
state?: string
|
||||
domain?: string
|
||||
layer?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -197,6 +199,8 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
const params = new URLSearchParams()
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.domain) params.set('domain', filters.domain)
|
||||
if (filters.layer) params.set('layer', filters.layer)
|
||||
if (filters.q) params.set('q', filters.q)
|
||||
params.set('limit', '200')
|
||||
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
||||
@@ -205,6 +209,26 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
||||
|
||||
export interface EntityType {
|
||||
name: string
|
||||
parent_type?: string | null
|
||||
is_abstract: boolean
|
||||
domain: string
|
||||
layer: OntologyLayer
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// The graph endpoint has no layer param, so callers build a type→layer map from
|
||||
// this to scope the graph client-side (the entities table filters server-side).
|
||||
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
||||
const res = await fetchWithAuth(`${API}/ontology`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.entity_types ?? []
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
type?: string
|
||||
severity?: string
|
||||
|
||||
34
web/src/lib/components/DetailSection.svelte
Normal file
34
web/src/lib/components/DetailSection.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
title,
|
||||
count,
|
||||
defaultOpen,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
count?: number
|
||||
defaultOpen: boolean
|
||||
children: Snippet
|
||||
} = $props()
|
||||
|
||||
let open = $state(defaultOpen)
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-lg border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-3 py-2 text-left hover:bg-muted/50">
|
||||
<span class="text-sm font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<div class="border-t px-3 py-2.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -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>
|
||||
|
||||
476
web/src/lib/components/EntityGraph.svelte
Normal file
476
web/src/lib/components/EntityGraph.svelte
Normal file
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allNodeTypes: string[]
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
layer,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
activeNodeTypes = $bindable(new Set<string>()),
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
layer: string
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes?: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
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 nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → ontology layer, so the graph can be scoped client-side (the graph
|
||||
// endpoint itself has no layer param).
|
||||
let typeLayer = $state<Map<string, string>>(new Map())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Node belongs to the active layer? (Unknown types fall back to visible so a
|
||||
// missing ontology entry never blanks the graph.)
|
||||
function inLayer(type: string): boolean {
|
||||
const l = typeLayer.get(type)
|
||||
return l === undefined || l === layer
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active layer.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).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(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeLayer = new Map(types.map((t) => [t.name, t.layer]))
|
||||
// Re-derive the active node types now that layer membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
})
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the layer perspective changes, reset the node-type toggles to that layer.
|
||||
$effect(() => {
|
||||
layer
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
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)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active layer.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))).sort())
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allNodeTypes,
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
// Visible = in the active layer AND its node-type toggle is on.
|
||||
const visibleNodeIds = $derived(new Set(nodes.filter((n) => inLayer(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
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 ?? selectedId
|
||||
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 ?? selectedId
|
||||
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 = $state<{ 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
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 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 || selectedId === 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}
|
||||
@@ -3,16 +3,26 @@
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
|
||||
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
|
||||
|
||||
// Lets a relation click inside the sheet drill into that entity in place,
|
||||
// without closing/reopening. Resets to the externally-requested slug
|
||||
// whenever the caller opens the sheet on a different entity.
|
||||
let currentSlug = $state(slug)
|
||||
$effect(() => {
|
||||
currentSlug = slug
|
||||
})
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>{slug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Description>Entity detail panel</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
{#if slug}
|
||||
<EntityDetailContent {slug} />
|
||||
{#if currentSlug}
|
||||
{#key currentSlug}
|
||||
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
|
||||
{/key}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
178
web/src/lib/components/EntityTable.svelte
Normal file
178
web/src/lib/components/EntityTable.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import type { Entity, EntityHealth } from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
|
||||
let {
|
||||
entities,
|
||||
loading,
|
||||
selectedSlug = null,
|
||||
onSelect
|
||||
}: {
|
||||
entities: Entity[]
|
||||
loading: boolean
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
||||
let sortKey = $state<SortKey>('slug')
|
||||
let sortDir = $state<'asc' | 'desc'>('asc')
|
||||
|
||||
function sortBy(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey = key
|
||||
sortDir = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const healthRank: Record<EntityHealth, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
|
||||
|
||||
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
|
||||
return (entity[key] ?? '').toString().toLowerCase()
|
||||
}
|
||||
|
||||
const sortedEntities = $derived.by(() => {
|
||||
const sorted = [...entities].sort((a, b) => {
|
||||
const av = sortValue(a, sortKey)
|
||||
const bv = sortValue(b, sortKey)
|
||||
if (av < bv) return -1
|
||||
if (av > bv) return 1
|
||||
return 0
|
||||
})
|
||||
if (sortDir === 'desc') sorted.reverse()
|
||||
return sorted
|
||||
})
|
||||
|
||||
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)}`
|
||||
}
|
||||
|
||||
// Widths vary per row so the skeleton reads as text, not a stack of identical bars.
|
||||
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="h-full min-h-0 overflow-hidden 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 skeletonSlugWidths as slugWidth, i}
|
||||
<Table.Row class="hover:bg-transparent">
|
||||
<Table.Cell><Skeleton class="h-4 {slugWidth}" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-5 w-16 rounded-full" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-4 {skeletonNameWidths[i]}" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-5 w-14 rounded-full" /></Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Skeleton class="size-2 shrink-0 rounded-full" />
|
||||
<Skeleton class="h-4 w-12" />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<Table.Head>
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => sortBy(key)}>
|
||||
{label}
|
||||
{#if sortKey === key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/snippet}
|
||||
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{@render sortHead('slug', 'Slug')}
|
||||
{@render sortHead('type', 'Type')}
|
||||
{@render sortHead('name', 'Name')}
|
||||
{@render sortHead('state', 'State')}
|
||||
{@render sortHead('health', 'Health')}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sortedEntities as entity (entity.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(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 in this layer match the filter.</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
288
web/src/pages/KnowledgeBase.svelte
Normal file
288
web/src/pages/KnowledgeBase.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user