mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-05-17 08:36:55 +03:00
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:
@@ -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) => ({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
app/vmui/packages/vmui/src/utils/bytes.test.ts
Normal file
47
app/vmui/packages/vmui/src/utils/bytes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
14
app/vmui/packages/vmui/src/utils/bytes.ts
Normal file
14
app/vmui/packages/vmui/src/utils/bytes.ts
Normal 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]}`;
|
||||
};
|
||||
Reference in New Issue
Block a user