vmui: heatmap (#3780)

* fix: add stroke and font for all axes

* feat: add util for generate gradient

* feat: add heatmap plugin

* feat: add heatmap legend

* feat: add heatmap graph (#3384)

* vmui: add heatmap graph (#3384)

* feat: add convert Prometheus to VictoriaMetrics histogram

* fix: prevent re-render graph

* feat: reset step for heatmap

* feat: normalize heatmap data

* fix: format heatmap legend

* wip

* app/vmselect/vmui: run `make vmui-update`

---------

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2023-03-26 09:30:02 +02:00 committed by Aliaksandr Valialkin
parent 229b39ac7d
commit 86a98fa131
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
26 changed files with 965 additions and 49 deletions

View file

@ -1,14 +1,14 @@
{
"files": {
"main.css": "./static/css/main.2c709f8b.css",
"main.js": "./static/js/main.34430dca.js",
"main.css": "./static/css/main.e0a028b9.css",
"main.js": "./static/js/main.e5645090.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"static/media/Lato-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf",
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.2c709f8b.css",
"static/js/main.34430dca.js"
"static/css/main.e0a028b9.css",
"static/js/main.e5645090.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.34430dca.js"></script><link href="./static/css/main.2c709f8b.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.e5645090.js"></script><link href="./static/css/main.e0a028b9.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -51,6 +51,13 @@ $chart-tooltip-y: -1 * ($padding-small + $chart-tooltip-half-icon);
color: $color-white;
cursor: move;
}
&__date {
&_range {
display: grid;
gap: 2px;
}
}
}
&-data {

View file

@ -0,0 +1,160 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot from "uplot";
import ReactDOM from "react-dom";
import Button from "../../Main/Button/Button";
import { CloseIcon, DragIcon } from "../../Main/Icons";
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent } from "react";
import "../ChartTooltip/style.scss";
export interface TooltipHeatmapProps {
cursor: {left: number, top: number}
startDate: string,
endDate: string,
metricName: string,
fields: string[],
value: number,
valueFormat: string
}
export interface ChartTooltipHeatmapProps extends TooltipHeatmapProps {
id: string,
u: uPlot,
unit?: string,
isSticky?: boolean,
tooltipOffset: { left: number, top: number },
onClose?: (id: string) => void
}
const ChartTooltipHeatmap: FC<ChartTooltipHeatmapProps> = ({
u,
id,
unit = "",
cursor,
tooltipOffset,
isSticky,
onClose,
startDate,
endDate,
metricName,
fields,
valueFormat,
value
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: -999, left: -999 });
const [moving, setMoving] = useState(false);
const [moved, setMoved] = useState(false);
const targetPortal = useMemo(() => u.root.querySelector(".u-wrap"), [u]);
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 = cursor.top;
const leftOnChart = cursor.left;
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, cursor, tooltipOffset, tooltipRef]);
useEffect(() => {
if (moving) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [moving]);
if (!targetPortal || !cursor.left || !cursor.top || !value) 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 vm-chart-tooltip-header__date_range">
<span>{startDate}</span>
<span>{endDate}</span>
</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">
<p>
{metricName}:
<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 ChartTooltipHeatmap;

View file

@ -0,0 +1,383 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot, {
AlignedData as uPlotData,
Options as uPlotOptions,
Range
} from "uplot";
import { defaultOptions, sizeAxis } from "../../../utils/uplot/helpers";
import { dragChart } from "../../../utils/uplot/events";
import { getAxes } from "../../../utils/uplot/axes";
import { MetricResult } from "../../../api/types";
import { dateFromSeconds, formatDateForNativeInput, limitsDurations } from "../../../utils/time";
import throttle from "lodash.throttle";
import useResize from "../../../hooks/useResize";
import { TimeParams } from "../../../types";
import { YaxisState } from "../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css";
import classNames from "classnames";
import dayjs from "dayjs";
import { useAppState } from "../../../state/common/StateContext";
import { heatmapPaths } from "../../../utils/uplot/heatmap";
import { DATE_FULL_TIMEZONE_FORMAT } from "../../../constants/date";
import ChartTooltipHeatmap, {
ChartTooltipHeatmapProps,
TooltipHeatmapProps
} from "../ChartTooltipHeatmap/ChartTooltipHeatmap";
export interface HeatmapChartProps {
metrics: MetricResult[];
data: uPlotData;
period: TimeParams;
yaxis: YaxisState;
unit?: string;
setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
container: HTMLDivElement | null;
height?: number;
onChangeLegend: (val: number) => void;
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange"}
const HeatmapChart: FC<HeatmapChartProps> = ({
data,
metrics = [],
period,
yaxis,
unit,
setPeriod,
container,
height,
onChangeLegend,
}) => {
const { isDarkTheme } = useAppState();
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 [startTouchDistance, setStartTouchDistance] = useState(0);
const layoutSize = useResize(container);
const [tooltipProps, setTooltipProps] = useState<TooltipHeatmapProps | null>(null);
const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipHeatmapProps[]>([]);
const tooltipId = useMemo(() => {
return `${tooltipProps?.fields.join(",")}_${tooltipProps?.startDate}`;
}, [tooltipProps]);
const setScale = ({ min, max }: { min: number, max: number }): void => {
if (isNaN(min) || isNaN(max)) return;
setPeriod({
from: dayjs(min * 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 factor = 0.9;
setTooltipOffset({
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
});
u.over.addEventListener("mousedown", e => {
const { ctrlKey, metaKey, button } = e;
const leftClick = button === 0;
const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
if (leftClickWithMeta) {
// drag pan
dragChart({ u, e, setPanning, setPlotScale, factor });
}
});
u.over.addEventListener("touchstart", e => {
dragChart({ u, e, setPanning, setPlotScale, factor });
});
u.over.addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const { width } = u.over.getBoundingClientRect();
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const xVal = u.posToVal(zoomPos, "x");
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange;
u.batch(() => setPlotScale({ u, min, max }));
});
};
const handleKeyDown = (e: KeyboardEvent) => {
const { target, ctrlKey, metaKey, key } = e;
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (!uPlotInst || isInput) return;
const minus = key === "-";
const plus = key === "+" || key === "=";
if ((minus || plus) && !(ctrlKey || metaKey)) {
e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1);
setPlotScale({
u: uPlotInst,
min: xRange.min + factor,
max: xRange.max - factor
});
}
};
const handleClick = () => {
if (!tooltipProps) return;
const id = `${tooltipProps?.fields.join(",")}_${tooltipProps?.startDate}`;
const props = {
id,
unit,
tooltipOffset,
...tooltipProps
};
if (!stickyTooltips.find(t => t.id === id)) {
const res = JSON.parse(JSON.stringify(props));
setStickyToolTips(prev => [...prev, res]);
}
};
const handleUnStick = (id:string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id));
};
const setCursor = (u: uPlot) => {
const left = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const top = u.cursor.top && u.cursor.top > 0 ? u.cursor.top : 0;
const xArr = (u.data[1][0] || []) as number[];
if (!Array.isArray(xArr)) return;
const xVal = u.posToVal(left, "x");
const yVal = u.posToVal(top, "y");
const xIdx = xArr.findIndex((t, i) => xVal >= t && xVal < xArr[i + 1]) || -1;
const second = xArr[xIdx + 1];
const result = metrics[Math.round(yVal)];
if (!result) {
setTooltipProps(null);
return;
}
const metric = result?.metric;
const metricName = metric["__name__"] || "value";
const labelNames = Object.keys(metric).filter(x => x != "__name__");
const fields = labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`);
const [endTime = 0, value = ""] = result.values.find(v => v[0] === second) || [];
const valueFormat = `${+value}%`;
const startTime = xArr[xIdx];
const startDate = dayjs(startTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const endDate = dayjs(endTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
setTooltipProps({
cursor: { left, top },
startDate,
endDate,
metricName,
fields,
value: +value,
valueFormat: valueFormat,
});
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
const axes = getAxes( [{}], unit);
const options: uPlotOptions = {
...defaultOptions,
mode: 2,
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
series: [
{},
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
paths: heatmapPaths(),
facets: [
{
scale: "x",
auto: true,
sorted: 1,
},
{
scale: "y",
auto: true,
},
],
},
],
axes: [
...axes,
{
scale: "y",
stroke: axes[0].stroke,
font: axes[0].font,
size: sizeAxis,
splits: metrics.map((m, i) => i),
values: metrics.map(m => Object.entries(m.metric).map(e => `${e[0]}=${JSON.stringify(e[1])}`)[0]),
}
],
scales: {
x: {
time: true,
},
y: {
log: 2,
time: false,
range: (self, initMin, initMax) => [initMin - 1, initMax + 1]
}
},
width: layoutSize.width || 400,
height: height || 500,
plugins: [{ hooks: { ready: onReadyChart, setCursor } }],
hooks: {
setSelect: [
(u) => {
const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ u, min, max });
}
]
},
};
const updateChart = (type: typeChartUpdate): void => {
if (!uPlotInst) return;
switch (type) {
case typeChartUpdate.xRange:
uPlotInst.scales.x.range = getRangeX;
break;
}
if (!isPanning) uPlotInst.redraw();
};
useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
useEffect(() => {
setStickyToolTips([]);
setTooltipProps(null);
if (!uPlotRef.current || !layoutSize.width || !layoutSize.height) return;
const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef.current, layoutSize, height, isDarkTheme, data]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [xRange]);
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
setStartTouchDistance(Math.sqrt(dx * dx + dy * dy));
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || !uPlotInst) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const endTouchDistance = Math.sqrt(dx * dx + dy * dy);
const diffDistance = startTouchDistance - endTouchDistance;
const max = (uPlotInst.scales.x.max || xRange.max);
const min = (uPlotInst.scales.x.min || xRange.min);
const dur = max - min;
const dir = (diffDistance > 0 ? -1 : 1);
const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({
u: uPlotInst,
min: min + zoomFactor,
max: max - zoomFactor
}));
};
useEffect(() => {
window.addEventListener("touchmove", handleTouchMove);
window.addEventListener("touchstart", handleTouchStart);
return () => {
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchstart", handleTouchStart);
};
}, [uPlotInst, startTouchDistance]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
useEffect(() => {
const show = !!tooltipProps?.value;
if (show) window.addEventListener("click", handleClick);
return () => {
window.removeEventListener("click", handleClick);
};
}, [tooltipProps, stickyTooltips]);
useEffect(() => {
onChangeLegend(tooltipProps?.value || 0);
}, [tooltipProps]);
return (
<div
className={classNames({
"vm-line-chart": true,
"vm-line-chart_panning": isPanning
})}
style={{
minWidth: `${layoutSize.width || 400}px`,
minHeight: `${height || 500}px`
}}
>
<div
className="vm-line-chart__u-plot"
ref={uPlotRef}
/>
{uPlotInst && tooltipProps && (
<ChartTooltipHeatmap
{...tooltipProps}
unit={unit}
u={uPlotInst}
tooltipOffset={tooltipOffset}
id={tooltipId}
/>
)}
{uPlotInst && stickyTooltips.map(t => (
<ChartTooltipHeatmap
{...t}
isSticky
u={uPlotInst}
key={t.id}
onClose={handleUnStick}
/>
))}
</div>
);
};
export default HeatmapChart;

View file

@ -0,0 +1,46 @@
import React, { FC, useEffect, useState } from "preact/compat";
import { gradMetal16 } from "../../../utils/uplot/heatmap";
import "./style.scss";
interface LegendHeatmapProps {
min: number
max: number
value?: number
}
const LegendHeatmap: FC<LegendHeatmapProps> = ({ min, max, value }) => {
const [percent, setPercent] = useState(0);
const [valueFormat, setValueFormat] = useState("");
const [minFormat, setMinFormat] = useState("");
const [maxFormat, setMaxFormat] = useState("");
useEffect(() => {
setPercent(value ? (value - min) / (max - min) * 100 : 0);
setValueFormat(value ? `${value}%` : "");
setMinFormat(`${min}%`);
setMaxFormat(`${max}%`);
}, [value, min, max]);
return (
<div className="vm-legend-heatmap">
<div
className="vm-legend-heatmap-gradient"
style={{ background: `linear-gradient(to right, ${gradMetal16.join(", ")})` }}
>
{!!value && (
<div
className="vm-legend-heatmap-gradient__value"
style={{ left: `${percent}%` }}
>
<span>{valueFormat}</span>
</div>
)}
</div>
<div className="vm-legend-heatmap__value">{minFormat}</div>
<div className="vm-legend-heatmap__value">{maxFormat}</div>
</div>
);
};
export default LegendHeatmap;

View file

@ -0,0 +1,55 @@
@use "src/styles/variables" as *;
.vm-legend-heatmap {
display: inline-grid;
grid-template-columns: auto auto;
align-items: center;
justify-content: space-between;
gap: 4px;
&__value {
color: $color-text;
font-size: $font-size-small;
&:last-child {
text-align: right;
}
}
&-gradient {
$gradient-height: $font-size-small;
display: flex;
align-items: center;
justify-content: center;
position: relative;
grid-column: 1/-1;
height: $gradient-height;
width: 200px;
&__value {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -2px;
border-radius: 50%;
border: 2px solid $color-text;
width: calc($gradient-height + 4px);
height: calc($gradient-height + 4px);
transform: translateX(calc(($gradient-height/-2) - 2px));
transition: left 100ms ease;
span {
position: absolute;
top: calc($gradient-height + 6px);
left: auto;
padding: 4px 8px;
color: $color-text;
font-size: $font-size-small;
background-color: $color-background-block;
box-shadow: $box-shadow;
}
}
}
}

View file

@ -36,7 +36,7 @@ export interface LineChartProps {
height?: number;
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange"}
const LineChart: FC<LineChartProps> = ({
data,
@ -215,9 +215,6 @@ const LineChart: FC<LineChartProps> = ({
uPlotInst.scales[axis].range = (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis);
});
break;
case typeChartUpdate.data:
uPlotInst.setData(data);
break;
}
if (!isPanning) uPlotInst.redraw();
};
@ -283,7 +280,6 @@ const LineChart: FC<LineChartProps> = ({
};
}, [uPlotInst, startTouchDistance]);
useEffect(() => updateChart(typeChartUpdate.data), [data]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);

View file

@ -1,10 +1,10 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import { ArrowDownIcon, RestartIcon, TimelineIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import Button from "../../Main/Button/Button";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { ErrorTypes } from "../../../types";
import { supportedDurations } from "../../../utils/time";
import { getStepFromDuration, supportedDurations } from "../../../utils/time";
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
import usePrevious from "../../../hooks/usePrevious";
@ -18,12 +18,15 @@ const StepConfigurator: FC = () => {
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const { customStep: value } = useGraphState();
const { period: { step: defaultStep } } = useTimeState();
const { customStep: value, isHistogram } = useGraphState();
const { period: { step, end, start } } = useTimeState();
const graphDispatch = useGraphDispatch();
const { period: duration } = useTimeState();
const prevDuration = usePrevious(duration.end - duration.start);
const prevDuration = usePrevious(end - start);
const defaultStep = useMemo(() => {
return getStepFromDuration(end - start, isHistogram);
}, [step, isHistogram]);
const [openOptions, setOpenOptions] = useState(false);
const [customStep, setCustomStep] = useState(value || defaultStep);
@ -94,12 +97,16 @@ const StepConfigurator: FC = () => {
}, [defaultStep]);
useEffect(() => {
const dur = duration.end - duration.start;
const dur = end - start;
if (dur === prevDuration || !prevDuration) return;
if (defaultStep) {
handleApply(defaultStep);
}
}, [duration, prevDuration, defaultStep]);
}, [end, start, prevDuration, defaultStep]);
useEffect(() => {
if (step === value || step === defaultStep) handleApply(defaultStep);
}, [isHistogram]);
return (
<div

View file

@ -3,6 +3,7 @@ import { MetricResult } from "../../../api/types";
import LineChart from "../../Chart/LineChart/LineChart";
import { AlignedData as uPlotData, Series as uPlotSeries } from "uplot";
import Legend from "../../Chart/Legend/Legend";
import LegendHeatmap from "../../Chart/LegendHeatmap/LegendHeatmap";
import { getHideSeries, getLegendItem, getSeriesItemContext } from "../../../utils/uplot/series";
import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes";
import { LegendItemType } from "../../../utils/uplot/types";
@ -11,8 +12,10 @@ import { AxisRange, YaxisState } from "../../../state/graph/reducer";
import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math";
import classNames from "classnames";
import { useTimeState } from "../../../state/time/TimeStateContext";
import HeatmapChart from "../../Chart/HeatmapChart/HeatmapChart";
import "./style.scss";
import { promValueToNumber } from "../../../utils/metric";
import { normalizeData } from "../../../utils/uplot/heatmap";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
export interface GraphViewProps {
@ -28,10 +31,11 @@ export interface GraphViewProps {
setPeriod: ({ from, to }: {from: Date, to: Date}) => void
fullWidth?: boolean
height?: number
isHistogram?: boolean
}
const GraphView: FC<GraphViewProps> = ({
data = [],
data: dataRaw = [],
period,
customStep,
query,
@ -42,20 +46,24 @@ const GraphView: FC<GraphViewProps> = ({
setPeriod,
alias = [],
fullWidth = true,
height
height,
isHistogram
}) => {
const { isMobile } = useDeviceDetect();
const { timezone } = useTimeState();
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
const data = useMemo(() => normalizeData(dataRaw, isHistogram), [isHistogram, dataRaw]);
const getSeriesItem = useCallback(getSeriesItemContext(), [data]);
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
const [series, setSeries] = useState<uPlotSeries[]>([]);
const [legend, setLegend] = useState<LegendItemType[]>([]);
const [hideSeries, setHideSeries] = useState<string[]>([]);
const [legendValue, setLegendValue] = useState(0);
const setLimitsYaxis = (values: {[key: string]: number[]}) => {
const limits = getLimitsYAxis(values);
const limits = getLimitsYAxis(values, !isHistogram);
setYaxisLimits(limits);
};
@ -63,6 +71,32 @@ const GraphView: FC<GraphViewProps> = ({
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
};
const handleChangeLegend = (val: number) => {
setLegendValue(val);
};
const prepareHistogramData = (data: (number | null)[][]) => {
const values = data.slice(1, data.length);
const xs: (number | null | undefined)[] = [];
const counts: (number | null | undefined)[] = [];
values.forEach((arr, indexRow) => {
arr.forEach((v, indexValue) => {
const targetIndex = (indexValue * values.length) + indexRow;
counts[targetIndex] = v;
});
});
data[0].forEach(t => {
const arr = new Array(values.length).fill(t);
xs.push(...arr);
});
const ys = new Array(xs.length).fill(0).map((n, i) => i % (values.length));
return [null, [xs, ys, counts]];
};
useEffect(() => {
const tempTimes: number[] = [];
const tempValues: {[key: string]: number[]} = {};
@ -111,10 +145,11 @@ const GraphView: FC<GraphViewProps> = ({
});
timeDataSeries.unshift(timeSeries);
setLimitsYaxis(tempValues);
setDataChart(timeDataSeries as uPlotData);
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
setDataChart(result as uPlotData);
setSeries(tempSeries);
setLegend(tempLegend);
}, [data, timezone]);
}, [data, timezone, isHistogram]);
useEffect(() => {
const tempLegend: LegendItemType[] = [];
@ -139,7 +174,7 @@ const GraphView: FC<GraphViewProps> = ({
})}
ref={containerRef}
>
{containerRef?.current &&
{containerRef?.current && !isHistogram && (
<LineChart
data={dataChart}
series={series}
@ -150,12 +185,35 @@ const GraphView: FC<GraphViewProps> = ({
setPeriod={setPeriod}
container={containerRef?.current}
height={height}
/>}
{showLegend && <Legend
labels={legend}
query={query}
onChange={onChangeLegend}
/>}
/>
)}
{containerRef?.current && isHistogram && (
<HeatmapChart
data={dataChart}
metrics={data}
period={period}
yaxis={yaxis}
unit={unit}
setPeriod={setPeriod}
container={containerRef?.current}
height={height}
onChangeLegend={handleChangeLegend}
/>
)}
{!isHistogram && showLegend && (
<Legend
labels={legend}
query={query}
onChange={onChangeLegend}
/>
)}
{isHistogram && showLegend && (
<LegendHeatmap
min={yaxis.limits.range[1][0] || 0}
max={yaxis.limits.range[1][1] || 0}
value={legendValue}
/>
)}
</div>
);
};

View file

@ -10,6 +10,7 @@ import Trace from "../components/TraceQuery/Trace";
import { useQueryState } from "../state/query/QueryStateContext";
import { useTimeState } from "../state/time/TimeStateContext";
import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext";
import { isHistogramData } from "../utils/metric";
interface FetchQueryParams {
predefinedQuery?: string[]
@ -29,6 +30,7 @@ interface FetchQueryReturn {
queryErrors: (ErrorTypes | string)[],
warning?: string,
traces?: Trace[],
isHistogram: boolean,
}
interface FetchDataParams {
@ -62,6 +64,7 @@ export const useFetchQuery = ({
const [queryErrors, setQueryErrors] = useState<(ErrorTypes | string)[]>([]);
const [warning, setWarning] = useState<string>();
const [fetchQueue, setFetchQueue] = useState<AbortController[]>([]);
const [isHistogram, setIsHistogram] = useState(false);
const fetchData = async ({
fetchUrl,
@ -118,6 +121,7 @@ export const useFetchQuery = ({
const limitText = `Showing ${seriesLimit} series out of ${totalLength} series due to performance reasons. Please narrow down the query, so it returns less series`;
setWarning(totalLength > seriesLimit ? limitText : "");
setIsHistogram(isDisplayChart && isHistogramData(tempData));
isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]);
setTraces(tempTraces);
} catch (e) {
@ -178,5 +182,5 @@ export const useFetchQuery = ({
setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
}, [fetchQueue]);
return { fetchUrl, isLoading, graphData, liveData, error, queryErrors, warning, traces };
return { fetchUrl, isLoading, graphData, liveData, error, queryErrors, warning, traces, isHistogram };
};

View file

@ -42,7 +42,7 @@ const CustomPanel: FC = () => {
const { queryOptions } = useFetchQueryOptions();
const {
isLoading, liveData, graphData, error, queryErrors, warning, traces
isLoading, liveData, graphData, error, queryErrors, warning, traces, isHistogram
} = useFetchQuery({
visible: true,
customStep,
@ -93,6 +93,10 @@ const CustomPanel: FC = () => {
setShowAllSeries(false);
}, [query]);
useEffect(() => {
graphDispatch({ type: "SET_IS_HISTOGRAM", payload: isHistogram });
}, [isHistogram]);
return (
<div
className={classNames({
@ -143,7 +147,7 @@ const CustomPanel: FC = () => {
>
<div className="vm-custom-panel-body-header">
<DisplayTypeSwitch/>
{displayType === "chart" && (
{displayType === "chart" && !isHistogram && (
<GraphSettings
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
@ -168,6 +172,7 @@ const CustomPanel: FC = () => {
setYaxisLimits={setYaxisLimits}
setPeriod={setPeriod}
height={isMobile ? window.innerHeight * 0.5 : 500}
isHistogram={isHistogram}
/>
)}
{liveData && (displayType === "code") && (

View file

@ -14,18 +14,21 @@ export interface YaxisState {
export interface GraphState {
customStep: string
yaxis: YaxisState
isHistogram: boolean
}
export type GraphAction =
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" }
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
| { type: "SET_CUSTOM_STEP", payload: string}
| { type: "SET_IS_HISTOGRAM", payload: boolean }
export const initialGraphState: GraphState = {
customStep: getQueryStringValue("g0.step_input", "") as string,
yaxis: {
limits: { enable: false, range: { "1": [0, 0] } }
}
},
isHistogram: false
};
export function reducer(state: GraphState, action: GraphAction): GraphState {
@ -57,6 +60,11 @@ export function reducer(state: GraphState, action: GraphAction): GraphState {
}
}
};
case "SET_IS_HISTOGRAM":
return {
...state,
isHistogram: action.payload
};
default:
throw new Error();
}

View file

@ -1,3 +1,5 @@
import { ArrayRGB } from "./uplot/types";
export const baseContrastColors = [
"#e54040",
"#32a9dc",
@ -56,3 +58,15 @@ export const getContrastColor = (value: string) => {
const yiq = ((r*299)+(g*587)+(b*114))/1000;
return yiq >= 128 ? "#000000" : "#FFFFFF";
};
export const generateGradient = (start: ArrayRGB, end: ArrayRGB, steps: number) => {
const gradient = [];
for (let i = 0; i < steps; i++) {
const k = (i / (steps - 1));
const r = start[0] + (end[0] - start[0]) * k;
const g = start[1] + (end[1] - start[1]) * k;
const b = start[2] + (end[2] - start[2]) * k;
gradient.push([r,g,b].map(n => Math.round(n)).join(", "));
}
return gradient.map(c => `rgb(${c})`);
};

View file

@ -28,3 +28,17 @@ export const promValueToNumber = (s: string): number => {
return parseFloat(s);
}
};
export const isHistogramData = (result: MetricBase[]) => {
if (result.length < 2) return false;
const histogramNames = ["le", "vmrange"];
return result.every(r => {
const keys = Object.keys(r.metric);
const labels = Object.keys(r.metric).filter(n => !histogramNames.includes(n));
const byName = keys.length > labels.length;
const byLabels = labels.every(l => r.metric[l] === result[0].metric[l]);
return byName && byLabels;
});
};

View file

@ -5,6 +5,7 @@ import { DATE_ISO_FORMAT } from "../constants/date";
import timezones from "../constants/timezones";
const MAX_ITEMS_PER_CHART = window.innerWidth / 4;
const MAX_ITEMS_PER_HISTOGRAM = window.innerWidth / 40;
export const limitsDurations = { min: 1, max: 1.578e+11 }; // min: 1 ms, max: 5 years
@ -83,17 +84,19 @@ export const getSecondsFromDuration = (dur: string) => {
return dayjs.duration(durObject).asSeconds();
};
export const getStepFromDuration = (dur: number, histogram?: boolean) => {
const size = histogram ? MAX_ITEMS_PER_HISTOGRAM : MAX_ITEMS_PER_CHART;
return roundStep(dur / size);
};
export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams => {
const n = (date || dayjs().toDate()).valueOf() / 1000;
const delta = getSecondsFromDuration(dur);
const rawStep = delta / MAX_ITEMS_PER_CHART;
const step = roundStep(rawStep);
return {
start: n - delta,
end: n,
step: step,
step: getStepFromDuration(delta),
date: formatDateToUTC(date || dayjs().toDate())
};
};

View file

@ -19,15 +19,17 @@ const timeValues = [
];
export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(new Set(series.map(s => s.scale))).map(a => {
const font = "10px Arial";
const stroke = getCssVariable("color-text");
const axis = {
scale: a,
show: true,
size: sizeAxis,
stroke: getCssVariable("color-text"),
font: "10px Arial",
stroke,
font,
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
};
if (!a) return { space: 80, values: timeValues, stroke: getCssVariable("color-text") };
if (!a) return { space: 80, values: timeValues, stroke, font };
if (!(Number(a) % 2)) return { ...axis, side: 1 };
return axis;
});
@ -66,12 +68,12 @@ export const getMinMaxBuffer = (min: number | null, max: number | null): [number
return [min - padding, max + padding];
};
export const getLimitsYAxis = (values: { [key: string]: number[] }): AxisRange => {
export const getLimitsYAxis = (values: { [key: string]: number[] }, buffer: boolean): AxisRange => {
const result: AxisRange = {};
const numbers = Object.values(values).flat();
const key = "1";
const min = getMinFromArray(numbers);
const max = getMaxFromArray(numbers);
result[key] = getMinMaxBuffer(min, max);
const min = getMinFromArray(numbers) || 0;
const max = getMaxFromArray(numbers) || 1;
result[key] = buffer ? getMinMaxBuffer(min, max) : [min, max];
return result;
};

View file

@ -0,0 +1,151 @@
import uPlot from "uplot";
import { generateGradient } from "../color";
import { MetricResult } from "../../api/types";
// 16-color gradient from "rgb(246, 226, 219)" to "rgb(127, 39, 4)"
export const gradMetal16 = generateGradient([246, 226, 219], [127, 39, 4], 16);
export const countsToFills = (u: uPlot, seriesIdx: number) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const counts = u.data[seriesIdx][2] as number[];
const palette = gradMetal16;
const hideThreshold = 0;
let minCount = Infinity;
let maxCount = -Infinity;
for (let i = 0; i < counts.length; i++) {
if (counts[i] > hideThreshold) {
minCount = Math.min(minCount, counts[i]);
maxCount = Math.max(maxCount, counts[i]);
}
}
const range = maxCount - minCount;
const paletteSize = palette.length;
const indexedFills = Array(counts.length);
for (let i = 0; i < counts.length; i++) {
indexedFills[i] = counts[i] === 0
? -1
: Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range));
}
return indexedFills;
};
export const heatmapPaths = () => (u: uPlot, seriesIdx: number) => {
const cellGap = Math.round(devicePixelRatio);
uPlot.orient(u, seriesIdx, (
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect
) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const [xs, ys, counts] = u.data[seriesIdx] as [number[], number[], number[]];
const dlen = xs.length;
// fill colors are mapped from interpolating densities / counts along some gradient
// (should be quantized to 64 colors/levels max. e.g. 16)
const fills = countsToFills(u, seriesIdx);
const fillPalette = gradMetal16 ?? [...Array.from(new Set(fills))];
const fillPaths = fillPalette.map(() => new Path2D());
// detect x and y bin qtys by detecting layout repetition in x & y data
const yBinQty = dlen - ys.lastIndexOf(ys[0]);
const xBinQty = dlen / yBinQty;
const yBinIncr = ys[1] - ys[0];
const xBinIncr = xs[yBinQty] - xs[0];
// uniform tile sizes based on zoom level
const xSize = (valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff)) - cellGap;
const ySize = (valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)) + cellGap;
// pre-compute x and y offsets
const cys = ys.slice(0, yBinQty).map((y: number) => {
return Math.round(valToPosY(y, scaleY, yDim, yOff) - ySize / 2);
});
const cxs = Array.from({ length: xBinQty }, (v, i) => {
return Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) - xSize);
});
for (let i = 0; i < dlen; i++) {
// filter out 0 counts and out of view
if (
counts[i] > 0 &&
xs[i] >= (scaleX.min || -Infinity) && xs[i] <= (scaleX.max || Infinity) &&
ys[i] >= (scaleY.min || -Infinity) && ys[i] <= (scaleY.max || Infinity)
) {
const cx = cxs[~~(i / yBinQty)];
const cy = cys[i % yBinQty];
const fillPath = fillPaths[fills[i]];
rect(fillPath, cx, cy, xSize, ySize);
}
}
u.ctx.save();
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
fillPaths.forEach((p, i) => {
u.ctx.fillStyle = fillPalette[i];
u.ctx.fill(p);
});
u.ctx.restore();
});
};
export const convertPrometheusToVictoriaMetrics = (buckets: MetricResult[]): MetricResult[] => {
if (!buckets.every(a => a.metric.le)) return buckets;
const sortedBuckets = buckets.sort((a,b) => parseFloat(a.metric.le) - parseFloat(b.metric.le));
const group = buckets[0]?.group || 1;
let prevBucket: MetricResult = { metric: { le: "0" }, values: [], group };
const result: MetricResult[] = [];
for (const bucket of sortedBuckets) {
const vmrange = `${prevBucket.metric.le}..${bucket.metric.le}`;
const values: [number, string][] = [];
for (const [timestamp, value] of bucket.values) {
const prevVal = prevBucket.values.find(v => v[0] === timestamp)?.[1] || 0;
const newVal = (+value) - (+prevVal);
values.push([timestamp, `${newVal}`]);
}
result.push({ metric: { vmrange }, values, group });
prevBucket = bucket;
}
return result;
};
export const normalizeData = (buckets: MetricResult[], isHistogram?: boolean): MetricResult[] => {
if (!isHistogram) return buckets;
const vmBuckets = convertPrometheusToVictoriaMetrics(buckets);
const allValues = vmBuckets.map(b => b.values).flat();
return vmBuckets.map(bucket => {
const values = bucket.values.map((v) => {
const totalHits = allValues.filter(av => av[0] === v[0]).reduce((bucketSum, v) => bucketSum + +v[1], 0);
return [v[0], `${Math.round((+v[1] / totalHits) * 100)}`];
});
return { ...bucket, values };
}) as MetricResult[];
};

View file

@ -80,7 +80,7 @@ export const sizeAxis = (u: uPlot, values: string[], axisIdx: number, cycleNum:
let axisSize = 6 + (axis?.ticks?.size || 0) + (axis.gap || 0);
const longestVal = (values ?? []).reduce((acc, val) => val.length > acc.length ? val : acc, "");
const longestVal = (values ?? []).reduce((acc, val) => val?.length > acc.length ? val : acc, "");
if (longestVal != "") axisSize += getTextWidth(longestVal, "10px Arial");
return Math.ceil(axisSize);

View file

@ -51,3 +51,5 @@ export interface Fill {
unit: number,
values: (u: { data: number[][]; }) => string[],
}
export type ArrayRGB = [number, number, number]

View file

@ -25,6 +25,7 @@ created by v1.90.0 or newer versions. The solution is to upgrade to v1.90.0 or n
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [VictoriaMetrics remote write protocol](https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol) when [sending / receiving data to / from Kafka](https://docs.victoriametrics.com/vmagent.html#kafka-integration). This protocol allows saving egress network bandwidth costs when sending data from `vmagent` to `Kafka` located in another datacenter or availability zone. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1225).
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add `-kafka.consumer.topic.concurrency` command-line flag. It controls the number of Kafka consumer workers to use by `vmagent`. It should eliminate the need to start multiple `vmagent` instances to improve data transfer rate. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1957).
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [Kafka producer and consumer](https://docs.victoriametrics.com/vmagent.html#kafka-integration) on `arm64` machines. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2271).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): automatically draw a heatmap graph when the query selects a single [histogram](https://docs.victoriametrics.com/keyConcepts.html#histogram). This simplifies analyzing histograms. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3384).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add support for drag'n'drop and paste from clipboard in the "Trace analyzer" page. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3971).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): hide messages longer than 3 lines in the trace. You can view the full message by clicking on the `show more` button. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3971).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to manually input date and time when selecting a time range. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3968).