Knowledge Base / Fleet browsing: - EntityTable renders as a treegrid (arbitrary depth, expand/collapse, ARIA row/level/expanded), grouped by parent-child relationships derived entirely from the live ontology graph (cardinality -> direction; typeDepth specificity for ties) rather than a hardcoded relationship list — see loadFleetGrouping in KnowledgeBase.svelte. - Fold Services and Storage categories into Fleet (services/pools/ volumes/datasets now nest under the compute entity or pool that provides/contains them instead of having their own browsing tabs). - Drop `cluster` entities from Fleet browsing so a host's `located-at` (site) relationship wins the tree-parent slot without needing a hardcoded priority override — member-of simply has no valid target left to point at. - Add a "show destroyed/inactive" Switch (default off) filtering on entity.state, replacing an always-on checkbox. Entity detail panel: - Split the Relations section into Outgoing/Incoming groups (relative to the viewed entity), and scope the section's count to edges actually incident to it rather than the whole depth-1 neighborhood. Dev experience: - Auto-fill the SPA's token from the dev server's own OIKOS_API_TOKEN (vite.config.ts define + main.ts, dev-only, only when unconfigured) so the "Connect to Oikos" prompt doesn't reappear on every reload. - .claude/launch.json: autoPort, since port 5173 is often already claimed by another worktree's dev server. Adds ui/checkbox and ui/switch (bits-ui primitives, following the existing shadcn-svelte wrapper pattern) and fetchOntology()/ RelationshipTypeDef to api.ts. Also fixes a missing types.ts import in api.ts (ChatEvent/MessageContent) that predates this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
266 lines
10 KiB
Svelte
266 lines
10 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'
|
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
|
|
let {
|
|
entities,
|
|
loading,
|
|
selectedSlug = null,
|
|
onSelect,
|
|
childToParent = null
|
|
}: {
|
|
entities: Entity[]
|
|
loading: boolean
|
|
selectedSlug?: string | null
|
|
onSelect: (slug: string) => void
|
|
// child entity slug -> parent entity slug, derived from the ontology
|
|
// graph (arbitrary relationship types, not a fixed list — see
|
|
// KnowledgeBase.svelte). When set, rows nest under their parent —
|
|
// possibly several levels deep (host -> lxc -> service) — instead of
|
|
// rendering flat. Since the parent for a given child can come from
|
|
// whichever relationship happened to be processed last, a cycle across
|
|
// relationship types isn't structurally impossible; `row` tracks the
|
|
// ancestor chain and drops a child that would re-enter it, rather than
|
|
// recursing forever.
|
|
childToParent?: Map<string, string> | null
|
|
} = $props()
|
|
|
|
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
|
let sortKey = $state<SortKey>('slug')
|
|
let sortDir = $state<'asc' | 'desc'>('asc')
|
|
let collapsedNodes = $state<Set<string>>(new Set())
|
|
|
|
function toggleNode(slug: string, e: Event) {
|
|
e.stopPropagation()
|
|
const next = new Set(collapsedNodes)
|
|
if (next.has(slug)) next.delete(slug)
|
|
else next.add(slug)
|
|
collapsedNodes = next
|
|
}
|
|
|
|
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
|
|
})
|
|
|
|
// ─── treegrid grouping: nest entities under their parent (per
|
|
// childToParent — host->lxc via `hosts`, lxc/vm/host->service via
|
|
// `provides`, chained to whatever depth the relationships form). An entity
|
|
// whose parent got filtered out of `entities` (e.g. by the type dropdown)
|
|
// has no parent row to nest under, so it falls back to rendering top-level
|
|
// rather than disappearing.
|
|
const childrenByParent = $derived.by(() => {
|
|
const map = new Map<string, Entity[]>()
|
|
if (!childToParent) return map
|
|
const visibleSlugs = new Set(entities.map((e) => e.slug))
|
|
for (const e of sortedEntities) {
|
|
const parentSlug = childToParent.get(e.slug)
|
|
if (parentSlug && visibleSlugs.has(parentSlug)) {
|
|
if (!map.has(parentSlug)) map.set(parentSlug, [])
|
|
map.get(parentSlug)!.push(e)
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
const nestedSlugs = $derived.by(() => {
|
|
const set = new Set<string>()
|
|
for (const children of childrenByParent.values()) for (const c of children) set.add(c.slug)
|
|
return set
|
|
})
|
|
|
|
const topLevelEntities = $derived.by(() =>
|
|
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
|
|
)
|
|
|
|
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}
|
|
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
|
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
|
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
|
|
<Table.Row
|
|
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
|
role="row"
|
|
aria-level={level}
|
|
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
|
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">
|
|
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
|
<span class="inline-flex size-3.5 shrink-0 items-center justify-center">
|
|
{#if children.length > 0}
|
|
<button
|
|
type="button"
|
|
class="rounded text-muted-foreground hover:text-foreground"
|
|
onclick={(e) => toggleNode(entity.slug, e)}
|
|
aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`}
|
|
>
|
|
{#if collapsedNodes.has(entity.slug)}
|
|
<ChevronRightIcon class="size-3.5" />
|
|
{:else}
|
|
<ChevronDownIcon class="size-3.5" />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</span>
|
|
{entity.slug}
|
|
{#if children.length > 0}
|
|
<span class="text-muted-foreground">({children.length})</span>
|
|
{/if}
|
|
</span>
|
|
</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>
|
|
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
|
{#each children as child (child.id)}
|
|
{@render row(child, level + 1, ancestorsWithSelf)}
|
|
{/each}
|
|
{/if}
|
|
{/snippet}
|
|
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
|
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
|
<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 topLevelEntities as entity (entity.id)}
|
|
{@render row(entity, 1, new Set())}
|
|
{: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}
|