feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs
with a synthesized category taxonomy built from the ontology's finer-grained
`domain` field, since layer lumped unrelated entity types (an LXC and a DNS
record and a storage volume) into one bucket. Network and Fleet each span
two domains, so the table view now fans out per-domain fetches and merges,
while the graph view maps domain->category client-side. Also carries over
several detail-panel polish items (Tasks-not-raw-executions, slug URL
encoding, MultiSelectFilter) from earlier in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:53:15 +02:00
parent f8e03806aa
commit 35c54ceef5
8 changed files with 465 additions and 168 deletions

View File

@@ -185,6 +185,7 @@ export interface Entity {
updated_at: string updated_at: string
health?: EntityHealth | null health?: EntityHealth | null
last_check_at?: string | null last_check_at?: string | null
maintenance_until?: string | null
} }
export interface EntityFilters { export interface EntityFilters {
@@ -518,14 +519,17 @@ export interface BlastRadiusItem {
} }
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> { export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`) const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
} }
// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") —
// slugs can contain '/', which must be percent-encoded or it splits the path
// into extra segments the router won't match.
export async function fetchEntity(id: string): Promise<Entity | null> { export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetchWithAuth(`${API}/entities/${id}`) const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -621,6 +625,44 @@ export async function fetchEntityExecutions(entityId: string): Promise<Execution
return data.items ?? [] return data.items ?? []
} }
export interface EntityTask {
task: Entity
executionCount: number
}
// Executions aren't directly browsable from an entity in a useful way — what
// matters is which task/session acted on it, and how many times. There's no
// "incoming relationships" endpoint (blast_radius/graph only walks outgoing
// edges), so this composes it client-side: find executions targeting this
// entity, then check each task's own outgoing edges (task --involves--> entity
// directly, or task --involves--> execution) to attribute them. Cheap while
// the task count is small; would want a dedicated query if that changes.
export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
const [executions, tasks] = await Promise.all([
fetchEntityExecutions(entity.id),
fetchEntities({ type: 'task' })
])
const executionIds = new Set(executions.map((e) => e.id))
const results = await Promise.all(
tasks.map(async (task): Promise<EntityTask | null> => {
const g = await fetchGraph({ root: task.slug, depth: 1 })
if (!g) return null
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
const executionCount = g.edges.filter((e) => {
if (e.type !== 'involves') return false
const targetId = idBySlug.get(e.target)
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
}).length
if (!involvesThisEntity && executionCount === 0) return null
return { task, executionCount }
})
)
return results.filter((r): r is EntityTask => r !== null)
}
export interface Check { export interface Check {
id: string id: string
slug: string slug: string

44
web/src/lib/categories.ts Normal file
View File

@@ -0,0 +1,44 @@
// Browsing categories for the Knowledge Base — a coarser, more useful axis
// than the ontology's own `layer` (infrastructure/governance/cognition),
// which lumps very different things (an LXC and a DNS record and a storage
// volume) into one "infrastructure" bucket. Built from the ontology's
// `domain` field instead, which already draws these lines; this just
// groups the 9 domains into 6 browsing-sized buckets.
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
export const categories: { id: Category; label: string }[] = [
{ id: 'fleet', label: 'Fleet' },
{ id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' },
{ id: 'storage', label: 'Storage' },
{ id: 'identity', label: 'Identity' },
{ id: 'knowledge', label: 'Knowledge' }
]
// entity_types.domain -> Category. `external` folds into Network (isp-link,
// domain-registration are network-adjacent); `physical` folds into Fleet
// (ups/sensor/site support compute, browsing them separately fragments
// "what's running where"). `meta` (the abstract root "entity" type) has no
// category — it's never instantiated directly.
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
network: 'network',
external: 'network',
compute: 'fleet',
physical: 'fleet',
software: 'services',
storage: 'storage',
identity: 'identity',
cognition: 'knowledge'
}
export function domainToCategory(domain: string): Category | undefined {
return DOMAIN_TO_CATEGORY[domain]
}
// A category maps to one domain in most cases, two for Network and Fleet —
// used to build the domain-filtered fetches for the table view.
export function domainsForCategory(category: Category): string[] {
return Object.entries(DOMAIN_TO_CATEGORY)
.filter(([, c]) => c === category)
.map(([domain]) => domain)
}

View File

@@ -18,16 +18,16 @@
let open = $state(defaultOpen) let open = $state(defaultOpen)
</script> </script>
<Collapsible.Root bind:open class="rounded-lg border bg-card"> <Collapsible.Root bind:open class="rounded-md 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"> <Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
<span class="text-sm font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span> <span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
<ChevronDownIcon <ChevronDownIcon
class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}" class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
aria-hidden="true" aria-hidden="true"
/> />
</Collapsible.Trigger> </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"> <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"> <div class="border-t px-2.5 py-2">
{@render children()} {@render children()}
</div> </div>
</Collapsible.Content> </Collapsible.Content>

View File

@@ -8,7 +8,7 @@
fetchMetrics, fetchMetrics,
fetchEntityEvents, fetchEntityEvents,
fetchEntitySignals, fetchEntitySignals,
fetchEntityExecutions, fetchEntityTasks,
fetchEntityKnowledge, fetchEntityKnowledge,
fetchChecksForTarget, fetchChecksForTarget,
fetchAgentActivity, fetchAgentActivity,
@@ -21,13 +21,13 @@
type Relationship, type Relationship,
type MetricSeries, type MetricSeries,
type Signal, type Signal,
type Execution, type EntityTask,
type KnowledgeHit, type KnowledgeHit,
type Check, type Check,
type AgentActivity, type AgentActivity,
type AuditEntry type AuditEntry
} from '$lib/api' } from '$lib/api'
import { relativeTime } from '$lib/utils' import { relativeTime, truncateMiddle } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events' import type { OikosEvent } from '$lib/stores/events'
import DetailSection from '$lib/components/DetailSection.svelte' import DetailSection from '$lib/components/DetailSection.svelte'
import { Badge } from '$lib/components/ui/badge' import { Badge } from '$lib/components/ui/badge'
@@ -42,7 +42,7 @@
let metrics = $state<MetricSeries[]>([]) let metrics = $state<MetricSeries[]>([])
let events = $state<OikosEvent[]>([]) let events = $state<OikosEvent[]>([])
let signals = $state<Signal[]>([]) let signals = $state<Signal[]>([])
let executions = $state<Execution[]>([]) let tasks = $state<EntityTask[]>([])
let knowledge = $state<KnowledgeHit[]>([]) let knowledge = $state<KnowledgeHit[]>([])
let checks = $state<Check[]>([]) let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([]) let agentActivity = $state<AgentActivity[]>([])
@@ -58,12 +58,12 @@
loading = false loading = false
return return
} }
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([ const [graphView, m, ev, sig, tk, kh, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }), fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id), fetchMetrics(entity.id),
fetchEntityEvents(entity.id), fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id), fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id), fetchEntityTasks(entity),
fetchEntityKnowledge(entity.id), fetchEntityKnowledge(entity.id),
fetchChecksForTarget(entity.slug), fetchChecksForTarget(entity.slug),
fetchAgentActivity({ entity_id: entity.id, limit: 50 }), fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
@@ -73,7 +73,7 @@
metrics = m metrics = m
events = ev events = ev
signals = sig signals = sig
executions = exec tasks = tk
knowledge = kh knowledge = kh
checks = ch checks = ch
agentActivity = aa agentActivity = aa
@@ -174,33 +174,113 @@
toast.error('Failed to update check') toast.error('Failed to update check')
} }
} }
// Attributes are freeform (no attribute_schema on most entity types), so
// the generic key/value list was truncating anything long — including a
// document's whole changelog — to an unreadable single line. Recognize a
// few common shapes and render them properly instead of hiding them.
interface ChangelogEntry {
date?: string
title?: string
body?: string
}
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
function isChangelog(value: unknown): value is ChangelogEntry[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
)
}
function isFlatObject(value: unknown): value is Record<string, unknown> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
)
}
type AttributeRow =
| { key: string; kind: 'long-text'; value: string }
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
| { key: string; kind: 'simple'; value: unknown }
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
return Object.entries(attrs).map(([key, value]): AttributeRow => {
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
return { key, kind: 'long-text', value }
}
if (isChangelog(value)) return { key, kind: 'changelog', value }
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
return { key, kind: 'simple', value }
})
}
</script> </script>
<div class="flex h-full flex-col gap-3 overflow-y-auto p-4 md:p-6"> <div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
{#if loading} {#if loading}
<Skeleton class="h-8 w-48" /> <Skeleton class="h-6 w-48" />
<Skeleton class="h-9 w-full" /> <Skeleton class="h-8 w-full" />
<Skeleton class="h-9 w-full" /> <Skeleton class="h-8 w-full" />
<Skeleton class="h-9 w-full" /> <Skeleton class="h-8 w-full" />
{:else if !entity} {:else if !entity}
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p> <p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
{:else} {:else}
<div class="flex flex-wrap items-center gap-2"> <h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
<Badge variant="outline">{entity.type}</Badge>
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{/if}
</div>
<DetailSection title="Monitoring" count={checks.length} defaultOpen={checks.length > 0}> {#snippet detailsContent()}
<div class="flex flex-col gap-1.5"> <dl class="flex flex-col gap-1 text-xs">
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Type</dt>
<dd><Badge variant="outline">{entity.type}</Badge></dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">State</dt>
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground"></span>{/if}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Health</dt>
<dd>
{#if entity.health}
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{:else}
<span class="text-muted-foreground">not monitored</span>
{/if}
</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Version</dt>
<dd>{entity.version}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Created</dt>
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
</div>
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
<dt class="shrink-0 text-muted-foreground">Updated</dt>
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
</div>
{#if entity.maintenance_until}
<div class="flex items-center justify-between gap-3">
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
</div>
{/if}
</dl>
{/snippet}
{#snippet monitoringContent()}
<div class="flex flex-col gap-1">
{#each checks as check (check.id)} {#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 justify-between gap-2 rounded-md border px-2 py-1 text-xs">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Badge variant="outline" class="font-mono">{check.kind}</Badge> <Badge variant="outline" class="font-mono">{check.kind}</Badge>
<span class="text-muted-foreground">every {check.interval_s}s</span> <span class="text-muted-foreground">every {check.interval_s}s</span>
@@ -218,46 +298,85 @@
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p> <p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Attributes" count={Object.keys(entity.attributes ?? {}).length} defaultOpen={!!entity.attributes && Object.keys(entity.attributes).length > 0}> {#snippet attributesContent()}
{#if entity.attributes && Object.keys(entity.attributes).length} {#if entity.attributes && Object.keys(entity.attributes).length}
<dl class="flex flex-col gap-1.5 text-xs"> {@const rows = classifyAttributes(entity.attributes)}
{#each Object.entries(entity.attributes) as [key, value]} <div class="flex flex-col gap-2 text-xs">
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0"> {#each rows as row (row.key)}
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt> {#if row.kind === 'long-text'}
<dd class="min-w-0 flex-1 truncate text-right"> <div class="flex flex-col gap-0.5">
{typeof value === 'object' ? JSON.stringify(value) : String(value)} <p class="font-mono text-muted-foreground">{row.key}</p>
</dd> <p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
</div> </div>
{:else if row.kind === 'changelog'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
<div class="flex flex-col gap-1">
{#each row.value as entry}
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
<div class="flex items-baseline gap-1.5">
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
</div>
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
</div>
{/each}
</div>
</div>
{:else if row.kind === 'flat-object'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key}</p>
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
{#each Object.entries(row.value) as [subKey, subValue]}
<div class="flex items-start justify-between gap-3">
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
</div>
{/each}
</dl>
</div>
{:else}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
<dd class="min-w-0 flex-1 break-words text-right">
{#if row.value !== null && typeof row.value === 'object'}
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
{:else}
{String(row.value)}
{/if}
</dd>
</div>
{/if}
{/each} {/each}
</dl> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">No attributes.</p> <p class="text-xs text-muted-foreground">No attributes.</p>
{/if} {/if}
</DetailSection> {/snippet}
<DetailSection title="Relations" count={relations.length} defaultOpen={relations.length > 0}> {#snippet relationsContent()}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each relations as rel} {#each relations as rel}
<div class="flex items-center gap-1 font-mono text-xs"> <div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity} {#if onSelectEntity}
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.source)}>{rel.source}</button> <button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
<span class="text-muted-foreground">{rel.type}</span> <span class="shrink-0 text-muted-foreground">{rel.type}</span>
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.target)}>{rel.target}</button> <button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
{:else} {:else}
<span>{rel.source}</span> <span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="text-muted-foreground">{rel.type}</span> <span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span>{rel.target}</span> <span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if} {/if}
</div> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">No direct relations.</p> <p class="text-xs text-muted-foreground">No direct relations.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Metrics" count={metrics.length} defaultOpen={metrics.length > 0}> {#snippet metricsContent()}
{#if metrics.length} {#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2"> <div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
{#each metrics as series (series.metric)} {#each metrics as series (series.metric)}
@@ -270,12 +389,12 @@
{:else} {:else}
<p class="text-xs text-muted-foreground">No metrics tracked.</p> <p class="text-xs text-muted-foreground">No metrics tracked.</p>
{/if} {/if}
</DetailSection> {/snippet}
<DetailSection title="Signals" count={signals.length} defaultOpen={signals.length > 0}> {#snippet signalsContent()}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-1.5">
{#each signals as signal (signal.id)} {#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 flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span> <span>{signal.kind}</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -314,22 +433,35 @@
<p class="text-xs text-muted-foreground">None.</p> <p class="text-xs text-muted-foreground">None.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Executions" count={executions.length} defaultOpen={executions.length > 0}> {#snippet tasksContent()}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each executions as execution (execution.id)} {#each tasks as { task, executionCount } (task.id)}
<div class="flex items-center justify-between text-xs"> {@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
<span>{execution.action}</span> {@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
<Badge variant="outline">{execution.status}</Badge> <div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
{#if onSelectEntity}
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
{title}
</button>
{:else}
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
{/if}
<div class="flex shrink-0 items-center gap-1">
{#if outcome}
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
{/if}
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
</div>
</div> </div>
{:else} {:else}
<p class="text-xs text-muted-foreground">None.</p> <p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Knowledge" count={knowledge.length} defaultOpen={knowledge.length > 0}> {#snippet knowledgeContent()}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each knowledge as hit (hit.id)} {#each knowledge as hit (hit.id)}
<div class="text-xs"> <div class="text-xs">
@@ -339,10 +471,10 @@
<p class="text-xs text-muted-foreground">None linked.</p> <p class="text-xs text-muted-foreground">None linked.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Recent events" count={events.length} defaultOpen={events.length > 0}> {#snippet eventsContent()}
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto"> <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each events as ev (ev.id)} {#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs"> <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="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
@@ -352,12 +484,12 @@
<p class="text-xs text-muted-foreground">No events yet.</p> <p class="text-xs text-muted-foreground">No events yet.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Agent activity" count={agentActivity.length} defaultOpen={agentActivity.length > 0}> {#snippet agentActivityContent()}
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto"> <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each agentActivity as activity (activity.id)} {#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 flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span> <span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge> <Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
@@ -370,12 +502,12 @@
<p class="text-xs text-muted-foreground">No agent activity.</p> <p class="text-xs text-muted-foreground">No agent activity.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
<DetailSection title="Audit trail" count={auditEntries.length} defaultOpen={auditEntries.length > 0}> {#snippet auditContent()}
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto"> <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each auditEntries as entry (entry.id)} {#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 flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span> <span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge> <Badge variant="outline">{entry.actor_type}</Badge>
@@ -386,6 +518,26 @@
<p class="text-xs text-muted-foreground">No audit entries.</p> <p class="text-xs text-muted-foreground">No audit entries.</p>
{/each} {/each}
</div> </div>
</DetailSection> {/snippet}
{@const sections = [
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
{#each sections as section (section.key)}
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
{@render section.content()}
</DetailSection>
{/each}
{/if} {/if}
</div> </div>

View File

@@ -3,6 +3,7 @@
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force' 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 { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events' import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { domainToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo { export interface GraphInfo {
@@ -15,7 +16,7 @@
} }
let { let {
layer, category,
selectedSlug = null, selectedSlug = null,
onSelect, onSelect,
root = $bindable(''), root = $bindable(''),
@@ -27,9 +28,9 @@
activeRelTypes = $bindable(new Set<string>()), activeRelTypes = $bindable(new Set<string>()),
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 }) info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: { }: {
layer: string category: Category
selectedSlug?: string | null selectedSlug?: string | null
onSelect: (slug: string) => void onSelect: (slug: string | null) => void
root?: string root?: string
depth: number depth: number
search: string search: string
@@ -64,9 +65,9 @@
let links = $state<Link[]>([]) let links = $state<Link[]>([])
let sim: Simulation<Node, Link> | null = null let sim: Simulation<Node, Link> | null = null
// type → ontology layer, so the graph can be scoped client-side (the graph // type → browsing category, so the graph can be scoped client-side (the
// endpoint itself has no layer param). // graph endpoint itself has no category/domain param).
let typeLayer = $state<Map<string, string>>(new Map()) let typeCategory = $state<Map<string, Category>>(new Map())
let hoveredId = $state<string | null>(null) let hoveredId = $state<string | null>(null)
@@ -107,11 +108,11 @@
return typeof end === 'object' ? end.id : end return typeof end === 'object' ? end.id : end
} }
// Node belongs to the active layer? (Unknown types fall back to visible so a // Node belongs to the active category? (Unknown types fall back to visible
// missing ontology entry never blanks the graph.) // so a missing ontology entry never blanks the graph.)
function inLayer(type: string): boolean { function inCategory(type: string): boolean {
const l = typeLayer.get(type) const c = typeCategory.get(type)
return l === undefined || l === layer return c === undefined || c === category
} }
async function load() { async function load() {
@@ -140,8 +141,8 @@
type: e.type type: e.type
})) }))
// Default the node/edge-type toggles to the types present in the active layer. // Default the node/edge-type toggles to the types present in the active category.
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
activeRelTypes = new Set(links.map((l) => l.type)) activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop() sim?.stop()
@@ -161,9 +162,11 @@
onMount(() => { onMount(() => {
fetchEntityTypes().then((types) => { fetchEntityTypes().then((types) => {
typeLayer = new Map(types.map((t) => [t.name, t.layer])) typeCategory = new Map(
// Re-derive the active node types now that layer membership is known. types.map((t) => [t.name, domainToCategory(t.domain)]).filter((e): e is [string, Category] => e[1] !== undefined)
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) )
// Re-derive the active node types now that category membership is known.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
}) })
load() load()
const unsubscribe = subscribeEvents() const unsubscribe = subscribeEvents()
@@ -175,10 +178,10 @@
onDestroy(() => sim?.stop()) onDestroy(() => sim?.stop())
// When the layer perspective changes, reset the node-type toggles to that layer. // When the category perspective changes, reset the node-type toggles to it.
$effect(() => { $effect(() => {
layer category
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
}) })
$effect(() => { $effect(() => {
@@ -227,8 +230,8 @@
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7) return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
} }
// Only offer node-type toggles that live in the active layer. // Only offer node-type toggles that live in the active category.
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))).sort()) const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort()) const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar. // Publish status/legend info up to the parent toolbar.
@@ -249,8 +252,8 @@
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id)) 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. // Visible = in the active category 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 visibleNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null) const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
@@ -315,14 +318,14 @@
view = { k, x: p.x - wx * k, y: p.y - wy * 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 panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
let dragState: { node: Node; moved: boolean } | null = null let dragState: { node: Node; moved: boolean } | null = null
function onBackgroundPointerDown(e: PointerEvent) { function onBackgroundPointerDown(e: PointerEvent) {
if (dragState) return if (dragState) return
;(e.currentTarget as Element).setPointerCapture(e.pointerId) ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toViewBox(e.clientX, e.clientY) const p = toViewBox(e.clientX, e.clientY)
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y } panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
} }
function onNodePointerDown(e: PointerEvent, node: Node) { function onNodePointerDown(e: PointerEvent, node: Node) {
@@ -342,7 +345,10 @@
} }
if (panState) { if (panState) {
const p = toViewBox(e.clientX, e.clientY) const p = toViewBox(e.clientX, e.clientY)
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) } const dx = p.x - panState.startX
const dy = p.y - panState.startY
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
} }
} }
@@ -356,6 +362,10 @@
if (!moved) selectNode(node) if (!moved) selectNode(node)
return return
} }
if (panState && !panState.moved) {
// Plain click on empty background (not a drag-pan) — clear selection.
onSelect(null)
}
panState = null panState = null
} }
</script> </script>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Button } from '$lib/components/ui/button'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
let {
label,
options,
selected = $bindable(),
colorFor
}: {
label: string
options: string[]
selected: Set<string>
colorFor?: (option: string) => string
} = $props()
function toggle(opt: string) {
const next = new Set(selected)
if (next.has(opt)) next.delete(opt)
else next.add(opt)
selected = next
}
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
{label}
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
<DropdownMenu.Item
closeOnSelect={false}
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
class="text-xs text-muted-foreground"
>
{allSelected ? 'Deselect all' : 'Select all'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#each options as opt}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selected.has(opt)}
onCheckedChange={() => toggle(opt)}
class="text-xs"
>
{#if colorFor}
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
{/if}
{opt}
</DropdownMenu.CheckboxItem>
{/each}
{#if options.length === 0}
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -21,6 +21,16 @@ export function relativeTime(iso: string | null | undefined): string {
return `${d}d ago`; return `${d}d ago`;
} }
// Truncates long slugs/names in the middle (keeping the "type:" prefix and
// the tail visible) rather than at the end — for slugs the distinguishing
// part is often at both ends, e.g. "investigation:nomos/dragonfly-memlock-…"
// vs "…rlimit-type-8-in-unprivileged-lxcs".
export function truncateMiddle(s: string, maxLen = 36): string {
if (s.length <= maxLen) return s;
const keep = Math.floor((maxLen - 1) / 2);
return `${s.slice(0, keep)}${s.slice(-keep)}`;
}
// debounce wraps fn so rapid calls (e.g. keystrokes in a filter input) // debounce wraps fn so rapid calls (e.g. keystrokes in a filter input)
// collapse into one invocation after `wait`ms of silence. // collapse into one invocation after `wait`ms of silence.
export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300): T { export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300): T {

View File

@@ -5,30 +5,24 @@
import EntityTable from '$lib/components/EntityTable.svelte' import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte' import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte' import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import * as Tabs from '$lib/components/ui/tabs' import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, domainsForCategory, type Category } from '$lib/categories'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input' import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import * as Select from '$lib/components/ui/select' import * as Select from '$lib/components/ui/select'
import NetworkIcon from '@lucide/svelte/icons/share-2' import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2' import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed' import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
import XIcon from '@lucide/svelte/icons/x'
type Layer = 'infrastructure' | 'governance' | 'cognition'
type View = 'graph' | 'table' 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 { function loadView(): View {
if (typeof localStorage === 'undefined') return 'graph' if (typeof localStorage === 'undefined') return 'graph'
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph' return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
} }
let layer = $state<Layer>('infrastructure') let category = $state<Category>('fleet')
let view = $state<View>(loadView()) let view = $state<View>(loadView())
let selectedSlug = $state<string | null>(null) let selectedSlug = $state<string | null>(null)
@@ -37,7 +31,7 @@
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-view', v) if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-view', v)
} }
function select(slug: string) { function select(slug: string | null) {
selectedSlug = slug selectedSlug = slug
} }
@@ -53,7 +47,9 @@
async function loadTable() { async function loadTable() {
tableLoading = true tableLoading = true
tableEntities = await fetchEntities({ layer }) const domains = domainsForCategory(category)
const results = await Promise.all(domains.map((domain) => fetchEntities({ domain })))
tableEntities = results.flat()
tableLoading = false tableLoading = false
} }
@@ -64,7 +60,7 @@
$effect(() => { $effect(() => {
if (view !== 'table') return if (view !== 'table') return
layer category
loadTable() loadTable()
}) })
@@ -106,11 +102,8 @@
graphResetToken++ graphResetToken++
} }
function toggleSet(set: Set<string>, value: string): Set<string> { function relColorFor(type: string): string {
const next = new Set(set) return graphInfo.relColors.get(type) ?? '#30363d'
if (next.has(value)) next.delete(value)
else next.add(value)
return next
} }
// ─── resizable browse/detail split (pattern from Chat.svelte) ───────── // ─── resizable browse/detail split (pattern from Chat.svelte) ─────────
@@ -145,15 +138,20 @@
</script> </script>
<div class="flex h-full flex-col gap-3 p-4"> <div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: layer perspective + view toggle --> <!-- toolbar: category perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Tabs.Root value={layer} onValueChange={(v) => (layer = v as Layer)}> <div class="inline-flex overflow-hidden rounded-md border">
<Tabs.List class="h-8"> {#each categories as c}
{#each layers as l} <Button
<Tabs.Trigger value={l.id} class="text-xs">{l.label}</Tabs.Trigger> variant={category === c.id ? 'secondary' : 'ghost'}
{/each} size="sm"
</Tabs.List> class="h-8 rounded-none border-0"
</Tabs.Root> onclick={() => (category = c.id)}
>
{c.label}
</Button>
{/each}
</div>
<div class="ml-auto inline-flex overflow-hidden rounded-md border"> <div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button <Button
@@ -200,42 +198,12 @@
<LocateFixedIcon class="mr-1 size-3.5" /> <LocateFixedIcon class="mr-1 size-3.5" />
Reset Reset
</Button> </Button>
<MultiSelectFilter label="Nodes" options={graphInfo.allNodeTypes} bind:selected={graphActiveNodeTypes} />
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="ml-auto text-xs text-muted-foreground"> <span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}% {graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span> </span>
</div> </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} {/if}
<!-- resizable browse | detail split --> <!-- resizable browse | detail split -->
@@ -243,7 +211,7 @@
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
{#if view === 'graph'} {#if view === 'graph'}
<EntityGraph <EntityGraph
{layer} {category}
{selectedSlug} {selectedSlug}
onSelect={select} onSelect={select}
bind:root={graphRoot} bind:root={graphRoot}
@@ -275,9 +243,16 @@
<div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px"> <div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px">
{#if selectedSlug} {#if selectedSlug}
{#key selectedSlug} <div class="flex shrink-0 items-center justify-end border-b p-1">
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} /> <Button variant="ghost" size="icon" class="size-7" onclick={() => select(null)} aria-label="Close detail panel">
{/key} <XIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1">
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
</div>
{:else} {:else}
<div class="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground"> <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. Select an entity to see its detail.