mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-02-19 15:30:17 +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": {
|
"files": {
|
||||||
"main.css": "./static/css/main.2c709f8b.css",
|
"main.css": "./static/css/main.e0a028b9.css",
|
||||||
"main.js": "./static/js/main.34430dca.js",
|
"main.js": "./static/js/main.e5645090.js",
|
||||||
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.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-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf",
|
||||||
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
|
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
|
||||||
"index.html": "./index.html"
|
"index.html": "./index.html"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.2c709f8b.css",
|
"static/css/main.e0a028b9.css",
|
||||||
"static/js/main.34430dca.js"
|
"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;
|
color: $color-white;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
&_range {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-data {
|
&-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;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
|
enum typeChartUpdate {xRange = "xRange", yRange = "yRange"}
|
||||||
|
|
||||||
const LineChart: FC<LineChartProps> = ({
|
const LineChart: FC<LineChartProps> = ({
|
||||||
data,
|
data,
|
||||||
|
@ -215,9 +215,6 @@ const LineChart: FC<LineChartProps> = ({
|
||||||
uPlotInst.scales[axis].range = (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis);
|
uPlotInst.scales[axis].range = (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case typeChartUpdate.data:
|
|
||||||
uPlotInst.setData(data);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
if (!isPanning) uPlotInst.redraw();
|
if (!isPanning) uPlotInst.redraw();
|
||||||
};
|
};
|
||||||
|
@ -283,7 +280,6 @@ const LineChart: FC<LineChartProps> = ({
|
||||||
};
|
};
|
||||||
}, [uPlotInst, startTouchDistance]);
|
}, [uPlotInst, startTouchDistance]);
|
||||||
|
|
||||||
useEffect(() => updateChart(typeChartUpdate.data), [data]);
|
|
||||||
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
|
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
|
||||||
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
|
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 { ArrowDownIcon, RestartIcon, TimelineIcon } from "../../Main/Icons";
|
||||||
import TextField from "../../Main/TextField/TextField";
|
import TextField from "../../Main/TextField/TextField";
|
||||||
import Button from "../../Main/Button/Button";
|
import Button from "../../Main/Button/Button";
|
||||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||||
import { ErrorTypes } from "../../../types";
|
import { ErrorTypes } from "../../../types";
|
||||||
import { supportedDurations } from "../../../utils/time";
|
import { getStepFromDuration, supportedDurations } from "../../../utils/time";
|
||||||
import { useTimeState } from "../../../state/time/TimeStateContext";
|
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||||
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
|
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
|
||||||
import usePrevious from "../../../hooks/usePrevious";
|
import usePrevious from "../../../hooks/usePrevious";
|
||||||
|
@ -18,12 +18,15 @@ const StepConfigurator: FC = () => {
|
||||||
const appModeEnable = getAppModeEnable();
|
const appModeEnable = getAppModeEnable();
|
||||||
const { isMobile } = useDeviceDetect();
|
const { isMobile } = useDeviceDetect();
|
||||||
|
|
||||||
const { customStep: value } = useGraphState();
|
const { customStep: value, isHistogram } = useGraphState();
|
||||||
const { period: { step: defaultStep } } = useTimeState();
|
const { period: { step, end, start } } = useTimeState();
|
||||||
const graphDispatch = useGraphDispatch();
|
const graphDispatch = useGraphDispatch();
|
||||||
|
|
||||||
const { period: duration } = useTimeState();
|
const prevDuration = usePrevious(end - start);
|
||||||
const prevDuration = usePrevious(duration.end - duration.start);
|
|
||||||
|
const defaultStep = useMemo(() => {
|
||||||
|
return getStepFromDuration(end - start, isHistogram);
|
||||||
|
}, [step, isHistogram]);
|
||||||
|
|
||||||
const [openOptions, setOpenOptions] = useState(false);
|
const [openOptions, setOpenOptions] = useState(false);
|
||||||
const [customStep, setCustomStep] = useState(value || defaultStep);
|
const [customStep, setCustomStep] = useState(value || defaultStep);
|
||||||
|
@ -94,12 +97,16 @@ const StepConfigurator: FC = () => {
|
||||||
}, [defaultStep]);
|
}, [defaultStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dur = duration.end - duration.start;
|
const dur = end - start;
|
||||||
if (dur === prevDuration || !prevDuration) return;
|
if (dur === prevDuration || !prevDuration) return;
|
||||||
if (defaultStep) {
|
if (defaultStep) {
|
||||||
handleApply(defaultStep);
|
handleApply(defaultStep);
|
||||||
}
|
}
|
||||||
}, [duration, prevDuration, defaultStep]);
|
}, [end, start, prevDuration, defaultStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === value || step === defaultStep) handleApply(defaultStep);
|
||||||
|
}, [isHistogram]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { MetricResult } from "../../../api/types";
|
||||||
import LineChart from "../../Chart/LineChart/LineChart";
|
import LineChart from "../../Chart/LineChart/LineChart";
|
||||||
import { AlignedData as uPlotData, Series as uPlotSeries } from "uplot";
|
import { AlignedData as uPlotData, Series as uPlotSeries } from "uplot";
|
||||||
import Legend from "../../Chart/Legend/Legend";
|
import Legend from "../../Chart/Legend/Legend";
|
||||||
|
import LegendHeatmap from "../../Chart/LegendHeatmap/LegendHeatmap";
|
||||||
import { getHideSeries, getLegendItem, getSeriesItemContext } from "../../../utils/uplot/series";
|
import { getHideSeries, getLegendItem, getSeriesItemContext } from "../../../utils/uplot/series";
|
||||||
import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes";
|
import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes";
|
||||||
import { LegendItemType } from "../../../utils/uplot/types";
|
import { LegendItemType } from "../../../utils/uplot/types";
|
||||||
|
@ -11,8 +12,10 @@ import { AxisRange, YaxisState } from "../../../state/graph/reducer";
|
||||||
import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math";
|
import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTimeState } from "../../../state/time/TimeStateContext";
|
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||||
|
import HeatmapChart from "../../Chart/HeatmapChart/HeatmapChart";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import { promValueToNumber } from "../../../utils/metric";
|
import { promValueToNumber } from "../../../utils/metric";
|
||||||
|
import { normalizeData } from "../../../utils/uplot/heatmap";
|
||||||
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
@ -28,10 +31,11 @@ export interface GraphViewProps {
|
||||||
setPeriod: ({ from, to }: {from: Date, to: Date}) => void
|
setPeriod: ({ from, to }: {from: Date, to: Date}) => void
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
height?: number
|
height?: number
|
||||||
|
isHistogram?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GraphView: FC<GraphViewProps> = ({
|
const GraphView: FC<GraphViewProps> = ({
|
||||||
data = [],
|
data: dataRaw = [],
|
||||||
period,
|
period,
|
||||||
customStep,
|
customStep,
|
||||||
query,
|
query,
|
||||||
|
@ -42,20 +46,24 @@ const GraphView: FC<GraphViewProps> = ({
|
||||||
setPeriod,
|
setPeriod,
|
||||||
alias = [],
|
alias = [],
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
height
|
height,
|
||||||
|
isHistogram
|
||||||
}) => {
|
}) => {
|
||||||
const { isMobile } = useDeviceDetect();
|
const { isMobile } = useDeviceDetect();
|
||||||
const { timezone } = useTimeState();
|
const { timezone } = useTimeState();
|
||||||
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
|
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
|
||||||
|
|
||||||
|
const data = useMemo(() => normalizeData(dataRaw, isHistogram), [isHistogram, dataRaw]);
|
||||||
const getSeriesItem = useCallback(getSeriesItemContext(), [data]);
|
const getSeriesItem = useCallback(getSeriesItemContext(), [data]);
|
||||||
|
|
||||||
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
|
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
|
||||||
const [series, setSeries] = useState<uPlotSeries[]>([]);
|
const [series, setSeries] = useState<uPlotSeries[]>([]);
|
||||||
const [legend, setLegend] = useState<LegendItemType[]>([]);
|
const [legend, setLegend] = useState<LegendItemType[]>([]);
|
||||||
const [hideSeries, setHideSeries] = useState<string[]>([]);
|
const [hideSeries, setHideSeries] = useState<string[]>([]);
|
||||||
|
const [legendValue, setLegendValue] = useState(0);
|
||||||
|
|
||||||
const setLimitsYaxis = (values: {[key: string]: number[]}) => {
|
const setLimitsYaxis = (values: {[key: string]: number[]}) => {
|
||||||
const limits = getLimitsYAxis(values);
|
const limits = getLimitsYAxis(values, !isHistogram);
|
||||||
setYaxisLimits(limits);
|
setYaxisLimits(limits);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -63,6 +71,32 @@ const GraphView: FC<GraphViewProps> = ({
|
||||||
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
|
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(() => {
|
useEffect(() => {
|
||||||
const tempTimes: number[] = [];
|
const tempTimes: number[] = [];
|
||||||
const tempValues: {[key: string]: number[]} = {};
|
const tempValues: {[key: string]: number[]} = {};
|
||||||
|
@ -111,10 +145,11 @@ const GraphView: FC<GraphViewProps> = ({
|
||||||
});
|
});
|
||||||
timeDataSeries.unshift(timeSeries);
|
timeDataSeries.unshift(timeSeries);
|
||||||
setLimitsYaxis(tempValues);
|
setLimitsYaxis(tempValues);
|
||||||
setDataChart(timeDataSeries as uPlotData);
|
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
|
||||||
|
setDataChart(result as uPlotData);
|
||||||
setSeries(tempSeries);
|
setSeries(tempSeries);
|
||||||
setLegend(tempLegend);
|
setLegend(tempLegend);
|
||||||
}, [data, timezone]);
|
}, [data, timezone, isHistogram]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tempLegend: LegendItemType[] = [];
|
const tempLegend: LegendItemType[] = [];
|
||||||
|
@ -139,7 +174,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||||
})}
|
})}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{containerRef?.current &&
|
{containerRef?.current && !isHistogram && (
|
||||||
<LineChart
|
<LineChart
|
||||||
data={dataChart}
|
data={dataChart}
|
||||||
series={series}
|
series={series}
|
||||||
|
@ -150,12 +185,35 @@ const GraphView: FC<GraphViewProps> = ({
|
||||||
setPeriod={setPeriod}
|
setPeriod={setPeriod}
|
||||||
container={containerRef?.current}
|
container={containerRef?.current}
|
||||||
height={height}
|
height={height}
|
||||||
/>}
|
/>
|
||||||
{showLegend && <Legend
|
)}
|
||||||
labels={legend}
|
{containerRef?.current && isHistogram && (
|
||||||
query={query}
|
<HeatmapChart
|
||||||
onChange={onChangeLegend}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,6 +10,7 @@ import Trace from "../components/TraceQuery/Trace";
|
||||||
import { useQueryState } from "../state/query/QueryStateContext";
|
import { useQueryState } from "../state/query/QueryStateContext";
|
||||||
import { useTimeState } from "../state/time/TimeStateContext";
|
import { useTimeState } from "../state/time/TimeStateContext";
|
||||||
import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext";
|
import { useCustomPanelState } from "../state/customPanel/CustomPanelStateContext";
|
||||||
|
import { isHistogramData } from "../utils/metric";
|
||||||
|
|
||||||
interface FetchQueryParams {
|
interface FetchQueryParams {
|
||||||
predefinedQuery?: string[]
|
predefinedQuery?: string[]
|
||||||
|
@ -29,6 +30,7 @@ interface FetchQueryReturn {
|
||||||
queryErrors: (ErrorTypes | string)[],
|
queryErrors: (ErrorTypes | string)[],
|
||||||
warning?: string,
|
warning?: string,
|
||||||
traces?: Trace[],
|
traces?: Trace[],
|
||||||
|
isHistogram: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchDataParams {
|
interface FetchDataParams {
|
||||||
|
@ -62,6 +64,7 @@ export const useFetchQuery = ({
|
||||||
const [queryErrors, setQueryErrors] = useState<(ErrorTypes | string)[]>([]);
|
const [queryErrors, setQueryErrors] = useState<(ErrorTypes | string)[]>([]);
|
||||||
const [warning, setWarning] = useState<string>();
|
const [warning, setWarning] = useState<string>();
|
||||||
const [fetchQueue, setFetchQueue] = useState<AbortController[]>([]);
|
const [fetchQueue, setFetchQueue] = useState<AbortController[]>([]);
|
||||||
|
const [isHistogram, setIsHistogram] = useState(false);
|
||||||
|
|
||||||
const fetchData = async ({
|
const fetchData = async ({
|
||||||
fetchUrl,
|
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`;
|
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 : "");
|
setWarning(totalLength > seriesLimit ? limitText : "");
|
||||||
|
|
||||||
|
setIsHistogram(isDisplayChart && isHistogramData(tempData));
|
||||||
isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]);
|
isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]);
|
||||||
setTraces(tempTraces);
|
setTraces(tempTraces);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -178,5 +182,5 @@ export const useFetchQuery = ({
|
||||||
setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
|
setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
|
||||||
}, [fetchQueue]);
|
}, [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 { queryOptions } = useFetchQueryOptions();
|
||||||
const {
|
const {
|
||||||
isLoading, liveData, graphData, error, queryErrors, warning, traces
|
isLoading, liveData, graphData, error, queryErrors, warning, traces, isHistogram
|
||||||
} = useFetchQuery({
|
} = useFetchQuery({
|
||||||
visible: true,
|
visible: true,
|
||||||
customStep,
|
customStep,
|
||||||
|
@ -93,6 +93,10 @@ const CustomPanel: FC = () => {
|
||||||
setShowAllSeries(false);
|
setShowAllSeries(false);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
graphDispatch({ type: "SET_IS_HISTOGRAM", payload: isHistogram });
|
||||||
|
}, [isHistogram]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
|
@ -143,7 +147,7 @@ const CustomPanel: FC = () => {
|
||||||
>
|
>
|
||||||
<div className="vm-custom-panel-body-header">
|
<div className="vm-custom-panel-body-header">
|
||||||
<DisplayTypeSwitch/>
|
<DisplayTypeSwitch/>
|
||||||
{displayType === "chart" && (
|
{displayType === "chart" && !isHistogram && (
|
||||||
<GraphSettings
|
<GraphSettings
|
||||||
yaxis={yaxis}
|
yaxis={yaxis}
|
||||||
setYaxisLimits={setYaxisLimits}
|
setYaxisLimits={setYaxisLimits}
|
||||||
|
@ -168,6 +172,7 @@ const CustomPanel: FC = () => {
|
||||||
setYaxisLimits={setYaxisLimits}
|
setYaxisLimits={setYaxisLimits}
|
||||||
setPeriod={setPeriod}
|
setPeriod={setPeriod}
|
||||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||||
|
isHistogram={isHistogram}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{liveData && (displayType === "code") && (
|
{liveData && (displayType === "code") && (
|
||||||
|
|
|
@ -14,18 +14,21 @@ export interface YaxisState {
|
||||||
export interface GraphState {
|
export interface GraphState {
|
||||||
customStep: string
|
customStep: string
|
||||||
yaxis: YaxisState
|
yaxis: YaxisState
|
||||||
|
isHistogram: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GraphAction =
|
export type GraphAction =
|
||||||
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" }
|
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" }
|
||||||
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
|
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
|
||||||
| { type: "SET_CUSTOM_STEP", payload: string}
|
| { type: "SET_CUSTOM_STEP", payload: string}
|
||||||
|
| { type: "SET_IS_HISTOGRAM", payload: boolean }
|
||||||
|
|
||||||
export const initialGraphState: GraphState = {
|
export const initialGraphState: GraphState = {
|
||||||
customStep: getQueryStringValue("g0.step_input", "") as string,
|
customStep: getQueryStringValue("g0.step_input", "") as string,
|
||||||
yaxis: {
|
yaxis: {
|
||||||
limits: { enable: false, range: { "1": [0, 0] } }
|
limits: { enable: false, range: { "1": [0, 0] } }
|
||||||
}
|
},
|
||||||
|
isHistogram: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export function reducer(state: GraphState, action: GraphAction): GraphState {
|
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:
|
default:
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ArrayRGB } from "./uplot/types";
|
||||||
|
|
||||||
export const baseContrastColors = [
|
export const baseContrastColors = [
|
||||||
"#e54040",
|
"#e54040",
|
||||||
"#32a9dc",
|
"#32a9dc",
|
||||||
|
@ -56,3 +58,15 @@ export const getContrastColor = (value: string) => {
|
||||||
const yiq = ((r*299)+(g*587)+(b*114))/1000;
|
const yiq = ((r*299)+(g*587)+(b*114))/1000;
|
||||||
return yiq >= 128 ? "#000000" : "#FFFFFF";
|
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);
|
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";
|
import timezones from "../constants/timezones";
|
||||||
|
|
||||||
const MAX_ITEMS_PER_CHART = window.innerWidth / 4;
|
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
|
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();
|
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 => {
|
export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams => {
|
||||||
const n = (date || dayjs().toDate()).valueOf() / 1000;
|
const n = (date || dayjs().toDate()).valueOf() / 1000;
|
||||||
|
|
||||||
const delta = getSecondsFromDuration(dur);
|
const delta = getSecondsFromDuration(dur);
|
||||||
const rawStep = delta / MAX_ITEMS_PER_CHART;
|
|
||||||
const step = roundStep(rawStep);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: n - delta,
|
start: n - delta,
|
||||||
end: n,
|
end: n,
|
||||||
step: step,
|
step: getStepFromDuration(delta),
|
||||||
date: formatDateToUTC(date || dayjs().toDate())
|
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 => {
|
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 = {
|
const axis = {
|
||||||
scale: a,
|
scale: a,
|
||||||
show: true,
|
show: true,
|
||||||
size: sizeAxis,
|
size: sizeAxis,
|
||||||
stroke: getCssVariable("color-text"),
|
stroke,
|
||||||
font: "10px Arial",
|
font,
|
||||||
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
|
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 };
|
if (!(Number(a) % 2)) return { ...axis, side: 1 };
|
||||||
return axis;
|
return axis;
|
||||||
});
|
});
|
||||||
|
@ -66,12 +68,12 @@ export const getMinMaxBuffer = (min: number | null, max: number | null): [number
|
||||||
return [min - padding, max + padding];
|
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 result: AxisRange = {};
|
||||||
const numbers = Object.values(values).flat();
|
const numbers = Object.values(values).flat();
|
||||||
const key = "1";
|
const key = "1";
|
||||||
const min = getMinFromArray(numbers);
|
const min = getMinFromArray(numbers) || 0;
|
||||||
const max = getMaxFromArray(numbers);
|
const max = getMaxFromArray(numbers) || 1;
|
||||||
result[key] = getMinMaxBuffer(min, max);
|
result[key] = buffer ? getMinMaxBuffer(min, max) : [min, max];
|
||||||
return result;
|
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);
|
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");
|
if (longestVal != "") axisSize += getTextWidth(longestVal, "10px Arial");
|
||||||
|
|
||||||
return Math.ceil(axisSize);
|
return Math.ceil(axisSize);
|
||||||
|
|
|
@ -51,3 +51,5 @@ export interface Fill {
|
||||||
unit: number,
|
unit: number,
|
||||||
values: (u: { data: number[][]; }) => string[],
|
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 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 `-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: [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): 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): 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).
|
* 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