Compare commits

...

5 Commits

Author SHA1 Message Date
Max Kotliar
72005d7e62 app/vmui: top queries better highligh for selected query 2026-04-13 10:36:22 +03:00
Max Kotliar
8d68214f88 revert unrelated changes 2026-04-10 21:48:18 +03:00
Max Kotliar
a901523ab4 revert unrelated changes 2026-04-10 21:47:25 +03:00
Max Kotliar
59032c839f revert unrelated changes 2026-04-10 21:46:07 +03:00
Max Kotliar
5673cd3ac8 app/vmui: link alert rules to top queries and highlight matches
Disclaimer: The code is completly AI genrated. I wanted to explore if
this is at all possible.

---

When visiting the rules page, fetch top queries in the background and
display a chart icon on any rule whose query appears in the top queries
list. The icon links to the top-queries page with topN=100,
maxLifetime=10m and the rule query pre-selected via a `query` URL param.

On the top-queries page, rows whose query matches the `query` param are
highlighted in bold so the user can immediately spot the relevant entry
across all four panels (by count, avg duration, sum duration, avg
memory).

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9493
2026-04-10 21:43:07 +03:00
15 changed files with 186 additions and 36 deletions

View File

@@ -146,6 +146,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -548,6 +549,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -596,31 +598,11 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
@@ -2421,6 +2403,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2763,6 +2746,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3592,6 +3576,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5821,6 +5806,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -6595,8 +6581,7 @@
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
@@ -7231,6 +7216,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7334,6 +7320,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",

View File

