Add route diagram page with execution overlay and group-aware APIs
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 1m3s
CI / deploy (push) Successful in 31s

Backend: Add group filtering to agent list, search, stats, and timeseries
endpoints. Add diagram lookup by group+routeId. Resolve application group
to agent IDs server-side for ClickHouse IN-clause queries.

Frontend: New route detail page at /apps/{group}/routes/{routeId} with
three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering
with panzoom, execution overlay (glow effects, duration/sequence badges,
flow particles, minimap), and processor detail panel. uPlot charts for
performance tab replacing old SVG sparklines. Ctrl+Click from
ExecutionExplorer navigates to route diagram with overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 21:35:42 +01:00
parent b64edaa16f
commit 7778793e7b
41 changed files with 2770 additions and 26 deletions

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import type { ExecutionSummary } from '../../api/types';
import { useAgents } from '../../api/queries/agents';
import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge';
@@ -55,11 +57,25 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
const sortColumn = useExecutionSearch((s) => s.sortField);
const sortDir = useExecutionSearch((s) => s.sortDir);
const setSort = useExecutionSearch((s) => s.setSort);
const navigate = useNavigate();
const { data: agents } = useAgents();
function handleSort(col: SortColumn) {
setSort(col);
}
/** Navigate to route diagram page with execution overlay */
function handleDiagramNav(exec: ExecutionSummary, e: React.MouseEvent) {
// Only navigate on double-click or if holding Ctrl/Cmd
if (!e.ctrlKey && !e.metaKey) return;
// Resolve agentId → group from agent registry
const agent = agents?.find((a) => a.id === exec.agentId);
const group = agent?.group ?? 'default';
navigate(`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`);
}
if (loading && results.length === 0) {
return (
<div className={styles.tableWrap}>
@@ -99,6 +115,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
exec={exec}
isExpanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
onDiagramNav={(e) => handleDiagramNav(exec, e)}
/>
);
})}
@@ -112,16 +129,25 @@ function ResultRow({
exec,
isExpanded,
onToggle,
onDiagramNav,
}: {
exec: ExecutionSummary;
isExpanded: boolean;
onToggle: () => void;
onDiagramNav: (e: React.MouseEvent) => void;
}) {
return (
<>
<tr
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
onClick={onToggle}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
onDiagramNav(e);
} else {
onToggle();
}
}}
title="Click to expand, Ctrl+Click to open diagram"
>
<td className={`${styles.td} ${styles.tdExpand}`}>&rsaquo;</td>
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>

View File

