From 925282665920a6c30fa1e1840f7eab39974d632f Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Wed, 17 Jul 2024 21:09:30 +0200 Subject: [PATCH] ECharts experiment Signed-off-by: Julius Volz --- web/ui/mantine-ui/package.json | 3 + web/ui/mantine-ui/src/App.tsx | 3 - web/ui/mantine-ui/src/pages/query/EChart.tsx | 272 +++++++++++++++++++ web/ui/mantine-ui/src/pages/query/Graph.tsx | 206 +++++++++----- web/ui/package-lock.json | 46 ++++ 5 files changed, 456 insertions(+), 74 deletions(-) create mode 100644 web/ui/mantine-ui/src/pages/query/EChart.tsx diff --git a/web/ui/mantine-ui/package.json b/web/ui/mantine-ui/package.json index 7d5037670..9ed21e7f5 100644 --- a/web/ui/mantine-ui/package.json +++ b/web/ui/mantine-ui/package.json @@ -27,8 +27,11 @@ "@reduxjs/toolkit": "^2.2.1", "@tabler/icons-react": "^2.47.0", "@tanstack/react-query": "^5.22.2", + "@types/lodash": "^4.17.7", "@uiw/react-codemirror": "^4.21.22", "dayjs": "^1.11.10", + "echarts": "^5.5.1", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", "react-infinite-scroll-component": "^6.1.0", diff --git a/web/ui/mantine-ui/src/App.tsx b/web/ui/mantine-ui/src/App.tsx index 3ecf5f041..2a336a603 100644 --- a/web/ui/mantine-ui/src/App.tsx +++ b/web/ui/mantine-ui/src/App.tsx @@ -27,10 +27,7 @@ import { IconChevronDown, IconChevronRight, IconCloudDataConnection, - IconCpu, IconDatabase, - IconDatabaseSearch, - IconFileAnalytics, IconFlag, IconHeartRateMonitor, IconInfoCircle, diff --git a/web/ui/mantine-ui/src/pages/query/EChart.tsx b/web/ui/mantine-ui/src/pages/query/EChart.tsx new file mode 100644 index 000000000..84bd8ac92 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/EChart.tsx @@ -0,0 +1,272 @@ +// Copyright 2023 The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import { + ECharts, + EChartsCoreOption, + EChartsOption, + init, + connect, + BarSeriesOption, + LineSeriesOption, + GaugeSeriesOption, +} from "echarts"; +import isEqual from "lodash/isEqual"; +import debounce from "lodash/debounce"; +import { Box } from "@mantine/core"; + +// https://github.com/apache/echarts/issues/12489#issuecomment-643185207 +export interface EChartsTheme extends EChartsOption { + bar?: BarSeriesOption; + line?: LineSeriesOption; + gauge?: GaugeSeriesOption; +} + +// see docs for info about each property: https://echarts.apache.org/en/api.html#events +export interface MouseEventsParameters { + componentType: string; + seriesType: string; + seriesIndex: number; + seriesName: string; + name: string; + dataIndex: number; + data: Record & T; + dataType: string; + value: number | number[]; + color: string; + info: Record; +} + +type OnEventFunction = ( + params: MouseEventsParameters, + // This is potentially undefined for testing purposes + instance?: ECharts +) => void; + +const mouseEvents = [ + "click", + "dblclick", + "mousedown", + "mousemove", + "mouseup", + "mouseover", + "mouseout", + "globalout", + "contextmenu", +] as const; + +export type MouseEventName = (typeof mouseEvents)[number]; + +// batch event types +export interface DataZoomPayloadBatchItem { + dataZoomId: string; + // start and end not returned unless dataZoom is based on percentProp, + // which is for cases when a dataZoom component controls multiple axes + start?: number; + end?: number; + // startValue and endValue return data index for 'category' axes, + // for axis types 'value' and 'time', actual values are returned + startValue?: number; + endValue?: number; +} + +export interface HighlightPayloadBatchItem { + dataIndex: number; + dataIndexInside: number; + seriesIndex: number; + // highlight action can effect multiple connected charts + escapeConnect?: boolean; + // whether blur state was triggered + notBlur?: boolean; +} + +export interface BatchEventsParameters { + type: BatchEventName; + batch: DataZoomPayloadBatchItem[] & HighlightPayloadBatchItem[]; +} + +type OnBatchEventFunction = (params: BatchEventsParameters) => void; + +const batchEvents = ["datazoom", "downplay", "highlight"] as const; + +export type BatchEventName = (typeof batchEvents)[number]; + +type ChartEventName = "finished"; + +type EventName = MouseEventName | ChartEventName | BatchEventName; + +export type OnEventsType = { + [mouseEventName in MouseEventName]?: OnEventFunction; +} & { + [batchEventName in BatchEventName]?: OnBatchEventFunction; +} & { + [eventName in ChartEventName]?: () => void; +}; + +export interface EChartsProps { + option: EChartsCoreOption; + theme?: string | EChartsTheme; + renderer?: "canvas" | "svg"; + onEvents?: OnEventsType; + _instance?: React.MutableRefObject; + syncGroup?: string; + onChartInitialized?: (instance: ECharts) => void; +} + +export const chartHeight = 500; +export const legendHeight = (numSeries: number) => numSeries * 24; +export const legendMargin = 25; + +export const EChart = React.memo(function EChart({ + option, + theme, + renderer, + onEvents, + _instance, + syncGroup, + onChartInitialized, +}: EChartsProps) { + const initialOption = useRef(option); + const prevOption = useRef(option); + const containerRef = useRef(null); + const chartElement = useRef(null); + + // Initialize chart, dispose on unmount + useLayoutEffect(() => { + if (containerRef.current === null || chartElement.current !== null) return; + chartElement.current = init(containerRef.current, theme, { + renderer: renderer ?? "canvas", + }); + if (chartElement.current === undefined) return; + chartElement.current.setOption(initialOption.current, true); + onChartInitialized?.(chartElement.current); + if (_instance !== undefined) { + _instance.current = chartElement.current; + } + return () => { + if (chartElement.current === null) return; + chartElement.current.dispose(); + chartElement.current = null; + }; + }, [_instance, onChartInitialized, theme, renderer]); + + // When syncGroup is explicitly set, charts within same group share interactions such as crosshair + useEffect(() => { + if (!chartElement.current || !syncGroup) return; + chartElement.current.group = syncGroup; + connect([chartElement.current]); // more info: https://echarts.apache.org/en/api.html#echarts.connect + }, [syncGroup, chartElement]); + + // Update chart data when option changes + useEffect(() => { + if (prevOption.current === undefined || isEqual(prevOption.current, option)) + return; + if (!chartElement.current) return; + chartElement.current.setOption(option, true); + prevOption.current = option; + }, [option]); + + // Resize chart, cleanup listener on unmount + useLayoutEffect(() => { + const updateSize = debounce(() => { + if (!chartElement.current) return; + chartElement.current.resize(); + }, 200); + window.addEventListener("resize", updateSize); + updateSize(); + return () => { + window.removeEventListener("resize", updateSize); + }; + }, []); + + // Bind and unbind chart events passed as prop + useEffect(() => { + const chart = chartElement.current; + if (!chart || onEvents === undefined) return; + bindEvents(chart, onEvents); + return () => { + if (chart === undefined) return; + if (chart.isDisposed() === true) return; + for (const event in onEvents) { + chart.off(event); + } + }; + }, [onEvents]); + + // // TODO: re-evaluate how this is triggered. It's technically working right + // // now because the sx prop is an object that gets re-created, but that also + // // means it runs unnecessarily some of the time and theoretically might + // // not run in some other cases. Maybe it should use a resize observer? + // useEffect(() => { + // // TODO: fix this debouncing. This likely isn't working as intended because + // // the debounced function is re-created every time this useEffect is called. + // const updateSize = debounce( + // () => { + // if (!chartElement.current) return; + // chartElement.current.resize(); + // }, + // 200, + // { + // leading: true, + // } + // ); + // updateSize(); + // }, [sx]); + + return ( + + ); +}); + +// Validate event config and bind custom events +function bindEvents(instance: ECharts, events?: OnEventsType) { + if (events === undefined) return; + + function bindEvent(eventName: EventName, OnEventFunction: unknown) { + if (typeof OnEventFunction === "function") { + if (isMouseEvent(eventName)) { + instance.on(eventName, (params) => OnEventFunction(params, instance)); + } else if (isBatchEvent(eventName)) { + instance.on(eventName, (params) => OnEventFunction(params)); + } else { + instance.on(eventName, () => OnEventFunction(null, instance)); + } + } + } + + for (const eventName in events) { + if (Object.prototype.hasOwnProperty.call(events, eventName)) { + const customEvent = events[eventName as EventName] ?? null; + if (customEvent) { + bindEvent(eventName as EventName, customEvent); + } + } + } +} + +function isMouseEvent(eventName: EventName): eventName is MouseEventName { + return (mouseEvents as readonly string[]).includes(eventName); +} + +function isBatchEvent(eventName: EventName): eventName is BatchEventName { + return (batchEvents as readonly string[]).includes(eventName); +} diff --git a/web/ui/mantine-ui/src/pages/query/Graph.tsx b/web/ui/mantine-ui/src/pages/query/Graph.tsx index 81960cf5e..531b33278 100644 --- a/web/ui/mantine-ui/src/pages/query/Graph.tsx +++ b/web/ui/mantine-ui/src/pages/query/Graph.tsx @@ -1,27 +1,13 @@ import { FC, useEffect, useId } from "react"; -import { Table, Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core"; +import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core"; import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; -import { - InstantQueryResult, - InstantSample, - RangeSamples, -} from "../../api/responseTypes/query"; -import SeriesName from "./SeriesName"; +import { InstantQueryResult } from "../../api/responseTypes/query"; import { useAPIQuery } from "../../api/api"; import classes from "./DataTable.module.css"; import { GraphDisplayMode } from "../../state/queryPageSlice"; - -const maxFormattableSeries = 1000; -const maxDisplayableSeries = 10000; - -const limitSeries = ( - series: S[] -): S[] => { - if (series.length > maxDisplayableSeries) { - return series.slice(0, maxDisplayableSeries); - } - return series; -}; +import { EChart, chartHeight, legendMargin } from "./EChart"; +import { formatSeries } from "../../lib/formatSeries"; +import { EChartsOption } from "echarts"; export interface GraphProps { expr: string; @@ -38,6 +24,7 @@ const Graph: FC = ({ endTime, range, resolution, + displayMode, retriggerIdx, }) => { const realEndTime = (endTime !== null ? endTime : Date.now()) / 1000; @@ -91,6 +78,18 @@ const Graph: FC = ({ const { result, resultType } = data.data; + if (resultType !== "matrix") { + return ( + } + > + This query returned a result of type "{resultType}", but a matrix was + expected. + + ); + } + if (result.length === 0) { return ( }> @@ -99,7 +98,68 @@ const Graph: FC = ({ ); } - const doFormat = result.length <= maxFormattableSeries; + const option: EChartsOption = { + animation: false, + grid: { + left: 20, + top: 20, + right: 20, + bottom: 20 + result.length * 24, + containLabel: true, + }, + legend: { + type: "scroll", + icon: "square", + orient: "vertical", + top: chartHeight + legendMargin, + bottom: 20, + left: 30, + right: 20, + }, + xAxis: { + type: "category", + // min: realEndTime * 1000 - range, + // max: realEndTime * 1000, + data: result[0].values?.map((v) => Math.round(v[0] * 1000)), + axisLine: { + show: true, + }, + }, + yAxis: { + type: "value", + axisLabel: { + formatter: formatValue, + }, + axisLine: { + // symbol: "arrow", + show: true, + // lineStyle: { + // type: "dashed", + // color: "rgba(0, 0, 0, 0.5)", + // }, + }, + }, + tooltip: { + show: true, + trigger: "item", + transitionDuration: 0, + axisPointer: { + type: "cross", + // snap: true, + }, + }, + series: result.map((series) => ({ + name: formatSeries(series.metric), + // data: series.values?.map((v) => [v[0] * 1000, parseFloat(v[1])]), + data: series.values?.map((v) => parseFloat(v[1])), + type: "line", + stack: displayMode === "stacked" ? "total" : undefined, + // showSymbol: false, + // fill: displayMode === "stacked" ? "tozeroy" : undefined, + })), + }; + + console.log(option); return ( @@ -112,59 +172,63 @@ const Graph: FC = ({ }} styles={{ loader: { width: "100%", height: "100%" } }} /> - - - {resultType === "vector" ? ( - limitSeries(result).map((s, idx) => ( - - - - - - {s.value && s.value[1]} - {s.histogram && "TODO HISTOGRAM DISPLAY"} - - - )) - ) : resultType === "matrix" ? ( - limitSeries(result).map((s, idx) => ( - - - - - - {s.values && - s.values.map((v, idx) => ( -
- {v[1]} @ {v[0]} -
- ))} -
-
- )) - ) : resultType === "scalar" ? ( - - Scalar value - {result[1]} - - ) : resultType === "string" ? ( - - String value - {result[1]} - - ) : ( - } - > - Invalid result value type - - )} -
-
+
); }; +const formatValue = (y: number | null): string => { + if (y === null) { + return "null"; + } + const absY = Math.abs(y); + + if (absY >= 1e24) { + return (y / 1e24).toFixed(2) + "Y"; + } else if (absY >= 1e21) { + return (y / 1e21).toFixed(2) + "Z"; + } else if (absY >= 1e18) { + return (y / 1e18).toFixed(2) + "E"; + } else if (absY >= 1e15) { + return (y / 1e15).toFixed(2) + "P"; + } else if (absY >= 1e12) { + return (y / 1e12).toFixed(2) + "T"; + } else if (absY >= 1e9) { + return (y / 1e9).toFixed(2) + "G"; + } else if (absY >= 1e6) { + return (y / 1e6).toFixed(2) + "M"; + } else if (absY >= 1e3) { + return (y / 1e3).toFixed(2) + "k"; + } else if (absY >= 1) { + return y.toFixed(2); + } else if (absY === 0) { + return y.toFixed(2); + } else if (absY < 1e-23) { + return (y / 1e-24).toFixed(2) + "y"; + } else if (absY < 1e-20) { + return (y / 1e-21).toFixed(2) + "z"; + } else if (absY < 1e-17) { + return (y / 1e-18).toFixed(2) + "a"; + } else if (absY < 1e-14) { + return (y / 1e-15).toFixed(2) + "f"; + } else if (absY < 1e-11) { + return (y / 1e-12).toFixed(2) + "p"; + } else if (absY < 1e-8) { + return (y / 1e-9).toFixed(2) + "n"; + } else if (absY < 1e-5) { + return (y / 1e-6).toFixed(2) + "ยต"; + } else if (absY < 1e-2) { + return (y / 1e-3).toFixed(2) + "m"; + } else if (absY <= 1) { + return y.toFixed(2); + } + throw Error("couldn't format a value, this is a bug"); +}; + export default Graph; diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 119e564f7..63b93b5da 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -124,8 +124,11 @@ "@reduxjs/toolkit": "^2.2.1", "@tabler/icons-react": "^2.47.0", "@tanstack/react-query": "^5.22.2", + "@types/lodash": "^4.17.7", "@uiw/react-codemirror": "^4.21.22", "dayjs": "^1.11.10", + "echarts": "^5.5.1", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", "react-infinite-scroll-component": "^6.1.0", @@ -2706,6 +2709,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.11.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz", @@ -3846,6 +3855,22 @@ "csstype": "^3.0.2" } }, + "node_modules/echarts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz", + "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { "version": "1.4.678", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.678.tgz", @@ -5619,6 +5644,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7662,6 +7693,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zrender": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz", + "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "react-app": { "name": "@prometheus-io/react-app", "version": "0.51.2",