feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
7
ui/src/api/schema.d.ts
vendored
7
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
82
ui/src/components/ProcessDiagram/CompoundNode.tsx
Normal file
82
ui/src/components/ProcessDiagram/CompoundNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
ui/src/components/ProcessDiagram/ConfigBadge.tsx
Normal file
48
ui/src/components/ProcessDiagram/ConfigBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
ui/src/components/ProcessDiagram/DiagramEdge.tsx
Normal file
49
ui/src/components/ProcessDiagram/DiagramEdge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/ProcessDiagram/DiagramNode.tsx
Normal file
93
ui/src/components/ProcessDiagram/DiagramNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/ProcessDiagram/ErrorSection.tsx
Normal file
93
ui/src/components/ProcessDiagram/ErrorSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal file
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal 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 };
|
||||
}
|
||||
79
ui/src/components/ProcessDiagram/ProcessDiagram.module.css
Normal file
79
ui/src/components/ProcessDiagram/ProcessDiagram.module.css
Normal 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;
|
||||
}
|
||||
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal file
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal 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;
|
||||
}
|
||||
19
ui/src/components/ProcessDiagram/ZoomControls.tsx
Normal file
19
ui/src/components/ProcessDiagram/ZoomControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
ui/src/components/ProcessDiagram/index.ts
Normal file
2
ui/src/components/ProcessDiagram/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProcessDiagram } from './ProcessDiagram';
|
||||
export type { ProcessDiagramProps, NodeAction, NodeConfig } from './types';
|
||||
93
ui/src/components/ProcessDiagram/node-colors.ts
Normal file
93
ui/src/components/ProcessDiagram/node-colors.ts
Normal 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
|
||||
}
|
||||
27
ui/src/components/ProcessDiagram/types.ts
Normal file
27
ui/src/components/ProcessDiagram/types.ts
Normal 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;
|
||||
}
|
||||
115
ui/src/components/ProcessDiagram/useDiagramData.ts
Normal file
115
ui/src/components/ProcessDiagram/useDiagramData.ts
Normal 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 };
|
||||
}
|
||||
171
ui/src/components/ProcessDiagram/useZoomPan.ts
Normal file
171
ui/src/components/ProcessDiagram/useZoomPan.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal file
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal 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);
|
||||
}
|
||||
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal file
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user