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'] });
},
});
}