vmui: memory leak fix (#4455)

* fix: optimize the preparation of data for the graph

* fix: optimize tooltip rendering

* fix: optimize re-rendering of the chart

* vmui: memory leak fix
This commit is contained in:
Yury Molodov 2023-06-20 11:29:24 +02:00 committed by GitHub
parent 7b2748e7a1
commit 66b42a6772
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 227 additions and 185 deletions

View file

@ -73,10 +73,9 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
}); });
}; };
const throttledSetScale = useCallback(throttle(setScale, 500), []); const throttledSetScale = useCallback(throttle(setScale, 500), []);
const setPlotScale = ({ u, min, max }: { u: uPlot, min: number, max: number }) => { const setPlotScale = ({ min, max }: { min: number, max: number }) => {
const delta = (max - min) * 1000; const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return; if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
u.setScale("x", { min, max });
setXRange({ min, max }); setXRange({ min, max });
throttledSetScale({ min, max }); throttledSetScale({ min, max });
}; };
@ -112,7 +111,7 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor; const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange; const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange; const max = min + nxRange;
u.batch(() => setPlotScale({ u, min, max })); u.batch(() => setPlotScale({ min, max }));
}); });
}; };
@ -126,7 +125,6 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
e.preventDefault(); e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1); const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1);
setPlotScale({ setPlotScale({
u: uPlotInst,
min: xRange.min + factor, min: xRange.min + factor,
max: xRange.max - factor max: xRange.max - factor
}); });
@ -241,7 +239,7 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
(u) => { (u) => {
const min = u.posToVal(u.select.left, "x"); const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x"); const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ u, min, max }); setPlotScale({ min, max });
} }
] ]
}, },
@ -295,7 +293,6 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
const zoomFactor = dur / 50 * dir; const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({ uPlotInst.batch(() => setPlotScale({
u: uPlotInst,
min: min + zoomFactor, min: min + zoomFactor,
max: max - zoomFactor max: max - zoomFactor
})); }));

View file

