feat: replace UI with design system example pages wired to real API
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled

Migrate all page components from the @cameleer/design-system v0.0.3
example UI, replacing mock data with real backend API hooks. This brings
richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline,
DateRangePicker, expandable rows) while preserving all existing API
integration, auth, and routing infrastructure.

Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail,
AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles).
Also enhanced LayoutShell CommandPalette with real search data from catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 16:42:16 +01:00
parent dafd7adb00
commit 81f85aa82d
23 changed files with 4439 additions and 2542 deletions

View File

@@ -1,10 +1,18 @@
.healthStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Filter bar spacing */
.filterBar {
margin-bottom: 16px;
}
/* Table section */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -39,6 +47,93 @@
font-family: var(--font-mono);
}
/* Status cell */
.statusCell {
display: flex;
align-items: center;
gap: 5px;
}
/* Route cells */
.routeName {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
/* Application column */
.appName {
font-size: 12px;
color: var(--text-secondary);
}
/* Duration color classes */
.durFast {
color: var(--success);
}
.durNormal {
color: var(--text-secondary);
}
.durSlow {
color: var(--warning);
}
.durBreach {
color: var(--error);
}
/* Agent badge in table */
.agentBadge {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.agentDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #5db866;
box-shadow: 0 0 4px rgba(93, 184, 102, 0.4);
flex-shrink: 0;
}
/* Inline error preview below row */
.inlineError {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px;
background: var(--error-bg);
border-left: 3px solid var(--error-border);
}
.inlineErrorIcon {
color: var(--error);
font-size: 14px;
flex-shrink: 0;
margin-top: 1px;
}
.inlineErrorText {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
line-height: 1.4;
}
.inlineErrorHint {
font-size: 10px;
color: var(--text-muted);
margin-top: 3px;
}
/* Detail panel sections */
.panelSection {
padding-bottom: 16px;
margin-bottom: 16px;
@@ -59,19 +154,21 @@
color: var(--text-muted);
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.panelSectionMeta {
font-size: 11px;
font-weight: 400;
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
font-family: var(--font-mono);
color: var(--text-faint);
}
/* Overview grid */
.overviewGrid {
display: flex;
flex-direction: column;
@@ -95,45 +192,67 @@
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;
width: 24px;
height: 24px;
font-size: 14px;
color: var(--text-muted);
transition: color 0.15s, opacity 0.15s;
text-decoration: none;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.inspectLink:hover {
color: var(--accent, #c6820e);
background: var(--bg-hover);
}
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
color: var(--text-primary);
opacity: 1;
}
/* Open full details link in panel */
.openDetailLink {
display: inline-block;
font-size: 13px;
font-weight: 600;
color: var(--accent, #c6820e);
cursor: pointer;
background: none;
background: transparent;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 12px;
padding: 0;
text-decoration: none;
font-family: var(--font-body);
transition: color 0.1s;
}
.openDetailLink:hover {
color: var(--amber-deep);
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -1,186 +1,417 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
StatCard, StatusDot, Badge, MonoText,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock, ShortcutsBar,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { useGlobalFilters } from '@cameleer/design-system';
import type { ExecutionSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './Dashboard.module.css';
DataTable,
DetailPanel,
ShortcutsBar,
ProcessorTimeline,
RouteFlow,
KpiStrip,
StatusDot,
MonoText,
Badge,
useGlobalFilters,
} from '@cameleer/design-system'
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
import {
useSearchExecutions,
useExecutionStats,
useStatsTimeseries,
useExecutionDetail,
} from '../../api/queries/executions'
import { useDiagramLayout } from '../../api/queries/diagrams'
import type { ExecutionSummary } from '../../api/types'
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
import styles from './Dashboard.module.css'
interface Row extends ExecutionSummary { id: string }
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
// Row type extends ExecutionSummary with an `id` field for DataTable
interface Row extends ExecutionSummary {
id: string
}
export default function Dashboard() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
// ─── Helpers ─────────────────────────────────────────────────────────────────
const [selectedId, setSelectedId] = useState<string | null>(null);
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`
}
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
function formatTimestamp(iso: string): string {
const date = new Date(iso)
const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
}
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: searchResult } = useSearchExecutions({
timeFrom, timeTo,
routeId: routeId || undefined,
application: appId || undefined,
offset: 0, limit: 50,
}, true);
const { data: detail } = useExecutionDetail(selectedId);
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'COMPLETED': return 'success'
case 'FAILED': return 'error'
case 'RUNNING': return 'running'
default: return 'warning'
}
}
const rows: Row[] = useMemo(() =>
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
);
function statusLabel(status: string): string {
switch (status) {
case 'COMPLETED': return 'OK'
case 'FAILED': return 'ERR'
case 'RUNNING': return 'RUN'
default: return 'WARN'
}
}
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
function durationClass(ms: number, status: string): string {
if (status === 'FAILED') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
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
}
const sparkExchanges = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
const sparkErrors = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
const sparkLatency = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
const sparkThroughput = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => {
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
}), [timeseries, timeWindowSeconds]);
// ─── Table columns (base, without inspect action) ────────────────────────────
const prevTotal = stats?.prevTotalCount ?? 0;
const prevFailed = stats?.prevFailedCount ?? 0;
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
const successRateDelta = successRate - prevSuccessRate;
const errorDelta = failedCount - prevFailed;
const columns: Column<Row>[] = [
function buildBaseColumns(): Column<Row>[] {
return [
{
key: 'status', header: 'Status', width: '80px',
render: (v, row) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
key: 'status',
header: 'Status',
width: '80px',
render: (_: unknown, row: Row) => (
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
</span>
),
},
{
key: '_inspect' as any, header: '', width: '36px',
render: (_v, row) => (
<a
href={`/exchanges/${row.executionId}`}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
className={styles.inspectLink}
title="Open full details"
>&#x2197;</a>
key: 'routeId',
header: 'Route',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.routeName}>{row.routeId}</span>
),
},
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
{
key: 'durationMs', header: 'Duration', sortable: true,
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
key: 'applicationName',
header: 'Application',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.appName}>{row.applicationName ?? ''}</span>
),
},
{
key: 'agentId', header: 'Agent',
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
key: 'executionId',
header: 'Exchange ID',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{row.executionId}</MonoText>
),
},
];
{
key: 'startTime',
header: 'Started',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
{formatDuration(row.durationMs)}
</MonoText>
),
},
{
key: 'agentId',
header: 'Agent',
render: (_: unknown, row: Row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
{row.agentId}
</span>
),
},
]
}
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '\u2191\u2193', label: 'Navigate rows' },
{ keys: 'Enter', label: 'Open detail' },
{ keys: 'Esc', label: 'Close panel' },
]
// ─── Dashboard component ─────────────────────────────────────────────────────
export default function Dashboard() {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const { timeRange, statusFilters } = useGlobalFilters()
const timeFrom = timeRange.start.toISOString()
const timeTo = timeRange.end.toISOString()
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
// ─── API hooks ───────────────────────────────────────────────────────────
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId)
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId)
const { data: searchResult } = useSearchExecutions(
{
timeFrom,
timeTo,
routeId: routeId || undefined,
application: appId || undefined,
offset: 0,
limit: 50,
},
true,
)
const { data: detail } = useExecutionDetail(selectedId ?? null)
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
// ─── Rows ────────────────────────────────────────────────────────────────
const allRows: Row[] = useMemo(
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
)
// Apply global status filters (time filtering is done server-side via timeFrom/timeTo)
const rows: Row[] = useMemo(() => {
if (statusFilters.size === 0) return allRows
return allRows.filter((r) => statusFilters.has(r.status.toLowerCase() as any))
}, [allRows, statusFilters])
// ─── KPI items ───────────────────────────────────────────────────────────
const totalCount = stats?.totalCount ?? 0
const failedCount = stats?.failedCount ?? 0
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0
const prevTotal = stats?.prevTotalCount ?? 0
const prevFailed = stats?.prevFailedCount ?? 0
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal) * 100 : 0
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal) * 100 : 100
const successRateDelta = successRate - prevSuccessRate
const errorDelta = failedCount - prevFailed
const sparkExchanges = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries],
)
const sparkErrors = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.failedCount as number),
[timeseries],
)
const sparkLatency = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number),
[timeseries],
)
const sparkThroughput = useMemo(
() =>
(timeseries?.buckets || []).map((b: any) => {
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1)
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0
}),
[timeseries, timeWindowSeconds],
)
const kpiItems: KpiItem[] = useMemo(
() => [
{
label: 'Exchanges',
value: totalCount.toLocaleString(),
trend: {
label: `${exchangeTrend > 0 ? '\u2191' : exchangeTrend < 0 ? '\u2193' : '\u2192'} ${exchangeTrend > 0 ? '+' : ''}${exchangeTrend.toFixed(0)}%`,
variant: (exchangeTrend > 0 ? 'success' : exchangeTrend < 0 ? 'error' : 'muted') as 'success' | 'error' | 'muted',
},
subtitle: `${successRate.toFixed(1)}% success rate`,
sparkline: sparkExchanges,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(1)}%`,
trend: {
label: `${successRateDelta >= 0 ? '\u2191' : '\u2193'} ${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`,
variant: (successRateDelta >= 0 ? 'success' : 'error') as 'success' | 'error',
},
subtitle: `${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`,
borderColor: 'var(--success)',
},
{
label: 'Errors',
value: failedCount,
trend: {
label: `${errorDelta > 0 ? '\u2191' : errorDelta < 0 ? '\u2193' : '\u2192'} ${errorDelta > 0 ? '+' : ''}${errorDelta}`,
variant: (errorDelta > 0 ? 'error' : errorDelta < 0 ? 'success' : 'muted') as 'success' | 'error' | 'muted',
},
subtitle: `${failedCount} errors in selected period`,
sparkline: sparkErrors,
borderColor: 'var(--error)',
},
{
label: 'Throughput',
value: `${throughput.toFixed(1)} msg/s`,
trend: { label: '\u2192', variant: 'muted' as const },
subtitle: `${throughput.toFixed(1)} msg/s`,
sparkline: sparkThroughput,
borderColor: 'var(--running)',
},
{
label: 'Latency p99',
value: `${(stats?.p99LatencyMs ?? 0).toLocaleString()} ms`,
trend: { label: '', variant: 'muted' as const },
subtitle: `${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`,
sparkline: sparkLatency,
borderColor: 'var(--warning)',
},
],
[totalCount, failedCount, successRate, throughput, exchangeTrend, successRateDelta, errorDelta, sparkExchanges, sparkErrors, sparkLatency, sparkThroughput, stats?.p99LatencyMs],
)
// ─── Table columns with inspect action ───────────────────────────────────
const columns: Column<Row>[] = useMemo(() => {
const inspectCol: Column<Row> = {
key: 'correlationId',
header: '',
width: '36px',
render: (_: unknown, row: Row) => (
<button
className={styles.inspectLink}
title="Inspect exchange"
onClick={(e) => {
e.stopPropagation()
navigate(`/exchanges/${row.executionId}`)
}}
>
&#x2197;
</button>
),
}
const base = buildBaseColumns()
const [statusCol, ...rest] = base
return [statusCol, inspectCol, ...rest]
}, [navigate])
// ─── Row click / detail panel ────────────────────────────────────────────
const selectedRow = useMemo(
() => rows.find((r) => r.id === selectedId),
[rows, selectedId],
)
function handleRowClick(row: Row) {
setSelectedId(row.id)
setPanelOpen(true)
}
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
if (row.status === 'FAILED') return 'error'
return undefined
}
// ─── Detail panel data ───────────────────────────────────────────────────
const procList = detail
? detail.processors?.length
? detail.processors
: (detail.children ?? [])
: []
const routeNodes: RouteNode[] = useMemo(() => {
if (diagram?.nodes) {
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
}
return []
}, [diagram, procList])
const flatProcs = useMemo(() => flattenProcessors(procList), [procList])
// Error info from detail
const errorClass = detail?.errorMessage?.split(':')[0] ?? ''
const errorMsg = detail?.errorMessage ?? ''
return (
<div>
<div className={styles.healthStrip}>
<StatCard
label="Exchanges"
value={totalCount.toLocaleString()}
detail={`${successRate.toFixed(1)}% success rate`}
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
sparkline={sparkExchanges}
accent="amber"
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
trend={successRateDelta >= 0 ? 'up' : 'down'}
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
accent="success"
/>
<StatCard
label="Errors"
value={failedCount}
detail={`${failedCount} errors in selected period`}
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
sparkline={sparkErrors}
accent="error"
/>
<StatCard
label="Throughput"
value={throughput.toFixed(1)}
detail={`${throughput.toFixed(1)} msg/s`}
sparkline={sparkThroughput}
accent="running"
/>
<StatCard
label="Latency p99"
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
sparkline={sparkLatency}
accent="warning"
/>
</div>
<>
{/* Scrollable content */}
<div className={styles.content}>
{/* KPI strip */}
<KpiStrip items={kpiItems} />
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
<Badge label="LIVE" color="success" />
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
rowAccent={handleRowAccent}
expandedContent={(row: Row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}>{'\u26A0'}</span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</div>
{selectedId && detail && (
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
{/* Detail panel */}
{selectedRow && detail && (
<DetailPanel
key={selectedId}
open={true}
onClose={() => setSelectedId(null)}
title={`${detail.routeId}${selectedId.slice(0, 12)}`}
className={styles.detailPanelOverride}
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
>
{/* Open full details link */}
{/* Link to full detail page */}
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
@@ -196,9 +427,9 @@ export default function Dashboard() {
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
<span>{detail.status}</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(detail.status)} />
<span>{statusLabel(detail.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
@@ -211,44 +442,38 @@ export default function Dashboard() {
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{detail.agentId ?? ''}</MonoText>
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{detail.correlationId ?? ''}</MonoText>
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : ''}</MonoText>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
</div>
</div>
</div>
{/* Errors */}
{detail.errorMessage && (
{errorMsg && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div>
<Alert variant="error">
<strong>{detail.errorMessage.split(':')[0]}</strong>
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
</Alert>
{detail.errorStackTrace && (
<Collapsible title="Stack Trace">
<CodeBlock content={detail.errorStackTrace} />
</Collapsible>
)}
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{errorClass}</div>
<div className={styles.errorMessage}>{errorMsg}</div>
</div>
</div>
)}
{/* Route Flow */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{diagram ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, _i) => {}}
/>
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
{routeNodes.length > 0 ? (
<RouteFlow nodes={routeNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}
</div>
{/* Processor Timeline */}
@@ -257,33 +482,17 @@ export default function Dashboard() {
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
</div>
{procList.length ? (
{flatProcs.length > 0 ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
processors={flatProcs}
totalMs={detail.durationMs}
/>
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
)}
</div>
</DetailPanel>
)}
</div>
);
}
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;
</>
)
}