Row click now navigates directly to the split view with diagram. Removed: DetailPanel, inspect column, unused imports (ExternalLink, ProcessorTimeline, RouteFlow, useExecutionDetail, useDiagramLayout, buildFlowSegments). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react'
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
|
import { AlertTriangle, X, Search } from 'lucide-react'
|
|
import {
|
|
DataTable,
|
|
ShortcutsBar,
|
|
KpiStrip,
|
|
StatusDot,
|
|
MonoText,
|
|
Badge,
|
|
useGlobalFilters,
|
|
} from '@cameleer/design-system'
|
|
import type { Column, KpiItem } from '@cameleer/design-system'
|
|
import {
|
|
useSearchExecutions,
|
|
useExecutionStats,
|
|
useStatsTimeseries,
|
|
} from '../../api/queries/executions'
|
|
import type { ExecutionSummary } from '../../api/types'
|
|
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<Row>[] {
|
|
return [
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
width: '80px',
|
|
render: (_: unknown, row: Row) => (
|
|
<span className={styles.statusCell}>
|
|
<StatusDot variant={statusToVariant(row.status)} />
|
|
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'routeId',
|
|
header: 'Route',
|
|
sortable: true,
|
|
render: (_: unknown, row: Row) => (
|
|
<span className={styles.routeName}>{row.routeId}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'applicationName',
|
|
header: 'Application',
|
|
sortable: true,
|
|
render: (_: unknown, row: Row) => (
|
|
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'attributes',
|
|
header: 'Attributes',
|
|
render: (_, row) => {
|
|
const attrs = row.attributes;
|
|
if (!attrs || Object.keys(attrs).length === 0) return <span className={styles.muted}>—</span>;
|
|
const entries = Object.entries(attrs);
|
|
const shown = entries.slice(0, 2);
|
|
const overflow = entries.length - 2;
|
|
return (
|
|
<div className={styles.attrCell}>
|
|
{shown.map(([k, v]) => (
|
|
<span key={k} title={k}>
|
|
<Badge label={String(v)} color="auto" />
|
|
</span>
|
|
))}
|
|
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'executionId',
|
|
header: 'Exchange ID',
|
|
sortable: true,
|
|
render: (_: unknown, row: Row) => (
|
|
<MonoText size="xs">{row.executionId}</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'startTime',
|
|
header: 'Started',
|
|
sortable: true,
|
|
render: (_: unknown, row: Row) => (
|
|
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'durationMs',
|
|
header: 'Duration',
|
|
sortable: true,
|
|
render: (_: unknown, row: Row) => (
|
|
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
|
{formatDuration(row.durationMs)}
|
|
</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'agentId',
|
|
header: 'Agent',
|
|
render: (_: unknown, row: Row) => (
|
|
<span className={styles.agentBadge}>
|
|
<span className={styles.agentDot} />
|
|
{row.agentId}
|
|
</span>
|
|
),
|
|
},
|
|
]
|
|
}
|
|
|
|
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 [searchParams, setSearchParams] = useSearchParams()
|
|
const textFilter = searchParams.get('text') || undefined
|
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
|
const [sortField, setSortField] = useState<string>('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)
|
|
// Convert design-system status filters (lowercase) to API status param (uppercase)
|
|
const statusParam = statusFilters.size > 0
|
|
? [...statusFilters].map(s => s.toUpperCase()).join(',')
|
|
: undefined
|
|
|
|
const { data: searchResult } = useSearchExecutions(
|
|
{
|
|
timeFrom,
|
|
timeTo,
|
|
routeId: routeId || undefined,
|
|
application: appId || undefined,
|
|
status: statusParam,
|
|
text: textFilter,
|
|
sortField,
|
|
sortDir,
|
|
offset: 0,
|
|
limit: textFilter ? 200 : 50,
|
|
},
|
|
!textFilter,
|
|
)
|
|
|
|
// ─── Rows ────────────────────────────────────────────────────────────────
|
|
const rows: Row[] = useMemo(
|
|
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
|
[searchResult],
|
|
)
|
|
|
|
// ─── 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 ──────────────────────────────────────────────────────
|
|
const columns: Column<Row>[] = useMemo(() => buildBaseColumns(), [])
|
|
|
|
// ─── Row click → navigate to diagram view ────────────────────────────────
|
|
|
|
function handleRowClick(row: Row) {
|
|
setSelectedId(row.id)
|
|
// Navigate to the split view with diagram
|
|
navigate(`/exchanges/${row.applicationName}/${row.routeId}/${row.executionId}`)
|
|
}
|
|
|
|
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
|
|
if (row.status === 'FAILED') return 'error'
|
|
return undefined
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Scrollable content */}
|
|
<div className={styles.content}>
|
|
{/* KPI strip */}
|
|
<KpiStrip items={kpiItems} />
|
|
|
|
{/* Exchanges table */}
|
|
<div className={styles.tableSection}>
|
|
<div className={styles.tableHeader}>
|
|
<span className={styles.tableTitle}>
|
|
{textFilter ? (
|
|
<>
|
|
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
|
Search: “{textFilter}”
|
|
<button
|
|
className={styles.clearSearch}
|
|
onClick={() => setSearchParams({})}
|
|
title="Clear search"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</>
|
|
) : 'Recent Exchanges'}
|
|
</span>
|
|
<div className={styles.tableRight}>
|
|
<span className={styles.tableMeta}>
|
|
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
|
</span>
|
|
{!textFilter && <Badge label="LIVE" color="success" />}
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
onRowClick={handleRowClick}
|
|
selectedId={selectedId}
|
|
sortable
|
|
flush
|
|
onSortChange={handleSortChange}
|
|
rowAccent={handleRowAccent}
|
|
expandedContent={(row: Row) =>
|
|
row.errorMessage ? (
|
|
<div className={styles.inlineError}>
|
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
|
<div>
|
|
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
|
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Shortcuts bar */}
|
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
|
</>
|
|
)
|
|
}
|