mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
vmui: use uPlot as default engine for graph (#1683)
* feat: initial uPlot graph * feat: add zoom/pan for graph * fix: add zoom by ctrl/mac * fix: remove unused code
This commit is contained in:
parent
4fddcf4c83
commit
41a83a1cc6
7 changed files with 245 additions and 20838 deletions
7
app/vmui/packages/vmui/config-overrides.js
Normal file
7
app/vmui/packages/vmui/config-overrides.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires,no-undef
|
||||||
|
const {override, addExternalBabelPlugin} = require("customize-cra");
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
module.exports = override(
|
||||||
|
addExternalBabelPlugin("@babel/plugin-proposal-nullish-coalescing-operator")
|
||||||
|
);
|
20821
app/vmui/packages/vmui/package-lock.json
generated
20821
app/vmui/packages/vmui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -18,6 +18,7 @@
|
||||||
"@types/lodash.debounce": "^4.0.6",
|
"@types/lodash.debounce": "^4.0.6",
|
||||||
"@types/lodash.get": "^4.4.6",
|
"@types/lodash.get": "^4.4.6",
|
||||||
"@types/node": "^12.19.4",
|
"@types/node": "^12.19.4",
|
||||||
|
"@types/numeral": "^2.0.2",
|
||||||
"@types/qs": "^6.9.6",
|
"@types/qs": "^6.9.6",
|
||||||
"@types/react": "^16.9.56",
|
"@types/react": "^16.9.56",
|
||||||
"@types/react-dom": "^16.9.9",
|
"@types/react-dom": "^16.9.9",
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
"dayjs": "^1.10.4",
|
"dayjs": "^1.10.4",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.get": "^4.4.2",
|
"lodash.get": "^4.4.2",
|
||||||
|
"numeral": "^2.0.6",
|
||||||
"qs": "^6.5.2",
|
"qs": "^6.5.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-chartjs-2": "^3.0.5",
|
"react-chartjs-2": "^3.0.5",
|
||||||
|
@ -37,12 +39,14 @@
|
||||||
"react-measure": "^2.5.2",
|
"react-measure": "^2.5.2",
|
||||||
"react-scripts": "4.0.0",
|
"react-scripts": "4.0.0",
|
||||||
"typescript": "~4.0.5",
|
"typescript": "~4.0.5",
|
||||||
|
"uplot": "^1.6.16",
|
||||||
|
"uplot-react": "^1.1.1",
|
||||||
"web-vitals": "^0.2.4"
|
"web-vitals": "^0.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-app-rewired start",
|
||||||
"build": "GENERATE_SOURCEMAP=false react-scripts build",
|
"build": "GENERATE_SOURCEMAP=false react-app-rewired build",
|
||||||
"test": "react-scripts test",
|
"test": "react-app-rewired test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "eslint src --ext tsx,ts",
|
"lint": "eslint src --ext tsx,ts",
|
||||||
"lint:fix": "eslint src --ext tsx,ts --fix"
|
"lint:fix": "eslint src --ext tsx,ts --fix"
|
||||||
|
@ -66,9 +70,12 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.14.2",
|
"@typescript-eslint/eslint-plugin": "^4.14.2",
|
||||||
"@typescript-eslint/parser": "^4.14.2",
|
"@typescript-eslint/parser": "^4.14.2",
|
||||||
|
"customize-cra": "^1.0.0",
|
||||||
"eslint": "^7.14.0",
|
"eslint": "^7.14.0",
|
||||||
"eslint-plugin-react": "^7.22.0"
|
"eslint-plugin-react": "^7.22.0",
|
||||||
|
"react-app-rewired": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ const HomeLayout: FC = () => {
|
||||||
<CircularProgress/>
|
<CircularProgress/>
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>}
|
</Fade>}
|
||||||
{<Box height={"100%"} py={3} px={6} bgcolor={"#fff"}>
|
{<Box height={"100%"} p={3} bgcolor={"#fff"}>
|
||||||
{error &&
|
{error &&
|
||||||
<Alert color="error" severity="error" style={{fontSize: "14px"}}>
|
<Alert color="error" severity="error" style={{fontSize: "14px"}}>
|
||||||
{error}
|
{error}
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
import React, {FC, useEffect, useRef, useState} from "react";
|
import React, {FC, useEffect, useMemo, useRef, useState} from "react";
|
||||||
import {Line} from "react-chartjs-2";
|
|
||||||
import {Chart, ChartData, ChartOptions, ScatterDataPoint} from "chart.js";
|
|
||||||
import {getNameForMetric} from "../../utils/metric";
|
import {getNameForMetric} from "../../utils/metric";
|
||||||
import "chartjs-adapter-date-fns";
|
import "chartjs-adapter-date-fns";
|
||||||
import debounce from "lodash.debounce";
|
|
||||||
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
|
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
|
||||||
import {dateFromSeconds, getTimeperiodForDuration} from "../../utils/time";
|
|
||||||
import {GraphViewProps} from "../Home/Views/GraphView";
|
import {GraphViewProps} from "../Home/Views/GraphView";
|
||||||
import {limitsDurations} from "../../utils/time";
|
import uPlot, {AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries} from "uplot";
|
||||||
|
import UplotReact from "uplot-react";
|
||||||
|
import "uplot/dist/uPlot.min.css";
|
||||||
|
import numeral from "numeral";
|
||||||
|
import "./legend.css";
|
||||||
|
|
||||||
const LineChart: FC<GraphViewProps> = ({data = []}) => {
|
const LineChart: FC<GraphViewProps> = ({data = []}) => {
|
||||||
|
|
||||||
const {time: {duration, period}} = useAppState();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [series, setSeries] = useState<ChartData<"line", (ScatterDataPoint)[]>>();
|
const {time: {period}} = useAppState();
|
||||||
const refLine = useRef<Chart>(null);
|
const [dataChart, setDataChart] = useState<uPlotData>();
|
||||||
|
const [series, setSeries] = useState<uPlotSeries[]>([]);
|
||||||
|
const [scale, setScale] = useState({min: period.start, max: period.end});
|
||||||
|
const refContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getColorByName = (str: string): string => {
|
const getColorByName = (str: string): string => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
|
@ -29,113 +31,117 @@ const LineChart: FC<GraphViewProps> = ({data = []}) => {
|
||||||
return colour;
|
return colour;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const times = useMemo(() => {
|
||||||
setSeries({
|
const allTimes = data.map(d => d.values.map(v => v[0])).flat();
|
||||||
datasets: data?.map(d => {
|
const start = Math.min(...allTimes);
|
||||||
const label = getNameForMetric(d);
|
const end = Math.max(...allTimes);
|
||||||
const color = getColorByName(label);
|
const output = [];
|
||||||
return {
|
for (let i = start; i < end; i += period.step || 1) {
|
||||||
label,
|
output.push(i);
|
||||||
data: d.values.map(v => ({y: +v[1], x: v[0] * 1000})),
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: color,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (refLine.current) {
|
|
||||||
refLine.current.stop(); // make sure animations are not running
|
|
||||||
refLine.current.update("none");
|
|
||||||
}
|
}
|
||||||
|
return output;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const onZoomComplete = ({chart}: {chart: Chart}) => {
|
useEffect(() => {
|
||||||
let {min, max} = chart.scales.x;
|
const values = data.map(d => times.map(t => {
|
||||||
if (!min || !max) return;
|
const v = d.values.find(v => v[0] === t);
|
||||||
const duration = max - min;
|
return v ? +v[1] : null;
|
||||||
if (duration < limitsDurations.min) max = min + limitsDurations.min;
|
}));
|
||||||
if (duration > limitsDurations.max) min = max - limitsDurations.max;
|
const seriesValues = data.map(d => ({
|
||||||
dispatch({type: "SET_PERIOD", payload: {from: new Date(min), to: new Date(max)}});
|
label: getNameForMetric(d),
|
||||||
|
width: 1,
|
||||||
|
font: "11px Arial",
|
||||||
|
stroke: getColorByName(getNameForMetric(d))}));
|
||||||
|
setSeries([{}, ...seriesValues]);
|
||||||
|
setDataChart([times, ...values]);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const onReadyChart = (u: uPlot) => {
|
||||||
|
const factor = 0.85;
|
||||||
|
|
||||||
|
// wheel drag pan
|
||||||
|
u.over.addEventListener("mousedown", e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const left0 = e.clientX;
|
||||||
|
const scXMin0 = u.scales.x.min || 1;
|
||||||
|
const scXMax0 = u.scales.x.max || 1;
|
||||||
|
const xUnitsPerPx = u.posToVal(1, "x") - u.posToVal(0, "x");
|
||||||
|
|
||||||
|
const onmove = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = xUnitsPerPx * (e.clientX - left0);
|
||||||
|
const min = scXMin0 - dx;
|
||||||
|
const max = scXMax0 - dx;
|
||||||
|
u.setScale("x", {min, max});
|
||||||
|
setScale({min, max});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onup = () => {
|
||||||
|
document.removeEventListener("mousemove", onmove);
|
||||||
|
document.removeEventListener("mouseup", onup);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", onmove);
|
||||||
|
document.addEventListener("mouseup", onup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// wheel scroll zoom
|
||||||
|
u.over.addEventListener("wheel", e => {
|
||||||
|
if (!e.ctrlKey && !e.metaKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const {width} = u.over.getBoundingClientRect();
|
||||||
|
const {left = width/2} = u.cursor;
|
||||||
|
const leftPct = left/width;
|
||||||
|
const xVal = u.posToVal(left, "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 - leftPct * nxRange;
|
||||||
|
const max = min + nxRange;
|
||||||
|
u.batch(() => {
|
||||||
|
u.setScale("x", {min, max});
|
||||||
|
setScale({min, max});
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPanComplete = ({chart}: {chart: Chart}) => {
|
useEffect(() => {setScale({min: period.start, max: period.end});}, [period]);
|
||||||
const {min, max} = chart.scales.x;
|
|
||||||
if (!min || !max) return;
|
|
||||||
const {start, end} = getTimeperiodForDuration(duration, new Date(max));
|
|
||||||
dispatch({type: "SET_PERIOD", payload: {from: dateFromSeconds(start), to: dateFromSeconds(end)}});
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: ChartOptions = {
|
useEffect(() => {
|
||||||
animation: {duration: 0},
|
const duration = (period.end - period.start)/3;
|
||||||
parsing: false,
|
const factor = duration / (scale.max - scale.min);
|
||||||
normalized: true,
|
if (scale.max > period.end + duration || scale.min < period.start - duration || factor >= 0.7) {
|
||||||
scales: {
|
dispatch({type: "SET_PERIOD", payload: {from: new Date(scale.min * 1000), to: new Date(scale.max * 1000)}});
|
||||||
x: {
|
|
||||||
type: "time",
|
|
||||||
position: "bottom",
|
|
||||||
min: (period.start * 1000),
|
|
||||||
max: (period.end * 1000),
|
|
||||||
time: {
|
|
||||||
tooltipFormat: "yyyy-MM-dd HH:mm:ss.SSS",
|
|
||||||
displayFormats: {millisecond: ":ss.SSS", second: "HH:mm:ss", minute: "HH:mm", hour: "HH:mm"}
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
source: "auto",
|
|
||||||
autoSkip: true,
|
|
||||||
autoSkipPadding: 105,
|
|
||||||
crossAlign: "center",
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
sampleSize: 1,
|
|
||||||
color: "#000",
|
|
||||||
font: {size: 10}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: "linear",
|
|
||||||
position: "left",
|
|
||||||
ticks: {
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
color: "#000",
|
|
||||||
font: {size: 10}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
line: {
|
|
||||||
tension: 0,
|
|
||||||
stepped: false,
|
|
||||||
borderDash: [],
|
|
||||||
borderWidth: 1,
|
|
||||||
capBezierPoints: false
|
|
||||||
},
|
|
||||||
point: {radius: 0, hitRadius: 10}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: "bottom",
|
|
||||||
align: "start",
|
|
||||||
labels: {padding: 20, color: "#000"}
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
pan: {
|
|
||||||
enabled: true,
|
|
||||||
mode: "x",
|
|
||||||
onPan: debounce(onPanComplete, 750)
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
pinch: {enabled: true},
|
|
||||||
wheel: {enabled: true, speed: 0.05},
|
|
||||||
mode: "x",
|
|
||||||
onZoom: debounce(onZoomComplete, 250)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
const options: uPlotOptions = {
|
||||||
|
width: refContainer.current ? refContainer.current.offsetWidth : 400,
|
||||||
|
height: 500,
|
||||||
|
series: series,
|
||||||
|
plugins: [{
|
||||||
|
hooks: {
|
||||||
|
ready: onReadyChart
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
cursor: {drag: {x: false, y: false}},
|
||||||
|
axes: [
|
||||||
|
{space: 80},
|
||||||
|
{
|
||||||
|
show: true,
|
||||||
|
font: "10px Arial",
|
||||||
|
values: (self, ticks) => ticks.map(n => n > 1000 ? numeral(n).format("0.0a") : n)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scales: {x: {range: () => [scale.min, scale.max]}}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>
|
return <div ref={refContainer}>
|
||||||
{series && <Line data={series} options={options} ref={refLine}/>}
|
{dataChart && <UplotReact
|
||||||
</>;
|
options={options}
|
||||||
|
data={dataChart}
|
||||||
|
/>}
|
||||||
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LineChart;
|
export default LineChart;
|
11
app/vmui/packages/vmui/src/components/LineChart/legend.css
Normal file
11
app/vmui/packages/vmui/src/components/LineChart/legend.css
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.uplot .u-legend {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uplot .u-legend .u-series {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
const MAX_ITEMS_PER_CHART = window.screen.availWidth / 7.68;
|
const MAX_ITEMS_PER_CHART = window.screen.availWidth / 2;
|
||||||
|
|
||||||
export const limitsDurations = {min: 1000, max: 1.578e+11}; // min: 1 seconds, max: 5 years
|
export const limitsDurations = {min: 1000, max: 1.578e+11}; // min: 1 seconds, max: 5 years
|
||||||
|
|
||||||
|
@ -74,12 +74,13 @@ export const formatDateForNativeInput = (date: Date): string => dayjs(date).form
|
||||||
export const getDateNowUTC = (): Date => new Date(dayjs().utc().format(dateIsoFormat));
|
export const getDateNowUTC = (): Date => new Date(dayjs().utc().format(dateIsoFormat));
|
||||||
|
|
||||||
const getDurationFromMilliseconds = (ms: number): string => {
|
const getDurationFromMilliseconds = (ms: number): string => {
|
||||||
|
const milliseconds = Math.floor(ms % 1000);
|
||||||
const seconds = Math.floor((ms / 1000) % 60);
|
const seconds = Math.floor((ms / 1000) % 60);
|
||||||
const minutes = Math.floor((ms / 1000 / 60) % 60);
|
const minutes = Math.floor((ms / 1000 / 60) % 60);
|
||||||
const hours = Math.floor((ms / 1000 / 3600 ) % 24);
|
const hours = Math.floor((ms / 1000 / 3600 ) % 24);
|
||||||
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
const durs: UnitTypeShort[] = ["d", "h", "m", "s"];
|
const durs: UnitTypeShort[] = ["d", "h", "m", "s", "ms"];
|
||||||
const values = [days, hours, minutes, seconds].map((t, i) => t ? `${t}${durs[i]}` : "");
|
const values = [days, hours, minutes, seconds, milliseconds].map((t, i) => t ? `${t}${durs[i]}` : "");
|
||||||
return values.filter(t => t).join(" ");
|
return values.filter(t => t).join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue