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

@@ -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 },