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:
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',
|
||||
database: 'Database',
|
||||
clickhouse: 'ClickHouse',
|
||||
'server-metrics': 'Server Metrics',
|
||||
appconfig: 'App Config',
|
||||
};
|
||||
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:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' },
|
||||
{ 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' },
|
||||
];
|
||||
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 DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||
const ServerMetricsAdminPage = lazy(() => import('./pages/Admin/ServerMetricsAdminPage'));
|
||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||
@@ -105,6 +106,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'server-metrics', element: <SuspenseWrapper><ServerMetricsAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
||||
],
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user