Compare commits
2 Commits
9b7626f6ff
...
eb9c20e734
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9c20e734 | ||
|
|
f6220a9f89 |
@@ -102,7 +102,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
|
||||
NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
|
||||
NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY,
|
||||
NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
|
||||
NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER
|
||||
NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER,
|
||||
NodeType.ON_COMPLETION
|
||||
);
|
||||
|
||||
public ElkDiagramRenderer() {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface CompoundNodeProps {
|
||||
hoveredNodeId: string | null;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -17,14 +17,21 @@ interface ErrorSectionProps {
|
||||
hoveredNodeId: string | null;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onNodeDoubleClick?: (nodeId: string) => void;
|
||||
onNodeEnter: (nodeId: string) => void;
|
||||
onNodeLeave: () => void;
|
||||
}
|
||||
|
||||
const VARIANT_COLORS: Record<string, string> = {
|
||||
error: '#C0392B',
|
||||
completion: '#1A7F8E',
|
||||
};
|
||||
|
||||
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(() => {
|
||||
let maxY = 0;
|
||||
for (const n of section.nodes) {
|
||||
@@ -44,7 +51,7 @@ export function ErrorSection({
|
||||
return (
|
||||
<g transform={`translate(0, ${section.offsetY})`}>
|
||||
{/* Section label */}
|
||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
||||
<text x={8} y={-6} fill={color} fontSize={11} fontWeight={600}>
|
||||
{section.label}
|
||||
</text>
|
||||
|
||||
@@ -54,7 +61,7 @@ export function ErrorSection({
|
||||
y1={0}
|
||||
x2={totalWidth}
|
||||
y2={0}
|
||||
stroke="#C0392B"
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 3"
|
||||
opacity={0.5}
|
||||
@@ -66,7 +73,7 @@ export function ErrorSection({
|
||||
y={4}
|
||||
width={totalWidth}
|
||||
height={boxHeight}
|
||||
fill="#C0392B"
|
||||
fill={color}
|
||||
opacity={0.03}
|
||||
rx={4}
|
||||
/>
|
||||
@@ -93,6 +100,7 @@ export function ErrorSection({
|
||||
hoveredNodeId={hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeEnter={onNodeEnter}
|
||||
onNodeLeave={onNodeLeave}
|
||||
/>
|
||||
@@ -106,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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>): 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<string[]>([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 (
|
||||
<div
|
||||
ref={zoom.containerRef}
|
||||
className={`${styles.container} ${className ?? ''}`}
|
||||
>
|
||||
{/* Breadcrumb bar — only shown when drilled down */}
|
||||
{routeStack.length > 1 && (
|
||||
<div className={styles.breadcrumbs}>
|
||||
{routeStack.map((route, i) => (
|
||||
<span key={i} className={styles.breadcrumbItem}>
|
||||
{i > 0 && <span className={styles.breadcrumbSep}>/</span>}
|
||||
{i < routeStack.length - 1 ? (
|
||||
<button
|
||||
className={styles.breadcrumbLink}
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
>
|
||||
{route}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.breadcrumbCurrent}>{route}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg
|
||||
className={styles.svg}
|
||||
onWheel={zoom.onWheel}
|
||||
@@ -140,6 +230,7 @@ export function ProcessDiagram({
|
||||
hoveredNodeId={toolbar.hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeEnter={toolbar.onNodeEnter}
|
||||
onNodeLeave={toolbar.onNodeLeave}
|
||||
/>
|
||||
@@ -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) => (
|
||||
<ErrorSection
|
||||
key={`error-${i}`}
|
||||
key={`handler-${i}`}
|
||||
section={section}
|
||||
totalWidth={totalWidth}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={toolbar.hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeEnter={toolbar.onNodeEnter}
|
||||
onNodeLeave={toolbar.onNodeLeave}
|
||||
/>
|
||||
@@ -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<string>();
|
||||
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<string>,
|
||||
) {
|
||||
function collectDescendantIds(nodes: DiagramNodeType[], set: Set<string>) {
|
||||
for (const n of nodes) {
|
||||
if (n.id) set.add(n.id);
|
||||
if (n.children) collectDescendantIds(n.children, set);
|
||||
|
||||
@@ -50,6 +50,8 @@ const TYPE_MAP: Record<string, string> = {
|
||||
DO_CATCH: ERROR_COLOR,
|
||||
DO_FINALLY: ERROR_COLOR,
|
||||
|
||||
ON_COMPLETION: '#1A7F8E', // --running (teal, lifecycle handler)
|
||||
|
||||
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
|
||||
EIP_ENRICH: CROSS_ROUTE_COLOR,
|
||||
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
|
||||
@@ -61,12 +63,17 @@ const COMPOUND_TYPES = new Set([
|
||||
'DO_TRY', 'DO_CATCH', 'DO_FINALLY',
|
||||
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||
'ON_COMPLETION',
|
||||
]);
|
||||
|
||||
const ERROR_COMPOUND_TYPES = new Set([
|
||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||
]);
|
||||
|
||||
const COMPLETION_COMPOUND_TYPES = new Set([
|
||||
'ON_COMPLETION',
|
||||
]);
|
||||
|
||||
export function colorForType(type: string | undefined): string {
|
||||
if (!type) return DEFAULT_COLOR;
|
||||
return TYPE_MAP[type] ?? DEFAULT_COLOR;
|
||||
@@ -80,6 +87,10 @@ export function isErrorCompoundType(type: string | undefined): boolean {
|
||||
return !!type && ERROR_COMPOUND_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function isCompletionCompoundType(type: string | undefined): boolean {
|
||||
return !!type && COMPLETION_COMPOUND_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** Icon character for a node type */
|
||||
export function iconForType(type: string | undefined): string {
|
||||
if (!type) return '\u2699'; // gear
|
||||
@@ -87,6 +98,7 @@ export function iconForType(type: string | undefined): string {
|
||||
if (t === 'ENDPOINT') return '\u25B6'; // play
|
||||
if (t === 'TO' || t === 'TO_DYNAMIC' || t === 'DIRECT' || t === 'SEDA') return '\u25A0'; // square
|
||||
if (t.startsWith('EIP_CHOICE') || t === 'EIP_WHEN' || t === 'EIP_OTHERWISE') return '\u25C6'; // diamond
|
||||
if (t === 'ON_COMPLETION') return '\u2714'; // checkmark
|
||||
if (t === 'ON_EXCEPTION' || t === 'ERROR_HANDLER' || t.startsWith('TRY') || t.startsWith('DO_')) return '\u26A0'; // warning
|
||||
if (t === 'EIP_SPLIT' || t === 'EIP_MULTICAST') return '\u2442'; // fork
|
||||
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface DiagramSection {
|
||||
nodes: DiagramNode[];
|
||||
edges: DiagramEdge[];
|
||||
offsetY: number;
|
||||
variant?: 'error';
|
||||
variant?: 'error' | 'completion';
|
||||
}
|
||||
|
||||
export interface ProcessDiagramProps {
|
||||
@@ -23,5 +23,7 @@ export interface ProcessDiagramProps {
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
/** Known route IDs for this application (enables drill-down resolution) */
|
||||
knownRouteIds?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
|
||||
import type { DiagramSection } from './types';
|
||||
import { isErrorCompoundType } from './node-colors';
|
||||
import { isErrorCompoundType, isCompletionCompoundType } from './node-colors';
|
||||
|
||||
const SECTION_GAP = 40;
|
||||
|
||||
@@ -20,12 +20,18 @@ export function useDiagramData(
|
||||
|
||||
const allEdges = layout.edges ?? [];
|
||||
|
||||
// Separate main nodes from error handler compound sections
|
||||
// Separate main nodes from completion and error handler compound sections
|
||||
const mainNodes: DiagramNode[] = [];
|
||||
const completionSections: { label: string; nodes: DiagramNode[] }[] = [];
|
||||
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
if (isCompletionCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
completionSections.push({
|
||||
label: node.label || 'onCompletion',
|
||||
nodes: node.children,
|
||||
});
|
||||
} else if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
errorSections.push({
|
||||
label: node.label || 'Error Handler',
|
||||
nodes: node.children,
|
||||
@@ -58,39 +64,41 @@ export function useDiagramData(
|
||||
let currentY = mainBounds.maxY + SECTION_GAP;
|
||||
let maxWidth = mainBounds.maxX;
|
||||
|
||||
for (const es of errorSections) {
|
||||
const errorBounds = computeBounds(es.nodes);
|
||||
const offX = errorBounds.minX;
|
||||
const offY = errorBounds.minY;
|
||||
const addHandlerSections = (
|
||||
handlers: { label: string; nodes: DiagramNode[] }[],
|
||||
variant: 'completion' | 'error',
|
||||
) => {
|
||||
for (const hs of handlers) {
|
||||
const bounds = computeBounds(hs.nodes);
|
||||
const offX = bounds.minX;
|
||||
const offY = bounds.minY;
|
||||
const shiftedNodes = shiftNodes(hs.nodes, offX, offY);
|
||||
const nodeIds = new Set<string>();
|
||||
collectNodeIds(hs.nodes, nodeIds);
|
||||
const edges = allEdges
|
||||
.filter(e => nodeIds.has(e.sourceId) && nodeIds.has(e.targetId))
|
||||
.map(e => ({
|
||||
...e,
|
||||
points: e.points.map(p => [p[0] - offX, p[1] - offY]),
|
||||
}));
|
||||
const sectionHeight = bounds.maxY - bounds.minY;
|
||||
const sectionWidth = bounds.maxX - bounds.minX;
|
||||
sections.push({
|
||||
label: hs.label,
|
||||
nodes: shiftedNodes,
|
||||
edges,
|
||||
offsetY: currentY,
|
||||
variant,
|
||||
});
|
||||
currentY += sectionHeight + SECTION_GAP;
|
||||
if (sectionWidth > maxWidth) maxWidth = sectionWidth;
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize node coordinates relative to the section's own origin
|
||||
const shiftedNodes = shiftNodes(es.nodes, offX, offY);
|
||||
|
||||
const errorNodeIds = new Set<string>();
|
||||
collectNodeIds(es.nodes, errorNodeIds);
|
||||
|
||||
// Shift edge points too
|
||||
const errorEdges = allEdges
|
||||
.filter(e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId))
|
||||
.map(e => ({
|
||||
...e,
|
||||
points: e.points.map(p => [p[0] - offX, p[1] - offY]),
|
||||
}));
|
||||
|
||||
const sectionHeight = errorBounds.maxY - errorBounds.minY;
|
||||
const sectionWidth = errorBounds.maxX - errorBounds.minX;
|
||||
|
||||
sections.push({
|
||||
label: es.label,
|
||||
nodes: shiftedNodes,
|
||||
edges: errorEdges,
|
||||
offsetY: currentY,
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
currentY += sectionHeight + SECTION_GAP;
|
||||
if (sectionWidth > maxWidth) maxWidth = sectionWidth;
|
||||
}
|
||||
// Completion handlers first (above error handlers)
|
||||
addHandlerSections(completionSections, 'completion');
|
||||
// Then error handlers
|
||||
addHandlerSections(errorSections, 'error');
|
||||
|
||||
const totalWidth = Math.max(layout.width ?? 0, mainBounds.maxX, maxWidth);
|
||||
const totalHeight = currentY;
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, NodeConfig>();
|
||||
@@ -85,6 +93,7 @@ export default function DevDiagram() {
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
knownRouteIds={knownRouteIds}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
|
||||
Reference in New Issue
Block a user