Files
design-system/src/pages/Dashboard/Dashboard.tsx

371 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import styles from './Dashboard.module.css'
// Layout
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
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'
// Primitives
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
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 } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── 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 {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
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 ────────────────────────────────────────────────────────────
const 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) => (
<div>
<div className={styles.routeName}>{row.route}</div>
<div className={styles.routeGroup}>{row.routeGroup}</div>
</div>
),
},
{
key: 'orderId',
header: 'Order ID',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.orderId}</MonoText>
),
},
{
key: 'customer',
header: 'Customer',
render: (_, row) => (
<MonoText size="xs" className={styles.customerText}>{row.customer}</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 } = useParams<{ id: string }>()
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
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
const scopedExchanges = useMemo(() => {
if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds])
// 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
}
// Build detail panel tabs for selected exchange
const detailTabs = selectedExchange
? [
{
label: 'Overview',
value: 'overview',
content: (
<div className={styles.overviewTab}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Order ID</span>
<MonoText size="sm">{selectedExchange.orderId}</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}>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}>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 ID</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>
{selectedExchange.errorMessage && (
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
<div className={styles.errorMessage}>{selectedExchange.errorMessage}</div>
</div>
)}
</div>
),
},
{
label: 'Processors',
value: 'processors',
content: (
<div className={styles.processorsTab}>
<ProcessorTimeline
processors={selectedExchange.processors}
totalMs={selectedExchange.durationMs}
/>
</div>
),
},
{
label: 'Exchange',
value: 'exchange',
content: (
<div className={styles.exchangeTab}>
<div className={styles.emptyTabMsg}>Exchange snapshot not available in mock mode.</div>
</div>
),
},
{
label: 'Error',
value: 'error',
content: (
<div className={styles.errorTab}>
{selectedExchange.errorMessage ? (
<>
<div className={styles.errorClass}>{selectedExchange.errorClass}</div>
<pre className={styles.errorPre}>{selectedExchange.errorMessage}</pre>
</>
) : (
<div className={styles.emptyTabMsg}>No error for this exchange.</div>
)}
</div>
),
},
]
: []
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
detail={
selectedExchange ? (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${selectedExchange.orderId}${selectedExchange.route}`}
tabs={detailTabs}
/>
) : undefined
}
>
{/* Top bar */}
<TopBar
breadcrumb={appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Health strip */}
<div className={styles.healthStrip}>
{kpiMetrics.map((kpi, i) => (
<StatCard
key={i}
label={kpi.label}
value={kpi.value}
detail={kpi.detail}
trend={kpi.trend}
trendValue={kpi.trendValue}
accent={kpi.accent}
sparkline={kpi.sparkline}
/>
))}
</div>
{/* 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
rowAccent={handleRowAccent}
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></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} />
</AppShell>
)
}