import { useState, useMemo, useCallback } from 'react' import { useParams, useNavigate } from 'react-router' import { DataTable, DetailPanel, ShortcutsBar, ProcessorTimeline, RouteFlow, KpiStrip, StatusDot, MonoText, Badge, useGlobalFilters, } from '@cameleer/design-system' import type { Column, KpiItem } from '@cameleer/design-system' import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, } from '../../api/queries/executions' import { useDiagramLayout } from '../../api/queries/diagrams' import type { ExecutionSummary } from '../../api/types' import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping' import styles from './Dashboard.module.css' // Row type extends ExecutionSummary with an `id` field for DataTable interface Row extends ExecutionSummary { id: string } // ─── 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 formatTimestamp(iso: string): string { const date = new Date(iso) const y = date.getFullYear() const mo = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') const h = String(date.getHours()).padStart(2, '0') const mi = String(date.getMinutes()).padStart(2, '0') const s = String(date.getSeconds()).padStart(2, '0') return `${y}-${mo}-${d} ${h}:${mi}:${s}` } function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' { switch (status) { case 'COMPLETED': return 'success' case 'FAILED': return 'error' case 'RUNNING': return 'running' default: return 'warning' } } function statusLabel(status: string): string { switch (status) { case 'COMPLETED': return 'OK' case 'FAILED': return 'ERR' case 'RUNNING': return 'RUN' default: return 'WARN' } } function durationClass(ms: number, status: string): string { if (status === 'FAILED') return styles.durBreach if (ms < 100) return styles.durFast if (ms < 200) return styles.durNormal if (ms < 300) return styles.durSlow return styles.durBreach } function flattenProcessors(nodes: any[]): any[] { const result: any[] = [] let offset = 0 function walk(node: any) { result.push({ name: node.processorId || node.processorType, type: node.processorType, durationMs: node.durationMs ?? 0, status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok', startMs: offset, }) offset += node.durationMs ?? 0 if (node.children) node.children.forEach(walk) } nodes.forEach(walk) return result } // ─── Table columns (base, without inspect action) ──────────────────────────── function buildBaseColumns(): Column[] { return [ { key: 'status', header: 'Status', width: '80px', render: (_: unknown, row: Row) => ( {statusLabel(row.status)} ), }, { key: 'routeId', header: 'Route', sortable: true, render: (_: unknown, row: Row) => ( {row.routeId} ), }, { key: 'applicationName', header: 'Application', sortable: true, render: (_: unknown, row: Row) => ( {row.applicationName ?? ''} ), }, { key: 'attributes', header: 'Attributes', render: (_, row) => { const attrs = row.attributes; if (!attrs || Object.keys(attrs).length === 0) return ; const entries = Object.entries(attrs); const shown = entries.slice(0, 2); const overflow = entries.length - 2; return (
{shown.map(([k, v]) => ( ))} {overflow > 0 && +{overflow}}
); }, }, { key: 'executionId', header: 'Exchange ID', sortable: true, render: (_: unknown, row: Row) => ( {row.executionId} ), }, { key: 'startTime', header: 'Started', sortable: true, render: (_: unknown, row: Row) => ( {formatTimestamp(row.startTime)} ), }, { key: 'durationMs', header: 'Duration', sortable: true, render: (_: unknown, row: Row) => ( {formatDuration(row.durationMs)} ), }, { key: 'agentId', header: 'Agent', render: (_: unknown, row: Row) => ( {row.agentId} ), }, ] } const SHORTCUTS = [ { keys: 'Ctrl+K', label: 'Search' }, { keys: '\u2191\u2193', label: 'Navigate rows' }, { keys: 'Enter', label: 'Open detail' }, { keys: 'Esc', label: 'Close panel' }, ] // ─── Dashboard component ───────────────────────────────────────────────────── export default function Dashboard() { const { appId, routeId } = useParams<{ appId: string; routeId: string }>() const navigate = useNavigate() const [selectedId, setSelectedId] = useState() const [panelOpen, setPanelOpen] = useState(false) const [sortField, setSortField] = useState('startTime') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') const { timeRange, statusFilters } = useGlobalFilters() const timeFrom = timeRange.start.toISOString() const timeTo = timeRange.end.toISOString() const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000 const handleSortChange = useCallback((key: string, dir: 'asc' | 'desc') => { setSortField(key) setSortDir(dir) }, []) // ─── API hooks ─────────────────────────────────────────────────────────── const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId) const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId) const { data: searchResult } = useSearchExecutions( { timeFrom, timeTo, routeId: routeId || undefined, application: appId || undefined, sortField, sortDir, offset: 0, limit: 50, }, true, ) const { data: detail } = useExecutionDetail(selectedId ?? null) const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null) // ─── Rows ──────────────────────────────────────────────────────────────── const allRows: Row[] = useMemo( () => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), [searchResult], ) // Apply global status filters (time filtering is done server-side via timeFrom/timeTo) const rows: Row[] = useMemo(() => { if (statusFilters.size === 0) return allRows return allRows.filter((r) => statusFilters.has(r.status.toLowerCase() as any)) }, [allRows, statusFilters]) // ─── KPI items ─────────────────────────────────────────────────────────── const totalCount = stats?.totalCount ?? 0 const failedCount = stats?.failedCount ?? 0 const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100 const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0 const prevTotal = stats?.prevTotalCount ?? 0 const prevFailed = stats?.prevFailedCount ?? 0 const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal) * 100 : 0 const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal) * 100 : 100 const successRateDelta = successRate - prevSuccessRate const errorDelta = failedCount - prevFailed const sparkExchanges = useMemo( () => (timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries], ) const sparkErrors = useMemo( () => (timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries], ) const sparkLatency = useMemo( () => (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries], ) const sparkThroughput = useMemo( () => (timeseries?.buckets || []).map((b: any) => { const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1) return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0 }), [timeseries, timeWindowSeconds], ) const kpiItems: KpiItem[] = useMemo( () => [ { label: 'Exchanges', value: totalCount.toLocaleString(), trend: { label: `${exchangeTrend > 0 ? '\u2191' : exchangeTrend < 0 ? '\u2193' : '\u2192'} ${exchangeTrend > 0 ? '+' : ''}${exchangeTrend.toFixed(0)}%`, variant: (exchangeTrend > 0 ? 'success' : exchangeTrend < 0 ? 'error' : 'muted') as 'success' | 'error' | 'muted', }, subtitle: `${successRate.toFixed(1)}% success rate`, sparkline: sparkExchanges, borderColor: 'var(--amber)', }, { label: 'Success Rate', value: `${successRate.toFixed(1)}%`, trend: { label: `${successRateDelta >= 0 ? '\u2191' : '\u2193'} ${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`, variant: (successRateDelta >= 0 ? 'success' : 'error') as 'success' | 'error', }, subtitle: `${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`, borderColor: 'var(--success)', }, { label: 'Errors', value: failedCount, trend: { label: `${errorDelta > 0 ? '\u2191' : errorDelta < 0 ? '\u2193' : '\u2192'} ${errorDelta > 0 ? '+' : ''}${errorDelta}`, variant: (errorDelta > 0 ? 'error' : errorDelta < 0 ? 'success' : 'muted') as 'success' | 'error' | 'muted', }, subtitle: `${failedCount} errors in selected period`, sparkline: sparkErrors, borderColor: 'var(--error)', }, { label: 'Throughput', value: `${throughput.toFixed(1)} msg/s`, trend: { label: '\u2192', variant: 'muted' as const }, subtitle: `${throughput.toFixed(1)} msg/s`, sparkline: sparkThroughput, borderColor: 'var(--running)', }, { label: 'Latency p99', value: `${(stats?.p99LatencyMs ?? 0).toLocaleString()} ms`, trend: { label: '', variant: 'muted' as const }, subtitle: `${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`, sparkline: sparkLatency, borderColor: 'var(--warning)', }, ], [totalCount, failedCount, successRate, throughput, exchangeTrend, successRateDelta, errorDelta, sparkExchanges, sparkErrors, sparkLatency, sparkThroughput, stats?.p99LatencyMs], ) // ─── Table columns with inspect action ─────────────────────────────────── const columns: Column[] = useMemo(() => { const inspectCol: Column = { key: 'correlationId', header: '', width: '36px', render: (_: unknown, row: Row) => ( ), } const base = buildBaseColumns() const [statusCol, ...rest] = base return [statusCol, inspectCol, ...rest] }, [navigate]) // ─── Row click / detail panel ──────────────────────────────────────────── const selectedRow = useMemo( () => rows.find((r) => r.id === selectedId), [rows, selectedId], ) function handleRowClick(row: Row) { setSelectedId(row.id) setPanelOpen(true) } function handleRowAccent(row: Row): 'error' | 'warning' | undefined { if (row.status === 'FAILED') return 'error' return undefined } // ─── Detail panel data ─────────────────────────────────────────────────── const procList = detail ? detail.processors?.length ? detail.processors : (detail.children ?? []) : [] const routeFlows = useMemo(() => { if (diagram?.nodes) { return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes || [], procList)).flows } return [] }, [diagram, procList]) const flatProcs = useMemo(() => flattenProcessors(procList), [procList]) // Error info from detail const errorClass = detail?.errorMessage?.split(':')[0] ?? '' const errorMsg = detail?.errorMessage ?? '' return ( <> {/* Scrollable content */}
{/* KPI strip */} {/* Exchanges table */}
Recent Exchanges
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
row.errorMessage ? (
{'\u26A0'}
{row.errorMessage}
Click to view full stack trace
) : null } />
{/* Shortcuts bar */} {/* Detail panel — auto-portals to AppShell level via design system */} {selectedRow && detail && ( setPanelOpen(false)} title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`} >
Overview
Status {statusLabel(detail.status)}
Duration {formatDuration(detail.durationMs)}
Route {detail.routeId}
Agent {detail.agentId ?? '\u2014'}
Correlation {detail.correlationId ?? '\u2014'}
Timestamp {detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}
{errorMsg && (
Errors
{errorClass}
{errorMsg}
)}
Route Flow
{routeFlows.length > 0 ? ( ) : (
No diagram available
)}
Processor Timeline {formatDuration(detail.durationMs)}
{flatProcs.length > 0 ? ( ) : (
No processor data
)}
)} ) }