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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 19:01:53 +01:00
parent 5da03d0938
commit e4c66b1311
8 changed files with 1010 additions and 0 deletions

View File

@@ -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<DetailTab>('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 (
<div className={styles.detailPanel}>
{/* Processor / Exchange header bar */}
<div className={styles.processorHeader}>
<span className={styles.processorName}>{headerName}</span>
<span className={`${styles.statusBadge} ${statusClass(headerStatus)}`}>
{headerStatus}
</span>
<span className={styles.processorId}>{headerId}</span>
<span className={styles.processorDuration}>{formatDuration(headerDuration)}</span>
</div>
{/* Tab bar */}
<div className={styles.tabBar}>
{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 (
<button
key={tab.key}
className={className}
onClick={() => {
if (!isDisabled) setActiveTab(tab.key);
}}
type="button"
>
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className={styles.tabContent}>
{activeTab === 'info' && (
<InfoTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'headers' && (
<HeadersTab
executionId={executionId}
processorId={selectedProcessor?.processorId ?? null}
exchangeInputHeaders={executionDetail.inputHeaders}
exchangeOutputHeaders={executionDetail.outputHeaders}
/>
)}
{activeTab === 'input' && (
<BodyTab body={inputBody} label="Input" />
)}
{activeTab === 'output' && (
<BodyTab body={outputBody} label="Output" />
)}
{activeTab === 'error' && (
<ErrorTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'config' && (
<ConfigTab />
)}
{activeTab === 'timeline' && (
<TimelineTab
executionDetail={executionDetail}
selectedProcessorId={selectedProcessor?.processorId ?? null}
onSelectProcessor={onSelectProcessor}
/>
)}
</div>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 <div className={styles.emptyState}>No {label.toLowerCase()} body available</div>;
}
const format = detectFormat(body);
const formatted = formatBody(body, format);
function handleCopy() {
navigator.clipboard.writeText(body!).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}
return (
<div>
<div className={styles.codeHeader}>
<span className={styles.codeFormat}>{format}</span>
<span className={styles.codeSize}>{byteSize(body)}</span>
<button className={styles.codeCopyBtn} onClick={handleCopy} type="button">
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre className={styles.codeBlock}>{formatted}</pre>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import styles from '../ExecutionDiagram.module.css';
export function ConfigTab() {
return (
<div className={styles.emptyState}>
Processor configuration data is not yet available.
</div>
);
}

View File

@@ -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 (
<div className={styles.emptyState}>
{processor
? 'No error on this processor'
: 'No error on this exchange'}
</div>
);
}
const exceptionType = extractExceptionType(errorMessage);
return (
<div>
<div className={styles.errorType}>{exceptionType}</div>
<div className={styles.errorMessage}>{errorMessage}</div>
{errorStackTrace && (
<>
<div className={styles.errorStackLabel}>Stack Trace</div>
<pre className={styles.errorStackTrace}>{errorStackTrace}</pre>
</>
)}
</div>
);
}

View File

@@ -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<string, string> {
if (!json) return {};
try {
return JSON.parse(json);
} catch {
return {};
}
}
function HeaderTable({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className={styles.emptyState}>No headers</div>;
}
return (
<table className={styles.headersTable}>
<tbody>
{entries.map(([k, v]) => (
<tr key={k}>
<td className={styles.headerKey}>{k}</td>
<td className={styles.headerVal}>{v}</td>
</tr>
))}
</tbody>
</table>
);
}
export function HeadersTab({
executionId,
processorId,
exchangeInputHeaders,
exchangeOutputHeaders,
}: HeadersTabProps) {
const snapshotQuery = useProcessorSnapshotById(
processorId ? executionId : null,
processorId,
);
let inputHeaders: Record<string, string>;
let outputHeaders: Record<string, string>;
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 <div className={styles.emptyState}>Loading headers...</div>;
}
return (
<div className={styles.headersSplit}>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Input Headers</div>
<HeaderTable headers={inputHeaders} />
</div>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Output Headers</div>
<HeaderTable headers={outputHeaders} />
</div>
</div>
);
}

View File

@@ -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 (
<div>
<div className={styles.fieldLabel}>{label}</div>
<div className={mono ? styles.fieldValueMono : styles.fieldValue}>{value || '-'}</div>
</div>
);
}
function Attributes({ attrs }: { attrs: Record<string, string> | undefined }) {
if (!attrs) return null;
const entries = Object.entries(attrs);
if (entries.length === 0) return null;
return (
<div className={styles.attributesSection}>
<div className={styles.attributesLabel}>Attributes</div>
<div className={styles.attributesList}>
{entries.map(([k, v]) => (
<span key={k} className={styles.attributePill}>
{k}: {v}
</span>
))}
</div>
</div>
);
}
export function InfoTab({ processor, executionDetail }: InfoTabProps) {
if (processor) {
return (
<div>
<div className={styles.infoGrid}>
<Field label="Processor ID" value={processor.processorId} mono />
<Field label="Type" value={processor.processorType} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(processor.status)}`}>
{processor.status}
</span>
</div>
<Field label="Start Time" value={formatTime(processor.startTime)} mono />
<Field label="End Time" value={formatTime(processor.endTime)} mono />
<Field label="Duration" value={formatDuration(processor.durationMs)} mono />
<Field label="Endpoint URI" value={processor.processorType} />
<Field label="Resolved URI" value="-" />
<div />
</div>
<Attributes attrs={processor.attributes} />
</div>
);
}
// Exchange-level view
return (
<div>
<div className={styles.infoGrid}>
<Field label="Execution ID" value={executionDetail.executionId} mono />
<Field label="Correlation ID" value={executionDetail.correlationId} mono />
<Field label="Exchange ID" value={executionDetail.exchangeId} mono />
<Field label="Application" value={executionDetail.applicationName} />
<Field label="Route ID" value={executionDetail.routeId} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(executionDetail.status)}`}>
{executionDetail.status}
</span>
</div>
<Field label="Start Time" value={formatTime(executionDetail.startTime)} mono />
<Field label="End Time" value={formatTime(executionDetail.endTime)} mono />
<Field label="Duration" value={formatDuration(executionDetail.durationMs)} mono />
</div>
<Attributes attrs={executionDetail.attributes} />
</div>
);
}

View File

@@ -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 <div className={styles.emptyState}>No processor timeline data available</div>;
}
const execStart = new Date(executionDetail.startTime).getTime();
const totalDuration = executionDetail.durationMs || 1;
return (
<div className={styles.ganttContainer}>
{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 (
<div
key={proc.processorId}
className={`${styles.ganttRow} ${isSelected ? styles.ganttSelected : ''}`}
onClick={() => onSelectProcessor(proc.processorId)}
>
<div className={styles.ganttLabel} title={proc.processorType || proc.processorId}>
{' '.repeat(proc.depth)}{proc.processorType || proc.processorId}
</div>
<div className={styles.ganttBar}>
<div
className={`${styles.ganttFill} ${fillClass}`}
style={{
left: `${offsetPct}%`,
width: `${Math.min(widthPct, 100 - offsetPct)}%`,
}}
/>
</div>
<div className={styles.ganttDuration}>
{proc.durationMs}ms
</div>
</div>
);
})}
</div>
);
}