feat(web): ontology-driven fleet treegrid, relations grouping, dev auto-config
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user