Files
cameleer-server/ui/src/pages/Dashboard/Dashboard.tsx
hsiegeln 20ee448f4e
All checks were successful
CI / build (push) Successful in 56s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m43s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
fix: OpenSearch status field mismatch, adopt RouteFlow flows prop
Fix admin OpenSearch page always showing "Disconnected" by aligning
frontend field names (reachable/nodeCount/host) with backend DTO.

Update design system to v0.1.10 and adopt the new multi-flow RouteFlow
API — error-handler nodes now render as labeled segments with error
variant instead of relying on legacy auto-separation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:34:58 +01:00

504 lines
18 KiB
TypeScript

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<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 [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)
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<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}`)
}}
>
&#x2197;
</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 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 */}
<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
onSortChange={handleSortChange}
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 — auto-portals to AppShell level via design system */}
{selectedRow && detail && (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
>
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
>
Open full details &#x2192;
</button>
</div>
<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>
{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>
)}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{routeFlows.length > 0 ? (
<RouteFlow flows={routeFlows} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}
</div>
<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>
)}
</>
)
}