From 35c54ceef56751f6e67f82072c1bcae148085b72 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 09:53:15 +0200 Subject: [PATCH] feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs with a synthesized category taxonomy built from the ontology's finer-grained `domain` field, since layer lumped unrelated entity types (an LXC and a DNS record and a storage volume) into one bucket. Network and Fleet each span two domains, so the table view now fans out per-domain fetches and merges, while the graph view maps domain->category client-side. Also carries over several detail-panel polish items (Tasks-not-raw-executions, slug URL encoding, MultiSelectFilter) from earlier in this session. Co-Authored-By: Claude Sonnet 5 --- web/src/lib/api.ts | 46 ++- web/src/lib/categories.ts | 44 +++ web/src/lib/components/DetailSection.svelte | 10 +- .../lib/components/EntityDetailContent.svelte | 298 +++++++++++++----- web/src/lib/components/EntityGraph.svelte | 62 ++-- .../lib/components/MultiSelectFilter.svelte | 64 ++++ web/src/lib/utils.ts | 10 + web/src/pages/KnowledgeBase.svelte | 99 +++--- 8 files changed, 465 insertions(+), 168 deletions(-) create mode 100644 web/src/lib/categories.ts create mode 100644 web/src/lib/components/MultiSelectFilter.svelte diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8684f5a..e0ab700 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -185,6 +185,7 @@ export interface Entity { updated_at: string health?: EntityHealth | null last_check_at?: string | null + maintenance_until?: string | null } export interface EntityFilters { @@ -518,14 +519,17 @@ export interface BlastRadiusItem { } export async function fetchBlastRadius(id: string): Promise { - const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`) + const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`) if (!res.ok) return [] const data = await res.json() return data.items ?? [] } +// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") — +// slugs can contain '/', which must be percent-encoded or it splits the path +// into extra segments the router won't match. export async function fetchEntity(id: string): Promise { - const res = await fetchWithAuth(`${API}/entities/${id}`) + const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`) if (!res.ok) return null return res.json() } @@ -621,6 +625,44 @@ export async function fetchEntityExecutions(entityId: string): Promise entity +// directly, or task --involves--> execution) to attribute them. Cheap while +// the task count is small; would want a dedicated query if that changes. +export async function fetchEntityTasks(entity: Entity): Promise { + const [executions, tasks] = await Promise.all([ + fetchEntityExecutions(entity.id), + fetchEntities({ type: 'task' }) + ]) + const executionIds = new Set(executions.map((e) => e.id)) + + const results = await Promise.all( + tasks.map(async (task): Promise => { + const g = await fetchGraph({ root: task.slug, depth: 1 }) + if (!g) return null + const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug) + const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type])) + const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id])) + const executionCount = g.edges.filter((e) => { + if (e.type !== 'involves') return false + const targetId = idBySlug.get(e.target) + return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId) + }).length + if (!involvesThisEntity && executionCount === 0) return null + return { task, executionCount } + }) + ) + return results.filter((r): r is EntityTask => r !== null) +} + export interface Check { id: string slug: string diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts new file mode 100644 index 0000000..97590d5 --- /dev/null +++ b/web/src/lib/categories.ts @@ -0,0 +1,44 @@ +// Browsing categories for the Knowledge Base — a coarser, more useful axis +// than the ontology's own `layer` (infrastructure/governance/cognition), +// 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. +export type Category = 'network' | 'fleet' | 'services' | 'storage' | '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) has no +// category — it's never instantiated directly. +const DOMAIN_TO_CATEGORY: Record = { + network: 'network', + external: 'network', + compute: 'fleet', + physical: 'fleet', + software: 'services', + storage: 'storage', + identity: 'identity', + cognition: 'knowledge' +} + +export function domainToCategory(domain: string): Category | undefined { + return DOMAIN_TO_CATEGORY[domain] +} + +// A category maps to one domain in most cases, two for Network and Fleet — +// used to build the domain-filtered fetches for the table view. +export function domainsForCategory(category: Category): string[] { + return Object.entries(DOMAIN_TO_CATEGORY) + .filter(([, c]) => c === category) + .map(([domain]) => domain) +} diff --git a/web/src/lib/components/DetailSection.svelte b/web/src/lib/components/DetailSection.svelte index bebcd7a..c6ecade 100644 --- a/web/src/lib/components/DetailSection.svelte +++ b/web/src/lib/components/DetailSection.svelte @@ -18,16 +18,16 @@ let open = $state(defaultOpen) - - - {title}{count !== undefined ? ` (${count})` : ''} + + + {title}{count !== undefined ? ` (${count})` : ''} -
+
{@render children()}
diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 059276d..181e50a 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -8,7 +8,7 @@ fetchMetrics, fetchEntityEvents, fetchEntitySignals, - fetchEntityExecutions, + fetchEntityTasks, fetchEntityKnowledge, fetchChecksForTarget, fetchAgentActivity, @@ -21,13 +21,13 @@ type Relationship, type MetricSeries, type Signal, - type Execution, + type EntityTask, type KnowledgeHit, type Check, type AgentActivity, type AuditEntry } from '$lib/api' - import { relativeTime } from '$lib/utils' + import { relativeTime, truncateMiddle } from '$lib/utils' import type { OikosEvent } from '$lib/stores/events' import DetailSection from '$lib/components/DetailSection.svelte' import { Badge } from '$lib/components/ui/badge' @@ -42,7 +42,7 @@ let metrics = $state([]) let events = $state([]) let signals = $state([]) - let executions = $state([]) + let tasks = $state([]) let knowledge = $state([]) let checks = $state([]) let agentActivity = $state([]) @@ -58,12 +58,12 @@ loading = false return } - const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([ + const [graphView, m, ev, sig, tk, kh, ch, aa, au] = await Promise.all([ fetchGraph({ root: entity.id, depth: 1 }), fetchMetrics(entity.id), fetchEntityEvents(entity.id), fetchEntitySignals(entity.id), - fetchEntityExecutions(entity.id), + fetchEntityTasks(entity), fetchEntityKnowledge(entity.id), fetchChecksForTarget(entity.slug), fetchAgentActivity({ entity_id: entity.id, limit: 50 }), @@ -73,7 +73,7 @@ metrics = m events = ev signals = sig - executions = exec + tasks = tk knowledge = kh checks = ch agentActivity = aa @@ -174,33 +174,113 @@ toast.error('Failed to update check') } } + + // Attributes are freeform (no attribute_schema on most entity types), so + // the generic key/value list was truncating anything long — including a + // document's whole changelog — to an unreadable single line. Recognize a + // few common shapes and render them properly instead of hiding them. + interface ChangelogEntry { + date?: string + title?: string + body?: string + } + + const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details']) + + function isChangelog(value: unknown): value is ChangelogEntry[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v)) + ) + } + + function isFlatObject(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value as object).every((v) => v === null || typeof v !== 'object') + ) + } + + type AttributeRow = + | { key: string; kind: 'long-text'; value: string } + | { key: string; kind: 'changelog'; value: ChangelogEntry[] } + | { key: string; kind: 'flat-object'; value: Record } + | { key: string; kind: 'simple'; value: unknown } + + function classifyAttributes(attrs: Record): AttributeRow[] { + return Object.entries(attrs).map(([key, value]): AttributeRow => { + if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) { + return { key, kind: 'long-text', value } + } + if (isChangelog(value)) return { key, kind: 'changelog', value } + if (isFlatObject(value)) return { key, kind: 'flat-object', value } + return { key, kind: 'simple', value } + }) + } -
+
{#if loading} - - - - + + + + {:else if !entity}

Entity "{slug}" not found.

{:else} -
-

{entity.slug}

- {entity.type} - {#if entity.state}{entity.state}{/if} - {#if entity.health} - - - {entity.health} · checked {relativeTime(entity.last_check_at)} - - {/if} -
+

{entity.slug}

- 0}> -
+ {#snippet detailsContent()} +
+
+
Type
+
{entity.type}
+
+
+
State
+
{#if entity.state}{entity.state}{:else}{/if}
+
+
+
Health
+
+ {#if entity.health} + + + {entity.health} · checked {relativeTime(entity.last_check_at)} + + {:else} + not monitored + {/if} +
+
+
+
Version
+
{entity.version}
+
+
+
Created
+
{relativeTime(entity.created_at)}
+
+
+
Updated
+
{relativeTime(entity.updated_at)}
+
+ {#if entity.maintenance_until} +
+
Maintenance until
+
{new Date(entity.maintenance_until).toLocaleString()}
+
+ {/if} +
+ {/snippet} + + {#snippet monitoringContent()} +
{#each checks as check (check.id)} -
+
{check.kind} every {check.interval_s}s @@ -218,46 +298,85 @@

No checks configured for this entity.

{/each}
- + {/snippet} - 0}> + {#snippet attributesContent()} {#if entity.attributes && Object.keys(entity.attributes).length} -
- {#each Object.entries(entity.attributes) as [key, value]} -
-
{key}
-
- {typeof value === 'object' ? JSON.stringify(value) : String(value)} -
-
+ {@const rows = classifyAttributes(entity.attributes)} +
+ {#each rows as row (row.key)} + {#if row.kind === 'long-text'} +
+

{row.key}

+

{row.value}

+
+ {:else if row.kind === 'changelog'} +
+

{row.key} ({row.value.length})

+
+ {#each row.value as entry} +
+
+ {#if entry.date}{entry.date}{/if} + {#if entry.title}{entry.title}{/if} +
+ {#if entry.body}

{entry.body}

{/if} +
+ {/each} +
+
+ {:else if row.kind === 'flat-object'} +
+

{row.key}

+
+ {#each Object.entries(row.value) as [subKey, subValue]} +
+
{subKey}
+
{String(subValue)}
+
+ {/each} +
+
+ {:else} +
+
{row.key}
+
+ {#if row.value !== null && typeof row.value === 'object'} +
{JSON.stringify(row.value, null, 2)}
+ {:else} + {String(row.value)} + {/if} +
+
+ {/if} {/each} -
+
{:else}

No attributes.

{/if} - + {/snippet} - 0}> + {#snippet relationsContent()}
{#each relations as rel} -
+
{#if onSelectEntity} - - —{rel.type}→ - + + —{rel.type}→ + {:else} - {rel.source} - —{rel.type}→ - {rel.target} + {truncateMiddle(rel.source)} + —{rel.type}→ + {truncateMiddle(rel.target)} {/if}
{:else}

No direct relations.

{/each}
- + {/snippet} - 0}> + {#snippet metricsContent()} {#if metrics.length}
{#each metrics as series (series.metric)} @@ -270,12 +389,12 @@ {:else}

No metrics tracked.

{/if} - + {/snippet} - 0}> -
+ {#snippet signalsContent()} +
{#each signals as signal (signal.id)} -
+
{signal.kind}
@@ -314,22 +433,35 @@

None.

{/each}
- + {/snippet} - 0}> + {#snippet tasksContent()}
- {#each executions as execution (execution.id)} -
- {execution.action} - {execution.status} + {#each tasks as { task, executionCount } (task.id)} + {@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name} + {@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined} +
+ {#if onSelectEntity} + + {:else} + {title} + {/if} +
+ {#if outcome} + {outcome} + {/if} + {executionCount} action{executionCount === 1 ? '' : 's'} +
{:else} -

None.

+

No tasks have acted on this entity.

{/each}
- + {/snippet} - 0}> + {#snippet knowledgeContent()}
{#each knowledge as hit (hit.id)}
@@ -339,10 +471,10 @@

None linked.

{/each}
- + {/snippet} - 0}> -
+ {#snippet eventsContent()} +
{#each events as ev (ev.id)}
{new Date(ev.ts).toLocaleString()} @@ -352,12 +484,12 @@

No events yet.

{/each}
- + {/snippet} - 0}> -
+ {#snippet agentActivityContent()} +
{#each agentActivity as activity (activity.id)} -
+
{new Date(activity.ts).toLocaleString()} {activity.activity_type} @@ -370,12 +502,12 @@

No agent activity.

{/each}
- + {/snippet} - 0}> -
+ {#snippet auditContent()} +
{#each auditEntries as entry (entry.id)} -
+
{new Date(entry.ts).toLocaleString()} {entry.actor_type} @@ -386,6 +518,26 @@

No audit entries.

{/each}
- + {/snippet} + + {@const sections = [ + { 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: '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 }, + { key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent }, + { key: 'events', title: 'Recent events', count: events.length, content: eventsContent }, + { key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent }, + { key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent } + ].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))} + + {#each sections as section (section.key)} + 0}> + {@render section.content()} + + {/each} {/if}
diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte index 5013952..6ffad38 100644 --- a/web/src/lib/components/EntityGraph.svelte +++ b/web/src/lib/components/EntityGraph.svelte @@ -3,6 +3,7 @@ import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force' import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api' import { liveEvents, subscribeEvents } from '$lib/stores/events' + import { domainToCategory, type Category } from '$lib/categories' import { Skeleton } from '$lib/components/ui/skeleton' export interface GraphInfo { @@ -15,7 +16,7 @@ } let { - layer, + category, selectedSlug = null, onSelect, root = $bindable(''), @@ -27,9 +28,9 @@ activeRelTypes = $bindable(new Set()), info = $bindable({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 }) }: { - layer: string + category: Category selectedSlug?: string | null - onSelect: (slug: string) => void + onSelect: (slug: string | null) => void root?: string depth: number search: string @@ -64,9 +65,9 @@ let links = $state([]) let sim: Simulation | null = null - // type → ontology layer, so the graph can be scoped client-side (the graph - // endpoint itself has no layer param). - let typeLayer = $state>(new Map()) + // type → browsing category, so the graph can be scoped client-side (the + // graph endpoint itself has no category/domain param). + let typeCategory = $state>(new Map()) let hoveredId = $state(null) @@ -107,11 +108,11 @@ return typeof end === 'object' ? end.id : end } - // Node belongs to the active layer? (Unknown types fall back to visible so a - // missing ontology entry never blanks the graph.) - function inLayer(type: string): boolean { - const l = typeLayer.get(type) - return l === undefined || l === layer + // Node belongs to the active category? (Unknown types fall back to visible + // so a missing ontology entry never blanks the graph.) + function inCategory(type: string): boolean { + const c = typeCategory.get(type) + return c === undefined || c === category } async function load() { @@ -140,8 +141,8 @@ type: e.type })) - // Default the node/edge-type toggles to the types present in the active layer. - activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) + // Default the node/edge-type toggles to the types present in the active category. + activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type)) activeRelTypes = new Set(links.map((l) => l.type)) sim?.stop() @@ -161,9 +162,11 @@ onMount(() => { fetchEntityTypes().then((types) => { - typeLayer = new Map(types.map((t) => [t.name, t.layer])) - // Re-derive the active node types now that layer membership is known. - activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) + typeCategory = new Map( + types.map((t) => [t.name, domainToCategory(t.domain)]).filter((e): e is [string, Category] => e[1] !== undefined) + ) + // Re-derive the active node types now that category membership is known. + activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type)) }) load() const unsubscribe = subscribeEvents() @@ -175,10 +178,10 @@ onDestroy(() => sim?.stop()) - // When the layer perspective changes, reset the node-type toggles to that layer. + // When the category perspective changes, reset the node-type toggles to it. $effect(() => { - layer - activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type)) + category + activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type)) }) $effect(() => { @@ -227,8 +230,8 @@ return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7) } - // Only offer node-type toggles that live in the active layer. - const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))).sort()) + // Only offer node-type toggles that live in the active category. + const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort()) const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort()) // Publish status/legend info up to the parent toolbar. @@ -249,8 +252,8 @@ return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id)) }) - // Visible = in the active layer AND its node-type toggle is on. - const visibleNodeIds = $derived(new Set(nodes.filter((n) => inLayer(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id))) + // Visible = in the active category AND its node-type toggle is on. + const visibleNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id))) const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null) @@ -315,14 +318,14 @@ view = { k, x: p.x - wx * k, y: p.y - wy * k } } - let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number } | null>(null) + let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null) let dragState: { node: Node; moved: boolean } | null = null function onBackgroundPointerDown(e: PointerEvent) { if (dragState) return ;(e.currentTarget as Element).setPointerCapture(e.pointerId) const p = toViewBox(e.clientX, e.clientY) - panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y } + panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false } } function onNodePointerDown(e: PointerEvent, node: Node) { @@ -342,7 +345,10 @@ } if (panState) { const p = toViewBox(e.clientX, e.clientY) - view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) } + const dx = p.x - panState.startX + const dy = p.y - panState.startY + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true + view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy } } } @@ -356,6 +362,10 @@ if (!moved) selectNode(node) return } + if (panState && !panState.moved) { + // Plain click on empty background (not a drag-pan) — clear selection. + onSelect(null) + } panState = null } diff --git a/web/src/lib/components/MultiSelectFilter.svelte b/web/src/lib/components/MultiSelectFilter.svelte new file mode 100644 index 0000000..0c811bd --- /dev/null +++ b/web/src/lib/components/MultiSelectFilter.svelte @@ -0,0 +1,64 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + { selected = allSelected ? new Set() : new Set(options) }} + class="text-xs text-muted-foreground" + > + {allSelected ? 'Deselect all' : 'Select all'} + + + {#each options as opt} + toggle(opt)} + class="text-xs" + > + {#if colorFor} + + {/if} + {opt} + + {/each} + {#if options.length === 0} +

No types loaded yet.

+ {/if} +
+
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 38f72bf..170c254 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -21,6 +21,16 @@ export function relativeTime(iso: string | null | undefined): string { return `${d}d ago`; } +// Truncates long slugs/names in the middle (keeping the "type:" prefix and +// the tail visible) rather than at the end — for slugs the distinguishing +// part is often at both ends, e.g. "investigation:nomos/dragonfly-memlock-…" +// vs "…rlimit-type-8-in-unprivileged-lxcs". +export function truncateMiddle(s: string, maxLen = 36): string { + if (s.length <= maxLen) return s; + const keep = Math.floor((maxLen - 1) / 2); + return `${s.slice(0, keep)}…${s.slice(-keep)}`; +} + // debounce wraps fn so rapid calls (e.g. keystrokes in a filter input) // collapse into one invocation after `wait`ms of silence. export function debounce void>(fn: T, wait = 300): T { diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte index 25be65a..a64b67b 100644 --- a/web/src/pages/KnowledgeBase.svelte +++ b/web/src/pages/KnowledgeBase.svelte @@ -5,30 +5,24 @@ import EntityTable from '$lib/components/EntityTable.svelte' import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte' import EntityDetailContent from '$lib/components/EntityDetailContent.svelte' - import * as Tabs from '$lib/components/ui/tabs' + import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte' + import { categories, domainsForCategory, type Category } from '$lib/categories' import { Button } from '$lib/components/ui/button' import { Input } from '$lib/components/ui/input' - import { Badge } from '$lib/components/ui/badge' import * as Select from '$lib/components/ui/select' import NetworkIcon from '@lucide/svelte/icons/share-2' import TableIcon from '@lucide/svelte/icons/table-2' import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed' + import XIcon from '@lucide/svelte/icons/x' - type Layer = 'infrastructure' | 'governance' | 'cognition' type View = 'graph' | 'table' - const layers: { id: Layer; label: string }[] = [ - { id: 'infrastructure', label: 'Infrastructure' }, - { id: 'governance', label: 'Governance' }, - { id: 'cognition', label: 'Cognition' } - ] - function loadView(): View { if (typeof localStorage === 'undefined') return 'graph' return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph' } - let layer = $state('infrastructure') + let category = $state('fleet') let view = $state(loadView()) let selectedSlug = $state(null) @@ -37,7 +31,7 @@ if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-view', v) } - function select(slug: string) { + function select(slug: string | null) { selectedSlug = slug } @@ -53,7 +47,9 @@ async function loadTable() { tableLoading = true - tableEntities = await fetchEntities({ layer }) + const domains = domainsForCategory(category) + const results = await Promise.all(domains.map((domain) => fetchEntities({ domain }))) + tableEntities = results.flat() tableLoading = false } @@ -64,7 +60,7 @@ $effect(() => { if (view !== 'table') return - layer + category loadTable() }) @@ -106,11 +102,8 @@ graphResetToken++ } - function toggleSet(set: Set, value: string): Set { - const next = new Set(set) - if (next.has(value)) next.delete(value) - else next.add(value) - return next + function relColorFor(type: string): string { + return graphInfo.relColors.get(type) ?? '#30363d' } // ─── resizable browse/detail split (pattern from Chat.svelte) ───────── @@ -145,15 +138,20 @@
- +
- (layer = v as Layer)}> - - {#each layers as l} - {l.label} - {/each} - - +
+ {#each categories as c} + + {/each} +
+ + {graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
- - {#if graphInfo.allNodeTypes.length || graphInfo.allRelTypes.length} -
- {#if graphInfo.allNodeTypes.length} -
- Nodes - {#each graphInfo.allNodeTypes as type} - - {/each} -
- {/if} - {#if graphInfo.allRelTypes.length} -
- Edges - {#each graphInfo.allRelTypes as type} - {@const color = graphInfo.relColors.get(type) ?? '#30363d'} - - {/each} -
- {/if} -
- {/if} {/if} @@ -243,7 +211,7 @@
{#if view === 'graph'} {#if selectedSlug} - {#key selectedSlug} - - {/key} +
+ +
+
+ {#key selectedSlug} + + {/key} +
{:else}
Select an entity to see its detail.