From 258b14dcbc057b1d3f75b826d04a67657c111c3c Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 17 Jul 2026 23:26:04 +0200 Subject: [PATCH] feat(web): ontology-driven fleet treegrid, relations grouping, dev auto-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/launch.json | 3 +- web/src/lib/api.ts | 31 +++- web/src/lib/categories.ts | 21 +-- .../lib/components/EntityDetailContent.svelte | 66 ++++++-- web/src/lib/components/EntityTable.svelte | 149 ++++++++++++++---- .../components/ui/checkbox/checkbox.svelte | 36 +++++ web/src/lib/components/ui/checkbox/index.ts | 1 + web/src/lib/components/ui/switch/index.ts | 1 + .../lib/components/ui/switch/switch.svelte | 27 ++++ web/src/main.ts | 16 +- web/src/pages/KnowledgeBase.svelte | 111 ++++++++++++- web/src/vite-env.d.ts | 1 + web/vite.config.ts | 7 + 13 files changed, 403 insertions(+), 67 deletions(-) create mode 100644 web/src/lib/components/ui/checkbox/checkbox.svelte create mode 100644 web/src/lib/components/ui/checkbox/index.ts create mode 100644 web/src/lib/components/ui/switch/index.ts create mode 100644 web/src/lib/components/ui/switch/switch.svelte diff --git a/.claude/launch.json b/.claude/launch.json index a94c249..85d98b2 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "web", "runtimeExecutable": "sh", "runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"], - "port": 5173 + "port": 5173, + "autoPort": true } ] } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8cb04bb..2e3c575 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { + 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 { - 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 { diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts index e137a87..4850ca1 100644 --- a/web/src/lib/categories.ts +++ b/web/src/lib/categories.ts @@ -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 = { network: 'network', external: 'network', compute: 'fleet', physical: 'fleet', - software: 'services', - storage: 'storage', + software: 'fleet', + storage: 'fleet', identity: 'identity' } diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 8a27f61..78f22ab 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -58,6 +58,15 @@ let actingSignal = $state(null) let chartContainers: Record = {} + // `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()} -
- {#each relations as rel} -
- {#if onSelectEntity} - - —{rel.type}→ - - {:else} - {truncateMiddle(rel.source)} - —{rel.type}→ - {truncateMiddle(rel.target)} - {/if} -
+ {#snippet relationRow(rel: Relationship)} +
+ {#if onSelectEntity} + + —{rel.type}→ + {:else} -

No direct relations.

- {/each} + {truncateMiddle(rel.source)} + —{rel.type}→ + {truncateMiddle(rel.target)} + {/if}
{/snippet} + {#snippet relationsContent()} + {#if outgoingRelations.length === 0 && incomingRelations.length === 0} +

No direct relations.

+ {:else} +
+ {#if outgoingRelations.length} +
+
Outgoing ({outgoingRelations.length})
+
+ {#each outgoingRelations as rel} + {@render relationRow(rel)} + {/each} +
+
+ {/if} + {#if incomingRelations.length} +
+
Incoming ({incomingRelations.length})
+
+ {#each incomingRelations as rel} + {@render relationRow(rel)} + {/each} +
+
+ {/if} +
+ {/if} + {/snippet} + {#snippet metricsContent()} {#if metrics.length}
@@ -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 }, diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte index fbf30cc..01ca93f 100644 --- a/web/src/lib/components/EntityTable.svelte +++ b/web/src/lib/components/EntityTable.svelte @@ -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 | null } = $props() type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health' let sortKey = $state('slug') let sortDir = $state<'asc' | 'desc'>('asc') + let collapsedNodes = $state>(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() + 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() + 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 @@ {/snippet} + {#snippet row(entity: Entity, level: number, ancestors: Set)} + {@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)} + {@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))} + 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) } }} + > + + + + {#if children.length > 0} + + {/if} + + {entity.slug} + {#if children.length > 0} + ({children.length}) + {/if} + + + {entity.type} + {entity.name} + + {#if entity.state} + {entity.state} + {:else} + + {/if} + + + {#if entity.health} + + + {relativeTime(entity.last_check_at)} + + {:else} + + {/if} + + + {#if children.length > 0 && !collapsedNodes.has(entity.slug)} + {#each children as child (child.id)} + {@render row(child, level + 1, ancestorsWithSelf)} + {/each} + {/if} + {/snippet}
- + {@render sortHead('slug', 'Slug')} @@ -136,35 +250,8 @@ - {#each sortedEntities as entity (entity.id)} - onSelect(entity.slug)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }} - > - {entity.slug} - {entity.type} - {entity.name} - - {#if entity.state} - {entity.state} - {:else} - - {/if} - - - {#if entity.health} - - - {relativeTime(entity.last_check_at)} - - {:else} - - {/if} - - + {#each topLevelEntities as entity (entity.id)} + {@render row(entity, 1, new Set())} {:else} + 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() + + + + {#snippet children({ checked, indeterminate })} +
+ {#if indeterminate} + + {:else if checked} + + {/if} +
+ {/snippet} +
diff --git a/web/src/lib/components/ui/checkbox/index.ts b/web/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..034639d --- /dev/null +++ b/web/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1 @@ +export { default as Checkbox } from './checkbox.svelte' diff --git a/web/src/lib/components/ui/switch/index.ts b/web/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..b662548 --- /dev/null +++ b/web/src/lib/components/ui/switch/index.ts @@ -0,0 +1 @@ +export { default as Switch } from './switch.svelte' diff --git a/web/src/lib/components/ui/switch/switch.svelte b/web/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..646ec66 --- /dev/null +++ b/web/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts index 8f42272..1657dbf 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,7 +1,20 @@ import { mount } from 'svelte' import App from './App.svelte' import './app.css' -import { initConfig, setConfig, getConfig } from '$lib/config' +import { initConfig, setConfig, getConfig, isConfigured } from '$lib/config' + +// Dev convenience: `npm run dev` already proxies /api and /agent with +// OIKOS_API_TOKEN baked in server-side (vite.config.ts's authProxy), so the +// SPA's own token only matters for the one route that reads it from a query +// param (config.ts's sseUrl). Auto-fill it here so the "Connect to Oikos" +// prompt doesn't reappear every time localStorage is cleared — only when +// nothing's configured yet, so it never clobbers a deliberate manual +// connection (e.g. pointing dev at a remote server). +function devAutoConfig() { + if (import.meta.env.DEV && __OIKOS_DEV_TOKEN__ && !isConfigured()) { + setConfig({ apiUrl: '', token: __OIKOS_DEV_TOKEN__ }) + } +} function handleDesktopToken() { const params = new URLSearchParams(location.search) @@ -20,6 +33,7 @@ function handleDesktopToken() { function start() { initConfig() + devAutoConfig() handleDesktopToken() mount(App, { target: document.getElementById('app')! }) diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte index dfd545c..e2f4796 100644 --- a/web/src/pages/KnowledgeBase.svelte +++ b/web/src/pages/KnowledgeBase.svelte @@ -1,6 +1,6 @@