diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryPanel/TopQueryPanel.tsx b/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryPanel/TopQueryPanel.tsx index e70bf6d21a..199820e9b5 100644 --- a/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryPanel/TopQueryPanel.tsx +++ b/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryPanel/TopQueryPanel.tsx @@ -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) => ({ diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryTable/TopQueryTable.tsx b/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryTable/TopQueryTable.tsx index 09343387d4..c118e86363 100644 --- a/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryTable/TopQueryTable.tsx +++ b/app/vmui/packages/vmui/src/pages/TopQueries/TopQueryTable/TopQueryTable.tsx @@ -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 = ({ rows, columns, defaultOrderBy }) - {columns.map((col) => ( - - ))} + + ); + })} @@ -69,7 +83,7 @@ const TopQueryTable:FC = ({ rows, columns, defaultOrderBy }) className="vm-table-cell" key={col.key} > - {row[col.key] || "-"} + {col.format?.(row) ?? row[col.key] ?? "-"} ))}
-
- {col.title || col.key} -
- + {columns.map((col) => { + const sortKey = col.sortBy || col.key; + + return ( +
+
+ {col.title || col.key} + {col.tooltip && ( + + + + + + )} +
+ +
- -
{/* empty cell for actions */}
diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useFetchTopQueries.ts b/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useFetchTopQueries.ts index ab5af22356..7620447e1e 100644 --- a/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useFetchTopQueries.ts +++ b/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useFetchTopQueries.ts @@ -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"; }); }); diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useTopQueriesColumns.ts b/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useTopQueriesColumns.ts new file mode 100644 index 0000000000..e65aaae13a --- /dev/null +++ b/app/vmui/packages/vmui/src/pages/TopQueries/hooks/useTopQueriesColumns.ts @@ -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]); +}; diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/index.tsx b/app/vmui/packages/vmui/src/pages/TopQueries/index.tsx index b212be6c2b..e00536cca8 100644 --- a/app/vmui/packages/vmui/src/pages/TopQueries/index.tsx +++ b/app/vmui/packages/vmui/src/pages/TopQueries/index.tsx @@ -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 && {error}} - {data && (<> + {data && (
- )} + )} ); }; diff --git a/app/vmui/packages/vmui/src/pages/TopQueries/style.scss b/app/vmui/packages/vmui/src/pages/TopQueries/style.scss index 4f55438450..98f4a08691 100644 --- a/app/vmui/packages/vmui/src/pages/TopQueries/style.scss +++ b/app/vmui/packages/vmui/src/pages/TopQueries/style.scss @@ -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; diff --git a/app/vmui/packages/vmui/src/utils/bytes.test.ts b/app/vmui/packages/vmui/src/utils/bytes.test.ts new file mode 100644 index 0000000000..816d15e197 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/bytes.test.ts @@ -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"); + }); +}); diff --git a/app/vmui/packages/vmui/src/utils/bytes.ts b/app/vmui/packages/vmui/src/utils/bytes.ts new file mode 100644 index 0000000000..aa6eb3e343 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/bytes.ts @@ -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]}`; +}; diff --git a/docs/victoriametrics/changelog/CHANGELOG.md b/docs/victoriametrics/changelog/CHANGELOG.md index 56bd9a3d5f..b28fc27ee1 100644 --- a/docs/victoriametrics/changelog/CHANGELOG.md +++ b/docs/victoriametrics/changelog/CHANGELOG.md @@ -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).