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:
@@ -5,7 +5,8 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"runtimeExecutable": "sh",
|
"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"],
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { fetchWithAuth } from './config'
|
import { fetchWithAuth } from './config'
|
||||||
|
import type { ChatEvent, MessageContent } from './types'
|
||||||
|
|
||||||
|
export type { ChatEvent }
|
||||||
|
|
||||||
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
||||||
// origin (relative vs. configured apiUrl) fresh on every call via
|
// origin (relative vs. configured apiUrl) fresh on every call via
|
||||||
@@ -221,13 +224,33 @@ export interface EntityType {
|
|||||||
description?: string | null
|
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
|
// 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).
|
// this to scope the graph client-side (the entities table filters server-side).
|
||||||
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
||||||
const res = await fetchWithAuth(`${API}/ontology`)
|
return (await fetchOntology()).entityTypes
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
return data.entity_types ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventFilters {
|
export interface EventFilters {
|
||||||
|
|||||||
@@ -3,32 +3,33 @@
|
|||||||
// which lumps very different things (an LXC and a DNS record and a storage
|
// which lumps very different things (an LXC and a DNS record and a storage
|
||||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||||
// `domain` field instead, which already draws these lines; this just
|
// `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'
|
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 }[] = [
|
export const categories: { id: Category; label: string }[] = [
|
||||||
{ id: 'fleet', label: 'Fleet' },
|
{ id: 'fleet', label: 'Fleet' },
|
||||||
{ id: 'network', label: 'Network' },
|
{ id: 'network', label: 'Network' },
|
||||||
{ id: 'services', label: 'Services' },
|
|
||||||
{ id: 'storage', label: 'Storage' },
|
|
||||||
{ id: 'identity', label: 'Identity' },
|
{ id: 'identity', label: 'Identity' },
|
||||||
{ id: 'knowledge', label: 'Knowledge' }
|
{ id: 'knowledge', label: 'Knowledge' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||||
// domain-registration are network-adjacent); `physical` folds into Fleet
|
// domain-registration are network-adjacent); `physical`, `software`, and
|
||||||
// (ups/sensor/site support compute, browsing them separately fragments
|
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
|
||||||
// "what's running where"). `meta` (the abstract root "entity" type) and
|
// nest under the compute entity that provides them, and pools/volumes/
|
||||||
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
|
// 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> = {
|
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||||
network: 'network',
|
network: 'network',
|
||||||
external: 'network',
|
external: 'network',
|
||||||
compute: 'fleet',
|
compute: 'fleet',
|
||||||
physical: 'fleet',
|
physical: 'fleet',
|
||||||
software: 'services',
|
software: 'fleet',
|
||||||
storage: 'storage',
|
storage: 'fleet',
|
||||||
identity: 'identity'
|
identity: 'identity'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
let actingSignal = $state<string | null>(null)
|
let actingSignal = $state<string | null>(null)
|
||||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
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) {
|
async function load(s: string) {
|
||||||
loading = true
|
loading = true
|
||||||
entity = await fetchEntity(s)
|
entity = await fetchEntity(s)
|
||||||
@@ -378,9 +387,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet relationsContent()}
|
{#snippet relationRow(rel: Relationship)}
|
||||||
<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">
|
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||||
{#if onSelectEntity}
|
{#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>
|
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||||
@@ -392,10 +399,35 @@
|
|||||||
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet relationsContent()}
|
||||||
|
{#if outgoingRelations.length === 0 && incomingRelations.length === 0}
|
||||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
<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}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#snippet metricsContent()}
|
{#snippet metricsContent()}
|
||||||
@@ -547,7 +579,7 @@
|
|||||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
{ 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: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||||
|
|||||||
@@ -7,22 +7,44 @@
|
|||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-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 {
|
let {
|
||||||
entities,
|
entities,
|
||||||
loading,
|
loading,
|
||||||
selectedSlug = null,
|
selectedSlug = null,
|
||||||
onSelect
|
onSelect,
|
||||||
|
childToParent = null
|
||||||
}: {
|
}: {
|
||||||
entities: Entity[]
|
entities: Entity[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
selectedSlug?: string | null
|
selectedSlug?: string | null
|
||||||
onSelect: (slug: string) => void
|
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()
|
} = $props()
|
||||||
|
|
||||||
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
||||||
let sortKey = $state<SortKey>('slug')
|
let sortKey = $state<SortKey>('slug')
|
||||||
let sortDir = $state<'asc' | 'desc'>('asc')
|
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) {
|
function sortBy(key: SortKey) {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@@ -52,6 +74,36 @@
|
|||||||
return sorted
|
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' {
|
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||||
if (!state) return 'outline'
|
if (!state) return 'outline'
|
||||||
if (state === 'active' || state === 'healthy') return 'default'
|
if (state === 'active' || state === 'healthy') return 'default'
|
||||||
@@ -124,27 +176,42 @@
|
|||||||
</button>
|
</button>
|
||||||
</Table.Head>
|
</Table.Head>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||||
<Table.Root>
|
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
||||||
<Table.Header>
|
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
|
||||||
<Table.Row>
|
|
||||||
{@render sortHead('slug', 'Slug')}
|
|
||||||
{@render sortHead('type', 'Type')}
|
|
||||||
{@render sortHead('name', 'Name')}
|
|
||||||
{@render sortHead('state', 'State')}
|
|
||||||
{@render sortHead('health', 'Health')}
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each sortedEntities as entity (entity.id)}
|
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||||
role="button"
|
role="row"
|
||||||
|
aria-level={level}
|
||||||
|
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
onclick={() => onSelect(entity.slug)}
|
onclick={() => onSelect(entity.slug)}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); 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 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><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||||
<Table.Cell>{entity.name}</Table.Cell>
|
<Table.Cell>{entity.name}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
@@ -165,6 +232,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</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 role={childToParent ? 'treegrid' : undefined}>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{@render sortHead('slug', 'Slug')}
|
||||||
|
{@render sortHead('type', 'Type')}
|
||||||
|
{@render sortHead('name', 'Name')}
|
||||||
|
{@render sortHead('state', 'State')}
|
||||||
|
{@render sortHead('health', 'Health')}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each topLevelEntities as entity (entity.id)}
|
||||||
|
{@render row(entity, 1, new Set())}
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
||||||
|
|||||||
36
web/src/lib/components/ui/checkbox/checkbox.svelte
Normal file
36
web/src/lib/components/ui/checkbox/checkbox.svelte
Normal 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>
|
||||||
1
web/src/lib/components/ui/checkbox/index.ts
Normal file
1
web/src/lib/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from './checkbox.svelte'
|
||||||
1
web/src/lib/components/ui/switch/index.ts
Normal file
1
web/src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from './switch.svelte'
|
||||||
27
web/src/lib/components/ui/switch/switch.svelte
Normal file
27
web/src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
import { mount } from 'svelte'
|
import { mount } from 'svelte'
|
||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
import './app.css'
|
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() {
|
function handleDesktopToken() {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
@@ -20,6 +33,7 @@ function handleDesktopToken() {
|
|||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
initConfig()
|
initConfig()
|
||||||
|
devAutoConfig()
|
||||||
handleDesktopToken()
|
handleDesktopToken()
|
||||||
|
|
||||||
mount(App, { target: document.getElementById('app')! })
|
mount(App, { target: document.getElementById('app')! })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
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 { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import EntityTable from '$lib/components/EntityTable.svelte'
|
import EntityTable from '$lib/components/EntityTable.svelte'
|
||||||
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Input } from '$lib/components/ui/input'
|
import { Input } from '$lib/components/ui/input'
|
||||||
import * as Select from '$lib/components/ui/select'
|
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 NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||||
import TableIcon from '@lucide/svelte/icons/table-2'
|
import TableIcon from '@lucide/svelte/icons/table-2'
|
||||||
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
||||||
@@ -44,12 +46,107 @@
|
|||||||
let tableLoading = $state(true)
|
let tableLoading = $state(true)
|
||||||
let query = $state('')
|
let query = $state('')
|
||||||
let typeFilter = $state('all')
|
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() {
|
async function loadTable() {
|
||||||
tableLoading = true
|
tableLoading = true
|
||||||
const filterSets = filtersForCategory(category)
|
const filterSets = filtersForCategory(category)
|
||||||
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
|
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
|
tableLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +173,10 @@
|
|||||||
return tableEntities.filter((e) => {
|
return tableEntities.filter((e) => {
|
||||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
||||||
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) 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
|
return true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -187,6 +288,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</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>
|
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -224,7 +329,7 @@
|
|||||||
bind:info={graphInfo}
|
bind:info={graphInfo}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} />
|
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} {childToParent} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
declare const __OIKOS_VERSION__: string
|
declare const __OIKOS_VERSION__: string
|
||||||
|
declare const __OIKOS_DEV_TOKEN__: string
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export default defineConfig({
|
|||||||
base: '/',
|
base: '/',
|
||||||
define: {
|
define: {
|
||||||
__OIKOS_VERSION__: JSON.stringify(`v${version}`),
|
__OIKOS_VERSION__: JSON.stringify(`v${version}`),
|
||||||
|
// Lets the dev server auto-configure the SPA with the same token it
|
||||||
|
// already injects into proxied requests (see authProxy above), so `npm
|
||||||
|
// run dev` skips the "Connect to Oikos" prompt instead of re-asking for
|
||||||
|
// a token every time localStorage gets cleared. Empty string (never a
|
||||||
|
// real prod secret — see main.ts, only consulted in import.meta.env.DEV)
|
||||||
|
// when OIKOS_API_TOKEN isn't set, so the prompt still shows if unconfigured.
|
||||||
|
__OIKOS_DEV_TOKEN__: JSON.stringify(process.env.OIKOS_API_TOKEN ?? ''),
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { $lib: '/src/lib' }
|
alias: { $lib: '/src/lib' }
|
||||||
|
|||||||
Reference in New Issue
Block a user