diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 1d1f30c..3931e2d 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -127,6 +127,11 @@ type MetricSeries = { valueFormatter: (value: number) => string; }; +type ServiceMetricSummaryItem = { + label: string; + value: string; +}; + const STATUS_TONE_CLASSES: Record< ServiceStatus["tone"], { dot: string; text: string } @@ -154,16 +159,21 @@ const STATUS_CODE_COLOR_PALETTES: Record = { default: ["#64748b", "#0ea5e9", "#a855f7", "#71717a"], }; +const STATUS_FAMILY_COLORS: Record = { + "2xx": "#10b981", + "3xx": "#6366f1", + "4xx": "#f59e0b", + "5xx": "#ef4444", + unknown: "#64748b", +}; + export function ServiceDetailsOverview({ service }: { service: Service }) { const { proxyDomain } = useService(); const overview = useMemo( () => buildOverviewData(service, proxyDomain), [service, proxyDomain], ); - const serviceMetricsUrl = - overview.runningDeployments > 0 - ? `/api/services/${service.id}/metrics?range=24h` - : null; + const serviceMetricsUrl = `/api/services/${service.id}/metrics?range=24h`; const { data: serviceMetrics, error: serviceMetricsError, @@ -177,7 +187,6 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
0} - hasRunningDeployments={overview.runningDeployments > 0} stats={serviceMetrics} error={serviceMetricsError} isLoading={isServiceMetricsLoading} @@ -191,13 +200,11 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { function ServiceMetricsPanel({ hasPublicHttp, - hasRunningDeployments, stats, error, isLoading, }: { hasPublicHttp: boolean; - hasRunningDeployments: boolean; stats?: ServiceMetricsResponse; error?: unknown; isLoading: boolean; @@ -211,11 +218,11 @@ function ServiceMetricsPanel({ ); const hasChartData = hasMetricDataForMode(chartRows, chartMode, activeSeries); const isUnavailable = Boolean(error) || stats?.metricsEnabled === false; - const hasMetricData = stats && !isUnavailable; - const p95 = getLatestValue(chartRows, "p95ResponseTimeMs"); - const egress = getLatestValue(chartRows, "egressBytesPerSecond"); - const cpu = getLatestValue(chartRows, "cpuUsagePercent"); - const memory = getLatestValue(chartRows, "memoryUsedBytes"); + const hasMetricData = Boolean(stats && !isUnavailable); + const summaryItems = useMemo( + () => buildServiceMetricSummaryItems(chartMode, stats, chartRows, hasMetricData), + [chartMode, stats, chartRows, hasMetricData], + ); return (
@@ -226,58 +233,17 @@ function ServiceMetricsPanel({
- ) : hasRunningDeployments ? ( + ) : (
-
-

- {hasMetricData - ? formatCompactNumber(stats.totalRequests) - : "-"} -

-

requests in 24h

-
-
-

- {hasMetricData - ? formatRate(getAverageRequestsPerSecond(stats)) - : "-"} -

-

avg RPS

-
-
-

- {hasMetricData && p95 != null ? formatDurationMs(p95) : "-"} -

-

p95 latency

-
-
-

- {hasMetricData && cpu != null ? `${formatRate(cpu)}%` : "-"} -

-

CPU

-
-
-

- {hasMetricData && memory != null ? formatBytes(memory) : "-"} -

-

memory

-
-
-

- {hasMetricData && egress != null - ? `${formatBytes(egress)}/s` - : "-"} -

-

egress

-
+ {summaryItems.map((item) => ( +
+

+ {item.value} +

+

{item.label}

+
+ ))}
- ) : ( - <> -

- - -

-

not deployed

- )}
@@ -285,15 +251,13 @@ function ServiceMetricsPanel({
- {!hasRunningDeployments ? ( - - ) : isLoading ? ( + {isLoading ? ( ) : isUnavailable ? ( @@ -922,6 +886,9 @@ function getStatusDataKeyBase(status: string): string { } function getStatusColor(status: string, index: number): string { + const familyColor = STATUS_FAMILY_COLORS[status]; + if (familyColor) return familyColor; + const palette = STATUS_CODE_COLOR_PALETTES[status.charAt(0)] ?? STATUS_CODE_COLOR_PALETTES.default; @@ -1021,6 +988,101 @@ function formatRate(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ""); } +function buildServiceMetricSummaryItems( + mode: ServiceChartMode, + stats: ServiceMetricsResponse | undefined, + rows: ChartRow[], + hasMetricData: boolean, +): ServiceMetricSummaryItem[] { + if (mode === "requests") { + return [ + { + label: "requests in 24h", + value: + hasMetricData && stats + ? formatCompactNumber(stats.totalRequests) + : "-", + }, + { + label: "avg RPS", + value: + hasMetricData && stats + ? formatRate(getAverageRequestsPerSecond(stats)) + : "-", + }, + ]; + } + + if (mode === "latency") { + return [ + { + label: "p50 latency", + value: formatNullableMetric( + getLatestValue(rows, "p50ResponseTimeMs"), + formatDurationMs, + ), + }, + { + label: "p95 latency", + value: formatNullableMetric( + getLatestValue(rows, "p95ResponseTimeMs"), + formatDurationMs, + ), + }, + { + label: "p99 latency", + value: formatNullableMetric( + getLatestValue(rows, "p99ResponseTimeMs"), + formatDurationMs, + ), + }, + ]; + } + + if (mode === "traffic") { + return [ + { + label: "ingress", + value: formatNullableMetric( + getLatestValue(rows, "ingressBytesPerSecond"), + (value) => `${formatBytes(value)}/s`, + ), + }, + { + label: "egress", + value: formatNullableMetric( + getLatestValue(rows, "egressBytesPerSecond"), + (value) => `${formatBytes(value)}/s`, + ), + }, + ]; + } + + return [ + { + label: "CPU", + value: formatNullableMetric( + getLatestValue(rows, "cpuUsagePercent"), + (value) => `${formatRate(value)}%`, + ), + }, + { + label: "memory", + value: formatNullableMetric( + getLatestValue(rows, "memoryUsagePercent"), + (value) => `${formatRate(value)}%`, + ), + }, + ]; +} + +function formatNullableMetric( + value: number | null, + formatter: (value: number) => string, +): string { + return value == null ? "-" : formatter(value); +} + function buildMetricSeries( mode: ServiceChartMode, statusSeries: StatusSeries[], diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 2e68ad3..806a871 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -408,7 +408,7 @@ export async function queryServiceMetrics(options: { const statusCodes = new Set(); for (const result of requestResults) { - const status = result.metric.code || "unknown"; + const status = normalizeHTTPStatusFamily(result.metric.code); statusCodes.add(status); for (const point of matrixResultToPoints(result)) { const bucket = bucketsByTimestamp.get(point.timestamp); @@ -671,7 +671,8 @@ export function getMetricWindow( stepSeconds: number; } { const option = METRIC_RANGE_OPTIONS[range]; - const end = new Date(Math.floor(now.getTime() / 1000) * 1000); + const stepMs = option.stepSeconds * 1000; + const end = new Date(Math.floor(now.getTime() / stepMs) * stepMs); const start = new Date(end.getTime() - option.durationMs); return { start, @@ -756,7 +757,23 @@ function matrixResultToPoints(result: { } function sortStatusCodes(statuses: string[]): string[] { + const statusOrder = new Map([ + ["2xx", 0], + ["3xx", 1], + ["4xx", 2], + ["5xx", 3], + ["unknown", 4], + ]); + return Array.from(new Set(statuses)).sort((a, b) => { + const orderA = statusOrder.get(a); + const orderB = statusOrder.get(b); + if (orderA !== undefined && orderB !== undefined) { + return orderA - orderB; + } + if (orderA !== undefined) return -1; + if (orderB !== undefined) return 1; + const statusA = Number(a); const statusB = Number(b); if (Number.isFinite(statusA) && Number.isFinite(statusB)) { @@ -766,6 +783,11 @@ function sortStatusCodes(statuses: string[]): string[] { }); } +function normalizeHTTPStatusFamily(code: string | undefined): string { + if (!code || !/^[2-5]\d\d$/.test(code)) return "unknown"; + return `${code.charAt(0)}xx`; +} + function escapePromQL(value: string) { return value .replace(/\\/g, "\\\\") diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts index 5f97115..40010cc 100644 --- a/web/tests/victoria-metrics-service-metrics.test.ts +++ b/web/tests/victoria-metrics-service-metrics.test.ts @@ -7,7 +7,7 @@ import { } from "@/lib/victoria-metrics"; const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000"; -const END_TS = Date.parse("2026-07-02T12:34:00Z") / 1000; +const END_TS = Date.parse("2026-07-02T12:30:00Z") / 1000; describe("VictoriaMetrics service metrics", () => { afterEach(() => { @@ -66,6 +66,26 @@ describe("VictoriaMetrics service metrics", () => { metric: { code: "200" }, values: [[END_TS, "3"]], }, + { + metric: { code: "204" }, + values: [[END_TS, "2"]], + }, + { + metric: { code: "301" }, + values: [[END_TS, "1"]], + }, + { + metric: { code: "404" }, + values: [[END_TS, "4"]], + }, + { + metric: { code: "500" }, + values: [[END_TS, "5"]], + }, + { + metric: { code: "502" }, + values: [[END_TS, "6"]], + }, ]); } if (query.includes("histogram_quantile(0.95")) { @@ -102,15 +122,16 @@ describe("VictoriaMetrics service metrics", () => { expect( starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)), ).toBe(true); - expect(stats.totalRequests).toBe(3); - expect(stats.statusCodes).toEqual(["200"]); + expect(stats.totalRequests).toBe(21); + expect(stats.statusCodes).toEqual(["2xx", "3xx", "4xx", "5xx"]); expect(stats.buckets).toHaveLength(288); - expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:39:00.000Z"); + expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z"); + expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:35:00.000Z"); const lastBucket = stats.buckets.at(-1); expect(lastBucket).toMatchObject({ - totalRequests: 3, - statuses: { "200": 3 }, + totalRequests: 21, + statuses: { "2xx": 5, "3xx": 1, "4xx": 4, "5xx": 11 }, p95ResponseTimeMs: 123, egressBytesPerSecond: 2048, cpuUsagePercent: 42,