feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
New interactive route diagram component with SVG rendering using server-computed ELK layout coordinates. TIBCO BW5-inspired top-bar card node style with zoom/pan, hover toolbars, config badges, and error handler sections below the main flow. Backend: add direction query parameter (LR/TB) to diagram render endpoints, defaulting to left-to-right layout. Frontend: 14-file ProcessDiagram component in ui/src/components/ with DiagramNode, CompoundNode, DiagramEdge, ConfigBadge, NodeToolbar, ErrorSection, ZoomControls, and supporting hooks. Dev test page at /dev/diagram for validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal file
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary, #1A1612);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
color: var(--text-primary, #1A1612);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diagramPane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
border: 1px dashed var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--text-muted, #9C9184);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidePanel h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5C5347);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nodeInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #9C9184);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field code, .field pre {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #1A1612);
|
||||
background: var(--bg-inset, #F0EDE8);
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted, #9C9184);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #5C5347);
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, #EDE9E3);
|
||||
}
|
||||
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal file
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ProcessDiagram } from '../../components/ProcessDiagram';
|
||||
import type { NodeConfig, NodeAction } from '../../components/ProcessDiagram';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import styles from './DevDiagram.module.css';
|
||||
|
||||
export default function DevDiagram() {
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
|
||||
const [selectedApp, setSelectedApp] = useState('');
|
||||
const [selectedRoute, setSelectedRoute] = useState('');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState('');
|
||||
const [direction, setDirection] = useState<'LR' | 'TB'>('LR');
|
||||
const [actionLog, setActionLog] = useState<string[]>([]);
|
||||
|
||||
// Extract unique applications and routes from catalog
|
||||
const { apps, routes } = useMemo(() => {
|
||||
if (!catalog) return { apps: [] as string[], routes: [] as string[] };
|
||||
const appSet = new Set<string>();
|
||||
const routeList: { app: string; routeId: string }[] = [];
|
||||
for (const entry of catalog as Array<{ application?: string; routeId?: string }>) {
|
||||
if (entry.application) appSet.add(entry.application);
|
||||
if (entry.application && entry.routeId) {
|
||||
routeList.push({ app: entry.application, routeId: entry.routeId });
|
||||
}
|
||||
}
|
||||
const appArr = Array.from(appSet).sort();
|
||||
const filtered = selectedApp
|
||||
? routeList.filter(r => r.app === selectedApp).map(r => r.routeId)
|
||||
: [];
|
||||
return { apps: appArr, routes: filtered };
|
||||
}, [catalog, selectedApp]);
|
||||
|
||||
// Mock node configs for testing
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>();
|
||||
// We'll add some mock configs if we have a route loaded
|
||||
map.set('log1', { traceEnabled: true });
|
||||
map.set('to1', { tapExpression: '${header.orderId}' });
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const handleNodeAction = (nodeId: string, action: NodeAction) => {
|
||||
const msg = `[${new Date().toLocaleTimeString()}] ${action}: ${nodeId}`;
|
||||
setActionLog(prev => [msg, ...prev.slice(0, 19)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h2>Process Diagram (Dev)</h2>
|
||||
<div className={styles.controls}>
|
||||
<select
|
||||
value={selectedApp}
|
||||
onChange={e => { setSelectedApp(e.target.value); setSelectedRoute(''); }}
|
||||
>
|
||||
<option value="">Select application...</option>
|
||||
{apps.map(app => <option key={app} value={app}>{app}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={selectedRoute}
|
||||
onChange={e => setSelectedRoute(e.target.value)}
|
||||
disabled={!selectedApp}
|
||||
>
|
||||
<option value="">Select route...</option>
|
||||
{routes.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<select value={direction} onChange={e => setDirection(e.target.value as 'LR' | 'TB')}>
|
||||
<option value="LR">Left → Right</option>
|
||||
<option value="TB">Top → Bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.diagramPane}>
|
||||
{selectedApp && selectedRoute ? (
|
||||
<ProcessDiagram
|
||||
application={selectedApp}
|
||||
routeId={selectedRoute}
|
||||
direction={direction}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
Select an application and route to view the diagram
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.sidePanel}>
|
||||
<h3>Selected Node</h3>
|
||||
{selectedNodeId ? (
|
||||
<div className={styles.nodeInfo}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Node ID</span>
|
||||
<code>{selectedNodeId}</code>
|
||||
</div>
|
||||
{nodeConfigs.has(selectedNodeId) && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Config</span>
|
||||
<pre>{JSON.stringify(nodeConfigs.get(selectedNodeId), null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.hint}>Click a node to inspect it</p>
|
||||
)}
|
||||
|
||||
<h3>Action Log</h3>
|
||||
<div className={styles.log}>
|
||||
{actionLog.length === 0 ? (
|
||||
<p className={styles.hint}>Hover a node and use the toolbar</p>
|
||||
) : (
|
||||
actionLog.map((msg, i) => (
|
||||
<div key={i} className={styles.logEntry}>{msg}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user