feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
.healthStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Filter bar spacing */
|
||||
.filterBar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Table section */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -39,6 +47,93 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/* Application column */
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
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 sections */
|
||||
.panelSection {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
@@ -59,19 +154,21 @@
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelSectionMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Overview grid */
|
||||
.overviewGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,45 +192,67 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Error block */
|
||||
.errorBlock {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.errorClass {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--error);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Inspect exchange icon in table */
|
||||
.inspectLink {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.inspectLink:hover {
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Open full details link in panel */
|
||||
.openDetailLink {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #c6820e);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.openDetailLink:hover {
|
||||
color: var(--amber-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,186 +1,417 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './Dashboard.module.css';
|
||||
DataTable,
|
||||
DetailPanel,
|
||||
ShortcutsBar,
|
||||
ProcessorTimeline,
|
||||
RouteFlow,
|
||||
KpiStrip,
|
||||
StatusDot,
|
||||
MonoText,
|
||||
Badge,
|
||||
useGlobalFilters,
|
||||
} from '@cameleer/design-system'
|
||||
import type { Column, KpiItem, RouteNode } 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 } from '../../utils/diagram-mapping'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
interface Row extends ExecutionSummary { id: string }
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||
interface Row extends ExecutionSummary {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
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`
|
||||
}
|
||||
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
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}`
|
||||
}
|
||||
|
||||
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,
|
||||
offset: 0, limit: 50,
|
||||
}, true);
|
||||
const { data: detail } = useExecutionDetail(selectedId);
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Row[] = useMemo(() =>
|
||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
);
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'OK'
|
||||
case 'FAILED': return 'ERR'
|
||||
case 'RUNNING': return 'RUN'
|
||||
default: return 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
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
|
||||
}
|
||||
|
||||
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;
|
||||
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
|
||||
}
|
||||
|
||||
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]);
|
||||
// ─── Table columns (base, without inspect action) ────────────────────────────
|
||||
|
||||
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 columns: Column<Row>[] = [
|
||||
function buildBaseColumns(): Column<Row>[] {
|
||||
return [
|
||||
{
|
||||
key: 'status', header: 'Status', width: '80px',
|
||||
render: (v, row) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
||||
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: '_inspect' as any, header: '', width: '36px',
|
||||
render: (_v, row) => (
|
||||
<a
|
||||
href={`/exchanges/${row.executionId}`}
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
||||
className={styles.inspectLink}
|
||||
title="Open full details"
|
||||
>↗</a>
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.routeName}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
|
||||
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
|
||||
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
|
||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
|
||||
{
|
||||
key: 'durationMs', header: 'Duration', sortable: true,
|
||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
||||
key: 'applicationName',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agentId', header: 'Agent',
|
||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
||||
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 procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
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 { timeRange, statusFilters } = useGlobalFilters()
|
||||
const timeFrom = timeRange.start.toISOString()
|
||||
const timeTo = timeRange.end.toISOString()
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
|
||||
|
||||
// ─── 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,
|
||||
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}`)
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</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 routeNodes: RouteNode[] = useMemo(() => {
|
||||
if (diagram?.nodes) {
|
||||
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
|
||||
}
|
||||
return []
|
||||
}, [diagram, procList])
|
||||
|
||||
const flatProcs = useMemo(() => flattenProcessors(procList), [procList])
|
||||
|
||||
// Error info from detail
|
||||
const errorClass = detail?.errorMessage?.split(':')[0] ?? ''
|
||||
const errorMsg = detail?.errorMessage ?? ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.healthStrip}>
|
||||
<StatCard
|
||||
label="Exchanges"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail={`${successRate.toFixed(1)}% success rate`}
|
||||
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
|
||||
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
|
||||
sparkline={sparkExchanges}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
|
||||
trend={successRateDelta >= 0 ? 'up' : 'down'}
|
||||
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
|
||||
accent="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={failedCount}
|
||||
detail={`${failedCount} errors in selected period`}
|
||||
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
|
||||
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
|
||||
sparkline={sparkErrors}
|
||||
accent="error"
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={throughput.toFixed(1)}
|
||||
detail={`${throughput.toFixed(1)} msg/s`}
|
||||
sparkline={sparkThroughput}
|
||||
accent="running"
|
||||
/>
|
||||
<StatCard
|
||||
label="Latency p99"
|
||||
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
|
||||
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
|
||||
sparkline={sparkLatency}
|
||||
accent="warning"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
{/* KPI strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
<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} of {searchResult?.total ?? 0} exchanges</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
{/* 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
|
||||
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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
||||
selectedId={selectedId ?? undefined}
|
||||
sortable
|
||||
pageSize={25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedId && detail && (
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedRow && detail && (
|
||||
<DetailPanel
|
||||
key={selectedId}
|
||||
open={true}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
||||
className={styles.detailPanelOverride}
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||
>
|
||||
{/* Open full details link */}
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
@@ -196,9 +427,9 @@ export default function Dashboard() {
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<span>{detail.status}</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(detail.status)} />
|
||||
<span>{statusLabel(detail.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
@@ -211,44 +442,38 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
||||
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
||||
<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).toLocaleString() : '—'}</MonoText>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{detail.errorMessage && (
|
||||
{errorMsg && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Errors</div>
|
||||
<Alert variant="error">
|
||||
<strong>{detail.errorMessage.split(':')[0]}</strong>
|
||||
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
|
||||
</Alert>
|
||||
{detail.errorStackTrace && (
|
||||
<Collapsible title="Stack Trace">
|
||||
<CodeBlock content={detail.errorStackTrace} />
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>{errorClass}</div>
|
||||
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{diagram ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, _i) => {}}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
||||
{routeNodes.length > 0 ? (
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
@@ -257,33 +482,17 @@ export default function Dashboard() {
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{procList.length ? (
|
||||
{flatProcs.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={flattenProcessors(procList)}
|
||||
processors={flatProcs}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user