diff --git a/ui/src/components/ProcessDiagram/CompoundNode.tsx b/ui/src/components/ProcessDiagram/CompoundNode.tsx index 42200f2e..eb5f7c33 100644 --- a/ui/src/components/ProcessDiagram/CompoundNode.tsx +++ b/ui/src/components/ProcessDiagram/CompoundNode.tsx @@ -18,6 +18,7 @@ interface CompoundNodeProps { hoveredNodeId: string | null; nodeConfigs?: Map; onNodeClick: (nodeId: string) => void; + onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; onNodeLeave: () => void; } @@ -25,7 +26,7 @@ interface CompoundNodeProps { export function CompoundNode({ node, edges, parentX = 0, parentY = 0, selectedNodeId, hoveredNodeId, nodeConfigs, - onNodeClick, onNodeEnter, onNodeLeave, + onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: CompoundNodeProps) { const x = (node.x ?? 0) - parentX; const y = (node.y ?? 0) - parentY; @@ -102,6 +103,7 @@ export function CompoundNode({ hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} onNodeLeave={onNodeLeave} /> @@ -119,6 +121,7 @@ export function CompoundNode({ isSelected={selectedNodeId === child.id} config={child.id ? nodeConfigs?.get(child.id) : undefined} onClick={() => child.id && onNodeClick(child.id)} + onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)} onMouseEnter={() => child.id && onNodeEnter(child.id)} onMouseLeave={onNodeLeave} /> diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx index 0702f807..1fd03d92 100644 --- a/ui/src/components/ProcessDiagram/DiagramNode.tsx +++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx @@ -12,12 +12,13 @@ interface DiagramNodeProps { isSelected: boolean; config?: NodeConfig; onClick: () => void; + onDoubleClick?: () => void; onMouseEnter: () => void; onMouseLeave: () => void; } export function DiagramNode({ - node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave, + node, isHovered, isSelected, config, onClick, onDoubleClick, onMouseEnter, onMouseLeave, }: DiagramNodeProps) { const x = node.x ?? 0; const y = node.y ?? 0; @@ -35,6 +36,7 @@ export function DiagramNode({ data-node-id={node.id} transform={`translate(${x}, ${y})`} onClick={(e) => { e.stopPropagation(); onClick(); }} + onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} style={{ cursor: 'pointer' }} diff --git a/ui/src/components/ProcessDiagram/ErrorSection.tsx b/ui/src/components/ProcessDiagram/ErrorSection.tsx index b36618aa..0ca067ff 100644 --- a/ui/src/components/ProcessDiagram/ErrorSection.tsx +++ b/ui/src/components/ProcessDiagram/ErrorSection.tsx @@ -17,6 +17,7 @@ interface ErrorSectionProps { hoveredNodeId: string | null; nodeConfigs?: Map; onNodeClick: (nodeId: string) => void; + onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; onNodeLeave: () => void; } @@ -28,7 +29,7 @@ const VARIANT_COLORS: Record = { export function ErrorSection({ section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, - onNodeClick, onNodeEnter, onNodeLeave, + onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: ErrorSectionProps) { const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error; const boxHeight = useMemo(() => { @@ -99,6 +100,7 @@ export function ErrorSection({ hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} onNodeLeave={onNodeLeave} /> @@ -112,6 +114,7 @@ export function ErrorSection({ isSelected={selectedNodeId === node.id} config={node.id ? nodeConfigs?.get(node.id) : undefined} onClick={() => node.id && onNodeClick(node.id)} + onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)} onMouseEnter={() => node.id && onNodeEnter(node.id)} onMouseLeave={onNodeLeave} /> diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css index bdff3294..a3a158af 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css @@ -78,6 +78,50 @@ font-variant-numeric: tabular-nums; } +.breadcrumbs { + position: absolute; + top: 8px; + left: 8px; + display: flex; + align-items: center; + padding: 4px 10px; + background: var(--bg-surface, #FFFFFF); + border: 1px solid var(--border, #E4DFD8); + border-radius: var(--radius-sm, 5px); + box-shadow: var(--shadow-sm, 0 1px 2px rgba(44, 37, 32, 0.06)); + z-index: 10; + font-size: 12px; +} + +.breadcrumbItem { + display: flex; + align-items: center; +} + +.breadcrumbSep { + margin: 0 6px; + color: var(--text-muted, #9C9184); +} + +.breadcrumbLink { + background: none; + border: none; + color: var(--running, #1A7F8E); + cursor: pointer; + padding: 0; + font-size: 12px; + font-family: inherit; +} + +.breadcrumbLink:hover { + text-decoration: underline; +} + +.breadcrumbCurrent { + color: var(--text-primary, #1A1612); + font-weight: 600; +} + .nodeToolbar { position: absolute; display: flex; diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 0d22911b..5dad44cc 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { ProcessDiagramProps } from './types'; +import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import { useDiagramData } from './useDiagramData'; import { useZoomPan } from './useZoomPan'; import { useToolbarHover, NodeToolbar } from './NodeToolbar'; @@ -13,6 +14,33 @@ import styles from './ProcessDiagram.module.css'; 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 | null { + if (knownRouteIds.has(endpoint)) return endpoint; + const kebab = camelToKebab(endpoint); + if (knownRouteIds.has(kebab)) return kebab; + return null; +} + export function ProcessDiagram({ application, routeId, @@ -21,10 +49,21 @@ export function ProcessDiagram({ onNodeSelect, onNodeAction, nodeConfigs, + knownRouteIds, className, }: ProcessDiagramProps) { + // Route stack for drill-down navigation + const [routeStack, setRouteStack] = useState([routeId]); + + // Reset stack when the external routeId prop changes + useEffect(() => { + setRouteStack([routeId]); + }, [routeId]); + + const currentRouteId = routeStack[routeStack.length - 1]; + const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( - application, routeId, direction, + application, currentRouteId, direction, ); const zoom = useZoomPan(); @@ -33,18 +72,43 @@ export function ProcessDiagram({ const contentWidth = totalWidth + PADDING * 2; const contentHeight = totalHeight + PADDING * 2; - // Reset to 100% at top-left on first data load + // Reset to 100% at top-left when route changes useEffect(() => { if (totalWidth > 0 && totalHeight > 0) { zoom.resetView(); } - }, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps + }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps const handleNodeClick = useCallback( (nodeId: string) => { onNodeSelect?.(nodeId); }, [onNodeSelect], ); + const handleNodeDoubleClick = useCallback( + (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; + if (resolved) { + onNodeSelect?.(''); + setRouteStack(prev => [...prev, resolved]); + } + }, + [sections, onNodeSelect, knownRouteIds], + ); + + const handleBreadcrumbClick = useCallback( + (index: number) => { + onNodeSelect?.(''); + setRouteStack(prev => prev.slice(0, index + 1)); + }, + [onNodeSelect], + ); + const handleNodeAction = useCallback( (nodeId: string, action: import('./types').NodeAction) => { if (action === 'inspect') onNodeSelect?.(nodeId); @@ -56,12 +120,17 @@ export function ProcessDiagram({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { - onNodeSelect?.(''); + if (routeStack.length > 1) { + // Go back one level + setRouteStack(prev => prev.slice(0, -1)); + } else { + onNodeSelect?.(''); + } return; } zoom.onKeyDown(e, contentWidth, contentHeight); }, - [onNodeSelect, zoom, contentWidth, contentHeight], + [onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length], ); if (isLoading) { @@ -89,13 +158,34 @@ export function ProcessDiagram({ } const mainSection = sections[0]; - const errorSections = sections.slice(1); + const handlerSections = sections.slice(1); return (
+ {/* Breadcrumb bar — only shown when drilled down */} + {routeStack.length > 1 && ( +
+ {routeStack.map((route, i) => ( + + {i > 0 && /} + {i < routeStack.length - 1 ? ( + + ) : ( + {route} + )} + + ))} +
+ )} + @@ -153,6 +244,7 @@ export function ProcessDiagram({ isSelected={selectedNodeId === node.id} config={node.id ? nodeConfigs?.get(node.id) : undefined} onClick={() => node.id && handleNodeClick(node.id)} + onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} onMouseLeave={toolbar.onNodeLeave} /> @@ -162,16 +254,17 @@ export function ProcessDiagram({ {/* Toolbar rendered as HTML overlay below */} - {/* Error handler sections */} - {errorSections.map((section, i) => ( + {/* Handler sections (completion, then error) */} + {handlerSections.map((section, i) => ( @@ -183,7 +276,6 @@ export function ProcessDiagram({ {toolbar.hoveredNodeId && onNodeAction && (() => { const hNode = findNodeById(sections, toolbar.hoveredNodeId!); if (!hNode) return null; - // Convert SVG coordinates to screen-space using zoom transform const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2; const nodeTop = hNode.y ?? 0; const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX; @@ -213,7 +305,7 @@ export function ProcessDiagram({ function findNodeById( sections: import('./types').DiagramSection[], nodeId: string, -): import('../../api/queries/diagrams').DiagramNode | undefined { +): DiagramNodeType | undefined { for (const section of sections) { for (const node of section.nodes) { if (node.id === nodeId) return node; @@ -227,9 +319,9 @@ function findNodeById( } function findInChildren( - nodes: import('../../api/queries/diagrams').DiagramNode[], + nodes: DiagramNodeType[], nodeId: string, -): import('../../api/queries/diagrams').DiagramNode | undefined { +): DiagramNodeType | undefined { for (const n of nodes) { if (n.id === nodeId) return n; if (n.children) { @@ -240,12 +332,10 @@ function findInChildren( return undefined; } -/** Returns true if the edge connects two top-level nodes (not inside any compound). */ function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, - nodes: import('../../api/queries/diagrams').DiagramNode[], + nodes: DiagramNodeType[], ): boolean { - // Collect all IDs that are children of compound nodes (at any depth) const compoundChildIds = new Set(); for (const n of nodes) { if (n.children && n.children.length > 0) { @@ -255,10 +345,7 @@ function topLevelEdge( return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId); } -function collectDescendantIds( - nodes: import('../../api/queries/diagrams').DiagramNode[], - set: Set, -) { +function collectDescendantIds(nodes: DiagramNodeType[], set: Set) { for (const n of nodes) { if (n.id) set.add(n.id); if (n.children) collectDescendantIds(n.children, set); diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index dc4ac79a..41e153e0 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -23,5 +23,7 @@ export interface ProcessDiagramProps { onNodeSelect?: (nodeId: string) => void; onNodeAction?: (nodeId: string, action: NodeAction) => void; nodeConfigs?: Map; + /** Known route IDs for this application (enables drill-down resolution) */ + knownRouteIds?: Set; className?: string; } diff --git a/ui/src/pages/DevDiagram/DevDiagram.tsx b/ui/src/pages/DevDiagram/DevDiagram.tsx index 33d74be3..b0901f56 100644 --- a/ui/src/pages/DevDiagram/DevDiagram.tsx +++ b/ui/src/pages/DevDiagram/DevDiagram.tsx @@ -33,6 +33,14 @@ export default function DevDiagram() { return { apps: appArr, routes: filtered }; }, [catalog, selectedApp]); + // All route IDs for the selected app (for drill-down resolution) + const knownRouteIds = useMemo(() => { + if (!catalog || !selectedApp) return new Set(); + const app = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>) + .find(a => a.appId === selectedApp); + return new Set((app?.routes ?? []).map(r => r.routeId)); + }, [catalog, selectedApp]); + // Mock node configs for testing const nodeConfigs = useMemo(() => { const map = new Map(); @@ -85,6 +93,7 @@ export default function DevDiagram() { onNodeSelect={setSelectedNodeId} onNodeAction={handleNodeAction} nodeConfigs={nodeConfigs} + knownRouteIds={knownRouteIds} /> ) : (