feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs
with a synthesized category taxonomy built from the ontology's finer-grained
`domain` field, since layer lumped unrelated entity types (an LXC and a DNS
record and a storage volume) into one bucket. Network and Fleet each span
two domains, so the table view now fans out per-domain fetches and merges,
while the graph view maps domain->category client-side. Also carries over
several detail-panel polish items (Tasks-not-raw-executions, slug URL
encoding, MultiSelectFilter) from earlier in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:53:15 +02:00
parent f8e03806aa
commit 35c54ceef5
8 changed files with 465 additions and 168 deletions

View File

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