vmui: graph fixes (#1982)

* fix: remove disabling custom step when zooming

* feat: add a dynamic calc of the width of the graph

* fix: add validate y-axis limits

* fix: correct axis limits for value 0

* fix: change logic create time series

* fix: change types for tooltip

* fix: correct points on the line

* fix: change the logic for set graph width

* fix: stop checking the period when auto-refresh is enabled

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

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2021-12-20 18:37:02 +03:00 committed by GitHub
parent 871528fedb
commit 718c352946
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 76 additions and 43 deletions

View file

@ -1,12 +1,12 @@
{
"files": {
"main.css": "./static/css/main.a33903a8.css",
"main.js": "./static/js/main.4305bd17.js",
"main.js": "./static/js/main.23f635e5.js",
"static/js/27.85f0e2b0.chunk.js": "./static/js/27.85f0e2b0.chunk.js",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.a33903a8.css",
"static/js/main.4305bd17.js"
"static/js/main.23f635e5.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><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><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.4305bd17.js"></script><link href="./static/css/main.a33903a8.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"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><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><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.23f635e5.js"></script><link href="./static/css/main.a33903a8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -15,6 +15,7 @@ const AxesLimitsConfigurator: FC = () => {
const onChangeLimit = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, axis: string, index: number) => {
const newLimits = yaxis.limits.range;
newLimits[axis][index] = +e.target.value;
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
graphDispatch({type: "SET_YAXIS_LIMITS", payload: newLimits});
};
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);

View file

@ -9,7 +9,7 @@ const StepConfigurator: FC = () => {
const {customStep} = useGraphState();
const graphDispatch = useGraphDispatch();
const [error, setError] = useState(false);
const {time: {period: {step}, duration}} = useAppState();
const {time: {period: {step}}} = useAppState();
const onChangeStep = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = +e.target.value;
@ -28,10 +28,6 @@ const StepConfigurator: FC = () => {
graphDispatch({type: "TOGGLE_CUSTOM_STEP"});
};
useEffect(() => {
if (customStep.enable) onChangeEnableStep();
}, [duration]);
useEffect(() => {
if (!customStep.enable) graphDispatch({type: "SET_CUSTOM_STEP", payload: step || 1});
}, [step]);

View file

@ -18,7 +18,7 @@ export const useFetchQuery = (): {
liveData?: InstantMetricResult[],
error?: ErrorTypes | string,
} => {
const {query, displayType, serverUrl, time: {period}, queryControls: {nocache}} = useAppState();
const {query, displayType, serverUrl, time: {period}, queryControls: {nocache, autoRefresh}} = useAppState();
const {basicData, bearerData, authMethod} = useAuthState();
const {customStep} = useGraphState();
@ -37,7 +37,7 @@ export const useFetchQuery = (): {
}, [error]);
const needUpdateData = useMemo(() => {
if (!prevPeriod) return true;
if (!prevPeriod || autoRefresh) return true;
const duration = (prevPeriod.end - prevPeriod.start) / 3;
const factorLimit = duration / (period.end - period.start) >= 0.7;
const maxLimit = period.end > (prevPeriod.end + duration);

View file

@ -16,7 +16,7 @@ const HomeLayout: FC = () => {
const {isLoading, liveData, graphData, error} = useFetchQuery();
return (
<>
<Box id="homeLayout">
<AppBar position="static">
<Toolbar>
<Box display="flex">
@ -78,7 +78,7 @@ const HomeLayout: FC = () => {
</Box>}
</Box>
</Box>
</>
</Box>
);
};

View file

@ -1,14 +1,14 @@
import React, {FC, useEffect, useState} from "react";
import React, {FC, useEffect, useMemo, useState} from "react";
import {MetricResult} from "../../../api/types";
import LineChart from "../../LineChart/LineChart";
import {AlignedData as uPlotData, Series as uPlotSeries} from "uplot";
import Legend from "../../Legend/Legend";
import {useGraphDispatch} from "../../../state/graph/GraphStateContext";
import {useGraphDispatch, useGraphState} from "../../../state/graph/GraphStateContext";
import {getHideSeries, getLegendItem, getSeriesItem} from "../../../utils/uplot/series";
import {getLimitsYAxis, getTimeSeries} from "../../../utils/uplot/axes";
import {LegendItem} from "../../../utils/uplot/types";
import {AxisRange} from "../../../state/graph/reducer";
import GraphSettings from "../Configurator/Graph/GraphSettings";
import {useAppState} from "../../../state/common/StateContext";
export interface GraphViewProps {
data?: MetricResult[];
@ -16,16 +16,17 @@ export interface GraphViewProps {
const GraphView: FC<GraphViewProps> = ({data = []}) => {
const graphDispatch = useGraphDispatch();
const {time: {period}} = useAppState();
const { customStep } = useGraphState();
const currentStep = useMemo(() => customStep.enable ? customStep.value : period.step || 1, [period.step, customStep]);
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
const [series, setSeries] = useState<uPlotSeries[]>([]);
const [legend, setLegend] = useState<LegendItem[]>([]);
const [hideSeries, setHideSeries] = useState<string[]>([]);
const [valuesLimit, setValuesLimit] = useState<AxisRange>({"1": [0, 1]});
const setLimitsYaxis = (values: {[key: string]: number[]}) => {
const limits = getLimitsYAxis(values);
setValuesLimit(limits);
graphDispatch({type: "SET_YAXIS_LIMITS", payload: limits});
};
@ -50,9 +51,12 @@ const GraphView: FC<GraphViewProps> = ({data = []}) => {
});
});
const timeSeries = getTimeSeries(tempTimes);
const timeSeries = getTimeSeries(tempTimes, currentStep, period);
setDataChart([timeSeries, ...data.map(d => {
return new Array(timeSeries.length).fill(1).map((v, i) => d.values[i] ? +d.values[i][1] : null);
return timeSeries.map(t => {
const value = d.values.find(v => v[0] === t);
return value ? +value[1] : null;
});
})] as uPlotData);
setLimitsYaxis(tempValues);
@ -79,7 +83,7 @@ const GraphView: FC<GraphViewProps> = ({data = []}) => {
{(data.length > 0)
? <div>
<GraphSettings/>
<LineChart data={dataChart} series={series} metrics={data} limits={valuesLimit}/>
<LineChart data={dataChart} series={series} metrics={data}/>
<Legend labels={legend} onChange={onChangeLegend}/>
</div>
: <div style={{textAlign: "center"}}>No data to show</div>}

View file

@ -11,29 +11,28 @@ import {limitsDurations} from "../../utils/time";
import throttle from "lodash.throttle";
import "uplot/dist/uPlot.min.css";
import "./tooltip.css";
import {AxisRange} from "../../state/graph/reducer";
import useResize from "../../hooks/useResize";
export interface LineChartProps {
metrics: MetricResult[];
data: uPlotData;
series: uPlotSeries[];
limits: AxisRange;
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) => {
const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
const dispatch = useAppDispatch();
const {time: {period}} = useAppState();
const {yaxis} = useGraphState();
const containerRef = useRef<HTMLDivElement>(null);
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({min: period.start, max: period.end});
const [uPlotInst, setUPlotInst] = useState<uPlot>();
const layoutSize = useResize(document.getElementById("homeLayout"));
const tooltip = document.createElement("div");
tooltip.className = "u-tooltip";
const tooltipIdx = {seriesIdx: 1, dataIdx: 0};
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = {seriesIdx: null, dataIdx: undefined};
const tooltipOffset = {left: 0, top: 0};
const setScale = ({min, max}: { min: number, max: number }): void => {
@ -73,22 +72,22 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) =>
const setCursor = (u: uPlot) => {
if (tooltipIdx.dataIdx === u.cursor.idx) return;
tooltipIdx.dataIdx = u.cursor.idx || 0;
if (tooltipIdx.seriesIdx && tooltipIdx.dataIdx) {
if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset});
}
};
const seriesFocus = (u: uPlot, sidx: (number | null)) => {
if (tooltipIdx.seriesIdx === sidx) return;
tooltipIdx.seriesIdx = sidx || 0;
sidx && tooltipIdx.dataIdx
tooltipIdx.seriesIdx = sidx;
sidx && tooltipIdx.dataIdx !== undefined
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset})
: tooltip.style.display = "none";
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => {
if (yaxis.limits.enable) return yaxis.limits.range[axis];
return min && max ? [min - (min * 0.05), max + (max * 0.05)] : limits[axis];
return min && max ? [min - (min * 0.25), max + (max * 0.25)] : [-1, 1];
};
const getScales = (): Scales => {
@ -104,7 +103,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) =>
series,
axes: getAxes(series),
scales: {...getScales()},
width: containerRef.current ? containerRef.current.offsetWidth : 400,
width: layoutSize.width ? layoutSize.width - 64 : 400,
plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}],
};
@ -135,13 +134,13 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) =>
setUPlotInst(u);
setXRange({min: period.start, max: period.end});
return u.destroy;
}, [uPlotRef.current, series]);
}, [uPlotRef.current, series, layoutSize]);
useEffect(() => updateChart(typeChartUpdate.data), [data]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
return <div ref={containerRef} style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}>
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}>
<div ref={uPlotRef}/>
</div>;
};

