From 335fa67d553f50e8c0dd21effa6354d38d9cadfb Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 10:39:31 +0200 Subject: [PATCH] fix(web): scope 1-hop neighbor expansion to rooted graph views only Two bugs found while verifying against real production data: - Excluded activity types (execution/check/task etc., see categories.ts) were falling through inCategory's "unknown type -> always visible" fallback, since typeCategory only stored entries whose category was defined. That fallback exists for types the ontology never returned at all; it wrongly re-admitted types the ontology returned but categories.ts deliberately excludes. Fixed by storing every type (including undefined categories) and checking key presence, not value truthiness. - Once that was fixed, the previous commit's 1-hop neighbor expansion (dimmed cross-category context) turned out fine for a rooted view but flooded an unrooted "browse the whole category" view: Fleet's ~49 focus entities are hub-like enough that 1-hop pulled in 325+ of the system's 479 total entities. Neighbor expansion now only applies when a root is set; the unscoped view goes back to same-category-only edges, which measured at a clean 49 nodes for Fleet. Verified against live production data (real bearer token, real DB) rather than mocks: Fleet unrooted = 49 nodes matching the DB's compute+physical count exactly; rooting on host:strong shows 33 nodes with both bright same-category and dimmed cross-category neighbors, no isolated dots. Co-Authored-By: Claude Sonnet 5 --- web/src/lib/components/EntityGraph.svelte | 33 ++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte index b3747f9..028987e 100644 --- a/web/src/lib/components/EntityGraph.svelte +++ b/web/src/lib/components/EntityGraph.svelte @@ -66,8 +66,11 @@ let sim: Simulation | 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>(new Map()) + // graph endpoint itself has no category/domain param). Value is undefined + // for types deliberately excluded from every category (e.g. execution/ + // check/task — see categories.ts); the key is still present so inCategory + // can tell "excluded on purpose" apart from "not in the ontology at all." + let typeCategory = $state>(new Map()) let hoveredId = $state(null) @@ -108,11 +111,13 @@ 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.) + // Node belongs to the active category? Types the ontology never returned + // at all fall back to visible (so a missing entry never blanks the + // graph); types the ontology returned but categories.ts deliberately + // excludes (key present, value undefined) do not. function inCategory(type: string): boolean { - const c = typeCategory.get(type) - return c === undefined || c === category + if (!typeCategory.has(type)) return true + return typeCategory.get(type) === category } async function load() { @@ -162,9 +167,7 @@ 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) - ) + typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)])) // 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)) }) @@ -259,11 +262,17 @@ // 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). + // left focus nodes looking like disconnected dots. Rooted views (the user + // is exploring out from one entity) pull in 1-hop neighbors of any + // category, dimmed, so the edges — and what they connect to — stay + // visible. Unscoped "browse the whole category" views (no root) skip + // this: with ~50 focus nodes that touch nearly everything, 1-hop + // expansion floods in most of the graph (measured: 417 of 479 total + // entities for an unrooted Fleet view) — worse than the isolated-dot + // problem it was meant to fix. There, same-category-only edges stay. const neighborNodeIds = $derived.by(() => { const neighbors = new Set() + if (!root.trim()) return neighbors for (const l of links) { if (!activeRelTypes.has(l.type)) continue const s = endpointId(l.source)