@@ -14,6 +14,7 @@ import {
AlertingRuleIcon,
RecordingRuleIcon,
DetailsIcon,
ChartIcon,
} from "../../Main/Icons";
import Button from "../../Main/Button/Button";
@@ -26,9 +27,10 @@ interface ItemHeaderControlsProps {
id?: string;
name: string;
onClose?: () => void;
topQueriesUrl?: string;
}
const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, type, states, onClose, classes }) => {
const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, type, states, onClose, classes, topQueriesUrl }) => {
const { isMobile } = useDeviceDetect();
const { serverUrl } = useAppState();
const navigate = useNavigate();
@@ -108,6 +110,19 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
<div className="vm-explore-alerts-item-header__name">{name}</div>
</div>
<div className="vm-explore-alerts-controls">
{topQueriesUrl && (
<Tooltip title="Top query">
<a
href={`#${topQueriesUrl}`}
target="_blank"
rel="noopener noreferrer"
className="vm-explore-alerts-item-header__top-queries-link"
onClick={(e) => e.stopPropagation()}
>
<ChartIcon />
</a>
</Tooltip>
)}
<Badges
align="end"
items={badgesItems}

View File

@@ -76,4 +76,22 @@
.vm-explore-alerts-controls {
display: flex;
column-gap: $padding-global;
align-items: center;
}
.vm-explore-alerts-item-header__top-queries-link {
display: flex;
align-items: center;
color: $color-text-secondary;
text-decoration: none;
svg {
width: 18px;
height: 18px;
fill: currentColor;
}
&:hover {
color: $color-primary;
}
}

View File

@@ -4,14 +4,24 @@ import Accordion from "../../Main/Accordion/Accordion";
import "./style.scss";
import { Rule as APIRule } from "../../../types";
import BaseRule from "../BaseRule";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { ChartIcon } from "../../Main/Icons";
import router from "../../../router";
interface RuleProps {
states: Record<string, number>;
rule: APIRule;
topQueriesSet?: Set<string>;
}
const Rule: FC<RuleProps> = ({ states, rule }) => {
const normalizeQuery = (q: string): string => q.replace(/\s+/g, " ").trim();
const Rule: FC<RuleProps> = ({ states, rule, topQueriesSet }) => {
const state = Object.keys(states).length > 0 ? Object.keys(states)[0] : "ok";
const isInTopQueries = topQueriesSet ? topQueriesSet.has(normalizeQuery(rule.query)) : false;
const topQueriesUrl = `${router.topQueries}?topN=100&maxLifetime=10m&query=${encodeURIComponent(rule.query)}`;
return (
<div className={`vm-explore-alerts-rule vm-badge-item ${state.replace(" ", "-")}`}>
<Accordion
@@ -23,6 +33,7 @@ const Rule: FC<RuleProps> = ({ states, rule }) => {
states={states}
id={rule.id}
name={rule.name}
topQueriesUrl={isInTopQueries ? topQueriesUrl : undefined}
/>}
>
<BaseRule item={rule} />

View File

@@ -1,8 +1,9 @@
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchItem } from "./hooks/useFetchItem";
import { useFetchGroup } from "./hooks/useFetchGroup";
import "./style.scss";
import { Alert as APIAlert } from "../../types";
import { Alert as APIAlert, Group as APIGroup } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseAlert from "../../components/ExploreAlerts/BaseAlert";
import Modal from "../../components/Main/Modal/Modal";
@@ -21,6 +22,9 @@ const ExploreAlert = ({ groupId, id, mode, onClose }: ExploreAlertProps) => {
error,
} = useFetchItem<APIAlert>({ groupId, id, mode });
const { group } = useFetchGroup<APIGroup>({ id: groupId });
const enrichedItem = item && group ? { ...item, group_interval: group.interval } : item;
if (isLoading) return (
<Spinner />
);
@@ -51,7 +55,7 @@ const ExploreAlert = ({ groupId, id, mode, onClose }: ExploreAlertProps) => {
onClose={onClose}
>
<div className="vm-explore-alerts">
{item && (<BaseAlert item={item} />) || (
{enrichedItem && (<BaseAlert item={enrichedItem} />) || (
<Alert variant="info">{noItemFound}</Alert>
)}
</div>

View File

@@ -1,8 +1,9 @@
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchItem } from "./hooks/useFetchItem";
import { useFetchGroup } from "./hooks/useFetchGroup";
import "./style.scss";
import { Rule as APIRule } from "../../types";
import { Rule as APIRule, Group as APIGroup } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseRule from "../../components/ExploreAlerts/BaseRule";
import Modal from "../../components/Main/Modal/Modal";
@@ -22,6 +23,10 @@ const ExploreRule = ({ groupId, id, mode, onClose }: ExploreRuleProps) => {
error,
} = useFetchItem<APIRule>({ groupId, id, mode });
const { group } = useFetchGroup<APIGroup>({ id: groupId });
console.log(group);
const enrichedItem = item && group ? { ...item, group_interval: group.interval } : item;
if (isLoading) return (
<Spinner />
);
@@ -49,7 +54,7 @@ const ExploreRule = ({ groupId, id, mode, onClose }: ExploreRuleProps) => {
onClose={onClose}
>
<div className="vm-explore-alerts">
{item && (<BaseRule item={item} />) || (
{enrichedItem && (<BaseRule item={enrichedItem} />) || (
<Alert variant="info">{noItemFound}</Alert>
)}
</div>

View File

@@ -17,6 +17,7 @@ import { getQueryStringValue } from "../../utils/query-string";
import { getChanges } from "./helpers";
import debounce from "lodash.debounce";
import { getStates } from "../../components/ExploreAlerts/helpers";
import { useTopQueriesSet } from "./hooks/useTopQueriesSet";
const defaultRuleType = getQueryStringValue("type", "") as string;
const defaultStatesStr = getQueryStringValue("states", "") as string;
@@ -119,6 +120,8 @@ const ExploreRules: FC = () => {
}
}, [states, allStates]);
const topQueriesSet = useTopQueriesSet();
const pageNumInt: number = Math.max(1, parseInt(pageNum, 10) || 1);
const {
groups,
@@ -187,6 +190,7 @@ const ExploreRules: FC = () => {
key={`rule-${rule.id}`}
rule={rule}
states={getStates(rule)}
topQueriesSet={topQueriesSet}
/>
))}
</div>

View File

@@ -2,7 +2,7 @@ import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getGroupUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
import { ErrorTypes, Group } from "../../../types";
interface FetchGroupReturn<T> {
group?: T;
@@ -38,7 +38,9 @@ export const useFetchGroup = <T>({
case "application/json": {
const resp = await response.json();
if (response.ok) {
setGroup(resp as T);
const data = resp as Group;
data.rules?.forEach(rule => { rule.group_interval = data.interval; });
setGroup(data as unknown as T);
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);

View File

@@ -0,0 +1,42 @@
import { useEffect, useMemo, useState } from "preact/compat";
import { useAppState } from "../../../state/common/StateContext";
import { getTopQueries } from "../../../api/top-queries";
import { TopQueriesData } from "../../../types";
const TOP_N = 100;
const MAX_LIFETIME = "10m";
const normalizeQuery = (q: string): string => q.replace(/\s+/g, " ").trim();
export const useTopQueriesSet = (): Set<string> => {
const { serverUrl } = useAppState();
const [querySet, setQuerySet] = useState<Set<string>>(new Set());
const fetchUrl = useMemo(() => getTopQueries(serverUrl, TOP_N, MAX_LIFETIME), [serverUrl]);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(fetchUrl);
if (!response.ok) return;
const data: TopQueriesData = await response.json();
const queries = new Set<string>();
const lists = [data.topByCount, data.topByAvgDuration, data.topBySumDuration, data.topByAvgMemoryUsage];
for (const list of lists) {
if (Array.isArray(list)) {
for (const item of list) {
queries.add(normalizeQuery(item.query));
}
}
}
setQuerySet(queries);
} catch {
// silently ignore errors - top queries is an optional enhancement
}
};
fetchData();
}, [fetchUrl]);
return querySet;
};

View File

@@ -13,6 +13,7 @@ export interface TopQueryPanelProps {
title?: string,
columns: {title?: string, key: (keyof TopQuery), sortBy?: (keyof TopQuery)}[],
defaultOrderBy?: keyof TopQuery,
highlightQuery?: string,
}
const tabs = ["table", "JSON"].map((t, i) => ({
value: String(i),
@@ -20,7 +21,7 @@ const tabs = ["table", "JSON"].map((t, i) => ({
icon: i === 0 ? <TableIcon /> : <CodeIcon />
}));
const TopQueryPanel: FC<TopQueryPanelProps> = ({ rows, title, columns, defaultOrderBy }) => {
const TopQueryPanel: FC<TopQueryPanelProps> = ({ rows, title, columns, defaultOrderBy, highlightQuery }) => {
const { isMobile } = useDeviceDetect();
const [activeTab, setActiveTab] = useState(0);
@@ -69,6 +70,7 @@ const TopQueryPanel: FC<TopQueryPanelProps> = ({ rows, title, columns, defaultOr
rows={rows}
columns={columns}
defaultOrderBy={defaultOrderBy}
highlightQuery={highlightQuery}
/>
)}
{activeTab === 1 && <JsonView data={rows} />}

View File

@@ -9,12 +9,16 @@ import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { Link } from "react-router-dom";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy }) => {
const normalizeQuery = (q: string): string => q.replace(/\s+/g, " ").trim();
const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy, highlightQuery }) => {
const copyToClipboard = useCopyToClipboard();
const [orderBy, setOrderBy] = useState<keyof TopQuery>(defaultOrderBy || "count");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("desc");
const normalizedHighlight = useMemo(() => highlightQuery ? normalizeQuery(highlightQuery) : "", [highlightQuery]);
const sortedList = useMemo(() => stableSort(rows, getComparator(orderDir, orderBy)),
[rows, orderBy, orderDir]);
@@ -59,9 +63,11 @@ const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy })
</tr>
</thead>
<tbody className="vm-table-body">
{sortedList.map((row, rowIndex) => (
{sortedList.map((row, rowIndex) => {
const isHighlighted = normalizedHighlight && normalizeQuery(row.query) === normalizedHighlight;
return (
<tr
className="vm-table__row"
className={classNames({ "vm-table__row": true, "vm-table__row_highlighted": !!isHighlighted })}
key={rowIndex}
>
{columns.map((col) => (
@@ -103,7 +109,8 @@ const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy })
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
);

View File

@@ -15,6 +15,7 @@ import "./style.scss";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import classNames from "classnames";
import useStateSearchParams from "../../hooks/useStateSearchParams";
import { getQueryStringValue } from "../../utils/query-string";
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 highlightQuery = getQueryStringValue("query", "") as string;
const { data, error, loading, fetch } = useFetchTopQueries({ topN, maxLifetime });
@@ -157,6 +159,7 @@ const TopQueries: FC = () => {
{ key: "count" }
]}
defaultOrderBy={"sumDurationSeconds"}
highlightQuery={highlightQuery}
/>
<TopQueryPanel
rows={data.topByAvgDuration}
@@ -168,6 +171,7 @@ const TopQueries: FC = () => {
{ key: "count" }
]}
defaultOrderBy={"avgDurationSeconds"}
highlightQuery={highlightQuery}
/>
<TopQueryPanel
rows={data.topByCount}
@@ -177,6 +181,7 @@ const TopQueries: FC = () => {
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
highlightQuery={highlightQuery}
/>
<TopQueryPanel
rows={data.topByAvgMemoryUsage}
@@ -188,6 +193,7 @@ const TopQueries: FC = () => {
{ key: "count" }
]}
defaultOrderBy={"avgMemoryBytes"}
highlightQuery={highlightQuery}
/>
</div>
</>)}

View File

@@ -23,6 +23,16 @@
&_selected {
background-color: rgba($color-dodger-blue, 0.05);
}
&_highlighted {
font-weight: bold;
background-color: rgba($color-dodger-blue, 0.08);
box-shadow: inset 3px 0 0 $color-dodger-blue;
&:hover {
background-color: rgba($color-dodger-blue, 0.14);
}
}
}
&-cell {

15
ceconfig.yaml Normal file
View File

@@ -0,0 +1,15 @@
streams:
- name: 'global_by_metric_name'
group: "__name__"
- name: 'global_by_instance'
group: "instance"
- name: 'eu_region_by_instance'
filter: '{region="eu-central-1"}'
group: "instance"
# TODO:
# - window duration
# - do not expose as metric below threashold
# -

22
task.md Normal file
View File

@@ -0,0 +1,22 @@
Implement cardinality estimator.
Place absolute all code in app/cestimator.
It should accept a config via -config in yaml format.
Example of configuration:
```yaml
estimators:
- stream: foo # required
filter: 'as promql' #optional
group: 'label name' # optional
```
For each estimator in config it should create hll counter using https://github.com/axiomhq/hyperloglog lib.
If a group parameter is defined than create a hll counter per group.
The app should accept data in Prometheus remote write protocol. Reuse existing solutions.
expose cardinality on /metrics endpoint in format:
cardinality_estimate{stream="foo",group="label name"} 123