feat: progressive drill-down dashboard with RED metrics and SLA compliance (#94)

Three-level dashboard driven by sidebar selection:
- L1 (no selection): all-apps overview with health table, per-app charts
- L2 (app selected): route performance table, error velocity, top errors
- L3 (route selected): processor table, latency heatmap data, bottleneck KPI

Backend: 3 new endpoints (timeseries/by-app, timeseries/by-route, errors/top),
per-app SLA settings (app_settings table, V12 migration), exact SLA compliance
from executions hypertable, error velocity with acceleration detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-29 23:29:20 +02:00
parent b2ae37637d
commit 213aa86c47
21 changed files with 2293 additions and 19 deletions

View File

@@ -0,0 +1,142 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval';
function authHeaders() {
const token = useAuthStore.getState().accessToken;
return {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
};
}
async function fetchJson<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
const qs = new URLSearchParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v != null) qs.set(k, v);
}
}
const url = `${config.apiBaseUrl}${path}${qs.toString() ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: authHeaders() });
if (!res.ok) throw new Error(`Failed to fetch ${path}`);
return res.json();
}
// ── Timeseries by app (L1 charts) ─────────────────────────────────────
export interface TimeseriesBucket {
time: string;
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99DurationMs: number;
activeCount: number;
}
export interface GroupedTimeseries {
[key: string]: { buckets: TimeseriesBucket[] };
}
export function useTimeseriesByApp(from?: string, to?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-app', from, to],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
from, to, buckets: '24',
}),
enabled: !!from,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
}
// ── Timeseries by route (L2 charts) ───────────────────────────────────
export function useTimeseriesByRoute(from?: string, to?: string, application?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-route', from, to, application],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
from, to, application, buckets: '24',
}),
enabled: !!from && !!application,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
}
// ── Top errors (L2/L3) ────────────────────────────────────────────────
export interface TopError {
errorType: string;
routeId: string | null;
processorId: string | null;
count: number;
velocity: number;
trend: 'accelerating' | 'stable' | 'decelerating';
lastSeen: string;
}
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string) {
const refetchInterval = useRefreshInterval(10_000);
return useQuery({
queryKey: ['dashboard', 'top-errors', from, to, application, routeId],
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
from, to, application, routeId, limit: '5',
}),
enabled: !!from,
placeholderData: (prev: TopError[] | undefined) => prev,
refetchInterval,
});
}
// ── App settings ──────────────────────────────────────────────────────
export interface AppSettings {
appId: string;
slaThresholdMs: number;
healthErrorWarn: number;
healthErrorCrit: number;
healthSlaWarn: number;
healthSlaCrit: number;
createdAt: string;
updatedAt: string;
}
export function useAppSettings(appId?: string) {
return useQuery({
queryKey: ['app-settings', appId],
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
enabled: !!appId,
staleTime: 60_000,
});
}
export function useAllAppSettings() {
return useQuery({
queryKey: ['app-settings', 'all'],
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
staleTime: 60_000,
});
}
export function useUpdateAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error('Failed to update app settings');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
},
});
}

View File

@@ -0,0 +1,442 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
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 } from '../../api/queries/dashboard';
import type { AppSettings } from '../../api/queries/dashboard';
import type { RouteMetrics } from '../../api/types';
import {
computeHealthDot,
formatThroughput,
formatSlaCompliance,
trendIndicator,
type HealthStatus,
} from './dashboard-utils';
import styles from './DashboardTab.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 status={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 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'p99DurationMs',
header: 'P99',
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)}ms</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: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => {
const cls = row.errorCount > 10 ? styles.rateBad : row.errorCount > 0 ? styles.rateWarn : styles.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 { 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);
const { data: stats } = useExecutionStats(timeFrom, timeTo);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo);
const { data: topErrors } = useTopErrors(timeFrom, timeTo);
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 as Record<string, unknown>)?.slaCompliance as number ?? -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 chart series (throughput stacked area) ──────────────────────
const throughputByAppSeries = useMemo(() => {
if (!timeseriesByApp) return [];
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
label: appId,
data: buckets.map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}));
}, [timeseriesByApp]);
// ── Per-app chart series (error rate line) ─────────────────────────────
const errorRateByAppSeries = useMemo(() => {
if (!timeseriesByApp) return [];
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
label: appId,
data: buckets.map((b, i) => ({
x: i as number,
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
})),
}));
}, [timeseriesByApp]);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Application Health table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Application Health</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{appRows.length} applications</span>
<Badge label="ALL" color="muted" />
</div>
</div>
<DataTable
columns={APP_COLUMNS}
data={appRows}
sortable
onRowClick={(row) => navigate(`/dashboard/${row.appId}`)}
/>
</div>
{/* Side-by-side charts */}
{throughputByAppSeries.length > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput by Application (msg/s)">
<AreaChart
series={throughputByAppSeries}
yLabel="msg/s"
stacked
height={200}
className={styles.chart}
/>
</Card>
<Card title="Error Rate by Application (%)">
<LineChart
series={errorRateByAppSeries}
yLabel="%"
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,421 @@
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,
} from '../../api/queries/dashboard';
import type { TopError } from '../../api/queries/dashboard';
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 ──────────────────────────────────────────────────────
const ERROR_COLUMNS: Column<TopError>[] = [
{
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: appSettings } = useAppSettings(appId);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
// Route performance table rows
const routeRows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: RouteMetrics) => {
const sla = m.p99DurationMs <= slaThresholdMs
? 99.9
: Math.max(0, 100 - ((m.p99DurationMs - slaThresholdMs) / slaThresholdMs) * 10);
return {
id: m.routeId,
routeId: m.routeId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
slaCompliance: sla,
sparkline: m.sparkline ?? [],
};
}),
[metrics, slaThresholdMs],
);
// 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"
stacked
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>
)}
</div>
);
}

View File

@@ -0,0 +1,434 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
Card,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useTopErrors, useAppSettings } from '../../api/queries/dashboard';
import type { TopError } from '../../api/queries/dashboard';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { ProcessDiagram } from '../../components/ProcessDiagram';
import {
formatRelativeTime,
trendArrow,
formatThroughput,
formatSlaCompliance,
trendIndicator,
} from './dashboard-utils';
import styles from './DashboardTab.module.css';
// ── Row types ───────────────────────────────────────────────────────────────
interface ProcessorRow {
id: string;
processorId: string;
processorType: string;
totalCount: number;
avgDurationMs: number;
p99DurationMs: number;
errorRate: number;
pctTime: number;
}
interface ErrorRow extends TopError {
id: string;
}
// ── Processor table columns ─────────────────────────────────────────────────
const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
{
key: 'processorId',
header: 'Processor ID',
sortable: true,
render: (_, row) => <MonoText size="sm">{row.processorId}</MonoText>,
},
{
key: 'processorType',
header: 'Type',
sortable: true,
render: (_, row) => <Badge label={row.processorType} color="muted" />,
},
{
key: 'totalCount',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.totalCount.toLocaleString()}</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: 'errorRate',
header: 'Error Rate(%)',
sortable: true,
render: (_, row) => {
const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(2)}%</MonoText>;
},
},
{
key: 'pctTime',
header: '% Time',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.pctTime.toFixed(1)}%</MonoText>
),
},
];
// ── Error table columns ─────────────────────────────────────────────────────
const ERROR_COLUMNS: Column<ErrorRow>[] = [
{
key: 'errorType',
header: 'Error Type',
sortable: true,
render: (_, row) => <MonoText size="sm">{row.errorType}</MonoText>,
},
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.processorId ?? '\u2014'}</MonoText>
),
},
{
key: 'count',
header: 'Count',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
),
},
{
key: 'trend',
header: 'Velocity',
render: (_, row) => (
<span>{trendArrow(row.trend)} {row.trend}</span>
),
},
{
key: 'lastSeen',
header: 'Last Seen',
sortable: true,
render: (_, row) => (
<span>{formatRelativeTime(row.lastSeen)}</span>
),
},
];
// ── Build KPI items ─────────────────────────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
slaThresholdMs: number,
bottleneck: { processorId: string; avgMs: number; pct: number } | null,
throughputSparkline: number[],
windowSeconds: number,
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const slaCompliance = totalCount > 0
? ((totalCount - failedCount) / totalCount) * 100
: 100;
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
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()} total exchanges`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(2)}%`,
trend: {
label: failedCount > 0 ? `${failedCount} failed` : 'No errors',
variant: successRate >= 99 ? 'success' as const : successRate >= 97 ? 'warning' as const : 'error' as const,
},
subtitle: `${totalCount - failedCount} succeeded / ${totalCount.toLocaleString()} total`,
borderColor: successRate >= 99 ? 'var(--success)' : 'var(--error)',
},
{
label: 'P99 Latency',
value: `${Math.round(p99Ms)}ms`,
trend: {
label: p99Ms > slaThresholdMs ? 'BREACH' : 'OK',
variant: p99Ms > slaThresholdMs ? 'error' as const : 'success' as const,
},
subtitle: `SLA threshold: ${slaThresholdMs}ms \u00B7 Avg: ${Math.round(avgMs)}ms`,
borderColor: p99Ms > slaThresholdMs ? 'var(--warning)' : 'var(--success)',
},
{
label: 'SLA Compliance',
value: formatSlaCompliance(slaCompliance),
trend: {
label: slaCompliance >= 99.9 ? 'Excellent' : slaCompliance >= 99 ? 'Good' : 'Degraded',
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
},
subtitle: `Target: 99.9%`,
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
},
{
label: 'Bottleneck',
value: bottleneck ? `${Math.round(bottleneck.avgMs)}ms` : '\u2014',
trend: {
label: bottleneck ? `${bottleneck.pct.toFixed(1)}% of total` : '\u2014',
variant: bottleneck && bottleneck.pct > 50 ? 'error' as const : 'muted' as const,
},
subtitle: bottleneck
? `${bottleneck.processorId} \u00B7 ${Math.round(bottleneck.avgMs)}ms \u00B7 ${bottleneck.pct.toFixed(1)}% of total`
: 'No processor data',
borderColor: 'var(--running)',
},
];
}
// ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL3() {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>();
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, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId);
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId);
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
const { data: appSettings } = useAppSettings(appId);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
// ── Bottleneck (processor with highest avgDurationMs) ───────────────────
const bottleneck = useMemo(() => {
if (!processorMetrics?.length) return null;
const routeAvg = stats?.avgDurationMs ?? 0;
const sorted = [...processorMetrics].sort(
(a: any, b: any) => b.avgDurationMs - a.avgDurationMs,
);
const top = sorted[0];
const pct = routeAvg > 0 ? (top.avgDurationMs / routeAvg) * 100 : 0;
return { processorId: top.processorId, avgMs: top.avgDurationMs, pct };
}, [processorMetrics, stats]);
// ── Sparklines from timeseries ──────────────────────────────────────────
const throughputSparkline = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.totalCount),
[timeseries],
);
// ── KPI strip ───────────────────────────────────────────────────────────
const kpiItems = useMemo(
() => buildKpiItems(stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds),
[stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds],
);
// ── Chart series ────────────────────────────────────────────────────────
const throughputChartSeries = useMemo(() => [{
label: 'Throughput',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.totalCount,
})),
}], [timeseries]);
const latencyChartSeries = useMemo(() => [{
label: 'P99',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.p99DurationMs,
})),
}], [timeseries]);
const errorRateChartSeries = useMemo(() => [{
label: 'Error Rate',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
})),
color: 'var(--error)',
}], [timeseries]);
// ── Processor table rows ────────────────────────────────────────────────
const processorRows: ProcessorRow[] = useMemo(() => {
if (!processorMetrics?.length) return [];
const routeAvg = stats?.avgDurationMs ?? 0;
return processorMetrics.map((m: any) => ({
id: m.processorId,
processorId: m.processorId,
processorType: m.processorType,
totalCount: m.totalCount,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
errorRate: m.errorRate,
pctTime: routeAvg > 0 ? (m.avgDurationMs / routeAvg) * 100 : 0,
}));
}, [processorMetrics, stats]);
// ── Latency heatmap for ProcessDiagram ──────────────────────────────────
const latencyHeatmap = useMemo(() => {
if (!processorMetrics?.length) return new Map();
const totalAvg = processorMetrics.reduce(
(sum: number, m: any) => sum + m.avgDurationMs, 0,
);
const map = new Map<string, { avgDurationMs: number; p99DurationMs: number; pctOfRoute: number }>();
for (const m of processorMetrics) {
map.set(m.processorId, {
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
pctOfRoute: totalAvg > 0 ? (m.avgDurationMs / totalAvg) * 100 : 0,
});
}
return map;
}, [processorMetrics]);
// ── Error table rows ────────────────────────────────────────────────────
const errorRows: ErrorRow[] = useMemo(
() => (topErrors || []).map((e, i) => ({ ...e, id: `${e.errorType}-${i}` })),
[topErrors],
);
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} />
{/* Charts — 3 in a row */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartRow}>
<Card title="Throughput">
<AreaChart
series={throughputChartSeries}
yLabel="msg/s"
height={200}
/>
</Card>
<Card title="Latency Percentiles">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
height={200}
/>
</Card>
<Card title="Error Rate">
<AreaChart
series={errorRateChartSeries}
yLabel="%"
height={200}
/>
</Card>
</div>
)}
{/* Process Diagram with Latency Heatmap */}
{appId && routeId && (
<div className={styles.diagramSection}>
<ProcessDiagram
application={appId}
routeId={routeId}
diagramLayout={diagramLayout}
latencyHeatmap={latencyHeatmap}
/>
</div>
)}
{/* Processor Metrics Table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Metrics</span>
<div>
<span className={styles.tableMeta}>
{processorRows.length} processor{processorRows.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<DataTable
columns={PROCESSOR_COLUMNS}
data={processorRows}
sortable
defaultSort={{ key: 'p99DurationMs', direction: 'desc' }}
/>
</div>
{/* Top 5 Errors — hidden if empty */}
{errorRows.length > 0 && (
<div className={styles.errorsSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Top 5 Errors</span>
<Badge label={`${errorRows.length}`} color="error" />
</div>
<DataTable
columns={ERROR_COLUMNS}
data={errorRows}
sortable
/>
</div>
)}
</div>
);
}

View File

@@ -2,16 +2,20 @@ import { useParams } from 'react-router';
import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system';
const RoutesMetrics = lazy(() => import('../Routes/RoutesMetrics'));
const RouteDetail = lazy(() => import('../Routes/RouteDetail'));
const DashboardL1 = lazy(() => import('./DashboardL1'));
const DashboardL2 = lazy(() => import('./DashboardL2'));
const DashboardL3 = lazy(() => import('./DashboardL3'));
const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
export default function DashboardPage() {
const { routeId } = useParams<{ appId?: string; routeId?: string }>();
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
if (routeId) {
return <Suspense fallback={Fallback}><RouteDetail /></Suspense>;
if (routeId && appId) {
return <Suspense fallback={Fallback}><DashboardL3 /></Suspense>;
}
return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>;
if (appId) {
return <Suspense fallback={Fallback}><DashboardL2 /></Suspense>;
}
return <Suspense fallback={Fallback}><DashboardL1 /></Suspense>;
}

View File

@@ -0,0 +1,133 @@
.content {
display: flex;
flex-direction: column;
gap: 20px;
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Tables */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Charts */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartRow {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
/* Cells */
.monoCell {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
}
.appNameCell {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
cursor: pointer;
}
.appNameCell:hover {
text-decoration: underline;
}
/* Rate coloring */
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }
.rateNeutral { color: var(--text-secondary); }
/* Diagram container */
.diagramSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
height: 280px;
}
/* Table right side (meta + badge) */
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
/* Chart fill */
.chart {
width: 100%;
}
/* Errors section */
.errorsSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}

View File

@@ -0,0 +1,70 @@
import type { AppSettings } from '../../api/queries/dashboard';
export type HealthStatus = 'success' | 'warning' | 'error';
const DEFAULT_SETTINGS: Pick<AppSettings, 'healthErrorWarn' | 'healthErrorCrit' | 'healthSlaWarn' | 'healthSlaCrit'> = {
healthErrorWarn: 1.0,
healthErrorCrit: 5.0,
healthSlaWarn: 99.0,
healthSlaCrit: 95.0,
};
export function computeHealthDot(
errorRate: number,
slaCompliance: number,
settings?: Partial<AppSettings> | null,
): HealthStatus {
const s = { ...DEFAULT_SETTINGS, ...settings };
const errorPct = errorRate * 100;
if (errorPct > s.healthErrorCrit || slaCompliance < s.healthSlaCrit) return 'error';
if (errorPct > s.healthErrorWarn || slaCompliance < s.healthSlaWarn) return 'warning';
return 'success';
}
export function formatThroughput(count: number, windowSeconds: number): string {
if (windowSeconds <= 0) return '0/s';
const tps = count / windowSeconds;
if (tps >= 1000) return `${(tps / 1000).toFixed(1)}k/s`;
if (tps >= 1) return `${tps.toFixed(0)}/s`;
return `${tps.toFixed(2)}/s`;
}
export function formatSlaCompliance(pct: number): string {
if (pct < 0) return '—';
return `${pct.toFixed(1)}%`;
}
export function trendIndicator(current: number, previous: number): { label: string; direction: 'up' | 'down' | 'flat' } {
if (previous === 0) return { label: '—', direction: 'flat' };
const delta = ((current - previous) / previous) * 100;
if (Math.abs(delta) < 0.5) return { label: '—', direction: 'flat' };
return {
label: `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`,
direction: delta > 0 ? 'up' : 'down',
};
}
export function trendArrow(trend: 'accelerating' | 'stable' | 'decelerating'): string {
switch (trend) {
case 'accelerating': return '\u25B2';
case 'decelerating': return '\u25BC';
default: return '\u2500\u2500';
}
}
export function formatDuration(ms: number): string {
if (ms < 1) return '<1ms';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function formatRelativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
return `${Math.floor(hours / 24)} d ago`;
}