From 94c94c075831fce55e0a70828764b066a024df93 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 12 Jul 2026 22:21:29 +0200 Subject: [PATCH] feat(web): merge Entities + Graph into a single Knowledge Base page Replaces the separate Entities/Graph nav items with one Knowledge Base page that browses all entities as either a table or a force-graph, scoped by ontology layer (Infrastructure/Governance/Cognition), with a resizable browse/detail split instead of a slide-over sheet. - New KnowledgeBase.svelte: layer tabs, view toggle, resizable browse/detail split (pattern from Chat.svelte's rail). - EntityTable/EntityGraph extracted as presentational sub-components; their search/filter/root/depth toolbars live in the shared page toolbar (not the resizable pane) so they don't truncate when the divider is dragged narrow, and both views start flush with the detail pane for consistent height. - EntityTable columns are sortable (slug/type/name/state/health). - EntityDetailContent redesigned as a single-column list of collapsible sections (DetailSection.svelte), collapsed by default when empty; relation entries are clickable and select the entity in the browse pane + detail pane (and drill in-place in EntitySheet wherever it's used elsewhere in the app). - api.ts: add layer filter to fetchEntities, add fetchEntityTypes for client-side graph layer scoping (the graph endpoint has no layer param). Old hash routes (#/entities, #/graph) redirect to #/kb. Co-Authored-By: Claude Opus 5 --- web/src/App.svelte | 20 +- web/src/lib/api.ts | 24 + web/src/lib/components/DetailSection.svelte | 34 ++ .../lib/components/EntityDetailContent.svelte | 341 ++++++------ web/src/lib/components/EntityGraph.svelte | 476 ++++++++++++++++ web/src/lib/components/EntitySheet.svelte | 16 +- web/src/lib/components/EntityTable.svelte | 178 ++++++ web/src/pages/Entities.svelte | 159 ------ web/src/pages/Graph.svelte | 517 ------------------ web/src/pages/KnowledgeBase.svelte | 288 ++++++++++ 10 files changed, 1178 insertions(+), 875 deletions(-) create mode 100644 web/src/lib/components/DetailSection.svelte create mode 100644 web/src/lib/components/EntityGraph.svelte create mode 100644 web/src/lib/components/EntityTable.svelte delete mode 100644 web/src/pages/Entities.svelte delete mode 100644 web/src/pages/Graph.svelte create mode 100644 web/src/pages/KnowledgeBase.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index ff00f94..2d491aa 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,10 +1,9 @@ + + + + {title}{count !== undefined ? ` (${count})` : ''} + + +
+ {@render children()} +
+
+
diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 0986253..059276d 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -29,13 +29,13 @@ } from '$lib/api' import { relativeTime } from '$lib/utils' import type { OikosEvent } from '$lib/stores/events' - import * as Card from '$lib/components/ui/card' + import DetailSection from '$lib/components/DetailSection.svelte' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Skeleton } from '$lib/components/ui/skeleton' import { toast } from 'svelte-sonner' - let { slug }: { slug: string } = $props() + let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props() let entity = $state(null) let relations = $state([]) @@ -176,13 +176,12 @@ } -
+
{#if loading} -
- - -
+ + + {:else if !entity}

Entity "{slug}" not found.

{:else} @@ -198,11 +197,8 @@ {/if}
- - - Monitoring ({checks.length}) - - + 0}> +
{#each checks as check (check.id)}
@@ -221,202 +217,175 @@ {:else}

No checks configured for this entity.

{/each} - - +
+ -
- - - Attributes - - - {#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)} -
-
- {/each} -
- {:else} -

No attributes.

- {/if} -
-
+ 0}> + {#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)} +
+
+ {/each} +
+ {:else} +

No attributes.

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

No direct relations.

- {/each} - - -
+ {/if} +
+ {:else} +

No direct relations.

+ {/each} +
+ - {#if metrics.length} - - - Metrics - - + 0}> + {#if metrics.length} +
{#each metrics as series (series.metric)}

{series.metric} ({series.rollup})

{/each} - - - {/if} +
+ {:else} +

No metrics tracked.

+ {/if} +
-
- - - Signals - - - {#each signals as signal (signal.id)} -
-
- {signal.kind} -
- {signal.severity} - {signal.state} -
+ 0}> +
+ {#each signals as signal (signal.id)} +
+
+ {signal.kind} +
+ {signal.severity} + {signal.state}
- {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} -
- {#if signal.state === 'raised'} - - {/if} +
+ {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} +
+ {#if signal.state === 'raised'} ackOpenSignal(signal.id)}>Ack - -
- {/if} -
- {:else} -

None.

- {/each} - - - - - - Executions - - - {#each executions as execution (execution.id)} -
- {execution.action} - {execution.status} -
- {:else} -

None.

- {/each} -
-
- - - - Knowledge - - - {#each knowledge as hit (hit.id)} -
- {hit.type}{hit.title} -
- {:else} -

None linked.

- {/each} -
-
-
- -
- - - Recent events - - - {#each events as ev (ev.id)} -
- {new Date(ev.ts).toLocaleString()} - {ev.type} -
- {:else} -

No events yet.

- {/each} -
-
- - - - Agent activity - - - {#each agentActivity as activity (activity.id)} -
-
- {new Date(activity.ts).toLocaleString()} - {activity.activity_type} + {/if} + +
- {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} -
- {:else} -

No agent activity.

- {/each} -
-
+ {/if} +
+ {:else} +

None.

+ {/each} +
+
- - - Audit trail - - - {#each auditEntries as entry (entry.id)} -
-
- {new Date(entry.ts).toLocaleString()} - {entry.actor_type} -
- {entry.actor_id ?? '—'} · {entry.action} + 0}> +
+ {#each executions as execution (execution.id)} +
+ {execution.action} + {execution.status} +
+ {:else} +

None.

+ {/each} +
+
+ + 0}> +
+ {#each knowledge as hit (hit.id)} +
+ {hit.type}{hit.title} +
+ {:else} +

None linked.

+ {/each} +
+
+ + 0}> +
+ {#each events as ev (ev.id)} +
+ {new Date(ev.ts).toLocaleString()} + {ev.type} +
+ {:else} +

No events yet.

+ {/each} +
+
+ + 0}> +
+ {#each agentActivity as activity (activity.id)} +
+
+ {new Date(activity.ts).toLocaleString()} + {activity.activity_type}
- {:else} -

No audit entries.

- {/each} - - -
+ {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} +
+ {:else} +

No agent activity.

+ {/each} +
+ + + 0}> +
+ {#each auditEntries as entry (entry.id)} +
+
+ {new Date(entry.ts).toLocaleString()} + {entry.actor_type} +
+ {entry.actor_id ?? '—'} · {entry.action} +
+ {:else} +

No audit entries.

+ {/each} +
+
{/if}
diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte new file mode 100644 index 0000000..5013952 --- /dev/null +++ b/web/src/lib/components/EntityGraph.svelte @@ -0,0 +1,476 @@ + + +{#if loading && !nodes.length} + +{:else} +
+ + + {#each allRelTypes as type} + + + + {/each} + + + + {#each links as link} + {@const s = endpoint(link.source)} + {@const t = endpoint(link.target)} + {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)} + {@const vs = linkVisualState(link)} + {@const dx = t.x - s.x} + {@const dy = t.y - s.y} + {@const len = Math.max(Math.hypot(dx, dy), 1)} + {@const tr = nodeRadius(t) + 3} + {@const ex = t.x - (dx / len) * tr} + {@const ey = t.y - (dy / len) * tr} + + {link.type} + + {#if vs.emphasized && view.k >= 0.7} + + {link.type} + + {/if} + {/if} + {/each} + + + {#each nodes as node (node.id)} + {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)} + {@const r = nodeRadius(node)} + {@const op = nodeOpacity(node)} + {@const isFocus = hoveredId === node.id || selectedId === node.id} + {@const isMatch = matchedIds !== null && matchedIds.has(node.id)} + onNodePointerDown(e, node)} + onpointerenter={() => (hoveredId = node.id)} + onpointerleave={() => (hoveredId = null)} + onkeydown={(e) => e.key === 'Enter' && selectNode(node)} + ondblclick={() => rerootTo(node)} + > + {#if isFocus || isMatch} + + {/if} + + {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null} + + {node.slug} + + {/if} + + {/if} + {/each} + + + +
+ scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root +
+
+{/if} diff --git a/web/src/lib/components/EntitySheet.svelte b/web/src/lib/components/EntitySheet.svelte index f74aaf1..f981160 100644 --- a/web/src/lib/components/EntitySheet.svelte +++ b/web/src/lib/components/EntitySheet.svelte @@ -3,16 +3,26 @@ import * as Sheet from '$lib/components/ui/sheet' let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props() + + // Lets a relation click inside the sheet drill into that entity in place, + // without closing/reopening. Resets to the externally-requested slug + // whenever the caller opens the sheet on a different entity. + let currentSlug = $state(slug) + $effect(() => { + currentSlug = slug + }) - {slug ?? 'Entity detail'} + {currentSlug ?? 'Entity detail'} Entity detail panel - {#if slug} - + {#if currentSlug} + {#key currentSlug} + (currentSlug = s)} /> + {/key} {/if} diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte new file mode 100644 index 0000000..fbf30cc --- /dev/null +++ b/web/src/lib/components/EntityTable.svelte @@ -0,0 +1,178 @@ + + +{#if loading} +
+ + + + Slug + Type + Name + State + Health + + + + {#each skeletonSlugWidths as slugWidth, i} + + + + + + +
+ + +
+
+
+ {/each} +
+
+
+{:else} + {#snippet sortHead(key: SortKey, label: string)} + + + + {/snippet} +
+ + + + {@render sortHead('slug', 'Slug')} + {@render sortHead('type', 'Type')} + {@render sortHead('name', 'Name')} + {@render sortHead('state', 'State')} + {@render sortHead('health', 'Health')} + + + + {#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} + + + {:else} + + No entities in this layer match the filter. + + {/each} + + +
+{/if} diff --git a/web/src/pages/Entities.svelte b/web/src/pages/Entities.svelte deleted file mode 100644 index 533d8df..0000000 --- a/web/src/pages/Entities.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -
-
-

Entities

- {filtered.length} of {entities.length} -
- -
- - - - {typeFilter === 'all' ? 'All types' : typeFilter} - - - All types - {#each types as type} - {type} - {/each} - - -
- - {#if loading} -
- {#each Array(8) as _} - - {/each} -
- {:else} -
- - - - Slug - Type - Name - State - Health - - - - {#each filtered as entity (entity.id)} - openEntity(entity.slug)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEntity(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} - - - {:else} - - No entities match this filter. - - {/each} - - -
- {/if} -
- - diff --git a/web/src/pages/Graph.svelte b/web/src/pages/Graph.svelte deleted file mode 100644 index 59dd9c8..0000000 --- a/web/src/pages/Graph.svelte +++ /dev/null @@ -1,517 +0,0 @@ - - -
-
-

Graph

- - - - -
- - {nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}% - -
- -
- {#if allNodeTypes.length} -
- Nodes - {#each allNodeTypes as type} - - {/each} -
- {/if} - {#if allRelTypes.length} -
- Edges - {#each allRelTypes as type} - - {/each} -
- {/if} -
- - {#if loading && !nodes.length} - - {:else} -
- - - {#each allRelTypes as type} - - - - {/each} - - - - {#each links as link} - {@const s = endpoint(link.source)} - {@const t = endpoint(link.target)} - {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)} - {@const vs = linkVisualState(link)} - {@const dx = t.x - s.x} - {@const dy = t.y - s.y} - {@const len = Math.max(Math.hypot(dx, dy), 1)} - {@const tr = nodeRadius(t) + 3} - {@const ex = t.x - (dx / len) * tr} - {@const ey = t.y - (dy / len) * tr} - - {link.type} - - {#if vs.emphasized && view.k >= 0.7} - - {link.type} - - {/if} - {/if} - {/each} - - - {#each nodes as node (node.id)} - {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)} - {@const r = nodeRadius(node)} - {@const op = nodeOpacity(node)} - {@const isFocus = hoveredId === node.id || selected?.id === node.id} - {@const isMatch = matchedIds !== null && matchedIds.has(node.id)} - onNodePointerDown(e, node)} - onpointerenter={() => (hoveredId = node.id)} - onpointerleave={() => (hoveredId = null)} - onkeydown={(e) => e.key === 'Enter' && selectNode(node)} - ondblclick={() => rerootTo(node)} - > - {#if isFocus || isMatch} - - {/if} - - {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null} - - {node.slug} - - {/if} - - {/if} - {/each} - - - -
- scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root -
-
- {/if} -
- - { if (!open) selected = null }}> - - {#if selected} - - {selected.slug} - {selected.type}{selected.state ? ` — ${selected.state}` : ''} - -
-
- - -
-
-

Attributes

-
{JSON.stringify(selected.attributes, null, 2)}
-
-
-

Relationships

-
- {#each links.filter((l) => endpointId(l.source) === selected!.id || endpointId(l.target) === selected!.id) as link} - {@const sId = endpointId(link.source)} - {@const other = endpoint(sId === selected!.id ? link.target : link.source)} -
- - {sId === selected!.id ? '→' : '←'} {link.type} - - {other?.slug ?? '—'} -
- {:else} -

No relationships in this view.

- {/each} -
-
-
-

Blast radius ({blastRadius.length})

-
- {#each blastRadius as item (item.entity.id)} -
- {item.entity.slug} - depth {item.depth} -
- {:else} -

No downstream dependents.

- {/each} -
-
-
- {/if} -
-
- - diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte new file mode 100644 index 0000000..25be65a --- /dev/null +++ b/web/src/pages/KnowledgeBase.svelte @@ -0,0 +1,288 @@ + + +
+ +
+ (layer = v as Layer)}> + + {#each layers as l} + {l.label} + {/each} + + + +
+ + +
+
+ + {#if view === 'table'} +
+ + + + {typeFilter === 'all' ? 'All types' : typeFilter} + + + All types + {#each tableTypes as type} + {type} + {/each} + + + {filteredEntities.length} of {tableEntities.length} +
+ {:else} +
+ + + + + + {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} + + +
+
+ {#if view === 'graph'} + + {:else} + + {/if} +
+ + + +
+ {#if selectedSlug} + {#key selectedSlug} + + {/key} + {:else} +
+ Select an entity to see its detail. +
+ {/if} +
+
+