vmui: autocomplete usability improvements (#5422)

* vmui: add show quick tip for autocomplete

* vmui: auto-completion usability improvements #5348

* vmui: add const for min symbols in autocomplete

* Use proper queries to VictoriaMetrics

* vmui: fix comments for autocomplete

* app/vmselect: run `make vmui-update`

---------

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2023-12-12 23:32:41 +01:00 committed by GitHub
parent 0f91f83639
commit 1a5cdb4790
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 400 additions and 167 deletions

View File

@ -396,6 +396,9 @@ The UI allows exploring query results via graphs and tables. It also provides th
- [WITH expressions playground](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/expand-with-exprs) - test how WITH expressions work;
- [Metric relabel debugger](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/relabeling) - playground for [relabeling](#relabeling) configs.
VMUI provides auto-completion for [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html) functions, metric names, label names and label values. The auto-completion can be enabled
by checking the `Autocomplete` toggle. When the auto-completion is disabled, it can still be triggered for the current cursor position by pressing `ctrl+space`.
VMUI automatically switches from graph view to heatmap view when the query returns [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram) buckets
(both [Prometheus histograms](https://prometheus.io/docs/concepts/metric_types/#histogram)
and [VictoriaMetrics histograms](https://valyala.medium.com/improving-histogram-usability-for-prometheus-and-grafana-bc7e5df0e350) are supported).

View File

@ -1,13 +1,13 @@
{
"files": {
"main.css": "./static/css/main.349e6522.css",
"main.js": "./static/js/main.c93073e5.js",
"main.css": "./static/css/main.fb353c1e.css",
"main.js": "./static/js/main.5bcddddc.js",
"static/js/522.da77e7b3.chunk.js": "./static/js/522.da77e7b3.chunk.js",
"static/media/MetricsQL.md": "./static/media/MetricsQL.8644fd7c964802dd34a9.md",
"static/media/MetricsQL.md": "./static/media/MetricsQL.b64c4dbf91f4fa581621.md",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.349e6522.css",
"static/js/main.c93073e5.js"
"static/css/main.fb353c1e.css",
"static/js/main.5bcddddc.js"
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.c93073e5.js"></script><link href="./static/css/main.349e6522.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.5bcddddc.js"></script><link href="./static/css/main.fb353c1e.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1029,7 +1029,7 @@ for every point of every time series returned by `q`.
Metric names are stripped from the resulting series. Add [keep_metric_names](#keep_metric_names) modifier in order to keep metric names.
This function is supported by PromQL. This function is supported by PromQL. See also [acosh](#acosh).
This function is supported by PromQL. See also [acosh](#acosh).
#### day_of_month
@ -1049,6 +1049,15 @@ Metric names are stripped from the resulting series. Add [keep_metric_names](#ke
This function is supported by PromQL.
#### day_of_year
`day_of_year(q)` is a [transform function](#transform-functions), which returns the day of year for every point of every time series returned by `q`.
It is expected that `q` returns unix timestamps. The returned values are in the range `[1...365]` for non-leap years, and `[1 to 366]` in leap years.
Metric names are stripped from the resulting series. Add [keep_metric_names](#keep_metric_names) modifier in order to keep metric names.
This function is supported by PromQL.
#### days_in_month
`days_in_month(q)` is a [transform function](#transform-functions), which returns the number of days in the month identified

View File

@ -1029,7 +1029,7 @@ for every point of every time series returned by `q`.
Metric names are stripped from the resulting series. Add [keep_metric_names](#keep_metric_names) modifier in order to keep metric names.
This function is supported by PromQL. This function is supported by PromQL. See also [acosh](#acosh).
This function is supported by PromQL. See also [acosh](#acosh).
#### day_of_month
@ -1049,6 +1049,15 @@ Metric names are stripped from the resulting series. Add [keep_metric_names](#ke
This function is supported by PromQL.
#### day_of_year
`day_of_year(q)` is a [transform function](#transform-functions), which returns the day of year for every point of every time series returned by `q`.
It is expected that `q` returns unix timestamps. The returned values are in the range `[1...365]` for non-leap years, and `[1 to 366]` in leap years.
Metric names are stripped from the resulting series. Add [keep_metric_names](#keep_metric_names) modifier in order to keep metric names.
This function is supported by PromQL.
#### days_in_month
`days_in_month(q)` is a [transform function](#transform-functions), which returns the number of days in the month identified

View File

@ -11,7 +11,7 @@ import classNames from "classnames";
import useBoolean from "../../../hooks/useBoolean";
import useEventListener from "../../../hooks/useEventListener";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { AUTOCOMPLETE_KEY } from "../../Main/ShortcutKeys/constants/keyList";
import { AUTOCOMPLETE_QUICK_KEY } from "../../Main/ShortcutKeys/constants/keyList";
const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
const { autocomplete } = useQueryState();
@ -32,11 +32,16 @@ const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
};
const onChangeQuickAutocomplete = () => {
queryDispatch({ type: "SET_AUTOCOMPLETE_QUICK", payload: true });
};
const handleKeyDown = (e: KeyboardEvent) => {
const { code, altKey } = e;
if (code === "KeyA" && altKey) {
/** @see AUTOCOMPLETE_QUICK_KEY */
const { code, ctrlKey, altKey } = e;
if (code === "Space" && (ctrlKey || altKey)) {
e.preventDefault();
onChangeAutocomplete();
onChangeQuickAutocomplete();
}
};
@ -49,7 +54,7 @@ const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
"vm-additional-settings_mobile": isMobile
})}
>
<Tooltip title={AUTOCOMPLETE_KEY}>
<Tooltip title={<>Quick tip: {AUTOCOMPLETE_QUICK_KEY}</>}>
<Switch
label={"Autocomplete"}
value={autocomplete}

View File

@ -0,0 +1,43 @@
import { AUTOCOMPLETE_LIMITS } from "../../../constants/queryAutocomplete";
export type QueryAutocompleteCacheItem = {
type: string;
value: string;
start: string;
end: string;
match: string;
}
export class QueryAutocompleteCache {
private maxSize: number;
private map: Map<string, string[]>;
constructor() {
this.maxSize = AUTOCOMPLETE_LIMITS.cacheLimit;
this.map = new Map();
}
get(key: QueryAutocompleteCacheItem) {
for (const [cacheKey, cacheValue] of this.map) {
const cacheItem = JSON.parse(cacheKey) as QueryAutocompleteCacheItem;
const equalRange = cacheItem.start === key.start && cacheItem.end === key.end;
const equalType = cacheItem.type === key.type;
const isIncluded = key.value && cacheItem.value && key.value.includes(cacheItem.value);
const isSimilar = cacheItem.match === key.match || isIncluded;
const isUnderLimit = cacheValue.length < AUTOCOMPLETE_LIMITS.queryLimit;
if (isSimilar && equalRange && equalType && isUnderLimit) {
return cacheValue;
}
}
return this.map.get(JSON.stringify(key));
}
put(key: QueryAutocompleteCacheItem, value: string[]) {
if (this.map.size >= this.maxSize) {
const firstKey = this.map.keys().next().value;
this.map.delete(firstKey);
}
this.map.set(JSON.stringify(key), value);
}
}

View File

@ -1,5 +1,5 @@
import React, { FC, useRef, useState } from "preact/compat";
import { KeyboardEvent } from "react";
import { KeyboardEvent, useEffect } from "react";
import { ErrorTypes } from "../../../types";
import TextField from "../../Main/TextField/TextField";
import QueryEditorAutocomplete from "./QueryEditorAutocomplete";
@ -7,6 +7,7 @@ import "./style.scss";
import { QueryStats } from "../../../api/types";
import { partialWarning, seriesFetchedWarning } from "./warningText";
import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
import { useQueryDispatch } from "../../../state/query/QueryStateContext";
export interface QueryEditorProps {
onChange: (query: string) => void;
@ -38,6 +39,7 @@ const QueryEditor: FC<QueryEditorProps> = ({
const [openAutocomplete, setOpenAutocomplete] = useState(false);
const [caretPosition, setCaretPosition] = useState([0, 0]);
const autocompleteAnchorEl = useRef<HTMLInputElement>(null);
const queryDispatch = useQueryDispatch();
const warning = [
{
@ -100,6 +102,10 @@ const QueryEditor: FC<QueryEditorProps> = ({
setCaretPosition(val);
};
useEffect(() => {
queryDispatch({ type: "SET_AUTOCOMPLETE_QUICK", payload: false });
}, [value]);
return (
<div
className="vm-query-editor"

View File

@ -2,15 +2,11 @@ import React, { FC, Ref, useState, useEffect, useMemo } from "preact/compat";
import Autocomplete, { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { getTextWidth } from "../../../utils/uplot";
import { escapeRegExp } from "../../../utils/regexp";
import { escapeRegexp } from "../../../utils/regexp";
import useGetMetricsQL from "../../../hooks/useGetMetricsQL";
enum ContextType {
empty = "empty",
metricsql = "metricsql",
label = "label",
value = "value",
}
import { RefreshIcon } from "../../Main/Icons";
import { QueryContextType } from "../../../types";
import { AUTOCOMPLETE_LIMITS, AUTOCOMPLETE_MIN_SYMBOLS } from "../../../constants/queryAutocomplete";
interface QueryEditorAutocompleteProps {
value: string;
@ -37,65 +33,70 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
}, [value]);
const label = useMemo(() => {
const regexp = /[a-z_]\w*(?=\s*(=|!=|=~|!~))/g;
const regexp = /[a-z_:-][\w\-.:/]*\b(?=\s*(=|!=|=~|!~))/g;
const match = value.match(regexp);
return match ? match[match.length - 1] : "";
}, [value]);
const metricRegexp = new RegExp(`\\(?(${escapeRegExp(metric)})$`, "g");
const labelRegexp = /[{.,].?(\w+)$/gm;
const valueRegexp = new RegExp(`(${escapeRegExp(metric)})?{?.+${escapeRegExp(label)}="?([^"]*)$`, "g");
const context = useMemo(() => {
[metricRegexp, labelRegexp, valueRegexp].forEach(regexp => regexp.lastIndex = 0);
switch (true) {
case valueRegexp.test(value):
return ContextType.value;
case labelRegexp.test(value):
return ContextType.label;
case metricRegexp.test(value):
return ContextType.metricsql;
default:
return ContextType.empty;
}
}, [value, valueRegexp, labelRegexp, metricRegexp]);
if (!value) return QueryContextType.empty;
const { metrics, labels, values } = useFetchQueryOptions({ metric, label });
const labelRegexp = /\{[^}]*?(\w+)$/gm;
const labelValueRegexp = new RegExp(`(${escapeRegexp(metric)})?{?.+${escapeRegexp(label)}(=|!=|=~|!~)"?([^"]*)$`, "g");
switch (true) {
case labelValueRegexp.test(value):
return QueryContextType.labelValue;
case labelRegexp.test(value):
return QueryContextType.label;
default:
return QueryContextType.metricsql;
}
}, [value, metric, label]);
const valueByContext = useMemo(() => {
const wordMatch = value.match(/([\w_\-.:/]+(?![},]))$/);
return wordMatch ? wordMatch[0] : "";
}, [value]);
const { metrics, labels, labelValues, loading } = useFetchQueryOptions({
valueByContext,
metric,
label,
context,
});
const options = useMemo(() => {
switch (context) {
case ContextType.metricsql:
case QueryContextType.metricsql:
return [...metrics, ...metricsqlFunctions];
case ContextType.label:
case QueryContextType.label:
return labels;
case ContextType.value:
return values;
case QueryContextType.labelValue:
return labelValues;
default:
return [];
}
}, [context, metrics, labels, values]);
const valueByContext = useMemo(() => {
if (value.length !== caretPosition[1]) return value;
const wordMatch = value.match(/([\w_]+)$/) || [];
return wordMatch[1] || "";
}, [context, caretPosition, value]);
}, [context, metrics, labels, labelValues]);
const handleSelect = (insert: string) => {
const wordMatch = value.match(/([\w_]+)$/);
const wordMatchIndex = wordMatch?.index !== undefined ? wordMatch.index : value.length;
const beforeInsert = value.substring(0, wordMatchIndex);
const afterInsert = value.substring(wordMatchIndex + (wordMatch?.[1].length || 0));
// Find the start and end of valueByContext in the query string
const startIndexOfValueByContext = value.lastIndexOf(valueByContext, caretPosition[0]);
const endIndexOfValueByContext = startIndexOfValueByContext + valueByContext.length;
if (context === ContextType.value) {
// Split the original string into parts: before, during, and after valueByContext
const beforeValueByContext = value.substring(0, startIndexOfValueByContext);
const afterValueByContext = value.substring(endIndexOfValueByContext);
// Add quotes around the value if the context is labelValue
if (context === QueryContextType.labelValue) {
const quote = "\"";
const needsQuote = beforeInsert[beforeInsert.length - 1] !== quote;
const needsQuote = !beforeValueByContext.endsWith(quote);
insert = `${needsQuote ? quote : ""}${insert}${quote}`;
}
const newVal = `${beforeInsert}${insert}${afterInsert}`;
// Assemble the new value with the inserted text
const newVal = `${beforeValueByContext}${insert}${afterValueByContext}`;
onSelect(newVal);
};
@ -113,16 +114,23 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
}, [anchorEl, caretPosition]);
return (
<Autocomplete
disabledFullScreen
value={valueByContext}
options={options}
anchor={anchorEl}
minLength={context === ContextType.metricsql ? 2 : 0}
offset={{ top: 0, left: leftOffset }}
onSelect={handleSelect}
onFoundOptions={onFoundOptions}
/>
<>
<Autocomplete
disabledFullScreen
value={valueByContext}
options={options?.length < AUTOCOMPLETE_LIMITS.queryLimit ? options : []}
anchor={anchorEl}
minLength={AUTOCOMPLETE_MIN_SYMBOLS[context]}
offset={{ top: 0, left: leftOffset }}
onSelect={handleSelect}
onFoundOptions={onFoundOptions}
maxDisplayResults={{
limit: AUTOCOMPLETE_LIMITS.displayResults,
message: "Please, specify the query more precisely."
}}
/>
{loading && <div className="vm-query-editor-autocomplete"><RefreshIcon/></div>}
</>
);
};

View File

@ -4,7 +4,18 @@
position: relative;
&-autocomplete {
max-height: 300px;
overflow: auto;
position: absolute;
display: flex;
align-items: center;
justify-items: center;
top: 0;
bottom: 0;
right: $padding-global;
width: 12px;
height: 100%;
color: $color-text-secondary;
z-index: 2;
animation: half-circle-spinner-animation 1s infinite linear, vm-fade 0.5s ease-in;
pointer-events: none;
}
}

View File

@ -26,6 +26,7 @@ interface AutocompleteProps {
label?: string
disabledFullScreen?: boolean
offset?: {top: number, left: number}
maxDisplayResults?: {limit: number, message?: string}
onSelect: (val: string) => void
onOpenAutocomplete?: (val: boolean) => void
onFoundOptions?: (val: AutocompleteOptions[]) => void
@ -48,6 +49,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
label,
disabledFullScreen,
offset,
maxDisplayResults,
onSelect,
onOpenAutocomplete,
onFoundOptions
@ -56,6 +58,8 @@ const Autocomplete: FC<AutocompleteProps> = ({
const wrapperEl = useRef<HTMLDivElement>(null);
const [focusOption, setFocusOption] = useState<{index: number, type?: FocusType}>({ index: -1 });
const [showMessage, setShowMessage] = useState("");
const [totalFound, setTotalFound] = useState(0);
const {
value: openAutocomplete,
@ -68,7 +72,14 @@ const Autocomplete: FC<AutocompleteProps> = ({
try {
const regexp = new RegExp(String(value.trim()), "i");
const found = options.filter((item) => regexp.test(item.value));
return found.sort((a,b) => (a.value.match(regexp)?.index || 0) - (b.value.match(regexp)?.index || 0));
const sorted = found.sort((a, b) => {
if (a.value.toLowerCase() === value.trim().toLowerCase()) return -1;
if (b.value.toLowerCase() === value.trim().toLowerCase()) return 1;
return (a.value.match(regexp)?.index || 0) - (b.value.match(regexp)?.index || 0);
});
setTotalFound(sorted.length);
setShowMessage(sorted.length > Number(maxDisplayResults?.limit) ? maxDisplayResults?.message || "" : "");
return maxDisplayResults?.limit ? sorted.slice(0, maxDisplayResults.limit) : sorted;
} catch (e) {
return [];
}
@ -133,7 +144,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
useEffect(() => {
setOpenAutocomplete(value.length >= minLength);
}, [value]);
}, [value, options]);
useEventListener("keydown", handleKeyDown);
@ -170,7 +181,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
ref={wrapperEl}
>
{displayNoOptionsText && <div className="vm-autocomplete__no-options">{noOptionsText}</div>}
{foundOptions.map((option, i) =>
{!(foundOptions.length === 1 && foundOptions[0]?.value === value) && foundOptions.map((option, i) =>
<div
className={classNames({
"vm-list-item": true,
@ -192,6 +203,11 @@ const Autocomplete: FC<AutocompleteProps> = ({
</div>
)}
</div>
{showMessage && (
<div className="vm-autocomplete-message">
Shown {maxDisplayResults?.limit} results out of {totalFound}. {showMessage}
</div>
)}
{foundOptions[focusOption.index]?.description && (
<div className="vm-autocomplete-info">
<div className="vm-autocomplete-info__type">

View File

@ -16,16 +16,33 @@
color: $color-text-disabled;
}
&-info {
position: absolute;
top: calc(100% + 1px);
left: 0;
right: 0;
min-width: 450px;
&-info,
&-message {
padding: $padding-global;
background-color: $color-background-block;
box-shadow: $box-shadow-popper;
border-radius: $border-radius-small;
border-top: $border-divider;
}
&-message {
position: relative;
color: $color-warning;
font-size: $font-size-small;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $color-warning;
opacity: 0.1;
}
}
&-info {
min-width: 450px;
max-width: 500px;
overflow-wrap: anywhere;
&__type {

View File

@ -4,9 +4,8 @@ import { VisibilityIcon } from "../../Icons";
import GraphTips from "../../../Chart/GraphTips/GraphTips";
const ctrlMeta = <code>{isMacOs() ? "Cmd" : "Ctrl"}</code>;
const altMeta = <code>{isMacOs() ? "Option" : "Alt"}</code>;
export const AUTOCOMPLETE_KEY = <>{altMeta} + <code>A</code></>;
export const AUTOCOMPLETE_QUICK_KEY = <>{<code>{isMacOs() ? "Option" : "Ctrl"}</code>} + <code>Space</code></>;
const keyList = [
{
@ -33,8 +32,8 @@ const keyList = [
description: "Toggle multiple queries"
},
{
keys: AUTOCOMPLETE_KEY,
description: "Toggle autocomplete"
keys: AUTOCOMPLETE_QUICK_KEY,
description: "Show quick autocomplete tips"
}
]
},

View File

@ -83,7 +83,6 @@ const TextField: FC<TextFieldProps> = ({
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onKeyDown && onKeyDown(e);
updateCaretPosition(e.currentTarget);
const { key, ctrlKey, metaKey } = e;
const isEnter = key === "Enter";
const runByEnter = type !== "textarea" ? isEnter : isEnter && (metaKey || ctrlKey);
@ -93,6 +92,10 @@ const TextField: FC<TextFieldProps> = ({
}
};
const handleKeyUp = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
updateCaretPosition(e.currentTarget);
};
const handleChange = (e: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (disabled) return;
onChange && onChange(e.currentTarget.value);
@ -135,6 +138,7 @@ const TextField: FC<TextFieldProps> = ({
autoCapitalize={"none"}
onInput={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseUp={handleMouseUp}
@ -152,6 +156,7 @@ const TextField: FC<TextFieldProps> = ({
autoCapitalize={"none"}
onInput={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseUp={handleMouseUp}

View File

@ -0,0 +1,14 @@
import { QueryContextType } from "../types";
export const AUTOCOMPLETE_LIMITS = {
displayResults: 50,
queryLimit: 1000,
cacheLimit: 1000,
};
export const AUTOCOMPLETE_MIN_SYMBOLS = {
[QueryContextType.metricsql]: 2,
[QueryContextType.empty]: 2,
[QueryContextType.label]: 0,
[QueryContextType.labelValue]: 0,
};

View File

@ -5,140 +5,176 @@ import { AutocompleteOptions } from "../components/Main/Autocomplete/Autocomplet
import { LabelIcon, MetricIcon, ValueIcon } from "../components/Main/Icons";
import { useTimeState } from "../state/time/TimeStateContext";
import { useCallback } from "react";
import qs from "qs";
import dayjs from "dayjs";
import debounce from "lodash.debounce";
import { useQueryDispatch, useQueryState } from "../state/query/QueryStateContext";
import { QueryContextType } from "../types";
import { AUTOCOMPLETE_LIMITS } from "../constants/queryAutocomplete";
import { escapeDoubleQuotes, escapeRegexp } from "../utils/regexp";
enum TypeData {
metric,
label,
value
metric = "metric",
label = "label",
labelValue = "labelValue"
}
type FetchDataArgs = {
value: string;
urlSuffix: string;
setter: StateUpdater<AutocompleteOptions[]>;
type: TypeData;
params?: URLSearchParams;
}
type FetchQueryArguments = {
valueByContext: string;
metric: string;
label: string;
context: QueryContextType
}
const icons = {
[TypeData.metric]: <MetricIcon />,
[TypeData.label]: <LabelIcon />,
[TypeData.value]: <ValueIcon />,
[TypeData.metric]: <MetricIcon/>,
[TypeData.label]: <LabelIcon/>,
[TypeData.labelValue]: <ValueIcon/>,
};
const QUERY_LIMIT = 1000;
export const useFetchQueryOptions = ({ metric, label }: { metric: string; label: string }) => {
export const useFetchQueryOptions = ({ valueByContext, metric, label, context }: FetchQueryArguments) => {
const { serverUrl } = useAppState();
const { period: { start, end } } = useTimeState();
const { autocompleteCache } = useQueryState();
const queryDispatch = useQueryDispatch();
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(valueByContext);
const debouncedSetValue = debounce(setValue, 800);
useEffect(() => {
debouncedSetValue(valueByContext);
return debouncedSetValue.cancel;
}, [valueByContext, debouncedSetValue]);
const [metrics, setMetrics] = useState<AutocompleteOptions[]>([]);
const [labels, setLabels] = useState<AutocompleteOptions[]>([]);
const [values, setValues] = useState<AutocompleteOptions[]>([]);
const [labelValues, setLabelValues] = useState<AutocompleteOptions[]>([]);
const prevParams = useRef<Record<string, URLSearchParams>>({});
const abortControllerRef = useRef(new AbortController());
const getQueryParams = useCallback((params?: Record<string, string>) => {
const roundedStart = dayjs(start).startOf("day").valueOf();
const roundedEnd = dayjs(end).endOf("day").valueOf();
return new URLSearchParams({
...(params || {}),
limit: `${QUERY_LIMIT}`,
start: `${roundedStart}`,
end: `${roundedEnd}`
limit: `${AUTOCOMPLETE_LIMITS.queryLimit}`,
start: `${start}`,
end: `${end}`
});
}, [start, end]);
const isParamsEqual = (prev: URLSearchParams, next: URLSearchParams) => {
const queryNext = qs.parse(next.toString());
const queryPrev = qs.parse(prev.toString());
return JSON.stringify(queryPrev) === JSON.stringify(queryNext);
const processData = (data: string[], type: TypeData) => {
return data.map(l => ({
value: l,
type: `${type}`,
icon: icons[type]
}));
};
const fetchData = async ({ urlSuffix, setter, type, params }: FetchDataArgs) => {
const fetchData = async ({ value, urlSuffix, setter, type, params }: FetchDataArgs) => {
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const key = {
type,
value,
start: params?.get("start") || "",
end: params?.get("end") || "",
match: params?.get("match[]") || ""
};
setLoading(true);
try {
const response = await fetch(`${serverUrl}/api/v1/${urlSuffix}?${params}`);
const cachedData = autocompleteCache.get(key);
if (cachedData) {
setter(processData(cachedData, type));
return;
}
const response = await fetch(`${serverUrl}/api/v1/${urlSuffix}?${params}`, { signal });
if (response.ok) {
const { data } = await response.json() as { data: string[] };
setter(data.map(l => ({
value: l,
type: `${type}`,
icon: icons[type]
})));
setter(processData(data, type));
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: data } });
}
} catch (e) {
console.error(e);
if (e instanceof Error && e.name !== "AbortError") {
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: [] } });
console.error(e);
}
} finally {
setLoading(false);
}
};
// fetch metrics
useEffect(() => {
if (!serverUrl) {
setMetrics([]);
const isInvalidContext = context !== QueryContextType.metricsql && context !== QueryContextType.empty;
if (!serverUrl || !metric || isInvalidContext) {
return;
}
setMetrics([]);
const params = getQueryParams();
const prev = prevParams.current.metrics || new URLSearchParams({});
if (isParamsEqual(params, prev)) return;
const metricReEscaped = escapeDoubleQuotes(escapeRegexp(metric));
fetchData({
value,
urlSuffix: "label/__name__/values",
setter: setMetrics,
type: TypeData.metric,
params
params: getQueryParams({ "match[]": `{__name__=~".*${metricReEscaped}.*"}` })
});
prevParams.current = { ...prevParams.current, metrics: params };
}, [serverUrl, getQueryParams]);
return () => abortControllerRef.current?.abort();
}, [serverUrl, value, context, metric]);
// fetch labels
useEffect(() => {
const notFoundMetric = !metrics.find(m => m.value === metric);
if (!serverUrl || notFoundMetric) {
setLabels([]);
if (!serverUrl || !metric || context !== QueryContextType.label) {
return;
}
setLabels([]);
const params = getQueryParams({ "match[]": metric });
const prev = prevParams.current.labels || new URLSearchParams({});
if (isParamsEqual(params, prev)) return;
const metricEscaped = escapeDoubleQuotes(metric);
fetchData({
value,
urlSuffix: "labels",
setter: setLabels,
type: TypeData.label,
params
params: getQueryParams({ "match[]": `{__name__="${metricEscaped}"}` })
});
prevParams.current = { ...prevParams.current, labels: params };
}, [serverUrl, metric, getQueryParams]);
return () => abortControllerRef.current?.abort();
}, [serverUrl, value, context, metric]);
// fetch labelValues
useEffect(() => {
const notFoundMetric = !metrics.find(m => m.value === metric);
const notFoundLabel = !labels.find(l => l.value === label);
if (!serverUrl || notFoundMetric || notFoundLabel) {
setValues([]);
if (!serverUrl || !metric || !label || context !== QueryContextType.labelValue) {
return;
}
setLabelValues([]);
const params = getQueryParams({ "match[]": metric });
const prev = prevParams.current.values || new URLSearchParams({});
if (isParamsEqual(params, prev)) return;
const metricEscaped = escapeDoubleQuotes(metric);
const valueReEscaped = escapeDoubleQuotes(escapeRegexp(value));
fetchData({
value,
urlSuffix: `label/${label}/values`,
setter: setValues,
type: TypeData.value,
params
setter: setLabelValues,
type: TypeData.labelValue,
params: getQueryParams({ "match[]": `{__name__="${metricEscaped}", ${label}=~".*${valueReEscaped}.*"}` })
});
prevParams.current = { ...prevParams.current, values: params };
}, [serverUrl, metric, label, getQueryParams]);
return () => abortControllerRef.current?.abort();
}, [serverUrl, value, context, metric, label]);
return {
metrics,
labels,
values,
labelValues,
loading,
};
};

View File

@ -1,8 +1,9 @@
import React, { useEffect, useState } from "preact/compat";
import React, { useEffect } from "preact/compat";
import { FunctionIcon } from "../components/Main/Icons";
import { AutocompleteOptions } from "../components/Main/Autocomplete/Autocomplete";
import { marked } from "marked";
import MetricsQL from "../assets/MetricsQL.md";
import { useQueryDispatch, useQueryState } from "../state/query/QueryStateContext";
const CATEGORY_TAG = "h3";
const FUNCTION_TAG = "h4";
@ -48,14 +49,14 @@ const processGroups = (groups: NodeListOf<Element>): AutocompleteOptions[] => {
};
const useGetMetricsQL = () => {
const [metricsQLFunctions, setMetricsQLFunctions] = useState<AutocompleteOptions[]>([]);
const { metricsQLFunctions } = useQueryState();
const queryDispatch = useQueryDispatch();
const processMarkdown = (text: string) => {
const div = document.createElement("div");
div.innerHTML = marked(text);
const groups = div.querySelectorAll(`${CATEGORY_TAG}, ${FUNCTION_TAG}`);
const result = processGroups(groups);
setMetricsQLFunctions(result);
return processGroups(groups);
};
useEffect(() => {
@ -63,12 +64,14 @@ const useGetMetricsQL = () => {
try {
const resp = await fetch(MetricsQL);
const text = await resp.text();
processMarkdown(text);
const result = processMarkdown(text);
queryDispatch({ type: "SET_METRICSQL_FUNCTIONS", payload: result });
} catch (e) {
console.error("Error fetching or processing the MetricsQL.md file:", e);
}
};
if (metricsQLFunctions.length) return;
fetchMarkdown();
}, []);

View File

@ -45,7 +45,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
const { isMobile } = useDeviceDetect();
const { query, queryHistory, autocomplete } = useQueryState();
const { query, queryHistory, autocomplete, autocompleteQuick } = useQueryState();
const queryDispatch = useQueryDispatch();
const timeDispatch = useTimeDispatch();
@ -187,7 +187,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
>
<QueryEditor
value={stateQuery[i]}
autocomplete={autocomplete}
autocomplete={autocomplete || autocompleteQuick}
error={queryErrors[i]}
stats={stats[i]}
onArrowUp={createHandlerArrow(-1, i)}

View File

@ -1,6 +1,11 @@
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { getQueryArray } from "../../utils/query-string";
import { setQueriesToStorage } from "../../pages/CustomPanel/QueryHistory/utils";
import {
QueryAutocompleteCache,
QueryAutocompleteCacheItem
} from "../../components/Configurators/QueryEditor/QueryAutocompleteCache";
import { AutocompleteOptions } from "../../components/Main/Autocomplete/Autocomplete";
export interface QueryHistoryType {
index: number;
@ -11,20 +16,28 @@ export interface QueryState {
query: string[];
queryHistory: QueryHistoryType[];
autocomplete: boolean;
autocompleteQuick: boolean;
autocompleteCache: QueryAutocompleteCache;
metricsQLFunctions: AutocompleteOptions[];
}
export type QueryAction =
| { type: "SET_QUERY", payload: string[] }
| { type: "SET_QUERY_HISTORY_BY_INDEX", payload: {value: QueryHistoryType, queryNumber: number} }
| { type: "SET_QUERY_HISTORY_BY_INDEX", payload: { value: QueryHistoryType, queryNumber: number } }
| { type: "SET_QUERY_HISTORY", payload: QueryHistoryType[] }
| { type: "TOGGLE_AUTOCOMPLETE"}
| { type: "TOGGLE_AUTOCOMPLETE" }
| { type: "SET_AUTOCOMPLETE_QUICK", payload: boolean }
| { type: "SET_AUTOCOMPLETE_CACHE", payload: { key: QueryAutocompleteCacheItem, value: string[] } }
| { type: "SET_METRICSQL_FUNCTIONS", payload: AutocompleteOptions[] }
const query = getQueryArray();
export const initialQueryState: QueryState = {
query,
queryHistory: query.map(q => ({ index: 0, values: [q] })),
autocomplete: getFromStorage("AUTOCOMPLETE") as boolean || false,
autocompleteQuick: false,
autocompleteCache: new QueryAutocompleteCache(),
metricsQLFunctions: [],
};
export function reducer(state: QueryState, action: QueryAction): QueryState {
@ -52,6 +65,22 @@ export function reducer(state: QueryState, action: QueryAction): QueryState {
...state,
autocomplete: !state.autocomplete
};
case "SET_AUTOCOMPLETE_QUICK":
return {
...state,
autocompleteQuick: action.payload
};
case "SET_AUTOCOMPLETE_CACHE": {
state.autocompleteCache.put(action.payload.key, action.payload.value);
return {
...state
};
}
case "SET_METRICSQL_FUNCTIONS":
return {
...state,
metricsQLFunctions: action.payload
};
default:
throw new Error();
}

View File

@ -153,3 +153,10 @@ export interface ActiveQueriesType {
args?: string;
data?: string;
}
export enum QueryContextType {
empty = "empty",
metricsql = "metricsql",
label = "label",
labelValue = "labelValue",
}

View File

@ -1,3 +1,8 @@
export const escapeRegExp = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
export const escapeRegexp = (s: string) => {
// taken from https://stackoverflow.com/a/3561711/274937
return s.replace(/[/\-\\^$*+?.()|[\]{}]/g, "\\$&");
};
export const escapeDoubleQuotes = (s: string) => {
return JSON.stringify(s).slice(1,-1);
};

View File

@ -18,7 +18,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"downlevelIteration": true
},
"include": [
"src"

View File

@ -54,6 +54,7 @@ The sandbox cluster installation is running under the constant load generated by
* `go_gc_pauses_seconds` - the [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram), which shows the duration of GC pauses.
* `go_scavenge_cpu_seconds_total` - the [counter](https://docs.victoriametrics.com/keyConcepts.html#counter), which shows the total CPU time spent by Go runtime for returning memory to the Operating System.
* `go_memlimit_bytes` - the value of [GOMEMLIMIT](https://pkg.go.dev/runtime#hdr-Environment_Variables) environment variable.
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): enhance autocomplete functionality with caching. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5348).
* FEATURE: add field `version` to the response for `/api/v1/status/buildinfo` API for using more efficient API in Grafana for receiving label values. Add additional info about setup Grafana datasource. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5370) and [these docs](https://docs.victoriametrics.com/#grafana-setup) for details.
* FEATURE: add `-search.maxResponseSeries` command-line flag for limiting the number of time series a single query to [`/api/v1/query`](https://docs.victoriametrics.com/keyConcepts.html#instant-query) or [`/api/v1/query_range`](https://docs.victoriametrics.com/keyConcepts.html#range-query) can return. This limit can protect Grafana from high memory usage when the query returns too many series. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5372).
* FEATURE: [Alerting rules for VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/deployment/docker#alerts): ease aggregation for certain alerting rules to keep more useful labels for the context. Before, all extra labels except `job` and `instance` were ignored. See this [pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5429) and this [follow-up commit](https://github.com/VictoriaMetrics/VictoriaMetrics/commit/8fb68152e67712ed2c16dcfccf7cf4d0af140835). Thanks to @7840vz.

View File

@ -399,6 +399,9 @@ The UI allows exploring query results via graphs and tables. It also provides th
- [WITH expressions playground](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/expand-with-exprs) - test how WITH expressions work;
- [Metric relabel debugger](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/relabeling) - playground for [relabeling](#relabeling) configs.
VMUI provides auto-completion for [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html) functions, metric names, label names and label values. The auto-completion can be enabled
by checking the `Autocomplete` toggle. When the auto-completion is disabled, it can still be triggered for the current cursor position by pressing `ctrl+space`.
VMUI automatically switches from graph view to heatmap view when the query returns [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram) buckets
(both [Prometheus histograms](https://prometheus.io/docs/concepts/metric_types/#histogram)
and [VictoriaMetrics histograms](https://valyala.medium.com/improving-histogram-usability-for-prometheus-and-grafana-bc7e5df0e350) are supported).
@ -2751,7 +2754,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-promscrape.cluster.memberNum string
The number of vmagent instance in the cluster of scrapers. It must be a unique value in the range 0 ... promscrape.cluster.membersCount-1 across scrapers in the cluster. Can be specified as pod name of Kubernetes StatefulSet - pod-name-Num, where Num is a numeric part of pod name. See also -promscrape.cluster.memberLabel . See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more info (default "0")
-promscrape.cluster.memberURLTemplate string
An optional template for URL to access vmagent instance with the given -promscrape.cluster.memberNum value. Every %d occurrence in the template is substituted with -promscrape.cluster.memberNum at urls to vmagent instances responsible for scraping the given target at /service-discovery page. For example -promscrape.cluster.memberURLTemplate='http://vmagent-%d:8429/targets'. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more details
An optional template for URL to access vmagent instance with the given -promscrape.cluster.memberNum value. Every %d occurence in the template is substituted with -promscrape.cluster.memberNum at urls to vmagent instances responsible for scraping the given target at /service-discovery page. For example -promscrape.cluster.memberURLTemplate='http://vmagent-%d:8429/targets'. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more details
-promscrape.cluster.membersCount int
The number of members in a cluster of scrapers. Each member must have a unique -promscrape.cluster.memberNum in the range 0 ... promscrape.cluster.membersCount-1 . Each member then scrapes roughly 1/N of all the targets. By default, cluster scraping is disabled, i.e. a single scraper scrapes all the targets. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more info (default 1)
-promscrape.cluster.name string

View File

@ -407,6 +407,9 @@ The UI allows exploring query results via graphs and tables. It also provides th
- [WITH expressions playground](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/expand-with-exprs) - test how WITH expressions work;
- [Metric relabel debugger](https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus/graph/#/relabeling) - playground for [relabeling](#relabeling) configs.
VMUI provides auto-completion for [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html) functions, metric names, label names and label values. The auto-completion can be enabled
by checking the `Autocomplete` toggle. When the auto-completion is disabled, it can still be triggered for the current cursor position by pressing `ctrl+space`.
VMUI automatically switches from graph view to heatmap view when the query returns [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram) buckets
(both [Prometheus histograms](https://prometheus.io/docs/concepts/metric_types/#histogram)
and [VictoriaMetrics histograms](https://valyala.medium.com/improving-histogram-usability-for-prometheus-and-grafana-bc7e5df0e350) are supported).
@ -2759,7 +2762,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-promscrape.cluster.memberNum string
The number of vmagent instance in the cluster of scrapers. It must be a unique value in the range 0 ... promscrape.cluster.membersCount-1 across scrapers in the cluster. Can be specified as pod name of Kubernetes StatefulSet - pod-name-Num, where Num is a numeric part of pod name. See also -promscrape.cluster.memberLabel . See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more info (default "0")
-promscrape.cluster.memberURLTemplate string
An optional template for URL to access vmagent instance with the given -promscrape.cluster.memberNum value. Every %d occurrence in the template is substituted with -promscrape.cluster.memberNum at urls to vmagent instances responsible for scraping the given target at /service-discovery page. For example -promscrape.cluster.memberURLTemplate='http://vmagent-%d:8429/targets'. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more details
An optional template for URL to access vmagent instance with the given -promscrape.cluster.memberNum value. Every %d occurence in the template is substituted with -promscrape.cluster.memberNum at urls to vmagent instances responsible for scraping the given target at /service-discovery page. For example -promscrape.cluster.memberURLTemplate='http://vmagent-%d:8429/targets'. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more details
-promscrape.cluster.membersCount int
The number of members in a cluster of scrapers. Each member must have a unique -promscrape.cluster.memberNum in the range 0 ... promscrape.cluster.membersCount-1 . Each member then scrapes roughly 1/N of all the targets. By default, cluster scraping is disabled, i.e. a single scraper scrapes all the targets. See https://docs.victoriametrics.com/vmagent.html#scraping-big-number-of-targets for more info (default 1)
-promscrape.cluster.name string