@ -17,11 +17,11 @@ import useEventListener from "../../../../hooks/useEventListener";
export interface ChartTooltipProps { export interface ChartTooltipProps {
id: string, id: string,
u: uPlot, u: uPlot,
metrics: MetricResult[], metricItem: MetricResult,
series: SeriesItem[], seriesItem: SeriesItem,
yRange: number[];
unit?: string, unit?: string,
isSticky?: boolean, isSticky?: boolean,
showQueryNum?: boolean,
tooltipOffset: { left: number, top: number }, tooltipOffset: { left: number, top: number },
tooltipIdx: { seriesIdx: number, dataIdx: number }, tooltipIdx: { seriesIdx: number, dataIdx: number },
onClose?: (id: string) => void onClose?: (id: string) => void
@ -31,12 +31,12 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
u, u,
id, id,
unit = "", unit = "",
metrics, metricItem,
series, seriesItem,
yRange,
tooltipIdx, tooltipIdx,
tooltipOffset, tooltipOffset,
isSticky, isSticky,
showQueryNum,
onClose onClose
}) => { }) => {
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
@ -49,21 +49,17 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
const [dataIdx, setDataIdx] = useState(tooltipIdx.dataIdx); const [dataIdx, setDataIdx] = useState(tooltipIdx.dataIdx);
const value = get(u, ["data", seriesIdx, dataIdx], 0); const value = get(u, ["data", seriesIdx, dataIdx], 0);
const valueFormat = formatPrettyNumber(value, get(yRange, [0]), get(yRange, [1])); const valueFormat = formatPrettyNumber(value, get(u, ["scales", "1", "min"], 0), get(u, ["scales", "1", "max"], 1));
const dataTime = u.data[0][dataIdx]; const dataTime = u.data[0][dataIdx];
const date = dayjs(dataTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT); const date = dayjs(dataTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const color = series[seriesIdx]?.stroke+""; const color = `${seriesItem?.stroke}`;
const calculations = seriesItem?.calculations || {};
const calculations = series[seriesIdx]?.calculations || {}; const group = metricItem?.group || 0;
const groups = new Set(metrics.map(m => m.group));
const showQueryNum = groups.size > 1;
const group = metrics[seriesIdx-1]?.group || 0;
const fullMetricName = useMemo(() => { const fullMetricName = useMemo(() => {
const metric = metrics[seriesIdx-1]?.metric || {}; const metric = metricItem?.metric || {};
const labelNames = Object.keys(metric).filter(x => x != "__name__"); const labelNames = Object.keys(metric).filter(x => x != "__name__");
const labels = labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`); const labels = labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`);
let metricName = metric["__name__"] || ""; let metricName = metric["__name__"] || "";
@ -71,7 +67,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
metricName += "{" + labels.join(",") + "}"; metricName += "{" + labels.join(",") + "}";
} }
return metricName; return metricName;
}, [metrics, seriesIdx]); }, [metricItem]);
const handleClose = () => { const handleClose = () => {
onClose && onClose(id); onClose && onClose(id);
@ -97,7 +93,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
const calcPosition = () => { const calcPosition = () => {
if (!tooltipRef.current) return; if (!tooltipRef.current) return;
const topOnChart = u.valToPos((value || 0), series[seriesIdx]?.scale || "1"); const topOnChart = u.valToPos((value || 0), seriesItem?.scale || "1");
const leftOnChart = u.valToPos(dataTime, "x"); const leftOnChart = u.valToPos(dataTime, "x");
const { width: tooltipWidth, height: tooltipHeight } = tooltipRef.current.getBoundingClientRect(); const { width: tooltipWidth, height: tooltipHeight } = tooltipRef.current.getBoundingClientRect();
const { width, height } = u.over.getBoundingClientRect(); const { width, height } = u.over.getBoundingClientRect();
@ -142,9 +138,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
> >
<div className="vm-chart-tooltip-header"> <div className="vm-chart-tooltip-header">
<div className="vm-chart-tooltip-header__date"> <div className="vm-chart-tooltip-header__date">
{showQueryNum && ( {showQueryNum && (<div>Query {group}</div>)}
<div>Query {group}</div>
)}
{date} {date}
</div> </div>
{isSticky && ( {isSticky && (

View file

@ -1,9 +1,8 @@
import React, { FC, useState, useMemo } from "preact/compat"; import React, { FC, useMemo } from "preact/compat";
import { MouseEvent } from "react"; import { MouseEvent } from "react";
import { LegendItemType } from "../../../../../utils/uplot/types"; import { LegendItemType } from "../../../../../utils/uplot/types";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import Tooltip from "../../../../Main/Tooltip/Tooltip";
import { getFreeFields } from "./helpers"; import { getFreeFields } from "./helpers";
import useCopyToClipboard from "../../../../../hooks/useCopyToClipboard"; import useCopyToClipboard from "../../../../../hooks/useCopyToClipboard";
@ -15,7 +14,6 @@ interface LegendItemProps {
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => { const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
const copyToClipboard = useCopyToClipboard(); const copyToClipboard = useCopyToClipboard();
const [copiedValue, setCopiedValue] = useState("");
const freeFormFields = useMemo(() => { const freeFormFields = useMemo(() => {
const result = getFreeFields(legend); const result = getFreeFields(legend);
@ -25,20 +23,17 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
const calculations = legend.calculations; const calculations = legend.calculations;
const showCalculations = Object.values(calculations).some(v => v); const showCalculations = Object.values(calculations).some(v => v);
const handleClickFreeField = async (val: string, id: string) => { const handleClickFreeField = async (val: string) => {
const copied = await copyToClipboard(val); await copyToClipboard(val, `${val} has been copied`);
if (!copied) return;
setCopiedValue(id);
setTimeout(() => setCopiedValue(""), 2000);
}; };
const createHandlerClick = (legend: LegendItemType) => (e: MouseEvent<HTMLDivElement>) => { const createHandlerClick = (legend: LegendItemType) => (e: MouseEvent<HTMLDivElement>) => {
onChange && onChange(legend, e.ctrlKey || e.metaKey); onChange && onChange(legend, e.ctrlKey || e.metaKey);
}; };
const createHandlerCopy = (freeField: string, id: string) => (e: MouseEvent<HTMLDivElement>) => { const createHandlerCopy = (freeField: string) => (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation(); e.stopPropagation();
handleClickFreeField(freeField, id); handleClickFreeField(freeField);
}; };
return ( return (
@ -62,21 +57,14 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, isHeatmap }) => {
{legend.freeFormFields["__name__"]} {legend.freeFormFields["__name__"]}
{!!freeFormFields.length && <>&#123;</>} {!!freeFormFields.length && <>&#123;</>}
{freeFormFields.map((f, i) => ( {freeFormFields.map((f, i) => (
<Tooltip
key={f.id}
open={copiedValue === f.id}
title={"copied!"}
placement="top-center"
>
<span <span
className="vm-legend-item-info__free-fields" className="vm-legend-item-info__free-fields"
key={f.key} key={f.key}
onClick={createHandlerCopy(f.freeField, f.id)} onClick={createHandlerCopy(f.freeField)}
title="copy to clipboard" title="copy to clipboard"
> >
{f.freeField}{i + 1 < freeFormFields.length && ","} {f.freeField}{i + 1 < freeFormFields.length && ","}
</span> </span>
</Tooltip>
))} ))}
{!!freeFormFields.length && <>&#125;</>} {!!freeFormFields.length && <>&#125;</>}
</span> </span>

View file

@ -1,18 +1,14 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat"; import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import uPlot, { import uPlot, {
AlignedData as uPlotData, AlignedData as uPlotData,
Options as uPlotOptions, Options as uPlotOptions,
Series as uPlotSeries, Series as uPlotSeries,
Range,
Scales,
Scale,
} from "uplot"; } from "uplot";
import { defaultOptions } from "../../../../utils/uplot/helpers"; import { defaultOptions } from "../../../../utils/uplot/helpers";
import { dragChart } from "../../../../utils/uplot/events"; import { dragChart } from "../../../../utils/uplot/events";
import { getAxes, getMinMaxBuffer } from "../../../../utils/uplot/axes"; import { getAxes } from "../../../../utils/uplot/axes";
import { MetricResult } from "../../../../api/types"; import { MetricResult } from "../../../../api/types";
import { dateFromSeconds, formatDateForNativeInput, limitsDurations } from "../../../../utils/time"; import { dateFromSeconds, formatDateForNativeInput, limitsDurations } from "../../../../utils/time";
import throttle from "lodash.throttle";
import { TimeParams } from "../../../../types"; import { TimeParams } from "../../../../types";
import { YaxisState } from "../../../../state/graph/reducer"; import { YaxisState } from "../../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
@ -24,6 +20,7 @@ import { useAppState } from "../../../../state/common/StateContext";
import { SeriesItem } from "../../../../utils/uplot/series"; import { SeriesItem } from "../../../../utils/uplot/series";
import { ElementSize } from "../../../../hooks/useElementSize"; import { ElementSize } from "../../../../hooks/useElementSize";
import useEventListener from "../../../../hooks/useEventListener"; import useEventListener from "../../../../hooks/useEventListener";
import { getRangeX, getRangeY, getScales } from "../../../../utils/uplot/scales";
export interface LineChartProps { export interface LineChartProps {
metrics: MetricResult[]; metrics: MetricResult[];
@ -37,8 +34,6 @@ export interface LineChartProps {
height?: number; height?: number;
} }
enum typeChartUpdate {xRange = "xRange", yRange = "yRange"}
const LineChart: FC<LineChartProps> = ({ const LineChart: FC<LineChartProps> = ({
data, data,
series, series,
@ -55,7 +50,6 @@ const LineChart: FC<LineChartProps> = ({
const uPlotRef = useRef<HTMLDivElement>(null); const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false); const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({ min: period.start, max: period.end }); const [xRange, setXRange] = useState({ min: period.start, max: period.end });
const [yRange, setYRange] = useState([0, 1]);
const [uPlotInst, setUPlotInst] = useState<uPlot>(); const [uPlotInst, setUPlotInst] = useState<uPlot>();
const [startTouchDistance, setStartTouchDistance] = useState(0); const [startTouchDistance, setStartTouchDistance] = useState(0);
@ -63,24 +57,18 @@ const LineChart: FC<LineChartProps> = ({
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 }); const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 }); const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]); const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
const tooltipId = useMemo(() => `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`, [tooltipIdx]);
const setScale = ({ min, max }: { min: number, max: number }): void => { const setPlotScale = ({ min, max }: { min: number, max: number }) => {
const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
setXRange({ min, max });
setPeriod({ setPeriod({
from: dayjs(min * 1000).toDate(), from: dayjs(min * 1000).toDate(),
to: dayjs(max * 1000).toDate() to: dayjs(max * 1000).toDate()
}); });
}; };
const throttledSetScale = useCallback(throttle(setScale, 500), []);
const setPlotScale = ({ u, min, max }: { u: uPlot, min: number, max: number }) => {
const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
u.setScale("x", { min, max });
setXRange({ min, max });
throttledSetScale({ min, max });
};
const onReadyChart = (u: uPlot) => { const onReadyChart = (u: uPlot): void => {
const factor = 0.9; const factor = 0.9;
setTooltipOffset({ setTooltipOffset({
left: parseFloat(u.over.style.left), left: parseFloat(u.over.style.left),
@ -111,7 +99,7 @@ const LineChart: FC<LineChartProps> = ({
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor; const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange; const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange; const max = min + nxRange;
u.batch(() => setPlotScale({ u, min, max })); u.batch(() => setPlotScale({ min, max }));
}); });
}; };
@ -125,33 +113,41 @@ const LineChart: FC<LineChartProps> = ({
e.preventDefault(); e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1); const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1);
setPlotScale({ setPlotScale({
u: uPlotInst,
min: xRange.min + factor, min: xRange.min + factor,
max: xRange.max - factor max: xRange.max - factor
}); });
} }
}, [uPlotInst, xRange]); }, [uPlotInst, xRange]);
const handleClick = useCallback(() => { const getChartProps = useCallback(() => {
if (!showTooltip) return; const { seriesIdx, dataIdx } = tooltipIdx;
const id = `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`; const id = `${seriesIdx}_${dataIdx}`;
const props = { const metricItem = metrics[seriesIdx-1];
const seriesItem = series[seriesIdx] as SeriesItem;
const groups = new Set(metrics.map(m => m.group));
const showQueryNum = groups.size > 1;
return {
id, id,
unit, unit,
series, seriesItem,
metrics, metricItem,
yRange,
tooltipIdx, tooltipIdx,
tooltipOffset, tooltipOffset,
showQueryNum,
}; };
}, [uPlotInst, metrics, series, tooltipIdx, tooltipOffset, unit]);
if (!stickyTooltips.find(t => t.id === id)) { const handleClick = useCallback(() => {
const tooltipProps = JSON.parse(JSON.stringify(props)); if (!showTooltip) return;
setStickyToolTips(prev => [...prev, tooltipProps]); const props = getChartProps();
if (!stickyTooltips.find(t => t.id === props.id)) {
setStickyToolTips(prev => [...prev, props as ChartTooltipProps]);
} }
}, [metrics, series, stickyTooltips, tooltipIdx, tooltipOffset, showTooltip, unit, yRange]); }, [getChartProps, stickyTooltips, showTooltip]);
const handleUnStick = (id:string) => { const handleUnStick = (id: string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id)); setStickyToolTips(prev => prev.filter(t => t.id !== id));
}; };
@ -165,23 +161,34 @@ const LineChart: FC<LineChartProps> = ({
setTooltipIdx(prev => ({ ...prev, seriesIdx })); setTooltipIdx(prev => ({ ...prev, seriesIdx }));
}; };
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max]; const addSeries = (u: uPlot, series: uPlotSeries[]) => {
series.forEach((s) => {
const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => { u.addSeries(s);
if (axis == "1") { });
setYRange([min, max]);
}
if (yaxis.limits.enable) return yaxis.limits.range[axis];
return getMinMaxBuffer(min, max);
}; };
const getScales = (): Scales => { const delSeries = (u: uPlot) => {
const scales: { [key: string]: { range: Scale.Range } } = { x: { range: getRangeX } }; for (let i = u.series.length - 1; i >= 0; i--) {
const ranges = Object.keys(yaxis.limits.range); u.delSeries(i);
(ranges.length ? ranges : ["1"]).forEach(axis => { }
scales[axis] = { range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis) }; };
const delHooks = (u: uPlot) => {
Object.keys(u.hooks).forEach(hook => {
u.hooks[hook as keyof uPlot.Hooks.Arrays] = [];
}); });
return scales; };
const handleDestroy = (u: uPlot) => {
delSeries(u);
delHooks(u);
u.setData([]);
};
const setSelect = (u: uPlot) => {
const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ min, max });
}; };
const options: uPlotOptions = { const options: uPlotOptions = {
@ -189,49 +196,18 @@ const LineChart: FC<LineChartProps> = ({
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(), tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
series, series,
axes: getAxes( [{}, { scale: "1" }], unit), axes: getAxes( [{}, { scale: "1" }], unit),
scales: { ...getScales() }, scales: getScales(yaxis, xRange),
width: layoutSize.width || 400, width: layoutSize.width || 400,
height: height || 500, height: height || 500,
plugins: [{ hooks: { ready: onReadyChart, setCursor, setSeries: seriesFocus } }],
hooks: { hooks: {
setSelect: [ ready: [onReadyChart],
(u) => { setSeries: [seriesFocus],
const min = u.posToVal(u.select.left, "x"); setCursor: [setCursor],
const max = u.posToVal(u.select.left + u.select.width, "x"); setSelect: [setSelect],
setPlotScale({ u, min, max }); destroy: [handleDestroy],
} },
]
}
}; };
const updateChart = (type: typeChartUpdate): void => {
if (!uPlotInst) return;
switch (type) {
case typeChartUpdate.xRange:
uPlotInst.scales.x.range = getRangeX;
break;
case typeChartUpdate.yRange:
Object.keys(yaxis.limits.range).forEach(axis => {
if (!uPlotInst.scales[axis]) return;
uPlotInst.scales[axis].range = (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis);
});
break;
}
if (!isPanning) uPlotInst.redraw();
};
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);
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef.current, series, layoutSize, height, isDarkTheme]);
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return; if (e.touches.length !== 2) return;
e.preventDefault(); e.preventDefault();
@ -257,19 +233,63 @@ const LineChart: FC<LineChartProps> = ({
const zoomFactor = dur / 50 * dir; const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({ uPlotInst.batch(() => setPlotScale({
u: uPlotInst,
min: min + zoomFactor, min: min + zoomFactor,
max: max - zoomFactor max: max - zoomFactor
})); }));
}, [uPlotInst, startTouchDistance, xRange]); }, [uPlotInst, startTouchDistance, xRange]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]); useEffect(() => {
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]); setXRange({ min: period.start, max: period.end });
}, [period]);
useEffect(() => { useEffect(() => {
const show = tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1; setStickyToolTips([]);
setShowTooltip(show); setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
}, [tooltipIdx, stickyTooltips]); if (!uPlotRef.current) return;
if (uPlotInst) uPlotInst.destroy();
const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef, isDarkTheme]);
useEffect(() => {
if (!uPlotInst) return;
uPlotInst.setData(data);
uPlotInst.redraw();
}, [data]);
useEffect(() => {
if (!uPlotInst) return;
delSeries(uPlotInst);
addSeries(uPlotInst, series);
uPlotInst.redraw();
}, [series]);
useEffect(() => {
if (!uPlotInst) return;
Object.keys(yaxis.limits.range).forEach(axis => {
if (!uPlotInst.scales[axis]) return;
uPlotInst.scales[axis].range = (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis, yaxis);
});
uPlotInst.redraw();
}, [yaxis]);
useEffect(() => {
if (!uPlotInst) return;
uPlotInst.scales.x.range = () => getRangeX(xRange);
uPlotInst.redraw();
}, [xRange]);
useEffect(() => {
if (!uPlotInst) return;
uPlotInst.setSize({ width: layoutSize.width || 400, height: height || 500 });
uPlotInst.redraw();
}, [height, layoutSize]);
useEffect(() => {
setShowTooltip(tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1);
}, [tooltipIdx]);
useEventListener("click", handleClick); useEventListener("click", handleClick);
useEventListener("keydown", handleKeyDown); useEventListener("keydown", handleKeyDown);
@ -293,14 +313,8 @@ const LineChart: FC<LineChartProps> = ({
/> />
{uPlotInst && showTooltip && ( {uPlotInst && showTooltip && (
<ChartTooltip <ChartTooltip
unit={unit} {...getChartProps()}
u={uPlotInst} u={uPlotInst}
series={series as SeriesItem[]}
metrics={metrics}
yRange={yRange}
tooltipIdx={tooltipIdx}
tooltipOffset={tooltipOffset}
id={tooltipId}
/> />
)} )}

View file

@ -4,7 +4,6 @@ import "./style.scss";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ExoticComponent } from "react"; import { ExoticComponent } from "react";
import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useEventListener from "../../../hooks/useEventListener";
interface TooltipProps { interface TooltipProps {
children: ReactNode children: ReactNode
@ -30,7 +29,6 @@ const Tooltip: FC<TooltipProps> = ({
const popperRef = useRef<HTMLDivElement>(null); const popperRef = useRef<HTMLDivElement>(null);
const onScrollWindow = () => setIsOpen(false); const onScrollWindow = () => setIsOpen(false);
useEventListener("scroll", onScrollWindow);
useEffect(() => { useEffect(() => {
if (!popperRef.current || !isOpen) return; if (!popperRef.current || !isOpen) return;
@ -38,6 +36,11 @@ const Tooltip: FC<TooltipProps> = ({
width: popperRef.current.clientWidth, width: popperRef.current.clientWidth,
height: popperRef.current.clientHeight height: popperRef.current.clientHeight
}); });
window.addEventListener("scroll", onScrollWindow);
return () => {
window.removeEventListener("scroll", onScrollWindow);
};
}, [isOpen]); }, [isOpen]);
const popperStyle = useMemo(() => { const popperStyle = useMemo(() => {

View file

@ -1,10 +1,15 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from "preact/compat"; import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import { MetricResult } from "../../../api/types"; import { MetricResult } from "../../../api/types";
import LineChart from "../../Chart/Line/LineChart/LineChart"; import LineChart from "../../Chart/Line/LineChart/LineChart";
import { AlignedData as uPlotData, Series as uPlotSeries } from "uplot"; import { AlignedData as uPlotData, Series as uPlotSeries } from "uplot";
import Legend from "../../Chart/Line/Legend/Legend"; import Legend from "../../Chart/Line/Legend/Legend";
import LegendHeatmap from "../../Chart/Heatmap/LegendHeatmap/LegendHeatmap"; import LegendHeatmap from "../../Chart/Heatmap/LegendHeatmap/LegendHeatmap";
import { getHideSeries, getLegendItem, getSeriesItemContext, SeriesItem } from "../../../utils/uplot/series"; import {
getHideSeries,
getLegendItem,
getSeriesItemContext,
SeriesItem
} from "../../../utils/uplot/series";
import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes"; import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes";
import { LegendItemType } from "../../../utils/uplot/types"; import { LegendItemType } from "../../../utils/uplot/types";
import { TimeParams } from "../../../types"; import { TimeParams } from "../../../types";
@ -56,7 +61,6 @@ const GraphView: FC<GraphViewProps> = ({
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]); const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
const data = useMemo(() => normalizeData(dataRaw, isHistogram), [isHistogram, dataRaw]); const data = useMemo(() => normalizeData(dataRaw, isHistogram), [isHistogram, dataRaw]);
const getSeriesItem = useCallback(getSeriesItemContext(), [data]);
const [dataChart, setDataChart] = useState<uPlotData>([[]]); const [dataChart, setDataChart] = useState<uPlotData>([[]]);
const [series, setSeries] = useState<uPlotSeries[]>([]); const [series, setSeries] = useState<uPlotSeries[]>([]);
@ -64,6 +68,10 @@ const GraphView: FC<GraphViewProps> = ({
const [hideSeries, setHideSeries] = useState<string[]>([]); const [hideSeries, setHideSeries] = useState<string[]>([]);
const [legendValue, setLegendValue] = useState<TooltipHeatmapProps | null>(null); const [legendValue, setLegendValue] = useState<TooltipHeatmapProps | null>(null);
const getSeriesItem = useMemo(() => {
return getSeriesItemContext(data, hideSeries, alias);
}, [data, hideSeries, alias]);
const setLimitsYaxis = (values: {[key: string]: number[]}) => { const setLimitsYaxis = (values: {[key: string]: number[]}) => {
const limits = getLimitsYAxis(values, !isHistogram); const limits = getLimitsYAxis(values, !isHistogram);
setYaxisLimits(limits); setYaxisLimits(limits);
@ -73,10 +81,6 @@ const GraphView: FC<GraphViewProps> = ({
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series })); setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
}; };
const handleChangeLegend = (val: TooltipHeatmapProps) => {
setLegendValue(val);
};
const prepareHistogramData = (data: (number | null)[][]) => { const prepareHistogramData = (data: (number | null)[][]) => {
const values = data.slice(1, data.length); const values = data.slice(1, data.length);
const xs: (number | null | undefined)[] = []; const xs: (number | null | undefined)[] = [];
@ -105,8 +109,9 @@ const GraphView: FC<GraphViewProps> = ({
const tempLegend: LegendItemType[] = []; const tempLegend: LegendItemType[] = [];
const tempSeries: uPlotSeries[] = [{}]; const tempSeries: uPlotSeries[] = [{}];
data?.forEach((d) => { data?.forEach((d, i) => {
const seriesItem = getSeriesItem(d, hideSeries, alias); const seriesItem = getSeriesItem(d, i);
tempSeries.push(seriesItem); tempSeries.push(seriesItem);
tempLegend.push(getLegendItem(seriesItem, d.group)); tempLegend.push(getLegendItem(seriesItem, d.group));
const tmpValues = tempValues[d.group] || []; const tmpValues = tempValues[d.group] || [];
@ -156,8 +161,8 @@ const GraphView: FC<GraphViewProps> = ({
useEffect(() => { useEffect(() => {
const tempLegend: LegendItemType[] = []; const tempLegend: LegendItemType[] = [];
const tempSeries: uPlotSeries[] = [{}]; const tempSeries: uPlotSeries[] = [{}];
data?.forEach(d => { data?.forEach((d, i) => {
const seriesItem = getSeriesItem(d, hideSeries, alias); const seriesItem = getSeriesItem(d, i);
tempSeries.push(seriesItem); tempSeries.push(seriesItem);
tempLegend.push(getLegendItem(seriesItem, d.group)); tempLegend.push(getLegendItem(seriesItem, d.group));
}); });
@ -199,7 +204,7 @@ const GraphView: FC<GraphViewProps> = ({
setPeriod={setPeriod} setPeriod={setPeriod}
layoutSize={containerSize} layoutSize={containerSize}
height={height} height={height}
onChangeLegend={handleChangeLegend} onChangeLegend={setLegendValue}
/> />
)} )}
{!isHistogram && showLegend && ( {!isHistogram && showLegend && (

View file

@ -51,6 +51,7 @@
font-weight: bold; font-weight: bold;
text-transform: capitalize; text-transform: capitalize;
text-align: left; text-align: left;
overflow-wrap: normal;
} }
&_gray { &_gray {

View file

@ -17,7 +17,7 @@ export const dragChart = ({ e, factor = 0.85, u, setPanning, setPlotScale }: Dra
const clientX = isMouseEvent ? e.clientX : e.touches[0].clientX; const clientX = isMouseEvent ? e.clientX : e.touches[0].clientX;
const dx = xUnitsPerPx * ((clientX - leftStart) * factor); const dx = xUnitsPerPx * ((clientX - leftStart) * factor);
setPlotScale({ u, min: scXMin - dx, max: scXMax - dx }); setPlotScale({ min: scXMin - dx, max: scXMax - dx });
}; };
const mouseUp = () => { const mouseUp = () => {
setPanning(false); setPanning(false);

View file

@ -145,17 +145,22 @@ const sortBucketsByValues = (a: MetricResult, b: MetricResult) => getUpperBound(
export const normalizeData = (buckets: MetricResult[], isHistogram?: boolean): MetricResult[] => { export const normalizeData = (buckets: MetricResult[], isHistogram?: boolean): MetricResult[] => {
if (!isHistogram) return buckets; if (!isHistogram) return buckets;
const sortedBuckets = buckets.sort(sortBucketsByValues); const sortedBuckets = buckets.sort(sortBucketsByValues);
const vmBuckets = convertPrometheusToVictoriaMetrics(sortedBuckets); const vmBuckets = convertPrometheusToVictoriaMetrics(sortedBuckets);
const allValues = vmBuckets.map(b => b.values).flat();
// Compute total hits for each timestamp upfront
const totalHitsPerTimestamp: { [timestamp: number]: number } = {};
vmBuckets.forEach(bucket =>
bucket.values.forEach(([timestamp, value]) => {
totalHitsPerTimestamp[timestamp] = (totalHitsPerTimestamp[timestamp] || 0) + +value;
})
);
const result = vmBuckets.map(bucket => { const result = vmBuckets.map(bucket => {
const values = bucket.values.map((v) => { const values = bucket.values.map(([timestamp, value]) => {
const totalHits = allValues const totalHits = totalHitsPerTimestamp[timestamp];
.filter(av => av[0] === v[0]) return [timestamp, `${Math.round((+value / totalHits) * 100)}`];
.reduce((bucketSum, v) => bucketSum + +v[1], 0);
return [v[0], `${Math.round((+v[1] / totalHits) * 100)}`];
}); });
return { ...bucket, values }; return { ...bucket, values };

View file

@ -0,0 +1,26 @@
import uPlot, { Range, Scale, Scales } from "uplot";
import { getMinMaxBuffer } from "./axes";
import { YaxisState } from "../../state/graph/reducer";
interface XRangeType {
min: number,
max: number
}
export const getRangeX = (xRange: XRangeType): Range.MinMax => {
return [xRange.min, xRange.max];
};
export const getRangeY = (u: uPlot, min = 0, max = 1, axis: string, yaxis: YaxisState): Range.MinMax => {
if (yaxis.limits.enable) return yaxis.limits.range[axis];
return getMinMaxBuffer(min, max);
};
export const getScales = (yaxis: YaxisState, xRange: XRangeType): Scales => {
const scales: { [key: string]: { range: Scale.Range } } = { x: { range: () => getRangeX(xRange) } };
const ranges = Object.keys(yaxis.limits.range);
(ranges.length ? ranges : ["1"]).forEach(axis => {
scales[axis] = { range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis, yaxis) };
});
return scales;
};

View file

@ -17,26 +17,34 @@ export interface SeriesItem extends Series {
} }
} }
export const getSeriesItemContext = () => { export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[]) => {
const colorState: {[key: string]: string} = {}; const colorState: {[key: string]: string} = {};
const calculations = data.map(d => {
return (d: MetricResult, hideSeries: string[], alias: string[]): SeriesItem => {
const label = getNameForMetric(d, alias[d.group - 1]);
const countSavedColors = Object.keys(colorState).length;
const hasBasicColors = countSavedColors < baseContrastColors.length;
if (hasBasicColors) colorState[label] = colorState[label] || baseContrastColors[countSavedColors];
const values = d.values.map(v => promValueToNumber(v[1])); const values = d.values.map(v => promValueToNumber(v[1]));
const min = getMinFromArray(values); return {
const max = getMaxFromArray(values); min: getMinFromArray(values),
const median = getMedianFromArray(values); max: getMaxFromArray(values),
const last = getLastFromArray(values); median: getMedianFromArray(values),
last: getLastFromArray(values),
};
});
const maxColors = Math.min(data.length, baseContrastColors.length);
for (let i = 0; i < maxColors; i++) {
const label = getNameForMetric(data[i], alias[data[i].group - 1]);
colorState[label] = baseContrastColors[i];
}
return (d: MetricResult, i: number): SeriesItem => {
const label = getNameForMetric(d, alias[d.group - 1]);
const color = colorState[label] || getColorFromString(label);
const { min, max, median, last } = calculations[i];
return { return {
label, label,
freeFormFields: d.metric, freeFormFields: d.metric,
width: 1.4, width: 1.4,
stroke: colorState[label] || getColorFromString(label), stroke: color,
show: !includesHideSeries(label, hideSeries), show: !includesHideSeries(label, hideSeries),
scale: "1", scale: "1",
points: { points: {

View file

@ -12,7 +12,7 @@ export interface DragArgs {
u: uPlot, u: uPlot,
factor: number, factor: number,
setPanning: (enable: boolean) => void, setPanning: (enable: boolean) => void,
setPlotScale: ({ u, min, max }: { u: uPlot, min: number, max: number }) => void setPlotScale: ({ min, max }: { min: number, max: number }) => void
} }
export interface LegendItemType { export interface LegendItemType {

View file

@ -42,6 +42,7 @@ The following tip changes can be tested by building VictoriaMetrics components f
* BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent.html): fixed service name detection for [consulagent service discovery](https://docs.victoriametrics.com/sd_configs.html?highlight=consulagent#consulagent_sd_configs) in case of a difference in service name and service id. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4390) for details. * BUGFIX: [vmagent](https://docs.victoriametrics.com/vmagent.html): fixed service name detection for [consulagent service discovery](https://docs.victoriametrics.com/sd_configs.html?highlight=consulagent#consulagent_sd_configs) in case of a difference in service name and service id. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4390) for details.
* BUGFIX: [vmbackupmanager](https://docs.victoriametrics.com/vmbackupmanager.html): fix an issue with `vmbackupmanager` not being able to restore data from a backup stored in GCS. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4420) for details. * BUGFIX: [vmbackupmanager](https://docs.victoriametrics.com/vmbackupmanager.html): fix an issue with `vmbackupmanager` not being able to restore data from a backup stored in GCS. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4420) for details.
* BUGFIX: [storage](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html): Properly creates `parts.json` after migration from versions below `v1.90.0. It must fix errors on start-up after unclean shutdown. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4336) for details. * BUGFIX: [storage](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html): Properly creates `parts.json` after migration from versions below `v1.90.0. It must fix errors on start-up after unclean shutdown. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4336) for details.
* BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): fix a memory leak issue associated with chart updates. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/4455).
## [v1.91.2](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.91.2) ## [v1.91.2](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.91.2)