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:
@@ -109,7 +109,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
|||||||
- `UsageAnalyticsController` — GET `/api/v1/admin/usage` (ClickHouse `usage_events`).
|
- `UsageAnalyticsController` — GET `/api/v1/admin/usage` (ClickHouse `usage_events`).
|
||||||
- `ClickHouseAdminController` — GET `/api/v1/admin/clickhouse/**` (conditional on `infrastructureendpoints` flag).
|
- `ClickHouseAdminController` — GET `/api/v1/admin/clickhouse/**` (conditional on `infrastructureendpoints` flag).
|
||||||
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
|
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
|
||||||
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
||||||
|
|
||||||
### Other (flat)
|
### Other (flat)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
|||||||
|
|
||||||
**Admin pages** (ADMIN-only, under `/admin/`):
|
**Admin pages** (ADMIN-only, under `/admin/`):
|
||||||
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
|
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
|
||||||
|
- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`). Toolbar: server-instance badges + DS `Select` window picker (15 min / 1 h / 6 h / 24 h / 7 d). Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs).
|
||||||
|
|
||||||
## Key UI Files
|
## Key UI Files
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import com.cameleer.server.core.storage.model.ServerMetricQueryRequest;
|
|||||||
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
|
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -32,12 +34,23 @@ import java.util.Map;
|
|||||||
* <li>{@code GET /instances} — list server instances (useful for partitioning counter math)</li>
|
* <li>{@code GET /instances} — list server instances (useful for partitioning counter math)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Protected by the {@code /api/v1/admin/**} catch-all in {@code SecurityConfig} — requires ADMIN role.
|
* <p>Visibility matches {@code ClickHouseAdminController} / {@code DatabaseAdminController}:
|
||||||
|
* <ul>
|
||||||
|
* <li>Conditional on {@code cameleer.server.security.infrastructureendpoints=true} (default).</li>
|
||||||
|
* <li>Class-level {@code @PreAuthorize("hasRole('ADMIN')")} on top of the
|
||||||
|
* {@code /api/v1/admin/**} catch-all in {@code SecurityConfig}.</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "cameleer.server.security.infrastructureendpoints",
|
||||||
|
havingValue = "true",
|
||||||
|
matchIfMissing = true
|
||||||
|
)
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/server-metrics")
|
@RequestMapping("/api/v1/admin/server-metrics")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Tag(name = "Server Self-Metrics",
|
@Tag(name = "Server Self-Metrics",
|
||||||
description = "Read API over the server's own Micrometer registry snapshots for dashboards")
|
description = "Read API over the server's own Micrometer registry snapshots (ADMIN only)")
|
||||||
public class ServerMetricsAdminController {
|
public class ServerMetricsAdminController {
|
||||||
|
|
||||||
/** Default lookback window for catalog/instances when from/to are omitted. */
|
/** Default lookback window for catalog/instances when from/to are omitted. */
|
||||||
|
|||||||
113
ui/src/api/queries/admin/serverMetrics.ts
Normal file
113
ui/src/api/queries/admin/serverMetrics.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -705,6 +705,7 @@ function LayoutContent() {
|
|||||||
oidc: 'OIDC',
|
oidc: 'OIDC',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
clickhouse: 'ClickHouse',
|
clickhouse: 'ClickHouse',
|
||||||
|
'server-metrics': 'Server Metrics',
|
||||||
appconfig: 'App Config',
|
appconfig: 'App Config',
|
||||||
};
|
};
|
||||||
const parts = location.pathname.split('/').filter(Boolean);
|
const parts = location.pathname.split('/').filter(Boolean);
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
|||||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||||
{ id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' },
|
{ id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' },
|
||||||
{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
|
{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
|
||||||
|
...(showInfra ? [{ id: 'admin:server-metrics', label: 'Server Metrics', path: '/admin/server-metrics' }] : []),
|
||||||
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||||
];
|
];
|
||||||
return nodes;
|
return nodes;
|
||||||
|
|||||||
81
ui/src/pages/Admin/ServerMetricsAdminPage.module.css
Normal file
81
ui/src/pages/Admin/ServerMetricsAdminPage.module.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceStrip {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowTriple {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 4px 2px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSubtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighten chart card internals for denser grid */
|
||||||
|
.compactCard {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.rowTriple,
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
466
ui/src/pages/Admin/ServerMetricsAdminPage.tsx
Normal file
466
ui/src/pages/Admin/ServerMetricsAdminPage.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ThemedChart, Area, Line, CHART_COLORS,
|
||||||
|
Badge, EmptyState, Spinner, Select,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import {
|
||||||
|
useServerMetricsCatalog,
|
||||||
|
useServerMetricsInstances,
|
||||||
|
useServerMetricsSeries,
|
||||||
|
type ServerMetricQueryResponse,
|
||||||
|
type ServerMetricSeries,
|
||||||
|
} from '../../api/queries/admin/serverMetrics';
|
||||||
|
import chartCardStyles from '../../styles/chart-card.module.css';
|
||||||
|
import styles from './ServerMetricsAdminPage.module.css';
|
||||||
|
|
||||||
|
// ── Window options ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WINDOWS: { label: string; seconds: number; step: number }[] = [
|
||||||
|
{ label: 'Last 15 min', seconds: 15 * 60, step: 60 },
|
||||||
|
{ label: 'Last 1 h', seconds: 60 * 60, step: 60 },
|
||||||
|
{ label: 'Last 6 h', seconds: 6 * 60 * 60, step: 300 },
|
||||||
|
{ label: 'Last 24 h', seconds: 24 * 60 * 60, step: 300 },
|
||||||
|
{ label: 'Last 7 d', seconds: 7 * 24 * 60 * 60, step: 3600 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Panel component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metric: string;
|
||||||
|
statistic?: string;
|
||||||
|
groupByTags?: string[];
|
||||||
|
filterTags?: Record<string, string>;
|
||||||
|
aggregation?: string;
|
||||||
|
mode?: 'raw' | 'delta';
|
||||||
|
yLabel?: string;
|
||||||
|
asArea?: boolean;
|
||||||
|
windowSeconds: number;
|
||||||
|
stepSeconds: number;
|
||||||
|
formatValue?: (v: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({
|
||||||
|
title, subtitle, metric, statistic, groupByTags, filterTags,
|
||||||
|
aggregation, mode = 'raw', yLabel, asArea = false,
|
||||||
|
windowSeconds, stepSeconds, formatValue,
|
||||||
|
}: PanelProps) {
|
||||||
|
const { data, isLoading, isError, error } = useServerMetricsSeries(
|
||||||
|
{ metric, statistic, groupByTags, filterTags, aggregation, mode, stepSeconds },
|
||||||
|
windowSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${chartCardStyles.chartCard} ${styles.compactCard}`}>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<span className={styles.chartTitle}>{title}</span>
|
||||||
|
{subtitle && <span className={styles.chartMeta}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<PanelBody
|
||||||
|
data={data}
|
||||||
|
loading={isLoading}
|
||||||
|
error={isError ? (error as Error | null)?.message ?? 'query failed' : null}
|
||||||
|
yLabel={yLabel}
|
||||||
|
asArea={asArea}
|
||||||
|
formatValue={formatValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelBody({
|
||||||
|
data, loading, error, yLabel, asArea, formatValue,
|
||||||
|
}: {
|
||||||
|
data: ServerMetricQueryResponse | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
yLabel?: string;
|
||||||
|
asArea?: boolean;
|
||||||
|
formatValue?: (v: number) => string;
|
||||||
|
}) {
|
||||||
|
const points = useMemo(() => flatten(data?.series ?? []), [data]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <EmptyState title="Query failed" description={error} />;
|
||||||
|
}
|
||||||
|
if (!data || data.series.length === 0 || points.rows.length === 0) {
|
||||||
|
return <EmptyState title="No data" description="No samples in the selected window" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedChart data={points.rows} height={180} xDataKey="t" xTickFormatter={formatTime}
|
||||||
|
yLabel={yLabel} yTickFormatter={formatValue}>
|
||||||
|
{points.seriesKeys.map((key, idx) => {
|
||||||
|
const color = CHART_COLORS[idx % CHART_COLORS.length];
|
||||||
|
return asArea ? (
|
||||||
|
<Area key={key} dataKey={key} name={key} stroke={color} fill={color}
|
||||||
|
fillOpacity={0.18} strokeWidth={2} dot={false} />
|
||||||
|
) : (
|
||||||
|
<Line key={key} dataKey={key} name={key} stroke={color} strokeWidth={2} dot={false} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ThemedChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn ServerMetricSeries[] into a single array of rows keyed by series label.
|
||||||
|
* Multiple series become overlapping lines on the same time axis; buckets are
|
||||||
|
* merged on `t` so Recharts can render them as one dataset.
|
||||||
|
*/
|
||||||
|
function flatten(series: ServerMetricSeries[]): { rows: Array<Record<string, number | string>>; seriesKeys: string[] } {
|
||||||
|
if (series.length === 0) return { rows: [], seriesKeys: [] };
|
||||||
|
|
||||||
|
const seriesKeys = series.map(seriesLabel);
|
||||||
|
const rowsByTime = new Map<string, Record<string, number | string>>();
|
||||||
|
series.forEach((s, i) => {
|
||||||
|
const key = seriesKeys[i];
|
||||||
|
for (const p of s.points) {
|
||||||
|
let row = rowsByTime.get(p.t);
|
||||||
|
if (!row) {
|
||||||
|
row = { t: p.t };
|
||||||
|
rowsByTime.set(p.t, row);
|
||||||
|
}
|
||||||
|
row[key] = p.v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rows = Array.from(rowsByTime.values()).sort((a, b) =>
|
||||||
|
(a.t as string).localeCompare(b.t as string));
|
||||||
|
return { rows, seriesKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesLabel(s: ServerMetricSeries): string {
|
||||||
|
const entries = Object.entries(s.tags);
|
||||||
|
if (entries.length === 0) return 'value';
|
||||||
|
return entries.map(([k, v]) => `${k}=${v}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | number): string {
|
||||||
|
const d = typeof iso === 'number' ? new Date(iso) : new Date(String(iso));
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMB(bytes: number): string {
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(frac: number): string {
|
||||||
|
return `${(frac * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ServerMetricsAdminPage() {
|
||||||
|
const [windowIdx, setWindowIdx] = useState(1); // default: last 1 h
|
||||||
|
const windowOpt = WINDOWS[windowIdx];
|
||||||
|
const windowSeconds = windowOpt.seconds;
|
||||||
|
const stepSeconds = windowOpt.step;
|
||||||
|
|
||||||
|
const { data: catalog } = useServerMetricsCatalog(windowSeconds);
|
||||||
|
const { data: instances } = useServerMetricsInstances(windowSeconds);
|
||||||
|
|
||||||
|
const has = (metricName: string) =>
|
||||||
|
(catalog ?? []).some((c) => c.metricName === metricName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.instanceStrip}>
|
||||||
|
{(instances ?? []).slice(0, 8).map((i) => (
|
||||||
|
<Badge key={i.serverInstanceId} label={i.serverInstanceId} variant="outlined" />
|
||||||
|
))}
|
||||||
|
{(instances ?? []).length > 8 && (
|
||||||
|
<Badge label={`+${(instances ?? []).length - 8}`} variant="outlined" />
|
||||||
|
)}
|
||||||
|
{(instances ?? []).length === 0 && (
|
||||||
|
<Badge label="no samples in window" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={String(windowIdx)}
|
||||||
|
onChange={(e) => setWindowIdx(Number(e.target.value))}
|
||||||
|
options={WINDOWS.map((w, i) => ({ value: String(i), label: w.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 1: Server health */}
|
||||||
|
<section>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
Server health
|
||||||
|
<span className={styles.sectionSubtitle}>agents, ingestion, auth</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Panel
|
||||||
|
title="Agents by state"
|
||||||
|
subtitle="stacked area"
|
||||||
|
metric="cameleer.agents.connected"
|
||||||
|
statistic="value"
|
||||||
|
groupByTags={['state']}
|
||||||
|
aggregation="avg"
|
||||||
|
asArea
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="Ingestion buffer depth"
|
||||||
|
subtitle="by type"
|
||||||
|
metric="cameleer.ingestion.buffer.size"
|
||||||
|
statistic="value"
|
||||||
|
groupByTags={['type']}
|
||||||
|
aggregation="avg"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||||
|
<Panel
|
||||||
|
title="Ingestion drops / interval"
|
||||||
|
subtitle="per-bucket delta"
|
||||||
|
metric="cameleer.ingestion.drops"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['reason']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="Auth failures / interval"
|
||||||
|
subtitle="per-bucket delta"
|
||||||
|
metric="cameleer.auth.failures"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['reason']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Row 2: JVM */}
|
||||||
|
<section>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
JVM
|
||||||
|
<span className={styles.sectionSubtitle}>memory, CPU, threads, GC</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rowTriple}>
|
||||||
|
<Panel
|
||||||
|
title="Heap used"
|
||||||
|
subtitle="sum across pools"
|
||||||
|
metric="jvm.memory.used"
|
||||||
|
statistic="value"
|
||||||
|
filterTags={{ area: 'heap' }}
|
||||||
|
aggregation="sum"
|
||||||
|
asArea
|
||||||
|
yLabel="MB"
|
||||||
|
formatValue={formatMB}
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="CPU usage"
|
||||||
|
subtitle="process + system"
|
||||||
|
metric="process.cpu.usage"
|
||||||
|
statistic="value"
|
||||||
|
aggregation="avg"
|
||||||
|
asArea
|
||||||
|
yLabel="%"
|
||||||
|
formatValue={formatPct}
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="GC pause max"
|
||||||
|
subtitle="by cause"
|
||||||
|
metric="jvm.gc.pause"
|
||||||
|
statistic="max"
|
||||||
|
groupByTags={['cause']}
|
||||||
|
aggregation="max"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||||
|
<Panel
|
||||||
|
title="Thread count"
|
||||||
|
subtitle="live threads"
|
||||||
|
metric="jvm.threads.live"
|
||||||
|
statistic="value"
|
||||||
|
aggregation="avg"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="Heap committed vs max"
|
||||||
|
subtitle="sum across pools"
|
||||||
|
metric="jvm.memory.committed"
|
||||||
|
statistic="value"
|
||||||
|
filterTags={{ area: 'heap' }}
|
||||||
|
aggregation="sum"
|
||||||
|
yLabel="MB"
|
||||||
|
formatValue={formatMB}
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Row 3: HTTP + DB */}
|
||||||
|
<section>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
HTTP & DB pools
|
||||||
|
<span className={styles.sectionSubtitle}>requests, Hikari saturation</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rowTriple}>
|
||||||
|
<Panel
|
||||||
|
title="HTTP latency — mean by URI"
|
||||||
|
subtitle="SUCCESS only"
|
||||||
|
metric="http.server.requests"
|
||||||
|
statistic="mean"
|
||||||
|
groupByTags={['uri']}
|
||||||
|
filterTags={{ outcome: 'SUCCESS' }}
|
||||||
|
aggregation="avg"
|
||||||
|
yLabel="s"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="HTTP requests / interval"
|
||||||
|
subtitle="all outcomes"
|
||||||
|
metric="http.server.requests"
|
||||||
|
statistic="count"
|
||||||
|
mode="delta"
|
||||||
|
aggregation="sum"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="Hikari pool — active vs pending"
|
||||||
|
subtitle="by pool"
|
||||||
|
metric="hikaricp.connections.active"
|
||||||
|
statistic="value"
|
||||||
|
groupByTags={['pool']}
|
||||||
|
aggregation="avg"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row} style={{ marginTop: 14 }}>
|
||||||
|
<Panel
|
||||||
|
title="Hikari acquire timeouts"
|
||||||
|
subtitle="per-bucket delta"
|
||||||
|
metric="hikaricp.connections.timeout"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['pool']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="Log events by level"
|
||||||
|
subtitle="per-bucket delta"
|
||||||
|
metric="logback.events"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['level']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Row 4: Alerting */}
|
||||||
|
{(has('alerting_instances_total')
|
||||||
|
|| has('alerting_eval_errors_total')
|
||||||
|
|| has('alerting_webhook_delivery_duration_seconds')) && (
|
||||||
|
<section>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
Alerting
|
||||||
|
<span className={styles.sectionSubtitle}>instances, eval errors, webhook delivery</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rowTriple}>
|
||||||
|
{has('alerting_instances_total') && (
|
||||||
|
<Panel
|
||||||
|
title="Alert instances by state"
|
||||||
|
subtitle="stacked"
|
||||||
|
metric="alerting_instances_total"
|
||||||
|
statistic="value"
|
||||||
|
groupByTags={['state']}
|
||||||
|
aggregation="avg"
|
||||||
|
asArea
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{has('alerting_eval_errors_total') && (
|
||||||
|
<Panel
|
||||||
|
title="Eval errors / interval"
|
||||||
|
subtitle="by kind"
|
||||||
|
metric="alerting_eval_errors_total"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['kind']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{has('alerting_webhook_delivery_duration_seconds') && (
|
||||||
|
<Panel
|
||||||
|
title="Webhook delivery max"
|
||||||
|
subtitle="max latency per bucket"
|
||||||
|
metric="alerting_webhook_delivery_duration_seconds"
|
||||||
|
statistic="max"
|
||||||
|
aggregation="max"
|
||||||
|
yLabel="s"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 5: Deployments (only when runtime orchestration is enabled) */}
|
||||||
|
{(has('cameleer.deployments.outcome') || has('cameleer.deployments.duration')) && (
|
||||||
|
<section>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
Deployments
|
||||||
|
<span className={styles.sectionSubtitle}>outcomes, duration</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
{has('cameleer.deployments.outcome') && (
|
||||||
|
<Panel
|
||||||
|
title="Deploy outcomes / interval"
|
||||||
|
subtitle="by status"
|
||||||
|
metric="cameleer.deployments.outcome"
|
||||||
|
statistic="count"
|
||||||
|
groupByTags={['status']}
|
||||||
|
mode="delta"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{has('cameleer.deployments.duration') && (
|
||||||
|
<Panel
|
||||||
|
title="Deploy duration mean"
|
||||||
|
subtitle="total_time / count"
|
||||||
|
metric="cameleer.deployments.duration"
|
||||||
|
statistic="mean"
|
||||||
|
aggregation="avg"
|
||||||
|
yLabel="s"
|
||||||
|
windowSeconds={windowSeconds}
|
||||||
|
stepSeconds={stepSeconds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
|
|||||||
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||||
|
const ServerMetricsAdminPage = lazy(() => import('./pages/Admin/ServerMetricsAdminPage'));
|
||||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||||
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
||||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||||
@@ -105,6 +106,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||||
|
{ path: 'server-metrics', element: <SuspenseWrapper><ServerMetricsAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
|||||||
Reference in New Issue
Block a user