From c5044cdba98dd7d554c3ca437c78f04ffecd41c4 Mon Sep 17 00:00:00 2001 From: Yury Molodov <yurymolodov@gmail.com> Date: Tue, 10 Oct 2023 10:38:08 +0200 Subject: [PATCH] vmui: enhancement of autocomplete feature (#5051) https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4993 https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3006 --- app/vmui/.gitignore | 2 + app/vmui/Makefile | 5 +- app/vmui/packages/vmui/package.json | 4 +- app/vmui/packages/vmui/src/api/query-range.ts | 2 - .../Configurators/QueryEditor/QueryEditor.tsx | 66 +++++---- .../QueryEditor/QueryEditorAutocomplete.tsx | 129 ++++++++++++++++++ .../Main/Autocomplete/Autocomplete.tsx | 91 ++++++++---- .../components/Main/Autocomplete/style.scss | 31 +++++ .../vmui/src/components/Main/Icons/index.tsx | 51 +++++++ .../src/components/Main/Select/Select.tsx | 5 +- .../components/Main/TextField/TextField.tsx | 35 ++++- .../vmui/src/hooks/useFetchQueryOptions.ts | 32 ----- .../vmui/src/hooks/useFetchQueryOptions.tsx | 94 +++++++++++++ .../vmui/src/hooks/useGetMetricsQL.tsx | 78 +++++++++++ .../QueryConfigurator/QueryConfigurator.tsx | 3 - .../vmui/src/pages/CustomPanel/index.tsx | 3 - .../ExploreLogsHeader/ExploreLogsHeader.tsx | 1 - .../vmui/src/styles/components/list.scss | 8 ++ .../packages/vmui/src/types/markdown.d.ts | 4 + app/vmui/packages/vmui/src/utils/regexp.ts | 3 + docs/CHANGELOG.md | 1 + 21 files changed, 541 insertions(+), 107 deletions(-) create mode 100644 app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx delete mode 100644 app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts create mode 100644 app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx create mode 100644 app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx create mode 100644 app/vmui/packages/vmui/src/types/markdown.d.ts create mode 100644 app/vmui/packages/vmui/src/utils/regexp.ts diff --git a/app/vmui/.gitignore b/app/vmui/.gitignore index 8f007b3120..5fa6b0d6ec 100644 --- a/app/vmui/.gitignore +++ b/app/vmui/.gitignore @@ -105,3 +105,5 @@ dist # WebStorm etc .idea/ + +MetricsQL.md diff --git a/app/vmui/Makefile b/app/vmui/Makefile index 39a65358ff..eb3354044c 100644 --- a/app/vmui/Makefile +++ b/app/vmui/Makefile @@ -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 \ diff --git a/app/vmui/packages/vmui/package.json b/app/vmui/packages/vmui/package.json index ff05078f7d..ad3e2d91ad 100644 --- a/app/vmui/packages/vmui/package.json +++ b/app/vmui/packages/vmui/package.json @@ -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": [ diff --git a/app/vmui/packages/vmui/src/api/query-range.ts b/app/vmui/packages/vmui/src/api/query-range.ts index 850760fe8c..28a3679d64 100644 --- a/app/vmui/packages/vmui/src/api/query-range.ts +++ b/app/vmui/packages/vmui/src/api/query-range.ts @@ -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`; diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx index 78e3053f0c..4b3bb8b721 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx @@ -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; diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx new file mode 100644 index 0000000000..0bc30519a9 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditorAutocomplete.tsx @@ -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; diff --git a/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx index 5f9a7d45f0..827340b506 100644 --- a/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx @@ -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> ); }; diff --git a/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss b/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss index a1905058a2..d5d3f360e9 100644 --- a/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss +++ b/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss @@ -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; + } + } + } + } } diff --git a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx index 4831ba7ec2..a3af70c69c 100644 --- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx @@ -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> +); diff --git a/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx b/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx index 906df1a035..4d93eddaac 100644 --- a/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx +++ b/app/vmui/packages/vmui/src/components/Main/Select/Select.tsx @@ -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} diff --git a/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx b/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx index 7e6211e3bb..ce1b21caad 100644 --- a/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx +++ b/app/vmui/packages/vmui/src/components/Main/TextField/TextField.tsx @@ -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} /> ) } diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts b/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts deleted file mode 100644 index 456ae367cc..0000000000 --- a/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts +++ /dev/null @@ -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 }; -}; diff --git a/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx b/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx new file mode 100644 index 0000000000..1cd028058b --- /dev/null +++ b/app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.tsx @@ -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, + }; +}; diff --git a/app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx b/app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx new file mode 100644 index 0000000000..fbe3960f4a --- /dev/null +++ b/app/vmui/packages/vmui/src/hooks/useGetMetricsQL.tsx @@ -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; diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx index 7648ffc0b1..312fe4ec34 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/QueryConfigurator/QueryConfigurator.tsx @@ -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)} diff --git a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx index fb170f9bd7..795ef78e69 100644 --- a/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx +++ b/app/vmui/packages/vmui/src/pages/CustomPanel/index.tsx @@ -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} /> diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsHeader/ExploreLogsHeader.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsHeader/ExploreLogsHeader.tsx index 92f7480aa1..0d51f1224e 100644 --- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsHeader/ExploreLogsHeader.tsx +++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsHeader/ExploreLogsHeader.tsx @@ -27,7 +27,6 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({ query, onChange, onRun } <QueryEditor value={query} autocomplete={false} - options={[]} onArrowUp={() => null} onArrowDown={() => null} onEnter={onRun} diff --git a/app/vmui/packages/vmui/src/styles/components/list.scss b/app/vmui/packages/vmui/src/styles/components/list.scss index 8dd60bb5f4..9fc9f58496 100644 --- a/app/vmui/packages/vmui/src/styles/components/list.scss +++ b/app/vmui/packages/vmui/src/styles/components/list.scss @@ -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; + } } } diff --git a/app/vmui/packages/vmui/src/types/markdown.d.ts b/app/vmui/packages/vmui/src/types/markdown.d.ts new file mode 100644 index 0000000000..d3f4b12bce --- /dev/null +++ b/app/vmui/packages/vmui/src/types/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const value: string; + export default value; +} diff --git a/app/vmui/packages/vmui/src/utils/regexp.ts b/app/vmui/packages/vmui/src/utils/regexp.ts new file mode 100644 index 0000000000..96e6f859c9 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/regexp.ts @@ -0,0 +1,3 @@ +export const escapeRegExp = (str: string) => { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +}; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dfbfc72dc6..112af249f9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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)