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.AppCatalogEntry;
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.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.storage.DiagramStore;
import com.cameleer3.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -34,10 +36,14 @@ import java.util.stream.Collectors;
public class RouteCatalogController {
private final AgentRegistryService registryService;
private final DiagramStore diagramStore;
private final JdbcTemplate jdbc;
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
public RouteCatalogController(AgentRegistryService registryService,
DiagramStore diagramStore,
JdbcTemplate jdbc) {
this.registryService = registryService;
this.diagramStore = diagramStore;
this.jdbc = jdbc;
}
@@ -114,12 +120,14 @@ public class RouteCatalogController {
// Routes
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::id).toList();
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = appId + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
return new RouteSummary(routeId, count, lastSeen);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
return new RouteSummary(routeId, count, lastSeen, fromUri);
})
.toList();
@@ -141,6 +149,15 @@ public class RouteCatalogController {
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) {
boolean hasDead = false;
boolean hasStale = false;

View File

@@ -666,7 +666,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
getAbsoluteX(wrapperElk, rootNode),
getAbsoluteY(wrapperElk, rootNode),
wrapperElk.getWidth(), wrapperElk.getHeight(),
wrapperChildren));
wrapperChildren, null));
ctx.compoundInfos.put(wrapperId, new CompoundInfo(wrapperId, Color.WHITE));
}
// 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",
absX, absY,
elkNode.getWidth(), elkNode.getHeight(),
children
children,
rn.getEndpointUri()
);
}

View File

@@ -9,5 +9,7 @@ import java.time.Instant;
public record RouteSummary(
@NotNull String routeId,
@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}
* contains the nested child nodes rendered inside the parent bounds.
*
* @param id node identifier (matches RouteNode.id)
* @param label display label
* @param type NodeType name (e.g., "ENDPOINT", "PROCESSOR")
* @param x horizontal position
* @param y vertical position
* @param width node width
* @param height node height
* @param children nested child nodes for compound/swimlane groups
* @param id node identifier (matches RouteNode.id)
* @param label display label
* @param type NodeType name (e.g., "ENDPOINT", "PROCESSOR")
* @param x horizontal position
* @param y vertical position
* @param width node width
* @param height node height
* @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(
String id,
@@ -25,6 +26,7 @@ public record PositionedNode(
double y,
double width,
double height,
List<PositionedNode> children
List<PositionedNode> children,
String endpointUri
) {
}

View File

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

View File

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

View File

@@ -18,30 +18,6 @@ const PADDING = 40;
/** Types that support drill-down — double-click navigates to the target route */
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({
application,
routeId,
@@ -51,6 +27,7 @@ export function ProcessDiagram({
onNodeAction,
nodeConfigs,
knownRouteIds,
endpointRouteMap,
className,
diagramLayout,
executionOverlay,
@@ -190,17 +167,18 @@ export function ProcessDiagram({
(nodeId: string) => {
const node = findNodeById(sections, nodeId);
if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return;
const endpoint = extractTargetEndpoint(node);
if (!endpoint) return;
const resolved = knownRouteIds
? resolveRouteId(endpoint, knownRouteIds)
: endpoint;
// Resolve via endpointUri → endpointRouteMap (exact match, no heuristics)
const uri = node.endpointUri;
if (!uri) return;
const stripped = uri.split('?')[0];
const resolved = endpointRouteMap?.get(stripped);
if (resolved) {
onNodeSelect?.('');
setRouteStack(prev => [...prev, resolved]);
}
},
[sections, onNodeSelect, knownRouteIds],
[sections, onNodeSelect, endpointRouteMap],
);
const handleBreadcrumbClick = useCallback(

View File

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

View File

@@ -136,6 +136,21 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
return ids;
}, [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)
const { data: appConfig } = useApplicationConfig(appId);
const tracedMap = useTracingStore((s) => s.tracedProcessors[appId]);
@@ -172,6 +187,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
executionId={exchangeId}
executionDetail={detail}
knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
@@ -187,6 +203,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
routeId={routeId}
diagramLayout={diagramQuery.data}
knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>