fix(web): scope 1-hop neighbor expansion to rooted graph views only
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 10:39:31 +02:00
parent d1243aceac
commit 335fa67d55

View File

@@ -66,8 +66,11 @@
let sim: Simulation<Node, Link> | null = null let sim: Simulation<Node, Link> | null = null
// type → browsing category, so the graph can be scoped client-side (the // type → browsing category, so the graph can be scoped client-side (the
// graph endpoint itself has no category/domain param). // graph endpoint itself has no category/domain param). Value is undefined
let typeCategory = $state<Map<string, Category>>(new Map()) // 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<Map<string, Category | undefined>>(new Map())
let hoveredId = $state<string | null>(null) let hoveredId = $state<string | null>(null)
@@ -108,11 +111,13 @@
return typeof end === 'object' ? end.id : end return typeof end === 'object' ? end.id : end
} }
// Node belongs to the active category? (Unknown types fall back to visible // Node belongs to the active category? Types the ontology never returned
// so a missing ontology entry never blanks the graph.) // 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 { function inCategory(type: string): boolean {
const c = typeCategory.get(type) if (!typeCategory.has(type)) return true
return c === undefined || c === category return typeCategory.get(type) === category
} }
async function load() { async function load() {
@@ -162,9 +167,7 @@
onMount(() => { onMount(() => {
fetchEntityTypes().then((types) => { fetchEntityTypes().then((types) => {
typeCategory = new Map( typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
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. // 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)) 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 // Real infra relationships mostly cross category lines (a service sits on
// a network, uses storage, runs on an lxc — different categories under // a network, uses storage, runs on an lxc — different categories under
// this taxonomy). Hard-hiding any edge whose other end isn't in-category // this taxonomy). Hard-hiding any edge whose other end isn't in-category
// left focus nodes looking like disconnected dots. Instead, pull in their // left focus nodes looking like disconnected dots. Rooted views (the user
// 1-hop neighbors (any category) so the edges — and what they connect // is exploring out from one entity) pull in 1-hop neighbors of any
// to — stay visible, just visually secondary (see nodeOpacity). // 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 neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>() const neighbors = new Set<string>()
if (!root.trim()) return neighbors
for (const l of links) { for (const l of links) {
if (!activeRelTypes.has(l.type)) continue if (!activeRelTypes.has(l.type)) continue
const s = endpointId(l.source) const s = endpointId(l.source)