chore(ui): remove dead code from navigation redesign
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Deleted:
- ScopeTrail component (replaced by inline breadcrumb in TopBar)
- ExchangeList component (replaced by Dashboard DataTable)
- ExchangeDetail page (replaced by inline split view)

Removed from Dashboard:
- flattenProcessors() function (unused after detail panel removal)
- 11 dead CSS classes (panelSection, overviewGrid, errorBlock,
  inspectLink, openDetailLink, filterBar, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-28 16:14:15 +01:00
parent f2a094f349
commit 9f281c3354
8 changed files with 0 additions and 1858 deletions

View File

@@ -1,40 +0,0 @@
.trail {
display: flex;
align-items: center;
gap: 0;
font-size: 0.8125rem;
color: var(--text-muted);
min-height: 1.5rem;
}
.segment {
display: inline-flex;
align-items: center;
}
.link {
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.8125rem;
}
.link:hover {
color: var(--amber);
text-decoration: underline;
}
.separator {
margin: 0 0.375rem;
color: var(--text-muted);
user-select: none;
}
.current {
color: var(--text-primary);
font-weight: 500;
}

View File

@@ -1,38 +0,0 @@
import type { Scope } from '../hooks/useScope';
import styles from './ScopeTrail.module.css';
interface ScopeTrailProps {
scope: Scope;
onNavigate: (path: string) => void;
}
export function ScopeTrail({ scope, onNavigate }: ScopeTrailProps) {
const segments: { label: string; path: string }[] = [
{ label: 'All Applications', path: `/${scope.tab}` },
];
if (scope.appId) {
segments.push({ label: scope.appId, path: `/${scope.tab}/${scope.appId}` });
}
if (scope.routeId) {
segments.push({ label: scope.routeId, path: `/${scope.tab}/${scope.appId}/${scope.routeId}` });
}
return (
<nav className={styles.trail}>
{segments.map((seg, i) => (
<span key={seg.path} className={styles.segment}>
{i > 0 && <span className={styles.separator}>&gt;</span>}
{i < segments.length - 1 ? (
<button className={styles.link} onClick={() => onNavigate(seg.path)}>
{seg.label}
</button>
) : (
<span className={styles.current}>{seg.label}</span>
)}
</span>
))}
</nav>
);
}

View File

@@ -11,11 +11,6 @@
/* Table section — stretches to fill and scrolls internally */
/* Filter bar spacing */
.filterBar {
margin-bottom: 16px;
}
.tableSection {
display: flex;
flex-direction: column;
@@ -166,112 +161,6 @@
margin-top: 3px;
}
/* Detail panel sections */
.panelSection {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.panelSection:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.panelSectionTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.panelSectionMeta {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--text-faint);
}
/* Overview grid */
.overviewGrid {
display: flex;
flex-direction: column;
gap: 8px;
}
.overviewRow {
display: flex;
align-items: flex-start;
gap: 12px;
}
.overviewLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
width: 90px;
flex-shrink: 0;
padding-top: 2px;
}
/* Error block */
.errorBlock {
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.errorClass {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--error);
margin-bottom: 4px;
}
.errorMessage {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
font-family: var(--font-mono);
word-break: break-word;
}
/* Inspect exchange icon in table */
.inspectLink {
background: transparent;
border: none;
color: var(--text-faint);
opacity: 0.75;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: var(--radius-sm);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, opacity 0.15s;
text-decoration: none;
}
.inspectLink:hover {
color: var(--text-primary);
opacity: 1;
}
/* Attributes cell in table */
.attrCell {
display: flex;
@@ -288,20 +177,3 @@
color: var(--text-muted);
}
/* Open full details link in panel */
.openDetailLink {
background: transparent;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 12px;
padding: 0;
font-family: var(--font-body);
transition: color 0.1s;
}
.openDetailLink:hover {
color: var(--amber-deep);
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -66,24 +66,6 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach
}
function flattenProcessors(nodes: any[]): any[] {
const result: any[] = []
let offset = 0
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
nodes.forEach(walk)
return result
}
// ─── Table columns (base, without inspect action) ────────────────────────────
function buildBaseColumns(): Column<Row>[] {

View File

@@ -1,685 +0,0 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
.loadingContainer {
display: flex;
justify-content: center;
padding: 4rem;
}
/* ==========================================================================
EXCHANGE HEADER CARD
========================================================================== */
.exchangeHeader {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
margin-bottom: 14px;
}
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.headerLeft {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
}
.exchangeId {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.exchangeRoute {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.routeLink {
color: var(--amber);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.routeLink:hover {
color: var(--amber-deep);
}
.headerDivider {
color: var(--text-faint);
}
.headerRight {
display: flex;
gap: 20px;
flex-shrink: 0;
}
.headerStat {
text-align: center;
}
.headerStatLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 2px;
}
.headerStatValue {
font-size: 14px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-primary);
}
/* ==========================================================================
CORRELATION CHAIN
========================================================================== */
.correlationChain {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.chainLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-right: 4px;
}
.chainNode {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
background: var(--bg-surface);
color: var(--text-secondary);
transition: all 0.12s;
}
.chainNode:hover {
border-color: var(--text-faint);
background: var(--bg-hover);
}
.chainNodeCurrent {
background: var(--amber-bg);
border-color: var(--amber-light);
color: var(--amber-deep);
font-weight: 600;
}
.chainNodeSuccess {
border-left: 3px solid var(--success);
}
.chainNodeError {
border-left: 3px solid var(--error);
}
.chainNodeRunning {
border-left: 3px solid var(--running);
}
.chainNodeWarning {
border-left: 3px solid var(--warning);
}
.chainMore {
color: var(--text-muted);
font-size: 11px;
font-style: italic;
}
/* ==========================================================================
ATTRIBUTES STRIP
========================================================================== */
.attributesStrip {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
padding: 10px 14px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: 16px;
}
.attributesLabel {
font-size: 11px;
color: var(--text-muted);
margin-right: 4px;
}
/* ==========================================================================
TIMELINE SECTION
========================================================================== */
.timelineSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 16px;
overflow: hidden;
}
.timelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.timelineTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.procCount {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
padding: 1px 8px;
border-radius: 10px;
background: var(--bg-inset);
color: var(--text-muted);
}
.timelineToggle {
display: inline-flex;
gap: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
overflow: hidden;
}
.toggleBtn {
padding: 4px 12px;
font-size: 11px;
font-family: var(--font-body);
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.12s;
}
.toggleBtn:hover {
background: var(--bg-hover);
}
.toggleBtnActive {
background: var(--amber);
color: #fff;
font-weight: 600;
}
.toggleBtnActive:hover {
background: var(--amber-deep);
}
.timelineBody {
padding: 12px 16px;
}
/* ==========================================================================
EXECUTION DIAGRAM CONTAINER (Flow view)
========================================================================== */
.executionDiagramContainer {
height: 600px;
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
overflow: hidden;
margin-bottom: 16px;
}
/* ==========================================================================
DETAIL SPLIT (IN / OUT panels)
========================================================================== */
.detailSplit {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.detailPanel {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.detailPanelError {
border-color: var(--error-border);
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
gap: 8px;
}
.detailPanelError .panelHeader {
background: var(--error-bg);
border-bottom-color: var(--error-border);
}
.panelTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.arrowIn {
color: var(--success);
font-weight: 700;
}
.arrowOut {
color: var(--running);
font-weight: 700;
}
.arrowError {
color: var(--error);
font-weight: 700;
font-size: 16px;
}
.panelTag {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
}
.panelBody {
padding: 16px;
}
/* Headers section */
.headersSection {
margin-bottom: 12px;
}
.headerList {
display: flex;
flex-direction: column;
gap: 0;
}
.headerKvRow {
display: grid;
grid-template-columns: 140px 1fr;
padding: 4px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
}
.headerKvRow:last-child {
border-bottom: none;
}
.headerKey {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerValue {
font-family: var(--font-mono);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Body section */
.bodySection {
margin-top: 12px;
}
.sectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.count {
font-family: var(--font-mono);
font-size: 10px;
padding: 0 5px;
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-faint);
}
/* Error panel styles */
.errorBadgeRow {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.errorHttpBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.errorMessageBox {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--error-bg);
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--error-border);
margin-bottom: 12px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.errorDetailGrid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 4px 12px;
font-size: 11px;
}
.errorDetailLabel {
font-weight: 600;
color: var(--text-muted);
font-family: var(--font-mono);
}
.errorDetailValue {
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
}
/* Snapshot loading */
.snapshotLoading {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}
/* Exchange log section */
.logSection {
margin-top: 20px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 420px;
}
.logSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.logMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.logToolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.logSearchWrap {
position: relative;
flex: 1;
min-width: 0;
}
.logSearchInput {
width: 100%;
padding: 5px 28px 5px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
}
.logSearchInput:focus {
border-color: var(--amber);
}
.logSearchInput::placeholder {
color: var(--text-faint);
}
.logSearchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
}
.logClearFilters {
background: none;
border: none;
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
white-space: nowrap;
}
.logClearFilters:hover {
color: var(--text-primary);
}
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* ==========================================================================
REPLAY MODAL
========================================================================== */
.replayWarning {
background: var(--warning-bg, #3d3520);
border: 1px solid var(--warning-border, #6b5c2a);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 12px;
color: var(--warning, #e6b84f);
margin-bottom: 16px;
line-height: 1.5;
}
.replayAgentRow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.replayFieldLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.replayHeadersTable {
margin-top: 12px;
}
.replayHeadersHead {
display: grid;
grid-template-columns: 1fr 1fr 28px;
gap: 8px;
padding-bottom: 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.replayHeaderRow {
display: grid;
grid-template-columns: 1fr 1fr 28px;
gap: 8px;
margin-bottom: 6px;
align-items: center;
}
.replayRemoveBtn {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
}
.replayRemoveBtn:hover {
color: var(--error);
background: var(--error-bg);
}
.replayAddHeader {
background: none;
border: none;
color: var(--amber);
font-size: 12px;
cursor: pointer;
padding: 4px 0;
margin-top: 4px;
}
.replayAddHeader:hover {
color: var(--amber-deep);
text-decoration: underline;
}
.replayBodyArea {
margin-top: 12px;
}
.replayBodyTextarea {
font-family: var(--font-mono);
font-size: 12px;
width: 100%;
}
.replayFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}

View File

@@ -1,819 +0,0 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Spinner, useToast,
LogViewer, ButtonGroup, SectionHeader, useBreadcrumb,
Modal, Tabs, Button, Select, Input, Textarea,
useGlobalFilters,
} from '@cameleer/design-system'
import type { ProcessorStep, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system'
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation'
import { useTracingStore } from '../../stores/tracing-store'
import { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands'
import { useAgents } from '../../api/queries/agents'
import { useApplicationLogs } from '../../api/queries/logs'
import { useRouteCatalog } from '../../api/queries/catalog'
import { ExecutionDiagram } from '../../components/ExecutionDiagram'
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'
import styles from './ExchangeDetail.module.css'
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'error', label: 'Error', color: 'var(--error)' },
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
{ value: 'info', label: 'Info', color: 'var(--success)' },
{ value: 'debug', label: 'Debug', color: 'var(--running)' },
{ value: 'trace', label: 'Trace', color: 'var(--text-muted)' },
]
function mapLogLevel(level: string): LogEntry['level'] {
switch (level?.toUpperCase()) {
case 'ERROR': return 'error'
case 'WARN': case 'WARNING': return 'warn'
case 'DEBUG': return 'debug'
case 'TRACE': return 'trace'
default: return 'info'
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status.toUpperCase()) {
case 'COMPLETED': return 'success'
case 'FAILED': return 'error'
case 'RUNNING': return 'running'
default: return 'warning'
}
}
function backendStatusToLabel(status: string): string {
return status.toUpperCase()
}
function procStatusToStep(status: string): 'ok' | 'slow' | 'fail' {
const s = status.toUpperCase()
if (s === 'FAILED') return 'fail'
if (s === 'RUNNING') return 'slow'
return 'ok'
}
function parseHeaders(raw: string | undefined | null): Record<string, string> {
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
if (typeof parsed === 'object' && parsed !== null) {
const result: Record<string, string> = {}
for (const [k, v] of Object.entries(parsed)) {
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
}
return result
}
} catch { /* ignore */ }
return {}
}
function countProcessors(nodes: Array<{ children?: any[] }>): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0)
}
// ── ExchangeDetail ───────────────────────────────────────────────────────────
export default function ExchangeDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
const [logSearch, setLogSearch] = useState('')
const [logLevels, setLogLevels] = useState<Set<string>>(new Set())
// Replay modal state
const [replayOpen, setReplayOpen] = useState(false)
const [replayHeaders, setReplayHeaders] = useState<Array<{ key: string; value: string }>>([])
const [replayBody, setReplayBody] = useState('')
const [replayAgent, setReplayAgent] = useState('')
const [replayTab, setReplayTab] = useState('headers')
const procList = detail
? (detail.processors ?? [])
: []
// Subscribe to tracing state for badge rendering
const tracedMap = useTracingStore((s) => s.tracedProcessors[detail?.applicationName ?? ''])
function badgesFor(processorId: string): NodeBadge[] | undefined {
if (!tracedMap || !(processorId in tracedMap)) return undefined
return [{ label: 'Traced', variant: 'info' }]
}
// Flatten processor tree into ProcessorStep[]
const processors: ProcessorStep[] = useMemo(() => {
if (!procList.length) return []
const result: ProcessorStep[] = []
let offset = 0
function walk(node: any) {
const pid = node.processorId || node.processorType
result.push({
name: pid,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: procStatusToStep(node.status ?? ''),
startMs: offset,
badges: badgesFor(node.processorId || ''),
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
procList.forEach(walk)
return result
}, [procList, tracedMap])
// Flatten processor tree into raw node objects (for attribute access)
const flatProcNodes = useMemo(() => {
const nodes: any[] = []
function walk(node: any) {
nodes.push(node)
if (node.children) node.children.forEach(walk)
}
procList.forEach(walk)
return nodes
}, [procList])
// Default selected processor: first failed, or 0
const defaultIndex = useMemo(() => {
if (!processors.length) return 0
const failIdx = processors.findIndex((p) => p.status === 'fail')
return failIdx >= 0 ? failIdx : 0
}, [processors])
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
const activeIndex = selectedProcessorIndex ?? defaultIndex
const { data: snapshot } = useProcessorSnapshot(
id ?? null,
procList.length > 0 ? activeIndex : null,
)
const selectedProc = processors[activeIndex]
const isSelectedFailed = selectedProc?.status === 'fail'
// Parse snapshot data
const inputHeaders = parseHeaders(snapshot?.inputHeaders)
const outputHeaders = parseHeaders(snapshot?.outputHeaders)
const inputBody = snapshot?.inputBody ?? null
const outputBody = snapshot?.outputBody ?? null
// ProcessorId lookup: timeline index → processorId
const processorIds: string[] = useMemo(() => {
const ids: string[] = []
function walk(node: any) {
ids.push(node.processorId || '')
if (node.children) node.children.forEach(walk)
}
procList.forEach(walk)
return ids
}, [procList])
// ── Tracing toggle ──────────────────────────────────────────────────────
const { toast } = useToast()
const tracingStore = useTracingStore()
const app = detail?.applicationName ?? ''
const { data: appConfig } = useApplicationConfig(app || undefined)
const updateConfig = useUpdateApplicationConfig()
// Sync tracing store with server config
useEffect(() => {
if (appConfig?.tracedProcessors && app) {
tracingStore.syncFromServer(app, appConfig.tracedProcessors)
}
}, [appConfig, app])
const handleToggleTracing = useCallback((processorId: string) => {
if (!processorId || !detail?.applicationName || !appConfig) return
const newMap = tracingStore.toggleProcessor(app, processorId)
const updatedConfig = {
...appConfig,
tracedProcessors: { ...newMap },
}
updateConfig.mutate(updatedConfig, {
onSuccess: (saved) => {
const action = processorId in newMap ? 'enabled' : 'disabled'
toast({ title: `Tracing ${action}`, description: `${processorId} — config v${saved.version}`, variant: 'success' })
},
onError: () => {
tracingStore.toggleProcessor(app, processorId)
toast({ title: 'Config update failed', description: 'Could not save configuration', variant: 'error' })
},
})
}, [detail, app, appConfig, tracingStore, updateConfig, toast])
// ── ExecutionDiagram support ──────────────────────────────────────────
const { timeRange } = useGlobalFilters()
const { data: catalog } = useRouteCatalog(
timeRange.start.toISOString(),
timeRange.end.toISOString(),
)
const knownRouteIds = useMemo(() => {
if (!catalog || !app) return new Set<string>()
const appEntry = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>)
.find(a => a.appId === app)
return new Set((appEntry?.routes ?? []).map(r => r.routeId))
}, [catalog, app])
const nodeConfigs = useMemo(() => {
const map = new Map<string, NodeConfig>()
if (tracedMap) {
for (const pid of Object.keys(tracedMap)) {
map.set(pid, { traceEnabled: true })
}
}
return map
}, [tracedMap])
const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => {
if (action === 'toggle-trace') {
handleToggleTracing(nodeId)
} else if (action === 'configure-tap' && detail?.applicationName) {
navigate(`/admin/appconfig?app=${encodeURIComponent(detail.applicationName)}&processor=${encodeURIComponent(nodeId)}`)
}
}, [handleToggleTracing, detail?.applicationName, navigate])
// ── Replay ─────────────────────────────────────────────────────────────
const { data: liveAgents } = useAgents('LIVE', detail?.applicationName)
const replay = useReplayExchange()
// Pre-populate replay form when modal opens
useEffect(() => {
if (!replayOpen || !detail) return
try {
const parsed = JSON.parse(detail.inputHeaders ?? '{}')
const entries = Object.entries(parsed).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v),
}))
setReplayHeaders(entries.length > 0 ? entries : [{ key: '', value: '' }])
} catch {
setReplayHeaders([{ key: '', value: '' }])
}
setReplayBody(detail.inputBody ?? '')
// Default to current agent if it is live
const agentIds = (liveAgents ?? []).map((a: any) => a.id)
setReplayAgent(agentIds.includes(detail.agentId) ? detail.agentId : (agentIds[0] ?? ''))
setReplayTab('headers')
}, [replayOpen])
function handleReplay() {
const headers: Record<string, string> = {}
replayHeaders.forEach((h) => { if (h.key) headers[h.key] = h.value })
replay.mutate(
{ agentId: replayAgent, headers, body: replayBody },
{
onSuccess: () => { toast({ title: 'Replay command sent', variant: 'success' }); setReplayOpen(false) },
onError: (err) => { toast({ title: `Replay failed: ${err.message}`, variant: 'error' }) },
},
)
}
// Correlation chain
const correlatedExchanges = useMemo(() => {
if (!correlationData?.data || correlationData.data.length <= 1) return []
return correlationData.data
}, [correlationData])
// Set semantic breadcrumb in TopBar when detail is loaded
const breadcrumbItems = useMemo(() => detail ? [
{ label: 'Applications', href: '/apps' },
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
{ label: detail.executionId || '' },
] : null, [detail?.applicationName, detail?.routeId, detail?.executionId])
useBreadcrumb(breadcrumbItems)
// Exchange logs from OpenSearch (filtered by exchangeId via MDC)
const { data: rawLogs } = useApplicationLogs(
detail?.applicationName,
undefined,
{ exchangeId: detail?.exchangeId ?? undefined },
)
const logEntries = useMemo<LogEntry[]>(
() => (rawLogs || []).map((l) => ({
timestamp: l.timestamp ?? '',
level: mapLogLevel(l.level),
message: l.message ?? '',
})),
[rawLogs],
)
const logSearchLower = logSearch.toLowerCase()
const filteredLogs = logEntries
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower))
// ── Loading state ────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spinner size="lg" />
</div>
)
}
// ── Not found state ──────────────────────────────────────────────────────
if (!detail) {
return (
<div className={styles.content}>
<InfoCallout variant="warning">Exchange &quot;{id}&quot; not found.</InfoCallout>
</div>
)
}
const statusVariant = backendStatusToVariant(detail.status)
const statusLabel = backendStatusToLabel(detail.status)
return (
<div className={styles.content}>
{/* Exchange header card */}
<div className={styles.exchangeHeader}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={statusVariant} />
<div>
<div className={styles.exchangeId}>
<MonoText size="md">{detail.executionId}</MonoText>
<Badge label={statusLabel} color={statusVariant} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
{detail.applicationName && (
<>
<span className={styles.headerDivider}>&middot;</span>
App: <MonoText size="xs">{detail.applicationName}</MonoText>
</>
)}
{detail.correlationId && (
<>
<span className={styles.headerDivider}>&middot;</span>
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
</>
)}
</div>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Agent</div>
<div className={styles.headerStatValue}>{detail.agentId}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{detail.startTime
? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '\u2014'}
</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(procList)}</div>
</div>
<Button variant="primary" size="sm" onClick={() => setReplayOpen(true)}>
&#8635; Replay
</Button>
</div>
</div>
{/* Route-level Attributes */}
{detail.attributes && Object.keys(detail.attributes).length > 0 && (
<div className={styles.attributesStrip}>
<span className={styles.attributesLabel}>Attributes</span>
{Object.entries(detail.attributes).map(([key, value]) => (
<Badge key={key} label={`${key}: ${value}`} color="auto" variant="filled" />
))}
</div>
)}
{/* Correlation Chain */}
{correlatedExchanges.length > 1 && (
<div className={styles.correlationChain}>
<span className={styles.chainLabel}>Correlated Exchanges</span>
{correlatedExchanges.map((ce) => {
const isCurrent = ce.executionId === id
const variant = backendStatusToVariant(ce.status)
const statusCls =
variant === 'success' ? styles.chainNodeSuccess
: variant === 'error' ? styles.chainNodeError
: variant === 'running' ? styles.chainNodeRunning
: styles.chainNodeWarning
return (
<button
key={ce.executionId}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => {
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
}}
title={`${ce.executionId} \u2014 ${ce.routeId}`}
>
<StatusDot variant={variant} />
<span>{ce.routeId}</span>
</button>
)
})}
{correlationData && correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)}
</div>
)}
</div>
{/* Processor Timeline Section */}
<div className={styles.timelineSection}>
<div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>
Processor Timeline
<span className={styles.procCount}>{processors.length} processors</span>
</span>
<div className={styles.timelineToggle}>
<button
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('gantt')}
>
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div>
{timelineView === 'gantt' && (
<div className={styles.timelineBody}>
{processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
getActions={(_proc, index) => {
const pid = processorIds[index]
if (!pid || !detail?.applicationName) return []
return [{
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',
onClick: () => handleToggleTracing(pid),
disabled: updateConfig.isPending,
}]
}}
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)}
</div>
)}
</div>
{timelineView === 'flow' && detail && (
<div className={styles.executionDiagramContainer}>
<ExecutionDiagram
executionId={id!}
executionDetail={detail}
knownRouteIds={knownRouteIds}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
</div>
)}
{/* Exchange-level body (start/end of route) */}
{detail && (detail.inputBody || detail.outputBody) && (
<div className={styles.detailSplit}>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowIn}>&rarr;</span> Exchange Input
</span>
<span className={styles.panelTag}>at route entry</span>
</div>
<div className={styles.panelBody}>
{detail.inputHeaders && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>Headers</div>
<div className={styles.headerList}>
{Object.entries(parseHeaders(detail.inputHeaders)).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={detail.inputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Exchange Output
</span>
<span className={styles.panelTag}>at route exit</span>
</div>
<div className={styles.panelBody}>
{detail.outputHeaders && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>Headers</div>
<div className={styles.headerList}>
{Object.entries(parseHeaders(detail.outputHeaders)).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={detail.outputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
</div>
)}
{/* Processor Attributes */}
{selectedProc && (() => {
const procNode = flatProcNodes[activeIndex]
return procNode?.attributes && Object.keys(procNode.attributes).length > 0 ? (
<div className={styles.attributesStrip}>
<span className={styles.attributesLabel}>Processor Attributes</span>
{Object.entries(procNode.attributes).map(([key, value]) => (
<Badge key={key} label={`${key}: ${value}`} color="auto" variant="filled" />
))}
</div>
) : null
})()}
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshot && (
<div className={styles.detailSplit}>
{/* Message IN */}
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowIn}>&rarr;</span> Message IN
</span>
<span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
</div>
<div className={styles.panelBody}>
{Object.keys(inputHeaders).length > 0 && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(inputHeaders).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
{/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{activeIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div>
<div className={styles.panelBody}>
{detail.errorMessage && (
<div className={styles.errorMessageBox}>{detail.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<span className={styles.errorDetailLabel}>Processor</span>
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
<span className={styles.errorDetailLabel}>Duration</span>
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
<span className={styles.errorDetailLabel}>Status</span>
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
</div>
</div>
</div>
) : (
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
</div>
<div className={styles.panelBody}>
{Object.keys(outputHeaders).length > 0 && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(outputHeaders).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(outputHeaders).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
)}
</div>
)}
{/* Snapshot loading indicator */}
{selectedProc && !snapshot && procList.length > 0 && (
<div className={styles.snapshotLoading}>
Loading exchange snapshot...
</div>
)}
{/* Exchange Application Log */}
{detail && (
<div className={styles.logSection}>
<div className={styles.logSectionHeader}>
<SectionHeader>Application Log</SectionHeader>
<span className={styles.logMeta}>{logEntries.length} entries</span>
</div>
<div className={styles.logToolbar}>
<div className={styles.logSearchWrap}>
<input
type="text"
className={styles.logSearchInput}
placeholder="Search logs\u2026"
value={logSearch}
onChange={(e) => setLogSearch(e.target.value)}
aria-label="Search logs"
/>
{logSearch && (
<button type="button" className={styles.logSearchClear} onClick={() => setLogSearch('')} aria-label="Clear search">
&times;
</button>
)}
</div>
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
{logLevels.size > 0 && (
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}>Clear</button>
)}
</div>
{filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} maxHeight={360} />
) : (
<div className={styles.logEmpty}>
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No logs captured for this exchange'}
</div>
)}
</div>
)}
{/* Replay Modal */}
<Modal open={replayOpen} onClose={() => setReplayOpen(false)} title="Replay Exchange" size="lg">
<div className={styles.replayWarning}>
This will re-send the exchange to a live agent. The agent will process
it as a new exchange. Use with caution in production environments.
</div>
<div className={styles.replayAgentRow}>
<span className={styles.replayFieldLabel}>Target Agent</span>
<Select
value={replayAgent}
onChange={(e) => setReplayAgent(e.target.value)}
options={(liveAgents ?? []).map((a: any) => ({
value: a.id,
label: a.name || a.id,
}))}
/>
</div>
<Tabs
tabs={[
{ label: 'Headers', value: 'headers', count: replayHeaders.filter((h) => h.key).length },
{ label: 'Body', value: 'body' },
]}
active={replayTab}
onChange={setReplayTab}
/>
{replayTab === 'headers' && (
<div className={styles.replayHeadersTable}>
<div className={styles.replayHeadersHead}>
<span>Key</span>
<span>Value</span>
<span />
</div>
{replayHeaders.map((h, i) => (
<div key={i} className={styles.replayHeaderRow}>
<Input
value={h.key}
placeholder="Header name"
onChange={(e) => {
const next = [...replayHeaders]
next[i] = { ...next[i], key: e.target.value }
setReplayHeaders(next)
}}
/>
<Input
value={h.value}
placeholder="Value"
onChange={(e) => {
const next = [...replayHeaders]
next[i] = { ...next[i], value: e.target.value }
setReplayHeaders(next)
}}
/>
<button
type="button"
className={styles.replayRemoveBtn}
onClick={() => {
const next = replayHeaders.filter((_, j) => j !== i)
setReplayHeaders(next.length > 0 ? next : [{ key: '', value: '' }])
}}
aria-label="Remove header"
>
&times;
</button>
</div>
))}
<button
type="button"
className={styles.replayAddHeader}
onClick={() => setReplayHeaders([...replayHeaders, { key: '', value: '' }])}
>
+ Add header
</button>
</div>
)}
{replayTab === 'body' && (
<div className={styles.replayBodyArea}>
<Textarea
className={styles.replayBodyTextarea}
value={replayBody}
onChange={(e) => setReplayBody(e.target.value)}
rows={12}
resize="vertical"
placeholder="Request body (JSON, XML, text, etc.)"
/>
</div>
)}
<div className={styles.replayFooter}>
<Button variant="secondary" size="sm" onClick={() => setReplayOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleReplay}
loading={replay.isPending}
disabled={!replayAgent}
>
&#8635; Replay
</Button>
</div>
</Modal>
</div>
)
}

View File

@@ -1,74 +0,0 @@
.list {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
border-right: 1px solid var(--border);
background: var(--surface);
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
font-size: 0.8125rem;
transition: background 0.1s;
}
.item:hover {
background: var(--surface-hover);
}
.itemSelected {
background: var(--surface-active);
border-left: 3px solid var(--amber);
padding-left: calc(0.75rem - 3px);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dotOk { background: var(--success); }
.dotErr { background: var(--error); }
.dotRun { background: var(--running); }
.meta {
flex: 1;
min-width: 0;
}
.exchangeId {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
flex-shrink: 0;
}
.timestamp {
font-size: 0.6875rem;
color: var(--text-muted);
flex-shrink: 0;
}
.empty {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}

View File

@@ -1,56 +0,0 @@
import type { ExecutionSummary } from '../../api/types';
import styles from './ExchangeList.module.css';
interface ExchangeListProps {
exchanges: ExecutionSummary[];
selectedId?: string;
onSelect: (exchange: ExecutionSummary) => void;
}
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
return `${ms}ms`;
}
function formatTime(iso: string): string {
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');
return `${h}:${m}:${s}`;
}
function dotClass(status: string): string {
switch (status) {
case 'COMPLETED': return styles.dotOk;
case 'FAILED': return styles.dotErr;
case 'RUNNING': return styles.dotRun;
default: return styles.dotOk;
}
}
export function ExchangeList({ exchanges, selectedId, onSelect }: ExchangeListProps) {
if (exchanges.length === 0) {
return <div className={styles.empty}>No exchanges found</div>;
}
return (
<div className={styles.list}>
{exchanges.map((ex) => (
<div
key={ex.executionId}
className={`${styles.item} ${selectedId === ex.executionId ? styles.itemSelected : ''}`}
onClick={() => onSelect(ex)}
>
<span className={`${styles.dot} ${dotClass(ex.status)}`} />
<div className={styles.meta}>
<div className={styles.exchangeId}>{ex.executionId.slice(0, 12)}</div>
</div>
<span className={styles.duration}>{formatDuration(ex.durationMs)}</span>
<span className={styles.timestamp}>{formatTime(ex.startTime)}</span>
</div>
))}
</div>
);
}