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": { "files": {
"main.css": "./static/css/main.a33903a8.css", "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", "static/js/27.85f0e2b0.chunk.js": "./static/js/27.85f0e2b0.chunk.js",
"index.html": "./index.html" "index.html": "./index.html"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.a33903a8.css", "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 onChangeLimit = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, axis: string, index: number) => {
const newLimits = yaxis.limits.range; const newLimits = yaxis.limits.range;
newLimits[axis][index] = +e.target.value; 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}); graphDispatch({type: "SET_YAXIS_LIMITS", payload: newLimits});
}; };
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]); const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);

View File

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

View File

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

View File

@ -16,7 +16,7 @@ const HomeLayout: FC = () => {
const {isLoading, liveData, graphData, error} = useFetchQuery(); const {isLoading, liveData, graphData, error} = useFetchQuery();
return ( return (
<> <Box id="homeLayout">
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<Box display="flex"> <Box display="flex">
@ -78,7 +78,7 @@ const HomeLayout: FC = () => {
</Box>} </Box>}
</Box> </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 {MetricResult} from "../../../api/types";
import LineChart from "../../LineChart/LineChart"; import LineChart from "../../LineChart/LineChart";
import {AlignedData as uPlotData, Series as uPlotSeries} from "uplot"; import {AlignedData as uPlotData, Series as uPlotSeries} from "uplot";
import Legend from "../../Legend/Legend"; 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 {getHideSeries, getLegendItem, getSeriesItem} from "../../../utils/uplot/series";
import {getLimitsYAxis, getTimeSeries} from "../../../utils/uplot/axes"; import {getLimitsYAxis, getTimeSeries} from "../../../utils/uplot/axes";
import {LegendItem} from "../../../utils/uplot/types"; import {LegendItem} from "../../../utils/uplot/types";
import {AxisRange} from "../../../state/graph/reducer";
import GraphSettings from "../Configurator/Graph/GraphSettings"; import GraphSettings from "../Configurator/Graph/GraphSettings";
import {useAppState} from "../../../state/common/StateContext";
export interface GraphViewProps { export interface GraphViewProps {
data?: MetricResult[]; data?: MetricResult[];
@ -16,16 +16,17 @@ export interface GraphViewProps {
const GraphView: FC<GraphViewProps> = ({data = []}) => { const GraphView: FC<GraphViewProps> = ({data = []}) => {
const graphDispatch = useGraphDispatch(); 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 [dataChart, setDataChart] = useState<uPlotData>([[]]);
const [series, setSeries] = useState<uPlotSeries[]>([]); const [series, setSeries] = useState<uPlotSeries[]>([]);
const [legend, setLegend] = useState<LegendItem[]>([]); const [legend, setLegend] = useState<LegendItem[]>([]);
const [hideSeries, setHideSeries] = useState<string[]>([]); const [hideSeries, setHideSeries] = useState<string[]>([]);
const [valuesLimit, setValuesLimit] = useState<AxisRange>({"1": [0, 1]});
const setLimitsYaxis = (values: {[key: string]: number[]}) => { const setLimitsYaxis = (values: {[key: string]: number[]}) => {
const limits = getLimitsYAxis(values); const limits = getLimitsYAxis(values);
setValuesLimit(limits);
graphDispatch({type: "SET_YAXIS_LIMITS", payload: 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 => { 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); })] as uPlotData);
setLimitsYaxis(tempValues); setLimitsYaxis(tempValues);
@ -79,7 +83,7 @@ const GraphView: FC<GraphViewProps> = ({data = []}) => {
{(data.length > 0) {(data.length > 0)
? <div> ? <div>
<GraphSettings/> <GraphSettings/>
<LineChart data={dataChart} series={series} metrics={data} limits={valuesLimit}/> <LineChart data={dataChart} series={series} metrics={data}/>
<Legend labels={legend} onChange={onChangeLegend}/> <Legend labels={legend} onChange={onChangeLegend}/>
</div> </div>
: <div style={{textAlign: "center"}}>No data to show</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 throttle from "lodash.throttle";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import "./tooltip.css"; import "./tooltip.css";
import {AxisRange} from "../../state/graph/reducer"; import useResize from "../../hooks/useResize";
export interface LineChartProps { export interface LineChartProps {
metrics: MetricResult[]; metrics: MetricResult[];
data: uPlotData; data: uPlotData;
series: uPlotSeries[]; series: uPlotSeries[];
limits: AxisRange;
} }
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"} 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 dispatch = useAppDispatch();
const {time: {period}} = useAppState(); const {time: {period}} = useAppState();
const {yaxis} = useGraphState(); const {yaxis} = useGraphState();
const containerRef = useRef<HTMLDivElement>(null);
const uPlotRef = useRef<HTMLDivElement>(null); const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false); const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({min: period.start, max: period.end}); const [xRange, setXRange] = useState({min: period.start, max: period.end});
const [uPlotInst, setUPlotInst] = useState<uPlot>(); const [uPlotInst, setUPlotInst] = useState<uPlot>();
const layoutSize = useResize(document.getElementById("homeLayout"));
const tooltip = document.createElement("div"); const tooltip = document.createElement("div");
tooltip.className = "u-tooltip"; 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 tooltipOffset = {left: 0, top: 0};
const setScale = ({min, max}: { min: number, max: number }): void => { 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) => { const setCursor = (u: uPlot) => {
if (tooltipIdx.dataIdx === u.cursor.idx) return; if (tooltipIdx.dataIdx === u.cursor.idx) return;
tooltipIdx.dataIdx = u.cursor.idx || 0; 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}); setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset});
} }
}; };
const seriesFocus = (u: uPlot, sidx: (number | null)) => { const seriesFocus = (u: uPlot, sidx: (number | null)) => {
if (tooltipIdx.seriesIdx === sidx) return; if (tooltipIdx.seriesIdx === sidx) return;
tooltipIdx.seriesIdx = sidx || 0; tooltipIdx.seriesIdx = sidx;
sidx && tooltipIdx.dataIdx sidx && tooltipIdx.dataIdx !== undefined
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset}) ? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset})
: tooltip.style.display = "none"; : tooltip.style.display = "none";
}; };
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max]; const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => { const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => {
if (yaxis.limits.enable) return yaxis.limits.range[axis]; 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 => { const getScales = (): Scales => {
@ -104,7 +103,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) =>
series, series,
axes: getAxes(series), axes: getAxes(series),
scales: {...getScales()}, scales: {...getScales()},
width: containerRef.current ? containerRef.current.offsetWidth : 400, width: layoutSize.width ? layoutSize.width - 64 : 400,
plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}], plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}],
}; };
@ -135,13 +134,13 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [], limits}) =>
setUPlotInst(u); setUPlotInst(u);
setXRange({min: period.start, max: period.end}); setXRange({min: period.start, max: period.end});
return u.destroy; return u.destroy;
}, [uPlotRef.current, series]); }, [uPlotRef.current, series, layoutSize]);
useEffect(() => updateChart(typeChartUpdate.data), [data]); 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]);
return <div ref={containerRef} style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}> return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}>
<div ref={uPlotRef}/> <div ref={uPlotRef}/>
</div>; </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 = export type GraphAction =
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" } | { 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: "TOGGLE_CUSTOM_STEP" }
| { type: "SET_CUSTOM_STEP", payload: number} | { type: "SET_CUSTOM_STEP", payload: number}

