From 8615f2268ff485c97ed5763dd92aad48112bb8af Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 18 Jul 2026 08:13:19 +0200 Subject: [PATCH] fix(web): entity Relations panel was missing true incoming edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchGraph({root, depth:1}) is backed by blast_radius, which only walks outgoing edges — so it could never surface an edge some other entity points at this one (e.g. host:hubris —hosts→ lxc:sophia) unless that other entity happened to also be reachable going forward from here. The Outgoing/Incoming split was filtering correctly, but "Incoming" was starved of data by construction. Switch to GET /entities/{id}/relations?direction=both — a dedicated endpoint that matches on source_id OR target_id directly — via a new fetchEntityRelations(). Simplifies the incoming/outgoing derivation too, since every relation returned is now actually incident to the entity (no more sibling-edge filtering needed). Co-Authored-By: Claude Sonnet 5 --- web/src/lib/api.ts | 12 ++++++++++++ .../lib/components/EntityDetailContent.svelte | 16 ++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2e3c575..9e5a452 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -494,6 +494,18 @@ export interface Relationship { valid_to?: string | null } +// Direct relationships of an entity, both directions — unlike fetchGraph's +// blast_radius (which only walks outgoing edges, so it can never surface an +// edge some other entity points at this one unless that entity is also +// reachable going forward from here), this hits a dedicated endpoint that +// matches on source_id OR target_id directly. +export async function fetchEntityRelations(id: string): Promise { + const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`) + if (!res.ok) return [] + const data = await res.json() + return data.items ?? [] +} + export type Health = 'healthy' | 'degraded' | 'down' | 'unknown' export interface GraphView { diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 78f22ab..ecb399c 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -6,7 +6,7 @@ import DOMPurify from 'dompurify' import { fetchEntity, - fetchGraph, + fetchEntityRelations, fetchMetrics, fetchEntityEvents, fetchEntitySignals, @@ -58,10 +58,10 @@ let actingSignal = $state(null) let chartContainers: Record = {} - // `relations` is the whole depth-1 neighborhood's edge set (any edge - // between any two nodes in the subgraph, e.g. two sibling LXCs' shared - // LAN), not just edges touching this entity — so "incoming"/"outgoing" - // only make sense for the subset actually incident to it. + // fetchEntityRelations already scopes to edges incident to this entity + // (source OR target = entity, both directions), so a plain split by which + // side matches is enough — no risk of an unrelated sibling-to-sibling edge + // sneaking into either group. const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : []) const incomingRelations = $derived( entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : [] @@ -74,8 +74,8 @@ loading = false return } - const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([ - fetchGraph({ root: entity.id, depth: 1 }), + const [rel, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([ + fetchEntityRelations(entity.id), fetchMetrics(entity.id), fetchEntityEvents(entity.id), fetchEntitySignals(entity.id), @@ -86,7 +86,7 @@ fetchAgentActivity({ entity_id: entity.id, limit: 50 }), fetchAudit({ entity_id: entity.id, limit: 50 }) ]) - relations = graphView?.edges ?? [] + relations = rel metrics = m events = ev signals = sig