From e4c66b13113d8b81ad885cca793f70986769c266 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:01:53 +0100 Subject: [PATCH] feat: add DetailPanel with 7 tabs for execution diagram overlay Implements the bottom detail panel with processor header bar, tab bar (Info, Headers, Input, Output, Error, Config, Timeline), and all tab content components. Info shows processor/exchange metadata in a grid, Headers fetches per-processor snapshots for side-by-side display, Input/Output render formatted code blocks, Error extracts exception types, Config is a placeholder, and Timeline renders a Gantt chart. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram/DetailPanel.tsx | 164 +++++++ .../ExecutionDiagram.module.css | 433 ++++++++++++++++++ .../ExecutionDiagram/tabs/BodyTab.tsx | 70 +++ .../ExecutionDiagram/tabs/ConfigTab.tsx | 9 + .../ExecutionDiagram/tabs/ErrorTab.tsx | 45 ++ .../ExecutionDiagram/tabs/HeadersTab.tsx | 80 ++++ .../ExecutionDiagram/tabs/InfoTab.tsx | 115 +++++ .../ExecutionDiagram/tabs/TimelineTab.tsx | 94 ++++ 8 files changed, 1010 insertions(+) create mode 100644 ui/src/components/ExecutionDiagram/DetailPanel.tsx create mode 100644 ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css create mode 100644 ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx 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 +
+
+ ); + })} +
+ ); +}