444 lines
16 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|