View File

@ -3,6 +3,7 @@ import {getMaxFromArray, getMinFromArray} from "../math";
import {roundTimeSeconds} from "../time"; import {roundTimeSeconds} from "../time";
import {AxisRange} from "../../state/graph/reducer"; import {AxisRange} from "../../state/graph/reducer";
import {formatTicks} from "./helpers"; 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 => { 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}; 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; 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 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; 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 => { 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 numbers = values[key];
const min = getMinFromArray(numbers); const min = getMinFromArray(numbers);
const max = getMaxFromArray(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; return result;
}; };

View File

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

View File

@ -10,10 +10,14 @@ export const getSeriesItem = (d: MetricResult, hideSeries: string[]): Series =>
return { return {
label, label,
dash: getDashLine(d.group), dash: getDashLine(d.group),
width: 1.5, width: 1.4,
stroke: getColorLine(d.group, label), stroke: getColorLine(d.group, label),
show: !includesHideSeries(label, d.group, hideSeries), 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 => { export const setTooltip = ({u, tooltipIdx, metrics, series, tooltip, tooltipOffset}: SetupTooltip): void => {
const {seriesIdx, dataIdx} = tooltipIdx; const {seriesIdx, dataIdx} = tooltipIdx;
if (seriesIdx === null || dataIdx === undefined) return;
const dataSeries = u.data[seriesIdx][dataIdx]; const dataSeries = u.data[seriesIdx][dataIdx];
const dataTime = u.data[0][dataIdx]; const dataTime = u.data[0][dataIdx];
const metric = metrics[seriesIdx - 1]?.metric || {}; const metric = metrics[seriesIdx - 1]?.metric || {};

View File

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