feat: support ON_COMPLETION handler sections in diagram

Add ON_COMPLETION to backend COMPOUND_TYPES and frontend rendering.
Completion handlers render as teal-tinted sections between the main
flow and error handlers, structurally parallel to onException.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 16:45:10 +01:00
parent 9b7626f6ff
commit f6220a9f89
5 changed files with 67 additions and 40 deletions

View File

@@ -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() {

View File

@@ -21,10 +21,16 @@ interface ErrorSectionProps {
onNodeLeave: () => void;
}
const VARIANT_COLORS: Record<string, string> = {
error: '#C0392B',
completion: '#1A7F8E',
};
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
onNodeClick, 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 +50,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 +60,7 @@ export function ErrorSection({
y1={0}
x2={totalWidth}
y2={0}
stroke="#C0392B"
stroke={color}
strokeWidth={1}
strokeDasharray="6 3"
opacity={0.5}
@@ -66,7 +72,7 @@ export function ErrorSection({
y={4}
width={totalWidth}
height={boxHeight}
fill="#C0392B"
fill={color}
opacity={0.03}
rx={4}
/>

View File

@@ -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

View File

@@ -12,7 +12,7 @@ export interface DiagramSection {
nodes: DiagramNode[];
edges: DiagramEdge[];
offsetY: number;
variant?: 'error';
variant?: 'error' | 'completion';
}
export interface ProcessDiagramProps {

View File

@@ -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;