- Sidebar: make +App button more subtle (lower opacity, brightens on hover) - Sidebar: add filter chips to hide empty routes and offline/stale apps - Sidebar: hide filter chips and +App button when sidebar is collapsed - Exchange table: reorder columns to Status, Attributes, App, Route, Started, Duration; remove ExchangeId and Agent columns - Exchange detail log tab: query by exchangeId only (no applicationId required), filter by processorId when processor selected - KPI tooltips: styled tooltips with current/previous values, time period labels, percentage change, themed with DS variables - KPI tooltips: fix overflow by left-aligning first two and right-aligning last two - Exchange detail: show full datetime (YYYY-MM-DD HH:mm:ss.SSS) for start/end times - Status labels: unify to title-case (Completed, Failed, Running) across all views - Status filter buttons: match title-case labels (Completed, Warning, Failed, Running) - Create app: show full external URL using routingDomain from env config or window.location.origin fallback - Create app: add Runtime Type selector and Custom Arguments to Resources tab - Create app: add Sensitive Keys tab with agent defaults, global keys, and app-specific keys (matching admin page design) - Create app: add placeholder text to all Input fields for consistency - Update design-system to 0.1.52 (sidebar collapse toggle fix) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
990 lines
37 KiB
TypeScript
990 lines
37 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router';
|
|
import { Pencil, Trash2 } from 'lucide-react';
|
|
import {
|
|
KpiStrip,
|
|
Badge,
|
|
StatusDot,
|
|
DataTable,
|
|
EmptyState,
|
|
Tabs,
|
|
ThemedChart,
|
|
Area,
|
|
Line,
|
|
Bar,
|
|
ReferenceLine,
|
|
CHART_COLORS,
|
|
RouteFlow,
|
|
Spinner,
|
|
MonoText,
|
|
Sparkline,
|
|
Toggle,
|
|
Button,
|
|
Modal,
|
|
FormField,
|
|
Input,
|
|
Select,
|
|
Textarea,
|
|
Collapsible,
|
|
ConfirmDialog,
|
|
} from '@cameleer/design-system';
|
|
import type { KpiItem, Column } from '@cameleer/design-system';
|
|
import { useGlobalFilters } from '@cameleer/design-system';
|
|
import { useCatalog } from '../../api/queries/catalog';
|
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
|
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
|
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
|
import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands';
|
|
import { useEnvironmentStore } from '../../api/environment-store';
|
|
import type { TapDefinition } from '../../api/queries/commands';
|
|
import type { ExecutionSummary } from '../../api/types';
|
|
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
|
import { buildFlowSegments } from '../../utils/diagram-mapping';
|
|
import { statusLabel } from '../../utils/format-utils';
|
|
import styles from './RouteDetail.module.css';
|
|
import tableStyles from '../../styles/table-section.module.css';
|
|
import rateStyles from '../../styles/rate-colors.module.css';
|
|
import chartCardStyles from '../../styles/chart-card.module.css';
|
|
import tapModalStyles from '../../components/TapConfigModal.module.css';
|
|
|
|
// ── Row types ────────────────────────────────────────────────────────────────
|
|
|
|
interface ExchangeRow extends ExecutionSummary {
|
|
id: string;
|
|
}
|
|
|
|
interface ProcessorRow {
|
|
id: string;
|
|
processorId: string;
|
|
callCount: number;
|
|
avgDurationMs: number;
|
|
p99DurationMs: number;
|
|
errorCount: number;
|
|
errorRate: number;
|
|
sparkline: number[];
|
|
}
|
|
|
|
interface ErrorPattern {
|
|
message: string;
|
|
count: number;
|
|
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();
|
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
const { timeRange } = useGlobalFilters();
|
|
const timeFrom = timeRange.start.toISOString();
|
|
const timeTo = timeRange.end.toISOString();
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'performance');
|
|
const [recentSortField, setRecentSortField] = useState<string>('startTime');
|
|
const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc');
|
|
|
|
// ── Tap modal state ────────────────────────────────────────────────────────
|
|
const [tapModalOpen, setTapModalOpen] = useState(false);
|
|
const [editingTap, setEditingTap] = useState<TapDefinition | null>(null);
|
|
const [tapName, setTapName] = useState('');
|
|
const [tapProcessor, setTapProcessor] = useState('');
|
|
const [tapLanguage, setTapLanguage] = useState('simple');
|
|
const [tapTarget, setTapTarget] = useState<'INPUT' | 'OUTPUT' | 'BOTH'>('OUTPUT');
|
|
const [tapExpression, setTapExpression] = useState('');
|
|
const [tapType, setTapType] = useState<'BUSINESS_OBJECT' | 'CORRELATION' | 'EVENT' | 'CUSTOM'>('BUSINESS_OBJECT');
|
|
const [tapEnabled, setTapEnabled] = useState(true);
|
|
const [deletingTap, setDeletingTap] = useState<TapDefinition | null>(null);
|
|
|
|
// ── Test expression state ──────────────────────────────────────────────────
|
|
const [testTab, setTestTab] = useState('recent');
|
|
const [testPayload, setTestPayload] = useState('');
|
|
const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null);
|
|
const [testExchangeId, setTestExchangeId] = useState('');
|
|
|
|
const handleRecentSortChange = useCallback((key: string, dir: 'asc' | 'desc') => {
|
|
setRecentSortField(key);
|
|
setRecentSortDir(dir);
|
|
}, []);
|
|
|
|
// ── API queries ────────────────────────────────────────────────────────────
|
|
const { data: catalog } = useCatalog(selectedEnv);
|
|
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
|
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
|
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId, selectedEnv);
|
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId, selectedEnv);
|
|
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
|
timeFrom,
|
|
timeTo,
|
|
routeId: routeId || undefined,
|
|
applicationId: appId || undefined,
|
|
environment: selectedEnv,
|
|
sortField: recentSortField,
|
|
sortDir: recentSortDir,
|
|
offset: 0,
|
|
limit: 50,
|
|
});
|
|
const { data: errorResult } = useSearchExecutions({
|
|
timeFrom,
|
|
timeTo,
|
|
routeId: routeId || undefined,
|
|
applicationId: appId || undefined,
|
|
environment: selectedEnv,
|
|
status: 'FAILED',
|
|
offset: 0,
|
|
limit: 200,
|
|
});
|
|
|
|
// ── Application config ──────────────────────────────────────────────────────
|
|
const config = useApplicationConfig(appId);
|
|
const updateConfig = useUpdateApplicationConfig();
|
|
const testExpressionMutation = useTestExpression();
|
|
|
|
const isRecording = config.data?.routeRecording?.[routeId!] !== false;
|
|
|
|
function toggleRecording() {
|
|
if (!config.data) return;
|
|
const routeRecording = { ...config.data.routeRecording, [routeId!]: !isRecording };
|
|
updateConfig.mutate({ config: { ...config.data, routeRecording }, environment: selectedEnv });
|
|
}
|
|
|
|
// ── Derived data ───────────────────────────────────────────────────────────
|
|
|
|
const appEntry: CatalogApp | undefined = useMemo(() =>
|
|
(catalog || []).find((e: CatalogApp) => e.slug === appId),
|
|
[catalog, appId],
|
|
);
|
|
|
|
const routeSummary: CatalogRoute | undefined = useMemo(() =>
|
|
appEntry?.routes?.find((r: CatalogRoute) => r.routeId === routeId),
|
|
[appEntry, routeId],
|
|
);
|
|
|
|
const health = appEntry?.health ?? 'unknown';
|
|
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();
|
|
if (h === 'healthy') return 'success';
|
|
if (h === 'degraded') return 'warning';
|
|
if (h === 'unhealthy') return 'error';
|
|
return 'dead';
|
|
}, [health]);
|
|
|
|
// Route flow from diagram
|
|
const diagramFlows = useMemo(() => {
|
|
if (!diagram?.nodes) return [];
|
|
return buildFlowSegments(diagram.nodes, []).flows;
|
|
}, [diagram]);
|
|
|
|
// Processor table rows
|
|
const processorRows: ProcessorRow[] = useMemo(() =>
|
|
(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],
|
|
);
|
|
|
|
// 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 }>();
|
|
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() : '\u2014',
|
|
}))
|
|
.sort((a, b) => b.count - a.count);
|
|
}, [errorResult]);
|
|
|
|
// Route taps — cross-reference config taps with diagram processor IDs
|
|
const routeTaps = useMemo(() => {
|
|
if (!config.data?.taps || !diagram) return [];
|
|
const routeProcessorIds = new Set(
|
|
(diagram.nodes || []).map((n: any) => n.id).filter(Boolean),
|
|
);
|
|
return config.data.taps.filter(t => routeProcessorIds.has(t.processorId));
|
|
}, [config.data?.taps, diagram]);
|
|
|
|
const activeTapCount = routeTaps.filter(t => t.enabled).length;
|
|
|
|
// KPI items
|
|
const kpiItems = useMemo(() => {
|
|
const base = buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline);
|
|
base.push({
|
|
label: 'Active Taps',
|
|
value: String(activeTapCount),
|
|
trend: { label: `${routeTaps.length} total`, variant: 'muted' as const },
|
|
subtitle: `${activeTapCount} enabled / ${routeTaps.length} configured`,
|
|
borderColor: 'var(--running)',
|
|
});
|
|
return base;
|
|
}, [stats, throughputSparkline, errorSparkline, latencySparkline, activeTapCount, routeTaps.length]);
|
|
|
|
const processorColumns = useMemo(() => makeProcessorColumns(styles), []);
|
|
|
|
const tabs = [
|
|
{ label: 'Performance', value: 'performance' },
|
|
{ label: 'Recent Executions', value: 'executions', count: exchangeRows.length },
|
|
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
|
{ label: 'Taps', value: 'taps', count: routeTaps.length },
|
|
];
|
|
|
|
// ── Tap helpers ──────────────────────────────────────────────────────────
|
|
|
|
const processorOptions = useMemo(() => {
|
|
if (!diagram?.nodes) return [];
|
|
return (diagram.nodes as Array<{ id?: string; label?: string }>)
|
|
.filter((n) => n.id)
|
|
.map((n) => ({ value: n.id!, label: n.label || n.id! }));
|
|
}, [diagram]);
|
|
|
|
function openTapModal(tap: TapDefinition | null) {
|
|
if (tap) {
|
|
setEditingTap(tap);
|
|
setTapName(tap.attributeName);
|
|
setTapProcessor(tap.processorId);
|
|
setTapLanguage(tap.language);
|
|
setTapTarget(tap.target);
|
|
setTapExpression(tap.expression);
|
|
setTapType(tap.attributeType);
|
|
setTapEnabled(tap.enabled);
|
|
} else {
|
|
setEditingTap(null);
|
|
setTapName('');
|
|
setTapProcessor(processorOptions[0]?.value ?? '');
|
|
setTapLanguage('simple');
|
|
setTapTarget('OUTPUT');
|
|
setTapExpression('');
|
|
setTapType('BUSINESS_OBJECT');
|
|
setTapEnabled(true);
|
|
}
|
|
setTestResult(null);
|
|
setTestPayload('');
|
|
setTestExchangeId('');
|
|
setTapModalOpen(true);
|
|
}
|
|
|
|
function saveTap() {
|
|
if (!config.data) return;
|
|
const tap: TapDefinition = {
|
|
tapId: editingTap?.tapId || `tap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
processorId: tapProcessor,
|
|
target: tapTarget,
|
|
expression: tapExpression,
|
|
language: tapLanguage,
|
|
attributeName: tapName,
|
|
attributeType: tapType,
|
|
enabled: tapEnabled,
|
|
version: editingTap ? editingTap.version + 1 : 1,
|
|
};
|
|
const taps = editingTap
|
|
? config.data.taps.map(t => t.tapId === editingTap.tapId ? tap : t)
|
|
: [...(config.data.taps || []), tap];
|
|
updateConfig.mutate({ config: { ...config.data, taps }, environment: selectedEnv });
|
|
setTapModalOpen(false);
|
|
}
|
|
|
|
function deleteTap(tap: TapDefinition) {
|
|
if (!config.data) return;
|
|
const taps = config.data.taps.filter(t => t.tapId !== tap.tapId);
|
|
updateConfig.mutate({ config: { ...config.data, taps }, environment: selectedEnv });
|
|
setDeletingTap(null);
|
|
}
|
|
|
|
function toggleTapEnabled(tap: TapDefinition) {
|
|
if (!config.data) return;
|
|
const taps = config.data.taps.map(t =>
|
|
t.tapId === tap.tapId ? { ...t, enabled: !t.enabled } : t,
|
|
);
|
|
updateConfig.mutate({ config: { ...config.data, taps }, environment: selectedEnv });
|
|
}
|
|
|
|
function runTestExpression() {
|
|
if (!appId) return;
|
|
const body = testTab === 'recent' ? testExchangeId : testPayload;
|
|
testExpressionMutation.mutate(
|
|
{ application: appId, expression: tapExpression, language: tapLanguage, body, target: tapTarget },
|
|
{ onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }) },
|
|
);
|
|
}
|
|
|
|
const tapColumns: Column<TapDefinition & { id: string }>[] = useMemo(() => [
|
|
{
|
|
key: 'attributeName',
|
|
header: 'Attribute',
|
|
sortable: true,
|
|
render: (_, row) => <span>{row.attributeName}</span>,
|
|
},
|
|
{
|
|
key: 'processorId',
|
|
header: 'Processor',
|
|
sortable: true,
|
|
render: (_, row) => <MonoText size="xs">{row.processorId}</MonoText>,
|
|
},
|
|
{
|
|
key: 'expression',
|
|
header: 'Expression',
|
|
render: (_, row) => <MonoText size="xs">{row.expression}</MonoText>,
|
|
},
|
|
{
|
|
key: 'language',
|
|
header: 'Language',
|
|
render: (_, row) => <Badge label={row.language} color="auto" />,
|
|
},
|
|
{
|
|
key: 'target',
|
|
header: 'Target',
|
|
render: (_, row) => <Badge label={row.target} color="auto" />,
|
|
},
|
|
{
|
|
key: 'attributeType',
|
|
header: 'Type',
|
|
render: (_, row) => <Badge label={row.attributeType} color="auto" />,
|
|
},
|
|
{
|
|
key: 'enabled',
|
|
header: 'Enabled',
|
|
width: '80px',
|
|
render: (_, row) => (
|
|
<Toggle
|
|
checked={row.enabled}
|
|
onChange={() => toggleTapEnabled(row)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions' as any,
|
|
header: '',
|
|
width: '80px',
|
|
render: (_, row) => (
|
|
<div className={styles.tapActions}>
|
|
<button className={styles.tapActionBtn} title="Edit" onClick={() => openTapModal(row)}><Pencil size={14} /></button>
|
|
<button className={styles.tapActionBtn} title="Delete" onClick={() => setDeletingTap(row)}><Trash2 size={14} /></button>
|
|
</div>
|
|
),
|
|
},
|
|
], [config.data, processorOptions]);
|
|
|
|
const languageOptions = [
|
|
{ value: 'simple', label: 'Simple' },
|
|
{ value: 'jsonpath', label: 'JSONPath' },
|
|
{ value: 'xpath', label: 'XPath' },
|
|
{ value: 'jq', label: 'jq' },
|
|
{ value: 'groovy', label: 'Groovy' },
|
|
];
|
|
|
|
const targetOptions = [
|
|
{ value: 'INPUT', label: 'Input' },
|
|
{ value: 'OUTPUT', label: 'Output' },
|
|
{ value: 'BOTH', label: 'Both' },
|
|
];
|
|
|
|
const typeChoices: Array<{ value: TapDefinition['attributeType']; label: string; tooltip: string }> = [
|
|
{ value: 'BUSINESS_OBJECT', label: 'Business Object', tooltip: 'A key business identifier like orderId, customerId, or invoiceNumber' },
|
|
{ value: 'CORRELATION', label: 'Correlation', tooltip: 'Used to correlate related exchanges across routes or services' },
|
|
{ value: 'EVENT', label: 'Event', tooltip: 'Marks a business event occurrence like orderPlaced or paymentReceived' },
|
|
{ value: 'CUSTOM', label: 'Custom', tooltip: 'General-purpose attribute for any other extraction need' },
|
|
];
|
|
|
|
const recentExchangeOptions = useMemo(() =>
|
|
exchangeRows.slice(0, 20).map(e => ({
|
|
value: e.executionId,
|
|
label: `${e.executionId.slice(0, 12)} — ${statusLabel(e.status)}`,
|
|
})),
|
|
[exchangeRows],
|
|
);
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div>
|
|
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
|
← {appId} routes
|
|
</Link>
|
|
|
|
{/* Route header card */}
|
|
<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.recordingPill}>
|
|
<span className={styles.recordingLabel}>Recording</span>
|
|
<Toggle checked={isRecording} onChange={toggleRecording} />
|
|
</div>
|
|
<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>
|
|
|
|
{/* KPI strip */}
|
|
<KpiStrip items={kpiItems} />
|
|
|
|
{/* Diagram + Processor Stats grid */}
|
|
<div className={styles.diagramStatsGrid}>
|
|
<div className={styles.diagramPane}>
|
|
<div className={styles.paneTitle}>Route Diagram</div>
|
|
{diagramFlows.length > 0 ? (
|
|
<RouteFlow flows={diagramFlows} />
|
|
) : (
|
|
<EmptyState title="No diagram" description="No diagram available for this route." />
|
|
)}
|
|
</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} />
|
|
) : (
|
|
<EmptyState title="No processor data" description="No processor data available." />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Processor Performance table (full width) */}
|
|
<div className={`${tableStyles.tableSection} ${styles.tableSection}`}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<span className={tableStyles.tableTitle}>Processor Performance</span>
|
|
<div className={tableStyles.tableRight}>
|
|
<span className={tableStyles.tableMeta}>{processorRows.length} processors</span>
|
|
<Badge label="AUTO" color="success" />
|
|
</div>
|
|
</div>
|
|
<DataTable
|
|
columns={processorColumns}
|
|
data={processorRows}
|
|
sortable
|
|
/>
|
|
</div>
|
|
|
|
{/* Route Flow section */}
|
|
{diagramFlows.length > 0 && (
|
|
<div className={styles.routeFlowSection}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<span className={tableStyles.tableTitle}>Route Flow</span>
|
|
</div>
|
|
<RouteFlow flows={diagramFlows} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
|
|
<div className={styles.tabSection}>
|
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
|
|
|
{activeTab === 'performance' && (
|
|
<div className={styles.chartGrid} style={{ marginTop: 16 }}>
|
|
<div className={chartCardStyles.chartCard}>
|
|
<div className={styles.chartTitle}>Throughput</div>
|
|
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="msg/s">
|
|
<Area dataKey="throughput" name="Throughput" stroke={CHART_COLORS[0]}
|
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
|
</ThemedChart>
|
|
</div>
|
|
<div className={chartCardStyles.chartCard}>
|
|
<div className={styles.chartTitle}>Latency</div>
|
|
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="ms">
|
|
<Line dataKey="latency" name="Latency" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
|
<ReferenceLine y={300} stroke="var(--error)" strokeDasharray="5 3"
|
|
label={{ value: 'SLA 300ms', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
|
</ThemedChart>
|
|
</div>
|
|
<div className={chartCardStyles.chartCard}>
|
|
<div className={styles.chartTitle}>Errors</div>
|
|
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="errors">
|
|
<Bar dataKey="errors" name="Errors" fill={CHART_COLORS[1]} />
|
|
</ThemedChart>
|
|
</div>
|
|
<div className={chartCardStyles.chartCard}>
|
|
<div className={styles.chartTitle}>Success Rate</div>
|
|
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="%">
|
|
<Area dataKey="successRate" name="Success Rate" stroke={CHART_COLORS[0]}
|
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
|
</ThemedChart>
|
|
</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={EXCHANGE_COLUMNS}
|
|
data={exchangeRows}
|
|
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
|
sortable
|
|
pageSize={20}
|
|
onSortChange={handleRecentSortChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'errors' && (
|
|
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
|
{errorPatterns.length === 0 ? (
|
|
<EmptyState title="No error patterns" description="No error patterns found in the selected time range." />
|
|
) : (
|
|
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>
|
|
)}
|
|
|
|
{activeTab === 'taps' && (
|
|
<div className={styles.tapsSection}>
|
|
<div className={styles.tapsHeader}>
|
|
<span className={styles.tapsTitle}>Data Extraction Taps</span>
|
|
<Button variant="primary" size="sm" onClick={() => openTapModal(null)}>+ Add Tap</Button>
|
|
</div>
|
|
{routeTaps.length === 0 ? (
|
|
<EmptyState title="No taps" description="No taps configured for this route. Add a tap to extract business attributes from exchange data." />
|
|
) : (
|
|
<DataTable
|
|
columns={tapColumns}
|
|
data={routeTaps.map(t => ({ ...t, id: t.tapId }))}
|
|
flush
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tap Modal */}
|
|
<Modal open={tapModalOpen} onClose={() => setTapModalOpen(false)} title={editingTap ? 'Edit Tap' : 'Add Tap'} size="lg">
|
|
<div className={styles.tapModalBody}>
|
|
<FormField label="Attribute Name">
|
|
<Input value={tapName} onChange={(e) => setTapName(e.target.value)} placeholder="e.g. orderId" />
|
|
</FormField>
|
|
|
|
<FormField label="Processor">
|
|
<Select
|
|
options={processorOptions}
|
|
value={tapProcessor}
|
|
onChange={(e) => setTapProcessor(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
|
|
<div className={styles.tapFormRow}>
|
|
<FormField label="Language">
|
|
<Select
|
|
options={languageOptions}
|
|
value={tapLanguage}
|
|
onChange={(e) => setTapLanguage(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Target">
|
|
<Select
|
|
options={targetOptions}
|
|
value={tapTarget}
|
|
onChange={(e) => setTapTarget(e.target.value as 'INPUT' | 'OUTPUT' | 'BOTH')}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
|
|
<FormField label="Expression">
|
|
<Textarea
|
|
className={styles.monoTextarea}
|
|
value={tapExpression}
|
|
onChange={(e) => setTapExpression(e.target.value)}
|
|
placeholder="e.g. ${body.orderId}"
|
|
rows={3}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="Type">
|
|
<div className={tapModalStyles.typeSelector}>
|
|
{typeChoices.map(tc => (
|
|
<button
|
|
key={tc.value}
|
|
type="button"
|
|
title={tc.tooltip}
|
|
className={`${tapModalStyles.typeOption} ${tapType === tc.value ? tapModalStyles.typeOptionActive : ''}`}
|
|
onClick={() => setTapType(tc.value)}
|
|
>
|
|
{tc.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</FormField>
|
|
|
|
<div className={styles.tapFormRow}>
|
|
<FormField label="Enabled">
|
|
<Toggle checked={tapEnabled} onChange={() => setTapEnabled(!tapEnabled)} />
|
|
</FormField>
|
|
</div>
|
|
|
|
{/* Test Expression */}
|
|
<Collapsible title="Test Expression" defaultOpen>
|
|
<div className={tapModalStyles.testSection}>
|
|
<div className={tapModalStyles.testTabs}>
|
|
<button
|
|
type="button"
|
|
className={`${tapModalStyles.testTabBtn} ${testTab === 'recent' ? tapModalStyles.testTabBtnActive : ''}`}
|
|
onClick={() => setTestTab('recent')}
|
|
>
|
|
Recent Exchange
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tapModalStyles.testTabBtn} ${testTab === 'custom' ? tapModalStyles.testTabBtnActive : ''}`}
|
|
onClick={() => setTestTab('custom')}
|
|
>
|
|
Custom Payload
|
|
</button>
|
|
</div>
|
|
|
|
{testTab === 'recent' && (
|
|
<div className={tapModalStyles.testBody}>
|
|
<Select
|
|
options={recentExchangeOptions.length > 0 ? recentExchangeOptions : [{ value: '', label: 'No recent exchanges' }]}
|
|
value={testExchangeId}
|
|
onChange={(e) => setTestExchangeId(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{testTab === 'custom' && (
|
|
<div className={tapModalStyles.testBody}>
|
|
<Textarea
|
|
className={styles.monoTextarea}
|
|
value={testPayload}
|
|
onChange={(e) => setTestPayload(e.target.value)}
|
|
placeholder='{"orderId": "12345", ...}'
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className={tapModalStyles.testBody}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={runTestExpression}
|
|
loading={testExpressionMutation.isPending}
|
|
disabled={!tapExpression}
|
|
>
|
|
Test
|
|
</Button>
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div className={`${tapModalStyles.testResult} ${testResult.error ? tapModalStyles.testError : tapModalStyles.testSuccess}`}>
|
|
{testResult.error ?? testResult.result ?? 'No result'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Collapsible>
|
|
|
|
<div className={styles.tapModalFooter}>
|
|
<Button variant="ghost" onClick={() => setTapModalOpen(false)}>Cancel</Button>
|
|
<Button variant="primary" onClick={saveTap} disabled={!tapName || !tapProcessor || !tapExpression}>Save</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Delete Tap Confirm */}
|
|
<ConfirmDialog
|
|
open={!!deletingTap}
|
|
onClose={() => setDeletingTap(null)}
|
|
onConfirm={() => deletingTap && deleteTap(deletingTap)}
|
|
title="Delete Tap"
|
|
message={`This will remove the tap "${deletingTap?.attributeName ?? ''}" from the configuration.`}
|
|
confirmText={deletingTap?.attributeName ?? ''}
|
|
confirmLabel="Delete"
|
|
variant="danger"
|
|
loading={updateConfig.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|