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

@@ -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)

View File

@@ -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

View File

@@ -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. */

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

View File

@@ -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);

View File

@@ -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;

View 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;
}
}

View 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 &amp; 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>
);
}

View File

@@ -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> },
], ],
}], }],