Files
design-system/src/pages/Dashboard/Dashboard.tsx
2026-04-02 18:09:16 +02:00

444 lines
16 KiB
TypeScript

import React, { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
import styles from './Dashboard.module.css'
// Layout
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
import { KpiStrip } from '../../design-system/composites/KpiStrip/KpiStrip'
import type { KpiItem } from '../../design-system/composites/KpiStrip/KpiStrip'
// Primitives
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges'
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
// Route → Application lookup
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── KPI mapping ─────────────────────────────────────────────────────────────
const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
amber: 'var(--amber)',
success: 'var(--success)',
error: 'var(--error)',
running: 'var(--running)',
warning: 'var(--warning)',
}
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
up: <TrendingUp size={12} />,
down: <TrendingDown size={12} />,
neutral: <ArrowRight size={12} />,
}
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
switch (sentiment) {
case 'good': return 'success'
case 'bad': return 'error'
case 'neutral': return 'muted'
}
}
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
label: m.label,
value: m.unit ? `${m.value} ${m.unit}` : m.value,
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
subtitle: m.detail,
sparkline: m.sparkline,
borderColor: ACCENT_TO_COLOR[m.accent],
}))
// ─── 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(date: Date): string {
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: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
// ─── Table columns (base, without navigate action) ──────────────────────────
const BASE_COLUMNS: Column<Exchange>[] = [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_, row) => (
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
</span>
),
},
{
key: 'route',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeName}>{row.route}</span>
),
},
{
key: 'routeGroup',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appName}>{ROUTE_TO_APP.get(row.route) ?? row.routeGroup}</span>
),
},
{
key: 'id',
header: 'Exchange ID',
sortable: true,
render: (_, row) => (
<MonoText size="xs">{row.id}</MonoText>
),
},
{
key: 'timestamp',
header: 'Started',
sortable: true,
render: (_, row) => (
<MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>
),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
{formatDuration(row.durationMs)}
</MonoText>
),
},
{
key: 'agent',
header: 'Agent',
render: (_, row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
{row.agent}
</span>
),
},
]
function durationClass(ms: number, status: Exchange['status']): 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
}
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' },
{ keys: 'Enter', label: 'Open detail' },
{ keys: 'Esc', label: 'Close panel' },
]
// ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() {
const { id: appId, routeId } = useParams<{ id: string; routeId: string }>()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
// Build columns with inspect action as second column
const COLUMNS: Column<Exchange>[] = useMemo(() => {
const inspectCol: Column<Exchange> = {
key: 'correlationId' as keyof Exchange,
header: '',
width: '36px',
render: (_, row) => (
<button
className={styles.inspectLink}
title="Inspect exchange"
onClick={(e) => {
e.stopPropagation()
navigate(`/exchanges/${row.id}`)
}}
>
<ExternalLink size={14} />
</button>
),
}
const [statusCol, ...rest] = BASE_COLUMNS
return [statusCol, inspectCol, ...rest]
}, [navigate])
const { isInTimeRange, statusFilters } = useGlobalFilters()
// Build set of route IDs belonging to the selected app (if any)
const appRouteIds = useMemo(() => {
if (!appId) return null
const app = SIDEBAR_APPS.find((a) => a.id === appId)
if (!app) return null
return new Set(app.routes.map((r) => r.id))
}, [appId])
// Scope all data to the selected app (and optionally route)
const scopedExchanges = useMemo(() => {
if (routeId) return exchanges.filter((e) => e.route === routeId)
if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds, routeId])
// Filter exchanges (scoped + global filters)
const filteredExchanges = useMemo(() => {
let data = scopedExchanges
// Time range filter
data = data.filter((e) => isInTimeRange(e.timestamp))
// Status filter
if (statusFilters.size > 0) {
data = data.filter((e) => statusFilters.has(e.status))
}
return data
}, [scopedExchanges, isInTimeRange, statusFilters])
function handleRowClick(row: Exchange) {
setSelectedId(row.id)
setSelectedExchange(row)
setPanelOpen(true)
}
function handleRowAccent(row: Exchange): 'error' | 'warning' | undefined {
if (row.status === 'failed') return 'error'
if (row.status === 'warning') return 'warning'
return undefined
}
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// Build RouteFlow nodes from exchange processors
const routeNodes: RouteNode[] = selectedExchange
? selectedExchange.processors.map((p) => ({
name: p.name,
type: toRouteNodeType(p.type),
durationMs: p.durationMs,
status: p.status,
}))
: []
// Collect errors from processors
const processorErrors = selectedExchange
? selectedExchange.processors.filter((p) => p.status === 'fail')
: []
const hasExchangeError = selectedExchange?.errorMessage != null
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
return (
<>
{/* Top bar */}
<TopBar
breadcrumb={
routeId
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
: appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Health 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}>
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={COLUMNS}
data={filteredExchanges}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
fillHeight
rowAccent={handleRowAccent}
expandedContent={(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} />
{/* Detail panel (portals itself) */}
{selectedExchange && (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${selectedExchange.orderId}${selectedExchange.route}`}
>
{/* Link to full detail page */}
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
>
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
</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(selectedExchange.status)} />
<span>{statusLabel(selectedExchange.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(selectedExchange.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{selectedExchange.route}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Customer</span>
<MonoText size="sm">{selectedExchange.customer}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{selectedExchange.agent}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{selectedExchange.correlationId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{selectedExchange.timestamp.toISOString()}</MonoText>
</div>
</div>
</div>
{/* Errors */}
{totalErrors > 0 && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Errors
{totalErrors > 1 && (
<Badge label={`+${totalErrors - 1} more`} color="error" variant="outlined" />
)}
</div>
<div className={styles.errorBlock}>
<div className={styles.errorClass}>
{selectedExchange.errorClass ?? processorErrors[0]?.name}
</div>
<div className={styles.errorMessage}>
{selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`}
</div>
</div>
</div>
)}
{/* Route Flow */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
<RouteFlow nodes={routeNodes} />
</div>
{/* Processor Timeline */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(selectedExchange.durationMs)}</span>
</div>
<ProcessorTimeline
processors={selectedExchange.processors}
totalMs={selectedExchange.durationMs}
/>
</div>
</DetailPanel>
)}
</>
)
}