app/vmui: improve Top Queries table UX (#10790)

- Add tooltip support to column headers with info icons, explaining what
each column is for.
- Format duration columns using humanizeSeconds instead of raw seconds
- Format memory column with human-readable units (B/KB/MB/GB/TB)
- Shorten column titles ("sum duration, sec" → "duration", "query time
interval" → "range", "avg memory usage, bytes" → "memory")
- Show "instant" for queries with no time range instead of empty value

Before:
<img width="1512" height="863" alt="Screenshot 2026-05-11 at 21 28 49"
src="https://github.com/user-attachments/assets/4e4dc67c-d121-4ecc-974f-3e1e9e28f3b7"
/>

After:
<img width="1512" height="862" alt="Screenshot 2026-05-11 at 21 28 21"
src="https://github.com/user-attachments/assets/89b21e58-a2c4-44d4-8806-a72e9f1555f3"
/>

PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10790

---------

Signed-off-by: Yury Molodov <yurymolodov@gmail.com>
Co-authored-by: Yury Moladau <yurymolodov@gmail.com>
This commit is contained in:
Max Kotliar
2026-05-12 18:57:45 +03:00
parent 0604077b5c
commit 79769e2300
9 changed files with 214 additions and 56 deletions

View File

@@ -1,4 +1,4 @@
import { FC, useState } from "react";
import { FC, ReactNode, useState } from "react";
import { TopQuery } from "../../../types";
import JsonView from "../../../components/Views/JsonView/JsonView";
import { CodeIcon, TableIcon } from "../../../components/Main/Icons";
@@ -8,10 +8,18 @@ import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
export interface TopQueryColumn {
title?: string;
tooltip?: string;
key: keyof TopQuery;
sortBy?: keyof TopQuery;
format?: (row: TopQuery) => ReactNode;
}
export interface TopQueryPanelProps {
rows: TopQuery[],
title?: string,
columns: {title?: string, key: (keyof TopQuery), sortBy?: (keyof TopQuery)}[],
columns: TopQueryColumn[],
defaultOrderBy?: keyof TopQuery,
}
const tabs = ["table", "JSON"].map((t, i) => ({

View File

@@ -3,7 +3,7 @@ import { TopQuery } from "../../../types";
import { getComparator, stableSort } from "../../../components/Table/helpers";
import { TopQueryPanelProps } from "../TopQueryPanel/TopQueryPanel";
import classNames from "classnames";
import { ArrowDropDownIcon, CopyIcon, PlayCircleOutlineIcon } from "../../../components/Main/Icons";
import { ArrowDropDownIcon, CopyIcon, InfoOutlinedIcon, PlayCircleOutlineIcon } from "../../../components/Main/Icons";
import Button from "../../../components/Main/Button/Button";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { Link } from "react-router-dom";
@@ -35,26 +35,40 @@ const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy })
<table className="vm-table">
<thead className="vm-table-header">
<tr className="vm-table__row vm-table__row_header">
{columns.map((col) => (
<th
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"
onClick={createSortHandler(col.sortBy || col.key)}
key={col.key}
>
<div className="vm-table-cell__content">
{col.title || col.key}
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === col.key,
"vm-table__sort-icon_desc": orderDir === "desc" && orderBy === col.key
})}
>
<ArrowDropDownIcon/>
{columns.map((col) => {
const sortKey = col.sortBy || col.key;
return (
<th
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"
onClick={createSortHandler(sortKey)}
key={col.key}
>
<div className="vm-table-cell__content">
{col.title || col.key}
{col.tooltip && (
<Tooltip
placement="top-center"
title={col.tooltip}
>
<span className="vm-top-queries-table__info-icon">
<InfoOutlinedIcon/>
</span>
</Tooltip>
)}
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === sortKey,
"vm-table__sort-icon_desc": orderDir === "desc" && orderBy === sortKey
})}
>
<ArrowDropDownIcon/>
</div>
</div>
</div>
</th>
))}
</th>
);
})}
<th className="vm-table-cell vm-table-cell_header"/> {/* empty cell for actions */}
</tr>
</thead>
@@ -69,7 +83,7 @@ const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy })
className="vm-table-cell"
key={col.key}
>
{row[col.key] || "-"}
{col.format?.(row) ?? row[col.key] ?? "-"}
</td>
))}
<td className="vm-table-cell vm-table-cell_no-padding">

