Replace custom LineChart/AreaChart/BarChart usage with ThemedChart wrapper. Data format changed from ChartSeries[] to Recharts-native flat objects. Uses DS v0.1.47. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import {
|
|
KpiStrip,
|
|
DataTable,
|
|
ThemedChart,
|
|
Area,
|
|
Line,
|
|
CHART_COLORS,
|
|
Card,
|
|
Sparkline,
|
|
MonoText,
|
|
StatusDot,
|
|
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 { useTimeseriesByApp, useTopErrors, useAllAppSettings, usePunchcard } from '../../api/queries/dashboard';
|
|
import { useEnvironmentStore } from '../../api/environment-store';
|
|
import type { AppSettings } from '../../api/queries/dashboard';
|
|
import { Treemap } from './Treemap';
|
|
import type { TreemapItem } from './Treemap';
|
|
import { PunchcardHeatmap } from './PunchcardHeatmap';
|
|
import type { RouteMetrics } from '../../api/types';
|
|
import {
|
|
computeHealthDot,
|
|
formatThroughput,
|
|
formatSlaCompliance,
|
|
trendIndicator,
|
|
type HealthStatus,
|
|
} from './dashboard-utils';
|
|
import styles from './DashboardTab.module.css';
|
|
import tableStyles from '../../styles/table-section.module.css';
|
|
import refreshStyles from '../../styles/refresh-indicator.module.css';
|
|
import rateStyles from '../../styles/rate-colors.module.css';
|
|
|
|
// ── Row type for application health table ───────────────────────────────────
|
|
|
|
interface AppRow {
|
|
id: string;
|
|
appId: string;
|
|
health: HealthStatus;
|
|
throughput: number;
|
|
throughputLabel: string;
|
|
successRate: number;
|
|
p99DurationMs: number;
|
|
slaCompliance: number;
|
|
errorCount: number;
|
|
sparkline: number[];
|
|
}
|
|
|
|
// ── Table columns ───────────────────────────────────────────────────────────
|
|
|
|
const APP_COLUMNS: Column<AppRow>[] = [
|
|
{
|
|
key: 'health',
|
|
header: '',
|
|
render: (_, row) => <StatusDot variant={row.health} />,
|
|
},
|
|
{
|
|
key: 'appId',
|
|
header: 'Application',
|
|
sortable: true,
|
|
render: (_, row) => (
|
|
<span className={styles.appNameCell}>{row.appId}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'throughput',
|
|
header: 'Throughput',
|
|
sortable: true,
|
|
render: (_, row) => (
|
|
<MonoText size="sm">{row.throughputLabel}</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'successRate',
|
|
header: 'Success %',
|
|
sortable: true,
|
|
render: (_, row) => {
|
|
const pct = row.successRate;
|
|
const cls = pct >= 99 ? rateStyles.rateGood : pct >= 97 ? rateStyles.rateWarn : rateStyles.rateBad;
|
|
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
|
},
|
|
},
|
|
{
|
|
key: 'p99DurationMs',
|
|
header: 'P99',
|
|
sortable: true,
|
|
render: (_, row) => {
|
|
const cls = row.p99DurationMs > 300 ? rateStyles.rateBad : row.p99DurationMs > 200 ? rateStyles.rateWarn : rateStyles.rateGood;
|
|
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
|
},
|
|
},
|
|
{
|
|
key: 'slaCompliance',
|
|
header: 'SLA %',
|
|
sortable: true,
|
|
render: (_, row) => {
|
|
const cls = row.slaCompliance >= 99 ? rateStyles.rateGood : row.slaCompliance >= 95 ? rateStyles.rateWarn : rateStyles.rateBad;
|
|
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
|
|
},
|
|
},
|
|
{
|
|
key: 'errorCount',
|
|
header: 'Errors',
|
|
sortable: true,
|
|
render: (_, row) => {
|
|
const cls = row.errorCount > 10 ? rateStyles.rateBad : row.errorCount > 0 ? rateStyles.rateWarn : rateStyles.rateGood;
|
|
return <MonoText size="sm" className={cls}>{row.errorCount.toLocaleString()}</MonoText>;
|
|
},
|
|
},
|
|
{
|
|
key: 'sparkline',
|
|
header: 'Trend',
|
|
render: (_, row) => (
|
|
<Sparkline data={row.sparkline} width={80} height={24} />
|
|
),
|
|
},
|
|
];
|
|
|
|
// ── Aggregate RouteMetrics by appId ─────────────────────────────────────────
|
|
|
|
function aggregateByApp(
|
|
metrics: RouteMetrics[],
|
|
windowSeconds: number,
|
|
settingsMap: Map<string, AppSettings>,
|
|
): AppRow[] {
|
|
const grouped = new Map<string, RouteMetrics[]>();
|
|
for (const m of metrics) {
|
|
const list = grouped.get(m.appId) ?? [];
|
|
list.push(m);
|
|
grouped.set(m.appId, list);
|
|
}
|
|
|
|
const rows: AppRow[] = [];
|
|
for (const [appId, routes] of grouped) {
|
|
const totalExchanges = routes.reduce((s, r) => s + r.exchangeCount, 0);
|
|
const totalFailed = routes.reduce((s, r) => s + r.exchangeCount * r.errorRate, 0);
|
|
const successRate = totalExchanges > 0 ? ((totalExchanges - totalFailed) / totalExchanges) * 100 : 100;
|
|
const errorRate = totalExchanges > 0 ? totalFailed / totalExchanges : 0;
|
|
|
|
// Weighted average p99 by exchange count
|
|
const p99Sum = routes.reduce((s, r) => s + r.p99DurationMs * r.exchangeCount, 0);
|
|
const p99DurationMs = totalExchanges > 0 ? p99Sum / totalExchanges : 0;
|
|
|
|
// SLA compliance: weighted average of per-route slaCompliance from backend
|
|
const appSettings = settingsMap.get(appId);
|
|
const slaWeightedSum = routes.reduce((s, r) => s + (r.slaCompliance ?? 100) * r.exchangeCount, 0);
|
|
const slaCompliance = totalExchanges > 0 ? slaWeightedSum / totalExchanges : 100;
|
|
|
|
const errorCount = Math.round(totalFailed);
|
|
|
|
// Merge sparklines: sum across routes per bucket position
|
|
const maxLen = Math.max(...routes.map((r) => (r.sparkline ?? []).length), 0);
|
|
const sparkline: number[] = [];
|
|
for (let i = 0; i < maxLen; i++) {
|
|
sparkline.push(routes.reduce((s, r) => s + ((r.sparkline ?? [])[i] ?? 0), 0));
|
|
}
|
|
|
|
rows.push({
|
|
id: appId,
|
|
appId,
|
|
health: computeHealthDot(errorRate, slaCompliance, appSettings),
|
|
throughput: totalExchanges,
|
|
throughputLabel: formatThroughput(totalExchanges, windowSeconds),
|
|
successRate,
|
|
p99DurationMs,
|
|
slaCompliance,
|
|
errorCount,
|
|
sparkline,
|
|
});
|
|
}
|
|
|
|
return rows.sort((a, b) => {
|
|
const order: Record<HealthStatus, number> = { error: 0, warning: 1, success: 2 };
|
|
return order[a.health] - order[b.health];
|
|
});
|
|
}
|
|
|
|
// ── Build KPI items ─────────────────────────────────────────────────────────
|
|
|
|
function buildKpiItems(
|
|
stats: {
|
|
totalCount: number;
|
|
failedCount: number;
|
|
p99LatencyMs: number;
|
|
prevTotalCount: number;
|
|
prevFailedCount: number;
|
|
prevP99LatencyMs: number;
|
|
} | undefined,
|
|
windowSeconds: number,
|
|
slaCompliance: number,
|
|
activeErrorCount: number,
|
|
throughputSparkline: number[],
|
|
successSparkline: number[],
|
|
latencySparkline: number[],
|
|
slaSparkline: number[],
|
|
errorSparkline: 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 throughput = windowSeconds > 0 ? totalCount / windowSeconds : 0;
|
|
const prevThroughput = windowSeconds > 0 ? prevTotalCount / windowSeconds : 0;
|
|
const throughputTrend = trendIndicator(throughput, prevThroughput);
|
|
|
|
// Success Rate
|
|
const successPct = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
|
const prevSuccessPct = prevTotalCount > 0
|
|
? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100
|
|
: 100;
|
|
const successTrend = trendIndicator(successPct, prevSuccessPct);
|
|
|
|
// P99 Latency
|
|
const p99Trend = trendIndicator(p99Ms, prevP99Ms);
|
|
|
|
// SLA compliance trend — higher is better, so invert the variant
|
|
const slaTrend = trendIndicator(slaCompliance, 100);
|
|
|
|
// Active Errors
|
|
const prevErrorRate = prevTotalCount > 0 ? (prevFailedCount / prevTotalCount) * 100 : 0;
|
|
const currentErrorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
|
const errorTrend = trendIndicator(currentErrorRate, prevErrorRate);
|
|
|
|
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,
|
|
},
|
|
subtitle: `${totalCount.toLocaleString()} msg total`,
|
|
sparkline: throughputSparkline,
|
|
borderColor: 'var(--amber)',
|
|
},
|
|
{
|
|
label: 'Success Rate',
|
|
value: `${successPct.toFixed(1)}%`,
|
|
trend: {
|
|
label: successTrend.label,
|
|
variant: successPct >= 99 ? 'success' as const : successPct >= 97 ? 'warning' as const : 'error' as const,
|
|
},
|
|
subtitle: `${(totalCount - failedCount).toLocaleString()} succeeded`,
|
|
sparkline: successSparkline,
|
|
borderColor: successPct >= 99 ? 'var(--success)' : 'var(--error)',
|
|
},
|
|
{
|
|
label: 'P99 Latency',
|
|
value: `${Math.round(p99Ms)}ms`,
|
|
trend: {
|
|
label: p99Trend.label,
|
|
variant: p99Ms > 300 ? 'error' as const : p99Ms > 200 ? 'warning' as const : 'success' as const,
|
|
},
|
|
subtitle: `prev ${Math.round(prevP99Ms)}ms`,
|
|
sparkline: latencySparkline,
|
|
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
|
},
|
|
{
|
|
label: 'SLA Compliance',
|
|
value: formatSlaCompliance(slaCompliance),
|
|
trend: {
|
|
label: slaTrend.label,
|
|
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
|
|
},
|
|
subtitle: 'P99 within threshold',
|
|
sparkline: slaSparkline,
|
|
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
|
|
},
|
|
{
|
|
label: 'Active Errors',
|
|
value: String(activeErrorCount),
|
|
trend: {
|
|
label: errorTrend.label,
|
|
variant: activeErrorCount === 0 ? 'success' as const : 'error' as const,
|
|
},
|
|
subtitle: `${failedCount.toLocaleString()} failures total`,
|
|
sparkline: errorSparkline,
|
|
borderColor: activeErrorCount === 0 ? 'var(--success)' : 'var(--error)',
|
|
},
|
|
];
|
|
}
|
|
|
|
// ── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export default function DashboardL1() {
|
|
const navigate = useNavigate();
|
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
const { timeRange } = useGlobalFilters();
|
|
const timeFrom = timeRange.start.toISOString();
|
|
const timeTo = timeRange.end.toISOString();
|
|
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
|
|
|
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, undefined, selectedEnv);
|
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
|
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv);
|
|
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
|
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
|
|
const { data: allAppSettings } = useAllAppSettings();
|
|
|
|
// Build settings lookup map
|
|
const settingsMap = useMemo(() => {
|
|
const map = new Map<string, AppSettings>();
|
|
for (const s of allAppSettings ?? []) {
|
|
map.set(s.appId, s);
|
|
}
|
|
return map;
|
|
}, [allAppSettings]);
|
|
|
|
// Aggregate route metrics by appId for the table
|
|
const appRows = useMemo(
|
|
() => aggregateByApp(metrics ?? [], windowSeconds, settingsMap),
|
|
[metrics, windowSeconds, settingsMap],
|
|
);
|
|
|
|
// Global SLA compliance from backend stats (exact calculation from executions table)
|
|
const globalSlaCompliance = stats?.slaCompliance ?? -1;
|
|
const effectiveSlaCompliance = globalSlaCompliance >= 0 ? globalSlaCompliance : 100;
|
|
|
|
// Active error count = distinct error types
|
|
const activeErrorCount = useMemo(
|
|
() => (topErrors ?? []).length,
|
|
[topErrors],
|
|
);
|
|
|
|
// KPI sparklines from timeseries buckets
|
|
const throughputSparkline = useMemo(
|
|
() => (timeseries?.buckets ?? []).map((b) => b.totalCount),
|
|
[timeseries],
|
|
);
|
|
const successSparkline = useMemo(
|
|
() => (timeseries?.buckets ?? []).map((b) =>
|
|
b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
|
),
|
|
[timeseries],
|
|
);
|
|
const latencySparkline = useMemo(
|
|
() => (timeseries?.buckets ?? []).map((b) => b.p99DurationMs),
|
|
[timeseries],
|
|
);
|
|
const slaSparkline = useMemo(
|
|
() => (timeseries?.buckets ?? []).map((b) =>
|
|
b.p99DurationMs <= 300 ? 100 : 0,
|
|
),
|
|
[timeseries],
|
|
);
|
|
const errorSparkline = useMemo(
|
|
() => (timeseries?.buckets ?? []).map((b) => b.failedCount),
|
|
[timeseries],
|
|
);
|
|
|
|
const kpiItems = useMemo(
|
|
() => buildKpiItems(
|
|
stats,
|
|
windowSeconds,
|
|
effectiveSlaCompliance,
|
|
activeErrorCount,
|
|
throughputSparkline,
|
|
successSparkline,
|
|
latencySparkline,
|
|
slaSparkline,
|
|
errorSparkline,
|
|
),
|
|
[stats, windowSeconds, effectiveSlaCompliance, activeErrorCount,
|
|
throughputSparkline, successSparkline, latencySparkline, slaSparkline, errorSparkline],
|
|
);
|
|
|
|
// ── Per-app flat chart data (throughput stacked area) ───────────────────
|
|
const throughputByAppData = useMemo(() => {
|
|
if (!timeseriesByApp) return { data: [], keys: [] as string[] };
|
|
const entries = Object.entries(timeseriesByApp);
|
|
if (!entries.length) return { data: [], keys: [] as string[] };
|
|
const len = entries[0][1].buckets.length;
|
|
const keys = entries.map(([appId]) => appId);
|
|
const data = Array.from({ length: len }, (_, i) => {
|
|
const point: Record<string, number | string> = { idx: i };
|
|
for (const [appId, { buckets }] of entries) {
|
|
point[appId] = buckets[i]?.totalCount ?? 0;
|
|
}
|
|
return point;
|
|
});
|
|
return { data, keys };
|
|
}, [timeseriesByApp]);
|
|
|
|
// ── Per-app flat chart data (error rate line) ────────────────────────────
|
|
const errorRateByAppData = useMemo(() => {
|
|
if (!timeseriesByApp) return { data: [], keys: [] as string[] };
|
|
const entries = Object.entries(timeseriesByApp);
|
|
if (!entries.length) return { data: [], keys: [] as string[] };
|
|
const len = entries[0][1].buckets.length;
|
|
const keys = entries.map(([appId]) => appId);
|
|
const data = Array.from({ length: len }, (_, i) => {
|
|
const point: Record<string, number | string> = { idx: i };
|
|
for (const [appId, { buckets }] of entries) {
|
|
const b = buckets[i];
|
|
point[appId] = b && b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0;
|
|
}
|
|
return point;
|
|
});
|
|
return { data, keys };
|
|
}, [timeseriesByApp]);
|
|
|
|
// Treemap items: one per app, sized by exchange count, colored by SLA
|
|
const treemapItems: TreemapItem[] = useMemo(
|
|
() => appRows.map(r => ({ id: r.appId, label: r.appId, value: r.throughput, slaCompliance: r.slaCompliance })),
|
|
[appRows],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.content}>
|
|
<div className={refreshStyles.refreshIndicator}>
|
|
<span className={refreshStyles.refreshDot} />
|
|
<span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
|
|
</div>
|
|
|
|
{/* KPI header cards */}
|
|
<KpiStrip items={kpiItems} />
|
|
|
|
{/* Application Health table */}
|
|
<div className={tableStyles.tableSection}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<span className={tableStyles.tableTitle}>Application Health</span>
|
|
<div className={tableStyles.tableRight}>
|
|
<span className={tableStyles.tableMeta}>{appRows.length} applications</span>
|
|
<Badge label="ALL" color="auto" />
|
|
</div>
|
|
</div>
|
|
<DataTable
|
|
columns={APP_COLUMNS}
|
|
data={appRows}
|
|
sortable
|
|
onRowClick={(row) => navigate(`/dashboard/${row.appId}`)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Side-by-side charts */}
|
|
{throughputByAppData.data.length > 0 && (
|
|
<div className={styles.chartGrid}>
|
|
<Card title="Throughput by Application (msg/s)">
|
|
<ThemedChart data={throughputByAppData.data} height={200} xDataKey="idx" yLabel="msg/s">
|
|
{throughputByAppData.keys.map((key, i) => (
|
|
<Area key={key} dataKey={key} name={key} stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
|
fill={CHART_COLORS[i % CHART_COLORS.length]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
|
))}
|
|
</ThemedChart>
|
|
</Card>
|
|
|
|
<Card title="Error Rate by Application (%)">
|
|
<ThemedChart data={errorRateByAppData.data} height={200} xDataKey="idx" yLabel="%">
|
|
{errorRateByAppData.keys.map((key, i) => (
|
|
<Line key={key} dataKey={key} name={key} stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
|
strokeWidth={2} dot={false} />
|
|
))}
|
|
</ThemedChart>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Treemap + Punchcard heatmaps side by side */}
|
|
{treemapItems.length > 0 && (
|
|
<div className={styles.vizRow}>
|
|
<Card title="Application Volume vs SLA Compliance">
|
|
<Treemap
|
|
items={treemapItems}
|
|
onItemClick={(id) => navigate(`/dashboard/${id}`)}
|
|
/>
|
|
</Card>
|
|
<Card title="7-Day Pattern">
|
|
<PunchcardHeatmap cells={punchcardData ?? []} timeRangeMs={timeRange.end.getTime() - timeRange.start.getTime()} />
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|