Add route diagram page with execution overlay and group-aware APIs
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:
@@ -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}`}>›</td>
|
||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||
|
||||
27
ui/src/pages/routes/DiagramTab.tsx
Normal file
27
ui/src/pages/routes/DiagramTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
ui/src/pages/routes/PerformanceTab.tsx
Normal file
95
ui/src/pages/routes/PerformanceTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
ui/src/pages/routes/RouteHeader.tsx
Normal file
29
ui/src/pages/routes/RouteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
ui/src/pages/routes/RoutePage.module.css
Normal file
262
ui/src/pages/routes/RoutePage.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
113
ui/src/pages/routes/RoutePage.tsx
Normal file
113
ui/src/pages/routes/RoutePage.tsx
Normal 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} · {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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
ui/src/pages/routes/diagram/DiagramCanvas.tsx
Normal file
110
ui/src/pages/routes/diagram/DiagramCanvas.tsx
Normal 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">−</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>
|
||||
);
|
||||
}
|
||||
71
ui/src/pages/routes/diagram/DiagramMinimap.tsx
Normal file
71
ui/src/pages/routes/diagram/DiagramMinimap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
ui/src/pages/routes/diagram/DiagramNode.tsx
Normal file
160
ui/src/pages/routes/diagram/DiagramNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
ui/src/pages/routes/diagram/EdgeLayer.tsx
Normal file
91
ui/src/pages/routes/diagram/EdgeLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
ui/src/pages/routes/diagram/ExchangeInspector.tsx
Normal file
60
ui/src/pages/routes/diagram/ExchangeInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
ui/src/pages/routes/diagram/FlowParticles.tsx
Normal file
61
ui/src/pages/routes/diagram/FlowParticles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx
Normal file
102
ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
ui/src/pages/routes/diagram/RouteDiagramSvg.tsx
Normal file
95
ui/src/pages/routes/diagram/RouteDiagramSvg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
ui/src/pages/routes/diagram/SvgDefs.tsx
Normal file
64
ui/src/pages/routes/diagram/SvgDefs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
ui/src/pages/routes/diagram/diagram.module.css
Normal file
325
ui/src/pages/routes/diagram/diagram.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
ui/src/pages/routes/diagram/nodeStyles.ts
Normal file
52
ui/src/pages/routes/diagram/nodeStyles.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user