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>
499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
import { useState, useMemo } 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, RouteNode } 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 } 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<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: '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 [selectedId, setSelectedId] = useState<string | undefined>()
|
|
const [panelOpen, setPanelOpen] = useState(false)
|
|
|
|
const { timeRange, statusFilters } = useGlobalFilters()
|
|
const timeFrom = timeRange.start.toISOString()
|
|
const timeTo = timeRange.end.toISOString()
|
|
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
|
|
|
|
// ─── 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,
|
|
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<Row>[] = useMemo(() => {
|
|
const inspectCol: Column<Row> = {
|
|
key: 'correlationId',
|
|
header: '',
|
|
width: '36px',
|
|
render: (_: unknown, row: Row) => (
|
|
<button
|
|
className={styles.inspectLink}
|
|
title="Inspect exchange"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
navigate(`/exchanges/${row.executionId}`)
|
|
}}
|
|
>
|
|
↗
|
|
</button>
|
|
),
|
|
}
|
|
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 routeNodes: RouteNode[] = useMemo(() => {
|
|
if (diagram?.nodes) {
|
|
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
|
|
}
|
|
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 */}
|
|
<div className={styles.content}>
|
|
{/* KPI strip */}
|
|
<KpiStrip items={kpiItems} />
|
|
|
|
{/* Exchanges table */}
|
|
<div className={styles.tableSection}>
|
|
<div className={styles.tableHeader}>
|
|
<span className={styles.tableTitle}>Recent Exchanges</span>
|
|
<div className={styles.tableRight}>
|
|
<span className={styles.tableMeta}>
|
|
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
|
</span>
|
|
<Badge label="LIVE" color="success" />
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
onRowClick={handleRowClick}
|
|
selectedId={selectedId}
|
|
sortable
|
|
flush
|
|
rowAccent={handleRowAccent}
|
|
expandedContent={(row: Row) =>
|
|
row.errorMessage ? (
|
|
<div className={styles.inlineError}>
|
|
<span className={styles.inlineErrorIcon}>{'\u26A0'}</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} />
|
|
|
|
{/* Detail panel */}
|
|
{selectedRow && detail && (
|
|
<DetailPanel
|
|
open={panelOpen}
|
|
onClose={() => setPanelOpen(false)}
|
|
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
|
>
|
|
{/* Link to full detail page */}
|
|
<div className={styles.panelSection}>
|
|
<button
|
|
className={styles.openDetailLink}
|
|
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
|
>
|
|
Open full details →
|
|
</button>
|
|
</div>
|
|
|
|
{/* Overview */}
|
|
<div className={styles.panelSection}>
|
|
<div className={styles.panelSectionTitle}>Overview</div>
|
|
<div className={styles.overviewGrid}>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Status</span>
|
|
<span className={styles.statusCell}>
|
|
<StatusDot variant={statusToVariant(detail.status)} />
|
|
<span>{statusLabel(detail.status)}</span>
|
|
</span>
|
|
</div>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Duration</span>
|
|
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
|
|
</div>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Route</span>
|
|
<span>{detail.routeId}</span>
|
|
</div>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Agent</span>
|
|
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
|
</div>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Correlation</span>
|
|
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
|
</div>
|
|
<div className={styles.overviewRow}>
|
|
<span className={styles.overviewLabel}>Timestamp</span>
|
|
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Errors */}
|
|
{errorMsg && (
|
|
<div className={styles.panelSection}>
|
|
<div className={styles.panelSectionTitle}>Errors</div>
|
|
<div className={styles.errorBlock}>
|
|
<div className={styles.errorClass}>{errorClass}</div>
|
|
<div className={styles.errorMessage}>{errorMsg}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Route Flow */}
|
|
<div className={styles.panelSection}>
|
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
|
{routeNodes.length > 0 ? (
|
|
<RouteFlow nodes={routeNodes} />
|
|
) : (
|
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Processor Timeline */}
|
|
<div className={styles.panelSection}>
|
|
<div className={styles.panelSectionTitle}>
|
|
Processor Timeline
|
|
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
|
</div>
|
|
{flatProcs.length > 0 ? (
|
|
<ProcessorTimeline
|
|
processors={flatProcs}
|
|
totalMs={detail.durationMs}
|
|
/>
|
|
) : (
|
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
|
)}
|
|
</div>
|
|
</DetailPanel>
|
|
)}
|
|
</>
|
|
)
|
|
}
|