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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user