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,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, type Entity } from '$lib/api'
import { fetchEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } 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'
@@ -10,6 +10,8 @@
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
@@ -44,12 +46,107 @@
let tableLoading = $state(true)
let query = $state('')
let typeFilter = $state('all')
let showInactive = $state(false)
// child entity slug -> parent entity slug, derived from the ontology graph
// (see loadFleetGrouping). Only populated for the fleet category — feeds
// EntityTable's treegrid grouping, nesting e.g.
// cluster -> host -> lxc -> service, or storage-pool -> volume -> dataset.
let childToParent = $state<Map<string, string> | null>(null)
let ontologyCache: Promise<Ontology> | null = null
function isOrDescendsFrom(byName: Map<string, EntityType>, typeName: string, ancestor: string): boolean {
if (typeName === ancestor) return true
let t = byName.get(typeName)
for (let depth = 0; t?.parent_type && depth < 10; depth++) {
if (t.parent_type === ancestor) return true
t = byName.get(t.parent_type)
}
return false
}
// Distance from the ontology's abstract root ("entity") down to typeName —
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
// specificity score: a relationship whose parent-side type is a generic
// ancestor (e.g. `configured-by`'s source is literally `entity` — almost
// everything is configured-by a repo) is a weaker structural signal than
// one whose parent-side type is narrow and concrete (e.g. `hosts`' source
// is `machine`, `member-of`'s is `proxmox-host`).
function typeDepth(byName: Map<string, EntityType>, typeName: string): number {
let depth = 0
let t = byName.get(typeName)
for (; t?.parent_type && depth < 10; depth++) t = byName.get(t.parent_type)
return depth
}
// Nesting is derived entirely from the ontology's own relationship
// definitions — no relationship or entity type names hardcoded here. Every
// relationship whose cardinality isn't many-to-many has exactly one "one"
// side, which is the parent: one-to-many/one-to-one -> source is parent
// (e.g. machine hosts many compute-entities); many-to-one -> target is
// parent (e.g. many hosts are located-at one site). many-to-many
// relationships (mounts, stores-on, backs-up-to, ...) have no single
// parent, so they're excluded from tree nesting. A candidate parent that
// isn't actually part of the fleet set being browsed (e.g. `cluster`,
// filtered out below) is dropped rather than kept as a dangling pointer —
// that's also what lets `located-at` surface as a host's parent instead of
// `member-of` without any special-cased priority: with cluster absent,
// member-of simply has nothing valid to point at. An entity can still be
// the child end of several different *remaining* relationship types at
// once (e.g. a service is `provides`-d by its lxc AND `configured-by` a
// repo) — only one can win as its tree parent, so ties go to the more
// specific relationship (see typeDepth) rather than whichever was fetched
// last.
async function loadFleetGrouping(fleetEntities: Entity[]): Promise<Map<string, string>> {
ontologyCache ??= fetchOntology()
const { entityTypes, relationshipTypes } = await ontologyCache
const byName = new Map(entityTypes.map((t) => [t.name, t]))
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const fleetSlugs = new Set(fleetEntities.map((e) => e.slug))
// blast_radius only walks source -> target, so an entity only surfaces a
// relationship if it can be that relationship's source.
const roots = fleetEntities.filter((e) =>
hierRels.some((rt) => isOrDescendsFrom(byName, e.type, rt.source_type))
)
const pairs = await Promise.all(
roots.map(async (root) => {
const g = await fetchGraph({ root: root.slug, depth: 1, relType: relTypeNames })
return (g?.edges ?? [])
.filter((edge) => edge.source === root.slug && cardinalityByType.has(edge.type))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // root is the child; target is the "one" (parent)
: [edge.target, edge.source] // root is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
.filter(({ parent }) => fleetSlugs.has(parent))
})
)
const best = new Map<string, { parent: string; weight: number }>()
for (const { child, parent, weight } of pairs.flat()) {
const current = best.get(child)
if (!current || weight > current.weight) best.set(child, { parent, weight })
}
return new Map([...best].map(([child, { parent }]) => [child, parent]))
}
async function loadTable() {
tableLoading = true
const filterSets = filtersForCategory(category)
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
tableEntities = results.flat()
// cluster entities aren't shown in Fleet browsing — with them absent, a
// host's `member-of` edge has no valid parent to point at, so
// `located-at` (site) is the only remaining candidate and wins the
// tree-parent tie-break without a hardcoded relationship priority (see
// loadFleetGrouping).
const fetched = results.flat().filter((e) => e.type !== 'cluster')
childToParent = category === 'fleet' ? await loadFleetGrouping(fetched) : null
tableEntities = fetched
tableLoading = false
}
@@ -76,6 +173,10 @@
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
// entities with no tracked lifecycle state (state is null) aren't
// "destroyed or inactive" — only hide ones whose tracked state has
// moved off `active`.
if (!showInactive && e.state && e.state !== 'active') return false
return true
})
})
@@ -187,6 +288,10 @@
{/each}
</Select.Content>
</Select.Root>
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
@@ -224,7 +329,7 @@
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} />
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} {childToParent} />
{/if}
</div>