vmui: enhancement of autocomplete feature (#5051)

https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4993
https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3006
(cherry picked from commit c5044cdba9)
This commit is contained in:
Yury Molodov 2023-10-10 10:38:08 +02:00 committed by hagen1778
parent d8c8a66c79
commit 29487700d9
No known key found for this signature in database
GPG key ID: 3BF75F3741CA9640
21 changed files with 541 additions and 107 deletions

2
app/vmui/.gitignore vendored
View file

@ -105,3 +105,5 @@ dist
# WebStorm etc
.idea/
MetricsQL.md

View file

@ -1,9 +1,12 @@
# All these commands must run from repository root.
copy-metricsql-docs:
cp docs/MetricsQL.md app/vmui/packages/vmui/src/assets/MetricsQL.md
vmui-package-base-image:
docker build -t vmui-builder-image -f app/vmui/Dockerfile-build ./app/vmui
vmui-build: vmui-package-base-image
vmui-build: copy-metricsql-docs vmui-package-base-image
docker run --rm \
--user $(shell id -u):$(shell id -g) \
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \

View file

@ -30,13 +30,15 @@
"web-vitals": "^3.3.2"
},
"scripts": {
"prestart": "npm run update-metricsql",
"start": "react-app-rewired start",
"start:logs": "cross-env REACT_APP_LOGS=true npm run start",
"build": "GENERATE_SOURCEMAP=false react-app-rewired build",
"build:logs": "cross-env REACT_APP_LOGS=true npm run build",
"lint": "eslint src --ext tsx,ts",
"lint:fix": "eslint src --ext tsx,ts --fix",
"analyze": "source-map-explorer 'build/static/js/*.js'"
"analyze": "source-map-explorer 'build/static/js/*.js'",
"copy-metricsql-docs": "cp ../../../../docs/MetricsQL.md src/assets/MetricsQL.md || true"
},
"eslintConfig": {
"extends": [

View file

@ -5,5 +5,3 @@ export const getQueryRangeUrl = (server: string, query: string, period: TimePara
export const getQueryUrl = (server: string, query: string, period: TimeParams, queryTracing: boolean): string =>
`${server}/api/v1/query?query=${encodeURIComponent(query)}&time=${period.end}${queryTracing ? "&trace=1" : ""}`;
export const getQueryOptions = (server: string) => `${server}/api/v1/label/__name__/values`;

View file

@ -2,10 +2,11 @@ import React, { FC, useRef, useState } from "preact/compat";
import { KeyboardEvent } from "react";
import { ErrorTypes } from "../../../types";
import TextField from "../../Main/TextField/TextField";
import Autocomplete from "../../Main/Autocomplete/Autocomplete";
import QueryEditorAutocomplete from "./QueryEditorAutocomplete";
import "./style.scss";
import { QueryStats } from "../../../api/types";
import { partialWarning, seriesFetchedWarning } from "./warningText";
import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
export interface QueryEditorProps {
onChange: (query: string) => void;
@ -17,7 +18,6 @@ export interface QueryEditorProps {
autocomplete: boolean;
error?: ErrorTypes | string;
stats?: QueryStats;
options: string[];
label: string;
disabled?: boolean
}
@ -31,13 +31,13 @@ const QueryEditor: FC<QueryEditorProps> = ({
autocomplete,
error,
stats,
options,
label,
disabled = false
}) => {
const [openAutocomplete, setOpenAutocomplete] = useState(false);
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const [caretPosition, setCaretPosition] = useState([0, 0]);
const autocompleteAnchorEl = useRef<HTMLInputElement>(null);
const warning = [
{
@ -88,37 +88,43 @@ const QueryEditor: FC<QueryEditorProps> = ({
}
};
const handleChangeFoundOptions = (val: string[]) => {
const handleChangeFoundOptions = (val: AutocompleteOptions[]) => {
setOpenAutocomplete(!!val.length);
};
return <div
className="vm-query-editor"
ref={autocompleteAnchorEl}
>
<TextField
value={value}
label={label}
type={"textarea"}
autofocus={!!value}
error={error}
warning={warning}
onKeyDown={handleKeyDown}
onChange={onChange}
disabled={disabled}
inputmode={"search"}
/>
{autocomplete && (
<Autocomplete
disabledFullScreen
const handleChangeCaret = (val: number[]) => {
setCaretPosition(val);
};
return (
<div
className="vm-query-editor"
ref={autocompleteAnchorEl}
>
<TextField
value={value}
options={options}
anchor={autocompleteAnchorEl}
onSelect={handleSelect}
onFoundOptions={handleChangeFoundOptions}
label={label}
type={"textarea"}
autofocus={!!value}
error={error}
warning={warning}
onKeyDown={handleKeyDown}
onChange={onChange}
onChangeCaret={handleChangeCaret}
disabled={disabled}
inputmode={"search"}
/>
)}
</div>;
{autocomplete && (
<QueryEditorAutocomplete
value={value}
anchorEl={autocompleteAnchorEl}
caretPosition={caretPosition}
onSelect={handleSelect}
onFoundOptions={handleChangeFoundOptions}
/>
)}
</div>
);
};
export default QueryEditor;

View file

@ -0,0 +1,129 @@
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 useGetMetricsQL from "../../../hooks/useGetMetricsQL";
enum ContextType {
empty = "empty",
metricsql = "metricsql",
label = "label",
value = "value",
}
interface QueryEditorAutocompleteProps {
value: string;
anchorEl: Ref<HTMLInputElement>;
caretPosition: number[];
onSelect: (val: string) => void;
onFoundOptions: (val: AutocompleteOptions[]) => void;
}
const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
value,
anchorEl,
caretPosition,
onSelect,
onFoundOptions
}) => {
const [leftOffset, setLeftOffset] = useState(0);
const metricsqlFunctions = useGetMetricsQL();
const metric = useMemo(() => {
const regexp = /\b[^{}(),\s]+(?={|$)/g;
const match = value.match(regexp);
return match ? match[0] : "";
}, [value]);
const label = useMemo(() => {
const regexp = /[a-z_]\w*(?=\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]);
const { metrics, labels, values } = useFetchQueryOptions({ metric, label });
const options = useMemo(() => {
switch (context) {
case ContextType.metricsql:
return [...metrics, ...metricsqlFunctions];
case ContextType.label:
return labels;
case ContextType.value:
return values;
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]);
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));
if (context === ContextType.value) {
const quote = "\"";
const needsQuote = beforeInsert[beforeInsert.length - 1] !== quote;
insert = `${needsQuote ? quote : ""}${insert}${quote}`;
}
const newVal = `${beforeInsert}${insert}${afterInsert}`;
onSelect(newVal);
};
useEffect(() => {
if (!anchorEl.current) {
setLeftOffset(0);
return;
}
const style = window.getComputedStyle(anchorEl.current);
const fontSize = `${style.getPropertyValue("font-size")}`;
const fontFamily = `${style.getPropertyValue("font-family")}`;
const offset = getTextWidth(value, `${fontSize} ${fontFamily}`);
setLeftOffset(offset);
}, [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}
/>
);
};
export default QueryEditorAutocomplete;

View file

@ -1,4 +1,4 @@
import React, { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import React, { FC, Ref, useCallback, useEffect, useMemo, useRef, useState, JSX } from "preact/compat";
import classNames from "classnames";
import Popper from "../Popper/Popper";
import "./style.scss";
@ -7,21 +7,33 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean";
import useEventListener from "../../../hooks/useEventListener";
export interface AutocompleteOptions {
value: string;
description?: string;
type?: string;
icon?: JSX.Element
}
interface AutocompleteProps {
value: string
options: string[]
options: AutocompleteOptions[]
anchor: Ref<HTMLElement>
disabled?: boolean
maxWords?: number
minLength?: number
fullWidth?: boolean
noOptionsText?: string
selected?: string[]
label?: string
disabledFullScreen?: boolean
offset?: {top: number, left: number}
onSelect: (val: string) => void
onOpenAutocomplete?: (val: boolean) => void
onFoundOptions?: (val: string[]) => void
onFoundOptions?: (val: AutocompleteOptions[]) => void
}
enum FocusType {
mouse,
keyboard
}
const Autocomplete: FC<AutocompleteProps> = ({
@ -29,13 +41,13 @@ const Autocomplete: FC<AutocompleteProps> = ({
options,
anchor,
disabled,
maxWords = 1,
minLength = 2,
fullWidth,
selected,
noOptionsText,
label,
disabledFullScreen,
offset,
onSelect,
onOpenAutocomplete,
onFoundOptions
@ -43,7 +55,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
const { isMobile } = useDeviceDetect();
const wrapperEl = useRef<HTMLDivElement>(null);
const [focusOption, setFocusOption] = useState(-1);
const [focusOption, setFocusOption] = useState<{index: number, type?: FocusType}>({ index: -1 });
const {
value: openAutocomplete,
@ -54,9 +66,9 @@ const Autocomplete: FC<AutocompleteProps> = ({
const foundOptions = useMemo(() => {
if (!openAutocomplete) return [];
try {
const regexp = new RegExp(String(value), "i");
const found = options.filter((item) => regexp.test(item) && (item !== value));
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
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));
} catch (e) {
return [];
}
@ -72,9 +84,17 @@ const Autocomplete: FC<AutocompleteProps> = ({
if (!selected) handleCloseAutocomplete();
};
const createHandlerMouseEnter = (index: number) => () => {
setFocusOption({ index, type: FocusType.mouse });
};
const handlerMouseLeave = () => {
setFocusOption({ index: -1 });
};
const scrollToValue = () => {
if (!wrapperEl.current) return;
const target = wrapperEl.current.childNodes[focusOption] as HTMLElement;
if (!wrapperEl.current || focusOption.type === FocusType.mouse) return;
const target = wrapperEl.current.childNodes[focusOption.index] as HTMLElement;
if (target?.scrollIntoView) target.scrollIntoView({ block: "center" });
};
@ -85,18 +105,24 @@ const Autocomplete: FC<AutocompleteProps> = ({
if (key === "ArrowUp" && !modifiers && hasOptions) {
e.preventDefault();
setFocusOption((prev) => prev <= 0 ? 0 : prev - 1);
setFocusOption(({ index }) => ({
index: index <= 0 ? 0 : index - 1,
type: FocusType.keyboard
}));
}
if (key === "ArrowDown" && !modifiers && hasOptions) {
e.preventDefault();
const lastIndex = foundOptions.length - 1;
setFocusOption((prev) => prev >= lastIndex ? lastIndex : prev + 1);
setFocusOption(({ index }) => ({
index: index >= lastIndex ? lastIndex : index + 1,
type: FocusType.keyboard
}));
}
if (key === "Enter") {
const value = foundOptions[focusOption];
value && onSelect(value);
const item = foundOptions[focusOption.index];
item && onSelect(item.value);
if (!selected) handleCloseAutocomplete();
}
@ -106,8 +132,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
}, [focusOption, foundOptions, handleCloseAutocomplete, onSelect, selected]);
useEffect(() => {
const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
setOpenAutocomplete(value.length > minLength && words <= maxWords);
setOpenAutocomplete(value.length >= minLength);
}, [value]);
useEventListener("keydown", handleKeyDown);
@ -115,7 +140,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
useEffect(scrollToValue, [focusOption, foundOptions]);
useEffect(() => {
setFocusOption(-1);
setFocusOption({ index: -1 });
}, [foundOptions]);
useEffect(() => {
@ -135,6 +160,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
fullWidth={fullWidth}
title={isMobile ? label : undefined}
disabledFullScreen={disabledFullScreen}
offset={offset}
>
<div
className={classNames({
@ -149,19 +175,34 @@ const Autocomplete: FC<AutocompleteProps> = ({
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": i === focusOption,
"vm-list-item_active": i === focusOption.index,
"vm-list-item_multiselect": selected,
"vm-list-item_multiselect_selected": selected?.includes(option)
"vm-list-item_multiselect_selected": selected?.includes(option.value),
"vm-list-item_with-icon": option.icon,
})}
id={`$autocomplete$${option}`}
key={option}
onClick={createHandlerSelect(option)}
id={`$autocomplete$${option.value}`}
key={`${i}${option.value}`}
onClick={createHandlerSelect(option.value)}
onMouseEnter={createHandlerMouseEnter(i)}
onMouseLeave={handlerMouseLeave}
>
{selected?.includes(option) && <DoneIcon/>}
<span>{option}</span>
{selected?.includes(option.value) && <DoneIcon/>}
<>{option.icon}</>
<span>{option.value}</span>
</div>
)}
</div>
{foundOptions[focusOption.index]?.description && (
<div className="vm-autocomplete-info">
<div className="vm-autocomplete-info__type">
{foundOptions[focusOption.index].type}
</div>
<div
className="vm-autocomplete-info__description"
dangerouslySetInnerHTML={{ __html: foundOptions[focusOption.index].description || "" }}
/>
</div>
)}
</Popper>
);
};

View file

@ -1,6 +1,7 @@
@use "src/styles/variables" as *;
.vm-autocomplete {
position: relative;
max-height: 300px;
overflow: auto;
overscroll-behavior: none;
@ -14,4 +15,34 @@
text-align: center;
color: $color-text-disabled;
}
&-info {
position: absolute;
top: calc(100% + 1px);
left: 0;
right: 0;
min-width: 450px;
padding: $padding-global;
background-color: $color-background-block;
box-shadow: $box-shadow-popper;
border-radius: $border-radius-small;
overflow-wrap: anywhere;
&__type {
color: $color-text-secondary;
margin-bottom: $padding-small;
}
&__description {
line-height: 130%;
p {
margin: $padding-global 0;
&:last-child {
margin: 0;
}
}
}
}
}

View file

@ -1,4 +1,5 @@
import React from "react";
import { getCssVariable } from "../../../utils/theme";
export const LogoIcon = () => (
<svg
@ -450,3 +451,53 @@ export const StarIcon = () => (
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path>
</svg>
);
export const MetricIcon = () => (
<svg
viewBox="0 0 16 16"
fill={getCssVariable("color-error")}
>
<path
d="M13.5095 4L8.50952 1H7.50952L2.50952 4L2.01953 4.85999V10.86L2.50952 11.71L7.50952 14.71H8.50952L13.5095 11.71L13.9995 10.86V4.85999L13.5095 4ZM7.50952 13.5601L3.00952 10.86V5.69995L7.50952 8.15002V13.5601ZM3.26953 4.69995L8.00952 1.85999L12.7495 4.69995L8.00952 7.29004L3.26953 4.69995ZM13.0095 10.86L8.50952 13.5601V8.15002L13.0095 5.69995V10.86Z"
/>
</svg>
);
export const FunctionIcon = () => (
<svg
viewBox="0 0 16 16"
fill={getCssVariable("color-primary")}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 5H4V4H1.5L1 4.5V12.5L1.5 13H4V12H2V5ZM14.5 4H12V5H14V12H12V13H14.5L15 12.5V4.5L14.5 4ZM11.76 6.56995L12 7V9.51001L11.7 9.95996L7.19995 11.96H6.73999L4.23999 10.46L4 10.03V7.53003L4.30005 7.06995L8.80005 5.06995H9.26001L11.76 6.56995ZM5 9.70996L6.5 10.61V9.28003L5 8.38V9.70996ZM5.57996 7.56006L7.03003 8.43005L10.42 6.93005L8.96997 6.06006L5.57996 7.56006ZM7.53003 10.73L11.03 9.17004V7.77002L7.53003 9.31995V10.73Z"
/>
</svg>
);
export const LabelIcon = () => (
<svg
viewBox="0 0 16 16"
fill={getCssVariable("color-warning")}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14 2H8L7 3V6H8V3H14V8H10V9H14L15 8V3L14 2ZM9 6H13V7H9.41L9 6.59V6ZM7 7H2L1 8V13L2 14H8L9 13V8L8 7H7ZM8 13H2V8H8V9V13ZM3 9H7V10H3V9ZM3 11H7V12H3V11ZM9 4H13V5H9V4Z"
/>
</svg>
);
export const ValueIcon = () => (
<svg
viewBox="0 0 16 16"
fill={getCssVariable("color-primary")}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 3L8 2H14L15 3V8L14 9H10V8H14V3H8V6H7V3ZM9 9V8L8 7H7H2L1 8V13L2 14H8L9 13V9ZM8 8V9V13H2V8H7H8ZM9.41421 7L9 6.58579V6H13V7H9.41421ZM9 4H13V5H9V4ZM7 10H3V11H7V10Z"
/>
</svg>
);

View file

@ -160,11 +160,10 @@ const Select: FC<SelectProps> = ({
<Autocomplete
label={label}
value={autocompleteValue}
options={list}
options={list.map(el => ({ value: el }))}
anchor={autocompleteAnchorEl}
selected={selectedValues}
maxWords={10}
minLength={0}
minLength={1}
fullWidth
noOptionsText={noOptionsText}
onSelect={handleSelected}

View file

@ -1,6 +1,15 @@
import React, { FC, KeyboardEvent, useEffect, useRef, HTMLInputTypeAttribute, ReactNode } from "react";
import React, {
FC,
useEffect,
useRef,
useMemo,
FormEvent,
KeyboardEvent,
MouseEvent,
HTMLInputTypeAttribute,
ReactNode
} from "react";
import classNames from "classnames";
import { useMemo } from "preact/compat";
import { useAppState } from "../../../state/common/StateContext";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import TextFieldMessage from "./TextFieldMessage";
@ -24,6 +33,7 @@ interface TextFieldProps {
onKeyDown?: (e: KeyboardEvent) => void
onFocus?: () => void
onBlur?: () => void
onChangeCaret?: (position: number[]) => void
}
const TextField: FC<TextFieldProps> = ({
@ -43,7 +53,8 @@ const TextField: FC<TextFieldProps> = ({
onEnter,
onKeyDown,
onFocus,
onBlur
onBlur,
onChangeCaret,
}) => {
const { isDarkTheme } = useAppState();
const { isMobile } = useDeviceDetect();
@ -61,9 +72,18 @@ const TextField: FC<TextFieldProps> = ({
"vm-text-field__input_textarea": type === "textarea",
});
const updateCaretPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
const { selectionStart, selectionEnd } = target;
onChangeCaret && onChangeCaret([selectionStart || 0, selectionEnd || 0]);
};
const handleMouseUp = (e: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => {
updateCaretPosition(e.currentTarget);
};
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);
@ -73,9 +93,10 @@ const TextField: FC<TextFieldProps> = ({
}
};
const handleChange = (e: React.FormEvent) => {
const handleChange = (e: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (disabled) return;
onChange && onChange((e.target as HTMLInputElement).value);
onChange && onChange(e.currentTarget.value);
updateCaretPosition(e.currentTarget);
};
useEffect(() => {
@ -116,6 +137,7 @@ const TextField: FC<TextFieldProps> = ({
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseUp={handleMouseUp}
/>
)
: (
@ -132,6 +154,7 @@ const TextField: FC<TextFieldProps> = ({
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseUp={handleMouseUp}
/>
)
}

View file

@ -1,32 +0,0 @@
import { useEffect, useState } from "preact/compat";
import { getQueryOptions } from "../api/query-range";
import { useAppState } from "../state/common/StateContext";
export const useFetchQueryOptions = (): {
queryOptions: string[],
} => {
const { serverUrl } = useAppState();
const [queryOptions, setQueryOptions] = useState([]);
const fetchOptions = async () => {
if (!serverUrl) return;
const url = getQueryOptions(serverUrl);
try {
const response = await fetch(url);
const resp = await response.json();
if (response.ok) {
setQueryOptions(resp.data);
}
} catch (e) {
console.error(e);
}
};
useEffect(() => {
fetchOptions();
}, [serverUrl]);
return { queryOptions };
};

View file

@ -0,0 +1,94 @@
import React, { StateUpdater, useEffect, useState } from "preact/compat";
import { useAppState } from "../state/common/StateContext";
import { AutocompleteOptions } from "../components/Main/Autocomplete/Autocomplete";
import { LabelIcon, MetricIcon, ValueIcon } from "../components/Main/Icons";
enum TypeData {
metric,
label,
value
}
type FetchDataArgs = {
url: string;
setter: StateUpdater<AutocompleteOptions[]>;
type: TypeData;
}
const icons = {
[TypeData.metric]: <MetricIcon />,
[TypeData.label]: <LabelIcon />,
[TypeData.value]: <ValueIcon />,
};
export const useFetchQueryOptions = ({ metric, label }: { metric: string; label: string }) => {
const { serverUrl } = useAppState();
const [metrics, setMetrics] = useState<AutocompleteOptions[]>([]);
const [labels, setLabels] = useState<AutocompleteOptions[]>([]);
const [values, setValues] = useState<AutocompleteOptions[]>([]);
const fetchData = async ({ url, setter, type, }: FetchDataArgs) => {
try {
const response = await fetch(url);
if (response.ok) {
const { data } = await response.json() as { data: string[] };
setter(data.map(l => ({
value: l,
type: `${type}`,
icon: icons[type]
})));
}
} catch (e) {
console.error(e);
}
};
useEffect(() => {
if (!serverUrl) {
setMetrics([]);
return;
}
fetchData({
url: `${serverUrl}/api/v1/label/__name__/values`,
setter: setMetrics,
type: TypeData.metric
});
}, [serverUrl]);
useEffect(() => {
const notFoundMetric = !metrics.find(m => m.value === metric);
if (!serverUrl || notFoundMetric) {
setLabels([]);
return;
}
fetchData({
url: `${serverUrl}/api/v1/labels?match[]=${metric}`,
setter: setLabels,
type: TypeData.label
});
}, [serverUrl, metric]);
useEffect(() => {
const notFoundMetric = !metrics.find(m => m.value === metric);
const notFoundLabel = !labels.find(l => l.value === label);
if (!serverUrl || notFoundMetric || notFoundLabel) {
setValues([]);
return;
}
fetchData({
url: `${serverUrl}/api/v1/label/${label}/values?match[]=${metric}`,
setter: setValues,
type: TypeData.value
});
}, [serverUrl, metric, label]);
return {
metrics,
labels,
values,
};
};

View file

@ -0,0 +1,78 @@
import React, { useEffect, useState } 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";
const CATEGORY_TAG = "h3";
const FUNCTION_TAG = "h4";
const DESCRIPTION_TAG = "p";
const docsUrl = "https://docs.victoriametrics.com/MetricsQL.html";
const classLink = "vm-link vm-link_colored";
const prepareDescription = (text: string): string => {
const replaceValue = `$1 target="_blank" class="${classLink}" $2${docsUrl}#`;
return text.replace(/(<a) (href=")#/gm, replaceValue);
};
const getParagraph = (el: Element): Element[] => {
const paragraphs: Element[] = [];
let nextEl = el.nextElementSibling;
while (nextEl && nextEl.tagName.toLowerCase() === DESCRIPTION_TAG) {
if (nextEl) paragraphs.push(nextEl);
nextEl = nextEl.nextElementSibling;
}
return paragraphs;
};
const createAutocompleteOption = (type: string, group: Element): AutocompleteOptions => {
const value = group.textContent ?? "";
const paragraphs = getParagraph(group);
const description = paragraphs.map(p => p.outerHTML ?? "").join("\n");
return {
type,
value,
description: prepareDescription(description),
icon: <FunctionIcon />,
};
};
const processGroups = (groups: NodeListOf<Element>): AutocompleteOptions[] => {
let type = "";
return Array.from(groups).map(group => {
const isCategory = group.tagName.toLowerCase() === CATEGORY_TAG;
type = isCategory ? group.textContent ?? "" : type;
return isCategory ? null : createAutocompleteOption(type, group);
}).filter(Boolean) as AutocompleteOptions[];
};
const useGetMetricsQL = () => {
const [metricsQLFunctions, setMetricsQLFunctions] = useState<AutocompleteOptions[]>([]);
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);
};
useEffect(() => {
const fetchMarkdown = async () => {
try {
const resp = await fetch(MetricsQL);
const text = await resp.text();
processMarkdown(text);
} catch (e) {
console.error("Error fetching or processing the MetricsQL.md file:", e);
}
};
fetchMarkdown();
}, []);
return metricsQLFunctions;
};
export default useGetMetricsQL;

View file

@ -29,7 +29,6 @@ export interface QueryConfiguratorProps {
setQueryErrors: StateUpdater<string[]>;
setHideError: StateUpdater<boolean>;
stats: QueryStats[];
queryOptions: string[]
onHideQuery: (queries: number[]) => void
onRunQuery: () => void
}
@ -39,7 +38,6 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
setQueryErrors,
setHideError,
stats,
queryOptions,
onHideQuery,
onRunQuery
}) => {
@ -189,7 +187,6 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
<QueryEditor
value={stateQuery[i]}
autocomplete={autocomplete}
options={queryOptions}
error={queryErrors[i]}
stats={stats[i]}
onArrowUp={createHandlerArrow(-1, i)}

View file

@ -8,7 +8,6 @@ import GraphSettings from "../../components/Configurators/GraphSettings/GraphSet
import { useGraphDispatch, useGraphState } from "../../state/graph/GraphStateContext";
import { AxisRange } from "../../state/graph/reducer";
import Spinner from "../../components/Main/Spinner/Spinner";
import { useFetchQueryOptions } from "../../hooks/useFetchQueryOptions";
import TracingsView from "../../components/TraceQuery/TracingsView";
import Trace from "../../components/TraceQuery/Trace";
import TableSettings from "../../components/Table/TableSettings/TableSettings";
@ -50,7 +49,6 @@ const CustomPanel: FC = () => {
const { customStep, yaxis } = useGraphState();
const graphDispatch = useGraphDispatch();
const { queryOptions } = useFetchQueryOptions();
const {
isLoading,
liveData,
@ -133,7 +131,6 @@ const CustomPanel: FC = () => {
setQueryErrors={setQueryErrors}
setHideError={setHideError}
stats={queryStats}
queryOptions={queryOptions}
onHideQuery={handleHideQuery}
onRunQuery={handleRunQuery}
/>

View file

@ -27,7 +27,6 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({ query, onChange, onRun }
<QueryEditor
value={query}
autocomplete={false}
options={[]}
onArrowUp={() => null}
onArrowDown={() => null}
onEnter={onRun}

View file

@ -35,5 +35,13 @@
color: $color-primary;
}
}
&_with-icon {
display: grid;
grid-template-columns: 14px 1fr;
gap: calc($padding-small/2);
align-items: center;
justify-content: flex-start;
}
}
}

View file

@ -0,0 +1,4 @@
declare module "*.md" {
const value: string;
export default value;
}

View file

@ -0,0 +1,3 @@
export const escapeRegExp = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
};

View file

@ -31,6 +31,7 @@ The sandbox cluster installation is running under the constant load generated by
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): support data ingestion from [NewRelic infrastructure agent](https://docs.newrelic.com/docs/infrastructure/install-infrastructure-agent). See [these docs](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html#how-to-send-data-from-newrelic-agent), [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3520) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/4712).
* FEATURE: [vmbackup](https://docs.victoriametrics.com/vmbackup.html): add `-filestream.disableFadvise` command-line flag, which can be used for disabling `fadvise` syscall during backup upload to the remote storage. By default `vmbackup` uses `fadvise` syscall in order to prevent from eviction of recently accessed data from the [OS page cache](https://en.wikipedia.org/wiki/Page_cache) when backing up large files. Sometimes the `fadvise` syscall may take significant amounts of CPU when the backup is performed with large value of `-concurrency` command-line flag on systems with big number of CPU cores. In this case it is better to manually disable `fadvise` syscall by passing `-filestream.disableFadvise` command-line flag to `vmbackup`. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/5120) for details.
* FEATURE: [Alerting rules for VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/deployment/docker#alerts): account for `vmauth` component for alerts `ServiceDown` and `TooManyRestarts`.
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add support for functions, labels, values in autocomplete. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3006).
## [v1.94.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.94.0)