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:
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