2026-03-23 18:25:58 +01:00
|
|
|
import { useState, useMemo } from 'react';
|
|
|
|
|
import { useParams, useNavigate, Link } from 'react-router';
|
|
|
|
|
import {
|
|
|
|
|
Badge, StatusDot, DataTable, Tabs,
|
|
|
|
|
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
|
|
|
|
|
MonoText,
|
|
|
|
|
} from '@cameleer/design-system';
|
|
|
|
|
import type { 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 type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
|
|
|
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
|
|
|
|
import styles from './RouteDetail.module.css';
|
|
|
|
|
|
|
|
|
|
interface ExchangeRow extends ExecutionSummary {
|
|
|
|
|
id: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProcessorRow {
|
|
|
|
|
id: string;
|
|
|
|
|
processorId: string;
|
|
|
|
|
callCount: number;
|
|
|
|
|
avgDurationMs: number;
|
|
|
|
|
p99DurationMs: number;
|
|
|
|
|
errorCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ErrorPattern {
|
|
|
|
|
message: string;
|
|
|
|
|
count: number;
|
|
|
|
|
lastSeen: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function RouteDetail() {
|
|
|
|
|
const { appId, routeId } = useParams();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { timeRange } = useGlobalFilters();
|
|
|
|
|
const timeFrom = timeRange.start.toISOString();
|
|
|
|
|
const timeTo = timeRange.end.toISOString();
|
|
|
|
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState('performance');
|
|
|
|
|
|
|
|
|
|
const { data: catalog } = useRouteCatalog();
|
|
|
|
|
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
|
|
|
|
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
|
|
|
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
|
|
|
|
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
|
|
|
|
timeFrom,
|
|
|
|
|
timeTo,
|
|
|
|
|
routeId: routeId || undefined,
|
refactor: rename group/groupName to application/applicationName
The execution-related "group" concept actually represents the
application name. Rename all Java fields, API parameters, and frontend
types from groupName→applicationName and group→application for clarity.
- Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument,
ExecutionRecord, ProcessorRecord
- API params: SearchRequest.group→application, SearchController
@RequestParam group→application
- Services: IngestionService, DetailService, SearchIndexer, StatsStore
- Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail,
executions query hooks
Database column names (group_name) and OpenSearch field names are
unchanged — only the API-facing Java/TS field names are renamed.
RBAC group references (groups table, GroupRepository, GroupsTab) are
a separate domain concept and are NOT affected by this change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:38 +01:00
|
|
|
application: appId || undefined,
|
2026-03-23 18:25:58 +01:00
|
|
|
offset: 0,
|
|
|
|
|
limit: 50,
|
|
|
|
|
});
|
|
|
|
|
const { data: errorResult } = useSearchExecutions({
|
|
|
|
|
timeFrom,
|
|
|
|
|
timeTo,
|
|
|
|
|
routeId: routeId || undefined,
|
refactor: rename group/groupName to application/applicationName
The execution-related "group" concept actually represents the
application name. Rename all Java fields, API parameters, and frontend
types from groupName→applicationName and group→application for clarity.
- Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument,
ExecutionRecord, ProcessorRecord
- API params: SearchRequest.group→application, SearchController
@RequestParam group→application
- Services: IngestionService, DetailService, SearchIndexer, StatsStore
- Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail,
executions query hooks
Database column names (group_name) and OpenSearch field names are
unchanged — only the API-facing Java/TS field names are renamed.
RBAC group references (groups table, GroupRepository, GroupsTab) are
a separate domain concept and are NOT affected by this change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:38 +01:00
|
|
|
application: appId || undefined,
|
2026-03-23 18:25:58 +01:00
|
|
|
status: 'FAILED',
|
|
|
|
|
offset: 0,
|
|
|
|
|
limit: 200,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
|
|
|
|
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
|
|
|
|
[catalog, appId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const routeSummary: RouteSummary | undefined = useMemo(() =>
|
|
|
|
|
appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId),
|
|
|
|
|
[appEntry, routeId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const health = appEntry?.health ?? 'unknown';
|
|
|
|
|
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
|
|
|
|
const lastSeen = routeSummary?.lastSeen
|
|
|
|
|
? new Date(routeSummary.lastSeen).toLocaleString()
|
|
|
|
|
: '—';
|
|
|
|
|
|
|
|
|
|
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
|
|
|
|
const h = health.toLowerCase();
|
|
|
|
|
if (h === 'healthy') return 'success';
|
|
|
|
|
if (h === 'degraded') return 'warning';
|
|
|
|
|
if (h === 'unhealthy') return 'error';
|
|
|
|
|
return 'dead';
|
|
|
|
|
}, [health]);
|
|
|
|
|
|
|
|
|
|
const diagramNodes = useMemo(() => {
|
|
|
|
|
if (!diagram?.nodes) return [];
|
|
|
|
|
return mapDiagramToRouteNodes(diagram.nodes, []);
|
|
|
|
|
}, [diagram]);
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
|
|
|
|
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
|
|
|
|
[recentResult],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
|
|
|
|
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
|
|
|
|
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
|
|
|
|
for (const ex of failed) {
|
|
|
|
|
const msg = ex.errorMessage || 'Unknown error';
|
|
|
|
|
const existing = grouped.get(msg);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
grouped.set(msg, { count: 1, lastSeen: ex.startTime ?? '' });
|
|
|
|
|
} else {
|
|
|
|
|
existing.count += 1;
|
|
|
|
|
if ((ex.startTime ?? '') > existing.lastSeen) {
|
|
|
|
|
existing.lastSeen = ex.startTime ?? '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Array.from(grouped.entries())
|
|
|
|
|
.map(([message, { count, lastSeen: ls }]) => ({
|
|
|
|
|
message,
|
|
|
|
|
count,
|
|
|
|
|
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
|
|
|
|
|
}))
|
|
|
|
|
.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>;
|
|
|
|
|
}},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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 tabs = [
|
|
|
|
|
{ label: 'Performance', value: 'performance' },
|
|
|
|
|
{ label: 'Recent Executions', value: 'executions', count: exchangeRows.length },
|
|
|
|
|
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
|
|
|
|
← {appId} routes
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className={styles.headerCard}>
|
|
|
|
|
<div className={styles.headerRow}>
|
|
|
|
|
<div className={styles.headerLeft}>
|
|
|
|
|
<StatusDot variant={healthVariant} />
|
|
|
|
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{routeId}</h2>
|
|
|
|
|
<Badge label={appId ?? ''} color="auto" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.headerRight}>
|
|
|
|
|
<div className={styles.headerStat}>
|
|
|
|
|
<div className={styles.headerStatLabel}>Exchanges</div>
|
|
|
|
|
<div className={styles.headerStatValue}>{exchangeCount.toLocaleString()}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.headerStat}>
|
|
|
|
|
<div className={styles.headerStatLabel}>Last Seen</div>
|
|
|
|
|
<div className={styles.headerStatValue}>{lastSeen}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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' }}>
|
|
|
|
|
No diagram available for this route.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.statsPane}>
|
|
|
|
|
<div className={styles.paneTitle}>Processor Stats</div>
|
|
|
|
|
{processorLoading ? (
|
|
|
|
|
<Spinner size="sm" />
|
|
|
|
|
) : processorRows.length > 0 ? (
|
|
|
|
|
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
|
|
|
|
No processor data available.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.tabSection}>
|
|
|
|
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
|
|
|
|
|
|
|
|
|
{activeTab === 'performance' && (
|
|
|
|
|
<div className={styles.chartGrid} style={{ marginTop: 16 }}>
|
|
|
|
|
<div className={styles.chartCard}>
|
|
|
|
|
<div className={styles.chartTitle}>Throughput</div>
|
|
|
|
|
<AreaChart
|
|
|
|
|
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 })) }]}
|
|
|
|
|
height={200}
|
|
|
|
|
/>
|
|
|
|
|
</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 })) }]}
|
|
|
|
|
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 })) }]}
|
|
|
|
|
height={200}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === 'executions' && (
|
|
|
|
|
<div className={styles.executionsTable} style={{ marginTop: 16 }}>
|
|
|
|
|
{recentLoading ? (
|
|
|
|
|
<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}>
|
|
|
|
|
<Spinner size="sm" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<DataTable
|
|
|
|
|
columns={exchangeColumns}
|
|
|
|
|
data={exchangeRows}
|
|
|
|
|
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
|
|
|
|
sortable
|
|
|
|
|
pageSize={20}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === 'errors' && (
|
|
|
|
|
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
|
|
|
|
{errorPatterns.length === 0 ? (
|
|
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
|
|
|
|
No error patterns found in the selected time range.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
errorPatterns.map((ep, i) => (
|
|
|
|
|
<div key={i} className={styles.errorRow}>
|
|
|
|
|
<span className={styles.errorMessage} title={ep.message}>{ep.message}</span>
|
|
|
|
|
<span className={styles.errorCount}>{ep.count}x</span>
|
|
|
|
|
<span className={styles.errorTime}>{ep.lastSeen}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|