Files
cameleer-server/ui/src/pages/Dashboard/Dashboard.tsx

196 lines
7.2 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react';
import { useParams } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, Sparkline,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
import type { ExecutionSummary } from '../../api/types';
import styles from './Dashboard.module.css';
interface Row extends ExecutionSummary { id: string }
export default function Dashboard() {
const { appId, routeId } = useParams();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailTab, setDetailTab] = useState('overview');
const [processorIdx, setProcessorIdx] = useState<number | null>(null);
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
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,
group: appId || undefined,
offset: 0, limit: 50,
}, true);
const { data: detail } = useExecutionDetail(selectedId);
const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx);
const rows: Row[] = useMemo(() =>
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
);
const sparklineData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries],
);
const columns: Column<Row>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'groupName', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{
key: 'durationMs', header: 'Duration', sortable: true,
render: (v) => `${v}ms`,
},
];
const detailTabs = detail ? [
{
label: 'Overview', value: 'overview',
content: (
<>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Details</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Exchange ID</span>
<MonoText size="sm">{detail.executionId}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<span>{detail.durationMs}ms</span>
</div>
</div>
</div>
{detail.errorMessage && (
<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>
)}
</>
),
},
{
label: 'Processors', value: 'processors',
content: (() => {
const procList = detail.processors?.length ? detail.processors : (detail.children ?? []);
return procList.length ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined}
/>
) : <div style={{ padding: '1rem' }}>No processor data</div>;
})(),
},
] : [];
return (
<div>
<div className={styles.healthStrip}>
<StatCard
label="Throughput"
value={timeWindowSeconds > 0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'}
sparkline={sparklineData}
/>
<StatCard
label="Error Rate"
value={(stats?.totalCount ?? 0) > 0 ? `${((stats?.failedCount ?? 0) / stats!.totalCount * 100).toFixed(1)}%` : '0.0%'}
accent="error"
/>
<StatCard
label="Avg Latency"
value={`${stats?.avgDurationMs ?? 0}ms`}
/>
<StatCard
label="P99 Latency"
value={`${stats?.p99LatencyMs ?? 0}ms`}
accent="warning"
/>
<StatCard
label="In-Flight"
value={stats?.activeCount ?? 0}
accent="running"
/>
</div>
<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} results</span>
</div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); setProcessorIdx(null); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</div>
<DetailPanel
open={!!selectedId}
onClose={() => setSelectedId(null)}
title={selectedId ? `Exchange ${selectedId.slice(0, 12)}...` : ''}
tabs={detailTabs}
/>
</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;
}