feat(ui): server metrics admin dashboard

Adds /admin/server-metrics page mirroring the Database/ClickHouse visibility
rules: sidebar entry gated on capabilities.infrastructureEndpoints, backend
controller now has @ConditionalOnProperty(infrastructureendpoints) and
class-level @PreAuthorize('hasRole(ADMIN)'). Dashboard panels are driven
from docs/server-self-metrics.md via the generic
/api/v1/admin/server-metrics/{catalog,instances,query} API — Server Health,
JVM, HTTP & DB pools, and conditionally Alerting + Deployments when their
metrics appear in the catalog. ThemedChart / Line / Area from the design
system; hooks in ui/src/api/queries/admin/serverMetrics.ts. Not yet
browser-verified against a running dev server — backend IT covers the API
end-to-end (8 tests), UI typecheck + production bundle both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-24 09:00:14 +02:00
parent 75a41929c4
commit b5ee9e1d1f
9 changed files with 681 additions and 3 deletions

View File

@@ -0,0 +1,113 @@
import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
import { useRefreshInterval } from '../use-refresh-interval';
// ── Types ──────────────────────────────────────────────────────────────
export interface ServerMetricCatalogEntry {
metricName: string;
metricType: string;
statistics: string[];
tagKeys: string[];
}
export interface ServerInstanceInfo {
serverInstanceId: string;
firstSeen: string;
lastSeen: string;
}
export interface ServerMetricPoint {
t: string;
v: number;
}
export interface ServerMetricSeries {
tags: Record<string, string>;
points: ServerMetricPoint[];
}
export interface ServerMetricQueryResponse {
metric: string;
statistic: string;
aggregation: string;
mode: string;
stepSeconds: number;
series: ServerMetricSeries[];
}
export interface ServerMetricQueryRequest {
metric: string;
statistic?: string | null;
from: string;
to: string;
stepSeconds?: number | null;
groupByTags?: string[] | null;
filterTags?: Record<string, string> | null;
aggregation?: string | null;
mode?: string | null;
serverInstanceIds?: string[] | null;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useServerMetricsCatalog(windowSeconds = 3600) {
const refetchInterval = useRefreshInterval(60_000);
return useQuery({
queryKey: ['admin', 'server-metrics', 'catalog', windowSeconds],
queryFn: async () => {
const to = new Date();
const from = new Date(to.getTime() - windowSeconds * 1000);
const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() });
return adminFetch<ServerMetricCatalogEntry[]>(`/server-metrics/catalog?${params}`);
},
refetchInterval,
});
}
export function useServerMetricsInstances(windowSeconds = 3600) {
const refetchInterval = useRefreshInterval(60_000);
return useQuery({
queryKey: ['admin', 'server-metrics', 'instances', windowSeconds],
queryFn: async () => {
const to = new Date();
const from = new Date(to.getTime() - windowSeconds * 1000);
const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() });
return adminFetch<ServerInstanceInfo[]>(`/server-metrics/instances?${params}`);
},
refetchInterval,
});
}
/**
* Run a time-series query against the server_metrics table.
*
* The window [from, to) is supplied in seconds of "now minus N" so the panel
* refreshes automatically at the polling interval without the caller
* recomputing timestamps.
*/
export function useServerMetricsSeries(
request: Omit<ServerMetricQueryRequest, 'from' | 'to'>,
windowSeconds: number,
opts?: { enabled?: boolean },
) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['admin', 'server-metrics', 'query', request, windowSeconds],
queryFn: async () => {
const to = new Date();
const from = new Date(to.getTime() - windowSeconds * 1000);
const body: ServerMetricQueryRequest = {
...request,
from: from.toISOString(),
to: to.toISOString(),
};
return adminFetch<ServerMetricQueryResponse>('/server-metrics/query', {
method: 'POST',
body: JSON.stringify(body),
});
},
refetchInterval,
enabled: opts?.enabled ?? true,
});
}