feat(web): ontology-driven fleet treegrid, relations grouping, dev auto-config
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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>
This commit is contained in:
2026-07-17 23:26:04 +02:00
parent 646373a676
commit 258b14dcbc
13 changed files with 403 additions and 67 deletions

View File

@@ -1,4 +1,7 @@
import { fetchWithAuth } from './config'
import type { ChatEvent, MessageContent } from './types'
export type { ChatEvent }
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
// origin (relative vs. configured apiUrl) fresh on every call via
@@ -221,13 +224,33 @@ export interface EntityType {
description?: string | null
}
export type RelationshipCardinality = 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'
export interface RelationshipTypeDef {
name: string
inverse?: string | null
source_type: string
target_type: string
cardinality: RelationshipCardinality
description?: string | null
}
export interface Ontology {
entityTypes: EntityType[]
relationshipTypes: RelationshipTypeDef[]
}
export async function fetchOntology(): Promise<Ontology> {
const res = await fetchWithAuth(`${API}/ontology`)
if (!res.ok) return { entityTypes: [], relationshipTypes: [] }
const data = await res.json()
return { entityTypes: data.entity_types ?? [], relationshipTypes: data.relationship_types ?? [] }
}
// 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 ?? []
return (await fetchOntology()).entityTypes
}
export interface EventFilters {

View File

@@ -3,32 +3,33 @@
// 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.
// groups the domains into browsing-sized buckets.
import type { EntityFilters } from './api'
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
export type Category = 'network' | 'fleet' | '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) and
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
// domain-registration are network-adjacent); `physical`, `software`, and
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
// nest under the compute entity that provides them, and pools/volumes/
// datasets nest under their compute entity or pool, all via EntityTable's
// treegrid) — browsing them separately fragments "what's running where".
// `meta` (the abstract root "entity" type) and `cognition` (see
// KNOWLEDGE_TYPES below) are handled outside this map.
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
network: 'network',
external: 'network',
compute: 'fleet',
physical: 'fleet',
software: 'services',
storage: 'storage',
software: 'fleet',
storage: 'fleet',
identity: 'identity'
}

View File

@@ -58,6 +58,15 @@
let actingSignal = $state<string | null>(null)
let chartContainers: Record<string, HTMLDivElement> = {}
// `relations` is the whole depth-1 neighborhood's edge set (any edge
// between any two nodes in the subgraph, e.g. two sibling LXCs' shared
// LAN), not just edges touching this entity — so "incoming"/"outgoing"
// only make sense for the subset actually incident to it.
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
const incomingRelations = $derived(
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
)
async function load(s: string) {
loading = true
entity = await fetchEntity(s)
@@ -378,26 +387,49 @@
{/if}
{/snippet}
{#snippet relationsContent()}
<div class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<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="shrink-0 text-muted-foreground">{rel.type}</span>
<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}
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{#snippet relationRow(rel: Relationship)}
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<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="shrink-0 text-muted-foreground">{rel.type}</span>
<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}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{/snippet}
{#snippet relationsContent()}
{#if outgoingRelations.length === 0 && incomingRelations.length === 0}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{:else}
<div class="flex flex-col gap-3">
{#if outgoingRelations.length}
<div>
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
<div class="flex flex-col gap-1">
{#each outgoingRelations as rel}
{@render relationRow(rel)}
{/each}
</div>
</div>
{/if}
{#if incomingRelations.length}
<div>
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
<div class="flex flex-col gap-1">
{#each incomingRelations as rel}
{@render relationRow(rel)}
{/each}
</div>
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet metricsContent()}
{#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
@@ -547,7 +579,7 @@
{ 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: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.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 },

View File

@@ -7,22 +7,44 @@
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
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) {
@@ -52,6 +74,36 @@
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'
@@ -124,8 +176,70 @@
</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>
<Table.Root role={childToParent ? 'treegrid' : undefined}>
<Table.Header>
<Table.Row>
{@render sortHead('slug', 'Slug')}
@@ -136,35 +250,8 @@
</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>
{#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"

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from 'bits-ui'
import CheckIcon from '@lucide/svelte/icons/check'
import MinusIcon from '@lucide/svelte/icons/minus'
import { cn } from '$lib/utils.js'
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: CheckboxPrimitive.RootProps = $props()
</script>
<CheckboxPrimitive.Root
bind:ref
bind:checked
bind:indeterminate
data-slot="checkbox"
class={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="flex items-center justify-center text-current transition-none">
{#if indeterminate}
<MinusIcon class="size-3.5" />
{:else if checked}
<CheckIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from './checkbox.svelte'

View File

@@ -0,0 +1 @@
export { default as Switch } from './switch.svelte'

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: SwitchPrimitive.RootProps = $props()
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class="bg-background pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
/>
</SwitchPrimitive.Root>