feat: use endpointUri for cross-route drill-down instead of label parsing
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m2s
CI / docker (push) Successful in 1m0s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

Server:
- Add endpointUri to PositionedNode (from RouteNode)
- Add fromEndpointUri to RouteSummary (catalog API)
- Catalog controller resolves endpoint URI from diagram store

UI:
- Build endpointRouteMap from catalog's fromEndpointUri field
- Drill-down uses exact match on node.endpointUri against the map
- Remove label parsing heuristics (extractTargetEndpoint, camelToKebab)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-28 18:31:08 +01:00
parent 0516207e83
commit e5e6175aca
9 changed files with 67 additions and 44 deletions

View File

@@ -3,9 +3,11 @@ package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentSummary; import com.cameleer3.server.app.dto.AgentSummary;
import com.cameleer3.server.app.dto.AppCatalogEntry; import com.cameleer3.server.app.dto.AppCatalogEntry;
import com.cameleer3.server.app.dto.RouteSummary; import com.cameleer3.server.app.dto.RouteSummary;
import com.cameleer3.common.graph.RouteGraph;
import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.storage.DiagramStore;
import com.cameleer3.server.core.storage.StatsStore; import com.cameleer3.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -34,10 +36,14 @@ import java.util.stream.Collectors;
public class RouteCatalogController { public class RouteCatalogController {
private final AgentRegistryService registryService; private final AgentRegistryService registryService;
private final DiagramStore diagramStore;
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) { public RouteCatalogController(AgentRegistryService registryService,
DiagramStore diagramStore,
JdbcTemplate jdbc) {
this.registryService = registryService; this.registryService = registryService;
this.diagramStore = diagramStore;
this.jdbc = jdbc; this.jdbc = jdbc;
} }
@@ -114,12 +120,14 @@ public class RouteCatalogController {
// Routes // Routes
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of()); Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::id).toList();
List<RouteSummary> routeSummaries = routeIds.stream() List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> { .map(routeId -> {
String key = appId + "/" + routeId; String key = appId + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L); long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key); Instant lastSeen = routeLastSeen.get(key);
return new RouteSummary(routeId, count, lastSeen); String fromUri = resolveFromEndpointUri(routeId, agentIds);
return new RouteSummary(routeId, count, lastSeen, fromUri);
}) })
.toList(); .toList();
@@ -141,6 +149,15 @@ public class RouteCatalogController {
return ResponseEntity.ok(catalog); return ResponseEntity.ok(catalog);
} }
/** Resolve the from() endpoint URI for a route by looking up its diagram. */
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())
.orElse(null);
}
private String computeWorstHealth(List<AgentInfo> agents) { private String computeWorstHealth(List<AgentInfo> agents) {
boolean hasDead = false; boolean hasDead = false;
boolean hasStale = false; boolean hasStale = false;

View File

@@ -666,7 +666,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
getAbsoluteX(wrapperElk, rootNode), getAbsoluteX(wrapperElk, rootNode),
getAbsoluteY(wrapperElk, rootNode), getAbsoluteY(wrapperElk, rootNode),
wrapperElk.getWidth(), wrapperElk.getHeight(), wrapperElk.getWidth(), wrapperElk.getHeight(),
wrapperChildren)); wrapperChildren, null));
ctx.compoundInfos.put(wrapperId, new CompoundInfo(wrapperId, Color.WHITE)); ctx.compoundInfos.put(wrapperId, new CompoundInfo(wrapperId, Color.WHITE));
} }
// Handler children in order: DO_FINALLY first, then DO_CATCH // Handler children in order: DO_FINALLY first, then DO_CATCH
@@ -693,7 +693,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
rn.getType() != null ? rn.getType().name() : "UNKNOWN", rn.getType() != null ? rn.getType().name() : "UNKNOWN",
absX, absY, absX, absY,
elkNode.getWidth(), elkNode.getHeight(), elkNode.getWidth(), elkNode.getHeight(),
children children,
rn.getEndpointUri()
); );
} }

View File

