Files
cameleer-server/ui/src/pages/DashboardTab/DashboardL2.tsx
hsiegeln 474738a894
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 1m25s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
fix: resolve TypeScript strict mode errors failing CI
- StatusDot: status → variant (correct prop name)
- Badge: color="muted" → color="auto" (valid BadgeColor)
- AreaChart: remove stacked prop (not in AreaChartProps)
- DataTable: remove defaultSort prop (not in DataTableProps)
- TopError → ErrorRow with id field (DataTable requires T extends {id})
- slaCompliance: type assertion for runtime field not in TS schema
- PunchcardHeatmap Scatter shape: proper typing for custom renderer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:26:26 +02:00

453 lines
15 KiB
TypeScript

import { useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
Card,
Sparkline,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import {
useTimeseriesByRoute,
useTopErrors,
useAppSettings,
usePunchcard,
} from '../../api/queries/dashboard';
import type { TopError } from '../../api/queries/dashboard';
import { Treemap } from './Treemap';
import type { TreemapItem } from './Treemap';
import { PunchcardHeatmap } from './PunchcardHeatmap';
import type { RouteMetrics } from '../../api/types';
import {
trendArrow,
trendIndicator,
formatThroughput,
formatSlaCompliance,
formatRelativeTime,
} from './dashboard-utils';
import styles from './DashboardTab.module.css';
// ── Route table row type ────────────────────────────────────────────────────
interface RouteRow {
id: string;
routeId: string;
exchangeCount: number;
successRate: number;
avgDurationMs: number;
p99DurationMs: number;
slaCompliance: number;
sparkline: number[];
}
// ── Route performance columns ───────────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteRow>[] = [
{
key: 'routeId',
header: 'Route ID',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.routeId}</MonoText>
),
},
{
key: 'exchangeCount',
header: 'Throughput',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success%',
sortable: true,
render: (_, row) => {
const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'avgDurationMs',
header: 'Avg(ms)',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'P99(ms)',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
},
},
{
key: 'slaCompliance',
header: 'SLA%',
sortable: true,
render: (_, row) => {
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
},
},
{
key: 'sparkline',
header: 'Sparkline',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Top errors columns ──────────────────────────────────────────────────────
type ErrorRow = TopError & { id: string };
const ERROR_COLUMNS: Column<ErrorRow>[] = [
{
key: 'errorType',
header: 'Error Type',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.errorType}</MonoText>
),
},
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.routeId ?? '\u2014'}</MonoText>
),
},
{
key: 'count',
header: 'Count',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
),
},
{
key: 'velocity',
header: 'Velocity',
sortable: true,
render: (_, row) => {
const arrow = trendArrow(row.trend);
const cls = row.trend === 'accelerating' ? styles.rateBad
: row.trend === 'decelerating' ? styles.rateGood
: styles.rateNeutral;
return <MonoText size="sm" className={cls}>{row.velocity.toFixed(1)}/min {arrow}</MonoText>;
},
},
{
key: 'lastSeen',
header: 'Last Seen',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{formatRelativeTime(row.lastSeen)}</MonoText>
),
},
];
// ── Build KPI items ─────────────────────────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
p99LatencyMs: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
slaThresholdMs: number,
throughputSparkline: number[],
latencySparkline: number[],
errors: TopError[] | undefined,
windowSeconds: number,
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const prevFailedCount = stats?.prevFailedCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
// Throughput
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
// Success Rate
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const prevSuccessRate = prevTotalCount > 0 ? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100 : 100;
const successTrend = trendIndicator(successRate, prevSuccessRate);
// P99 Latency
const latencyTrend = trendIndicator(p99Ms, prevP99Ms);
// SLA Compliance — percentage of exchanges under threshold
// Approximate from p99: if p99 < threshold, ~99%+ are compliant
const slaCompliance = p99Ms <= slaThresholdMs ? 99.9 : Math.max(0, 100 - ((p99Ms - slaThresholdMs) / slaThresholdMs) * 10);
// Error Velocity — aggregate from top errors
const errorList = errors ?? [];
const totalVelocity = errorList.reduce((sum, e) => sum + e.velocity, 0);
const hasAccelerating = errorList.some((e) => e.trend === 'accelerating');
const allDecelerating = errorList.length > 0 && errorList.every((e) => e.trend === 'decelerating');
const velocityTrendLabel = hasAccelerating ? '\u25B2' : allDecelerating ? '\u25BC' : '\u2500\u2500';
const velocityVariant = hasAccelerating ? 'error' as const : allDecelerating ? 'success' as const : 'muted' as const;
return [
{
label: 'Throughput',
value: formatThroughput(totalCount, windowSeconds),
trend: {
label: throughputTrend.label,
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(2)}%`,
trend: {
label: successTrend.label,
variant: successTrend.direction === 'up' ? 'success' as const : successTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
borderColor: successRate >= 99 ? 'var(--success)' : successRate >= 95 ? 'var(--warning)' : 'var(--error)',
},
{
label: 'P99 Latency',
value: `${Math.round(p99Ms)}ms`,
trend: {
label: latencyTrend.label,
variant: latencyTrend.direction === 'up' ? 'error' as const : latencyTrend.direction === 'down' ? 'success' as const : 'muted' as const,
},
sparkline: latencySparkline,
borderColor: p99Ms > slaThresholdMs ? 'var(--error)' : 'var(--success)',
},
{
label: 'SLA Compliance',
value: formatSlaCompliance(slaCompliance),
trend: {
label: slaCompliance >= 99 ? 'OK' : 'BREACH',
variant: slaCompliance >= 99 ? 'success' as const : 'error' as const,
},
subtitle: `Threshold: ${slaThresholdMs}ms`,
borderColor: slaCompliance >= 99 ? 'var(--success)' : slaCompliance >= 95 ? 'var(--warning)' : 'var(--error)',
},
{
label: 'Error Velocity',
value: `${totalVelocity.toFixed(1)}/min`,
trend: {
label: velocityTrendLabel,
variant: velocityVariant,
},
subtitle: `${errorList.length} error type${errorList.length !== 1 ? 's' : ''} tracked`,
borderColor: hasAccelerating ? 'var(--error)' : allDecelerating ? 'var(--success)' : 'var(--text-muted)',
},
];
}
// ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL2() {
const { appId } = useParams<{ appId: string }>();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
// Data hooks
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId);
const { data: errors } = useTopErrors(timeFrom, timeTo, appId);
const { data: punchcardData } = usePunchcard(appId);
const { data: appSettings } = useAppSettings(appId);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
// Route performance table rows
const routeRows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: RouteMetrics) => ({
id: m.routeId,
routeId: m.routeId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
slaCompliance: (m as Record<string, unknown>).slaCompliance as number ?? -1,
sparkline: m.sparkline ?? [],
})),
[metrics],
);
// Treemap items: one per route, sized by exchange count, colored by SLA
const treemapItems: TreemapItem[] = useMemo(
() => routeRows.map(r => ({
id: r.routeId, label: r.routeId,
value: r.exchangeCount,
slaCompliance: r.slaCompliance >= 0 ? r.slaCompliance : 100,
})),
[routeRows],
);
// KPI sparklines from timeseries
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const latencySparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
[timeseries],
);
const kpiItems = useMemo(() =>
buildKpiItems(stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds),
[stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds],
);
// Throughput by Route — stacked area chart series
const throughputByRouteSeries = useMemo(() => {
if (!timeseriesByRoute) return [];
return Object.entries(timeseriesByRoute).map(([routeId, data]) => ({
label: routeId,
data: (data.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}));
}, [timeseriesByRoute]);
// Latency percentiles chart — P99 line from app-level timeseries
const latencyChartSeries = useMemo(() => {
const buckets = timeseries?.buckets || [];
return [
{
label: 'P99',
data: buckets.map((b, i) => ({
x: i as number,
y: b.p99DurationMs,
})),
},
{
label: 'Avg',
data: buckets.map((b, i) => ({
x: i as number,
y: b.avgDurationMs,
})),
},
];
}, [timeseries]);
// Error rows with stable identity
const errorRows = useMemo(() =>
(errors ?? []).map((e, i) => ({ ...e, id: `${e.errorType}-${e.routeId}-${i}` })),
[errors],
);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI Strip */}
<KpiStrip items={kpiItems} />
{/* Route Performance Table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{routeRows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={routeRows}
sortable
onRowClick={(row) => navigate(`/dashboard/${appId}/${row.routeId}`)}
/>
</div>
{/* Charts: Throughput by Route + Latency Percentiles */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput by Route">
<AreaChart
series={throughputByRouteSeries}
yLabel="msg/s"
height={200}
className={styles.chart}
/>
</Card>
<Card title="Latency Percentiles">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
{/* Top 5 Errors — hidden when empty */}
{errorRows.length > 0 && (
<div className={styles.errorsSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Top Errors</span>
<span className={styles.tableMeta}>{errorRows.length} error types</span>
</div>
<DataTable
columns={ERROR_COLUMNS}
data={errorRows}
sortable
/>
</div>
)}
{/* Treemap + Punchcard heatmaps side by side */}
{treemapItems.length > 0 && (
<div className={styles.vizRow}>
<Card title="Route Volume vs SLA Compliance">
<Treemap
items={treemapItems}
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
/>
</Card>
<div className={styles.punchcardStack}>
<Card title="Transactions (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" />
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" />
</Card>
</div>
</div>
)}
</div>
);
}