View file

@ -0,0 +1,23 @@
import { useState, useEffect } from "react";
const useResize = (node: HTMLElement | null): {width: number, height: number} => {
const [windowSize, setWindowSize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
if (!node) return;
const handleResize = () => {
setWindowSize({
width: node.offsetWidth,
height: node.offsetHeight,
});
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, []);
return windowSize;
};
export default useResize;

View file

@ -21,7 +21,7 @@ export interface GraphState {
export type GraphAction =
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" }
| { type: "SET_YAXIS_LIMITS", payload: { [key: string]: [number, number] } }
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
| { type: "TOGGLE_CUSTOM_STEP" }
| { type: "SET_CUSTOM_STEP", payload: number}

View file

@ -3,6 +3,7 @@ import {getMaxFromArray, getMinFromArray} from "../math";
import {roundTimeSeconds} from "../time";
import {AxisRange} from "../../state/graph/reducer";
import {formatTicks} from "./helpers";
import {TimeParams} from "../../types";
export const getAxes = (series: Series[]): Axis[] => Array.from(new Set(series.map(s => s.scale))).map(a => {
const axis = {scale: a, show: true, font: "10px Arial", values: formatTicks};
@ -11,11 +12,11 @@ export const getAxes = (series: Series[]): Axis[] => Array.from(new Set(series.m
return axis;
});
export const getTimeSeries = (times: number[]): number[] => {
export const getTimeSeries = (times: number[], defaultStep: number, period: TimeParams): number[] => {
const allTimes = Array.from(new Set(times)).sort((a, b) => a - b);
const step = getMinFromArray(allTimes.map((t, i) => allTimes[i + 1] - t));
const length = Math.ceil((period.end - period.start)/defaultStep);
const startTime = allTimes[0] || 0;
return new Array(allTimes.length).fill(startTime).map((d, i) => roundTimeSeconds(d + (step * i)));
return new Array(length*2).fill(startTime).map((d, i) => roundTimeSeconds(d + (defaultStep * i)));
};
export const getLimitsYAxis = (values: { [key: string]: number[] }): AxisRange => {
@ -24,7 +25,7 @@ export const getLimitsYAxis = (values: { [key: string]: number[] }): AxisRange =
const numbers = values[key];
const min = getMinFromArray(numbers);
const max = getMaxFromArray(numbers);
result[key] = [min - (min * 0.05), max + (max * 0.05)];
result[key] = min && max ? [min - (min * 0.25), max + (max * 0.25)] : [-1, 1];
}
return result;
};

View file

@ -15,6 +15,10 @@ export const defaultOptions = {
focus: {
prox: 30
},
points: {
size: 5.6,
width: 1.4
},
bind: {
mouseup: (): null => null,
mousedown: (): null => null,

View file

@ -10,10 +10,14 @@ export const getSeriesItem = (d: MetricResult, hideSeries: string[]): Series =>
return {
label,
dash: getDashLine(d.group),
width: 1.5,
width: 1.4,
stroke: getColorLine(d.group, label),
show: !includesHideSeries(label, d.group, hideSeries),
scale: String(d.group)
scale: String(d.group),
points: {
size: 4.2,
width: 1.4
}
};
};

View file

@ -4,6 +4,7 @@ import {getColorLine} from "./helpers";
export const setTooltip = ({u, tooltipIdx, metrics, series, tooltip, tooltipOffset}: SetupTooltip): void => {
const {seriesIdx, dataIdx} = tooltipIdx;
if (seriesIdx === null || dataIdx === undefined) return;
const dataSeries = u.data[seriesIdx][dataIdx];
const dataTime = u.data[0][dataIdx];
const metric = metrics[seriesIdx - 1]?.metric || {};

View file

@ -11,8 +11,8 @@ export interface SetupTooltip {
top: number
},
tooltipIdx: {
seriesIdx: number,
dataIdx: number
seriesIdx: number | null,
dataIdx: number | undefined
}
}