@@ -0,0 +1,27 @@
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
import type { OverlayState } from '../../hooks/useExecutionOverlay';
import { DiagramCanvas } from './diagram/DiagramCanvas';
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
import styles from './diagram/diagram.module.css';
interface DiagramTabProps {
layout: DiagramLayout;
overlay: OverlayState;
execution: ExecutionDetail | null | undefined;
}
export function DiagramTab({ layout, overlay, execution }: DiagramTabProps) {
return (
<div className={styles.splitLayout}>
<div className={styles.diagramSide}>
<DiagramCanvas layout={layout} overlay={overlay} />
</div>
{overlay.isActive && execution && (
<ProcessorDetailPanel
execution={execution}
selectedNodeId={overlay.selectedNodeId}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ThroughputChart } from '../../components/charts/ThroughputChart';
import { DurationHistogram } from '../../components/charts/DurationHistogram';
import { LatencyHeatmap } from '../../components/charts/LatencyHeatmap';
import styles from './RoutePage.module.css';
interface PerformanceTabProps {
group: string;
routeId: string;
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const timeTo = new Date().toISOString();
// Use scoped stats/timeseries via group+routeId query params
const { data: stats } = useExecutionStats(timeFrom, timeTo);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
const buckets = timeseries?.buckets ?? [];
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
const sparkP99 = buckets.map((b) => b.p99DurationMs ?? 0);
const sparkFailed = buckets.map((b) => b.failedCount ?? 0);
const sparkAvg = buckets.map((b) => b.avgDurationMs ?? 0);
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const failChange = stats ? pctChange(failureRate, prevFailureRate) : null;
return (
<div className={styles.performanceTab}>
{/* Stats cards row */}
<div className={styles.perfStatsRow}>
<StatCard
label="Executions Today"
value={stats ? stats.totalToday.toLocaleString() : '--'}
accent="amber"
change={`for ${group}/${routeId}`}
sparkData={sparkTotal}
/>
<StatCard
label="Avg Duration"
value={stats ? `${stats.avgDurationMs}ms` : '--'}
accent="cyan"
sparkData={sparkAvg}
/>
<StatCard
label="P99 Latency"
value={stats ? `${stats.p99LatencyMs}ms` : '--'}
accent="green"
change={p99Change?.text}
changeDirection={p99Change?.direction}
sparkData={sparkP99}
/>
<StatCard
label="Failure Rate"
value={stats ? `${failureRate.toFixed(1)}%` : '--'}
accent="rose"
change={failChange?.text}
changeDirection={failChange?.direction}
sparkData={sparkFailed}
/>
</div>
{/* Charts */}
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Throughput</h4>
<ThroughputChart buckets={buckets} />
</div>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Duration Distribution</h4>
<DurationHistogram buckets={buckets} />
</div>
<div className={`${styles.chartCard} ${styles.chartFull}`}>
<h4 className={styles.chartTitle}>Latency Over Time</h4>
<LatencyHeatmap buckets={buckets} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { DiagramLayout } from '../../api/types';
import styles from './RoutePage.module.css';
interface RouteHeaderProps {
group: string;
routeId: string;
layout: DiagramLayout | undefined;
}
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
const nodeCount = layout?.nodes?.length ?? 0;
return (
<div className={styles.routeHeader}>
<div className={styles.routeTitle}>
<span className={styles.routeId}>{routeId}</span>
<div className={styles.routeMeta}>
<span className={styles.routeMetaItem}>
<span className={styles.routeMetaDot} />
{group}
</span>
{nodeCount > 0 && (
<span className={styles.routeMetaItem}>{nodeCount} nodes</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
/* ─── Breadcrumb ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 12px;
}
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbLink:hover {
color: var(--amber);
}
.breadcrumbSep {
color: var(--text-muted);
opacity: 0.5;
}
.breadcrumbText {
color: var(--text-secondary);
}
.breadcrumbCurrent {
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 500;
}
/* ─── Route Header ─── */
.routeHeader {
position: relative;
margin-bottom: 20px;
padding: 20px 24px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.routeHeader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--cyan));
}
.routeTitle {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.routeId {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.routeMeta {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
}
.routeMetaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.routeMetaDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
/* ─── Toolbar & Tabs ─── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.tabBar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-subtle);
}
.tab {
padding: 8px 20px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
}
.tab:hover {
color: var(--text-secondary);
}
.tabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.toolbarRight {
display: flex;
align-items: center;
gap: 10px;
}
.overlayToggle {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.overlayToggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.overlayOn {
background: var(--green-glow);
border-color: rgba(16, 185, 129, 0.3);
color: var(--green);
}
.execBadge {
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 0.3px;
}
.execBadgeOk {
background: var(--green-glow);
color: var(--green);
}
.execBadgeFailed {
background: var(--rose-glow);
color: var(--rose);
}
/* ─── States ─── */
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
.emptyState {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.error {
color: var(--rose);
text-align: center;
padding: 60px 20px;
}
/* ─── Performance Tab ─── */
.performanceTab {
display: flex;
flex-direction: column;
gap: 20px;
}
.perfStatsRow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.chartFull {
grid-column: 1 / -1;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.perfStatsRow {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.perfStatsRow {
grid-template-columns: 1fr;
}
.chartGrid {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useParams, useSearchParams, NavLink } from 'react-router';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useExecutionDetail } from '../../api/queries/executions';
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
import { RouteHeader } from './RouteHeader';
import { DiagramTab } from './DiagramTab';
import { PerformanceTab } from './PerformanceTab';
import { ProcessorTree } from '../executions/ProcessorTree';
import styles from './RoutePage.module.css';
type Tab = 'diagram' | 'performance' | 'processors';
export function RoutePage() {
const { group, routeId } = useParams<{ group: string; routeId: string }>();
const [searchParams] = useSearchParams();
const execId = searchParams.get('exec');
const [activeTab, setActiveTab] = useState<Tab>('diagram');
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
const { data: execution } = useExecutionDetail(execId);
const overlay = useExecutionOverlay(
execution ?? null,
layout?.edges ?? [],
);
if (!group || !routeId) {
return <div className={styles.error}>Missing group or routeId parameters</div>;
}
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbText}>{group}</span>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{routeId}</span>
</nav>
{/* Route Header */}
<RouteHeader group={group} routeId={routeId} layout={layout} />
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.tabBar}>
<button
className={`${styles.tab} ${activeTab === 'diagram' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('diagram')}
>
Diagram
</button>
<button
className={`${styles.tab} ${activeTab === 'performance' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('performance')}
>
Performance
</button>
<button
className={`${styles.tab} ${activeTab === 'processors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('processors')}
>
Processor Tree
</button>
</div>
{activeTab === 'diagram' && (
<div className={styles.toolbarRight}>
<button
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
onClick={overlay.toggle}
title="Toggle execution overlay (E)"
>
{overlay.isActive ? 'Hide' : 'Show'} Execution
</button>
{execution && (
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
{execution.status} &middot; {execution.durationMs}ms
</span>
)}
</div>
)}
</div>
{/* Tab Content */}
{activeTab === 'diagram' && (
layoutLoading ? (
<div className={styles.loading}>Loading diagram...</div>
) : layout ? (
<DiagramTab layout={layout} overlay={overlay} execution={execution} />
) : (
<div className={styles.emptyState}>No diagram available for this route</div>
)
)}
{activeTab === 'performance' && (
<PerformanceTab group={group} routeId={routeId} />
)}
{activeTab === 'processors' && execId && (
<ProcessorTree executionId={execId} />
)}
{activeTab === 'processors' && !execId && (
<div className={styles.emptyState}>
Select an execution to view the processor tree
</div>
)}
</>
);
}

View File

@@ -0,0 +1,110 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import panzoom, { type PanZoom } from 'panzoom';
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { RouteDiagramSvg } from './RouteDiagramSvg';
import { DiagramMinimap } from './DiagramMinimap';
import styles from './diagram.module.css';
interface DiagramCanvasProps {
layout: DiagramLayout;
overlay: OverlayState;
}
export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgWrapRef = useRef<HTMLDivElement>(null);
const panzoomRef = useRef<PanZoom | null>(null);
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 });
useEffect(() => {
if (!svgWrapRef.current) return;
const instance = panzoom(svgWrapRef.current, {
smoothScroll: false,
zoomDoubleClickSpeed: 1,
minZoom: 0.1,
maxZoom: 5,
bounds: true,
boundsPadding: 0.2,
});
panzoomRef.current = instance;
const updateViewBox = () => {
if (!containerRef.current) return;
const transform = instance.getTransform();
const rect = containerRef.current.getBoundingClientRect();
setViewBox({
x: -transform.x / transform.scale,
y: -transform.y / transform.scale,
w: rect.width / transform.scale,
h: rect.height / transform.scale,
});
};
instance.on('transform', updateViewBox);
updateViewBox();
return () => {
instance.dispose();
panzoomRef.current = null;
};
}, [layout]);
const handleFit = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const padding = 80;
const w = (layout.width ?? 600) + padding;
const h = (layout.height ?? 400) + padding;
const scale = Math.min(rect.width / w, rect.height / h, 1);
const cx = (rect.width - w * scale) / 2;
const cy = (rect.height - h * scale) / 2;
panzoomRef.current.moveTo(cx, cy);
panzoomRef.current.zoomAbs(0, 0, scale);
}, [layout]);
const handleZoomIn = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 1.3);
}, []);
const handleZoomOut = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 0.7);
}, []);
// Fit on initial load
useEffect(() => {
const t = setTimeout(handleFit, 100);
return () => clearTimeout(t);
}, [handleFit]);
return (
<div className={styles.canvasContainer}>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<button className={styles.zoomBtn} onClick={handleFit} title="Fit to view">Fit</button>
<button className={styles.zoomBtn} onClick={handleZoomIn} title="Zoom in">+</button>
<button className={styles.zoomBtn} onClick={handleZoomOut} title="Zoom out">&minus;</button>
</div>
<div ref={containerRef} className={styles.canvas}>
<div ref={svgWrapRef}>
<RouteDiagramSvg layout={layout} overlay={overlay} />
</div>
</div>
<DiagramMinimap
nodes={layout.nodes ?? []}
edges={layout.edges ?? []}
diagramWidth={layout.width ?? 600}
diagramHeight={layout.height ?? 400}
viewBox={viewBox}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import type { PositionedNode, PositionedEdge } from '../../../api/types';
import { getNodeStyle } from './nodeStyles';
import styles from './diagram.module.css';
interface DiagramMinimapProps {
nodes: PositionedNode[];
edges: PositionedEdge[];
diagramWidth: number;
diagramHeight: number;
viewBox: { x: number; y: number; w: number; h: number };
}
const MINIMAP_W = 160;
const MINIMAP_H = 100;
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox }: DiagramMinimapProps) {
const scale = useMemo(() => {
if (diagramWidth === 0 || diagramHeight === 0) return 1;
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
}, [diagramWidth, diagramHeight]);
const vpRect = useMemo(() => ({
x: viewBox.x * scale,
y: viewBox.y * scale,
w: viewBox.w * scale,
h: viewBox.h * scale,
}), [viewBox, scale]);
return (
<div className={styles.minimap}>
<svg width={MINIMAP_W} height={MINIMAP_H} viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}>
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
{/* Edges */}
{edges.map((e) => {
const pts = e.points;
if (!pts || pts.length < 2) return null;
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0] * scale},${p[1] * scale}`).join(' ');
return <path key={`${e.sourceId}-${e.targetId}`} d={d} fill="none" stroke="#30363d" strokeWidth={0.5} />;
})}
{/* Nodes */}
{nodes.map((n) => {
const ns = getNodeStyle(n.type ?? '');
return (
<rect
key={n.id}
x={(n.x ?? 0) * scale}
y={(n.y ?? 0) * scale}
width={Math.max((n.width ?? 0) * scale, 2)}
height={Math.max((n.height ?? 0) * scale, 2)}
fill={ns.border}
opacity={0.6}
rx={1}
/>
);
})}
{/* Viewport rect */}
<rect
x={vpRect.x}
y={vpRect.y}
width={vpRect.w}
height={vpRect.h}
fill="rgba(240, 180, 41, 0.1)"
stroke="#f0b429"
strokeWidth={1}
rx={1}
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import type { PositionedNode } from '../../../api/types';
import { getNodeStyle, isCompoundType } from './nodeStyles';
import styles from './diagram.module.css';
interface DiagramNodeProps {
node: PositionedNode;
isExecuted: boolean;
isError: boolean;
isOverlayActive: boolean;
duration?: number;
sequence?: number;
isSelected: boolean;
onClick: (nodeId: string) => void;
}
export function DiagramNode({
node,
isExecuted,
isError,
isOverlayActive,
duration,
sequence,
isSelected,
onClick,
}: DiagramNodeProps) {
const style = getNodeStyle(node.type ?? 'PROCESSOR');
const isCompound = isCompoundType(node.type ?? '');
const dimmed = isOverlayActive && !isExecuted;
const glowFilter = isOverlayActive && isExecuted
? (isError ? 'url(#glow-red)' : 'url(#glow-green)')
: undefined;
const borderColor = isOverlayActive && isExecuted
? (isError ? '#f85149' : '#3fb950')
: style.border;
if (isCompound) {
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''}`}
opacity={dimmed ? 0.15 : 1}
role="img"
aria-label={`${node.type} container: ${node.label}`}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
fill={`${style.bg}80`}
stroke={borderColor}
strokeWidth={1}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={node.x! + 8}
y={node.y! + 14}
fill={style.border}
fontSize={10}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
opacity={0.7}
>
{node.label}
</text>
{/* Children rendered by parent layer */}
</g>
);
}
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
opacity={dimmed ? 0.15 : 1}
onClick={() => node.id && onClick(node.id)}
style={{ cursor: 'pointer' }}
role="img"
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
fill={style.bg}
stroke={isSelected ? '#f0b429' : borderColor}
strokeWidth={isSelected ? 2 : 1.5}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) / 2}
y={(node.y ?? 0) + (node.height ?? 0) / 2 + 4}
fill="#fff"
fontSize={12}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
textAnchor="middle"
>
{node.label}
</text>
{/* Duration badge */}
{isOverlayActive && isExecuted && duration != null && (
<g>
<rect
x={(node.x ?? 0) + (node.width ?? 0) - 28}
y={(node.y ?? 0) - 8}
width={36}
height={16}
rx={8}
fill={isError ? '#f85149' : '#3fb950'}
opacity={0.9}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) - 10}
y={(node.y ?? 0) + 4}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{duration}ms
</text>
</g>
)}
{/* Sequence badge */}
{isOverlayActive && isExecuted && sequence != null && (
<g>
<circle
cx={(node.x ?? 0) + 8}
cy={(node.y ?? 0) - 4}
r={8}
fill="#21262d"
stroke={isError ? '#f85149' : '#3fb950'}
strokeWidth={1.5}
/>
<text
x={(node.x ?? 0) + 8}
y={(node.y ?? 0) - 1}
fill="#fff"
fontSize={8}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{sequence}
</text>
</g>
)}
</g>
);
}

View File

@@ -0,0 +1,91 @@
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface EdgeLayerProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isOverlayActive: boolean;
}
function edgeKey(e: PositionedEdge): string {
return `${e.sourceId}->${e.targetId}`;
}
/** Convert waypoints to a smooth cubic bezier SVG path */
function pointsToPath(points: number[][]): string {
if (!points || points.length === 0) return '';
if (points.length === 1) return `M${points[0][0]},${points[0][1]}`;
let d = `M${points[0][0]},${points[0][1]}`;
if (points.length === 2) {
d += ` L${points[1][0]},${points[1][1]}`;
return d;
}
// Catmull-Rom → cubic bezier approximation for smooth curves
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(i - 1, 0)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(i + 2, points.length - 1)];
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
export function EdgeLayer({ edges, executedEdges, isOverlayActive }: EdgeLayerProps) {
return (
<g className={styles.edgeLayer}>
{edges.map((edge) => {
const key = edgeKey(edge);
const executed = executedEdges.has(key);
const dimmed = isOverlayActive && !executed;
const path = pointsToPath(edge.points ?? []);
return (
<g key={key} opacity={dimmed ? 0.1 : 1}>
{/* Glow under-layer for executed edges */}
{isOverlayActive && executed && (
<path
d={path}
fill="none"
stroke="#3fb950"
strokeWidth={6}
strokeOpacity={0.2}
strokeLinecap="round"
/>
)}
<path
d={path}
fill="none"
stroke={isOverlayActive && executed ? '#3fb950' : '#4a5e7a'}
strokeWidth={isOverlayActive && executed ? 2.5 : 1.5}
strokeLinecap="round"
markerEnd={executed ? 'url(#arrowhead-executed)' : 'url(#arrowhead)'}
/>
{edge.label && edge.points && edge.points.length > 1 && (
<text
x={(edge.points[0][0] + edge.points[edge.points.length - 1][0]) / 2}
y={(edge.points[0][1] + edge.points[edge.points.length - 1][1]) / 2 - 4}
fill="#7d8590"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
})}
</g>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import styles from './diagram.module.css';
interface ExchangeInspectorProps {
snapshot: Record<string, string>;
}
type Tab = 'input' | 'output';
function tryFormatJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export function ExchangeInspector({ snapshot }: ExchangeInspectorProps) {
const [tab, setTab] = useState<Tab>('input');
const body = tab === 'input' ? snapshot.inputBody : snapshot.outputBody;
const headers = tab === 'input' ? snapshot.inputHeaders : snapshot.outputHeaders;
return (
<div className={styles.exchangeInspector}>
<div className={styles.exchangeTabs}>
<button
className={`${styles.exchangeTab} ${tab === 'input' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('input')}
>
Input
</button>
<button
className={`${styles.exchangeTab} ${tab === 'output' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('output')}
>
Output
</button>
</div>
{body && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Body</div>
<pre className={styles.exchangeBody}>{tryFormatJson(body)}</pre>
</div>
)}
{headers && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Headers</div>
<pre className={styles.exchangeBody}>{tryFormatJson(headers)}</pre>
</div>
)}
{!body && !headers && (
<div className={styles.exchangeEmpty}>No exchange data available</div>
)}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface FlowParticlesProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isActive: boolean;
}
function pointsToPath(points: number[][]): string {
if (!points || points.length < 2) return '';
let d = `M${points[0][0]},${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
d += ` L${points[i][0]},${points[i][1]}`;
}
return d;
}
export function FlowParticles({ edges, executedEdges, isActive }: FlowParticlesProps) {
const paths = useMemo(() => {
if (!isActive) return [];
return edges
.filter((e) => executedEdges.has(`${e.sourceId}->${e.targetId}`))
.map((e, i) => ({
id: `particle-${e.sourceId}-${e.targetId}`,
d: pointsToPath(e.points ?? []),
delay: (i * 0.3) % 1.5,
}))
.filter((p) => p.d);
}, [edges, executedEdges, isActive]);
if (!isActive || paths.length === 0) return null;
return (
<g className={styles.flowParticles}>
{paths.map((p) => (
<g key={p.id}>
<path id={p.id} d={p.d} fill="none" stroke="none" />
<circle r={3} fill="url(#particle-gradient)">
<animateMotion
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
>
<mpath href={`#${p.id}`} />
</animateMotion>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.1;0.8;1"
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
/>
</circle>
</g>
))}
</g>
);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../../../api/types';
import { useProcessorSnapshot } from '../../../api/queries/executions';
import { ExchangeInspector } from './ExchangeInspector';
import styles from './diagram.module.css';
interface ProcessorDetailPanelProps {
execution: ExecutionDetail;
selectedNodeId: string | null;
}
/** Find the processor node matching a diagramNodeId, return its flat index too */
function findProcessor(
processors: ProcessorNode[],
nodeId: string,
indexRef: { idx: number },
): ProcessorNode | null {
for (const proc of processors) {
const currentIdx = indexRef.idx;
indexRef.idx++;
if (proc.diagramNodeId === nodeId) {
return { ...proc, _flatIndex: currentIdx } as ProcessorNode & { _flatIndex: number };
}
if (proc.children && proc.children.length > 0) {
const found = findProcessor(proc.children, nodeId, indexRef);
if (found) return found;
}
}
return null;
}
export function ProcessorDetailPanel({ execution, selectedNodeId }: ProcessorDetailPanelProps) {
const processor = useMemo(() => {
if (!selectedNodeId || !execution.processors) return null;
return findProcessor(execution.processors, selectedNodeId, { idx: 0 });
}, [execution, selectedNodeId]);
// Get flat index for snapshot lookup
const flatIndex = useMemo(() => {
if (!processor) return null;
return (processor as ProcessorNode & { _flatIndex?: number })._flatIndex ?? null;
}, [processor]);
const { data: snapshot } = useProcessorSnapshot(
flatIndex != null ? execution.executionId ?? null : null,
flatIndex,
);
if (!selectedNodeId || !processor) {
return (
<div className={styles.detailPanel}>
<div className={styles.detailEmpty}>
Click a node to view processor details
</div>
</div>
);
}
return (
<div className={styles.detailPanel}>
{/* Processor identity */}
<div className={styles.detailHeader}>
<div className={styles.detailType}>{processor.processorType}</div>
<div className={styles.detailId}>{processor.processorId}</div>
</div>
<div className={styles.detailMeta}>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Status</span>
<span className={`${styles.detailMetaValue} ${processor.status === 'FAILED' ? styles.statusFailed : styles.statusOk}`}>
{processor.status}
</span>
</div>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Duration</span>
<span className={styles.detailMetaValue}>{processor.durationMs}ms</span>
</div>
</div>
{/* Error info */}
{processor.errorMessage && (
<div className={styles.detailError}>
<div className={styles.detailErrorLabel}>Error</div>
<div className={styles.detailErrorMessage}>{processor.errorMessage}</div>
</div>
)}
{/* Exchange data */}
{snapshot && <ExchangeInspector snapshot={snapshot} />}
{/* Actions (future) */}
<div className={styles.detailActions}>
<button className={styles.detailActionBtn} disabled title="Coming soon">
Collect Trace Data
</button>
<button className={styles.detailActionBtn} disabled title="Coming soon">
View Logs
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { SvgDefs } from './SvgDefs';
import { EdgeLayer } from './EdgeLayer';
import { DiagramNode } from './DiagramNode';
import { FlowParticles } from './FlowParticles';
import { isCompoundType } from './nodeStyles';
import type { PositionedNode } from '../../../api/types';
interface RouteDiagramSvgProps {
layout: DiagramLayout;
overlay: OverlayState;
}
/** Recursively flatten all nodes (including compound children) for rendering */
function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
const result: PositionedNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
result.push(...flattenNodes(node.children));
}
}
return result;
}
export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
const padding = 40;
const width = (layout.width ?? 600) + padding * 2;
const height = (layout.height ?? 400) + padding * 2;
const allNodes = flattenNodes(layout.nodes ?? []);
// Render compound nodes first (background), then regular nodes on top
const compoundNodes = allNodes.filter((n) => isCompoundType(n.type ?? ''));
const leafNodes = allNodes.filter((n) => !isCompoundType(n.type ?? ''));
return (
<svg
width={width}
height={height}
viewBox={`-${padding} -${padding} ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block' }}
>
<SvgDefs />
{/* Compound container nodes (background) */}
{compoundNodes.map((node) => (
<DiagramNode
key={node.id}
node={node}
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
isError={false}
isOverlayActive={overlay.isActive}
duration={node.id ? overlay.durations.get(node.id) : undefined}
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
/>
))}
{/* Edges */}
<EdgeLayer
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isOverlayActive={overlay.isActive}
/>
{/* Flow particles */}
<FlowParticles
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isActive={overlay.isActive}
/>
{/* Leaf nodes (on top of edges) */}
{leafNodes.map((node) => {
const nodeId = node.id ?? '';
return (
<DiagramNode
key={nodeId}
node={node}
isExecuted={overlay.executedNodes.has(nodeId)}
isError={false}
isOverlayActive={overlay.isActive}
duration={overlay.durations.get(nodeId)}
sequence={overlay.sequences.get(nodeId)}
isSelected={overlay.selectedNodeId === nodeId}
onClick={overlay.selectNode}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,64 @@
/** SVG definitions: arrow markers, glow filters, gradient fills */
export function SvgDefs() {
return (
<defs>
{/* Arrow marker for edges */}
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#4a5e7a" />
</marker>
<marker id="arrowhead-executed" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#3fb950" />
</marker>
<marker id="arrowhead-error" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#f85149" />
</marker>
{/* Glow filters */}
<filter id="glow-green" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3fb950" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-red" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#f85149" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-blue" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#58a6ff" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-purple" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#b87aff" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Flow particle gradient */}
<radialGradient id="particle-gradient">
<stop offset="0%" stopColor="#3fb950" stopOpacity="1" />
<stop offset="100%" stopColor="#3fb950" stopOpacity="0" />
</radialGradient>
</defs>
);
}

View File

@@ -0,0 +1,325 @@
/* ─── Diagram Canvas ─── */
.canvasContainer {
position: relative;
flex: 1;
min-height: 0;
background: var(--bg-deep);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
min-height: 500px;
overflow: hidden;
cursor: grab;
}
.canvas:active {
cursor: grabbing;
}
/* ─── Zoom Controls ─── */
.zoomControls {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 4px;
z-index: 10;
}
.zoomBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.zoomBtn:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
/* ─── Minimap ─── */
.minimap {
position: absolute;
bottom: 12px;
right: 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px;
z-index: 10;
opacity: 0.85;
transition: opacity 0.2s;
}
.minimap:hover {
opacity: 1;
}
/* ─── Node Styles ─── */
.nodeGroup {
transition: opacity 0.3s;
}
.dimmed {
opacity: 0.15 !important;
}
.selected rect {
stroke-width: 2.5;
}
/* ─── Edge Layer ─── */
.edgeLayer path {
transition: opacity 0.3s, stroke 0.3s;
}
/* ─── Flow Particles ─── */
.flowParticles circle {
pointer-events: none;
}
/* ─── Split Layout (Diagram + Detail Panel) ─── */
.splitLayout {
display: flex;
gap: 0;
height: 100%;
min-height: 500px;
}
.diagramSide {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ─── Processor Detail Panel ─── */
.detailPanel {
width: 340px;
flex-shrink: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.detailEmpty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 40px 16px;
}
.detailHeader {
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 12px;
}
.detailType {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--amber);
margin-bottom: 4px;
}
.detailId {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
word-break: break-all;
}
.detailMeta {
display: flex;
gap: 16px;
}
.detailMetaItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.detailMetaLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.detailMetaValue {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
}
.statusFailed {
color: var(--rose);
}
.statusOk {
color: var(--green);
}
.detailError {
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.detailErrorLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--rose);
margin-bottom: 4px;
}
.detailErrorMessage {
font-family: var(--font-mono);
font-size: 11px;
color: var(--rose);
max-height: 80px;
overflow: auto;
}
/* ─── Exchange Inspector ─── */
.exchangeInspector {
flex: 1;
min-height: 0;
}
.exchangeTabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 12px;
}
.exchangeTab {
padding: 6px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.exchangeTab:hover {
color: var(--text-secondary);
}
.exchangeTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.exchangeSection {
margin-bottom: 12px;
}
.exchangeSectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 6px;
}
.exchangeBody {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.exchangeEmpty {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}
/* ─── Detail Actions ─── */
.detailActions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.detailActionBtn {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.detailActionBtn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.detailActionBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.splitLayout {
flex-direction: column;
}
.detailPanel {
width: 100%;
max-height: 300px;
border-left: none;
border-top: 1px solid var(--border-subtle);
}
.minimap {
display: none;
}
}

View File

@@ -0,0 +1,52 @@
/** Node type styling: border color, background, glow filter */
const ENDPOINT_TYPES = new Set([
'ENDPOINT', 'DIRECT', 'SEDA', 'TO', 'TO_DYNAMIC', 'FROM',
]);
const EIP_TYPES = new Set([
'CHOICE', 'SPLIT', 'MULTICAST', 'FILTER', 'AGGREGATE',
'RECIPIENT_LIST', 'ROUTING_SLIP', 'DYNAMIC_ROUTER',
'CIRCUIT_BREAKER', 'WHEN', 'OTHERWISE', 'LOOP',
]);
const ERROR_TYPES = new Set([
'ON_EXCEPTION', 'TRY_CATCH', 'DO_CATCH', 'DO_FINALLY',
'ERROR_HANDLER',
]);
const CROSS_ROUTE_TYPES = new Set([
'WIRE_TAP', 'ENRICH', 'POLL_ENRICH',
]);
export interface NodeStyle {
border: string;
bg: string;
glowFilter: string;
category: 'endpoint' | 'eip' | 'processor' | 'error' | 'crossRoute';
}
export function getNodeStyle(type: string): NodeStyle {
const upper = type.toUpperCase();
if (ERROR_TYPES.has(upper)) {
return { border: '#f85149', bg: '#3d1418', glowFilter: 'url(#glow-red)', category: 'error' };
}
if (ENDPOINT_TYPES.has(upper)) {
return { border: '#58a6ff', bg: '#1a3a5c', glowFilter: 'url(#glow-blue)', category: 'endpoint' };
}
if (CROSS_ROUTE_TYPES.has(upper)) {
return { border: '#39d2e0', bg: 'transparent', glowFilter: 'url(#glow-blue)', category: 'crossRoute' };
}
if (EIP_TYPES.has(upper)) {
return { border: '#b87aff', bg: '#2d1b4e', glowFilter: 'url(#glow-purple)', category: 'eip' };
}
// Default: Processor
return { border: '#3fb950', bg: '#0d2818', glowFilter: 'url(#glow-green)', category: 'processor' };
}
/** Compound node types that can contain children */
export const COMPOUND_TYPES = new Set([
'CHOICE', 'SPLIT', 'TRY_CATCH', 'LOOP', 'MULTICAST', 'AGGREGATE',
'ON_EXCEPTION', 'DO_CATCH', 'DO_FINALLY',
]);
export function isCompoundType(type: string): boolean {
return COMPOUND_TYPES.has(type.toUpperCase());
}