@@ -9,5 +9,7 @@ import java.time.Instant;
public record RouteSummary( public record RouteSummary(
@NotNull String routeId, @NotNull String routeId,
@NotNull long exchangeCount, @NotNull long exchangeCount,
Instant lastSeen Instant lastSeen,
@Schema(description = "The from() endpoint URI, e.g. 'direct:processOrder'")
String fromEndpointUri
) {} ) {}

View File

@@ -8,14 +8,15 @@ import java.util.List;
* For compound nodes (CHOICE, SPLIT, TRY_CATCH, etc.), {@code children} * For compound nodes (CHOICE, SPLIT, TRY_CATCH, etc.), {@code children}
* contains the nested child nodes rendered inside the parent bounds. * contains the nested child nodes rendered inside the parent bounds.
* *
* @param id node identifier (matches RouteNode.id) * @param id node identifier (matches RouteNode.id)
* @param label display label * @param label display label
* @param type NodeType name (e.g., "ENDPOINT", "PROCESSOR") * @param type NodeType name (e.g., "ENDPOINT", "PROCESSOR")
* @param x horizontal position * @param x horizontal position
* @param y vertical position * @param y vertical position
* @param width node width * @param width node width
* @param height node height * @param height node height
* @param children nested child nodes for compound/swimlane groups * @param children nested child nodes for compound/swimlane groups
* @param endpointUri the Camel endpoint URI (e.g., "direct:processOrder"), null for non-endpoint nodes
*/ */
public record PositionedNode( public record PositionedNode(
String id, String id,
@@ -25,6 +26,7 @@ public record PositionedNode(
double y, double y,
double width, double width,
double height, double height,
List<PositionedNode> children List<PositionedNode> children,
String endpointUri
) { ) {
} }

View File

@@ -10,6 +10,7 @@ export interface DiagramNode {
width?: number; width?: number;
height?: number; height?: number;
children?: DiagramNode[]; children?: DiagramNode[];
endpointUri?: string;
} }
export interface DiagramEdge { export interface DiagramEdge {

View File

@@ -14,6 +14,7 @@ interface ExecutionDiagramProps {
executionDetail?: ExecutionDetail; executionDetail?: ExecutionDetail;
direction?: 'LR' | 'TB'; direction?: 'LR' | 'TB';
knownRouteIds?: Set<string>; knownRouteIds?: Set<string>;
endpointRouteMap?: Map<string, string>;
onNodeAction?: (nodeId: string, action: NodeAction) => void; onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>; nodeConfigs?: Map<string, NodeConfig>;
className?: string; className?: string;
@@ -58,6 +59,7 @@ export function ExecutionDiagram({
executionDetail: externalDetail, executionDetail: externalDetail,
direction = 'LR', direction = 'LR',
knownRouteIds, knownRouteIds,
endpointRouteMap,
onNodeAction, onNodeAction,
nodeConfigs, nodeConfigs,
className, className,
@@ -166,6 +168,7 @@ export function ExecutionDiagram({
onNodeAction={onNodeAction} onNodeAction={onNodeAction}
nodeConfigs={nodeConfigs} nodeConfigs={nodeConfigs}
knownRouteIds={knownRouteIds} knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
executionOverlay={overlay} executionOverlay={overlay}
iterationState={iterationState} iterationState={iterationState}
onIterationChange={setIteration} onIterationChange={setIteration}

View File

@@ -18,30 +18,6 @@ const PADDING = 40;
/** Types that support drill-down — double-click navigates to the target route */ /** Types that support drill-down — double-click navigates to the target route */
const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']); const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']);
/** Extract the target endpoint name from a node's label */
function extractTargetEndpoint(node: DiagramNodeType): string | null {
// Labels like "to: direct:orderProcessing" or "direct:orderProcessing"
const label = node.label ?? '';
const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i);
return match ? match[1] : null;
}
/** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */
function camelToKebab(s: string): string {
return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/**
* Resolve a direct/seda endpoint name to a routeId.
* Tries: exact match, kebab-case conversion, then gives up.
*/
function resolveRouteId(endpoint: string, knownRouteIds: Set<string>): string | null {
if (knownRouteIds.has(endpoint)) return endpoint;
const kebab = camelToKebab(endpoint);
if (knownRouteIds.has(kebab)) return kebab;
return null;
}
export function ProcessDiagram({ export function ProcessDiagram({
application, application,
routeId, routeId,
@@ -51,6 +27,7 @@ export function ProcessDiagram({
onNodeAction, onNodeAction,
nodeConfigs, nodeConfigs,
knownRouteIds, knownRouteIds,
endpointRouteMap,
className, className,
diagramLayout, diagramLayout,
executionOverlay, executionOverlay,
@@ -190,17 +167,18 @@ export function ProcessDiagram({
(nodeId: string) => { (nodeId: string) => {
const node = findNodeById(sections, nodeId); const node = findNodeById(sections, nodeId);
if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return; if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return;
const endpoint = extractTargetEndpoint(node);
if (!endpoint) return; // Resolve via endpointUri → endpointRouteMap (exact match, no heuristics)
const resolved = knownRouteIds const uri = node.endpointUri;
? resolveRouteId(endpoint, knownRouteIds) if (!uri) return;
: endpoint; const stripped = uri.split('?')[0];
const resolved = endpointRouteMap?.get(stripped);
if (resolved) { if (resolved) {
onNodeSelect?.(''); onNodeSelect?.('');
setRouteStack(prev => [...prev, resolved]); setRouteStack(prev => [...prev, resolved]);
} }
}, },
[sections, onNodeSelect, knownRouteIds], [sections, onNodeSelect, endpointRouteMap],
); );
const handleBreadcrumbClick = useCallback( const handleBreadcrumbClick = useCallback(

View File

@@ -26,6 +26,8 @@ export interface ProcessDiagramProps {
nodeConfigs?: Map<string, NodeConfig>; nodeConfigs?: Map<string, NodeConfig>;
/** Known route IDs for this application (enables drill-down resolution) */ /** Known route IDs for this application (enables drill-down resolution) */
knownRouteIds?: Set<string>; knownRouteIds?: Set<string>;
/** Maps from() endpoint URI → routeId for cross-route drill-down */
endpointRouteMap?: Map<string, string>;
className?: string; className?: string;
/** Pre-fetched diagram layout (bypasses internal fetch by application/routeId) */ /** Pre-fetched diagram layout (bypasses internal fetch by application/routeId) */
diagramLayout?: DiagramLayout; diagramLayout?: DiagramLayout;

View File

@@ -136,6 +136,21 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
return ids; return ids;
}, [catalog]); }, [catalog]);
// Build endpoint URI → routeId map for cross-route drill-down
const endpointRouteMap = useMemo(() => {
const map = new Map<string, string>();
if (catalog) {
for (const app of catalog as any[]) {
for (const r of app.routes || []) {
if (r.fromEndpointUri) {
map.set(r.fromEndpointUri, r.routeId);
}
}
}
}
return map;
}, [catalog]);
// Build nodeConfigs from tracing store + app config (for TRACE/TAP badges) // Build nodeConfigs from tracing store + app config (for TRACE/TAP badges)
const { data: appConfig } = useApplicationConfig(appId); const { data: appConfig } = useApplicationConfig(appId);
const tracedMap = useTracingStore((s) => s.tracedProcessors[appId]); const tracedMap = useTracingStore((s) => s.tracedProcessors[appId]);
@@ -172,6 +187,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
executionId={exchangeId} executionId={exchangeId}
executionDetail={detail} executionDetail={detail}
knownRouteIds={knownRouteIds} knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction} onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs} nodeConfigs={nodeConfigs}
/> />
@@ -187,6 +203,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
routeId={routeId} routeId={routeId}
diagramLayout={diagramQuery.data} diagramLayout={diagramQuery.data}
knownRouteIds={knownRouteIds} knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction} onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs} nodeConfigs={nodeConfigs}
/> />