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:
164
ui/src/components/ExecutionDiagram/DetailPanel.tsx
Normal file
164
ui/src/components/ExecutionDiagram/DetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
433
ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
Normal file
433
ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
Normal 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;
|
||||
}
|
||||
70
ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
Normal file
70
ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx
Normal file
9
ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx
Normal file
45
ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx
Normal file
80
ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
Normal file
115
ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx
Normal file
94
ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user