diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2c707..80dbbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ For more information about each release including git tags and artifacts, see [R - Event stream shows sensible defaults so first-load isn't empty ([#297](https://github.com/roostorg/osprey/pull/297) by [@haileyok](https://github.com/haileyok)) - Replace `react-scripts` with `rsbuild`/`rspack` for UI builds ([#235](https://github.com/roostorg/osprey/pull/235) by [@chimosky](https://github.com/chimosky)) - Migrate from npm to pnpm via Corepack ([#252](https://github.com/roostorg/osprey/pull/252) by [@haileyok](https://github.com/haileyok)) +- Replace `highcharts` (proprietary) with `echarts` (Apache 2.0) in the Timeseries chart ([#374](https://github.com/roostorg/osprey/issues/374)) ### Fixed diff --git a/osprey_ui/package.json b/osprey_ui/package.json index 8ee8db6..57dd865 100644 --- a/osprey_ui/package.json +++ b/osprey_ui/package.json @@ -15,10 +15,10 @@ "cytoscape-dagre": "^2.4.0", "cytoscape-popper": "^2.0.0", "dayjs": "^1.11.0", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", "file-saver": "2.0.1", "glob-to-regexp": "0.4.1", - "highcharts": "8.1.0", - "highcharts-react-official": "3.0.0", "highlight.js": "10.6.0", "history": "4.7.2", "invariant": "2.2.4", @@ -35,6 +35,10 @@ }, "devDependencies": { "@eslint/js": "9.39.4", + "@rsbuild/core": "2.0.1", + "@rsbuild/plugin-react": "1.4.6", + "@rsbuild/plugin-sass": "1.5.1", + "@rstest/core": "0.9.9", "@types/classnames": "2.2.9", "@types/cytoscape": "^3.19.4", "@types/cytoscape-dagre": "^2.3.0", @@ -52,10 +56,6 @@ "@types/react-router": "5.1.19", "@types/react-router-dom": "5.3.3", "@types/react-virtualized": "9.21.8", - "@rsbuild/core": "2.0.1", - "@rsbuild/plugin-react": "1.4.6", - "@rsbuild/plugin-sass": "1.5.1", - "@rstest/core": "0.9.9", "core-js": "3.49.0", "eslint": "9.39.4", "eslint-plugin-jsx-a11y": "6.10.2", diff --git a/osprey_ui/pnpm-lock.yaml b/osprey_ui/pnpm-lock.yaml index b20d3bc..5e1b78f 100644 --- a/osprey_ui/pnpm-lock.yaml +++ b/osprey_ui/pnpm-lock.yaml @@ -38,18 +38,18 @@ importers: dayjs: specifier: ^1.11.0 version: 1.11.18 + echarts: + specifier: ^6.1.0 + version: 6.1.0 + echarts-for-react: + specifier: ^3.0.6 + version: 3.0.6(echarts@6.1.0)(react@18.3.1) file-saver: specifier: 2.0.1 version: 2.0.1 glob-to-regexp: specifier: 0.4.1 version: 0.4.1 - highcharts: - specifier: 8.1.0 - version: 8.1.0 - highcharts-react-official: - specifier: 3.0.0 - version: 3.0.0(highcharts@8.1.0)(react@18.3.1) highlight.js: specifier: 10.6.0 version: 10.6.0 @@ -1206,6 +1206,15 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts-for-react@3.0.6: + resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} + peerDependencies: + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + react: ^15.0.0 || >=16.0.0 + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} @@ -1503,14 +1512,6 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - highcharts-react-official@3.0.0: - resolution: {integrity: sha512-VefJgDY2hkT9gfppsQGrRF2g5u8d9dtfHGcx2/xqiP+PkZXCqalw9xOeKVCRvJKTOh0coiDFwvVjOvB7KaGl4A==} - peerDependencies: - highcharts: '>=6.0.0' - react: '>=16.8.0' - - highcharts@8.1.0: - resolution: {integrity: sha512-4KXq9t2/PU0cqKUtET7om9Kh5AyOinIn4vYi62oYVsb4ql5wyUYW06f9Si/ERG2Thoy/rcwNmR77upKjg8xhqQ==} highlight.js@10.6.0: resolution: {integrity: sha512-8mlRcn5vk/r4+QcqerapwBYTe+iPL5ih6xrNylxrnBdHQiijDETfXX7VIxC3UiCRiINBJfANBAsPzAvRQj8RpQ==} @@ -2499,6 +2500,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2616,6 +2620,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2775,6 +2782,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + zustand@3.5.2: resolution: {integrity: sha512-HUCQI9O77/D/zvoWPNv4dwLriiiY2xtEqnQ0TVY8uaPjmOHls04Okd0w3sWUE9Ex2LaYlSk11DbPPIOZ6shEWg==} peerDependencies: @@ -3951,6 +3961,18 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + echarts-for-react@3.0.6(echarts@6.1.0)(react@18.3.1): + dependencies: + echarts: 6.1.0 + fast-deep-equal: 3.1.3 + react: 18.3.1 + size-sensor: 1.0.3 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + electron-to-chromium@1.5.376: {} emoji-regex@9.2.2: {} @@ -4368,12 +4390,6 @@ snapshots: dependencies: hermes-estree: 0.25.1 - highcharts-react-official@3.0.0(highcharts@8.1.0)(react@18.3.1): - dependencies: - highcharts: 8.1.0 - react: 18.3.1 - - highcharts@8.1.0: {} highlight.js@10.6.0: {} @@ -5551,6 +5567,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + size-sensor@1.0.3: {} + source-map-js@1.2.1: {} source-map@0.6.1: @@ -5684,6 +5702,8 @@ snapshots: dependencies: typescript: 5.9.2 + tslib@2.3.0: {} + tslib@2.8.1: {} type-check@0.4.0: @@ -5875,6 +5895,10 @@ snapshots: zod@4.4.3: {} + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + zustand@3.5.2(react@18.3.1): optionalDependencies: react: 18.3.1 diff --git a/osprey_ui/src/components/timeseries/Timeseries.tsx b/osprey_ui/src/components/timeseries/Timeseries.tsx index 809a562..fdbbaba 100644 --- a/osprey_ui/src/components/timeseries/Timeseries.tsx +++ b/osprey_ui/src/components/timeseries/Timeseries.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Select, Spin } from 'antd'; -import Highcharts, { SeriesOptionsType } from 'highcharts'; -import HighchartsReact from 'highcharts-react-official'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsType } from 'echarts'; import dayjs from 'dayjs'; import shallow from 'zustand/shallow'; @@ -14,7 +14,6 @@ import Panel from '../common/Panel'; import styles from './Timeseries.module.css'; -const CHART_TYPE = 'column'; // prettier-ignore const Granularities = { // name: duration in ms @@ -39,45 +38,40 @@ const DEFAULT_GRANULARITY = 'hour'; type Granularity = keyof typeof Granularities; function getDateFormatForGranularity(granularity: Granularity | 'other'): string { - // https://api.highcharts.com/class-reference/Highcharts#.dateFormat + // dayjs format strings — https://day.js.org/docs/en/display/format switch (granularity) { case 'minute': // 4:23PM - return '%l:%M%p'; + return 'h:mmA'; case 'fifteen_minute': case 'thirty_minute': case 'hour': // Jun 1 4PM - return '%b %e %l%p'; + return 'MMM D hA'; case 'day': case 'week': // Jun 1, 2020 - return '%b %e, %Y'; + return 'MMM D, YYYY'; case 'month': // Jun 2020 - return '%b %Y'; + return 'MMM YYYY'; default: // Jun 1, 2020 4:23PM - return '%b %e, %Y %l:%M%p'; + return 'MMM D, YYYY h:mmA'; } } const MIN_GRANULARITY_DATAPOINTS = 7; function getDefaultGranularityForTimeSpan(start: string | null, end: string | null): Granularity { // choose the largest granularity that would have more than $MIN_GRANULARITY_DATAPOINTS datapoints - // calculate duration, and from there, figure out - if (!start || !end) { - // we won't be able to determine duration return DEFAULT_GRANULARITY; } - // ms difference between start and end dates const startDate = dayjs(start); const endDate = dayjs(end); const duration = Math.abs(dayjs.duration(endDate.diff(startDate)).asMilliseconds()); - // sort granularities by desc duration const sortedGranularities = (Object.entries(Granularities) as Array<[Granularity, number]>).sort( ([, durationA], [, durationB]) => durationB - durationA ); @@ -85,9 +79,7 @@ function getDefaultGranularityForTimeSpan(start: string | null, end: string | nu let currentGranularity: Granularity | null = null; for (const [granularity, granularityDuration] of sortedGranularities) { currentGranularity = granularity; - if (duration / granularityDuration > MIN_GRANULARITY_DATAPOINTS) { - // this granularity meets our minimum data points break; } } @@ -99,24 +91,11 @@ function getDefaultGranularityForTimeSpan(start: string | null, end: string | nu return currentGranularity; } -function getChartData(timeseriesData: TimeseriesResult[]): SeriesOptionsType[] { - if (timeseriesData.length <= 1) { +function getChartData(timeseriesData: TimeseriesResult[]): [number, number][] { + if (timeseriesData.length === 0) { return []; } - - const data = timeseriesData.map((point: TimeseriesResult) => [Date.parse(point.timestamp), point.result.count]); - - return [ - { - type: CHART_TYPE, - data: data, - name: '# Events', - color: '#8e5ea2', - // By default, Highcarts groups multiple series for the same x together; - // because we only have one data series, we don't want to have any padding between the lines. - groupPadding: 0, - }, - ]; + return timeseriesData.map((point: TimeseriesResult) => [Date.parse(point.timestamp), point.result.count]); } const EmptyOverlay = ({ show, children }: { show: boolean; children: React.ReactNode }) => { @@ -208,83 +187,94 @@ const Timeseries: React.FC = ({ extraQuery }: TimeseriesProps) setGranularity(defaultGranularity); }, [defaultGranularity]); - function handleChartSelection(event: Highcharts.ChartSelectionContextObject): false { - const newRange = event.xAxis?.[0]; - if (newRange == null) return false; + const chartInstanceRef = React.useRef(null); + + // Activate lineX brush mode immediately so the user can drag to select a date range + // without needing to click a toolbox button first (matching the previous Highcharts zoomType:'x' UX). + function handleChartReady(chart: EChartsType): void { + chartInstanceRef.current = chart; + chart.dispatchAction({ + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { brushType: 'lineX', brushMode: 'single' }, + }); + } + + function handleBrushEnd(params: { areas?: Array<{ coordRange?: [number, number] }> }): void { + const area = params.areas?.[0]; + if (!area?.coordRange) return; + const [startTs, endTs] = area.coordRange; - // Highcharts does not update the values of executedQuery when the component rerenders, - // so get it directly from the store when handler is called. - const { executedQuery } = useQueryStore.getState(); + // Clear the brush selection immediately so it doesn't persist over the chart. + chartInstanceRef.current?.dispatchAction({ type: 'brush', areas: [] }); + // ECharts does not close over executedQuery like React handlers do, + // so read the latest value directly from the store. + const { executedQuery: current } = useQueryStore.getState(); updateExecutedQuery({ - ...executedQuery, + ...current, interval: 'custom', - start: new Date(newRange.min).toISOString(), - end: new Date(newRange.max).toISOString(), + start: new Date(startTs).toISOString(), + end: new Date(endTs).toISOString(), }); - // return false because we don't want highcharts to apply the zoom, as we'll just re-query the data instead - return false; } const chartData = getChartData(timeseriesData); - const chartOptions: Highcharts.Options = { - chart: { - type: CHART_TYPE, - height: 300, - // allows selection of a subset of date - zoomType: 'x', - events: { - // event handler for the selection - selection: handleChartSelection, - }, - }, - title: { - text: undefined, - }, - credits: { - enabled: false, - }, + const axisDateFormat = getDateFormatForGranularity(granularity); + const tooltipDateFormat = getDateFormatForGranularity('other'); + + // Only show ticks/labels at timestamps that actually have data, matching the + // previous Highcharts behaviour, while keeping a continuous time axis (so brush + // drag-to-select still maps directly to real timestamps). + const dataTimestamps = chartData.map(([timestamp]) => timestamp); + + const chartOptions = { + grid: { left: '3%', right: '4%', bottom: 10, containLabel: true }, xAxis: { - type: 'datetime', - // adjusts granularity of each tick - tickPixelInterval: 10, - labels: { - format: `{value:${getDateFormatForGranularity(granularity)}}`, - rotation: -45, - }, - // shows x-axis grid - gridLineWidth: 1, - title: { - text: undefined, + type: 'time', + splitLine: { show: true, customValues: dataTimestamps }, + axisTick: { customValues: dataTimestamps }, + axisLabel: { + customValues: dataTimestamps, + // ECharts time axis provides millisecond timestamps; dayjs renders them + // in the browser's local timezone, matching the previous Highcharts behaviour. + formatter: (value: number) => dayjs(value).format(axisDateFormat), + rotate: 45, + align: 'right', }, }, yAxis: { - type: 'linear', - // always start at 0 events + type: 'value', min: 0, - tickPixelInterval: 10, - labels: { - format: '{value:,.0f}', - }, - title: { - text: undefined, + axisLabel: { + formatter: (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 0 }), }, }, tooltip: { - // date format for tooltip's x value (in our case, datetime) - xDateFormat: getDateFormatForGranularity('other'), - }, - time: { - // our time data is UTC, but we'll convert it to whatever timezone dayjs - // thinks the user is in - timezone: dayjs.tz.guess(), - // don't render time as UTC - useUTC: false, - }, - legend: { - enabled: false, + trigger: 'axis', + formatter: (params: unknown) => { + const list = params as Array<{ data: [number, number] }>; + if (!Array.isArray(list) || !list.length || !list[0]?.data) return ''; + const [timestamp, value] = list[0].data; + return `${dayjs(timestamp).format(tooltipDateFormat)}: ${Number(value).toLocaleString()}`; + }, }, - series: chartData, + series: + chartData.length > 0 + ? [ + { + type: 'bar', + data: chartData, + name: '# Events', + itemStyle: { color: '#8e5ea2' }, + barMaxWidth: 40, + }, + ] + : [], + // Register the brush component. The toolbox buttons are hidden; brush mode is + // activated programmatically in handleChartReady so drag-to-select works immediately. + brush: { toolbox: ['lineX'], xAxisIndex: 0 }, + toolbox: { show: false }, }; return ( @@ -297,7 +287,12 @@ const Timeseries: React.FC = ({ extraQuery }: TimeseriesProps)
- +