diff --git a/ui/src/components/ExecutionDiagram/DetailPanel.tsx b/ui/src/components/ExecutionDiagram/DetailPanel.tsx new file mode 100644 index 00000000..74c90411 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/DetailPanel.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react'; +import type { ProcessorNode, ExecutionDetail, DetailTab } from './types'; +import { useProcessorSnapshotById } from '../../api/queries/executions'; +import { InfoTab } from './tabs/InfoTab'; +import { HeadersTab } from './tabs/HeadersTab'; +import { BodyTab } from './tabs/BodyTab'; +import { ErrorTab } from './tabs/ErrorTab'; +import { ConfigTab } from './tabs/ConfigTab'; +import { TimelineTab } from './tabs/TimelineTab'; +import styles from './ExecutionDiagram.module.css'; + +interface DetailPanelProps { + selectedProcessor: ProcessorNode | null; + executionDetail: ExecutionDetail; + executionId: string; + onSelectProcessor: (processorId: string) => void; +} + +const TABS: { key: DetailTab; label: string }[] = [ + { key: 'info', label: 'Info' }, + { key: 'headers', label: 'Headers' }, + { key: 'input', label: 'Input' }, + { key: 'output', label: 'Output' }, + { key: 'error', label: 'Error' }, + { key: 'config', label: 'Config' }, + { key: 'timeline', label: 'Timeline' }, +]; + +function formatDuration(ms: number | undefined): string { + if (ms === undefined || ms === null) return '-'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function statusClass(status: string): string { + const s = status?.toUpperCase(); + if (s === 'COMPLETED') return styles.statusCompleted; + if (s === 'FAILED') return styles.statusFailed; + return ''; +} + +export function DetailPanel({ + selectedProcessor, + executionDetail, + executionId, + onSelectProcessor, +}: DetailPanelProps) { + const [activeTab, setActiveTab] = useState('info'); + + // When selectedProcessor changes, keep current tab unless it was a + // processor-specific tab and now there is no processor selected. + const prevProcessorId = selectedProcessor?.processorId; + useEffect(() => { + // If no processor is selected and we're on a processor-specific tab, go to info + if (!selectedProcessor && (activeTab === 'input' || activeTab === 'output')) { + // Input/Output at exchange level still make sense, keep them + } + }, [prevProcessorId]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasError = selectedProcessor + ? !!selectedProcessor.errorMessage + : !!executionDetail.errorMessage; + + // Fetch snapshot for body tabs when a processor is selected + const snapshotQuery = useProcessorSnapshotById( + selectedProcessor ? executionId : null, + selectedProcessor?.processorId ?? null, + ); + + // Determine body content for Input/Output tabs + let inputBody: string | undefined; + let outputBody: string | undefined; + + if (selectedProcessor && snapshotQuery.data) { + inputBody = snapshotQuery.data.inputBody; + outputBody = snapshotQuery.data.outputBody; + } else if (!selectedProcessor) { + inputBody = executionDetail.inputBody; + outputBody = executionDetail.outputBody; + } + + // Header display + const headerName = selectedProcessor ? selectedProcessor.processorType : 'Exchange'; + const headerStatus = selectedProcessor ? selectedProcessor.status : executionDetail.status; + const headerId = selectedProcessor ? selectedProcessor.processorId : executionDetail.executionId; + const headerDuration = selectedProcessor ? selectedProcessor.durationMs : executionDetail.durationMs; + + return ( +
+ {/* Processor / Exchange header bar */} +
+ {headerName} + + {headerStatus} + + {headerId} + {formatDuration(headerDuration)} +
+ + {/* Tab bar */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.key; + const isDisabled = tab.key === 'config'; + const isError = tab.key === 'error' && hasError; + const isErrorGrayed = tab.key === 'error' && !hasError; + + let className = styles.tab; + if (isActive) className += ` ${styles.tabActive}`; + if (isDisabled) className += ` ${styles.tabDisabled}`; + if (isError && !isActive) className += ` ${styles.tabError}`; + if (isErrorGrayed && !isActive) className += ` ${styles.tabDisabled}`; + + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeTab === 'info' && ( + + )} + {activeTab === 'headers' && ( + + )} + {activeTab === 'input' && ( + + )} + {activeTab === 'output' && ( + + )} + {activeTab === 'error' && ( + + )} + {activeTab === 'config' && ( + + )} + {activeTab === 'timeline' && ( + + )} +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css new file mode 100644 index 00000000..c4964460 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css @@ -0,0 +1,433 @@ +/* ========================================================================== + DETAIL PANEL + ========================================================================== */ +.detailPanel { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-surface, #FFFFFF); + border-top: 1px solid var(--border, #E4DFD8); +} + +.processorHeader { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 6px 14px; + border-bottom: 1px solid var(--border, #E4DFD8); + background: #FAFAF8; + min-height: 32px; +} + +.processorName { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #1A1612); +} + +.processorId { + font-size: 11px; + font-family: var(--font-mono, monospace); + color: var(--text-muted, #9C9184); +} + +.processorDuration { + font-size: 11px; + font-family: var(--font-mono, monospace); + color: var(--text-secondary, #5C5347); + margin-left: auto; +} + +/* ========================================================================== + STATUS BADGE + ========================================================================== */ +.statusBadge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.statusCompleted { + color: var(--success, #3D7C47); + background: #F0F9F1; +} + +.statusFailed { + color: var(--error, #C0392B); + background: #FDF2F0; +} + +/* ========================================================================== + TAB BAR + ========================================================================== */ +.tabBar { + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--border, #E4DFD8); + padding: 0 14px; + background: #FAFAF8; + gap: 0; +} + +.tab { + padding: 6px 12px; + font-size: 11px; + font-family: var(--font-body, inherit); + cursor: pointer; + color: var(--text-muted, #9C9184); + border: none; + background: none; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + white-space: nowrap; +} + +.tab:hover { + color: var(--text-secondary, #5C5347); +} + +.tabActive { + color: var(--amber, #C6820E); + border-bottom: 2px solid var(--amber, #C6820E); + font-weight: 600; +} + +.tabDisabled { + opacity: 0.4; + cursor: default; +} + +.tabDisabled:hover { + color: var(--text-muted, #9C9184); +} + +.tabError { + color: var(--error, #C0392B); +} + +.tabError:hover { + color: var(--error, #C0392B); +} + +/* ========================================================================== + TAB CONTENT + ========================================================================== */ +.tabContent { + flex: 1; + overflow-y: auto; + padding: 10px 14px; +} + +/* ========================================================================== + INFO TAB — GRID + ========================================================================== */ +.infoGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px 24px; +} + +.fieldLabel { + font-size: 10px; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.fieldValue { + font-size: 12px; + color: var(--text-primary, #1A1612); + word-break: break-all; +} + +.fieldValueMono { + font-size: 12px; + color: var(--text-primary, #1A1612); + font-family: var(--font-mono, monospace); + word-break: break-all; +} + +/* ========================================================================== + ATTRIBUTE PILLS + ========================================================================== */ +.attributesSection { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--border, #E4DFD8); +} + +.attributesLabel { + font-size: 10px; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.attributesList { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.attributePill { + font-size: 10px; + padding: 2px 8px; + background: var(--bg-hover, #F5F0EA); + border-radius: 10px; + color: var(--text-secondary, #5C5347); + font-family: var(--font-mono, monospace); +} + +/* ========================================================================== + HEADERS TAB — SPLIT + ========================================================================== */ +.headersSplit { + display: flex; + gap: 0; + min-height: 0; +} + +.headersColumn { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.headersColumn + .headersColumn { + border-left: 1px solid var(--border, #E4DFD8); + padding-left: 14px; + margin-left: 14px; +} + +.headersColumnLabel { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.headersTable { + width: 100%; + font-size: 11px; + border-collapse: collapse; +} + +.headersTable td { + padding: 3px 0; + border-bottom: 1px solid var(--border, #E4DFD8); + vertical-align: top; +} + +.headersTable tr:last-child td { + border-bottom: none; +} + +.headerKey { + font-family: var(--font-mono, monospace); + font-weight: 600; + color: var(--text-muted, #9C9184); + white-space: nowrap; + padding-right: 12px; + width: 140px; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerVal { + font-family: var(--font-mono, monospace); + color: var(--text-primary, #1A1612); + word-break: break-all; +} + +/* ========================================================================== + BODY / CODE TAB + ========================================================================== */ +.codeHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.codeFormat { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.codeSize { + font-size: 10px; + color: var(--text-muted, #9C9184); + font-family: var(--font-mono, monospace); +} + +.codeCopyBtn { + margin-left: auto; + font-size: 10px; + font-family: var(--font-body, inherit); + padding: 2px 8px; + border: 1px solid var(--border, #E4DFD8); + border-radius: 4px; + background: var(--bg-surface, #FFFFFF); + color: var(--text-secondary, #5C5347); + cursor: pointer; +} + +.codeCopyBtn:hover { + background: var(--bg-hover, #F5F0EA); +} + +.codeBlock { + background: #1A1612; + color: #E4DFD8; + padding: 12px; + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 11px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 400px; + overflow-y: auto; +} + +/* ========================================================================== + ERROR TAB + ========================================================================== */ +.errorType { + font-size: 13px; + font-weight: 600; + color: var(--error, #C0392B); + margin-bottom: 8px; +} + +.errorMessage { + font-size: 12px; + color: var(--text-primary, #1A1612); + background: #FDF2F0; + border: 1px solid #F5D5D0; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 12px; + line-height: 1.5; + word-break: break-word; +} + +.errorStackTrace { + background: #1A1612; + color: #E4DFD8; + padding: 12px; + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 10px; + line-height: 1.5; + overflow-x: auto; + white-space: pre; + max-height: 300px; + overflow-y: auto; +} + +.errorStackLabel { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +/* ========================================================================== + TIMELINE / GANTT TAB + ========================================================================== */ +.ganttContainer { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ganttRow { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 3px 4px; + border-radius: 3px; + transition: background 0.1s; +} + +.ganttRow:hover { + background: var(--bg-hover, #F5F0EA); +} + +.ganttSelected { + background: #FFF8F0; +} + +.ganttSelected:hover { + background: #FFF8F0; +} + +.ganttLabel { + width: 100px; + min-width: 100px; + font-size: 10px; + color: var(--text-secondary, #5C5347); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ganttBar { + flex: 1; + height: 16px; + background: var(--bg-hover, #F5F0EA); + border-radius: 2px; + position: relative; + min-width: 0; +} + +.ganttFill { + position: absolute; + height: 100%; + border-radius: 2px; + min-width: 2px; +} + +.ganttFillCompleted { + background: var(--success, #3D7C47); +} + +.ganttFillFailed { + background: var(--error, #C0392B); +} + +.ganttDuration { + width: 50px; + min-width: 50px; + font-size: 10px; + font-family: var(--font-mono, monospace); + color: var(--text-muted, #9C9184); + text-align: right; +} + +/* ========================================================================== + EMPTY STATE + ========================================================================== */ +.emptyState { + text-align: center; + color: var(--text-muted, #9C9184); + font-size: 12px; + padding: 20px; +} diff --git a/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx new file mode 100644 index 00000000..1ef04535 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import styles from '../ExecutionDiagram.module.css'; + +interface BodyTabProps { + body: string | undefined; + label: string; +} + +function detectFormat(text: string): 'JSON' | 'XML' | 'Text' { + const trimmed = text.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(text); + return 'JSON'; + } catch { + // not valid JSON + } + } + if (trimmed.startsWith('<')) return 'XML'; + return 'Text'; +} + +function formatBody(text: string, format: string): string { + if (format === 'JSON') { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + return text; +} + +function byteSize(text: string): string { + const bytes = new TextEncoder().encode(text).length; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function BodyTab({ body, label }: BodyTabProps) { + const [copied, setCopied] = useState(false); + + if (!body) { + return
No {label.toLowerCase()} body available
; + } + + const format = detectFormat(body); + const formatted = formatBody(body, format); + + function handleCopy() { + navigator.clipboard.writeText(body!).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
+ {format} + {byteSize(body)} + +
+
{formatted}
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx b/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx new file mode 100644 index 00000000..da6cf21d --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx @@ -0,0 +1,9 @@ +import styles from '../ExecutionDiagram.module.css'; + +export function ConfigTab() { + return ( +
+ Processor configuration data is not yet available. +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx new file mode 100644 index 00000000..511d4e8b --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx @@ -0,0 +1,45 @@ +import type { ProcessorNode, ExecutionDetail } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface ErrorTabProps { + processor: ProcessorNode | null; + executionDetail: ExecutionDetail; +} + +function extractExceptionType(errorMessage: string): string { + const colonIdx = errorMessage.indexOf(':'); + if (colonIdx > 0) { + return errorMessage.substring(0, colonIdx).trim(); + } + return 'Error'; +} + +export function ErrorTab({ processor, executionDetail }: ErrorTabProps) { + const errorMessage = processor?.errorMessage || executionDetail.errorMessage; + const errorStackTrace = processor?.errorStackTrace || executionDetail.errorStackTrace; + + if (!errorMessage) { + return ( +
+ {processor + ? 'No error on this processor' + : 'No error on this exchange'} +
+ ); + } + + const exceptionType = extractExceptionType(errorMessage); + + return ( +
+
{exceptionType}
+
{errorMessage}
+ {errorStackTrace && ( + <> +
Stack Trace
+
{errorStackTrace}
+ + )} +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx b/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx new file mode 100644 index 00000000..762133b6 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx @@ -0,0 +1,80 @@ +import { useProcessorSnapshotById } from '../../../api/queries/executions'; +import styles from '../ExecutionDiagram.module.css'; + +interface HeadersTabProps { + executionId: string; + processorId: string | null; + exchangeInputHeaders?: string; + exchangeOutputHeaders?: string; +} + +function parseHeaders(json: string | undefined): Record { + if (!json) return {}; + try { + return JSON.parse(json); + } catch { + return {}; + } +} + +function HeaderTable({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers
; + } + return ( + + + {entries.map(([k, v]) => ( + + + + + ))} + +
{k}{v}
+ ); +} + +export function HeadersTab({ + executionId, + processorId, + exchangeInputHeaders, + exchangeOutputHeaders, +}: HeadersTabProps) { + const snapshotQuery = useProcessorSnapshotById( + processorId ? executionId : null, + processorId, + ); + + let inputHeaders: Record; + let outputHeaders: Record; + + if (processorId && snapshotQuery.data) { + inputHeaders = parseHeaders(snapshotQuery.data.inputHeaders); + outputHeaders = parseHeaders(snapshotQuery.data.outputHeaders); + } else if (!processorId) { + inputHeaders = parseHeaders(exchangeInputHeaders); + outputHeaders = parseHeaders(exchangeOutputHeaders); + } else { + inputHeaders = {}; + outputHeaders = {}; + } + + if (processorId && snapshotQuery.isLoading) { + return
Loading headers...
; + } + + return ( +
+
+
Input Headers
+ +
+
+
Output Headers
+ +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx new file mode 100644 index 00000000..65f59566 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx @@ -0,0 +1,115 @@ +import type { ProcessorNode, ExecutionDetail } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface InfoTabProps { + processor: ProcessorNode | null; + executionDetail: ExecutionDetail; +} + +function formatTime(iso: string | undefined): string { + if (!iso) return '-'; + try { + const d = new Date(iso); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; + } catch { + return iso; + } +} + +function formatDuration(ms: number | undefined): string { + if (ms === undefined || ms === null) return '-'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function statusClass(status: string): string { + const s = status?.toUpperCase(); + if (s === 'COMPLETED') return styles.statusCompleted; + if (s === 'FAILED') return styles.statusFailed; + return ''; +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +function Attributes({ attrs }: { attrs: Record | undefined }) { + if (!attrs) return null; + const entries = Object.entries(attrs); + if (entries.length === 0) return null; + + return ( +
+
Attributes
+
+ {entries.map(([k, v]) => ( + + {k}: {v} + + ))} +
+
+ ); +} + +export function InfoTab({ processor, executionDetail }: InfoTabProps) { + if (processor) { + return ( +
+
+ + +
+
Status
+ + {processor.status} + +
+ + + + + + + +
+
+ +
+ ); + } + + // Exchange-level view + return ( +
+
+ + + + + + +
+
Status
+ + {executionDetail.status} + +
+ + + + +
+ +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx b/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx new file mode 100644 index 00000000..1438b9fb --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx @@ -0,0 +1,94 @@ +import type { ExecutionDetail, ProcessorNode } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface TimelineTabProps { + executionDetail: ExecutionDetail; + selectedProcessorId: string | null; + onSelectProcessor: (id: string) => void; +} + +interface FlatProcessor { + processorId: string; + processorType: string; + status: string; + startTime: string; + durationMs: number; + depth: number; +} + +function flattenProcessors( + nodes: ProcessorNode[], + depth: number, + result: FlatProcessor[], +): void { + for (const node of nodes) { + const status = node.status?.toUpperCase(); + if (status === 'COMPLETED' || status === 'FAILED') { + result.push({ + processorId: node.processorId, + processorType: node.processorType, + status, + startTime: node.startTime, + durationMs: node.durationMs, + depth, + }); + } + if (node.children && node.children.length > 0) { + flattenProcessors(node.children, depth + 1, result); + } + } +} + +export function TimelineTab({ + executionDetail, + selectedProcessorId, + onSelectProcessor, +}: TimelineTabProps) { + const flat: FlatProcessor[] = []; + flattenProcessors(executionDetail.processors || [], 0, flat); + + if (flat.length === 0) { + return
No processor timeline data available
; + } + + const execStart = new Date(executionDetail.startTime).getTime(); + const totalDuration = executionDetail.durationMs || 1; + + return ( +
+ {flat.map((proc) => { + const procStart = new Date(proc.startTime).getTime(); + const offsetPct = Math.max(0, ((procStart - execStart) / totalDuration) * 100); + const widthPct = Math.max(0.5, (proc.durationMs / totalDuration) * 100); + const isSelected = proc.processorId === selectedProcessorId; + const fillClass = proc.status === 'FAILED' + ? styles.ganttFillFailed + : styles.ganttFillCompleted; + + return ( +
onSelectProcessor(proc.processorId)} + > +
+ {' '.repeat(proc.depth)}{proc.processorType || proc.processorId} +
+
+
+
+
+ {proc.durationMs}ms +
+
+ ); + })} +
+ ); +}