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,39 +1,288 @@
/* Back link */
.backLink {
font-size: 13px;
color: var(--text-muted);
text-decoration: none;
margin-bottom: 12px;
display: inline-block;
}
.backLink:hover {
color: var(--text-primary);
}
/* Route header card */
.headerCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
padding: 16px; margin-bottom: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.headerLeft { display: flex; align-items: center; gap: 12px; }
.headerRight { display: flex; gap: 20px; }
.headerStat { text-align: center; }
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.diagramPane, .statsPane {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
.headerRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
.tabSection { margin-top: 20px; }
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.headerRight {
display: flex;
gap: 20px;
}
.headerStat {
text-align: center;
}
.headerStatLabel {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 2px;
}
.headerStatValue {
font-size: 14px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
}
/* Diagram + Stats side-by-side */
.diagramStatsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.diagramPane,
.statsPane {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.paneTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
/* Processor type badges */
.processorType {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.typeConsumer {
background: var(--running-bg);
color: var(--running);
}
.typeProducer {
background: var(--success-bg);
color: var(--success);
}
.typeEnricher {
background: var(--amber-bg);
color: var(--amber);
}
.typeValidator {
background: var(--running-bg);
color: var(--running);
}
.typeTransformer {
background: var(--bg-hover);
color: var(--text-muted);
}
.typeRouter {
background: var(--purple-bg);
color: var(--purple);
}
.typeProcessor {
background: var(--bg-hover);
color: var(--text-secondary);
}
/* Tabs section */
.tabSection {
margin-top: 20px;
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Table section (reused for processor table) */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; }
.chartTitle {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 12px;
}
/* Executions table */
.executionsTable {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.errorPatterns { display: flex; flex-direction: column; gap: 8px; }
/* Error patterns */
.errorPatterns {
display: flex;
flex-direction: column;
gap: 8px;
}
.errorRow {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
font-size: 12px;
}
.errorMessage {
flex: 1;
font-family: var(--font-mono);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.errorCount {
font-weight: 700;
color: var(--error);
margin: 0 12px;
}
.errorTime {
color: var(--text-muted);
font-size: 11px;
}
/* Route flow section */
.routeFlowSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
margin-top: 16px;
}
/* Empty / muted text */
.emptyText {
color: var(--text-muted);
font-size: 13px;
padding: 8px 0;
}
.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; }
.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; }
.errorTime { color: var(--text-muted); font-size: 11px; }
.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; }
.backLink:hover { color: var(--text-primary); }

View File

@@ -1,20 +1,31 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
Badge, StatusDot, DataTable, Tabs,
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
KpiStrip,
Badge,
StatusDot,
DataTable,
Tabs,
AreaChart,
LineChart,
BarChart,
RouteFlow,
Spinner,
MonoText,
Sparkline,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions';
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css';
// ── Row types ────────────────────────────────────────────────────────────────
interface ExchangeRow extends ExecutionSummary {
id: string;
}
@@ -26,6 +37,8 @@ interface ProcessorRow {
avgDurationMs: number;
p99DurationMs: number;
errorCount: number;
errorRate: number;
sparkline: number[];
}
interface ErrorPattern {
@@ -34,6 +47,211 @@ interface ErrorPattern {
lastSeen: string;
}
// ── Processor type badge classes ─────────────────────────────────────────────
const TYPE_STYLE_MAP: Record<string, string> = {
consumer: styles.typeConsumer,
producer: styles.typeProducer,
enricher: styles.typeEnricher,
validator: styles.typeValidator,
transformer: styles.typeTransformer,
router: styles.typeRouter,
processor: styles.typeProcessor,
};
function classifyProcessorType(processorId: string): string {
const lower = processorId.toLowerCase();
if (lower.startsWith('from(') || lower.includes('consumer')) return 'consumer';
if (lower.startsWith('to(')) return 'producer';
if (lower.includes('enrich')) return 'enricher';
if (lower.includes('validate') || lower.includes('check')) return 'validator';
if (lower.includes('unmarshal') || lower.includes('marshal')) return 'transformer';
if (lower.includes('route') || lower.includes('choice')) return 'router';
return 'processor';
}
// ── Processor table columns ──────────────────────────────────────────────────
function makeProcessorColumns(css: typeof styles): Column<ProcessorRow>[] {
return [
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => (
<span className={css.routeNameCell}>{row.processorId}</span>
),
},
{
key: 'callCount',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.callCount.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => {
const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.avgDurationMs)}ms</MonoText>;
},
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? css.rateBad : css.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const cls = row.errorRate > 1 ? css.rateBad : row.errorRate > 0.5 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{row.errorRate.toFixed(2)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
}
// ── Exchange table columns ───────────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<ExchangeRow>[] = [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_, row) => (
<StatusDot variant={row.status === 'COMPLETED' ? 'success' : row.status === 'FAILED' ? 'error' : 'running'} />
),
},
{
key: 'executionId',
header: 'Exchange ID',
render: (_, row) => <MonoText size="xs">{row.executionId.slice(0, 12)}</MonoText>,
},
{
key: 'startTime',
header: 'Started',
sortable: true,
render: (_, row) => new Date(row.startTime).toLocaleTimeString(),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_, row) => `${row.durationMs}ms`,
},
];
// ── Build KPI items ──────────────────────────────────────────────────────────
function buildDetailKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
throughputSparkline: number[],
errorSparkline: number[],
latencySparkline: number[],
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const activeCount = stats?.activeCount ?? 0;
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const throughputPctChange = prevTotalCount > 0
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
: 0;
return [
{
label: 'Total Throughput',
value: totalCount.toLocaleString(),
trend: {
label: throughputPctChange >= 0 ? `\u25B2 +${throughputPctChange}%` : `\u25BC ${throughputPctChange}%`,
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
},
subtitle: `${activeCount} in-flight`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'System Error Rate',
value: `${errorRate.toFixed(2)}%`,
trend: {
label: errorRate < 1 ? '\u25BC low' : `\u25B2 ${errorRate.toFixed(1)}%`,
variant: errorRate < 1 ? 'success' as const : 'error' as const,
},
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
sparkline: errorSparkline,
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
},
{
label: 'Latency P99',
value: `${p99Ms}ms`,
trend: {
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
},
subtitle: `Avg ${avgMs}ms \u00B7 SLA <300ms`,
sparkline: latencySparkline,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(1)}%`,
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${totalCount - failedCount} ok / ${failedCount} failed`,
borderColor: 'var(--success)',
},
{
label: 'In-Flight',
value: String(activeCount),
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${activeCount} active exchanges`,
borderColor: 'var(--amber)',
},
];
}
// ── Component ────────────────────────────────────────────────────────────────
export default function RouteDetail() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
@@ -43,9 +261,11 @@ export default function RouteDetail() {
const [activeTab, setActiveTab] = useState('performance');
// ── API queries ────────────────────────────────────────────────────────────
const { data: catalog } = useRouteCatalog();
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
timeFrom,
@@ -65,6 +285,8 @@ export default function RouteDetail() {
limit: 200,
});
// ── Derived data ───────────────────────────────────────────────────────────
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
[catalog, appId],
@@ -79,7 +301,7 @@ export default function RouteDetail() {
const exchangeCount = routeSummary?.exchangeCount ?? 0;
const lastSeen = routeSummary?.lastSeen
? new Date(routeSummary.lastSeen).toLocaleString()
: '';
: '\u2014';
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
const h = health.toLowerCase();
@@ -89,39 +311,70 @@ export default function RouteDetail() {
return 'dead';
}, [health]);
// Route flow from diagram
const diagramNodes = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
}, [diagram]);
// Processor table rows
const processorRows: ProcessorRow[] = useMemo(() =>
(processorMetrics || []).map((p: any) => ({
id: p.processorId,
processorId: p.processorId,
callCount: p.callCount ?? 0,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount: p.errorCount ?? 0,
})),
(processorMetrics || []).map((p: any) => {
const callCount = p.callCount ?? 0;
const errorCount = p.errorCount ?? 0;
const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
return {
id: p.processorId,
processorId: p.processorId,
type: classifyProcessorType(p.processorId ?? ''),
callCount,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount,
errorRate: Number(errRate.toFixed(2)),
sparkline: p.sparkline ?? [],
};
}),
[processorMetrics],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
})),
// Timeseries-derived data
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const errorSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.failedCount),
[timeseries],
);
const latencySparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
[timeseries],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
return {
time: !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '\u2014',
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
}),
[timeseries],
);
// Exchange rows
const exchangeRows: ExchangeRow[] = useMemo(() =>
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[recentResult],
);
// Error patterns
const errorPatterns: ErrorPattern[] = useMemo(() => {
const failed = (errorResult?.data || []) as ExecutionSummary[];
const grouped = new Map<string, { count: number; lastSeen: string }>();
@@ -141,31 +394,18 @@ export default function RouteDetail() {
.map(([message, { count, lastSeen: ls }]) => ({
message,
count,
lastSeen: ls ? new Date(ls).toLocaleString() : '',
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
}))
.sort((a, b) => b.count - a.count);
}, [errorResult]);
const processorColumns: Column<ProcessorRow>[] = [
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'callCount', header: 'Calls', sortable: true },
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
const n = v as number;
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
}},
];
// KPI items
const kpiItems = useMemo(() =>
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
[stats, throughputSparkline, errorSparkline, latencySparkline],
);
const exchangeColumns: Column<ExchangeRow>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{ key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` },
];
const processorColumns = useMemo(() => makeProcessorColumns(styles), []);
const tabs = [
{ label: 'Performance', value: 'performance' },
@@ -173,12 +413,15 @@ export default function RouteDetail() {
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
];
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div>
<Link to={`/routes/${appId}`} className={styles.backLink}>
{appId} routes
&larr; {appId} routes
</Link>
{/* Route header card */}
<div className={styles.headerCard}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
@@ -199,13 +442,17 @@ export default function RouteDetail() {
</div>
</div>
{/* KPI strip */}
<KpiStrip items={kpiItems} />
{/* Diagram + Processor Stats grid */}
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No diagram available for this route.
</div>
)}
@@ -217,13 +464,40 @@ export default function RouteDetail() {
) : processorRows.length > 0 ? (
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No processor data available.
</div>
)}
</div>
</div>
{/* Processor Performance table (full width) */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorRows.length} processors</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={processorColumns}
data={processorRows}
sortable
/>
</div>
{/* Route Flow section */}
{diagramNodes.length > 0 && (
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={diagramNodes} />
</div>
)}
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
<div className={styles.tabSection}>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
@@ -232,28 +506,41 @@ export default function RouteDetail() {
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<AreaChart
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
series={[{
label: 'Throughput',
data: chartData.map((d, i) => ({ x: i, y: d.throughput })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d, i) => ({ x: i, y: d.latency })) }]}
series={[{
label: 'Latency',
data: chartData.map((d, i) => ({ x: i, y: d.latency })),
}]}
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<BarChart
series={[{ label: 'Errors', data: chartData.map((d) => ({ x: d.time, y: d.errors })) }]}
series={[{
label: 'Errors',
data: chartData.map((d) => ({ x: d.time, y: d.errors })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<AreaChart
series={[{ label: 'Success Rate', data: chartData.map((d, i) => ({ x: i, y: d.successRate })) }]}
series={[{
label: 'Success Rate',
data: chartData.map((d, i) => ({ x: i, y: d.successRate })),
}]}
height={200}
/>
</div>
@@ -268,7 +555,7 @@ export default function RouteDetail() {
</div>
) : (
<DataTable
columns={exchangeColumns}
columns={EXCHANGE_COLUMNS}
data={exchangeRows}
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
sortable
@@ -281,7 +568,7 @@ export default function RouteDetail() {
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
<div className={styles.emptyText}>
No error patterns found in the selected time range.
</div>
) : (

View File

@@ -1,17 +1,44 @@
.statStrip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 16px;
/* Scrollable content area */
.content {
display: flex;
flex-direction: column;
gap: 20px;
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route performance table */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px;
}
.tableHeader {
@@ -28,36 +55,56 @@
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route name in table */
.routeNameCell {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
}
/* Application column */
.appCell {
font-size: 12px;
color: var(--text-secondary);
}
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* 2x2 chart grid */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
.chart {
width: 100%;
}
.chartTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }

View File

@@ -1,13 +1,21 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { useParams, useNavigate } from 'react-router';
import {
StatCard, Sparkline, MonoText, Badge,
DataTable, AreaChart, LineChart, BarChart,
KpiStrip,
DataTable,
AreaChart,
LineChart,
BarChart,
Card,
Sparkline,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
import type { RouteMetrics } from '../../api/types';
import styles from './RoutesMetrics.module.css';
interface RouteRow {
@@ -23,186 +31,322 @@ interface RouteRow {
sparkline: number[];
}
// ── Route table columns ──────────────────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteRow>[] = [
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeId}</span>
),
},
{
key: 'appId',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appCell}>{row.appId}</span>
),
},
{
key: 'exchangeCount',
header: 'Exchanges',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}ms</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Build KPI items from backend stats ───────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
routeCount: number,
throughputSparkline: number[],
errorSparkline: number[],
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const activeCount = stats?.activeCount ?? 0;
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
const throughputPctChange = prevTotalCount > 0
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
: 0;
const throughputTrendLabel = throughputPctChange >= 0
? `\u25B2 +${throughputPctChange}%`
: `\u25BC ${throughputPctChange}%`;
const p50 = Math.round(avgMs * 0.5);
const p95 = Math.round(avgMs * 1.4);
const slaStatus = p99Ms > 300 ? 'BREACH' : 'OK';
const prevErrorRate = prevTotalCount > 0
? ((stats?.prevFailedCount ?? 0) / prevTotalCount) * 100
: 0;
const errorDelta = (errorRate - prevErrorRate).toFixed(1);
return [
{
label: 'Total Throughput',
value: totalCount.toLocaleString(),
trend: {
label: throughputTrendLabel,
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
},
subtitle: `${activeCount} active exchanges`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'System Error Rate',
value: `${errorRate.toFixed(2)}%`,
trend: {
label: errorRate <= prevErrorRate ? `\u25BC ${errorDelta}%` : `\u25B2 +${errorDelta}%`,
variant: errorRate < 1 ? 'success' as const : 'error' as const,
},
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
sparkline: errorSparkline,
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
},
{
label: 'Latency Percentiles',
value: `${p99Ms}ms`,
trend: {
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
},
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'Active Routes',
value: `${routeCount}`,
trend: { label: '\u2194 stable', variant: 'muted' as const },
subtitle: `${routeCount} routes reporting`,
borderColor: 'var(--running)',
},
{
label: 'In-Flight Exchanges',
value: String(activeCount),
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${activeCount} active`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
];
}
// ── Component ────────────────────────────────────────────────────────────────
export default function RoutesMetrics() {
const { appId, routeId } = useParams();
const { appId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
// Map backend RouteMetrics[] to table rows
const rows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: any) => ({
(metrics || []).map((m: RouteMetrics) => ({
id: `${m.appId}/${m.routeId}`,
...m,
routeId: m.routeId,
appId: m.appId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
errorRate: m.errorRate,
throughputPerSec: m.throughputPerSec,
sparkline: m.sparkline ?? [],
})),
[metrics],
);
const sparklineData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
// Sparkline data from timeseries buckets
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const errorSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.failedCount),
[timeseries],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any, i: number) => {
const ts = b.timestamp ? new Date(b.timestamp) : null;
const time = ts && !isNaN(ts.getTime())
// Chart series from timeseries buckets
const throughputChartSeries = useMemo(() => [{
label: 'Throughput',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const latencyChartSeries = useMemo(() => [{
label: 'Latency',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.avgDurationMs,
})),
}], [timeseries]);
const errorBarSeries = useMemo(() => [{
label: 'Errors',
data: (timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
const label = !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: String(i);
return {
time,
throughput: b.totalCount ?? 0,
latency: b.avgDurationMs ?? 0,
errors: b.failedCount ?? 0,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
: '—';
return { x: label, y: b.failedCount };
}),
[timeseries],
}], [timeseries]);
const volumeChartSeries = useMemo(() => [{
label: 'Volume',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const kpiItems = useMemo(() =>
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
[stats, rows.length, throughputSparkline, errorSparkline],
);
const columns: Column<RouteRow>[] = [
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
{
key: 'successRate', header: 'Success', sortable: true,
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
},
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{
key: 'errorRate', header: 'Error Rate', sortable: true,
render: (v) => {
const rate = v as number;
const cls = rate > 0.05 ? styles.rateBad : rate > 0.01 ? styles.rateWarn : styles.rateGood;
return <span className={cls}>{(rate * 100).toFixed(1)}%</span>;
},
},
{
key: 'sparkline', header: 'Trend', width: '80px',
render: (v) => <Sparkline data={v as number[]} />,
},
];
const errorRate = stats?.totalCount
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
: 0;
const prevErrorRate = stats?.prevTotalCount
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
: 0;
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
const errorTrendValue = stats?.prevTotalCount
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
: undefined;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
const totalCount = stats?.totalCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
const throughputTrendValue = prevTotalCount
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
: undefined;
const successRate = stats?.totalCount
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
: 100;
const activeCount = stats?.activeCount ?? 0;
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
return (
<div>
<div className={styles.statStrip}>
<StatCard
label="Total Throughput"
value={totalCount.toLocaleString()}
detail="exchanges"
trend={throughputTrend}
trendValue={throughputTrendValue}
accent="amber"
sparkline={sparklineData}
/>
<StatCard
label="System Error Rate"
value={`${errorRate.toFixed(2)}%`}
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
trend={errorTrend}
trendValue={errorTrendValue}
accent={errorRate < 1 ? 'success' : 'error'}
sparkline={errorSparkline}
/>
<StatCard
label="P99 Latency"
value={`${p99Ms}ms`}
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
trend={latencyTrend}
trendValue={latencyTrendValue}
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
sparkline={latencySparkline}
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${activeCount} active routes`}
accent="success"
sparkline={sparklineData.map((v, i) => {
const failed = errorSparkline[i] ?? 0;
return v > 0 ? ((v - failed) / v) * 100 : 100;
})}
/>
<StatCard
label="In-Flight"
value={activeCount}
detail="active exchanges"
accent="amber"
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<span className={styles.tableMeta}>{rows.length} routes</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={columns}
columns={ROUTE_COLUMNS}
data={rows}
sortable
pageSize={20}
onRowClick={(row) => {
const targetAppId = appId ?? row.appId;
navigate(`/routes/${targetAppId}/${row.routeId}`);
}}
/>
</div>
{chartData.length > 0 && (
{/* 2x2 chart grid */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
yLabel="ms"
<Card title="Throughput (msg/s)">
<AreaChart
series={throughputChartSeries}
yLabel="msg/s"
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
</div>
</Card>
<Card title="Latency (ms)">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Errors by Route">
<BarChart
series={errorBarSeries}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Message Volume (msg/min)">
<AreaChart
series={volumeChartSeries}
yLabel="msg/min"
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
</div>