diff --git a/graph-ui/src/components/EdgeLines.tsx b/graph-ui/src/components/EdgeLines.tsx index 4001b9d28..50052d821 100644 --- a/graph-ui/src/components/EdgeLines.tsx +++ b/graph-ui/src/components/EdgeLines.tsx @@ -7,6 +7,10 @@ interface EdgeLinesProps { edges: GraphEdge[]; highlightedIds: Set | null; opacity?: number; + /* When set, edge.target is looked up in this array instead of `nodes`. + * Used for cross-galaxy edges where source lives in the primary graph + * and target lives in a linked project's offset-adjusted nodes. */ + targetNodes?: GraphNode[]; } function getClusterKey(fp?: string): string { @@ -28,17 +32,39 @@ const EDGE_TYPE_COLORS: Record = { IMPLEMENTS: "#f97316", HTTP_CALLS: "#e11d48", ASYNC_CALLS: "#ec4899", + GRPC_CALLS: "#f59e0b", + GRAPHQL_CALLS: "#e879f9", + TRPC_CALLS: "#a78bfa", + CROSS_HTTP_CALLS: "#fb923c", + CROSS_ASYNC_CALLS: "#fb7185", + CROSS_GRPC_CALLS: "#fbbf24", + CROSS_GRAPHQL_CALLS: "#f0abfc", + CROSS_TRPC_CALLS: "#c4b5fd", + CROSS_CHANNEL: "#fdba74", MEMBER_OF: "#64748b", TESTS_FILE: "#06b6d4", }; const DEFAULT_EDGE_COLOR = "#1C8585"; -export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeLinesProps) { +export function EdgeLines({ + nodes, + edges, + highlightedIds, + opacity = 1.0, + targetNodes, +}: EdgeLinesProps) { const geometry = useMemo(() => { - const idMap = new Map(); + const srcMap = new Map(); for (let i = 0; i < nodes.length; i++) { - idMap.set(nodes[i].id, i); + srcMap.set(nodes[i].id, i); + } + const tgtArr = targetNodes ?? nodes; + const tgtMap = targetNodes ? new Map() : srcMap; + if (targetNodes) { + for (let i = 0; i < targetNodes.length; i++) { + tgtMap.set(targetNodes[i].id, i); + } } const hasHighlight = highlightedIds && highlightedIds.size > 0; @@ -47,12 +73,12 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL let validCount = 0; for (const edge of edges) { - const si = idMap.get(edge.source); - const ti = idMap.get(edge.target); + const si = srcMap.get(edge.source); + const ti = tgtMap.get(edge.target); if (si === undefined || ti === undefined) continue; const s = nodes[si]; - const t = nodes[ti]; + const t = tgtArr[ti]; const sHL = !hasHighlight || highlightedIds.has(s.id); const tHL = !hasHighlight || highlightedIds.has(t.id); @@ -99,7 +125,7 @@ export function EdgeLines({ nodes, edges, highlightedIds, opacity = 1.0 }: EdgeL new THREE.BufferAttribute(colors.slice(0, validCount * 6), 3), ); return geo; - }, [nodes, edges, highlightedIds]); + }, [nodes, edges, highlightedIds, targetNodes]); return ( diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index 50055fbd3..fa150be36 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -156,6 +156,17 @@ export function GraphScene({ onClick={onNodeClick} opacity={0.5} /> + {/* Inter-galaxy CROSS_* edges: source is in primary, target in + * this linked project's offset nodes. */} + {lp.cross_edges && lp.cross_edges.length > 0 && ( + + )} ); })} diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 735ffd04f..ea9b7bb5b 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -48,6 +48,11 @@ export function GraphTab({ project }: GraphTabProps) { if (!data) return; const labels = new Set(data.nodes.map((n) => n.label)); const types = new Set(data.edges.map((e) => e.type)); + for (const lp of data.linked_projects ?? []) { + for (const n of lp.nodes) labels.add(n.label); + for (const e of lp.edges) types.add(e.type); + for (const e of lp.cross_edges) types.add(e.type); + } setEnabledLabels(labels); setEnabledEdgeTypes(types); }, [data]); @@ -65,7 +70,21 @@ export function GraphTab({ project }: GraphTabProps) { nodeIds.has(e.target), ); - return { nodes, edges, total_nodes: data.total_nodes }; + const linked_projects = data.linked_projects?.map((lp) => { + const lpNodes = lp.nodes.filter((n) => enabledLabels.has(n.label)); + const lpIds = new Set(lpNodes.map((n) => n.id)); + const lpEdges = lp.edges.filter( + (e) => + enabledEdgeTypes.has(e.type) && lpIds.has(e.source) && lpIds.has(e.target), + ); + const crossEdges = lp.cross_edges.filter( + (e) => + enabledEdgeTypes.has(e.type) && nodeIds.has(e.source) && lpIds.has(e.target), + ); + return { ...lp, nodes: lpNodes, edges: lpEdges, cross_edges: crossEdges }; + }); + + return { nodes, edges, total_nodes: data.total_nodes, linked_projects }; }, [data, enabledLabels, enabledEdgeTypes]); useEffect(() => { @@ -136,8 +155,15 @@ export function GraphTab({ project }: GraphTabProps) { const enableAll = useCallback(() => { if (!data) return; - setEnabledLabels(new Set(data.nodes.map((n) => n.label))); - setEnabledEdgeTypes(new Set(data.edges.map((e) => e.type))); + const labels = new Set(data.nodes.map((n) => n.label)); + const types = new Set(data.edges.map((e) => e.type)); + for (const lp of data.linked_projects ?? []) { + for (const n of lp.nodes) labels.add(n.label); + for (const e of lp.edges) types.add(e.type); + for (const e of lp.cross_edges) types.add(e.type); + } + setEnabledLabels(labels); + setEnabledEdgeTypes(types); }, [data]); const disableAll = useCallback(() => { diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 51436d65f..cef4ff708 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -952,6 +952,28 @@ static int find_cross_repo_targets(cbm_store_t *store, const char *project, char enum { LAYOUT_MAX_LINKED = 16 }; #define LAYOUT_GALAXY_SPACING 600.0 +#define LAYOUT_GALAXY_PAD 400.0 + +/* Bounding-radius of a layout result: max distance from origin across all + * nodes. Used to size galaxy spacing so satellites don't overlap the primary + * cluster. Layouts with a 1000-node cluster have radius ~1500; the previous + * fixed 600 spacing buried satellites inside the primary mass. */ +static double layout_radius(const cbm_layout_result_t *r) { + if (!r || r->node_count == 0) + return 0.0; + double max_r2 = 0.0; + for (int i = 0; i < r->node_count; i++) { + double x = (double)r->nodes[i].x; + double y = (double)r->nodes[i].y; + double z = (double)r->nodes[i].z; + if (!isfinite(x) || !isfinite(y) || !isfinite(z)) + continue; + double r2 = x * x + y * y + z * z; + if (r2 > max_r2) + max_r2 = r2; + } + return sqrt(max_r2); +} static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { char project[256] = {0}; @@ -987,26 +1009,32 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { cbm_layout_result_t *layout = cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); - /* Find linked projects from CROSS_* edges */ + /* Find linked projects from CROSS_* edges. Keep `store` open through the + * linked-projects loop below so we can resolve target Route QNs against + * the linked stores when populating cross_edges. */ char *linked[LAYOUT_MAX_LINKED]; int linked_count = find_cross_repo_targets(store, project, linked, LAYOUT_MAX_LINKED); - cbm_store_close(store); - if (!layout) { + cbm_store_close(store); mg_http_reply(c, 500, g_cors_json, "{\"error\":\"layout computation failed\"}"); return; } + /* Capture primary cluster radius before freeing the layout. */ + double primary_radius = layout_radius(layout); + /* Build JSON: primary layout + linked_projects */ char *primary_json = cbm_layout_to_json(layout); cbm_layout_free(layout); if (!primary_json) { + cbm_store_close(store); mg_http_reply(c, 500, g_cors_json, "{\"error\":\"JSON serialization failed\"}"); return; } if (linked_count == 0) { + cbm_store_close(store); mg_http_reply(c, 200, g_cors_json, "%s", primary_json); free(primary_json); return; @@ -1016,6 +1044,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); free(primary_json); if (!pdoc) { + cbm_store_close(store); mg_http_reply(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}"); return; } @@ -1040,18 +1069,21 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { continue; } + /* Keep lp_store open through cross_edges resolution below. */ cbm_layout_result_t *lp_layout = cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); - cbm_store_close(lp_store); if (!lp_layout) { + cbm_store_close(lp_store); free(linked[li]); continue; } + double sat_radius = layout_radius(lp_layout); char *lp_json = cbm_layout_to_json(lp_layout); cbm_layout_free(lp_layout); if (!lp_json) { + cbm_store_close(lp_store); free(linked[li]); continue; } @@ -1060,6 +1092,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0); free(lp_json); if (!lpdoc) { + cbm_store_close(lp_store); free(linked[li]); continue; } @@ -1082,22 +1115,85 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) { yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_mut_val_mut_copy(mdoc, le)); } - /* Compute galaxy offset: evenly spaced around primary */ + /* Compute galaxy offset: evenly spaced around primary, far enough out + * that the primary cluster (radius primary_radius) and the satellite + * cluster (radius sat_radius) don't overlap. Bounded below by + * LAYOUT_GALAXY_SPACING for trivially small projects. */ double angle = (2.0 * 3.14159265358979) * (double)li / (double)linked_count; + double dist = primary_radius + sat_radius + LAYOUT_GALAXY_PAD; + if (dist < LAYOUT_GALAXY_SPACING) { + dist = LAYOUT_GALAXY_SPACING; + } yyjson_mut_val *offset = yyjson_mut_obj(mdoc); - yyjson_mut_obj_add_real(mdoc, offset, "x", cos(angle) * LAYOUT_GALAXY_SPACING); - yyjson_mut_obj_add_real(mdoc, offset, "y", sin(angle) * LAYOUT_GALAXY_SPACING); + yyjson_mut_obj_add_real(mdoc, offset, "x", cos(angle) * dist); + yyjson_mut_obj_add_real(mdoc, offset, "y", sin(angle) * dist); yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0); yyjson_mut_obj_add_val(mdoc, entry, "offset", offset); - /* TODO: cross_edges array with CROSS_* edges connecting the galaxies */ - yyjson_mut_obj_add_val(mdoc, entry, "cross_edges", yyjson_mut_arr(mdoc)); + /* Populate cross_edges connecting primary→this linked galaxy. Each + * entry: {source: , target: , type}. + * + * A CROSS_* edge in the source store points caller_id → local_route_id + * (a Route node in the source store). The Route's qualified_name is + * canonical and the same Route exists in the linked store too — that's + * the cross-repo matching contract. Join edges → nodes in source to + * pull the QN, then look it up in the linked store. */ + yyjson_mut_val *cross_arr = yyjson_mut_arr(mdoc); + struct sqlite3 *src_db = cbm_store_get_db(store); + struct sqlite3 *lp_db = cbm_store_get_db(lp_store); + if (src_db && lp_db) { + sqlite3_stmt *eq = NULL; + if (sqlite3_prepare_v2( + src_db, + "SELECT e.source_id, e.type, n.qualified_name " + "FROM edges e JOIN nodes n " + " ON n.id = e.target_id AND n.project = e.project " + "WHERE e.project = ?1 AND e.type LIKE 'CROSS_%' " + " AND json_extract(e.properties, '$.target_project') = ?2 " + " AND n.qualified_name IS NOT NULL", + -1, &eq, NULL) == SQLITE_OK) { + sqlite3_bind_text(eq, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(eq, 2, linked[li], -1, SQLITE_STATIC); + sqlite3_stmt *lookup = NULL; + sqlite3_prepare_v2( + lp_db, "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1", -1, &lookup, + NULL); + + while (sqlite3_step(eq) == SQLITE_ROW) { + int64_t src_id = sqlite3_column_int64(eq, 0); + const char *etype = (const char *)sqlite3_column_text(eq, 1); + const char *qn = (const char *)sqlite3_column_text(eq, 2); + if (!qn || !etype || !lookup) { + continue; + } + sqlite3_reset(lookup); + sqlite3_clear_bindings(lookup); + sqlite3_bind_text(lookup, 1, qn, -1, SQLITE_STATIC); + if (sqlite3_step(lookup) != SQLITE_ROW) { + continue; + } + int64_t tgt_id = sqlite3_column_int64(lookup, 0); + yyjson_mut_val *ce = yyjson_mut_obj(mdoc); + yyjson_mut_obj_add_int(mdoc, ce, "source", src_id); + yyjson_mut_obj_add_int(mdoc, ce, "target", tgt_id); + yyjson_mut_obj_add_strcpy(mdoc, ce, "type", etype); + yyjson_mut_arr_append(cross_arr, ce); + } + if (lookup) + sqlite3_finalize(lookup); + sqlite3_finalize(eq); + } + } + yyjson_mut_obj_add_val(mdoc, entry, "cross_edges", cross_arr); + + cbm_store_close(lp_store); yyjson_mut_arr_append(lp_arr, entry); yyjson_mut_doc_free(lm); free(linked[li]); } + cbm_store_close(store); yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr); size_t len = 0;