Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 128 additions & 66 deletions web/components/service/details/service-details-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -154,16 +159,21 @@ const STATUS_CODE_COLOR_PALETTES: Record<string, string[]> = {
default: ["#64748b", "#0ea5e9", "#a855f7", "#71717a"],
};

const STATUS_FAMILY_COLORS: Record<string, string> = {
"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,
Expand All @@ -177,7 +187,6 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
<div className="grid items-stretch lg:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.8fr)]">
<ServiceMetricsPanel
hasPublicHttp={overview.publicHttpCount > 0}
hasRunningDeployments={overview.runningDeployments > 0}
stats={serviceMetrics}
error={serviceMetricsError}
isLoading={isServiceMetricsLoading}
Expand All @@ -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;
Expand All @@ -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 (
<div className="flex h-full min-h-72 flex-col gap-4 p-4">
Expand All @@ -226,74 +233,31 @@ function ServiceMetricsPanel({
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-20" />
</div>
) : hasRunningDeployments ? (
) : (
<div className="flex flex-wrap items-end gap-x-5 gap-y-2">
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData
? formatCompactNumber(stats.totalRequests)
: "-"}
</p>
<p className="text-sm text-muted-foreground">requests in 24h</p>
</div>
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData
? formatRate(getAverageRequestsPerSecond(stats))
: "-"}
</p>
<p className="text-sm text-muted-foreground">avg RPS</p>
</div>
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData && p95 != null ? formatDurationMs(p95) : "-"}
</p>
<p className="text-sm text-muted-foreground">p95 latency</p>
</div>
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData && cpu != null ? `${formatRate(cpu)}%` : "-"}
</p>
<p className="text-sm text-muted-foreground">CPU</p>
</div>
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData && memory != null ? formatBytes(memory) : "-"}
</p>
<p className="text-sm text-muted-foreground">memory</p>
</div>
<div>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{hasMetricData && egress != null
? `${formatBytes(egress)}/s`
: "-"}
</p>
<p className="text-sm text-muted-foreground">egress</p>
</div>
{summaryItems.map((item) => (
<div key={item.label}>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
{item.value}
</p>
<p className="text-sm text-muted-foreground">{item.label}</p>
</div>
))}
</div>
) : (
<>
<p className="font-mono text-xl font-semibold tabular-nums tracking-tight">
-
</p>
<p className="text-sm text-muted-foreground">not deployed</p>
</>
)}
</div>
<div className="flex flex-col items-end gap-2">
<p className="text-sm text-muted-foreground">{formatToday()}</p>
<ServiceChartModeToggle
value={chartMode}
onChange={setChartMode}
disabled={!hasRunningDeployments || isLoading || isUnavailable}
disabled={isLoading || isUnavailable}
/>
</div>
</div>

<div className="min-h-40 min-w-0 flex-1">
{!hasRunningDeployments ? (
<ServiceMetricsState message="Deploy the service to collect metrics" />
) : isLoading ? (
{isLoading ? (
<Skeleton className="h-full rounded-lg" />
) : isUnavailable ? (
<ServiceMetricsState message="Service metrics unavailable" />
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand Down
26 changes: 24 additions & 2 deletions web/lib/victoria-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ export async function queryServiceMetrics(options: {
const statusCodes = new Set<string>();

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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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, "\\\\")
Expand Down
33 changes: 27 additions & 6 deletions web/tests/victoria-metrics-service-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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,
Expand Down
Loading