- 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>
453 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|