View File

@@ -34,7 +34,7 @@ const processResponse = (data: TopQueriesData) => {
target.forEach(t => {
const timeRange = getDurationFromMilliseconds(t.timeRangeSeconds*1000);
t.url = getQueryUrl(t, timeRange);
t.timeRange = timeRange;
t.timeRange = timeRange || "instant";
});
});

View File

@@ -0,0 +1,77 @@
import { useMemo } from "react";
import { TopQueryColumn } from "../TopQueryPanel/TopQueryPanel";
import { humanizeSeconds } from "../../../utils/time";
import { formatBytes } from "../../../utils/bytes";
type UseTopQueriesColumns = {
maxLifetime: string;
};
export const useTopQueriesColumns = ({ maxLifetime }: UseTopQueriesColumns) => {
return useMemo(() => {
const queryCol: TopQueryColumn = {
key: "query"
};
const timeRangeCol: TopQueryColumn = {
key: "timeRange",
sortBy: "timeRangeSeconds",
title: "range",
tooltip: "The time range between start and end of the query request. 'instant' means the query was executed at a single point in time without a time range"
};
const countCol: TopQueryColumn = {
key: "count",
tooltip: `The number of times the query was executed over the last ${maxLifetime}`,
};
const topBySumDuration: TopQueryColumn[] = [
queryCol,
{
key: "sumDurationSeconds",
title: "duration",
tooltip: `Cumulative time spent executing the query across all its invocations over the last ${maxLifetime}`,
format: (row) => humanizeSeconds(row.sumDurationSeconds)
},
timeRangeCol,
countCol,
];
const topByAvgDuration: TopQueryColumn[] = [
queryCol,
{
key: "avgDurationSeconds",
title: "duration",
tooltip: `Average time spent executing the query over the last ${maxLifetime}`,
format: (row) => humanizeSeconds(row.avgDurationSeconds)
},
timeRangeCol,
countCol,
];
const topByCount: TopQueryColumn[] = [
queryCol,
timeRangeCol,
countCol,
];
const topByAvgMemoryUsage: TopQueryColumn[] = [
queryCol,
{
key: "avgMemoryBytes",
title: "memory",
tooltip: `Average memory used during query execution over the last ${maxLifetime}`,
format: (row) => formatBytes(row.avgMemoryBytes)
},
timeRangeCol,
countCol,
];
return {
topBySumDuration,
topByAvgDuration,
topByCount,
topByAvgMemoryUsage,
};
}, [maxLifetime]);
};

View File

