Files
cameleer-server/ui/src/components/ProcessDiagram/DiagramNode.tsx
hsiegeln 26de222884
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s
refactor: move config badges inline, fix trace config from server
- Render hasTrace/hasTap/status badges inside the node card in both
  raw diagram and overlay modes (consistent positioning)
- Pulse only on trace badge in overlay mode when hasTraceData is true
- Fix nodeConfigs to read tracedProcessors from appConfig instead of
  never-synced tracing store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:08:40 +02:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}