feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

New interactive route diagram component with SVG rendering using
server-computed ELK layout coordinates. TIBCO BW5-inspired top-bar
card node style with zoom/pan, hover toolbars, config badges, and
error handler sections below the main flow.

Backend: add direction query parameter (LR/TB) to diagram render
endpoints, defaulting to left-to-right layout.

Frontend: 14-file ProcessDiagram component in ui/src/components/
with DiagramNode, CompoundNode, DiagramEdge, ConfigBadge, NodeToolbar,
ErrorSection, ZoomControls, and supporting hooks. Dev test page at
/dev/diagram for validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 13:55:29 +01:00
parent 78e12f5cf9
commit ac32396a57
24 changed files with 7264 additions and 18 deletions

View File

@@ -62,6 +62,7 @@ public class DiagramRenderController {
@ApiResponse(responseCode = "404", description = "Diagram not found")
public ResponseEntity<?> renderDiagram(
@PathVariable String contentHash,
@RequestParam(defaultValue = "LR") String direction,
HttpServletRequest request) {
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
@@ -76,7 +77,7 @@ public class DiagramRenderController {
// without also accepting everything (*/*). This means "application/json"
// must appear and wildcards must not dominate the preference.
if (accept != null && isJsonPreferred(accept)) {
DiagramLayout layout = diagramRenderer.layoutJson(graph);
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(layout);
@@ -96,7 +97,8 @@ public class DiagramRenderController {
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String application,
@RequestParam String routeId) {
@RequestParam String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplication(application).stream()
.map(AgentInfo::id)
.toList();
@@ -115,7 +117,7 @@ public class DiagramRenderController {
return ResponseEntity.notFound().build();
}
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get());
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get(), direction);
return ResponseEntity.ok(layout);
}

View File

@@ -112,7 +112,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
@Override
public String renderSvg(RouteGraph graph) {
LayoutResult result = computeLayout(graph);
LayoutResult result = computeLayout(graph, Direction.DOWN);
DiagramLayout layout = result.layout;
int svgWidth = (int) Math.ceil(layout.width()) + 2 * PADDING;
@@ -153,21 +153,27 @@ public class ElkDiagramRenderer implements DiagramRenderer {
@Override
public DiagramLayout layoutJson(RouteGraph graph) {
return computeLayout(graph).layout;
return computeLayout(graph, Direction.RIGHT).layout;
}
@Override
public DiagramLayout layoutJson(RouteGraph graph, String direction) {
Direction dir = "TB".equalsIgnoreCase(direction) ? Direction.DOWN : Direction.RIGHT;
return computeLayout(graph, dir).layout;
}
// ----------------------------------------------------------------
// Layout computation
// ----------------------------------------------------------------
private LayoutResult computeLayout(RouteGraph graph) {
private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) {
ElkGraphFactory factory = ElkGraphFactory.eINSTANCE;
// Create root node
ElkNode rootNode = factory.createElkNode();
rootNode.setIdentifier("root");
rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
rootNode.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
rootNode.setProperty(CoreOptions.DIRECTION, rootDirection);
rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);

View File

@@ -19,4 +19,14 @@ public interface DiagramRenderer {
* Compute a positioned JSON layout for the route graph.
*/
DiagramLayout layoutJson(RouteGraph graph);
/**
* Compute a positioned JSON layout with a specific flow direction.
*
* @param graph the route graph
* @param direction "LR" for left-to-right, "TB" for top-to-bottom
*/
default DiagramLayout layoutJson(RouteGraph graph, String direction) {
return layoutJson(graph);
}
}

View File

@@ -0,0 +1,335 @@
# Interactive Process Diagram — Design Spec
**Sub-project:** 1 of 3 (Component → Execution Overlay → Page Integration)
**Scope:** Interactive SVG diagram component with zoom/pan, node interactions, config badges, and a configurable layout direction. Does NOT include execution overlay or page replacement — those are sub-projects 2 and 3.
---
## Problem
The current RouteFlow component renders Camel routes as a flat vertical list of nodes. It cannot show compound structures (choice branches, split fan-out, try-catch nesting), does not support zoom/pan, and has no interactive controls beyond click-to-select. Routes with 10+ processors become hard to follow, and the relationship between processors is not visually clear.
## Goal
Build an interactive process diagram component styled after MuleSoft / TIBCO BusinessWorks 5, rendering Camel routes as left-to-right flow diagrams using server-computed ELK layout coordinates. The component supports zoom/pan, node hover toolbars for tracing/tap configuration, config badge indicators, and a collapsible detail side-panel.
---
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Rendering | SVG + custom React | Full control over styling, no heavy deps. Server owns layout. |
| Node style | Top-Bar Cards | TIBCO BW5-inspired white cards with colored top accent bar. Professional, clean. |
| Flow direction | Left-to-right (default) | Matches MuleSoft/BW5 conventions. Query param for flexibility. |
| Component location | `ui/src/components/ProcessDiagram/` | Tightly coupled to Cameleer data model, no design-system abstraction needed. |
| Interactions | Hover floating toolbar + click-to-select | Discoverable, no right-click dependency. |
| Error handlers | Below main flow | Clear visual separation, labeled divider. |
| Selection behavior | Side panel with config info; execution data only with overlay | Keeps base diagram focused on topology. |
---
## 1. Backend: Layout Direction Parameter
### Change
Add optional `direction` query parameter to diagram render endpoints.
### Files
- `cameleer3-server-app/.../diagram/ElkDiagramRenderer.java` — accept direction param, map to ELK `Direction.RIGHT` (LR) or `Direction.DOWN` (TB)
- `cameleer3-server-core/.../diagram/DiagramRenderer.java` — update interface to accept direction
- `cameleer3-server-app/.../controller/DiagramRenderController.java` — add `@RequestParam(defaultValue = "LR") String direction` to render endpoints
- `ui/src/api/queries/diagrams.ts` — pass `direction` query param to API calls; also update `DiagramLayout` edge type to match backend `PositionedEdge` serialization: `{ sourceId, targetId, label?, points: number[][] }` (currently defines `{ from?, to? }` which is missing `points` and `label`)
### Behavior
- `GET /diagrams/{contentHash}/render?direction=LR` → left-to-right layout (default)
- `GET /diagrams/{contentHash}/render?direction=TB` → top-to-bottom layout
- `GET /diagrams?application=X&routeId=Y&direction=LR` → same for by-route endpoint
### Compound Node Direction
The direction parameter applies to the **root** layout only. Compound nodes (CHOICE, SPLIT, TRY_CATCH, etc.) keep their internal layout direction as **top-to-bottom** regardless of the root direction. This matches how MuleSoft/BW5 render branching patterns: the main flow goes left-to-right, but branches within a choice or split fan out vertically inside their container.
---
## 2. Frontend: ProcessDiagram Component
### File Structure
```
ui/src/components/ProcessDiagram/
├── ProcessDiagram.tsx # Root: SVG container, zoom/pan, section layout
├── ProcessDiagram.module.css # Styles using design system tokens
├── DiagramNode.tsx # Individual node: top-bar card rendering
├── DiagramEdge.tsx # Edge: cubic Bezier path with arrowhead
├── CompoundNode.tsx # Container for compound types (choice, split)
├── NodeToolbar.tsx # Floating action toolbar on hover
├── ConfigBadge.tsx # Indicator badges (TRACE, TAP) on nodes
├── ErrorSection.tsx # Visual separator + error handler flow section
├── ZoomControls.tsx # HTML overlay: zoom in/out/fit buttons
├── useZoomPan.ts # Hook: viewBox transform, wheel zoom, drag pan
├── useDiagramData.ts # Hook: fetch + separate layout into sections
├── node-colors.ts # NodeType → design system color token mapping
├── types.ts # Shared TypeScript interfaces
└── index.ts # Public exports
```
### Props API
```typescript
interface ProcessDiagramProps {
application: string;
routeId: string;
direction?: 'LR' | 'TB'; // default 'LR'
selectedNodeId?: string; // controlled selection
onNodeSelect?: (nodeId: string) => void;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>; // active taps/tracing per processor
className?: string;
}
type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id';
interface NodeConfig {
traceEnabled?: boolean;
tapExpression?: string;
}
// ExecutionOverlay types will be added in sub-project 2 when needed.
// No forward-declared types here to avoid drift.
```
### SVG Structure
```
<div class="process-diagram">
<svg viewBox="..."> // zoom = viewBox transform
<defs> // arrowhead markers, filters
<marker id="arrow">...</marker>
</defs>
<g class="diagram-content"> // pan offset transform
<!-- Main Route section -->
<g class="section section--main">
<g class="edges"> // rendered first (behind nodes)
<path d="M ... C ..." /> // cubic bezier from ELK waypoints
</g>
<g class="nodes">
<g transform="translate(x,y)"> // ELK-computed position
<!-- DiagramNode: top-bar card -->
<!-- ConfigBadge: top-right corner pills -->
<!-- NodeToolbar: foreignObject on hover -->
</g>
<g class="compound"> // CompoundNode: dashed border container
<g transform="translate(...)"> <!-- children inside -->
</g>
</g>
</g>
<!-- Error Handler section(s) -->
<g class="section section--error"
transform="translate(0, mainHeight + gap)">
<text>onException: java.lang.Exception</text>
<line ... /> // divider
<g class="edges">...</g>
<g class="nodes">...</g>
</g>
</g>
</svg>
<div class="zoom-controls">...</div> // HTML overlay, bottom-right
</div>
```
---
## 3. Node Visual States
### Base States
| State | Visual |
|-------|--------|
| Normal | White card, `--border` (#E4DFD8), colored top bar per type |
| Hovered | Warm tint background (`--bg-hover` / #F5F0EA), stronger border, floating toolbar appears above |
| Selected | Amber selection ring (2.5px solid `--amber`), side panel opens |
### Config Badges
Small colored pill badges positioned at the top-right corner of the node card, always visible:
- **TRACE** — teal (`--running`) pill, shown when tracing is enabled
- **TAP** — purple (`--purple`) pill, shown when a tap expression is configured
### Execution Overlay States (sub-project 2 — node must support these props)
| State | Visual |
|-------|--------|
| Executed (OK) | Green left border or subtle green tint |
| Failed (caused error handler) | Red border (2px `--error`), red marker icon |
| Not executed | Dimmed (reduced opacity) |
| Has trace data | Small "data available" indicator icon |
| No trace data | No indicator (or grayed-out data icon) |
### Node Type Colors
| Category | Token | Hex | Types |
|----------|-------|-----|-------|
| Endpoints | `--running` | #1A7F8E teal | ENDPOINT |
| Processors | `--amber` | #C6820E | PROCESSOR, BEAN, LOG, SET_HEADER, SET_BODY, TRANSFORM, MARSHAL, UNMARSHAL |
| Targets | `--success` | #3D7C47 green | TO, TO_DYNAMIC, DIRECT, SEDA |
| EIP Patterns | `--purple` | #7C3AED | EIP_CHOICE, EIP_WHEN, EIP_OTHERWISE, EIP_SPLIT, EIP_MULTICAST, EIP_LOOP, EIP_AGGREGATE, EIP_FILTER, etc. |
| Error Handling | `--error` | #C0392B red | ERROR_HANDLER, ON_EXCEPTION, TRY_CATCH, DO_TRY, DO_CATCH, DO_FINALLY |
| Cross-Route | (hardcoded) | #06B6D4 cyan | EIP_WIRE_TAP, EIP_ENRICH, EIP_POLL_ENRICH |
Note: This frontend color mapping intentionally differs from the backend `ElkDiagramRenderer` SVG colors (which use blue for endpoints, green for processors). The frontend uses design system tokens for consistency with the rest of the UI. The backend SVG renderer is not changed.
### Compound Node Rendering
Compound types (CHOICE, SPLIT, TRY_CATCH, LOOP, etc.) render as:
- Full-width colored header bar with white text label (type name)
- White body area with subtle border matching the type color
- Children rendered inside at their ELK-relative positions
- Children have their own hover/select/badge behavior
---
## 4. Interactions
### Hover Floating Toolbar
On mouse enter over a node, a dark floating toolbar appears above the node (centered). Uses `<foreignObject>` for HTML accessibility.
| Icon | Action | Callback |
|------|--------|----------|
| Search | Inspect | `onNodeAction(id, 'inspect')` — selects node, opens side panel |
| T | Toggle Trace | `onNodeAction(id, 'toggle-trace')` — enables/disables tracing |
| Pencil | Configure Tap | `onNodeAction(id, 'configure-tap')` — opens tap config |
| ... | More | `onNodeAction(id, 'copy-id')` — copies processor ID |
Toolbar hides on mouse leave after a short delay (150ms) to prevent flicker when moving between node and toolbar.
### Click-to-Select
Click on a node → calls `onNodeSelect(nodeId)`. Parent controls `selectedNodeId` prop. Selected node shows amber ring.
### Zoom & Pan
**`useZoomPan` hook manages:**
- Mouse wheel → zoom centered on cursor
- Click+drag on background → pan
- Pinch gesture → zoom (trackpad/touch)
- State: `{ scale, translateX, translateY }`
- Applied to SVG `viewBox` attribute
**`ZoomControls` component:**
- Three buttons: `+` (zoom in), `` (zoom out), fit-to-view icon
- Positioned as HTML overlay at bottom-right of diagram container
- Fit-to-view calculates viewBox to show entire diagram with 40px padding
**Zoom limits:** 25% to 400%.
### Keyboard Navigation
**Required:**
| Key | Action |
|-----|--------|
| Escape | Deselect / close panel |
| +/- | Zoom in/out |
| 0 | Fit to view |
**Stretch (implement if time permits):**
| Key | Action |
|-----|--------|
| Arrow keys | Move selection between connected nodes |
| Tab | Cycle through nodes in flow order |
| Enter | Open detail panel for selected node |
---
## 5. Error Handler Sections
Error handler compounds (ON_EXCEPTION, ERROR_HANDLER) render as separate sections below the main flow:
1. **Divider:** Horizontal line with label text (e.g., "onException: java.lang.Exception")
2. **Gap:** 40px vertical gap between main section and error section
3. **Layout:** Error section gets its own ELK-computed layout (compound node children already have relative coordinates)
4. **Styling:** Same node rendering as main section, but the section background has a subtle red tint
5. **Multiple handlers:** Each ON_EXCEPTION becomes its own section, stacked vertically
The `useDiagramData` hook separates top-level compound error nodes from regular nodes, computing the Y offset for each error section based on accumulated heights.
---
## 6. Data Flow
```
useDiagramByRoute(app, routeId)
→ contentHash
→ useDiagramLayout(contentHash, direction)
→ DiagramLayout { nodes[], edges[], width, height }
useDiagramData hook:
1. Separate nodes into mainNodes[] and errorSections[]
(reuses logic from buildFlowSegments: error-handler compounds with children → error sections)
2. Filter edges: mainEdges (between main nodes), errorEdges (within each error section)
3. Compute total SVG dimensions: max(mainWidth, errorWidths) × (mainHeight + gap + errorHeights)
4. Return { mainNodes, mainEdges, errorSections, totalWidth, totalHeight }
```
The existing `diagram-mapping.ts` `buildFlowSegments` function handles the separation logic. The new `useDiagramData` hook adapts this for SVG coordinate-based rendering instead of RouteFlow's FlowSegment format.
---
## 7. Side Panel (Detail Panel)
When a node is selected, a collapsible side panel slides in from the right of the diagram container.
**Base mode (no execution overlay):**
- Processor ID
- Processor type
- Endpoint URI (if applicable)
- Active configuration: tracing status, tap expression
- Node metadata from the diagram
**With execution overlay (sub-project 2):**
- Execution status + duration
- Input/output body (if trace data captured)
- Input/output headers
- Error message + stack trace (if failed)
- Loop iteration selector (if inside a loop)
For sub-project 1, the side panel shows config info only. The component accepts an `onNodeSelect` callback — the parent page controls what appears in the panel.
The side panel is NOT part of the ProcessDiagram component itself. It is rendered by the parent page and controlled via the `selectedNodeId` / `onNodeSelect` props. This keeps the diagram component focused on visualization.
**Dev test page (`/dev/diagram`):** In sub-project 1, the test page renders the ProcessDiagram with a simple stub side panel that shows the selected node's ID, type, label, and any `nodeConfigs` entry. This validates the selection interaction without needing full page integration.
---
## 8. Non-Goals (Sub-project 2 & 3)
These are explicitly out of scope for sub-project 1:
- **Execution overlay rendering** — animated flow, per-node status/duration, dimming non-executed nodes
- **Loop/split iteration stepping** — "debugger" UI with iteration tabs
- **Page integration** — replacing RouteFlow on RouteDetail, ExchangeDetail, Dashboard
- **Minimap** — small overview for large diagrams (stretch goal, not v1)
- **Drag to rearrange** — nodes are server-positioned, not user-movable
---
## Verification
1. **Backend:** `mvn clean verify -DskipITs` passes after direction param addition
2. **Frontend types:** `npx tsc -p tsconfig.app.json --noEmit` passes
3. **Manual test:** Create a temporary test page or Storybook-like route (`/dev/diagram`) that renders the ProcessDiagram component with a known route
4. **Zoom/pan:** Mouse wheel zooms, drag pans, fit-to-view works
5. **Node interaction:** Hover shows toolbar, click selects with amber ring
6. **Config badges:** Pass mock `nodeConfigs` and verify TRACE/TAP pills render
7. **Error sections:** Route with ON_EXCEPTION renders error handler below main flow
8. **Compound nodes:** Route with CHOICE renders children inside dashed container
9. **Keyboard (required):** Escape deselects, +/- zooms, 0 fits to view
10. **Direction:** `?direction=TB` renders top-to-bottom layout

File diff suppressed because one or more lines are too long

View File

@@ -12,19 +12,32 @@ export interface DiagramNode {
children?: DiagramNode[];
}
interface DiagramLayout {
export interface DiagramEdge {
sourceId: string;
targetId: string;
label?: string;
points: number[][];
}
export interface DiagramLayout {
width?: number;
height?: number;
nodes?: DiagramNode[];
edges?: Array<{ from?: string; to?: string }>;
edges?: DiagramEdge[];
}
export function useDiagramLayout(contentHash: string | null) {
export function useDiagramLayout(
contentHash: string | null,
direction: 'LR' | 'TB' = 'LR',
) {
return useQuery({
queryKey: ['diagrams', 'layout', contentHash],
queryKey: ['diagrams', 'layout', contentHash, direction],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams/{contentHash}/render', {
params: { path: { contentHash: contentHash! } },
params: {
path: { contentHash: contentHash! },
query: { direction },
},
headers: { Accept: 'application/json' },
});
if (error) throw new Error('Failed to load diagram layout');
@@ -34,15 +47,19 @@ export function useDiagramLayout(contentHash: string | null) {
});
}
export function useDiagramByRoute(application: string | undefined, routeId: string | undefined) {
export function useDiagramByRoute(
application: string | undefined,
routeId: string | undefined,
direction: 'LR' | 'TB' = 'LR',
) {
return useQuery({
queryKey: ['diagrams', 'byRoute', application, routeId],
queryKey: ['diagrams', 'byRoute', application, routeId, direction],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams', {
params: { query: { application: application!, routeId: routeId! } },
params: { query: { application: application!, routeId: routeId!, direction } },
});
if (error) throw new Error('Failed to load diagram for route');
return data!;
return data as DiagramLayout;
},
enabled: !!application && !!routeId,
});

View File

@@ -3642,6 +3642,8 @@ export interface operations {
query: {
application: string;
routeId: string;
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
direction?: "LR" | "TB";
};
header?: never;
path?: never;
@@ -3671,7 +3673,10 @@ export interface operations {
};
renderDiagram: {
parameters: {
query?: never;
query?: {
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
direction?: "LR" | "TB";
};
header?: never;
path: {
contentHash: string;

View File

@@ -0,0 +1,82 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import { colorForType } from './node-colors';
import { DiagramNode } from './DiagramNode';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
interface CompoundNodeProps {
node: DiagramNodeType;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
onNodeClick: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function CompoundNode({
node, selectedNodeId, hoveredNodeId, nodeConfigs,
onNodeClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 200;
const h = node.height ?? 100;
const color = colorForType(node.type);
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const label = node.label ? `${typeName}: ${node.label}` : typeName;
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
{/* Container body */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill="white"
stroke={color}
strokeWidth={1.5}
/>
{/* Colored header bar */}
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
{/* Header label */}
<text
x={w / 2}
y={HEADER_HEIGHT / 2 + 4}
fill="white"
fontSize={10}
fontWeight={600}
textAnchor="middle"
>
{label}
</text>
{/* Children nodes (positioned relative to compound) */}
{node.children?.map(child => (
<DiagramNode
key={child.id}
node={{
...child,
// Children have absolute coordinates from the backend,
// but since we're inside the compound's translate, subtract parent offset
x: (child.x ?? 0) - x,
y: (child.y ?? 0) - y,
}}
isHovered={hoveredNodeId === child.id}
isSelected={selectedNodeId === child.id}
config={child.id ? nodeConfigs?.get(child.id) : undefined}
onClick={() => child.id && onNodeClick(child.id)}
onMouseEnter={() => child.id && onNodeEnter(child.id)}
onMouseLeave={onNodeLeave}
/>
))}
</g>
);
}

View File

@@ -0,0 +1,48 @@
import type { NodeConfig } from './types';
const BADGE_HEIGHT = 14;
const BADGE_RADIUS = 7;
const BADGE_FONT_SIZE = 8;
const BADGE_GAP = 4;
interface ConfigBadgeProps {
nodeWidth: number;
config: NodeConfig;
}
export function ConfigBadge({ nodeWidth, config }: ConfigBadgeProps) {
const badges: { label: string; color: string }[] = [];
if (config.tapExpression) badges.push({ label: 'TAP', color: '#7C3AED' });
if (config.traceEnabled) badges.push({ label: 'TRACE', color: '#1A7F8E' });
if (badges.length === 0) return null;
let xOffset = nodeWidth;
return (
<g className="config-badges">
{badges.map((badge, i) => {
const textWidth = badge.label.length * 5.5 + 8;
xOffset -= textWidth + (i > 0 ? BADGE_GAP : 0);
return (
<g key={badge.label} transform={`translate(${xOffset}, ${-BADGE_HEIGHT / 2 - 2})`}>
<rect
width={textWidth}
height={BADGE_HEIGHT}
rx={BADGE_RADIUS}
fill={badge.color}
/>
<text
x={textWidth / 2}
y={BADGE_HEIGHT / 2 + 3}
fill="white"
fontSize={BADGE_FONT_SIZE}
fontWeight={600}
textAnchor="middle"
>
{badge.label}
</text>
</g>
);
})}
</g>
);
}

View File

@@ -0,0 +1,49 @@
import type { DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
interface DiagramEdgeProps {
edge: DiagramEdgeType;
offsetY?: number;
}
export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) {
const pts = edge.points;
if (!pts || pts.length < 2) return null;
// Build SVG path: move to first point, then cubic bezier or line to rest
let d = `M ${pts[0][0]} ${pts[0][1] + offsetY}`;
if (pts.length === 2) {
d += ` L ${pts[1][0]} ${pts[1][1] + offsetY}`;
} else if (pts.length === 4) {
// 4 points: start, control1, control2, end → cubic bezier
d += ` C ${pts[1][0]} ${pts[1][1] + offsetY}, ${pts[2][0]} ${pts[2][1] + offsetY}, ${pts[3][0]} ${pts[3][1] + offsetY}`;
} else {
// Multiple points: connect with line segments through intermediate points
for (let i = 1; i < pts.length; i++) {
d += ` L ${pts[i][0]} ${pts[i][1] + offsetY}`;
}
}
return (
<g className="diagram-edge">
<path
d={d}
fill="none"
stroke="#9CA3AF"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
{edge.label && pts.length >= 2 && (
<text
x={(pts[0][0] + pts[pts.length - 1][0]) / 2}
y={(pts[0][1] + pts[pts.length - 1][1]) / 2 + offsetY - 6}
fill="#9C9184"
fontSize={9}
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
}

View File

@@ -0,0 +1,93 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import { colorForType, iconForType } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
const TOP_BAR_HEIGHT = 6;
const CORNER_RADIUS = 4;
interface DiagramNodeProps {
node: DiagramNodeType;
isHovered: boolean;
isSelected: boolean;
config?: NodeConfig;
onClick: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function DiagramNode({
node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 120;
const h = node.height ?? 40;
const color = colorForType(node.type);
const icon = iconForType(node.type);
// Extract label parts: type name and detail
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const detail = node.label || '';
return (
<g
data-node-id={node.id}
transform={`translate(${x}, ${y})`}
onClick={(e) => { e.stopPropagation(); onClick(); }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
>
{/* Selection ring */}
{isSelected && (
<rect
x={-2}
y={-2}
width={w + 4}
height={h + 4}
rx={CORNER_RADIUS + 2}
fill="none"
stroke="#C6820E"
strokeWidth={2.5}
/>
)}
{/* Card background */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill={isHovered ? '#F5F0EA' : 'white'}
stroke={isHovered || isSelected ? color : '#E4DFD8'}
strokeWidth={isHovered || isSelected ? 1.5 : 1}
/>
{/* Colored top bar */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={color} />
{/* Icon */}
<text x={14} y={h / 2 + 6} fill={color} fontSize={14}>
{icon}
</text>
{/* Type name */}
<text x={32} y={h / 2 + 1} fill="#1A1612" fontSize={11} fontWeight={600}>
{typeName}
</text>
{/* Detail label (truncated) */}
{detail && detail !== typeName && (
<text x={32} y={h / 2 + 14} fill="#5C5347" fontSize={10}>
{detail.length > 22 ? detail.slice(0, 20) + '...' : detail}
</text>
)}
{/* Config badges */}
{config && <ConfigBadge nodeWidth={w} config={config} />}
</g>
);
}

View File

@@ -0,0 +1,93 @@
import type { DiagramSection } from './types';
import type { NodeConfig } from './types';
import { DiagramEdge } from './DiagramEdge';
import { DiagramNode } from './DiagramNode';
import { CompoundNode } from './CompoundNode';
import { isCompoundType } from './node-colors';
interface ErrorSectionProps {
section: DiagramSection;
totalWidth: number;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
onNodeClick: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
onNodeClick, onNodeEnter, onNodeLeave,
}: ErrorSectionProps) {
return (
<g transform={`translate(0, ${section.offsetY})`}>
{/* Divider line */}
<line
x1={0}
y1={0}
x2={totalWidth}
y2={0}
stroke="#C0392B"
strokeWidth={1}
strokeDasharray="6 3"
opacity={0.5}
/>
{/* Section label */}
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
{section.label}
</text>
{/* Subtle red tint background */}
<rect
x={0}
y={4}
width={totalWidth}
height={300}
fill="#C0392B"
opacity={0.03}
rx={4}
/>
{/* Edges */}
<g className="edges">
{section.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</g>
{/* Nodes */}
<g className="nodes">
{section.nodes.map(node => {
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
return (
<CompoundNode
key={node.id}
node={node}
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={onNodeClick}
onNodeEnter={onNodeEnter}
onNodeLeave={onNodeLeave}
/>
);
}
return (
<DiagramNode
key={node.id}
node={node}
isHovered={hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
onClick={() => node.id && onNodeClick(node.id)}
onMouseEnter={() => node.id && onNodeEnter(node.id)}
onMouseLeave={onNodeLeave}
/>
);
})}
</g>
</g>
);
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useRef, useState } from 'react';
import type { NodeAction } from './types';
const TOOLBAR_HEIGHT = 28;
const TOOLBAR_WIDTH = 140;
const HIDE_DELAY = 150;
interface NodeToolbarProps {
nodeId: string;
nodeX: number;
nodeY: number;
nodeWidth: number;
onAction: (nodeId: string, action: NodeAction) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
const ACTIONS: { label: string; icon: string; action: NodeAction; title: string }[] = [
{ label: 'Inspect', icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect node' },
{ label: 'Trace', icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
{ label: 'Tap', icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
{ label: 'More', icon: '\u22EF', action: 'copy-id', title: 'Copy processor ID' },
];
export function NodeToolbar({
nodeId, nodeX, nodeY, nodeWidth, onAction, onMouseEnter, onMouseLeave,
}: NodeToolbarProps) {
const x = nodeX + (nodeWidth - TOOLBAR_WIDTH) / 2;
const y = nodeY - TOOLBAR_HEIGHT - 6;
return (
<foreignObject
x={x}
y={y}
width={TOOLBAR_WIDTH}
height={TOOLBAR_HEIGHT}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
height: TOOLBAR_HEIGHT,
background: 'rgba(26, 22, 18, 0.92)',
borderRadius: '6px',
padding: '0 8px',
}}
>
{ACTIONS.map(a => (
<button
key={a.action}
title={a.title}
onClick={(e) => {
e.stopPropagation();
onAction(nodeId, a.action);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: '50%',
border: 'none',
background: 'rgba(255, 255, 255, 0.15)',
color: 'white',
fontSize: '11px',
cursor: 'pointer',
padding: 0,
}}
>
{a.icon}
</button>
))}
</div>
</foreignObject>
);
}
/** Hook to manage toolbar visibility with hide delay */
export function useToolbarHover() {
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onNodeEnter = useCallback((nodeId: string) => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setHoveredNodeId(nodeId);
}, []);
const onNodeLeave = useCallback(() => {
hideTimer.current = setTimeout(() => {
setHoveredNodeId(null);
hideTimer.current = null;
}, HIDE_DELAY);
}, []);
const onToolbarEnter = useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
const onToolbarLeave = useCallback(() => {
hideTimer.current = setTimeout(() => {
setHoveredNodeId(null);
hideTimer.current = null;
}, HIDE_DELAY);
}, []);
return { hoveredNodeId, onNodeEnter, onNodeLeave, onToolbarEnter, onToolbarLeave };
}

View File

@@ -0,0 +1,79 @@
.container {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
overflow: hidden;
background: var(--bg-surface, #FFFFFF);
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
}
.svg {
width: 100%;
height: 100%;
display: block;
outline: none;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--text-muted, #9C9184);
font-size: 14px;
}
.error {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--error, #C0392B);
font-size: 14px;
}
.zoomControls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-surface, #FFFFFF);
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-sm, 5px);
padding: 4px;
box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08));
}
.zoomBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-primary, #1A1612);
font-size: 16px;
cursor: pointer;
border-radius: var(--radius-sm, 5px);
}
.zoomBtn:hover {
background: var(--bg-hover, #F5F0EA);
}
.zoomLevel {
font-size: 11px;
color: var(--text-muted, #9C9184);
min-width: 36px;
text-align: center;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,221 @@
import { useCallback, useEffect } from 'react';
import type { ProcessDiagramProps } from './types';
import { useDiagramData } from './useDiagramData';
import { useZoomPan } from './useZoomPan';
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import { CompoundNode } from './CompoundNode';
import { ErrorSection } from './ErrorSection';
import { ZoomControls } from './ZoomControls';
import { isCompoundType } from './node-colors';
import styles from './ProcessDiagram.module.css';
const PADDING = 40;
export function ProcessDiagram({
application,
routeId,
direction = 'LR',
selectedNodeId,
onNodeSelect,
onNodeAction,
nodeConfigs,
className,
}: ProcessDiagramProps) {
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
application, routeId, direction,
);
const zoom = useZoomPan();
const toolbar = useToolbarHover();
const contentWidth = totalWidth + PADDING * 2;
const contentHeight = totalHeight + PADDING * 2;
// Fit to view on first data load
useEffect(() => {
if (totalWidth > 0 && totalHeight > 0) {
zoom.fitToView(contentWidth, contentHeight);
}
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNodeClick = useCallback(
(nodeId: string) => { onNodeSelect?.(nodeId); },
[onNodeSelect],
);
const handleNodeAction = useCallback(
(nodeId: string, action: import('./types').NodeAction) => {
if (action === 'inspect') onNodeSelect?.(nodeId);
onNodeAction?.(nodeId, action);
},
[onNodeSelect, onNodeAction],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onNodeSelect?.('');
return;
}
zoom.onKeyDown(e, contentWidth, contentHeight);
},
[onNodeSelect, zoom, contentWidth, contentHeight],
);
if (isLoading) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>Loading diagram...</div>
</div>
);
}
if (error) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.error}>Failed to load diagram</div>
</div>
);
}
if (sections.length === 0) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>No diagram data available</div>
</div>
);
}
const mainSection = sections[0];
const errorSections = sections.slice(1);
return (
<div
ref={zoom.containerRef}
className={`${styles.container} ${className ?? ''}`}
>
<svg
className={styles.svg}
viewBox={zoom.viewBox(contentWidth, contentHeight)}
onWheel={zoom.onWheel}
onPointerDown={zoom.onPointerDown}
onPointerMove={zoom.onPointerMove}
onPointerUp={zoom.onPointerUp}
onKeyDown={handleKeyDown}
tabIndex={0}
onClick={() => onNodeSelect?.('')}
>
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="7"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
</marker>
</defs>
<g transform={`translate(${PADDING}, ${PADDING})`}>
{/* Main section edges */}
<g className="edges">
{mainSection.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</g>
{/* Main section nodes */}
<g className="nodes">
{mainSection.nodes.map(node => {
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
return (
<CompoundNode
key={node.id}
node={node}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
);
}
return (
<DiagramNode
key={node.id}
node={node}
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
onClick={() => node.id && handleNodeClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
onMouseLeave={toolbar.onNodeLeave}
/>
);
})}
</g>
{/* Toolbar for hovered node */}
{toolbar.hoveredNodeId && onNodeAction && (() => {
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
if (!hNode) return null;
return (
<NodeToolbar
nodeId={toolbar.hoveredNodeId!}
nodeX={hNode.x ?? 0}
nodeY={hNode.y ?? 0}
nodeWidth={hNode.width ?? 120}
onAction={handleNodeAction}
onMouseEnter={toolbar.onToolbarEnter}
onMouseLeave={toolbar.onToolbarLeave}
/>
);
})()}
{/* Error handler sections */}
{errorSections.map((section, i) => (
<ErrorSection
key={`error-${i}`}
section={section}
totalWidth={totalWidth}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
))}
</g>
</svg>
<ZoomControls
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onFitToView={() => zoom.fitToView(contentWidth, contentHeight)}
scale={zoom.state.scale}
/>
</div>
);
}
function findNodeById(
sections: import('./types').DiagramSection[],
nodeId: string,
): import('../../api/queries/diagrams').DiagramNode | undefined {
for (const section of sections) {
for (const node of section.nodes) {
if (node.id === nodeId) return node;
if (node.children) {
const child = node.children.find(c => c.id === nodeId);
if (child) return child;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,19 @@
import styles from './ProcessDiagram.module.css';
interface ZoomControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitToView: () => void;
scale: number;
}
export function ZoomControls({ onZoomIn, onZoomOut, onFitToView, scale }: ZoomControlsProps) {
return (
<div className={styles.zoomControls}>
<button className={styles.zoomBtn} onClick={onZoomIn} title="Zoom in (+)">+</button>
<span className={styles.zoomLevel}>{Math.round(scale * 100)}%</span>
<button className={styles.zoomBtn} onClick={onZoomOut} title="Zoom out (-)"></button>
<button className={styles.zoomBtn} onClick={onFitToView} title="Fit to view (0)"></button>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ProcessDiagram } from './ProcessDiagram';
export type { ProcessDiagramProps, NodeAction, NodeConfig } from './types';

View File

@@ -0,0 +1,93 @@
/** Maps backend NodeType strings to CSS color values using design system tokens. */
const ENDPOINT_COLOR = '#1A7F8E'; // --running
const PROCESSOR_COLOR = '#C6820E'; // --amber
const TARGET_COLOR = '#3D7C47'; // --success
const EIP_COLOR = '#7C3AED'; // --purple
const ERROR_COLOR = '#C0392B'; // --error
const CROSS_ROUTE_COLOR = '#06B6D4';
const DEFAULT_COLOR = '#9C9184'; // --text-muted
const TYPE_MAP: Record<string, string> = {
ENDPOINT: ENDPOINT_COLOR,
PROCESSOR: PROCESSOR_COLOR,
BEAN: PROCESSOR_COLOR,
LOG: PROCESSOR_COLOR,
SET_HEADER: PROCESSOR_COLOR,
SET_BODY: PROCESSOR_COLOR,
TRANSFORM: PROCESSOR_COLOR,
MARSHAL: PROCESSOR_COLOR,
UNMARSHAL: PROCESSOR_COLOR,
TO: TARGET_COLOR,
TO_DYNAMIC: TARGET_COLOR,
DIRECT: TARGET_COLOR,
SEDA: TARGET_COLOR,
EIP_CHOICE: EIP_COLOR,
EIP_WHEN: EIP_COLOR,
EIP_OTHERWISE: EIP_COLOR,
EIP_SPLIT: EIP_COLOR,
EIP_MULTICAST: EIP_COLOR,
EIP_LOOP: EIP_COLOR,
EIP_AGGREGATE: EIP_COLOR,
EIP_FILTER: EIP_COLOR,
EIP_RECIPIENT_LIST: EIP_COLOR,
EIP_ROUTING_SLIP: EIP_COLOR,
EIP_DYNAMIC_ROUTER: EIP_COLOR,
EIP_LOAD_BALANCE: EIP_COLOR,
EIP_THROTTLE: EIP_COLOR,
EIP_DELAY: EIP_COLOR,
EIP_IDEMPOTENT_CONSUMER: EIP_COLOR,
EIP_CIRCUIT_BREAKER: EIP_COLOR,
EIP_PIPELINE: EIP_COLOR,
ERROR_HANDLER: ERROR_COLOR,
ON_EXCEPTION: ERROR_COLOR,
TRY_CATCH: ERROR_COLOR,
DO_TRY: ERROR_COLOR,
DO_CATCH: ERROR_COLOR,
DO_FINALLY: ERROR_COLOR,
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
EIP_ENRICH: CROSS_ROUTE_COLOR,
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
};
const COMPOUND_TYPES = new Set([
'EIP_CHOICE', 'EIP_SPLIT', 'TRY_CATCH', 'DO_TRY',
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
'ON_EXCEPTION', 'ERROR_HANDLER',
]);
const ERROR_COMPOUND_TYPES = new Set([
'ON_EXCEPTION', 'ERROR_HANDLER',
]);
export function colorForType(type: string | undefined): string {
if (!type) return DEFAULT_COLOR;
return TYPE_MAP[type] ?? DEFAULT_COLOR;
}
export function isCompoundType(type: string | undefined): boolean {
return !!type && COMPOUND_TYPES.has(type);
}
export function isErrorCompoundType(type: string | undefined): boolean {
return !!type && ERROR_COMPOUND_TYPES.has(type);
}
/** Icon character for a node type */
export function iconForType(type: string | undefined): string {
if (!type) return '\u2699'; // gear
const t = type.toUpperCase();
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_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
if (t === 'EIP_WIRE_TAP' || t === 'EIP_ENRICH' || t === 'EIP_POLL_ENRICH') return '\u2197'; // arrow
return '\u2699'; // gear
}

View File

@@ -0,0 +1,27 @@
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
export type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id';
export interface NodeConfig {
traceEnabled?: boolean;
tapExpression?: string;
}
export interface DiagramSection {
label: string;
nodes: DiagramNode[];
edges: DiagramEdge[];
offsetY: number;
variant?: 'error';
}
export interface ProcessDiagramProps {
application: string;
routeId: string;
direction?: 'LR' | 'TB';
selectedNodeId?: string;
onNodeSelect?: (nodeId: string) => void;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>;
className?: string;
}

View File

@@ -0,0 +1,115 @@
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';
const SECTION_GAP = 40;
export function useDiagramData(
application: string,
routeId: string,
direction: 'LR' | 'TB' = 'LR',
) {
const { data: layout, isLoading, error } = useDiagramByRoute(application, routeId, direction);
const result = useMemo(() => {
if (!layout?.nodes) {
return { sections: [], totalWidth: 0, totalHeight: 0 };
}
const allEdges = layout.edges ?? [];
// Separate main nodes from error handler compound sections
const mainNodes: DiagramNode[] = [];
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
for (const node of layout.nodes) {
if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
errorSections.push({
label: node.label || 'Error Handler',
nodes: node.children,
});
} else {
mainNodes.push(node);
}
}
// Collect node IDs for edge filtering
const mainNodeIds = new Set<string>();
collectNodeIds(mainNodes, mainNodeIds);
const mainEdges = allEdges.filter(
e => mainNodeIds.has(e.sourceId) && mainNodeIds.has(e.targetId),
);
// Compute main section bounding box
const mainBounds = computeBounds(mainNodes);
const sections: DiagramSection[] = [
{
label: 'Main Route',
nodes: mainNodes,
edges: mainEdges,
offsetY: 0,
},
];
let currentY = mainBounds.maxY + SECTION_GAP;
for (const es of errorSections) {
const errorNodeIds = new Set<string>();
collectNodeIds(es.nodes, errorNodeIds);
const errorEdges = allEdges.filter(
e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId),
);
sections.push({
label: es.label,
nodes: es.nodes,
edges: errorEdges,
offsetY: currentY,
variant: 'error',
});
const errorBounds = computeBounds(es.nodes);
currentY += (errorBounds.maxY - errorBounds.minY) + SECTION_GAP;
}
const totalWidth = layout.width ?? mainBounds.maxX;
const totalHeight = currentY;
return { sections, totalWidth, totalHeight };
}, [layout]);
return { ...result, isLoading, error };
}
function collectNodeIds(nodes: DiagramNode[], set: Set<string>) {
for (const n of nodes) {
if (n.id) set.add(n.id);
if (n.children) collectNodeIds(n.children, set);
}
}
function computeBounds(nodes: DiagramNode[]): {
minX: number; minY: number; maxX: number; maxY: number;
} {
let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
for (const n of nodes) {
const x = n.x ?? 0;
const y = n.y ?? 0;
const w = n.width ?? 80;
const h = n.height ?? 40;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
if (n.children) {
const childBounds = computeBounds(n.children);
if (childBounds.maxX > maxX) maxX = childBounds.maxX;
if (childBounds.maxY > maxY) maxY = childBounds.maxY;
}
}
return { minX: minX === Infinity ? 0 : minX, minY: minY === Infinity ? 0 : minY, maxX, maxY };
}

View File

@@ -0,0 +1,171 @@
import { useCallback, useRef, useState } from 'react';
interface ZoomPanState {
scale: number;
translateX: number;
translateY: number;
}
const MIN_SCALE = 0.25;
const MAX_SCALE = 4.0;
const ZOOM_STEP = 0.15;
const FIT_PADDING = 40;
export function useZoomPan() {
const [state, setState] = useState<ZoomPanState>({
scale: 1,
translateX: 0,
translateY: 0,
});
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
const viewBox = useCallback(
(contentWidth: number, contentHeight: number) => {
const vw = contentWidth / state.scale;
const vh = contentHeight / state.scale;
const vx = -state.translateX / state.scale;
const vy = -state.translateY / state.scale;
return `${vx} ${vy} ${vw} ${vh}`;
},
[state],
);
const onWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
const factor = 1 + direction * ZOOM_STEP;
setState(prev => {
const newScale = clampScale(prev.scale * factor);
// Zoom centered on cursor
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const scaleRatio = newScale / prev.scale;
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
return { scale: newScale, translateX: newTx, translateY: newTy };
});
},
[],
);
const onPointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
// Only pan on background click (not on nodes)
if ((e.target as Element).closest('[data-node-id]')) return;
isPanning.current = true;
panStart.current = { x: e.clientX - state.translateX, y: e.clientY - state.translateY };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
},
[state.translateX, state.translateY],
);
const onPointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!isPanning.current) return;
setState(prev => ({
...prev,
translateX: e.clientX - panStart.current.x,
translateY: e.clientY - panStart.current.y,
}));
},
[],
);
const onPointerUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
isPanning.current = false;
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
},
[],
);
const zoomIn = useCallback(() => {
setState(prev => {
const newScale = clampScale(prev.scale * (1 + ZOOM_STEP));
const container = containerRef.current;
if (!container) return { ...prev, scale: newScale };
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const ratio = newScale / prev.scale;
return {
scale: newScale,
translateX: cx - ratio * (cx - prev.translateX),
translateY: cy - ratio * (cy - prev.translateY),
};
});
}, []);
const zoomOut = useCallback(() => {
setState(prev => {
const newScale = clampScale(prev.scale * (1 - ZOOM_STEP));
const container = containerRef.current;
if (!container) return { ...prev, scale: newScale };
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const ratio = newScale / prev.scale;
return {
scale: newScale,
translateX: cx - ratio * (cx - prev.translateX),
translateY: cy - ratio * (cy - prev.translateY),
};
});
}, []);
const fitToView = useCallback(
(contentWidth: number, contentHeight: number) => {
const container = containerRef.current;
if (!container) return;
const cw = container.clientWidth - FIT_PADDING * 2;
const ch = container.clientHeight - FIT_PADDING * 2;
const scaleX = cw / contentWidth;
const scaleY = ch / contentHeight;
const newScale = clampScale(Math.min(scaleX, scaleY));
const tx = (container.clientWidth - contentWidth * newScale) / 2;
const ty = (container.clientHeight - contentHeight * newScale) / 2;
setState({ scale: newScale, translateX: tx, translateY: ty });
},
[],
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent, contentWidth: number, contentHeight: number) => {
switch (e.key) {
case '+':
case '=':
e.preventDefault();
zoomIn();
break;
case '-':
e.preventDefault();
zoomOut();
break;
case '0':
e.preventDefault();
fitToView(contentWidth, contentHeight);
break;
}
},
[zoomIn, zoomOut, fitToView],
);
return {
state,
containerRef,
viewBox,
onWheel,
onPointerDown,
onPointerMove,
onPointerUp,
zoomIn,
zoomOut,
fitToView,
onKeyDown,
};
}

View File

@@ -0,0 +1,131 @@
.page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary, #1A1612);
}
.controls {
display: flex;
gap: 8px;
}
.controls select {
padding: 6px 10px;
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-sm, 5px);
background: var(--bg-surface, #FFFFFF);
color: var(--text-primary, #1A1612);
font-size: 13px;
}
.content {
display: flex;
gap: 12px;
flex: 1;
min-height: 0;
}
.diagramPane {
flex: 1;
min-width: 0;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
border: 1px dashed var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
color: var(--text-muted, #9C9184);
font-size: 14px;
}
.sidePanel {
width: 280px;
flex-shrink: 0;
padding: 12px;
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
background: var(--bg-surface, #FFFFFF);
overflow-y: auto;
}
.sidePanel h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #5C5347);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nodeInfo {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
}
.fieldLabel {
font-size: 11px;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
}
.field code, .field pre {
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--text-primary, #1A1612);
background: var(--bg-inset, #F0EDE8);
padding: 4px 6px;
border-radius: 3px;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.hint {
color: var(--text-muted, #9C9184);
font-size: 12px;
font-style: italic;
margin: 0;
}
.log {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 200px;
overflow-y: auto;
}
.logEntry {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary, #5C5347);
padding: 2px 0;
border-bottom: 1px solid var(--border-subtle, #EDE9E3);
}

View File

@@ -0,0 +1,127 @@
import { useMemo, useState } from 'react';
import { ProcessDiagram } from '../../components/ProcessDiagram';
import type { NodeConfig, NodeAction } from '../../components/ProcessDiagram';
import { useRouteCatalog } from '../../api/queries/catalog';
import styles from './DevDiagram.module.css';
export default function DevDiagram() {
const { data: catalog } = useRouteCatalog();
const [selectedApp, setSelectedApp] = useState('');
const [selectedRoute, setSelectedRoute] = useState('');
const [selectedNodeId, setSelectedNodeId] = useState('');
const [direction, setDirection] = useState<'LR' | 'TB'>('LR');
const [actionLog, setActionLog] = useState<string[]>([]);
// Extract unique applications and routes from catalog
const { apps, routes } = useMemo(() => {
if (!catalog) return { apps: [] as string[], routes: [] as string[] };
const appSet = new Set<string>();
const routeList: { app: string; routeId: string }[] = [];
for (const entry of catalog as Array<{ application?: string; routeId?: string }>) {
if (entry.application) appSet.add(entry.application);
if (entry.application && entry.routeId) {
routeList.push({ app: entry.application, routeId: entry.routeId });
}
}
const appArr = Array.from(appSet).sort();
const filtered = selectedApp
? routeList.filter(r => r.app === selectedApp).map(r => r.routeId)
: [];
return { apps: appArr, routes: filtered };
}, [catalog, selectedApp]);
// Mock node configs for testing
const nodeConfigs = useMemo(() => {
const map = new Map<string, NodeConfig>();
// We'll add some mock configs if we have a route loaded
map.set('log1', { traceEnabled: true });
map.set('to1', { tapExpression: '${header.orderId}' });
return map;
}, []);
const handleNodeAction = (nodeId: string, action: NodeAction) => {
const msg = `[${new Date().toLocaleTimeString()}] ${action}: ${nodeId}`;
setActionLog(prev => [msg, ...prev.slice(0, 19)]);
};
return (
<div className={styles.page}>
<div className={styles.header}>
<h2>Process Diagram (Dev)</h2>
<div className={styles.controls}>
<select
value={selectedApp}
onChange={e => { setSelectedApp(e.target.value); setSelectedRoute(''); }}
>
<option value="">Select application...</option>
{apps.map(app => <option key={app} value={app}>{app}</option>)}
</select>
<select
value={selectedRoute}
onChange={e => setSelectedRoute(e.target.value)}
disabled={!selectedApp}
>
<option value="">Select route...</option>
{routes.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<select value={direction} onChange={e => setDirection(e.target.value as 'LR' | 'TB')}>
<option value="LR">Left Right</option>
<option value="TB">Top Bottom</option>
</select>
</div>
</div>
<div className={styles.content}>
<div className={styles.diagramPane}>
{selectedApp && selectedRoute ? (
<ProcessDiagram
application={selectedApp}
routeId={selectedRoute}
direction={direction}
selectedNodeId={selectedNodeId}
onNodeSelect={setSelectedNodeId}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
) : (
<div className={styles.placeholder}>
Select an application and route to view the diagram
</div>
)}
</div>
<div className={styles.sidePanel}>
<h3>Selected Node</h3>
{selectedNodeId ? (
<div className={styles.nodeInfo}>
<div className={styles.field}>
<span className={styles.fieldLabel}>Node ID</span>
<code>{selectedNodeId}</code>
</div>
{nodeConfigs.has(selectedNodeId) && (
<div className={styles.field}>
<span className={styles.fieldLabel}>Config</span>
<pre>{JSON.stringify(nodeConfigs.get(selectedNodeId), null, 2)}</pre>
</div>
)}
</div>
) : (
<p className={styles.hint}>Click a node to inspect it</p>
)}
<h3>Action Log</h3>
<div className={styles.log}>
{actionLog.length === 0 ? (
<p className={styles.hint}>Hover a node and use the toolbar</p>
) : (
actionLog.map((msg, i) => (
<div key={i} className={styles.logEntry}>{msg}</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -20,6 +20,7 @@ const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
const DevDiagram = lazy(() => import('./pages/DevDiagram/DevDiagram'));
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
return (
@@ -63,6 +64,7 @@ export const router = createBrowserRouter([
],
},
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
{ path: 'dev/diagram', element: <SuspenseWrapper><DevDiagram /></SuspenseWrapper> },
],
},
],