Repository: VictoriaMetrics/VictoriaMetrics
Pull request #3376 (2022-11-21 16:40:22)

vmui: sticky tooltip
- Click on the datapoint to make the tooltip "stick"
- Hold and drag on the `drag` icon to reposition the tooltip
- Click on the `cross` icon to hide the tooltip
- #3321

<details>
  <summary>Demo</summary>
  
![ezgif-2-cbb8594436](https://user-images.githubusercontent.com/29711459/203110173-dfe81139-be6d-4e0a-8054-ea435f046b21.gif)

</details>


codecov[bot]: # [Codecov](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics) Report
Base: **57.71**% // Head: **58.04**% // Increases project coverage by **`+0.32%`** :tada:
> Coverage data is based on head [(`4bf771f`)](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics) compared to base [(`2c9e403`)](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/commit/2c9e403d5f4617c1710709c4a9198b92d415f256?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics).
> Patch has no changes to coverable lines.

> :exclamation: Current head 4bf771f differs from pull request most recent head f5a3f8e. Consider uploading reports for the commit f5a3f8e to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3376      +/-   ##
==========================================
+ Coverage   57.71%   58.04%   +0.32%     
==========================================
  Files         286      286              
  Lines       48558    48813     +255     
==========================================
+ Hits        28027    28333     +306     
+ Misses      18722    18674      -48     
+ Partials     1809     1806       -3     
```


| [Impacted Files](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics) | Coverage Δ | |
|---|---|---|
| [app/vmselect/promql/aggr\_incremental.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-YXBwL3Ztc2VsZWN0L3Byb21xbC9hZ2dyX2luY3JlbWVudGFsLmdv) | `85.28% <0.00%> (ø)` | |
| [lib/storage/storage.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-bGliL3N0b3JhZ2Uvc3RvcmFnZS5nbw==) | `69.98% <0.00%> (+0.11%)` | :arrow_up: |
| [lib/mergeset/table.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-bGliL21lcmdlc2V0L3RhYmxlLmdv) | `68.65% <0.00%> (+1.35%)` | :arrow_up: |
| [app/vmselect/promql/transform.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-YXBwL3Ztc2VsZWN0L3Byb21xbC90cmFuc2Zvcm0uZ28=) | `89.56% <0.00%> (+1.36%)` | :arrow_up: |
| [app/vmselect/promql/eval.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-YXBwL3Ztc2VsZWN0L3Byb21xbC9ldmFsLmdv) | `64.91% <0.00%> (+3.59%)` | :arrow_up: |
| [app/vmalert/remotewrite/remotewrite.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-YXBwL3ZtYWxlcnQvcmVtb3Rld3JpdGUvcmVtb3Rld3JpdGUuZ28=) | `66.44% <0.00%> (+4.02%)` | :arrow_up: |
| [lib/mergeset/block\_stream\_reader.go](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics#diff-bGliL21lcmdlc2V0L2Jsb2NrX3N0cmVhbV9yZWFkZXIuZ28=) | `65.68% <0.00%> (+11.76%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/VictoriaMetrics/VictoriaMetrics/pull/3376?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VictoriaMetrics).


Patch:
diff --git a/app/vmselect/promql/transform.go b/app/vmselect/promql/transform.go
index 3fa73ed6f..f4d151295 100644
--- a/app/vmselect/promql/transform.go
+++ b/app/vmselect/promql/transform.go
@@ -91,6 +91,7 @@ var transformFuncs = map[string]transformFunc{
 	"range_linear_regression":    transformRangeLinearRegression,
 	"range_max":                  newTransformFuncRange(runningMax),
 	"range_min":                  newTransformFuncRange(runningMin),
+	"range_normalize":            transformRangeNormalize,
 	"range_quantile":             transformRangeQuantile,
 	"range_stddev":               transformRangeStddev,
 	"range_stdvar":               transformRangeStdvar,
@@ -142,6 +143,7 @@ var transformFuncsKeepMetricName = map[string]bool{
 	"range_linear_regression": true,
 	"range_max":               true,
 	"range_min":               true,
+	"range_normalize":         true,
 	"range_quantile":          true,
 	"range_stdvar":            true,
 	"range_sddev":             true,
@@ -1240,6 +1242,38 @@ func newTransformFuncRange(rf func(a, b float64, idx int) float64) transformFunc
 	}
 }
 
+func transformRangeNormalize(tfa *transformFuncArg) ([]*timeseries, error) {
+	args := tfa.args
+	var rvs []*timeseries
+	for _, tss := range args {
+		for _, ts := range tss {
+			values := ts.Values
+			vMin := inf
+			vMax := -inf
+			for _, v := range values {
+				if math.IsNaN(v) {
+					continue
+				}
+				if v < vMin {
+					vMin = v
+				}
+				if v > vMax {
+					vMax = v
+				}
+			}
+			d := vMax - vMin
+			if math.IsInf(d, 0) {
+				continue
+			}
+			for i, v := range values {
+				values[i] = (v - vMin) / d
+			}
+			rvs = append(rvs, ts)
+		}
+	}
+	return rvs, nil
+}
+
 func transformRangeLinearRegression(tfa *transformFuncArg) ([]*timeseries, error) {
 	args := tfa.args
 	if err := expectTransformArgsNum(args, 1); err != nil {
diff --git a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx
new file mode 100644
index 000000000..099771946
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx
@@ -0,0 +1,181 @@
+import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
+import uPlot, { Series } from "uplot";
+import { MetricResult } from "../../../api/types";
+import { formatPrettyNumber, getColorLine, getLegendLabel } from "../../../utils/uplot/helpers";
+import dayjs from "dayjs";
+import { DATE_FULL_TIMEZONE_FORMAT } from "../../../constants/date";
+import ReactDOM from "react-dom";
+import get from "lodash.get";
+import Button from "../../Main/Button/Button";
+import { CloseIcon, DragIcon } from "../../Main/Icons";
+import classNames from "classnames";
+import { MouseEvent as ReactMouseEvent } from "react";
+import "./style.scss";
+
+export interface ChartTooltipProps {
+  id: string,
+  u: uPlot,
+  metrics: MetricResult[],
+  series: Series[],
+  unit?: string,
+  isSticky?: boolean,
+  tooltipOffset: { left: number, top: number },
+  tooltipIdx: { seriesIdx: number, dataIdx: number },
+  onClose?: (id: string) => void
+}
+
+const ChartTooltip: FC<ChartTooltipProps> = ({
+  u,
+  id,
+  unit = "",
+  metrics,
+  series,
+  tooltipIdx,
+  tooltipOffset,
+  isSticky,
+  onClose
+}) => {
+  const tooltipRef = useRef<HTMLDivElement>(null);
+
+  const [position, setPosition] = useState({ top: -999, left: -999 });
+  const [moving, setMoving] = useState(false);
+  const [moved, setMoved] = useState(false);
+
+  const [seriesIdx, setSeriesIdx] = useState(tooltipIdx.seriesIdx);
+  const [dataIdx, setDataIdx] = useState(tooltipIdx.dataIdx);
+
+  const targetPortal = useMemo(() => u.root.querySelector(".u-wrap"), [u]);
+
+  const value = useMemo(() => get(u, ["data", seriesIdx, dataIdx], 0), [u, seriesIdx, dataIdx]);
+  const valueFormat = useMemo(() => formatPrettyNumber(value), [value]);
+  const dataTime = useMemo(() => u.data[0][dataIdx], [u, dataIdx]);
+  const date = useMemo(() => dayjs(new Date(dataTime * 1000)).format(DATE_FULL_TIMEZONE_FORMAT), [dataTime]);
+
+  const color = useMemo(() => getColorLine(series[seriesIdx]?.label || ""), [series, seriesIdx]);
+
+  const name = useMemo(() => {
+    const metricName = (series[seriesIdx]?.label || "").replace(/{.+}/gmi, "").trim();
+    return getLegendLabel(metricName);
+  }, []);
+
+  const fields = useMemo(() => {
+    const metric = metrics[seriesIdx - 1]?.metric || {};
+    const fields = Object.keys(metric).filter(k => k !== "__name__");
+    return fields.map(key => `${key}="${metric[key]}"`);
+  }, [metrics, seriesIdx]);
+
+  const handleClose = () => {
+    onClose && onClose(id);
+  };
+
+  const handleMouseDown = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
+    setMoved(true);
+    setMoving(true);
+    const { clientX, clientY } = e;
+    setPosition({ top: clientY, left: clientX });
+  };
+
+  const handleMouseMove = (e: MouseEvent) => {
+    if (!moving) return;
+    const { clientX, clientY } = e;
+    setPosition({ top: clientY, left: clientX });
+  };
+
+  const handleMouseUp = () => {
+    setMoving(false);
+  };
+
+  const calcPosition = () => {
+    if (!tooltipRef.current) return;
+
+    const topOnChart = u.valToPos((value || 0), series[seriesIdx]?.scale || "1");
+    const leftOnChart = u.valToPos(dataTime, "x");
+    const { width: tooltipWidth, height: tooltipHeight } = tooltipRef.current.getBoundingClientRect();
+    const { width, height } = u.over.getBoundingClientRect();
+
+    const margin = 10;
+    const overflowX = leftOnChart + tooltipWidth >= width ? tooltipWidth + (2 * margin) : 0;
+    const overflowY = topOnChart + tooltipHeight >= height ? tooltipHeight + (2 * margin) : 0;
+
+    setPosition({
+      top: topOnChart + tooltipOffset.top + margin - overflowY,
+      left: leftOnChart + tooltipOffset.left + margin - overflowX
+    });
+  };
+
+  useEffect(calcPosition, [u, value, dataTime, seriesIdx, tooltipOffset, tooltipRef]);
+
+  useEffect(() => {
+    setSeriesIdx(tooltipIdx.seriesIdx);
+    setDataIdx(tooltipIdx.dataIdx);
+  }, [tooltipIdx]);
+
+  useEffect(() => {
+    if (moving) {
+      document.addEventListener("mousemove", handleMouseMove);
+      document.addEventListener("mouseup", handleMouseUp);
+    }
+
+    return () => {
+      document.removeEventListener("mousemove", handleMouseMove);
+      document.removeEventListener("mouseup", handleMouseUp);
+    };
+  }, [moving]);
+
+  if (!targetPortal || tooltipIdx.seriesIdx < 0 || tooltipIdx.dataIdx < 0) return null;
+
+  return ReactDOM.createPortal((
+    <div
+      className={classNames({
+        "vm-chart-tooltip": true,
+        "vm-chart-tooltip_sticky": isSticky,
+        "vm-chart-tooltip_moved": moved
+
+      })}
+      ref={tooltipRef}
+      style={position}
+    >
+      <div className="vm-chart-tooltip-header">
+        <div className="vm-chart-tooltip-header__date">{date}</div>
+        {isSticky && (
+          <>
+            <Button
+              className="vm-chart-tooltip-header__drag"
+              variant="text"
+              size="small"
+              startIcon={<DragIcon/>}
+              onMouseDown={handleMouseDown}
+            />
+            <Button
+              className="vm-chart-tooltip-header__close"
+              variant="text"
+              size="small"
+              startIcon={<CloseIcon/>}
+              onClick={handleClose}
+            />
+          </>
+        )}
+      </div>
+      <div className="vm-chart-tooltip-data">
+        <div
+          className="vm-chart-tooltip-data__marker"
+          style={{ background: color }}
+        />
+        <p>
+          {name}:
+          <b className="vm-chart-tooltip-data__value">{valueFormat}</b>
+          {unit}
+        </p>
+      </div>
+      {!!fields.length && (
+        <div className="vm-chart-tooltip-info">
+          {fields.map((f, i) => (
+            <div key={`${f}_${i}`}>{f}</div>
+          ))}
+        </div>
+      )}
+    </div>
+  ), targetPortal);
+};
+
+export default ChartTooltip;
diff --git a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss
new file mode 100644
index 000000000..9c8cedc94
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss
@@ -0,0 +1,77 @@
+@use "src/styles/variables" as *;
+$chart-tooltip-width: 300px;
+$chart-tooltip-icon-width: 25px;
+$chart-tooltip-date-width: $chart-tooltip-width - (2*$chart-tooltip-icon-width) - (2*$padding-global) - $padding-small;
+$chart-tooltip-x: -1 * ($padding-small + $padding-global + $chart-tooltip-date-width + ($chart-tooltip-icon-width/2));
+$chart-tooltip-y: -1 * ($padding-small + ($chart-tooltip-icon-width/2));
+
+.vm-chart-tooltip {
+  position: absolute;
+  display: grid;
+  gap: $padding-global;
+  width: $chart-tooltip-width;
+  padding: $padding-small;
+  border-radius: $border-radius-medium;
+  background: $color-background-tooltip;
+  color: $color-white;
+  font-size: $font-size-small;
+  font-weight: normal;
+  line-height: 150%;
+  word-wrap: break-word;
+  font-family: $font-family-monospace;
+  z-index: 98;
+  user-select: text;
+  pointer-events: none;
+
+  &_sticky {
+    background-color: $color-dove-gray;
+    pointer-events: auto;
+    z-index: 99;
+  }
+
+  &_moved {
+    position: fixed;
+    margin-top: $chart-tooltip-y;
+    margin-left: $chart-tooltip-x;
+  }
+
+  &-header {
+    display: grid;
+    grid-template-columns: 1fr $chart-tooltip-icon-width $chart-tooltip-icon-width;
+    gap: $padding-small;
+    align-items: center;
+    justify-content: center;
+    min-height: 25px;
+
+    &__close {
+      color: $color-white;
+    }
+
+    &__drag {
+      color: $color-white;
+      cursor: move;
+    }
+  }
+
+  &-data {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+
+    &__value {
+      padding: 4px;
+      font-weight: bold;
+    }
+
+    &__marker {
+      width: 12px;
+      height: 12px;
+      margin-right: $padding-small;
+    }
+  }
+
+  &-info {
+    display: grid;
+    grid-gap: 4px;
+  }
+}
diff --git a/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx b/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
index b8c37503f..1e7e4ff7c 100644
--- a/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
+++ b/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
@@ -1,9 +1,15 @@
-import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
-import uPlot, { AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale } from "uplot";
+import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
+import uPlot, {
+  AlignedData as uPlotData,
+  Options as uPlotOptions,
+  Series as uPlotSeries,
+  Range,
+  Scales,
+  Scale,
+} from "uplot";
 import { defaultOptions } from "../../../utils/uplot/helpers";
 import { dragChart } from "../../../utils/uplot/events";
 import { getAxes, getMinMaxBuffer } from "../../../utils/uplot/axes";
-import { setTooltip } from "../../../utils/uplot/tooltip";
 import { MetricResult } from "../../../api/types";
 import { limitsDurations } from "../../../utils/time";
 import throttle from "lodash.throttle";
@@ -13,6 +19,7 @@ import { YaxisState } from "../../../state/graph/reducer";
 import "uplot/dist/uPlot.min.css";
 import "./style.scss";
 import classNames from "classnames";
+import ChartTooltip, { ChartTooltipProps } from "../ChartTooltip/ChartTooltip";
 
 export interface LineChartProps {
   metrics: MetricResult[];
@@ -24,21 +31,30 @@ export interface LineChartProps {
   setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
   container: HTMLDivElement | null
 }
-enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
 
-const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
-  period, yaxis, unit, setPeriod, container }) => {
+enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
 
+const LineChart: FC<LineChartProps> = ({
+  data,
+  series,
+  metrics = [],
+  period,
+  yaxis,
+  unit,
+  setPeriod,
+  container
+}) => {
   const uPlotRef = useRef<HTMLDivElement>(null);
   const [isPanning, setPanning] = useState(false);
   const [xRange, setXRange] = useState({ min: period.start, max: period.end });
   const [uPlotInst, setUPlotInst] = useState<uPlot>();
   const layoutSize = useResize(container);
 
-  const tooltip = document.createElement("div");
-  tooltip.className = "u-tooltip";
-  const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = { seriesIdx: null, dataIdx: undefined };
-  const tooltipOffset = { left: 0, top: 0 };
+  const [showTooltip, setShowTooltip] = useState(false);
+  const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
+  const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 });
+  const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
+  const tooltipId = useMemo(() => `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`, [tooltipIdx]);
 
   const setScale = ({ min, max }: { min: number, max: number }): void => {
     setPeriod({ from: new Date(min * 1000), to: new Date(max * 1000) });
@@ -54,12 +70,13 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
 
   const onReadyChart = (u: uPlot) => {
     const factor = 0.9;
-    tooltipOffset.left = parseFloat(u.over.style.left);
-    tooltipOffset.top = parseFloat(u.over.style.top);
-    u.root.querySelector(".u-wrap")?.appendChild(tooltip);
+    setTooltipOffset({
+      left: parseFloat(u.over.style.left),
+      top: parseFloat(u.over.style.top)
+    });
     u.over.addEventListener("mousedown", e => {
-      const { ctrlKey, metaKey } = e;
-      const leftClick = e.button === 0;
+      const { ctrlKey, metaKey, button } = e;
+      const leftClick = button === 0;
       const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
       if (leftClickWithMeta) {
         // drag pan
@@ -98,21 +115,37 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
     }
   };
 
-  const setCursor = (u: uPlot) => {
-    if (tooltipIdx.dataIdx === u.cursor.idx) return;
-    tooltipIdx.dataIdx = u.cursor.idx || 0;
-    if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
-      setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit });
+  const handleClick = () => {
+    const id = `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`;
+    const props = {
+      id,
+      unit,
+      series,
+      metrics,
+      tooltipIdx,
+      tooltipOffset,
+    };
+
+    if (!stickyTooltips.find(t => t.id === id)) {
+      const tooltipProps = JSON.parse(JSON.stringify(props));
+      setStickyToolTips(prev => [...prev, tooltipProps]);
     }
   };
 
+  const handleUnStick = (id:string) => {
+    setStickyToolTips(prev => prev.filter(t => t.id !== id));
+  };
+
+  const setCursor = (u: uPlot) => {
+    const dataIdx = u.cursor.idx ?? -1;
+    setTooltipIdx(prev => ({ ...prev, dataIdx }));
+  };
+
   const seriesFocus = (u: uPlot, sidx: (number | null)) => {
-    if (tooltipIdx.seriesIdx === sidx) return;
-    tooltipIdx.seriesIdx = sidx;
-    sidx && tooltipIdx.dataIdx !== undefined
-      ? setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit })
-      : tooltip.style.display = "none";
+    const seriesIdx = sidx ?? -1;
+    setTooltipIdx(prev => ({ ...prev, seriesIdx }));
   };
+
   const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
   const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => {
     if (yaxis.limits.enable) return yaxis.limits.range[axis];
@@ -168,6 +201,8 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
   useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
 
   useEffect(() => {
+    setStickyToolTips([]);
+    setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
     if (!uPlotRef.current) return;
     const u = new uPlot(options, data, uPlotRef.current);
     setUPlotInst(u);
@@ -187,6 +222,17 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
   useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
   useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
 
+  useEffect(() => {
+    const show = tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1;
+    setShowTooltip(show);
+
+    if (show) window.addEventListener("click", handleClick);
+
+    return () => {
+      window.removeEventListener("click", handleClick);
+    };
+  }, [tooltipIdx, stickyTooltips]);
+
   return (
     <div
       className={classNames({
@@ -194,7 +240,31 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
         "vm-line-chart_panning": isPanning
       })}
     >
-      <div ref={uPlotRef}/>
+      <div
+        className="vm-line-chart__u-plot"
+        ref={uPlotRef}
+      />
+      {uPlotInst && showTooltip && (
+        <ChartTooltip
+          unit={unit}
+          u={uPlotInst}
+          series={series}
+          metrics={metrics}
+          tooltipIdx={tooltipIdx}
+          tooltipOffset={tooltipOffset}
+          id={tooltipId}
+        />
+      )}
+
+      {uPlotInst && stickyTooltips.map(t => (
+        <ChartTooltip
+          {...t}
+          isSticky
+          u={uPlotInst}
+          key={t.id}
+          onClose={handleUnStick}
+        />
+      ))}
     </div>
   );
 };
diff --git a/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss b/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
index d1b3b26de..30186608f 100644
--- a/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
+++ b/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
@@ -7,45 +7,8 @@
   &_panning {
     pointer-events: none;
   }
-}
-
-.u-tooltip {
-  position: absolute;
-  display: none;
-  grid-gap: $padding-global;
-  max-width: 300px;
-  padding: $padding-small;
-  border-radius: $border-radius-medium;
-  background: $color-background-tooltip;
-  color: $color-white;
-  font-size: $font-size-small;
-  font-weight: normal;
-  line-height: 1.4;
-  word-wrap: break-word;
-  font-family: monospace;
-  pointer-events: none;
-  z-index: 100;
-
-  &-data {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-    line-height: 150%;
-
-    &__value {
-      padding: 4px;
-      font-weight: bold;
-    }
-  }
-
-  &__info {
-    display: grid;
-    grid-gap: 4px;
-  }
 
-  &__marker {
-    width: 12px;
-    height: 12px;
-    margin-right: $padding-small;
+  &__u-plot {
+    position: relative;
   }
 }
diff --git a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
index 09d5c9edd..ad54f6168 100644
--- a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
@@ -14,6 +14,7 @@ interface ButtonProps {
   children?: ReactNode
   className?: string
   onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
+  onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
 }
 
 const Button: FC<ButtonProps> = ({
@@ -27,6 +28,7 @@ const Button: FC<ButtonProps> = ({
   className,
   disabled,
   onClick,
+  onMouseDown,
 }) => {
 
   const classesButton = classNames({
@@ -45,6 +47,7 @@ const Button: FC<ButtonProps> = ({
       className={classesButton}
       disabled={disabled}
       onClick={onClick}
+      onMouseDown={onMouseDown}
     >
       <>
         {startIcon && <span className="vm-button__start-icon">{startIcon}</span>}
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 68ab7a9bf..cd7ea24dc 100644
--- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
@@ -300,3 +300,12 @@ export const CopyIcon = () => (
     ></path>
   </svg>
 );
+
+export const DragIcon = () => (
+  <svg
+    viewBox="0 0 24 24"
+    fill="currentColor"
+  >
+    <path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"></path>
+  </svg>
+);
diff --git a/app/vmui/packages/vmui/src/styles/variables.scss b/app/vmui/packages/vmui/src/styles/variables.scss
index 094acc677..a3c55ae3d 100644
--- a/app/vmui/packages/vmui/src/styles/variables.scss
+++ b/app/vmui/packages/vmui/src/styles/variables.scss
@@ -18,6 +18,7 @@ $color-text-secondary: rgba($color-text, 0.6);
 $color-text-disabled: rgba($color-text, 0.4);
 
 $color-black: #110f0f;
+$color-dove-gray: #616161;
 $color-silver: #C4C4C4;
 $color-alto: #D8D8D8;
 $color-white: #ffffff;
@@ -30,7 +31,7 @@ $color-tropical-blue: #C9E3F6;
 $color-background-body: var(--color-background-body);
 $color-background-block: var(--color-background-block);
 $color-background-modal: rgba($color-black, 0.7);
-$color-background-tooltip: rgba(97, 97, 97, 0.92);
+$color-background-tooltip: rgba($color-dove-gray, 0.92);
 
 
 /************* padding *************/
diff --git a/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts b/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts
deleted file mode 100644
index 970da5b5e..000000000
--- a/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import dayjs from "dayjs";
-import { SetupTooltip } from "./types";
-import { getColorLine, formatPrettyNumber, getLegendLabel } from "./helpers";
-import { DATE_FULL_TIMEZONE_FORMAT } from "../../constants/date";
-
-// TODO create jsx component
-export const setTooltip = ({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit = "" }: SetupTooltip): void => {
-  const { seriesIdx, dataIdx } = tooltipIdx;
-  if (seriesIdx === null || dataIdx === undefined) return;
-  const dataSeries = u.data[seriesIdx][dataIdx];
-  const dataTime = u.data[0][dataIdx];
-  const metric = metrics[seriesIdx - 1]?.metric || {};
-  const selectedSeries = series[seriesIdx];
-  const color = getColorLine(selectedSeries.label || "");
-
-  const { width, height } = u.over.getBoundingClientRect();
-  const top = u.valToPos((dataSeries || 0), series[seriesIdx]?.scale || "1");
-  const lft = u.valToPos(dataTime, "x");
-  const { width: tooltipWidth, height: tooltipHeight } = tooltip.getBoundingClientRect();
-  const overflowX = lft + tooltipWidth >= width;
-  const overflowY = top + tooltipHeight >= height;
-
-  tooltip.style.display = "grid";
-  tooltip.style.top = `${tooltipOffset.top + top + 10 - (overflowY ? tooltipHeight + 10 : 0)}px`;
-  tooltip.style.left = `${tooltipOffset.left + lft + 10 - (overflowX ? tooltipWidth + 20 : 0)}px`;
-  const metricName = (selectedSeries.label || "").replace(/{.+}/gmi, "").trim();
-  const name = getLegendLabel(metricName);
-  const date = dayjs(new Date(dataTime * 1000)).format(DATE_FULL_TIMEZONE_FORMAT);
-  const info = Object.keys(metric).filter(k => k !== "__name__").map(k => `<div><b>${k}</b>: ${metric[k]}</div>`).join("");
-  const marker = `<div class="u-tooltip__marker" style="background: ${color}"></div>`;
-  tooltip.innerHTML = `<div>${date}</div>
-                       <div class="u-tooltip-data">
-                         ${marker}${name}: <b class="u-tooltip-data__value">${formatPrettyNumber(dataSeries)}</b> ${unit}
-                       </div>
-                       <div class="u-tooltip__info">${info}</div>`;
-};
diff --git a/app/vmui/packages/vmui/src/utils/uplot/types.ts b/app/vmui/packages/vmui/src/utils/uplot/types.ts
index 604b86549..e46850228 100644
--- a/app/vmui/packages/vmui/src/utils/uplot/types.ts
+++ b/app/vmui/packages/vmui/src/utils/uplot/types.ts
@@ -1,21 +1,4 @@
 import uPlot, { Series } from "uplot";
-import { MetricResult } from "../../api/types";
-
-export interface SetupTooltip {
-    u: uPlot,
-    metrics: MetricResult[],
-    series: Series[],
-    tooltip: HTMLDivElement,
-    unit?: string,
-    tooltipOffset: {
-        left: number,
-        top: number
-    },
-    tooltipIdx: {
-        seriesIdx: number | null,
-        dataIdx: number | undefined
-    }
-}
 
 export interface HideSeriesArgs {
     hideSeries: string[],
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 930882d70..8f922c3a1 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -16,15 +16,19 @@ The following tip changes can be tested by building VictoriaMetrics components f
 ## tip
 
 * FEATURE: [VictoriaMetrics enterprise](https://docs.victoriametrics.com/enterprise.html): add `-storageNode.filter` command-line flag for filtering the [discovered vmstorage nodes](https://docs.victoriametrics.com/Cluster-VictoriaMetrics.html#automatic-vmstorage-discovery) with arbitrary regular expressions. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3353).
+* FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): allow using numeric values with `K`, `Ki`, `M`, `Mi`, `G`, `Gi`, `T` and `Ti` suffixes inside MetricsQL queries. For example `8Ki` equals to `8*1024`, while `8.2M` equals to `8.2*1000*1000`.
+* FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): add [range_normalize](https://docs.victoriametrics.com/MetricsQL.html#range_normalize) function for normalizing multiple time series into `[0...1]` value range. This function is useful for correlation analyzis of time series with distinct value ranges. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3167).
 * FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): add [range_linear_regression](https://docs.victoriametrics.com/MetricsQL.html#range_linear_regression) function for calculating [simple linear regression](https://en.wikipedia.org/wiki/Simple_linear_regression) over the input time series on the selected time range. This function is useful for predictions and capacity planning. For example, `range_linear_regression(process_resident_memory_bytes)` can predict future memory usage based on the past memory usage.
 * FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): add [range_stddev](https://docs.victoriametrics.com/MetricsQL.html#range_stddev) and [range_stdvar](https://docs.victoriametrics.com/MetricsQL.html#range_stdvar) functions.
 * FEATURE: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): optimize `expr1 op expr2` query when `expr1` returns an empty result. In this case there is no sense in executing `expr2` for `op` not equal to `or`, since the end result will be empty according to [PromQL series matching rules](https://prometheus.io/docs/prometheus/latest/querying/operators/#vector-matching). See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3349). Thanks to @jianglinjian for pointing to this case.
 * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to upload/paste JSON to investigate the trace. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3308) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3310).
 * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): reduce JS bundle size from 200Kb to 100Kb. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3298).
 * FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to hide results of a particular query by clicking the `eye` icon. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3359).
+* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to "stick" a tooltip on the chart by clicking on a data point. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3321) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3376)
 * FEATURE: [vmalert](https://docs.victoriametrics.com/vmalert.html): add default alert list for vmalert's metrics. See [alerts-vmalert.yml](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts-vmalert.yml).
 
 * BUGFIX: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): properly return an empty result from [limit_offset](https://docs.victoriametrics.com/MetricsQL.html#limit_offset) if the `offset` arg exceeds the number of inner time series. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3312).
+* BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent.html): properly discover GCE zones when `filter` option is set at [gce_sd_configs](https://docs.victoriametrics.com/sd_configs.html#gce_sd_configs). See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3202).
 * BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): properly display the requested graph on the requested time range when navigating from Prometheus URL in Grafana.
 * BUGFIX: reduce CPU usage spikes and memory usage spikes under high data ingestion rate introduced in [v1.83.0](https://docs.victoriametrics.com/CHANGELOG.html#v1830). See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3343).
 
diff --git a/docs/MetricsQL.md b/docs/MetricsQL.md
index 33d1b9c92..e16949ec1 100644
--- a/docs/MetricsQL.md
+++ b/docs/MetricsQL.md
@@ -73,6 +73,7 @@ The list of MetricsQL features:
 * The duration suffix is optional. The duration is in seconds if the suffix is missing.
   For example, `rate(m[300] offset 1800)` is equivalent to `rate(m[5m]) offset 30m`.
 * The duration can be placed anywhere in the query. For example, `sum_over_time(m[1h]) / 1h` is equivalent to `sum_over_time(m[1h]) / 3600`.
+* Numeric values can have `K`, `Ki`, `M`, `Mi`, `G`, `Gi`, `T` and `Ti` suffixes. For example, `8K` is equivalent to `8000`, while `1.2Mi` is equvalent to `1.2*1024*1024`.
 * Trailing commas on all the lists are allowed - label filters, function args and with expressions.
   For instance, the following queries are valid: `m{foo="bar",}`, `f(a, b,)`, `WITH (x=y,) x`.
   This simplifies maintenance of multi-line queries.
@@ -1220,6 +1221,11 @@ over the selected time range per each time series returned by `q`. This function
 
 `range_min(q)` is a [transform function](#transform-functions), which calculates the min value across points per each time series returned by `q`.
 
+#### range_normalize
+
+`range_normalize(q1, ...)` is a [transform function](#transform-functions), which normalizes values for time series returned by `q1, ...` into `[0 ... 1]` range.
+This function is useful for correlating time series with distinct value ranges.
+
 #### range_quantile
 
 `range_quantile(phi, q)` is a [transform function](#transform-functions), which returns `phi`-quantile across points per each time series returned by `q`.
diff --git a/go.mod b/go.mod
index 0c8db875c..4ec30937b 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	// like https://github.com/valyala/fasthttp/commit/996610f021ff45fdc98c2ce7884d5fa4e7f9199b
 	github.com/VictoriaMetrics/fasthttp v1.1.0
 	github.com/VictoriaMetrics/metrics v1.23.0
-	github.com/VictoriaMetrics/metricsql v0.47.0
+	github.com/VictoriaMetrics/metricsql v0.49.0
 	github.com/aws/aws-sdk-go-v2 v1.17.1
 	github.com/aws/aws-sdk-go-v2/config v1.18.1
 	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.40
diff --git a/go.sum b/go.sum
index 62b35fb06..9ecd8cf86 100644
--- a/go.sum
+++ b/go.sum
@@ -100,8 +100,8 @@ github.com/VictoriaMetrics/fasthttp v1.1.0/go.mod h1:/7DMcogqd+aaD3G3Hg5kFgoFwlR
 github.com/VictoriaMetrics/metrics v1.18.1/go.mod h1:ArjwVz7WpgpegX/JpB0zpNF2h2232kErkEnzH1sxMmA=
 github.com/VictoriaMetrics/metrics v1.23.0 h1:WzfqyzCaxUZip+OBbg1+lV33WChDSu4ssYII3nxtpeA=
 github.com/VictoriaMetrics/metrics v1.23.0/go.mod h1:rAr/llLpEnAdTehiNlUxKgnjcOuROSzpw0GvjpEbvFc=
-github.com/VictoriaMetrics/metricsql v0.47.0 h1:PQwadjoQnKKkaUiupkDq0ZbCAHX2qP8OOexJ9oJwupo=
-github.com/VictoriaMetrics/metricsql v0.47.0/go.mod h1:6pP1ZeLVJHqJrHlF6Ij3gmpQIznSsgktEcZgsAWYel0=
+github.com/VictoriaMetrics/metricsql v0.49.0 h1:7R04eab3gU0PKu8Ksak7SJnORXm0K+hSGt2+t3XGyKg=
+github.com/VictoriaMetrics/metricsql v0.49.0/go.mod h1:6pP1ZeLVJHqJrHlF6Ij3gmpQIznSsgktEcZgsAWYel0=
 github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
 github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
 github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
diff --git a/lib/promscrape/discovery/gce/api.go b/lib/promscrape/discovery/gce/api.go
index fdf373901..fc6c51438 100644
--- a/lib/promscrape/discovery/gce/api.go
+++ b/lib/promscrape/discovery/gce/api.go
@@ -58,7 +58,9 @@ func newAPIConfig(sdc *SDConfig) (*apiConfig, error) {
 		logger.Infof("autodetected the current GCE zone: %q", zone)
 	} else if len(zones) == 1 && zones[0] == "*" {
 		// Autodetect zones for project.
-		zs, err := getZonesForProject(client, project, sdc.Filter)
+		// Do not pass sdc.Filter when discovering zones, since GCE doesn't support it.
+		// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3202
+		zs, err := getZonesForProject(client, project)
 		if err != nil {
 			return nil, fmt.Errorf("cannot obtain zones for project %q: %w", project, err)
 		}
diff --git a/lib/promscrape/discovery/gce/zone.go b/lib/promscrape/discovery/gce/zone.go
index b949bab9e..b3144656c 100644
--- a/lib/promscrape/discovery/gce/zone.go
+++ b/lib/promscrape/discovery/gce/zone.go
@@ -6,13 +6,13 @@ import (
 	"net/http"
 )
 
-func getZonesForProject(client *http.Client, project, filter string) ([]string, error) {
+func getZonesForProject(client *http.Client, project string) ([]string, error) {
 	// See https://cloud.google.com/compute/docs/reference/rest/v1/zones
 	zonesURL := fmt.Sprintf("https://compute.googleapis.com/compute/v1/projects/%s/zones", project)
 	var zones []string
 	pageToken := ""
 	for {
-		data, err := getAPIResponse(client, zonesURL, filter, pageToken)
+		data, err := getAPIResponse(client, zonesURL, "", pageToken)
 		if err != nil {
 			return nil, fmt.Errorf("cannot obtain zones: %w", err)
 		}
diff --git a/vendor/github.com/VictoriaMetrics/metricsql/lexer.go b/vendor/github.com/VictoriaMetrics/metricsql/lexer.go
index 947da2c53..c1a0ec69b 100644
--- a/vendor/github.com/VictoriaMetrics/metricsql/lexer.go
+++ b/vendor/github.com/VictoriaMetrics/metricsql/lexer.go
@@ -149,6 +149,73 @@ func scanString(s string) (string, error) {
 	}
 }
 
+func parsePositiveNumber(s string) (float64, error) {
+	if isSpecialIntegerPrefix(s) {
+		n, err := strconv.ParseInt(s, 0, 64)
+		if err != nil {
+			return 0, err
+		}
+		return float64(n), nil
+	}
+	s = strings.ToLower(s)
+	m := float64(1)
+	switch true {
+	case strings.HasSuffix(s, "kib"):
+		s = s[:len(s)-3]
+		m = 1024
+	case strings.HasSuffix(s, "ki"):
+		s = s[:len(s)-2]
+		m = 1024
+	case strings.HasSuffix(s, "kb"):
+		s = s[:len(s)-2]
+		m = 1000
+	case strings.HasSuffix(s, "k"):
+		s = s[:len(s)-1]
+		m = 1000
+	case strings.HasSuffix(s, "mib"):
+		s = s[:len(s)-3]
+		m = 1024 * 1024
+	case strings.HasSuffix(s, "mi"):
+		s = s[:len(s)-2]
+		m = 1024 * 1024
+	case strings.HasSuffix(s, "mb"):
+		s = s[:len(s)-2]
+		m = 1000 * 1000
+	case strings.HasSuffix(s, "m"):
+		s = s[:len(s)-1]
+		m = 1000 * 1000
+	case strings.HasSuffix(s, "gib"):
+		s = s[:len(s)-3]
+		m = 1024 * 1024 * 1024
+	case strings.HasSuffix(s, "gi"):
+		s = s[:len(s)-2]
+		m = 1024 * 1024 * 1024
+	case strings.HasSuffix(s, "gb"):
+		s = s[:len(s)-2]
+		m = 1000 * 1000 * 1000
+	case strings.HasSuffix(s, "g"):
+		s = s[:len(s)-1]
+		m = 1000 * 1000 * 1000
+	case strings.HasSuffix(s, "tib"):
+		s = s[:len(s)-3]
+		m = 1024 * 1024 * 1024 * 1024
+	case strings.HasSuffix(s, "ti"):
+		s = s[:len(s)-2]
+		m = 1024 * 1024 * 1024 * 1024
+	case strings.HasSuffix(s, "tb"):
+		s = s[:len(s)-2]
+		m = 1000 * 1000 * 1000 * 1000
+	case strings.HasSuffix(s, "t"):
+		s = s[:len(s)-1]
+		m = 1000 * 1000 * 1000 * 1000
+	}
+	v, err := strconv.ParseFloat(s, 64)
+	if err != nil {
+		return 0, err
+	}
+	return v * m, nil
+}
+
 func scanPositiveNumber(s string) (string, error) {
 	// Scan integer part. It may be empty if fractional part exists.
 	i := 0
@@ -171,7 +238,14 @@ func scanPositiveNumber(s string) (string, error) {
 		}
 		return s, nil
 	}
+	if sLen := scanNumMultiplier(s[i:]); sLen > 0 {
+		i += sLen
+		return s[:i], nil
+	}
 	if s[i] != '.' && s[i] != 'e' && s[i] != 'E' {
+		if i == 0 {
+			return "", fmt.Errorf("missing positive number")
+		}
 		return s[:i], nil
 	}
 
@@ -182,14 +256,15 @@ func scanPositiveNumber(s string) (string, error) {
 		for j < len(s) && isDecimalChar(s[j]) {
 			j++
 		}
-		if j == i {
-			return "", fmt.Errorf("missing fractional part in %q", s)
-		}
 		i = j
 		if i == len(s) {
 			return s, nil
 		}
 	}
+	if sLen := scanNumMultiplier(s[i:]); sLen > 0 {
+		i += sLen
+		return s[:i], nil
+	}
 
 	if s[i] != 'e' && s[i] != 'E' {
 		return s[:i], nil
@@ -213,6 +288,46 @@ func scanPositiveNumber(s string) (string, error) {
 	return s[:j], nil
 }
 
+func scanNumMultiplier(s string) int {
+	s = strings.ToLower(s)
+	switch true {
+	case strings.HasPrefix(s, "kib"):
+		return 3
+	case strings.HasPrefix(s, "ki"):
+		return 2
+	case strings.HasPrefix(s, "kb"):
+		return 2
+	case strings.HasPrefix(s, "k"):
+		return 1
+	case strings.HasPrefix(s, "mib"):
+		return 3
+	case strings.HasPrefix(s, "mi"):
+		return 2
+	case strings.HasPrefix(s, "mb"):
+		return 2
+	case strings.HasPrefix(s, "m"):
+		return 1
+	case strings.HasPrefix(s, "gib"):
+		return 3
+	case strings.HasPrefix(s, "gi"):
+		return 2
+	case strings.HasPrefix(s, "gb"):
+		return 2
+	case strings.HasPrefix(s, "g"):
+		return 1
+	case strings.HasPrefix(s, "tib"):
+		return 3
+	case strings.HasPrefix(s, "ti"):
+		return 2
+	case strings.HasPrefix(s, "tb"):
+		return 2
+	case strings.HasPrefix(s, "t"):
+		return 1
+	default:
+		return 0
+	}
+}
+
 func scanIdent(s string) string {
 	i := 0
 	for i < len(s) {
diff --git a/vendor/github.com/VictoriaMetrics/metricsql/optimizer.go b/vendor/github.com/VictoriaMetrics/metricsql/optimizer.go
index e56659b86..3a432e63e 100644
--- a/vendor/github.com/VictoriaMetrics/metricsql/optimizer.go
+++ b/vendor/github.com/VictoriaMetrics/metricsql/optimizer.go
@@ -386,7 +386,7 @@ func getTransformArgIdxForOptimization(funcName string, args []Expr) int {
 		return -1
 	}
 	switch funcName {
-	case "", "absent", "scalar", "union", "vector":
+	case "", "absent", "scalar", "union", "vector", "range_normalize":
 		return -1
 	case "end", "now", "pi", "ru", "start", "step", "time":
 		return -1
diff --git a/vendor/github.com/VictoriaMetrics/metricsql/parser.go b/vendor/github.com/VictoriaMetrics/metricsql/parser.go
index ac64fc438..9b92dbabb 100644
--- a/vendor/github.com/VictoriaMetrics/metricsql/parser.go
+++ b/vendor/github.com/VictoriaMetrics/metricsql/parser.go
@@ -479,24 +479,19 @@ func (p *parser) parsePositiveNumberExpr() (*NumberExpr, error) {
 	if !isPositiveNumberPrefix(p.lex.Token) && !isInfOrNaN(p.lex.Token) {
 		return nil, fmt.Errorf(`positiveNumberExpr: unexpected token %q; want "number"`, p.lex.Token)
 	}
-	var ne NumberExpr
-	if isSpecialIntegerPrefix(p.lex.Token) {
-		in, err := strconv.ParseInt(p.lex.Token, 0, 64)
-		if err != nil {
-			return nil, fmt.Errorf(`positiveNumberExpr: cannot parse integer %q: %s`, p.lex.Token, err)
-		}
-		ne.N = float64(in)
-	} else {
-		n, err := strconv.ParseFloat(p.lex.Token, 64)
-		if err != nil {
-			return nil, fmt.Errorf(`positiveNumberExpr: cannot parse %q: %s`, p.lex.Token, err)
-		}
-		ne.N = n
+	s := p.lex.Token
+	n, err := parsePositiveNumber(s)
+	if err != nil {
+		return nil, fmt.Errorf(`positivenumberExpr: cannot parse %q: %s`, s, err)
 	}
 	if err := p.lex.Next(); err != nil {
 		return nil, err
 	}
-	return &ne, nil
+	ne := &NumberExpr{
+		N: n,
+		s: s,
+	}
+	return ne, nil
 }
 
 func (p *parser) parseStringExpr() (*StringExpr, error) {
@@ -1499,10 +1494,16 @@ func (se *StringExpr) AppendString(dst []byte) []byte {
 type NumberExpr struct {
 	// N is the parsed number, i.e. `1.23`, `-234`, etc.
 	N float64
+
+	// s contains the original string representation for N.
+	s string
 }
 
 // AppendString appends string representation of ne to dst and returns the result.
 func (ne *NumberExpr) AppendString(dst []byte) []byte {
+	if ne.s != "" {
+		return append(dst, ne.s...)
+	}
 	return strconv.AppendFloat(dst, ne.N, 'g', -1, 64)
 }
 
diff --git a/vendor/github.com/VictoriaMetrics/metricsql/transform.go b/vendor/github.com/VictoriaMetrics/metricsql/transform.go
index 40d315832..5876c8290 100644
--- a/vendor/github.com/VictoriaMetrics/metricsql/transform.go
+++ b/vendor/github.com/VictoriaMetrics/metricsql/transform.go
@@ -76,6 +76,7 @@ var transformFuncs = map[string]bool{
 	"range_linear_regression":    true,
 	"range_max":                  true,
 	"range_min":                  true,
+	"range_normalize":            true,
 	"range_quantile":             true,
 	"range_stddev":               true,
 	"range_stdvar":               true,
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 17b3e4dce..c172a7d07 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -69,7 +69,7 @@ github.com/VictoriaMetrics/fasthttp/stackless
 # github.com/VictoriaMetrics/metrics v1.23.0
 ## explicit; go 1.15
 github.com/VictoriaMetrics/metrics
-# github.com/VictoriaMetrics/metricsql v0.47.0
+# github.com/VictoriaMetrics/metricsql v0.49.0
 ## explicit; go 1.13
 github.com/VictoriaMetrics/metricsql
 github.com/VictoriaMetrics/metricsql/binaryop


Test patch:
diff --git a/app/vmselect/promql/exec_test.go b/app/vmselect/promql/exec_test.go
index 8930f6363..49eb8d540 100644
--- a/app/vmselect/promql/exec_test.go
+++ b/app/vmselect/promql/exec_test.go
@@ -6902,6 +6902,23 @@ func TestExecSuccess(t *testing.T) {
 		resultExpected := []netstorage.Result{r}
 		f(q, resultExpected)
 	})
+	t.Run(`range_normalize(time(),alias(-time(),"negative"))`, func(t *testing.T) {
+		t.Parallel()
+		q := `range_normalize(time(),alias(-time(), "negative"))`
+		r1 := netstorage.Result{
+			MetricName: metricNameExpected,
+			Values:     []float64{0, 0.2, 0.4, 0.6, 0.8, 1},
+			Timestamps: timestampsExpected,
+		}
+		r2 := netstorage.Result{
+			MetricName: metricNameExpected,
+			Values:     []float64{1, 0.8, 0.6, 0.4, 0.2, 0},
+			Timestamps: timestampsExpected,
+		}
+		r2.MetricName.MetricGroup = []byte("negative")
+		resultExpected := []netstorage.Result{r1, r2}
+		f(q, resultExpected)
+	})
 	t.Run(`range_first(time())`, func(t *testing.T) {
 		t.Parallel()
 		q := `range_first(time())`