+
Nodes
{NODE_TYPES.map((t) => )}
diff --git a/ui/src/pages/routes/diagram/DiagramNode.tsx b/ui/src/pages/routes/diagram/DiagramNode.tsx
index a19992c8..b07288fb 100644
--- a/ui/src/pages/routes/diagram/DiagramNode.tsx
+++ b/ui/src/pages/routes/diagram/DiagramNode.tsx
@@ -3,7 +3,7 @@ import { getNodeStyle, isCompoundType } from './nodeStyles';
import styles from './diagram.module.css';
const FIXED_W = 200;
-const FIXED_H = 40;
+const FIXED_H = 52;
const MAX_LABEL = 22;
function truncateLabel(label: string | undefined): string {
@@ -11,13 +11,13 @@ function truncateLabel(label: string | undefined): string {
return label.length > MAX_LABEL ? label.slice(0, MAX_LABEL - 1) + '\u2026' : label;
}
-function buildTooltip(node: PositionedNode, isOverlayActive: boolean, isExecuted: boolean, isError: boolean, duration?: number): string {
- const parts = [`${node.type ?? 'PROCESSOR'}: ${node.label ?? ''}`];
- if (isOverlayActive && isExecuted) {
- parts.push(`Status: ${isError ? 'FAILED' : 'OK'}`);
- if (duration != null) parts.push(`Duration: ${duration}ms`);
- }
- return parts.join('\n');
+export interface TooltipData {
+ nodeType: string;
+ label: string;
+ color: string;
+ isExecuted: boolean;
+ isError: boolean;
+ duration?: number;
}
interface DiagramNodeProps {
@@ -29,6 +29,7 @@ interface DiagramNodeProps {
sequence?: number;
isSelected: boolean;
onClick: (nodeId: string) => void;
+ onHover?: (data: TooltipData | null, x: number, y: number) => void;
}
export function DiagramNode({
@@ -40,6 +41,7 @@ export function DiagramNode({
sequence,
isSelected,
onClick,
+ onHover,
}: DiagramNodeProps) {
const style = getNodeStyle(node.type ?? 'PROCESSOR');
const isCompound = isCompoundType(node.type ?? '');
@@ -53,7 +55,20 @@ export function DiagramNode({
? (isError ? '#f85149' : '#3fb950')
: style.border;
- const tooltip = buildTooltip(node, isOverlayActive, isExecuted, isError, duration);
+ const handleMouseEnter = (e: React.MouseEvent) => {
+ onHover?.({
+ nodeType: node.type ?? 'PROCESSOR',
+ label: node.label ?? '',
+ color: style.border,
+ isExecuted: isOverlayActive && isExecuted,
+ isError,
+ duration,
+ }, e.clientX, e.clientY);
+ };
+
+ const handleMouseLeave = () => {
+ onHover?.(null, 0, 0);
+ };
if (isCompound) {
return (
@@ -62,8 +77,9 @@ export function DiagramNode({
opacity={dimmed ? 0.15 : 1}
role="img"
aria-label={`${node.type} container: ${node.label}`}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
>
-
{tooltip}
node.id && onClick(node.id)}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
style={{ cursor: 'pointer' }}
role="img"
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0}
>
- {tooltip}
void;
}
/** Recursively flatten all nodes (including compound children) for rendering */
@@ -24,7 +26,7 @@ function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
return result;
}
-export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
+export function RouteDiagramSvg({ layout, overlay, onNodeHover }: RouteDiagramSvgProps) {
const padding = 40;
const width = (layout.width ?? 600) + padding * 2;
const height = (layout.height ?? 400) + padding * 2;
@@ -58,6 +60,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
+ onHover={onNodeHover}
/>
{/* Iteration count badge */}
{overlay.isActive && iterData && iterData.count > 1 && (
@@ -116,6 +119,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
sequence={overlay.sequences.get(nodeId)}
isSelected={overlay.selectedNodeId === nodeId}
onClick={overlay.selectNode}
+ onHover={onNodeHover}
/>
);
})}
diff --git a/ui/src/pages/routes/diagram/diagram.module.css b/ui/src/pages/routes/diagram/diagram.module.css
index fcdce4e6..16efb2fd 100644
--- a/ui/src/pages/routes/diagram/diagram.module.css
+++ b/ui/src/pages/routes/diagram/diagram.module.css
@@ -409,7 +409,102 @@
color: var(--text-muted);
}
+/* ─── Node Tooltip ─── */
+.nodeTooltip {
+ position: fixed;
+ transform: translate(12px, -50%);
+ background: rgba(13, 17, 23, 0.95);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 8px 12px;
+ z-index: 100;
+ pointer-events: none;
+ backdrop-filter: blur(8px);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
+ min-width: 140px;
+ max-width: 280px;
+}
+
+.tooltipHeader {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+
+.tooltipDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tooltipType {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-muted);
+}
+
+.tooltipLabel {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-primary);
+ word-break: break-all;
+}
+
+.tooltipMeta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border-subtle);
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.tooltipStatusOk {
+ color: var(--green);
+ font-weight: 600;
+}
+
+.tooltipStatusFailed {
+ color: var(--rose);
+ font-weight: 600;
+}
+
+.tooltipDuration {
+ color: var(--text-secondary);
+}
+
/* ─── Legend ─── */
+.legendToggle {
+ position: absolute;
+ bottom: 12px;
+ left: 12px;
+ padding: 5px 12px;
+ background: rgba(13, 17, 23, 0.85);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ cursor: pointer;
+ z-index: 10;
+ backdrop-filter: blur(4px);
+ transition: all 0.15s;
+}
+
+.legendToggle:hover {
+ background: rgba(13, 17, 23, 0.95);
+ color: var(--text-secondary);
+ border-color: var(--border);
+}
+
.legend {
position: absolute;
bottom: 12px;
@@ -424,6 +519,24 @@
backdrop-filter: blur(4px);
}
+.legendCloseBtn {
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 0 4px;
+ line-height: 1;
+ transition: color 0.15s;
+}
+
+.legendCloseBtn:hover {
+ color: var(--text-primary);
+}
+
.legendSection {
display: flex;
flex-direction: column;
@@ -485,4 +598,8 @@
.legend {
display: none;
}
+
+ .legendToggle {
+ display: none;
+ }
}