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,3 +1,21 @@
/* 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);
@@ -38,14 +56,14 @@
}
.routeLink {
color: var(--accent, #c6820e);
color: var(--amber);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.routeLink:hover {
color: var(--amber-deep, #a36b0b);
color: var(--amber-deep);
}
.headerDivider {
@@ -78,7 +96,9 @@
color: var(--text-primary);
}
/* Correlation Chain */
/* ==========================================================================
CORRELATION CHAIN
========================================================================== */
.correlationChain {
display: flex;
flex-direction: row;
@@ -104,7 +124,7 @@
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm, 4px);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-family: var(--font-mono);
@@ -120,20 +140,37 @@
}
.chainNodeCurrent {
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
border-color: var(--accent, #c6820e);
color: var(--accent, #c6820e);
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); }
.chainNodeSuccess {
border-left: 3px solid var(--success);
}
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
.chainNodeError {
border-left: 3px solid var(--error);
}
/* Timeline Section */
.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;
}
/* ==========================================================================
TIMELINE SECTION
========================================================================== */
.timelineSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -174,7 +211,7 @@
display: inline-flex;
gap: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm, 4px);
border-radius: var(--radius-sm);
overflow: hidden;
}
@@ -194,20 +231,22 @@
}
.toggleBtnActive {
background: var(--accent, #c6820e);
background: var(--amber);
color: #fff;
font-weight: 600;
}
.toggleBtnActive:hover {
background: var(--amber-deep, #a36b0b);
background: var(--amber-deep);
}
.timelineBody {
padding: 12px 16px;
}
/* Detail Split (IN / OUT panels) */
/* ==========================================================================
DETAIL SPLIT (IN / OUT panels)
========================================================================== */
.detailSplit {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -224,7 +263,7 @@
}
.detailPanelError {
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
border-color: var(--error-border);
}
.panelHeader {
@@ -238,8 +277,8 @@
}
.detailPanelError .panelHeader {
background: var(--error-bg, rgba(220, 38, 38, 0.06));
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
background: var(--error-bg);
border-bottom-color: var(--error-border);
}
.panelTitle {
@@ -350,14 +389,33 @@
}
/* 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, rgba(220, 38, 38, 0.06));
background: var(--error-bg);
padding: 10px 12px;
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
border-radius: var(--radius-sm);
border: 1px solid var(--error-border);
margin-bottom: 12px;
line-height: 1.5;
word-break: break-word;
@@ -382,3 +440,11 @@
font-family: var(--font-mono);
word-break: break-all;
}
/* Snapshot loading */
.snapshotLoading {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}

View File

@@ -1,112 +1,187 @@
import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
} from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useCorrelationChain } from '../../api/queries/correlation';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './ExchangeDetail.module.css';
} from '@cameleer/design-system'
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation'
import { useDiagramLayout } from '../../api/queries/diagrams'
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
import styles from './ExchangeDetail.module.css'
function countProcessors(nodes: any[]): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
// ── 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 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 {};
if (!raw) return {}
try {
const parsed = JSON.parse(raw);
const parsed = JSON.parse(raw)
if (typeof parsed === 'object' && parsed !== null) {
const result: Record<string, string> = {};
const result: Record<string, string> = {}
for (const [k, v] of Object.entries(parsed)) {
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
}
return result;
return result
}
} catch { /* ignore */ }
return {};
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();
const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
// Auto-select first failed processor, or 0
const defaultIndex = useMemo(() => {
if (!procList.length) return 0;
const failIdx = procList.findIndex((p: any) =>
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
);
return failIdx >= 0 ? failIdx : 0;
}, [procList]);
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
const activeIndex = selectedProcessorIndex ?? defaultIndex;
const procList = detail
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
: []
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
const processors = useMemo(() => {
if (!procList.length) return [];
const result: any[] = [];
let offset = 0;
// Flatten processor tree into ProcessorStep[]
const processors: ProcessorStep[] = useMemo(() => {
if (!procList.length) return []
const result: ProcessorStep[] = []
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',
status: procStatusToStep(node.status ?? ''),
startMs: offset,
});
offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk);
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
procList.forEach(walk);
return result;
}, [procList]);
procList.forEach(walk)
return result
}, [procList])
const selectedProc = processors[activeIndex];
const isSelectedFailed = selectedProc?.status === 'fail';
// 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])
// Parse snapshot headers
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
const inputBody = snapshot?.inputBody ?? null;
const outputBody = snapshot?.outputBody ?? null;
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
const activeIndex = selectedProcessorIndex ?? defaultIndex
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
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
// Build RouteFlow nodes from diagram + execution data
const routeNodes: RouteNode[] = useMemo(() => {
if (diagram?.nodes) {
return mapDiagramToRouteNodes(diagram.nodes, procList)
}
// Fallback: build from processor list
return processors.map((p) => ({
name: p.name,
type: 'process' as RouteNode['type'],
durationMs: p.durationMs,
status: p.status,
}))
}, [diagram, processors, procList])
// Correlation chain
const correlatedExchanges = useMemo(() => {
if (!correlationData?.data || correlationData.data.length <= 1) return []
return correlationData.data
}, [correlationData])
// ── Loading state ────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spinner size="lg" />
</div>
)
}
// ── Not found state ──────────────────────────────────────────────────────
if (!detail) {
return (
<div className={styles.content}>
<Breadcrumb items={[
{ label: 'Applications', href: '/apps' },
{ label: 'Exchanges' },
{ label: id ?? 'Unknown' },
]} />
<InfoCallout variant="warning">Exchange &quot;{id}&quot; not found.</InfoCallout>
</div>
)
}
const statusVariant = backendStatusToVariant(detail.status)
const statusLabel = backendStatusToLabel(detail.status)
return (
<div>
<div className={styles.content}>
{/* Breadcrumb */}
<Breadcrumb items={[
{ label: 'Dashboard', href: '/apps' },
{ label: 'Applications', href: '/apps' },
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
{ label: id?.slice(0, 12) || '' },
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
{ label: detail.executionId?.slice(0, 12) || '' },
]} />
{/* Exchange header card */}
<div className={styles.exchangeHeader}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<StatusDot variant={statusVariant} />
<div>
<div className={styles.exchangeId}>
<MonoText size="md">{id}</MonoText>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
<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>
@@ -116,6 +191,12 @@ export default function ExchangeDetail() {
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>
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
{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}>
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
</div>
{/* Correlation Chain */}
{correlationData?.data && correlationData.data.length > 1 && (
{correlatedExchanges.length > 1 && (
<div className={styles.correlationChain}>
<span className={styles.chainLabel}>Correlated Exchanges</span>
{correlationData.data.map((exec: any) => {
const isCurrent = exec.executionId === id;
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
{correlatedExchanges.map((ce) => {
const isCurrent = ce.executionId === id
const variant = backendStatusToVariant(ce.status)
const statusCls =
variant === 'success' ? styles.chainNodeSuccess
: variant === 'error' ? styles.chainNodeError
: styles.chainNodeRunning;
: variant === 'running' ? styles.chainNodeRunning
: styles.chainNodeWarning
return (
<button
key={exec.executionId}
key={ce.executionId}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
title={`${exec.executionId}${exec.routeId}`}
onClick={() => {
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
}}
title={`${ce.executionId} \u2014 ${ce.routeId}`}
>
<StatusDot variant={variant as any} />
<span>{exec.routeId}</span>
<StatusDot variant={variant} />
<span>{ce.routeId}</span>
</button>
);
)
})}
{correlationData.total > 20 && (
{correlationData && correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)}
</div>
)}
</div>
{/* Error callout */}
{detail.errorMessage && (
<InfoCallout variant="error">
{detail.errorMessage}
</InfoCallout>
)}
{/* Processor Timeline / Flow Section */}
{/* Processor Timeline Section */}
<div className={styles.timelineSection}>
<div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)
) : (
diagram ? (
routeNodes.length > 0 ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={activeIndex}
/>
) : (
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
</div>
</div>
{/* Processor Detail: Message IN / Message OUT or Error */}
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshot && (
<div className={styles.detailSplit}>
{/* Message IN */}
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={inputBody ?? 'null'} />
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={outputBody ?? 'null'} />
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
</div>
</div>
</div>
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
</div>
)}
{/* No snapshot loaded yet - show prompt */}
{/* Snapshot loading indicator */}
{selectedProc && !snapshot && procList.length > 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
<div className={styles.snapshotLoading}>
Loading exchange snapshot...
</div>
)}
</div>
);
)
}