mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-02-09 15:27:11 +00:00
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:
parent
d8c8a66c79
commit
29487700d9
21 changed files with 541 additions and 107 deletions
2
app/vmui/.gitignore
vendored
2
app/vmui/.gitignore
vendored
|
@ -105,3 +105,5 @@ dist
|
|||
|
||||
# WebStorm etc
|
||||
.idea/
|
||||
|
||||
MetricsQL.md
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
94
app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx
Normal file
94
app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx
Normal 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,
|
||||
};
|
||||
};
|
78
app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx
Normal file
78
app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx
Normal 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;
|
|
@ -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)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,6 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({ query, onChange, onRun }
|
|||
<QueryEditor
|
||||
value={query}
|
||||
autocomplete={false}
|
||||
options={[]}
|
||||
onArrowUp={() => null}
|
||||
onArrowDown={() => null}
|
||||
onEnter={onRun}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
4
app/vmui/packages/vmui/src/types/markdown.d.ts
vendored
Normal file
4
app/vmui/packages/vmui/src/types/markdown.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module "*.md" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
3
app/vmui/packages/vmui/src/utils/regexp.ts
Normal file
3
app/vmui/packages/vmui/src/utils/regexp.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const escapeRegExp = (str: string) => {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
||||
};
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Reference in a new issue