vmui: trigger auto-suggestion at any cursor position (#6155)

- Implemented auto-suggestion triggers for mid-string cursor positions
in vmui.
- Improved the suggestion list positioning to appear directly beneath
the active text editing area.

https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5864
This commit is contained in:
Yury Molodov 2024-04-25 12:48:49 +02:00 committed by GitHub
parent 6aaf1768f4
commit 6193fa3dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 131 additions and 41 deletions

View file

@ -9,6 +9,7 @@ import { partialWarning, seriesFetchedWarning } from "./warningText";
import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete"; import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { useQueryState } from "../../../state/query/QueryStateContext"; import { useQueryState } from "../../../state/query/QueryStateContext";
import debounce from "lodash.debounce";
export interface QueryEditorProps { export interface QueryEditorProps {
onChange: (query: string) => void; onChange: (query: string) => void;
@ -40,9 +41,12 @@ const QueryEditor: FC<QueryEditorProps> = ({
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const [openAutocomplete, setOpenAutocomplete] = useState(false); const [openAutocomplete, setOpenAutocomplete] = useState(false);
const [caretPosition, setCaretPosition] = useState([0, 0]); const [caretPosition, setCaretPosition] = useState<[number, number]>([0, 0]);
const autocompleteAnchorEl = useRef<HTMLInputElement>(null); const autocompleteAnchorEl = useRef<HTMLInputElement>(null);
const [showAutocomplete, setShowAutocomplete] = useState(autocomplete);
const debouncedSetShowAutocomplete = useRef(debounce(setShowAutocomplete, 500)).current;
const warning = [ const warning = [
{ {
show: stats?.seriesFetched === "0" && !stats.resultLength, show: stats?.seriesFetched === "0" && !stats.resultLength,
@ -58,8 +62,9 @@ const QueryEditor: FC<QueryEditorProps> = ({
label = `${label} (${stats.executionTimeMsec || 0}ms)`; label = `${label} (${stats.executionTimeMsec || 0}ms)`;
} }
const handleSelect = (val: string) => { const handleSelect = (val: string, caretPosition: number) => {
onChange(val); onChange(val);
setCaretPosition([caretPosition, caretPosition]);
}; };
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -100,14 +105,19 @@ const QueryEditor: FC<QueryEditorProps> = ({
setOpenAutocomplete(!!val.length); setOpenAutocomplete(!!val.length);
}; };
const handleChangeCaret = (val: number[]) => { const handleChangeCaret = (val: [number, number]) => {
setCaretPosition(val); setCaretPosition(prev => prev[0] === val[0] && prev[1] === val[1] ? prev : val);
}; };
useEffect(() => { useEffect(() => {
setOpenAutocomplete(autocomplete); setOpenAutocomplete(autocomplete);
}, [autocompleteQuick]); }, [autocompleteQuick]);
useEffect(() => {
setShowAutocomplete(false);
debouncedSetShowAutocomplete(true);
}, [caretPosition]);
return ( return (
<div <div
className="vm-query-editor" className="vm-query-editor"
@ -125,12 +135,14 @@ const QueryEditor: FC<QueryEditorProps> = ({
onChangeCaret={handleChangeCaret} onChangeCaret={handleChangeCaret}
disabled={disabled} disabled={disabled}
inputmode={"search"} inputmode={"search"}
caretPosition={caretPosition}
/> />
{autocomplete && ( {showAutocomplete && autocomplete && (
<QueryEditorAutocomplete <QueryEditorAutocomplete
value={value} value={value}
anchorEl={autocompleteAnchorEl} anchorEl={autocompleteAnchorEl}
caretPosition={caretPosition} caretPosition={caretPosition}
hasHelperText={Boolean(warning || error)}
onSelect={handleSelect} onSelect={handleSelect}
onFoundOptions={handleChangeFoundOptions} onFoundOptions={handleChangeFoundOptions}
/> />

View file

@ -1,7 +1,6 @@
import React, { FC, Ref, useState, useEffect, useMemo } from "preact/compat"; import React, { FC, Ref, useState, useEffect, useMemo, useCallback } from "preact/compat";
import Autocomplete, { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete"; import Autocomplete, { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions"; import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { getTextWidth } from "../../../utils/uplot";
import { escapeRegexp, hasUnclosedQuotes } from "../../../utils/regexp"; import { escapeRegexp, hasUnclosedQuotes } from "../../../utils/regexp";
import useGetMetricsQL from "../../../hooks/useGetMetricsQL"; import useGetMetricsQL from "../../../hooks/useGetMetricsQL";
import { QueryContextType } from "../../../types"; import { QueryContextType } from "../../../types";
@ -10,8 +9,9 @@ import { AUTOCOMPLETE_LIMITS } from "../../../constants/queryAutocomplete";
interface QueryEditorAutocompleteProps { interface QueryEditorAutocompleteProps {
value: string; value: string;
anchorEl: Ref<HTMLInputElement>; anchorEl: Ref<HTMLInputElement>;
caretPosition: number[]; caretPosition: [number, number]; // [start, end]
onSelect: (val: string) => void; hasHelperText: boolean;
onSelect: (val: string, caretPosition: number) => void;
onFoundOptions: (val: AutocompleteOptions[]) => void; onFoundOptions: (val: AutocompleteOptions[]) => void;
} }
@ -19,16 +19,24 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
value, value,
anchorEl, anchorEl,
caretPosition, caretPosition,
hasHelperText,
onSelect, onSelect,
onFoundOptions onFoundOptions
}) => { }) => {
const [leftOffset, setLeftOffset] = useState(0); const [offsetPos, setOffsetPos] = useState({ top: 0, left: 0 });
const metricsqlFunctions = useGetMetricsQL(); const metricsqlFunctions = useGetMetricsQL();
const values = useMemo(() => {
if (caretPosition[0] !== caretPosition[1]) return { beforeCursor: value, afterCursor: "" };
const beforeCursor = value.substring(0, caretPosition[0]);
const afterCursor = value.substring(caretPosition[1]);
return { beforeCursor, afterCursor };
}, [value, caretPosition]);
const exprLastPart = useMemo(() => { const exprLastPart = useMemo(() => {
const parts = value.split("}"); const parts = values.beforeCursor.split("}");
return parts[parts.length - 1]; return parts[parts.length - 1];
}, [value]); }, [values]);
const metric = useMemo(() => { const metric = useMemo(() => {
const regexp = /\b[^{}(),\s]+(?={|$)/g; const regexp = /\b[^{}(),\s]+(?={|$)/g;
@ -43,7 +51,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
}, [exprLastPart]); }, [exprLastPart]);
const shouldSuppressAutoSuggestion = (value: string) => { const shouldSuppressAutoSuggestion = (value: string) => {
const pattern = /([(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/; const pattern = /([{(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/;
const parts = value.split(/\s+/); const parts = value.split(/\s+/);
const partsCount = parts.length; const partsCount = parts.length;
const lastPart = parts[partsCount - 1]; const lastPart = parts[partsCount - 1];
@ -55,7 +63,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
}; };
const context = useMemo(() => { const context = useMemo(() => {
if (!value || value.endsWith("}") || shouldSuppressAutoSuggestion(value)) { if (!values.beforeCursor || values.beforeCursor.endsWith("}") || shouldSuppressAutoSuggestion(values.beforeCursor)) {
return QueryContextType.empty; return QueryContextType.empty;
} }
@ -63,19 +71,19 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
const labelValueRegexp = new RegExp(`(${escapeRegexp(metric)})?{?.+${escapeRegexp(label)}(=|!=|=~|!~)"?([^"]*)$`, "g"); const labelValueRegexp = new RegExp(`(${escapeRegexp(metric)})?{?.+${escapeRegexp(label)}(=|!=|=~|!~)"?([^"]*)$`, "g");
switch (true) { switch (true) {
case labelValueRegexp.test(value): case labelValueRegexp.test(values.beforeCursor):
return QueryContextType.labelValue; return QueryContextType.labelValue;
case labelRegexp.test(value): case labelRegexp.test(values.beforeCursor):
return QueryContextType.label; return QueryContextType.label;
default: default:
return QueryContextType.metricsql; return QueryContextType.metricsql;
} }
}, [value, metric, label]); }, [values, metric, label]);
const valueByContext = useMemo(() => { const valueByContext = useMemo(() => {
const wordMatch = value.match(/([\w_\-.:/]+(?![},]))$/); const wordMatch = values.beforeCursor.match(/([\w_\-.:/]+(?![},]))$/);
return wordMatch ? wordMatch[0] : ""; return wordMatch ? wordMatch[0] : "";
}, [value]); }, [values.beforeCursor]);
const { metrics, labels, labelValues, loading } = useFetchQueryOptions({ const { metrics, labels, labelValues, loading } = useFetchQueryOptions({
valueByContext, valueByContext,
@ -97,8 +105,10 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
} }
}, [context, metrics, labels, labelValues]); }, [context, metrics, labels, labelValues]);
const handleSelect = (insert: string) => { const handleSelect = useCallback((insert: string) => {
// Find the start and end of valueByContext in the query string // Find the start and end of valueByContext in the query string
const value = values.beforeCursor;
let valueAfterCursor = values.afterCursor;
const startIndexOfValueByContext = value.lastIndexOf(valueByContext, caretPosition[0]); const startIndexOfValueByContext = value.lastIndexOf(valueByContext, caretPosition[0]);
const endIndexOfValueByContext = startIndexOfValueByContext + valueByContext.length; const endIndexOfValueByContext = startIndexOfValueByContext + valueByContext.length;
@ -110,26 +120,59 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
if (context === QueryContextType.labelValue) { if (context === QueryContextType.labelValue) {
const quote = "\""; const quote = "\"";
const needsQuote = /(?:=|!=|=~|!~)$/.test(beforeValueByContext); const needsQuote = /(?:=|!=|=~|!~)$/.test(beforeValueByContext);
valueAfterCursor = valueAfterCursor.replace(/^[^\s"|},]*/, "");
insert = `${needsQuote ? quote : ""}${insert}`; insert = `${needsQuote ? quote : ""}${insert}`;
} }
if (context === QueryContextType.label) {
valueAfterCursor = valueAfterCursor.replace(/^[^\s=!,{}()"|+\-/*^]*/, "");
}
if (context === QueryContextType.metricsql) {
valueAfterCursor = valueAfterCursor.replace(/^[^\s[\]{}()"|+\-/*^]*/, "");
}
// Assemble the new value with the inserted text // Assemble the new value with the inserted text
const newVal = `${beforeValueByContext}${insert}${afterValueByContext}`; const newVal = `${beforeValueByContext}${insert}${afterValueByContext}${valueAfterCursor}`;
onSelect(newVal); onSelect(newVal, beforeValueByContext.length + insert.length);
}; }, [values]);
useEffect(() => { useEffect(() => {
if (!anchorEl.current) { if (!anchorEl.current) {
setLeftOffset(0); setOffsetPos({ top: 0, left: 0 });
return; return;
} }
const style = window.getComputedStyle(anchorEl.current); const element = anchorEl.current.querySelector("textarea") || anchorEl.current;
const style = window.getComputedStyle(element);
const fontSize = `${style.getPropertyValue("font-size")}`; const fontSize = `${style.getPropertyValue("font-size")}`;
const fontFamily = `${style.getPropertyValue("font-family")}`; const fontFamily = `${style.getPropertyValue("font-family")}`;
const offset = getTextWidth(value, `${fontSize} ${fontFamily}`); const lineHeight = parseInt(`${style.getPropertyValue("line-height")}`);
setLeftOffset(offset);
}, [anchorEl, caretPosition]); const span = document.createElement("div");
span.style.font = `${fontSize} ${fontFamily}`;
span.style.padding = style.getPropertyValue("padding");
span.style.lineHeight = `${lineHeight}px`;
span.style.width = `${element.offsetWidth}px`;
span.style.maxWidth = `${element.offsetWidth}px`;
span.style.whiteSpace = style.getPropertyValue("white-space");
span.style.overflowWrap = style.getPropertyValue("overflow-wrap");
const marker = document.createElement("span");
span.appendChild(document.createTextNode(values.beforeCursor));
span.appendChild(marker);
span.appendChild(document.createTextNode(values.afterCursor));
document.body.appendChild(span);
const spanRect = span.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
const leftOffset = markerRect.left - spanRect.left;
const topOffset = markerRect.bottom - spanRect.bottom - (hasHelperText ? lineHeight : 0);
setOffsetPos({ top: topOffset, left: leftOffset });
span.remove();
marker.remove();
}, [anchorEl, caretPosition, hasHelperText]);
return ( return (
<> <>
@ -140,7 +183,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
options={options} options={options}
anchor={anchorEl} anchor={anchorEl}
minLength={0} minLength={0}
offset={{ top: 0, left: leftOffset }} offset={offsetPos}
onSelect={handleSelect} onSelect={handleSelect}
onFoundOptions={onFoundOptions} onFoundOptions={onFoundOptions}
maxDisplayResults={{ maxDisplayResults={{

View file

@ -2,4 +2,13 @@
.vm-query-editor { .vm-query-editor {
position: relative; position: relative;
.marker-detection {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: -9999;
visibility: hidden;
}
} }

View file

@ -120,7 +120,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, shiftKey } = e; const { key, ctrlKey, metaKey, shiftKey } = e;
const modifiers = ctrlKey || metaKey || shiftKey; const modifiers = ctrlKey || metaKey || shiftKey;
const hasOptions = foundOptions.length; const hasOptions = foundOptions.length && !hideFoundedOptions;
if (key === "ArrowUp" && !modifiers && hasOptions) { if (key === "ArrowUp" && !modifiers && hasOptions) {
e.preventDefault(); e.preventDefault();
@ -148,7 +148,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
if (key === "Escape") { if (key === "Escape") {
handleCloseAutocomplete(); handleCloseAutocomplete();
} }
}, [focusOption, foundOptions, handleCloseAutocomplete, onSelect, selected]); }, [focusOption, foundOptions, hideFoundedOptions, handleCloseAutocomplete, onSelect, selected]);
useEffect(() => { useEffect(() => {
setOpenAutocomplete(value.length >= minLength); setOpenAutocomplete(value.length >= minLength);

View file

@ -1,6 +1,7 @@
import React, { import React, {
FC, FC,
useEffect, useEffect,
useState,
useRef, useRef,
useMemo, useMemo,
FormEvent, FormEvent,
@ -28,12 +29,13 @@ interface TextFieldProps {
autofocus?: boolean autofocus?: boolean
helperText?: string helperText?: string
inputmode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal" inputmode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal"
caretPosition?: [number, number]
onChange?: (value: string) => void onChange?: (value: string) => void
onEnter?: () => void onEnter?: () => void
onKeyDown?: (e: KeyboardEvent) => void onKeyDown?: (e: KeyboardEvent) => void
onFocus?: () => void onFocus?: () => void
onBlur?: () => void onBlur?: () => void
onChangeCaret?: (position: number[]) => void onChangeCaret?: (position: [number, number]) => void
} }
const TextField: FC<TextFieldProps> = ({ const TextField: FC<TextFieldProps> = ({
@ -49,6 +51,7 @@ const TextField: FC<TextFieldProps> = ({
disabled = false, disabled = false,
autofocus = false, autofocus = false,
inputmode = "text", inputmode = "text",
caretPosition,
onChange, onChange,
onEnter, onEnter,
onKeyDown, onKeyDown,
@ -62,6 +65,7 @@ const TextField: FC<TextFieldProps> = ({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fieldRef = useMemo(() => type === "textarea" ? textareaRef : inputRef, [type]); const fieldRef = useMemo(() => type === "textarea" ? textareaRef : inputRef, [type]);
const [selectionPos, setSelectionPos] = useState<[start: number, end: number]>([0, 0]);
const inputClasses = classNames({ const inputClasses = classNames({
"vm-text-field__input": true, "vm-text-field__input": true,
@ -74,7 +78,7 @@ const TextField: FC<TextFieldProps> = ({
const updateCaretPosition = (target: HTMLInputElement | HTMLTextAreaElement) => { const updateCaretPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
const { selectionStart, selectionEnd } = target; const { selectionStart, selectionEnd } = target;
onChangeCaret && onChangeCaret([selectionStart || 0, selectionEnd || 0]); setSelectionPos([selectionStart || 0, selectionEnd || 0]);
}; };
const handleMouseUp = (e: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleMouseUp = (e: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@ -102,11 +106,6 @@ const TextField: FC<TextFieldProps> = ({
updateCaretPosition(e.currentTarget); updateCaretPosition(e.currentTarget);
}; };
useEffect(() => {
if (!autofocus || isMobile) return;
fieldRef?.current?.focus && fieldRef.current.focus();
}, [fieldRef, autofocus]);
const handleFocus = () => { const handleFocus = () => {
onFocus && onFocus(); onFocus && onFocus();
}; };
@ -115,6 +114,31 @@ const TextField: FC<TextFieldProps> = ({
onBlur && onBlur(); onBlur && onBlur();
}; };
const setSelectionRange = (range: [number, number]) => {
try {
fieldRef.current && fieldRef.current.setSelectionRange(range[0], range[1]);
} catch (e) {
return e;
}
};
useEffect(() => {
if (!autofocus || isMobile) return;
fieldRef?.current?.focus && fieldRef.current.focus();
}, [fieldRef, autofocus]);
useEffect(() => {
onChangeCaret && onChangeCaret(selectionPos);
}, [selectionPos]);
useEffect(() => {
setSelectionRange(selectionPos);
}, [value]);
useEffect(() => {
caretPosition && setSelectionRange(caretPosition);
}, [caretPosition]);
return <label return <label
className={classNames({ className={classNames({
"vm-text-field": true, "vm-text-field": true,

View file

@ -9,7 +9,7 @@
&_textarea:after { &_textarea:after {
content: attr(data-replicated-value) " "; content: attr(data-replicated-value) " ";
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; overflow-wrap: break-word;
visibility: hidden; visibility: hidden;
} }

View file

@ -96,6 +96,7 @@ export const useFetchQueryOptions = ({ valueByContext, metric, label, context }:
const cachedData = autocompleteCache.get(key); const cachedData = autocompleteCache.get(key);
if (cachedData) { if (cachedData) {
setter(processData(cachedData, type)); setter(processData(cachedData, type));
setLoading(false);
return; return;
} }
const response = await fetch(`${serverUrl}/api/v1/${urlSuffix}?${params}`, { signal }); const response = await fetch(`${serverUrl}/api/v1/${urlSuffix}?${params}`, { signal });
@ -104,13 +105,13 @@ export const useFetchQueryOptions = ({ valueByContext, metric, label, context }:
setter(processData(data, type)); setter(processData(data, type));
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: data } }); queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: data } });
} }
setLoading(false);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name !== "AbortError") { if (e instanceof Error && e.name !== "AbortError") {
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: [] } }); queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: [] } });
setLoading(false);
console.error(e); console.error(e);
} }
} finally {
setLoading(false);
} }
}; };

View file

@ -41,6 +41,7 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
* FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth/): support regex matching when routing incoming requests based on HTTP [query args](https://en.wikipedia.org/wiki/Query_string) via `src_query_args` option at `url_map`. See [these docs](https://docs.victoriametrics.com/vmauth/#generic-http-proxy-for-different-backends) and [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6070). * FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth/): support regex matching when routing incoming requests based on HTTP [query args](https://en.wikipedia.org/wiki/Query_string) via `src_query_args` option at `url_map`. See [these docs](https://docs.victoriametrics.com/vmauth/#generic-http-proxy-for-different-backends) and [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6070).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): optimize auto-suggestion performance for metric names when the database contains big number of unique time series. * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): optimize auto-suggestion performance for metric names when the database contains big number of unique time series.
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): in the Select component, user-entered values are now preserved on blur if they match options in the list. * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): in the Select component, user-entered values are now preserved on blur if they match options in the list.
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): auto-suggestion triggers at any cursor position in the query input. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5864).
* BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): skip unnecessary index lookups if downsampling wasn't set for ENT versions of VictoriaMetrics. Before, users of VictoriaMetrics ENT could have experience elevated CPU usage even if no downsampling was configured. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000). * BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): skip unnecessary index lookups if downsampling wasn't set for ENT versions of VictoriaMetrics. Before, users of VictoriaMetrics ENT could have experience elevated CPU usage even if no downsampling was configured. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000).
* BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): properly populate downsampling metadata for data parts created by VictoriaMetrics ENT versions lower than v1.100.0. The bug could trigger the downsampling actions for parts that were downsampled already. This bug doesn't have any negative effect apart from spending extra CPU resources on the repeated downsampling. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000). * BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): properly populate downsampling metadata for data parts created by VictoriaMetrics ENT versions lower than v1.100.0. The bug could trigger the downsampling actions for parts that were downsampled already. This bug doesn't have any negative effect apart from spending extra CPU resources on the repeated downsampling. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000).