diff --git a/src/App.tsx b/src/App.tsx index c07964a..0b38c47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,10 @@ +import { Routes, Route } from 'react-router-dom' +import { Dashboard } from './pages/Dashboard/Dashboard' + export default function App() { - return
Cameleer3 Design System
+ return ( + + } /> + + ) } diff --git a/src/pages/Dashboard/Dashboard.module.css b/src/pages/Dashboard/Dashboard.module.css new file mode 100644 index 0000000..e840a83 --- /dev/null +++ b/src/pages/Dashboard/Dashboard.module.css @@ -0,0 +1,231 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +/* Health strip */ +.healthStrip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +/* Filter bar spacing */ +.filterBar { + margin-bottom: 16px; +} + +/* Table section */ +.tableSection { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.tableTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.tableRight { + display: flex; + align-items: center; + gap: 10px; +} + +.tableMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Status cell */ +.statusCell { + display: flex; + align-items: center; + gap: 5px; +} + +/* Route cells */ +.routeName { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); +} + +.routeGroup { + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Customer text */ +.customerText { + color: var(--text-secondary); +} + +/* Duration color classes */ +.durFast { + color: var(--success); +} + +.durNormal { + color: var(--text-secondary); +} + +.durSlow { + color: var(--warning); +} + +.durBreach { + color: var(--error); +} + +/* Agent badge in table */ +.agentBadge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.agentDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #5db866; + box-shadow: 0 0 4px rgba(93, 184, 102, 0.4); + flex-shrink: 0; +} + +/* Inline error preview below row */ +.inlineError { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 12px; + background: var(--error-bg); + border-left: 3px solid var(--error-border); +} + +.inlineErrorIcon { + color: var(--error); + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; +} + +.inlineErrorText { + font-size: 11px; + color: var(--error); + font-family: var(--font-mono); + line-height: 1.4; +} + +.inlineErrorHint { + font-size: 10px; + color: var(--text-muted); + margin-top: 3px; +} + +/* Detail panel: overview tab */ +.overviewTab { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.overviewRow { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.overviewLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + width: 100px; + flex-shrink: 0; + padding-top: 2px; +} + +.errorBlock { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 4px; +} + +.errorClass { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--error); + margin-bottom: 6px; +} + +.errorMessage { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.5; + font-family: var(--font-mono); +} + +/* Detail panel: processors tab */ +.processorsTab { + padding: 16px; +} + +/* Detail panel: exchange tab */ +.exchangeTab { + padding: 16px; +} + +/* Detail panel: error tab */ +.errorTab { + padding: 16px; +} + +.emptyTabMsg { + font-size: 12px; + color: var(--text-muted); + text-align: center; + padding: 40px 0; +} + +.errorPre { + font-family: var(--font-mono); + font-size: 11px; + color: var(--error); + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: var(--radius-sm); + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + margin-top: 8px; +} diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..70d5fcd --- /dev/null +++ b/src/pages/Dashboard/Dashboard.tsx @@ -0,0 +1,460 @@ +import { useState, useMemo } from 'react' +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 { FilterBar } from '../../design-system/composites/FilterBar/FilterBar' +import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar' +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 { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette' +import type { SearchResult } from '../../design-system/composites/CommandPalette/types' +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' + +// Mock data +import { executions, type Execution } from '../../mocks/executions' +import { routes } from '../../mocks/routes' +import { agents } from '../../mocks/agents' +import { kpiMetrics } from '../../mocks/metrics' + +// ─── Sidebar app list (static) ─────────────────────────────────────────────── +const APPS = [ + { id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 }, + { id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 }, + { id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 }, + { id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 }, +] + +// ─── Sidebar routes (top 3) ─────────────────────────────────────────────────── +const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({ + id: r.id, + name: r.name, + execCount: r.execCount, +})) + +// ─── 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: Execution['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: Execution['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[] = [ + { + key: 'status', + header: 'Status', + width: '80px', + render: (_, row) => ( + + + {statusLabel(row.status)} + + ), + }, + { + key: 'route', + header: 'Route', + sortable: true, + render: (_, row) => ( +
+
{row.route}
+
{row.routeGroup}
+
+ ), + }, + { + key: 'orderId', + header: 'Order ID', + sortable: true, + render: (_, row) => ( + {row.orderId} + ), + }, + { + key: 'customer', + header: 'Customer', + render: (_, row) => ( + {row.customer} + ), + }, + { + key: 'timestamp', + header: 'Started', + sortable: true, + render: (_, row) => ( + {formatTimestamp(row.timestamp)} + ), + }, + { + key: 'durationMs', + header: 'Duration', + sortable: true, + render: (_, row) => ( + + {formatDuration(row.durationMs)} + + ), + }, + { + key: 'agent', + header: 'Agent', + render: (_, row) => ( + + + {row.agent} + + ), + }, +] + +function durationClass(ms: number, status: Execution['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 +} + +// ─── Build CommandPalette search data ──────────────────────────────────────── +function buildSearchData(): SearchResult[] { + const results: SearchResult[] = [] + + for (const exec of executions) { + results.push({ + id: exec.id, + category: 'execution', + title: `${exec.orderId} — ${exec.route}`, + badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }], + meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`, + timestamp: formatTimestamp(exec.timestamp), + }) + } + + for (const route of routes) { + results.push({ + id: route.id, + category: 'route', + title: route.name, + badges: [{ label: route.group }], + meta: `${route.execCount.toLocaleString()} executions · ${route.successRate}% success`, + }) + } + + for (const agent of agents) { + results.push({ + id: agent.id, + category: 'agent', + title: agent.name, + badges: [{ label: agent.status }], + meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`, + }) + } + + return results +} + +const SEARCH_DATA = buildSearchData() + +// ─── Filter options ─────────────────────────────────────────────────────────── +const STATUS_FILTERS = [ + { label: 'All', value: 'all', count: executions.length }, + { label: 'OK', value: 'completed', count: executions.filter((e) => e.status === 'completed').length, color: 'success' as const }, + { label: 'Warn', value: 'warning', count: executions.filter((e) => e.status === 'warning').length }, + { label: 'Error', value: 'failed', count: executions.filter((e) => e.status === 'failed').length, color: 'error' as const }, + { label: 'Running', value: 'running', count: executions.filter((e) => e.status === 'running').length, color: 'running' as const }, +] + +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 [activeItem, setActiveItem] = useState('order-service') + const [activeFilters, setActiveFilters] = useState([]) + const [search, setSearch] = useState('') + const [selectedId, setSelectedId] = useState() + const [panelOpen, setPanelOpen] = useState(false) + const [selectedExecution, setSelectedExecution] = useState(null) + const [paletteOpen, setPaletteOpen] = useState(false) + + // Filter executions + const filteredExecutions = useMemo(() => { + let data = executions + + const statusFilter = activeFilters.find((f) => + ['completed', 'failed', 'running', 'warning', 'all'].includes(f.value), + ) + if (statusFilter && statusFilter.value !== 'all') { + data = data.filter((e) => e.status === statusFilter.value) + } + + if (search.trim()) { + const q = search.toLowerCase() + data = data.filter( + (e) => + e.orderId.toLowerCase().includes(q) || + e.route.toLowerCase().includes(q) || + e.customer.toLowerCase().includes(q) || + e.correlationId.toLowerCase().includes(q) || + (e.errorMessage?.toLowerCase().includes(q) ?? false), + ) + } + + return data + }, [activeFilters, search]) + + function handleRowClick(row: Execution) { + setSelectedId(row.id) + setSelectedExecution(row) + setPanelOpen(true) + } + + function handleRowAccent(row: Execution): 'error' | 'warning' | undefined { + if (row.status === 'failed') return 'error' + if (row.status === 'warning') return 'warning' + return undefined + } + + // Build detail panel tabs for selected execution + const detailTabs = selectedExecution + ? [ + { + label: 'Overview', + value: 'overview', + content: ( +
+
+ Order ID + {selectedExecution.orderId} +
+
+ Route + {selectedExecution.route} +
+
+ Status + + + {statusLabel(selectedExecution.status)} + +
+
+ Duration + {formatDuration(selectedExecution.durationMs)} +
+
+ Customer + {selectedExecution.customer} +
+
+ Agent + {selectedExecution.agent} +
+
+ Correlation ID + {selectedExecution.correlationId} +
+
+ Timestamp + {selectedExecution.timestamp.toISOString()} +
+ {selectedExecution.errorMessage && ( +
+
{selectedExecution.errorClass}
+
{selectedExecution.errorMessage}
+
+ )} +
+ ), + }, + { + label: 'Processors', + value: 'processors', + content: ( +
+ +
+ ), + }, + { + label: 'Exchange', + value: 'exchange', + content: ( +
+
Exchange snapshot not available in mock mode.
+
+ ), + }, + { + label: 'Error', + value: 'error', + content: ( +
+ {selectedExecution.errorMessage ? ( + <> +
{selectedExecution.errorClass}
+
{selectedExecution.errorMessage}
+ + ) : ( +
No error for this execution.
+ )} +
+ ), + }, + ] + : [] + + return ( + + } + detail={ + selectedExecution ? ( + setPanelOpen(false)} + title={`${selectedExecution.orderId} — ${selectedExecution.route}`} + tabs={detailTabs} + /> + ) : undefined + } + > + {/* Top bar */} + setPaletteOpen(true)} + /> + + {/* Scrollable content */} +
+ + {/* Health strip */} +
+ {kpiMetrics.map((kpi, i) => ( + + ))} +
+ + {/* Filter bar */} + + + {/* Executions table */} +
+
+ Recent Executions +
+ + {filteredExecutions.length.toLocaleString()} of {executions.length.toLocaleString()} executions + + +
+
+ + + row.errorMessage ? ( +
+ +
+
{row.errorMessage}
+
Click to view full stack trace
+
+
+ ) : null + } + /> +
+
+ + {/* Command palette */} + setPaletteOpen(false)} + onSelect={() => setPaletteOpen(false)} + data={SEARCH_DATA} + onOpen={() => setPaletteOpen(true)} + /> + + {/* Shortcuts bar */} + +
+ ) +}