fix: add involves edge from task to agent:nomos at creation
Plus sync vendor directory for Docker build compatibility.
This commit is contained in:
77
web/package-lock.json
generated
77
web/package-lock.json
generated
@@ -9,11 +9,15 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@joan/procedural-glyph-engine": "file:../vendor",
|
||||
"@sigma/node-image": "^3.0.0",
|
||||
"@surdeddd/wmkit": "^0.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"marked": "^18.0.5",
|
||||
"sigma": "^3.0.3",
|
||||
"svelte-splitpanes": "^8.0.12",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uplot": "^1.6.32"
|
||||
@@ -44,7 +48,10 @@
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"../vendor": {},
|
||||
"../vendor": {
|
||||
"name": "@joan/procedural-glyph-engine",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
@@ -1303,6 +1310,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sigma/node-image": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sigma/node-image/-/node-image-3.0.0.tgz",
|
||||
"integrity": "sha512-i4WLNPugDY4jgQEZtNSiSVj4HHXOraciXLtlgdygeUxMVEhH8PJ/+Q1vQ9f/SlKFnZQ+7vH3HnsSDW6FD9aP+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"sigma": ">=3.0.0-beta.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@surdeddd/wmkit": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@surdeddd/wmkit/-/wmkit-0.3.0.tgz",
|
||||
@@ -2973,6 +2989,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
@@ -3200,6 +3225,46 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphology": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
|
||||
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events": "^3.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-layout-forceatlas2": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz",
|
||||
"integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-utils": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-types": {
|
||||
"version": "0.24.8",
|
||||
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
|
||||
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/graphology-utils": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
|
||||
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -4458,6 +4523,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sigma": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
|
||||
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events": "^3.3.0",
|
||||
"graphology-utils": "^2.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -43,11 +43,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@joan/procedural-glyph-engine": "file:../vendor",
|
||||
"@sigma/node-image": "^3.0.0",
|
||||
"@surdeddd/wmkit": "^0.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"marked": "^18.0.5",
|
||||
"sigma": "^3.0.3",
|
||||
"svelte-splitpanes": "^8.0.12",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uplot": "^1.6.32"
|
||||
|
||||
@@ -39,6 +39,7 @@ import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import EggIcon from '@lucide/svelte/icons/egg'
|
||||
import StoreIcon from '@lucide/svelte/icons/store'
|
||||
import Share2Icon from '@lucide/svelte/icons/share-2'
|
||||
import ChartNetworkIcon from '@lucide/svelte/icons/chart-network'
|
||||
|
||||
export type { AppManifest, AppPermission }
|
||||
|
||||
@@ -185,7 +186,7 @@ export const builtinApps: AppDef[] = [
|
||||
{
|
||||
id: 'entity-graph',
|
||||
title: 'Entity Graph',
|
||||
icon: Share2Icon,
|
||||
icon: ChartNetworkIcon,
|
||||
component: () => import('../pages/EntityGraph.svelte'),
|
||||
width: 1100,
|
||||
height: 750,
|
||||
|
||||
648
web/src/pages/EntityGraph.svelte
Normal file
648
web/src/pages/EntityGraph.svelte
Normal file
@@ -0,0 +1,648 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import Sigma from 'sigma'
|
||||
import Graph from 'graphology'
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2'
|
||||
import { createNodeImageProgram } from '@sigma/node-image'
|
||||
import {
|
||||
fetchGraph, fetchOntology, fetchAllEntities, fetchBlastRadius,
|
||||
type Entity, type EntityType, type GraphView, type BlastRadiusItem, type OntologyLayer
|
||||
} from '$lib/api'
|
||||
import { subscribeEvents } from '$lib/stores/events'
|
||||
import { applyHealthEvent } from '$lib/health'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle'
|
||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||
import HeartPulseIcon from '@lucide/svelte/icons/heart-pulse'
|
||||
import ZapIcon from '@lucide/svelte/icons/zap'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
|
||||
// ── Color maps (Gruvbox palette, aligned with cyberspace theme) ──
|
||||
const HEALTH_COLOR: Record<string, string> = {
|
||||
healthy: '#b8bb26', degraded: '#fabd2f', down: '#fb4934',
|
||||
unknown: '#928374', stale: '#fabd2f'
|
||||
}
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
'proxmox-host': '#fe8019', 'standalone-server': '#d65d0e', workstation: '#83a598',
|
||||
appliance: '#928374', vm: '#fabd2f', lxc: '#8ec07c', service: '#b8bb26',
|
||||
'service-group': '#98971a', agent: '#d3869b', task: '#83a598', execution: '#458588',
|
||||
document: '#928374', runbook: '#d3869b', investigation: '#b16286', check: '#8ec07c',
|
||||
signal: '#fb4934', 'config-repo': '#d79921', 'deploy-pipeline': '#83a598',
|
||||
secret: '#cc241d', cluster: '#8ec07c', 'storage-pool': '#d65d0e',
|
||||
'network-device': '#689d6a', namespace: '#504945', site: '#d3869b', entity: '#928374'
|
||||
}
|
||||
const LAYER_FALLBACK: Record<string, string> = {
|
||||
infrastructure: '#fe8019', governance: '#83a598', cognition: '#d3869b', meta: '#928374'
|
||||
}
|
||||
const LAYER_LABEL: Record<string, string> = {
|
||||
infrastructure: 'Infrastructure', governance: 'Governance',
|
||||
cognition: 'Cognition', meta: 'Meta'
|
||||
}
|
||||
const REL_COLORS: Record<string, string> = {
|
||||
hosts: '#83a598', 'depends-on': '#fe8019', 'member-of': '#8ec07c',
|
||||
'located-at': '#d3869b', documents: '#928374', about: '#b16286',
|
||||
provides: '#b8bb26', 'backs-up-to': '#689d6a', 'part-of': '#504945',
|
||||
runs: '#d3869b', uses: '#fabd2f'
|
||||
}
|
||||
const DEFAULT_COLOR = '#928374'
|
||||
const FADE_COLOR = 'rgba(146,131,116,0.3)'
|
||||
const EDGE_BASE = 'rgba(131,165,152,0.35)'
|
||||
const EDGE_FADE = 'rgba(146,131,116,0.06)'
|
||||
|
||||
type ColorMode = 'health' | 'type'
|
||||
|
||||
// ── Lucide icon rendering ───────────────────────────────
|
||||
type IconNode = [string, Record<string, string>]
|
||||
function lucideIcon(node: IconNode[]): string {
|
||||
const svg = `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${
|
||||
node.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')}/>`).join('')
|
||||
}</svg>`
|
||||
)}`
|
||||
const img = new Image()
|
||||
img.src = svg
|
||||
const c = document.createElement('canvas')
|
||||
c.width = 128; c.height = 128
|
||||
const ctx = c.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0, 128, 128)
|
||||
return c.toDataURL()
|
||||
}
|
||||
const IC = (n: IconNode[]) => lucideIcon(n)
|
||||
const ICONS: Record<string, string> = {
|
||||
host: IC([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }], ["circle", { cx: "6", cy: "6", r: "0.5", fill: "#fff" }], ["circle", { cx: "6", cy: "18", r: "0.5", fill: "#fff" }]]),
|
||||
machine: IC([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]),
|
||||
lxc: IC([["path", { d: "M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" }], ["path", { d: "M10 21.9V14L2.1 9.1" }], ["path", { d: "m10 14 11.9-6.9" }]]),
|
||||
vm: IC([["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["line", { x1: "8", x2: "16", y1: "21", y2: "21" }], ["line", { x1: "12", x2: "12", y1: "17", y2: "21" }]]),
|
||||
service: IC([["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]]),
|
||||
agent: IC([["path", { d: "M12 8V4H8" }], ["rect", { width: "16", height: "12", x: "4", y: "8", rx: "2" }], ["path", { d: "M2 14h2" }], ["path", { d: "M20 14h2" }], ["path", { d: "M15 13v2" }], ["path", { d: "M9 13v2" }]]),
|
||||
task: IC([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]),
|
||||
execution: IC([["polygon", { points: "6 3 20 12 6 21 6 3" }]]),
|
||||
document: IC([["path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }], ["polyline", { points: "14 2 14 8 20 8" }], ["line", { x1: "16", x2: "8", y1: "13", y2: "13" }], ["line", { x1: "16", x2: "8", y1: "17", y2: "17" }], ["line", { x1: "10", x2: "8", y1: "9", y2: "9" }]]),
|
||||
runbook: IC([["path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }], ["path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" }]]),
|
||||
investigation: IC([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]),
|
||||
check: IC([["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]]),
|
||||
signal: IC([["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }], ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" }]]),
|
||||
'config-repo': IC([["line", { x1: "6", x2: "6", y1: "3", y2: "15" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]]),
|
||||
'deploy-pipeline': IC([["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["line", { x1: "6", x2: "9", y1: "9", y2: "6" }]]),
|
||||
secret: IC([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]),
|
||||
cluster: IC([["rect", { width: "6", height: "6", x: "16", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "2", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "9", y: "2", rx: "1" }], ["path", { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }], ["path", { d: "M12 12V8" }]]),
|
||||
namespace: IC([["path", { d: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]]),
|
||||
site: IC([["path", { d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }], ["circle", { cx: "12", cy: "10", r: "3" }]]),
|
||||
entity: IC([["circle", { cx: "12", cy: "12", r: "10" }]])
|
||||
}
|
||||
function iconForType(type: string): string {
|
||||
return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let tooltipEl = $state<HTMLDivElement | null>(null)
|
||||
let loading = $state(true)
|
||||
let loadError = $state<string | null>(null)
|
||||
let search = $state('')
|
||||
let colorMode = $state<ColorMode>('health')
|
||||
let healthFilter = $state<Set<string>>(new Set())
|
||||
let typeFilters = $state<Record<string, boolean>>({})
|
||||
let relFilters = $state<Record<string, boolean>>({})
|
||||
let selectedNode = $state<string | null>(null)
|
||||
let blast = $state<BlastRadiusItem[]>([])
|
||||
let tooltip = $state<{ x: number; y: number; node: string } | null>(null)
|
||||
|
||||
let sigma: Sigma | null = null
|
||||
let graph: Graph | null = null
|
||||
let allEntities = $state<Entity[]>([])
|
||||
let ontology = $state<{ entityTypes: EntityType[]; relTypes: { name: string }[] }>({ entityTypes: [], relTypes: [] })
|
||||
let typeToLayer = $state<Map<string, string>>(new Map())
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [g, ont, entities] = await Promise.all([
|
||||
fetchGraph({ includeStatus: true }),
|
||||
fetchOntology(),
|
||||
fetchAllEntities()
|
||||
])
|
||||
allEntities = entities
|
||||
ontology = {
|
||||
entityTypes: ont.entityTypes,
|
||||
relTypes: ont.relationshipTypes.map(r => ({ name: r.name }))
|
||||
}
|
||||
const t2l = new Map<string, string>()
|
||||
for (const t of ont.entityTypes) t2l.set(t.name, t.layer)
|
||||
typeToLayer = t2l
|
||||
|
||||
const initialTypes: Record<string, boolean> = {}
|
||||
for (const t of ont.entityTypes) if (!t.is_abstract) initialTypes[t.name] = true
|
||||
typeFilters = initialTypes
|
||||
|
||||
if (g) {
|
||||
const initialRels: Record<string, boolean> = {}
|
||||
for (const e of g.edges) initialRels[e.type] = true
|
||||
relFilters = initialRels
|
||||
buildGraph(g, t2l)
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Failed to load graph'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
return subscribeEvents()
|
||||
})
|
||||
|
||||
// ── Graph construction ──────────────────────────────────
|
||||
function nodeColor(type: string, health: string): string {
|
||||
if (colorMode === 'health') return HEALTH_COLOR[health] ?? HEALTH_COLOR.unknown
|
||||
return TYPE_COLORS[type] ?? LAYER_FALLBACK[typeToLayer.get(type) ?? 'meta'] ?? DEFAULT_COLOR
|
||||
}
|
||||
|
||||
function buildGraph(g: GraphView, t2l: Map<string, string>) {
|
||||
if (sigma) { sigma.kill(); sigma = null }
|
||||
if (!container) return
|
||||
|
||||
const gr = new Graph({ multi: true, type: 'directed', allowSelfLoops: false })
|
||||
const idBySlug = new Map<string, string>()
|
||||
const degree = new Map<string, number>()
|
||||
|
||||
for (const node of g.nodes) { idBySlug.set(node.slug, node.slug); degree.set(node.slug, 0) }
|
||||
for (const edge of g.edges) {
|
||||
if (idBySlug.has(edge.source) && idBySlug.has(edge.target)) {
|
||||
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
|
||||
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const maxDeg = Math.max(...degree.values(), 1)
|
||||
const minDeg = Math.min(...degree.values(), 0)
|
||||
|
||||
for (const node of g.nodes) {
|
||||
const id = node.slug
|
||||
const deg = degree.get(id) ?? 0
|
||||
const size = 4 + ((deg - minDeg) / Math.max(maxDeg - minDeg, 1)) * 20
|
||||
const layer = t2l.get(node.type) ?? 'meta'
|
||||
const hp = node.health ?? 'unknown'
|
||||
gr.addNode(id, {
|
||||
label: node.name || id.split(':').pop() || id,
|
||||
size, x: Math.random() * 600 - 300, y: Math.random() * 600 - 300,
|
||||
color: nodeColor(node.type, hp),
|
||||
slug: id, entityType: node.type, layer, health: hp, degree: deg,
|
||||
image: iconForType(node.type)
|
||||
})
|
||||
}
|
||||
for (const edge of g.edges) {
|
||||
const s = idBySlug.get(edge.source)
|
||||
const t = idBySlug.get(edge.target)
|
||||
if (s && t && s !== t) {
|
||||
try {
|
||||
const relColor = REL_COLORS[edge.type] ?? '#928374'
|
||||
gr.addEdgeWithKey(`${s}→${t}→${edge.type}`, s, t, {
|
||||
size: 1.2, relType: edge.type, color: relColor + '88'
|
||||
})
|
||||
} catch { /* duplicate edge key */ }
|
||||
}
|
||||
}
|
||||
|
||||
forceAtlas2.assign(gr, {
|
||||
iterations: 140,
|
||||
settings: { slowDown: 12, scalingRatio: 12, gravity: 1, edgeWeightInfluence: 0 }
|
||||
})
|
||||
// Normalize layout
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
|
||||
gr.forEachNode((n) => {
|
||||
const x = gr.getNodeAttribute(n, 'x'), y = gr.getNodeAttribute(n, 'y')
|
||||
minX = Math.min(minX, x); maxX = Math.max(maxX, x)
|
||||
minY = Math.min(minY, y); maxY = Math.max(maxY, y)
|
||||
})
|
||||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
|
||||
gr.forEachNode((n) => {
|
||||
gr.setNodeAttribute(n, 'x', (gr.getNodeAttribute(n, 'x') - cx) * 2)
|
||||
gr.setNodeAttribute(n, 'y', (gr.getNodeAttribute(n, 'y') - cy) * 2)
|
||||
})
|
||||
|
||||
const sig = new Sigma(gr, container, {
|
||||
allowInvalidContainer: true,
|
||||
nodeProgramClasses: { image: createNodeImageProgram({ size: { mode: 'force', value: 128 }, keepWithinCircle: true }) },
|
||||
defaultNodeType: 'image',
|
||||
labelDensity: 0.07, labelGridCellSize: 60, labelRenderedSizeThreshold: 10,
|
||||
labelFont: 'JetBrains Mono, monospace', labelSize: 11,
|
||||
labelColor: { color: '#000000dd' },
|
||||
zIndex: true, defaultEdgeType: 'arrow',
|
||||
minCameraRatio: 0.02, maxCameraRatio: 10,
|
||||
renderEdgeLabels: false,
|
||||
labelRenderedSizeThreshold: 10
|
||||
})
|
||||
|
||||
sig.on('enterNode', ({ node, event }) => {
|
||||
container!.style.cursor = 'pointer'
|
||||
tooltip = { x: event.x, y: event.y, node }
|
||||
applyHover(sig, gr, node)
|
||||
})
|
||||
sig.on('leaveNode', () => {
|
||||
container!.style.cursor = ''
|
||||
tooltip = null
|
||||
if (!selectedNode) clearHover(sig, gr)
|
||||
else applyHover(sig, gr, selectedNode)
|
||||
})
|
||||
sig.on('clickNode', ({ node }) => { selectNode(node) })
|
||||
sig.on('clickStage', () => {
|
||||
selectedNode = null
|
||||
blast = []
|
||||
clearHover(sig, gr)
|
||||
sig.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 })
|
||||
})
|
||||
sig.on('doubleClickNode', ({ node }) => {
|
||||
openEntityWindow(gr.getNodeAttribute(node, 'slug'))
|
||||
})
|
||||
|
||||
sigma = sig
|
||||
graph = gr
|
||||
applyAllFilters()
|
||||
}
|
||||
|
||||
async function selectNode(node: string) {
|
||||
selectedNode = node
|
||||
const slug = graph?.getNodeAttribute(node, 'slug') as string
|
||||
if (slug) {
|
||||
blast = []
|
||||
try { blast = await fetchBlastRadius(slug, 3) } catch { /* ignore */ }
|
||||
}
|
||||
if (sigma && graph) applyHover(sigma, graph, node)
|
||||
}
|
||||
|
||||
// ── Hover / selection highlight ─────────────────────────
|
||||
function applyHover(sig: Sigma, gr: Graph, node: string) {
|
||||
const hc = sig.getNodeDisplayData(node)?.color ?? DEFAULT_COLOR
|
||||
sig.setSetting('nodeReducer', (n, data) => {
|
||||
if (gr.hasEdge(n, node) || gr.hasEdge(node, n) || n === node)
|
||||
return { ...data, zIndex: 1, highlighted: n === node }
|
||||
return { ...data, zIndex: 0, label: '', color: FADE_COLOR, image: null, highlighted: false }
|
||||
})
|
||||
sig.setSetting('edgeReducer', (edge, data) => {
|
||||
if (gr.hasExtremity(edge, node)) return { ...data, color: hc, size: 2.5 }
|
||||
return { ...data, color: EDGE_FADE, hidden: true }
|
||||
})
|
||||
sig.refresh()
|
||||
}
|
||||
function clearHover(sig: Sigma, gr: Graph) {
|
||||
sig.setSetting('nodeReducer', null)
|
||||
sig.setSetting('edgeReducer', null)
|
||||
sig.refresh()
|
||||
}
|
||||
|
||||
// ── Filters ─────────────────────────────────────────────
|
||||
function applyAllFilters() {
|
||||
if (!sigma || !graph) return
|
||||
const hiddenTypes = new Set(Object.entries(typeFilters).filter(([, v]) => !v).map(([k]) => k))
|
||||
const hiddenRels = new Set(Object.entries(relFilters).filter(([, v]) => !v).map(([k]) => k))
|
||||
const hiddenHealth = healthFilter.size > 0
|
||||
? new Set(Object.keys(HEALTH_COLOR).filter(h => !healthFilter.has(h))
|
||||
.concat(['stale'].filter(h => !healthFilter.has('degraded') && h === 'stale')))
|
||||
: null
|
||||
|
||||
graph.forEachNode((n) => {
|
||||
const type = graph!.getNodeAttribute(n, 'entityType') as string
|
||||
const health = graph!.getNodeAttribute(n, 'health') as string
|
||||
const hide = hiddenTypes.has(type) || (hiddenHealth?.has(health) ?? false)
|
||||
graph!.setNodeAttribute(n, 'hidden', hide)
|
||||
graph!.setNodeAttribute(n, 'color', nodeColor(type, health))
|
||||
})
|
||||
graph.forEachEdge((e) => {
|
||||
const rt = graph!.getEdgeAttribute(e, 'relType') as string
|
||||
graph!.setEdgeAttribute(e, 'hidden', hiddenRels.has(rt))
|
||||
})
|
||||
sigma.refresh()
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void [typeFilters, relFilters, healthFilter, colorMode]
|
||||
if (graph) applyAllFilters()
|
||||
})
|
||||
|
||||
// ── Quick presets ───────────────────────────────────────
|
||||
function setPreset(p: 'all' | 'problems' | 'infra') {
|
||||
healthFilter = new Set()
|
||||
const allTrue: Record<string, boolean> = {}
|
||||
for (const t of ontology.entityTypes) if (!t.is_abstract) allTrue[t.name] = true
|
||||
const allRels: Record<string, boolean> = {}
|
||||
for (const k of Object.keys(relFilters)) allRels[k] = true
|
||||
|
||||
if (p === 'all') {
|
||||
typeFilters = { ...allTrue }
|
||||
relFilters = { ...allRels }
|
||||
colorMode = 'health'
|
||||
} else if (p === 'problems') {
|
||||
healthFilter = new Set(['degraded', 'down', 'stale'])
|
||||
typeFilters = { ...allTrue }
|
||||
relFilters = { ...allRels }
|
||||
colorMode = 'health'
|
||||
} else if (p === 'infra') {
|
||||
typeFilters = { ...allTrue }
|
||||
const infra = ontology.entityTypes.filter(t => t.layer === 'infrastructure' && !t.is_abstract)
|
||||
for (const k of Object.keys(typeFilters)) {
|
||||
typeFilters[k] = infra.some(t => t.name === k)
|
||||
}
|
||||
relFilters = { ...allRels, 'hosts': true, 'depends-on': true }
|
||||
colorMode = 'type'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────
|
||||
function focusSearch() {
|
||||
if (!sigma || !graph || !search) return
|
||||
const q = search.toLowerCase()
|
||||
let best: string | null = null
|
||||
let bestDeg = -1
|
||||
graph.forEachNode((n) => {
|
||||
const label = (graph!.getNodeAttribute(n, 'label') as string) ?? ''
|
||||
if (label.toLowerCase().includes(q)) {
|
||||
const d = graph!.getNodeAttribute(n, 'degree') as number
|
||||
if (d > bestDeg) { best = n; bestDeg = d }
|
||||
}
|
||||
})
|
||||
if (best) {
|
||||
const dd = sigma.getNodeDisplayData(best)
|
||||
if (dd) sigma.getCamera().animate({ x: dd.x, y: dd.y, ratio: 0.15 }, { duration: 600 })
|
||||
selectNode(best)
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() { sigma?.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 }) }
|
||||
function zoom(f: number) { const c = sigma?.getCamera(); if (c) c.animate({ ratio: c.ratio * f }, { duration: 200 }) }
|
||||
|
||||
onDestroy(() => { sigma?.kill() })
|
||||
|
||||
// ── Derived stats ───────────────────────────────────────
|
||||
const healthCounts = $derived.by(() => {
|
||||
const c: Record<string, number> = { healthy: 0, degraded: 0, down: 0, unknown: 0, stale: 0 }
|
||||
for (const e of allEntities) {
|
||||
const h = e.health ?? 'unknown'
|
||||
c[h] = (c[h] ?? 0) + 1
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const totalHealth = $derived(Object.values(healthCounts).reduce((a, b) => a + b, 0) || 1)
|
||||
|
||||
const typesByLayer = $derived.by(() => {
|
||||
const m: Record<string, EntityType[]> = {}
|
||||
for (const t of ontology.entityTypes) {
|
||||
if (t.is_abstract) continue
|
||||
const layer = t.layer ?? 'meta'
|
||||
if (!m[layer]) m[layer] = []
|
||||
m[layer].push(t)
|
||||
}
|
||||
return Object.entries(m).sort(([a], [b]) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
const relTypeList = $derived(
|
||||
[...new Set(Object.keys(relFilters))].sort()
|
||||
)
|
||||
|
||||
const selectedEntity = $derived.by(() => {
|
||||
if (!selectedNode || !graph) return null
|
||||
const slug = graph.getNodeAttribute(selectedNode, 'slug') as string
|
||||
return allEntities.find(e => e.slug === slug) ?? null
|
||||
})
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
if (!graph) return { nodes: 0, edges: 0, visible: 0 }
|
||||
let visible = 0
|
||||
graph.forEachNode((n) => { if (!graph!.getNodeAttribute(n, 'hidden')) visible++ })
|
||||
return { nodes: graph.order, edges: graph.size, visible }
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full overflow-hidden bg-card">
|
||||
<!-- Graph canvas -->
|
||||
<div class="relative flex-1">
|
||||
<div class="absolute inset-0 bg-card" style="background-image: radial-gradient(circle, color-mix(in oklab, var(--foreground) 8%, transparent) 1px, transparent 1px); background-size: 24px 24px;" bind:this={container}></div>
|
||||
|
||||
{#if tooltip && graph && sigma}
|
||||
{@const data = sigma.getNodeDisplayData(tooltip.node)}
|
||||
{@const ent = allEntities.find(e => e.slug === (graph!.getNodeAttribute(tooltip.node, 'slug') as string))}
|
||||
{@const hp = graph.getNodeAttribute(tooltip.node, 'health') as string}
|
||||
{@const tp = graph.getNodeAttribute(tooltip.node, 'entityType') as string}
|
||||
{@const dg = graph.getNodeAttribute(tooltip.node, 'degree') as number}
|
||||
<div class="pointer-events-none absolute z-20 max-w-[220px] rounded-md border border-border/60 bg-card px-3 py-2 shadow-md" style="left:{Math.min(tooltip.x + 14, (container?.clientWidth ?? 0) - 230)}px; top:{Math.max(tooltip.y - 10, 0)}px">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full" style="background:{HEALTH_COLOR[hp] ?? '#888'}"></span>
|
||||
<span class="truncate text-xs font-medium">{ent?.name ?? tooltip.node}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span class="font-mono">{tp}</span>
|
||||
<span>·</span>
|
||||
<span style="color:{HEALTH_COLOR[hp]}">{hp}</span>
|
||||
{#if dg > 0}<span>·</span><span>{dg} link{dg === 1 ? '' : 's'}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-card">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span class="text-xs text-muted-foreground">Loading entity graph…</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-card">
|
||||
<div class="flex flex-col items-center gap-2 px-6 text-center">
|
||||
<AlertTriangleIcon class="size-6 text-destructive" />
|
||||
<span class="text-sm text-destructive">{loadError}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Zoom controls -->
|
||||
<div class="absolute bottom-3 left-3 flex flex-col gap-1">
|
||||
<button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => zoom(0.7)} title="Zoom in">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => zoom(1 / 0.7)} title="Zoom out">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={resetView} title="Reset view">
|
||||
<Maximize2Icon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color mode toggle -->
|
||||
<div class="absolute right-3 top-3 flex overflow-hidden rounded-md border border-border/60 bg-card/90 backdrop-blur-sm">
|
||||
<button class="flex items-center gap-1 px-2.5 py-1.5 text-[10px] font-medium transition-colors {colorMode === 'health' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}" onclick={() => colorMode = 'health'}>
|
||||
<HeartPulseIcon class="size-3" /> Health
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-2.5 py-1.5 text-[10px] font-medium transition-colors {colorMode === 'type' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}" onclick={() => colorMode = 'type'}>
|
||||
<LayersIcon class="size-3" /> Type
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-80 shrink-0 flex-col border-l bg-card">
|
||||
<!-- Search -->
|
||||
<div class="border-b p-2.5">
|
||||
<div class="relative">
|
||||
<SearchIcon class="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input type="search" placeholder="Search entities…" bind:value={search}
|
||||
oninput={focusSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && focusSearch()}
|
||||
class="h-8 w-full rounded-md border bg-background pl-7 pr-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" />
|
||||
</div>
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button class="flex-1 rounded border border-border/60 px-1.5 py-1 text-[10px] font-medium transition-colors hover:bg-muted" onclick={() => setPreset('all')}>All</button>
|
||||
<button class="flex-1 rounded border border-warning/40 px-1.5 py-1 text-[10px] font-medium text-warning transition-colors hover:bg-warning/10" onclick={() => setPreset('problems')}>
|
||||
<AlertTriangleIcon class="mr-0.5 inline size-2.5" />Problems
|
||||
</button>
|
||||
<button class="flex-1 rounded border border-border/60 px-1.5 py-1 text-[10px] font-medium transition-colors hover:bg-muted" onclick={() => setPreset('infra')}>Infra</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Health distribution -->
|
||||
<div class="border-b p-2.5">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Health</span>
|
||||
{#if healthFilter.size > 0}
|
||||
<button class="text-[10px] text-primary hover:underline" onclick={() => healthFilter = new Set()}>clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex h-4 overflow-hidden rounded-sm">
|
||||
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
|
||||
{@const cnt = healthCounts[h] ?? 0}
|
||||
{#if cnt > 0}
|
||||
{@const active = healthFilter.size === 0 || healthFilter.has(h)}
|
||||
<button
|
||||
style="background:{HEALTH_COLOR[h]}; width:{(cnt / totalHealth) * 100}%; opacity:{active ? 1 : 0.25}"
|
||||
title="{h}: {cnt}"
|
||||
onclick={() => {
|
||||
const s = new Set(healthFilter)
|
||||
if (s.has(h)) s.delete(h); else s.add(h)
|
||||
healthFilter = s.size === Object.keys(HEALTH_COLOR).length ? new Set() : s
|
||||
}}
|
||||
class="h-full min-w-[2px] transition-opacity hover:opacity-80 cursor-pointer"></button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{#each [['healthy', healthCounts.healthy ?? 0], ['degraded', healthCounts.degraded ?? 0], ['down', healthCounts.down ?? 0], ['unknown', healthCounts.unknown ?? 0]] as [h, cnt]}
|
||||
<button class="flex items-center gap-1 text-[10px] transition-opacity {(healthFilter.size === 0 || healthFilter.has(h)) ? 'text-foreground' : 'text-muted-foreground/40'}"
|
||||
onclick={() => {
|
||||
const s = new Set(healthFilter)
|
||||
if (s.has(h)) s.delete(h); else s.add(h)
|
||||
healthFilter = s.size === Object.keys(HEALTH_COLOR).length ? new Set() : s
|
||||
}}>
|
||||
<span class="size-1.5 rounded-full" style="background:{HEALTH_COLOR[h]}"></span>
|
||||
{h} <span class="font-mono">{cnt}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected node detail -->
|
||||
{#if selectedEntity}
|
||||
<div class="border-b bg-muted/30 p-2.5">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full" style="background:{HEALTH_COLOR[selectedEntity.health ?? 'unknown']}"></span>
|
||||
<span class="truncate text-xs font-semibold">{selectedEntity.name}</span>
|
||||
</div>
|
||||
<span class="mt-0.5 block font-mono text-[10px] text-muted-foreground">{selectedEntity.slug}</span>
|
||||
</div>
|
||||
<button class="shrink-0 text-muted-foreground hover:text-foreground" onclick={() => { selectedNode = null; blast = []; clearHover(sigma!, graph!) }} title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button class="flex flex-1 items-center justify-center gap-1 rounded border px-1 py-1 text-[10px] hover:bg-muted" onclick={() => openEntityWindow(selectedEntity.slug)}>
|
||||
<CircleDotIcon class="size-3" /> Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if blast.length > 1}
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<ZapIcon class="size-2.5" /> Blast Radius ({blast.length - 1})
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each blast.filter(b => b.depth > 0).slice(0, 15) as b (b.entity.id)}
|
||||
<button onclick={() => selectNode(b.entity.slug)}
|
||||
class="rounded border px-1 py-0.5 font-mono text-[9px] text-muted-foreground hover:bg-muted"
|
||||
title="{b.entity.slug} — {b.depth} hop{b.depth === 1 ? '' : 's'}">
|
||||
{b.entity.slug.split(':').pop()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Entity types by layer -->
|
||||
<div class="border-b p-2.5">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Entity Types</span>
|
||||
<span class="font-mono text-[10px] text-muted-foreground">{stats.visible}/{stats.nodes}</span>
|
||||
</div>
|
||||
{#each typesByLayer as [layer, types]}
|
||||
{@const allOn = types.every(t => typeFilters[t.name] ?? true)}
|
||||
<Collapsible.Root>
|
||||
<div class="flex items-center">
|
||||
<Collapsible.Trigger class="flex flex-1 items-center gap-1.5 py-1 text-[10px] font-medium hover:text-foreground {allOn ? 'text-muted-foreground' : 'text-foreground'}">
|
||||
<svg viewBox="0 0 24 24" class="size-3" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span class="size-2 rounded-full" style="background:{LAYER_FALLBACK[layer]}"></span>
|
||||
{LAYER_LABEL[layer] ?? layer}
|
||||
</Collapsible.Trigger>
|
||||
<button class="text-[9px] text-muted-foreground hover:text-foreground" onclick={() => {
|
||||
typeFilters = { ...typeFilters, ...Object.fromEntries(types.map(t => [t.name, !allOn])) }
|
||||
}}>{allOn ? 'all' : 'none'}</button>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 space-y-0.5 pb-1">
|
||||
{#each types as t}
|
||||
{@const cnt = allEntities.filter(e => e.type === t.name).length}
|
||||
{@const color = TYPE_COLORS[t.name] ?? DEFAULT_COLOR}
|
||||
<label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
|
||||
<input type="checkbox" checked={typeFilters[t.name] ?? true}
|
||||
onchange={() => { typeFilters = { ...typeFilters, [t.name]: !typeFilters[t.name] } }}
|
||||
class="size-2.5" />
|
||||
<span class="size-1.5 shrink-0 rounded-full" style="background:{color}"></span>
|
||||
<span class="truncate flex-1">{t.name}</span>
|
||||
<span class="font-mono text-muted-foreground">{cnt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Relationship types -->
|
||||
{#if relTypeList.length > 0}
|
||||
<div class="p-2.5">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Relationships</span>
|
||||
<span class="font-mono text-[10px] text-muted-foreground">{stats.edges} edges</span>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each relTypeList as rt}
|
||||
{@const color = REL_COLORS[rt] ?? '#888'}
|
||||
<label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
|
||||
<input type="checkbox" checked={relFilters[rt] ?? true}
|
||||
onchange={() => { relFilters = { ...relFilters, [rt]: !relFilters[rt] } }}
|
||||
class="size-2.5" />
|
||||
<span class="h-0.5 w-4 shrink-0 rounded" style="background:{color}"></span>
|
||||
<span class="truncate">{rt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div class="border-t px-2.5 py-1.5 text-[9px] leading-relaxed text-muted-foreground/60">
|
||||
Hover to trace. Click to select + blast radius. Double-click to open.
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
Reference in New Issue
Block a user