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>
179 lines
6.3 KiB
Svelte
179 lines
6.3 KiB
Svelte
<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}
|