mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
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:
parent
229b39ac7d
commit
86a98fa131
26 changed files with 965 additions and 49 deletions
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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
1
app/vmselect/vmui/static/css/main.e0a028b9.css
Normal file
1
app/vmselect/vmui/static/css/main.e0a028b9.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
app/vmselect/vmui/static/js/main.e5645090.js
Normal file
2
app/vmselect/vmui/static/js/main.e5645090.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -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 {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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") && (
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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})`);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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())
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
151
app/vmui/packages/vmui/src/utils/uplot/heatmap.ts
Normal file
151
app/vmui/packages/vmui/src/utils/uplot/heatmap.ts
Normal 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[];
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -51,3 +51,5 @@ export interface Fill {
|
|||
unit: number,
|
||||
values: (u: { data: number[][]; }) => string[],
|
||||
}
|
||||
|
||||
export type ArrayRGB = [number, number, number]
|
||||
|
|
|
@ -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).
|
||||
|
|
Loading…
Reference in a new issue