Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail, Entities row), case-sensitive entity filter, two competing entity-detail navigation patterns (standardize on EntitySheet), non-clickable Overview KPI cards, a bare button bypassing the shared Button component, inconsistent blur-only vs live filtering, and an unenforced sanitization assumption on search snippet HTML (now using the already-present dompurify dependency). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
518 lines
19 KiB
Svelte
518 lines
19 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, fetchBlastRadius, type GraphView, type Entity, type BlastRadiusItem, type Health } from '$lib/api'
|
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
|
import { Input } from '$lib/components/ui/input'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import * as Sheet from '$lib/components/ui/sheet'
|
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
|
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
|
|
|
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 root = $state('')
|
|
let depth = $state(2)
|
|
let nodes = $state<Node[]>([])
|
|
let links = $state<Link[]>([])
|
|
let selected = $state<Entity | null>(null)
|
|
let blastRadius = $state<BlastRadiusItem[]>([])
|
|
let sim: Simulation<Node, Link> | null = null
|
|
|
|
let search = $state('')
|
|
let activeNodeTypes = $state<Set<string>>(new Set())
|
|
let activeRelTypes = $state<Set<string>>(new Set())
|
|
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
|
|
}
|
|
|
|
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
|
|
}))
|
|
|
|
activeNodeTypes = new Set(nodes.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(() => {
|
|
load()
|
|
const unsubscribe = subscribeEvents()
|
|
return () => {
|
|
unsubscribe()
|
|
sim?.stop()
|
|
}
|
|
})
|
|
|
|
onDestroy(() => sim?.stop())
|
|
|
|
$effect(() => {
|
|
const ev = $liveEvents[0]
|
|
if (!ev) return
|
|
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
|
load()
|
|
}
|
|
})
|
|
|
|
async function selectNode(node: Node) {
|
|
selected = node
|
|
blastRadius = await fetchBlastRadius(node.id)
|
|
}
|
|
|
|
function rerootTo(node: Node) {
|
|
root = node.slug
|
|
selected = null
|
|
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)
|
|
}
|
|
|
|
function toggleSet(set: Set<string>, value: string): Set<string> {
|
|
const next = new Set(set)
|
|
if (next.has(value)) next.delete(value)
|
|
else next.add(value)
|
|
return next
|
|
}
|
|
|
|
const allNodeTypes = $derived(Array.from(new Set(nodes.map((n) => n.type))).sort())
|
|
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
|
|
|
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))
|
|
})
|
|
|
|
const visibleNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
|
|
|
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 ?? selected?.id ?? null
|
|
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 {
|
|
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
|
|
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
|
|
return 1
|
|
}
|
|
|
|
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
|
const s = endpointId(link.source)
|
|
const t = endpointId(link.target)
|
|
const focus = hoveredId ?? selected?.id ?? null
|
|
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: { startX: number; startY: number; viewX: number; viewY: number } | 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 }
|
|
}
|
|
|
|
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)
|
|
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
panState = null
|
|
}
|
|
|
|
function resetView() {
|
|
view = { x: 0, y: 0, k: 1 }
|
|
root = ''
|
|
search = ''
|
|
load()
|
|
}
|
|
|
|
let entitySheetOpen = $state(false)
|
|
let entitySheetSlug = $state<string | null>(null)
|
|
|
|
function openEntityDetail(slug: string) {
|
|
entitySheetSlug = slug
|
|
entitySheetOpen = true
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col gap-3 p-4">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<h1 class="mr-2 text-lg font-semibold">Graph</h1>
|
|
<Input placeholder="Root entity…" bind:value={root} class="h-8 max-w-44 text-xs" onchange={load} />
|
|
<Input type="number" min="1" max="5" bind:value={depth} class="h-8 w-16 text-xs" onchange={load} />
|
|
<Input placeholder="Search / highlight…" bind:value={search} class="h-8 max-w-44 text-xs" />
|
|
<Button variant="outline" size="sm" class="h-8" onclick={resetView}>
|
|
<LocateFixedIcon class="mr-1 size-3.5" />
|
|
Reset
|
|
</Button>
|
|
<div class="flex-1"></div>
|
|
<span class="text-xs text-muted-foreground">
|
|
{nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
{#if allNodeTypes.length}
|
|
<div class="flex flex-wrap items-center gap-1">
|
|
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
|
|
{#each allNodeTypes as type}
|
|
<button type="button" onclick={() => (activeNodeTypes = toggleSet(activeNodeTypes, type))}>
|
|
<Badge variant={activeNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if allRelTypes.length}
|
|
<div class="flex flex-wrap items-center gap-1">
|
|
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
|
|
{#each allRelTypes as type}
|
|
<button type="button" onclick={() => (activeRelTypes = toggleSet(activeRelTypes, type))}>
|
|
<Badge
|
|
variant="outline"
|
|
class="h-5 cursor-pointer px-1.5 text-[10px] {activeRelTypes.has(type) ? '' : 'opacity-35'}"
|
|
style="border-color: {relColor(type)}; color: {relColor(type)};"
|
|
>
|
|
{type}
|
|
</Badge>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loading && !nodes.length}
|
|
<Skeleton class="min-h-0 flex-1" />
|
|
{:else}
|
|
<div class="relative min-h-0 flex-1 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 || selected?.id === 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}
|
|
</div>
|
|
|
|
<Sheet.Root open={selected !== null} onOpenChange={(open) => { if (!open) selected = null }}>
|
|
<Sheet.Content side="right" class="w-[420px] sm:max-w-[420px]">
|
|
{#if selected}
|
|
<Sheet.Header>
|
|
<Sheet.Title class="font-mono text-sm">{selected.slug}</Sheet.Title>
|
|
<Sheet.Description>{selected.type}{selected.state ? ` — ${selected.state}` : ''}</Sheet.Description>
|
|
</Sheet.Header>
|
|
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
|
|
<div class="flex gap-2">
|
|
<Button variant="outline" size="sm" onclick={() => openEntityDetail(selected!.slug)}>
|
|
View entity detail
|
|
</Button>
|
|
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
|
|
</div>
|
|
<div>
|
|
<p class="mb-1 text-xs font-medium text-muted-foreground">Attributes</p>
|
|
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(selected.attributes, null, 2)}</pre>
|
|
</div>
|
|
<div>
|
|
<p class="mb-1 text-xs font-medium text-muted-foreground">Relationships</p>
|
|
<div class="flex flex-col gap-1">
|
|
{#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)}
|
|
<div class="flex items-center gap-1 text-xs">
|
|
<Badge variant="outline" style="border-color: {relColor(link.type)}; color: {relColor(link.type)};">
|
|
{sId === selected!.id ? '→' : '←'} {link.type}
|
|
</Badge>
|
|
<span class="font-mono">{other?.slug ?? '—'}</span>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No relationships in this view.</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="mb-1 text-xs font-medium text-muted-foreground">Blast radius ({blastRadius.length})</p>
|
|
<div class="flex flex-col gap-1">
|
|
{#each blastRadius as item (item.entity.id)}
|
|
<div class="flex items-center justify-between text-xs">
|
|
<span class="font-mono">{item.entity.slug}</span>
|
|
<Badge variant="outline">depth {item.depth}</Badge>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No downstream dependents.</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|
|
|
|
<EntitySheet slug={entitySheetSlug} bind:open={entitySheetOpen} />
|