@@ -15,6 +15,7 @@ import "./style.scss";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import classNames from "classnames";
import useStateSearchParams from "../../hooks/useStateSearchParams";
import { useTopQueriesColumns } from "./hooks/useTopQueriesColumns";
const exampleDuration = "30ms, 15s, 3d4h, 1y2w";
@@ -23,6 +24,7 @@ const TopQueries: FC = () => {
const [topN, setTopN] = useStateSearchParams(10, "topN");
const [maxLifetime, setMaxLifetime] = useStateSearchParams("10m", "maxLifetime");
const columns = useTopQueriesColumns({ maxLifetime });
const { data, error, loading, fetch } = useFetchTopQueries({ topN, maxLifetime });
@@ -145,52 +147,33 @@ const TopQueries: FC = () => {
{error && <Alert variant="error">{error}</Alert>}
{data && (<>
{data && (
<div className="vm-top-queries-panels">
<TopQueryPanel
title="Queries with most summary time to execute"
rows={data.topBySumDuration}
title={"Queries with most summary time to execute"}
columns={[
{ key: "query" },
{ key: "sumDurationSeconds", title: "sum duration, sec" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
defaultOrderBy={"sumDurationSeconds"}
columns={columns.topBySumDuration}
defaultOrderBy="sumDurationSeconds"
/>
<TopQueryPanel
title="Most heavy queries"
rows={data.topByAvgDuration}
title={"Most heavy queries"}
columns={[
{ key: "query" },
{ key: "avgDurationSeconds", title: "avg duration, sec" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
defaultOrderBy={"avgDurationSeconds"}
columns={columns.topByAvgDuration}
defaultOrderBy="avgDurationSeconds"
/>
<TopQueryPanel
title="Most frequently executed queries"
rows={data.topByCount}
title={"Most frequently executed queries"}
columns={[
{ key: "query" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
columns={columns.topByCount}
/>
<TopQueryPanel
title="Queries with most memory to execute"
rows={data.topByAvgMemoryUsage}
title={"Queries with most memory to execute"}
columns={[
{ key: "query" },
{ key: "avgMemoryBytes", title: "avg memory usage, bytes" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
defaultOrderBy={"avgMemoryBytes"}
columns={columns.topByAvgMemoryUsage}
defaultOrderBy="avgMemoryBytes"
/>
</div>
</>)}
)}
</div>
);
};

View File

@@ -1,5 +1,19 @@
@use "src/styles/variables" as *;
.vm-top-queries-table {
&__info-icon {
display: inline-flex;
align-items: center;
color: $color-text-secondary;
margin-left: 4px;
svg {
width: 14px;
height: 14px;
}
}
}
.vm-top-queries {
display: grid;
align-items: flex-start;

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "./bytes";
describe("formatBytes", () => {
it("returns null for invalid values", () => {
expect(formatBytes(-1)).toBeNull();
expect(formatBytes(Number.NaN)).toBeNull();
expect(formatBytes(Number.POSITIVE_INFINITY)).toBeNull();
expect(formatBytes(Number.NEGATIVE_INFINITY)).toBeNull();
});
it("formats zero bytes", () => {
expect(formatBytes(0)).toBe("0 B");
});
it("formats bytes", () => {
expect(formatBytes(0.5)).toBe("0.5 B");
expect(formatBytes(1)).toBe("1 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1023)).toBe("1023 B");
});
it("formats kilobytes", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formats megabytes", () => {
expect(formatBytes(1024 ** 2)).toBe("1 MB");
expect(formatBytes(2.5 * 1024 ** 2)).toBe("2.5 MB");
});
it("formats gigabytes, terabytes and petabytes", () => {
expect(formatBytes(1024 ** 3)).toBe("1 GB");
expect(formatBytes(1024 ** 4)).toBe("1 TB");
expect(formatBytes(1024 ** 5)).toBe("1 PB");
});
it("caps values above PB to PB unit", () => {
expect(formatBytes(1024 ** 6)).toBe("1024 PB");
});
it("rounds to two decimals", () => {
expect(formatBytes(1234)).toBe("1.21 KB");
expect(formatBytes(1234567)).toBe("1.18 MB");
});
});

View File

@@ -0,0 +1,14 @@
const LOG_1024 = Math.log(1024);
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
export const formatBytes = (bytes: number): string | null => {
if (!Number.isFinite(bytes) || bytes < 0) return null;
if (bytes === 0) return "0 B";
const unitIndex = Math.min(
Math.max(Math.floor(Math.log(bytes) / LOG_1024), 0),
UNITS.length - 1
);
return `${parseFloat((bytes / 1024 ** unitIndex).toFixed(2))} ${UNITS[unitIndex]}`;
};

View File

@@ -27,6 +27,7 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
## tip
* FEATURE: [vmsingle](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/), `vminsert` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/) and [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): add `-opentelemetry.labelNameUnderscoreSanitization` command-line flag to control whether to enable prepending of `key` to labels starting with `_` when `-opentelemetry.usePrometheusNaming` is enabled. See [OpenTelemetry](https://docs.victoriametrics.com/victoriametrics/integrations/opentelemetry/) docs and [#9663](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9663). Thanks to @andriibeee for the contribution.
* FEATURE: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): improve the [Top Queries](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#top-queries) table UI. Duration columns now display human-readable values (e.g. `1.23s`) instead of raw seconds, memory column shows human-readable sizes (e.g. `1.23 MB`), instant queries are labeled as `instant` instead of empty string, and column headers now show tooltips with descriptions. See [#10790](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10790).
* BUGFIX: [stream aggregation](https://docs.victoriametrics.com/victoriametrics/stream-aggregation/): stop emitting stale values for `quantiles(...)` outputs when a time series has no samples during the current aggregation interval. Thanks to @alexei38 for the [pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10918).
* BUGFIX: [stream aggregation](https://docs.victoriametrics.com/victoriametrics/stream-aggregation/): extend delay on aggregation windows flush by the biggest lag among pushed samples. Before, the delay was calculated as 95th percentile across samples, which could underrepresent outliers and reject them from aggregation as "too old". See [#10402](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10402).