Two fixes to the new category taxonomy:
- Knowledge Base couldn't show a document/investigation/runbook's own
markdown body — knowledge_entities.content was never exposed by any
endpoint (GetEntityKnowledge answers "what knowledge references this
entity", not "what is this entity's content"). Add GET
/api/v1/knowledge/content/{id} and render it with the existing
marked+DOMPurify pipeline in a new Content section.
- The graph hid any edge whose other endpoint wasn't in the active
category, so nodes with only cross-category neighbors rendered as
disconnected dots. Queried the real relationship table: ~70% of infra
edges cross Fleet/Network/Services/Storage lines (compute+network+
software+storage+physical used to be one "infrastructure" layer).
EntityGraph now keeps 1-hop neighbors visible but dimmed instead of
hiding them, so the edges — and what they connect to — stay visible.
- categories.ts: `cognition` domain conflated true knowledge (document/
investigation/runbook, 58 entities) with operational telemetry
(execution/check/task/signal/approval/pattern/skill/classification/
feedback, 300+ entities with their own Operations/Signals/Learning
pages). Mapping the whole domain to Knowledge pulled in 245 execution
entities fanning out from ~17 compute nodes via `targets` edges — the
single biggest source of graph clutter. Knowledge now maps by type
(document/investigation/runbook only); the rest of cognition is
excluded from Knowledge Base browsing entirely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
509 lines
18 KiB
Svelte
509 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte'
|
|
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 { typeToCategory, type Category } from '$lib/categories'
|
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
|
|
|
export interface GraphInfo {
|
|
allNodeTypes: string[]
|
|
allRelTypes: string[]
|
|
relColors: Map<string, string>
|
|
visibleCount: number
|
|
truncated: boolean
|
|
zoomPct: number
|
|
}
|
|
|
|
let {
|
|
category,
|
|
selectedSlug = null,
|
|
onSelect,
|
|
root = $bindable(''),
|
|
depth,
|
|
search,
|
|
reloadToken,
|
|
resetToken,
|
|
activeNodeTypes = $bindable(new Set<string>()),
|
|
activeRelTypes = $bindable(new Set<string>()),
|
|
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
|
}: {
|
|
category: Category
|
|
selectedSlug?: string | null
|
|
onSelect: (slug: string | null) => void
|
|
root?: string
|
|
depth: number
|
|
search: string
|
|
// Bumped by the parent toolbar to request a data reload / view reset —
|
|
// these controls live in the shared page toolbar (not squeezed inside
|
|
// this resizable pane), so they can't call load()/resetView() directly.
|
|
reloadToken: number
|
|
resetToken: number
|
|
activeNodeTypes?: Set<string>
|
|
activeRelTypes?: Set<string>
|
|
info?: GraphInfo
|
|
} = $props()
|
|
|
|
interface Node extends Entity {
|
|
x?: number
|
|
y?: number
|
|
vx?: number
|
|
vy?: number
|
|
fx?: number | null
|
|
fy?: number | null
|
|
degree: number
|
|
}
|
|
interface Link {
|
|
source: string | Node
|
|
target: string | Node
|
|
type: string
|
|
}
|
|
|
|
let graph = $state<GraphView | null>(null)
|
|
let loading = $state(true)
|
|
let nodes = $state<Node[]>([])
|
|
let links = $state<Link[]>([])
|
|
let sim: Simulation<Node, Link> | null = null
|
|
|
|
// type → browsing category, so the graph can be scoped client-side (the
|
|
// graph endpoint itself has no category/domain param).
|
|
let typeCategory = $state<Map<string, Category>>(new Map())
|
|
|
|
let hoveredId = $state<string | null>(null)
|
|
|
|
// viewport transform: translate(x, y) scale(k)
|
|
let view = $state({ x: 0, y: 0, k: 1 })
|
|
let svgEl = $state<SVGSVGElement | null>(null)
|
|
|
|
const width = 1200
|
|
const height = 800
|
|
|
|
const healthColor: Record<Health, string> = {
|
|
healthy: '#3fb950',
|
|
degraded: '#d29922',
|
|
down: '#f85149',
|
|
unknown: '#8b949e'
|
|
}
|
|
|
|
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
|
const relColorByType = $derived.by(() => {
|
|
const map = new Map<string, string>()
|
|
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
|
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
|
return map
|
|
})
|
|
|
|
function relColor(type: string): string {
|
|
return relColorByType.get(type) ?? '#30363d'
|
|
}
|
|
|
|
function markerId(type: string): string {
|
|
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
|
}
|
|
|
|
function endpoint(end: string | Node): Node | undefined {
|
|
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
|
}
|
|
function endpointId(end: string | Node): string {
|
|
return typeof end === 'object' ? end.id : end
|
|
}
|
|
|
|
// 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() {
|
|
loading = true
|
|
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
|
loading = false
|
|
if (!graph) return
|
|
|
|
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
const degree = new Map<string, number>()
|
|
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
|
for (const e of graph.edges) {
|
|
const s = idBySlug.get(e.source) ?? e.source
|
|
const t = idBySlug.get(e.target) ?? e.target
|
|
degree.set(s, (degree.get(s) ?? 0) + 1)
|
|
degree.set(t, (degree.get(t) ?? 0) + 1)
|
|
}
|
|
|
|
nodes = graph.nodes.map((n) => {
|
|
const prev = byId.get(n.id)
|
|
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
|
})
|
|
links = graph.edges.map((e) => ({
|
|
source: idBySlug.get(e.source) ?? e.source,
|
|
target: idBySlug.get(e.target) ?? e.target,
|
|
type: e.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()
|
|
sim = forceSimulation(nodes)
|
|
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
|
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
|
.force('center', forceCenter(width / 2, height / 2))
|
|
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
|
.force('x', forceX(width / 2).strength(0.04))
|
|
.force('y', forceY(height / 2).strength(0.04))
|
|
.velocityDecay(0.32)
|
|
.alphaDecay(0.035)
|
|
.on('tick', () => {
|
|
nodes = [...nodes]
|
|
})
|
|
}
|
|
|
|
onMount(() => {
|
|
fetchEntityTypes().then((types) => {
|
|
typeCategory = new Map(
|
|
types.map((t) => [t.name, typeToCategory(t.name, 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()
|
|
return () => {
|
|
unsubscribe()
|
|
sim?.stop()
|
|
}
|
|
})
|
|
|
|
onDestroy(() => sim?.stop())
|
|
|
|
// When the category perspective changes, reset the node-type toggles to it.
|
|
$effect(() => {
|
|
category
|
|
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
|
})
|
|
|
|
$effect(() => {
|
|
const ev = $liveEvents[0]
|
|
if (!ev) return
|
|
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
|
load()
|
|
}
|
|
})
|
|
|
|
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
|
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
|
// parent's inputs, or the Reset button) bumps the token.
|
|
let lastReloadToken = $state(0)
|
|
$effect(() => {
|
|
if (reloadToken !== lastReloadToken) {
|
|
lastReloadToken = reloadToken
|
|
load()
|
|
}
|
|
})
|
|
|
|
let lastResetToken = $state(0)
|
|
$effect(() => {
|
|
if (resetToken !== lastResetToken) {
|
|
lastResetToken = resetToken
|
|
view = { x: 0, y: 0, k: 1 }
|
|
load()
|
|
}
|
|
})
|
|
|
|
function selectNode(node: Node) {
|
|
onSelect(node.slug)
|
|
}
|
|
|
|
function rerootTo(node: Node) {
|
|
root = node.slug
|
|
load()
|
|
}
|
|
|
|
function nodeColor(node: Node): string {
|
|
const h = graph?.health?.[node.id]
|
|
return h ? healthColor[h] : '#58a6ff'
|
|
}
|
|
|
|
function nodeRadius(node: Node): number {
|
|
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
|
}
|
|
|
|
// 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.
|
|
$effect(() => {
|
|
info = {
|
|
allNodeTypes,
|
|
allRelTypes,
|
|
relColors: relColorByType,
|
|
visibleCount: visibleNodeIds.size,
|
|
truncated: !!graph?.truncated,
|
|
zoomPct: Math.round(view.k * 100)
|
|
}
|
|
})
|
|
|
|
const matchedIds = $derived.by(() => {
|
|
if (!search.trim()) return null
|
|
const q = search.trim().toLowerCase()
|
|
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
|
})
|
|
|
|
// Focus = in the active category AND its node-type toggle is on — these
|
|
// are what the category tab is "about."
|
|
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
|
|
|
// Real infra relationships mostly cross category lines (a service sits on
|
|
// a network, uses storage, runs on an lxc — different categories under
|
|
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
|
// left focus nodes looking like disconnected dots. Instead, pull in their
|
|
// 1-hop neighbors (any category) so the edges — and what they connect
|
|
// to — stay visible, just visually secondary (see nodeOpacity).
|
|
const neighborNodeIds = $derived.by(() => {
|
|
const neighbors = new Set<string>()
|
|
for (const l of links) {
|
|
if (!activeRelTypes.has(l.type)) continue
|
|
const s = endpointId(l.source)
|
|
const t = endpointId(l.target)
|
|
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
|
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
|
}
|
|
return neighbors
|
|
})
|
|
|
|
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
|
|
|
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
|
|
|
const adjacency = $derived.by(() => {
|
|
const adj = new Map<string, Set<string>>()
|
|
for (const l of links) {
|
|
const s = endpointId(l.source)
|
|
const t = endpointId(l.target)
|
|
if (!adj.has(s)) adj.set(s, new Set())
|
|
if (!adj.has(t)) adj.set(t, new Set())
|
|
adj.get(s)!.add(t)
|
|
adj.get(t)!.add(s)
|
|
}
|
|
return adj
|
|
})
|
|
|
|
const focusIds = $derived.by(() => {
|
|
const focus = hoveredId ?? selectedId
|
|
if (!focus) return null
|
|
const set = new Set<string>([focus])
|
|
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
|
return set
|
|
})
|
|
|
|
function nodeOpacity(node: Node): number {
|
|
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
|
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
|
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
|
return base
|
|
}
|
|
|
|
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
|
const s = endpointId(link.source)
|
|
const t = endpointId(link.target)
|
|
const focus = hoveredId ?? selectedId
|
|
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
|
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
|
return { opacity: 0.45, emphasized: false }
|
|
}
|
|
|
|
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
|
|
|
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
|
const rect = svgEl!.getBoundingClientRect()
|
|
return {
|
|
x: ((clientX - rect.left) / rect.width) * width,
|
|
y: ((clientY - rect.top) / rect.height) * height
|
|
}
|
|
}
|
|
|
|
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
|
const p = toViewBox(clientX, clientY)
|
|
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
|
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
|
const p = toViewBox(e.clientX, e.clientY)
|
|
const wx = (p.x - view.x) / view.k
|
|
const wy = (p.y - view.y) / view.k
|
|
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
|
}
|
|
|
|
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, moved: false }
|
|
}
|
|
|
|
function onNodePointerDown(e: PointerEvent, node: Node) {
|
|
e.stopPropagation()
|
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
|
dragState = { node, moved: false }
|
|
sim?.alphaTarget(0.25).restart()
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (dragState) {
|
|
const w = toWorld(e.clientX, e.clientY)
|
|
dragState.node.fx = w.x
|
|
dragState.node.fy = w.y
|
|
dragState.moved = true
|
|
return
|
|
}
|
|
if (panState) {
|
|
const p = toViewBox(e.clientX, e.clientY)
|
|
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 }
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
if (dragState) {
|
|
const { node, moved } = dragState
|
|
node.fx = null
|
|
node.fy = null
|
|
sim?.alphaTarget(0)
|
|
dragState = null
|
|
if (!moved) selectNode(node)
|
|
return
|
|
}
|
|
if (panState && !panState.moved) {
|
|
// Plain click on empty background (not a drag-pan) — clear selection.
|
|
onSelect(null)
|
|
}
|
|
panState = null
|
|
}
|
|
</script>
|
|
|
|
{#if loading && !nodes.length}
|
|
<Skeleton class="h-full min-h-0" />
|
|
{:else}
|
|
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
|
|
<svg
|
|
bind:this={svgEl}
|
|
viewBox="0 0 {width} {height}"
|
|
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
|
role="application"
|
|
aria-label="Entity graph"
|
|
onwheel={onWheel}
|
|
onpointerdown={onBackgroundPointerDown}
|
|
onpointermove={onPointerMove}
|
|
onpointerup={onPointerUp}
|
|
onpointercancel={onPointerUp}
|
|
>
|
|
<defs>
|
|
{#each allRelTypes as type}
|
|
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
|
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
|
</marker>
|
|
{/each}
|
|
</defs>
|
|
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
|
<g>
|
|
{#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}
|
|
<line
|
|
x1={s.x}
|
|
y1={s.y}
|
|
x2={ex}
|
|
y2={ey}
|
|
stroke={relColor(link.type)}
|
|
stroke-width={vs.emphasized ? 2 : 1.2}
|
|
opacity={vs.opacity}
|
|
marker-end="url(#{markerId(link.type)})"
|
|
>
|
|
<title>{link.type}</title>
|
|
</line>
|
|
{#if vs.emphasized && view.k >= 0.7}
|
|
<text
|
|
x={(s.x + ex) / 2}
|
|
y={(s.y + ey) / 2 - 4}
|
|
text-anchor="middle"
|
|
font-size={10 / view.k}
|
|
fill={relColor(link.type)}
|
|
opacity="0.95"
|
|
paint-order="stroke"
|
|
stroke="var(--background)"
|
|
stroke-width={3 / view.k}
|
|
>
|
|
{link.type}
|
|
</text>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
<g>
|
|
{#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)}
|
|
<g
|
|
transform="translate({node.x},{node.y})"
|
|
opacity={op}
|
|
class="cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
onpointerdown={(e) => onNodePointerDown(e, node)}
|
|
onpointerenter={() => (hoveredId = node.id)}
|
|
onpointerleave={() => (hoveredId = null)}
|
|
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
|
ondblclick={() => rerootTo(node)}
|
|
>
|
|
{#if isFocus || isMatch}
|
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
|
{/if}
|
|
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
|
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
|
<text
|
|
y={r + 12}
|
|
text-anchor="middle"
|
|
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
|
fill={isFocus ? '#e6edf3' : '#8b949e'}
|
|
paint-order="stroke"
|
|
stroke="#0d1117"
|
|
stroke-width={3 / view.k}
|
|
class="pointer-events-none select-none"
|
|
>
|
|
{node.slug}
|
|
</text>
|
|
{/if}
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
|
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
|
</div>
|
|
</div>
|
|
{/if}
|