Files
cameleer-server/ui/src/components/ProcessDiagram/DiagramNode.tsx

307 lines
12 KiB
TypeScript
Raw Normal View History

import React from 'react';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType, type IconElement } from './node-colors';
const TOP_BAR_HEIGHT = 6;
const TEXT_LEFT = 32;
const TEXT_RIGHT_PAD = 24;
const CORNER_RADIUS = 4;
interface DiagramNodeProps {
node: DiagramNodeType;
isHovered: boolean;
isSelected: boolean;
config?: NodeConfig;
executionState?: NodeExecutionState;
overlayActive?: boolean;
heatmapEntry?: LatencyHeatmapEntry;
onClick: () => void;
onDoubleClick?: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
/** Interpolate green (120°) → yellow (60°) → red (0°) based on pctOfRoute */
function heatmapColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
// 0% → hue 120 (green), 50% → hue 60 (yellow), 100% → hue 0 (red)
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 70%, 92%)`;
}
function heatmapBorderColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 60%, 50%)`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function DiagramNode({
node, isHovered, isSelected, config,
executionState, overlayActive, heatmapEntry,
onClick, onDoubleClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 120;
const h = node.height ?? 40;
const color = colorForType(node.type);
const iconElements = iconForType(node.type);
// Extract label parts: type name and detail
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const detail = node.label || '';
const resolvedUri = executionState?.resolvedEndpointUri;
// Overlay state derivation
const isCompleted = executionState?.status === 'COMPLETED';
const isFailed = executionState?.status === 'FAILED';
const isSkipped = overlayActive && !executionState;
// Colors based on execution state (heatmap takes priority when no execution overlay)
let cardFill = isHovered ? '#F5F0EA' : 'white';
let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
let borderWidth = isHovered || isSelected ? 1.5 : 1;
let topBarColor = color;
let labelColor = '#1A1612';
if (isCompleted) {
cardFill = isHovered ? '#E4F5E6' : '#F0F9F1';
borderStroke = '#3D7C47';
borderWidth = 1.5;
topBarColor = '#3D7C47';
} else if (isFailed) {
cardFill = isHovered ? '#F9E4E1' : '#FDF2F0';
borderStroke = '#C0392B';
borderWidth = 2;
topBarColor = '#C0392B';
labelColor = '#C0392B';
} else if (heatmapEntry && !overlayActive) {
cardFill = heatmapColor(heatmapEntry.pctOfRoute);
borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute);
borderWidth = 1.5;
topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute);
}
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
return (
<g
data-node-id={node.id}
transform={`translate(${x}, ${y})`}
onClick={(e) => { e.stopPropagation(); onClick(); }}
onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
opacity={isSkipped ? 0.35 : undefined}
>
{/* Selection ring */}
{isSelected && (
<rect
x={-2}
y={-2}
width={w + 4}
height={h + 4}
rx={CORNER_RADIUS + 2}
fill="none"
stroke="#C6820E"
strokeWidth={2.5}
/>
)}
{/* Card background */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill={cardFill}
stroke={borderStroke}
strokeWidth={borderWidth}
/>
{/* Colored top bar */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={topBarColor} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={topBarColor} />
{/* Clip path for text area */}
<clipPath id={`clip-${node.id}`}>
<rect x={TEXT_LEFT} y={TOP_BAR_HEIGHT} width={w - TEXT_LEFT - TEXT_RIGHT_PAD} height={h - TOP_BAR_HEIGHT} />
</clipPath>
{/* Icon (lucide 24×24 scaled to 14px) */}
<g transform={`translate(6, ${h / 2 - 7}) scale(0.583)`}>
{iconElements.map((el: IconElement, i: number) =>
'd' in el
? <path key={i} d={el.d} fill="none" stroke={statusColor ?? color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke={statusColor ?? color} strokeWidth={2} />
)}
</g>
{/* Type name + detail + resolved URI (clipped to available width) */}
<g clipPath={`url(#clip-${node.id})`}>
{resolvedUri ? (
<>
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 12} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{detail && detail !== typeName && (
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 24} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail}
</text>
)}
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + (detail && detail !== typeName ? 35 : 24)} fill="#1A7F8E" fontSize={9} fontStyle="italic">
{resolvedUri.split('?')[0]}
</text>
</>
) : (
<>
<text x={TEXT_LEFT} y={h / 2 - 1} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{detail && detail !== typeName && (
<text x={TEXT_LEFT} y={h / 2 + 12} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail}
</text>
)}
</>
)}
</g>
{/* Inline badges row: hasTrace, hasTap, status — inside card, top-right */}
{(() => {
const BADGE_R = 6;
const BADGE_D = BADGE_R * 2;
const BADGE_GAP = 3;
const cy = TOP_BAR_HEIGHT + BADGE_R + 2;
const showTrace = config?.traceEnabled || executionState?.hasTraceData;
const showTap = !!config?.tapExpression;
if (!showTrace && !showTap && !isCompleted && !isFailed) return null;
const badges: React.ReactNode[] = [];
let slot = 0;
// Status badge (rightmost, only during overlay)
const statusCx = w - BADGE_R - 4;
if (isCompleted) {
badges.push(
<g key="status">
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#3D7C47" />
<path d={`M${statusCx - 3} ${cy} l2 2 4-4`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</g>
);
slot++;
} else if (isFailed) {
badges.push(
<g key="status">
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values="6;14" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values="6;14" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
</circle>
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#C0392B" />
<path d={`M${statusCx} ${cy - 3} v4 M${statusCx} ${cy + 2.5} v0.5`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" />
</g>
);
slot++;
}
// Tap badge (before status)
if (showTap) {
const tapCx = statusCx - slot * (BADGE_D + BADGE_GAP);
badges.push(
<g key="tap">
<circle cx={tapCx} cy={cy} r={BADGE_R} fill="#7C3AED" />
<g transform={`translate(${tapCx - 5}, ${cy - 5})`} stroke="white" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" />
</g>
</g>
);
slot++;
}
// Trace badge (leftmost)
if (showTrace) {
const traceCx = statusCx - slot * (BADGE_D + BADGE_GAP);
const tracePulse = overlayActive && executionState?.hasTraceData;
const traceHasData = executionState?.hasTraceData;
badges.push(
<g key="trace">
{tracePulse && (
<>
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" begin="0.75s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
</circle>
</>
)}
<circle cx={traceCx} cy={cy} r={BADGE_R} fill={traceHasData ? '#1A7F8E' : '#1A7F8E'} opacity={traceHasData ? 1 : 0.2} />
<g transform={`translate(${traceCx - 5}, ${cy - 5}) scale(${10/24})`} stroke={traceHasData ? 'white' : '#1A7F8E'} strokeWidth={2.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z" />
<path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z" />
<path d="M16 17h4" />
<path d="M4 13h4" />
</g>
</g>
);
}
return <>{badges}</>;
})()}
{/* Execution overlay: duration text at bottom-right */}
{executionState && statusColor && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={statusColor}
fontSize={9}
fontWeight={500}
>
{formatDuration(executionState.durationMs)}
</text>
)}
{/* Heatmap: avg duration label at bottom-right */}
{heatmapEntry && !overlayActive && !executionState && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={heatmapBorderColor(heatmapEntry.pctOfRoute)}
fontSize={9}
fontWeight={600}
>
{formatDuration(heatmapEntry.avgDurationMs)}
</text>
)}
{/* Sub-route failure: drill-down arrow at bottom-left */}
{isFailed && executionState?.subRouteFailed && (
<g transform={`translate(4, ${h - 14})`}>
<path
d="M2 2 v5 a3 3 0 003 3 h5"
fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
/>
<path d="M8 8 l2 2 -2 2" fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</g>
)}
</g>
);
}