From 7eef8e5feac17a4ff086f3be0654c409c7734655 Mon Sep 17 00:00:00 2001 From: Alex Mylonas Date: Fri, 20 Nov 2020 19:40:48 +0200 Subject: [PATCH] Extend TimeSeriesChart to displays bars & Tooltip based on metadata (#2079) * Extend TimeseriesData to display bar series * Updated tooltip axisPointer * Added tooltip components in different files * Updated TimeSeriesChart to accept metadata & color and render tooltips * Removed multiseriesTooltip * mage gen fmt * Added show label * Added prop to control display of series label * Rename options to useChartOptions & other fixes * Renamed seriesType to chartType * Fixes for tooltip * Updated props * ***TO REVERT THIS*** * Revert "***TO REVERT THIS***" This reverts commit fa7884ee7ed827bbca8792ad60155d9ba31c68cd. * Added default tooltip for charts * Updated types Co-authored-by: panther-bot --- .../charts/TimeSeriesChart/ChartTooltip.tsx | 55 +++++++ .../TimeSeriesChart/TimeSeriesChart.tsx | 138 ++++++++---------- .../TimeSeriesChart/useChartOptions.tsx | 77 ++++++++++ 3 files changed, 196 insertions(+), 74 deletions(-) create mode 100644 web/src/components/charts/TimeSeriesChart/ChartTooltip.tsx create mode 100644 web/src/components/charts/TimeSeriesChart/useChartOptions.tsx diff --git a/web/src/components/charts/TimeSeriesChart/ChartTooltip.tsx b/web/src/components/charts/TimeSeriesChart/ChartTooltip.tsx new file mode 100644 index 0000000000..98abdc0070 --- /dev/null +++ b/web/src/components/charts/TimeSeriesChart/ChartTooltip.tsx @@ -0,0 +1,55 @@ +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import React from 'react'; +import { Box, Flex, Text } from 'pouncejs'; +import { formatDatetime } from 'Helpers/utils'; + +export interface ChartTooltipProps { + params: any[]; + units: string; +} + +const ChartTooltip: React.FC = ({ params, units }) => { + return ( + + + {formatDatetime(params[0].value[0], true)} + + + {params.map((seriesInfo, i) => { + return ( + + + + + {seriesInfo.seriesName} + + + {seriesInfo.value[1].toLocaleString('en')} + {units ? ` ${units}` : ''} + + + + ); + })} + + + ); +}; + +export default ChartTooltip; diff --git a/web/src/components/charts/TimeSeriesChart/TimeSeriesChart.tsx b/web/src/components/charts/TimeSeriesChart/TimeSeriesChart.tsx index aa8562c91e..a8bcf5b840 100644 --- a/web/src/components/charts/TimeSeriesChart/TimeSeriesChart.tsx +++ b/web/src/components/charts/TimeSeriesChart/TimeSeriesChart.tsx @@ -18,19 +18,29 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Box, Flex, Text, useTheme } from 'pouncejs'; -import { formatTime, formatDatetime, remToPx, capitalize } from 'Helpers/utils'; +import { Box, Flex, theme as Theme, useTheme } from 'pouncejs'; +import { formatTime, remToPx, capitalize } from 'Helpers/utils'; import { FloatSeriesData, LongSeriesData, FloatSeries, LongSeries } from 'Generated/schema'; import { EChartOption, ECharts } from 'echarts'; import mapKeys from 'lodash/mapKeys'; import { SEVERITY_COLOR_MAP } from 'Source/constants'; import { stringToPaleColor } from 'Helpers/colors'; +import ChartTooltip, { ChartTooltipProps } from './ChartTooltip'; +import useChartOptions from './useChartOptions'; import ResetButton from '../ResetButton'; import ScaleControls from '../ScaleControls'; -interface TimeSeriesLinesProps { +type SeriesMetadata = { + color?: keyof typeof Theme['colors']; +}; + +export type SeriesDataMetadata = { + metadata?: any[]; +}; + +interface TimeSeriesChartProps { /** The data for the time series */ - data: LongSeriesData | FloatSeriesData; + data: (LongSeriesData | FloatSeriesData) & SeriesDataMetadata; /** * The number of segments that the X-axis is split into @@ -59,6 +69,24 @@ interface TimeSeriesLinesProps { */ maxZoomPeriod?: number; + /** + * Whether to render chart as lines or bars + * @default line + */ + chartType?: 'line' | 'bar'; + + /** + * Whether to show label for series + * @default true + */ + hideSeriesLabels?: boolean; + + /** + * Whether to hide legend + * @default false + */ + hideLegend?: boolean; + /** * This is parameter determines if we need to display the values with an appropriate suffix */ @@ -68,6 +96,12 @@ interface TimeSeriesLinesProps { * This is an optional parameter that will render the text provided above legend if defined */ title?: string; + + /** + * + * @default ChartTooltip + */ + tooltipComponent?: React.FC; } const severityColors = mapKeys(SEVERITY_COLOR_MAP, (val, key) => capitalize(key.toLowerCase())); @@ -79,21 +113,25 @@ function formatDateString(timestamp) { return `${hourFormat(timestamp)}\n${dateFormat(timestamp).toUpperCase()}`; } -const TimeSeriesChart: React.FC = ({ +const TimeSeriesChart: React.FC = ({ data, zoomable = false, scaleControls = true, segments = 12, maxZoomPeriod = 3600 * 1000 * 24, + chartType = 'line', + hideLegend = false, + hideSeriesLabels = true, units, title, + tooltipComponent = ChartTooltip, }) => { const [scaleType, setScaleType] = React.useState('value'); const theme = useTheme(); + const { getLegend } = useChartOptions(); const timeSeriesChart = React.useRef(null); const container = React.useRef(null); const tooltip = React.useRef(document.createElement('div')); - /* * Defining ChartOptions */ @@ -102,13 +140,7 @@ const TimeSeriesChart: React.FC = ({ * Timestamps & Series are common for all series since everything has the same interval * and the same time frame */ - const series = data.series as (LongSeries | FloatSeries)[]; - /* - * 'legendData' must be an array of values that matches 'series.name'in order - * to display them in correct order and color - * e.g. [AWS.ALB, AWS.S3, ...etc] - */ - const legendData = series.map(({ label }) => label); + const series = data.series as ((LongSeries | FloatSeries) & SeriesMetadata)[]; /* * 'series' must be an array of objects that includes some graph options @@ -116,20 +148,26 @@ const TimeSeriesChart: React.FC = ({ * is an array of values for all datapoints * Must be ordered */ - const seriesData = series.map(({ label, values }) => { + const seriesData = series.map(({ label, values, color }) => { return { name: label, - type: 'line', + type: chartType, symbol: 'none', smooth: true, + barMaxWidth: 24, itemStyle: { - color: theme.colors[severityColors[label]] || stringToPaleColor(label), + color: theme.colors[color || severityColors[label]] || stringToPaleColor(label), + }, + label: { + show: !hideSeriesLabels, + position: 'top', + color: '#fff', }, data: values .map((v, i) => { return { name: label, - value: [data.timestamps[i], v], + value: [data.timestamps[i], v, data.metadata ? data.metadata[i] : null], }; }) /* This reverse is needed cause data provided by API are coming by descending timestamp. @@ -143,7 +181,7 @@ const TimeSeriesChart: React.FC = ({ const options: EChartOption = { grid: { - left: 180, + left: hideLegend ? 0 : 180, right: 50, bottom: 50, containLabel: true, @@ -178,67 +216,20 @@ const TimeSeriesChart: React.FC = ({ }), tooltip: { trigger: 'axis' as const, + axisPointer: { + type: chartType === 'line' ? 'line' : 'none', + }, backgroundColor: theme.colors['navyblue-300'], formatter: (params: EChartOption.Tooltip.Format[]) => { if (!params || !params.length) { return ''; } - const component = ( - - - {formatDatetime(params[0].value[0], true)} - - - {params.map(seriesTooltip => ( - - - - {seriesTooltip.seriesName} - - - {seriesTooltip.value[1].toLocaleString('en')} - {units ? ` ${units}` : ''} - - - ))} - - - ); - - ReactDOM.render(component, tooltip.current); + ReactDOM.render(tooltipComponent({ params, units }), tooltip.current); return tooltip.current.innerHTML; }, }, - legend: { - type: 'scroll' as const, - orient: 'vertical' as const, - left: 'auto', - right: 'auto', - // Pushing down legend to fit title - top: title ? 30 : 'auto', - icon: 'circle', - data: legendData, - inactiveColor: theme.colors['gray-400'], - textStyle: { - color: theme.colors['gray-50'], - fontFamily: theme.fonts.primary, - fontSize: remToPx(theme.fontSizes['x-small']), - }, - pageIcons: { - vertical: ['M7 10L12 15L17 10H7Z', 'M7 14L12 9L17 14H7Z'], - }, - pageIconColor: theme.colors['gray-50'], - pageIconInactiveColor: theme.colors['navyblue-300'], - pageIconSize: 12, - pageTextStyle: { - fontFamily: theme.fonts.primary, - color: theme.colors['gray-50'], - fontWeight: theme.fontWeights.bold as any, - fontSize: remToPx(theme.fontSizes['x-small']), - }, - pageButtonGap: theme.space[3] as number, - }, + ...(!hideLegend && { legend: getLegend({ series, title }) }), xAxis: { type: 'time' as const, splitNumber: segments, @@ -296,7 +287,7 @@ const TimeSeriesChart: React.FC = ({ const [echarts] = await Promise.all( [ import(/* webpackChunkName: "echarts" */ 'echarts/lib/echarts'), - import(/* webpackChunkName: "echarts" */ 'echarts/lib/chart/line'), + import(/* webpackChunkName: "echarts" */ `echarts/lib/chart/${chartType}`), import(/* webpackChunkName: "echarts" */ 'echarts/lib/component/tooltip'), zoomable && import(/* webpackChunkName: "echarts" */ 'echarts/lib/component/dataZoom'), // This is needed for reset functionality @@ -343,7 +334,7 @@ const TimeSeriesChart: React.FC = ({ // eslint-disable-next-line func-names newChart.on('restore', function () { const options = chartOptions; - if (options.legend.selected) { + if (options.legend?.selected) { options.legend.selected = Object.keys(options.legend.selected).reduce((acc, cur) => { acc[cur] = true; return acc; @@ -370,8 +361,7 @@ const TimeSeriesChart: React.FC = ({ {title} - - + {scaleControls && } diff --git a/web/src/components/charts/TimeSeriesChart/useChartOptions.tsx b/web/src/components/charts/TimeSeriesChart/useChartOptions.tsx new file mode 100644 index 0000000000..e939a6058d --- /dev/null +++ b/web/src/components/charts/TimeSeriesChart/useChartOptions.tsx @@ -0,0 +1,77 @@ +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { remToPx } from 'Helpers/utils'; +import { FloatSeries, LongSeries } from 'Generated/schema'; +import { useTheme } from 'pouncejs'; + +type GetLegendProps = { + series: (LongSeries | FloatSeries)[]; + title?: string; +}; + +type GetLegendFunc = (props: GetLegendProps) => any; + +const useChartOptions = () => { + const theme = useTheme(); + const getLegend: GetLegendFunc = React.useCallback( + ({ series, title }) => { + /* + * 'legendData' must be an array of values that matches 'series.name' in order + * to display them in correct order and color + * e.g. [AWS.ALB, AWS.S3, ...etc] + */ + const legendData = series.map(({ label }) => label); + return { + type: 'scroll' as const, + orient: 'vertical' as const, + left: 'auto', + right: 'auto', + // Pushing down legend to fit title + top: title ? 30 : 'auto', + icon: 'circle', + data: legendData, + inactiveColor: theme.colors['gray-400'], + textStyle: { + color: theme.colors['gray-50'], + fontFamily: theme.fonts.primary, + fontSize: remToPx(theme.fontSizes['x-small']), + }, + pageIcons: { + vertical: ['M7 10L12 15L17 10H7Z', 'M7 14L12 9L17 14H7Z'], + }, + pageIconColor: theme.colors['gray-50'], + pageIconInactiveColor: theme.colors['navyblue-300'], + pageIconSize: 12, + pageTextStyle: { + fontFamily: theme.fonts.primary, + color: theme.colors['gray-50'], + fontWeight: theme.fontWeights.bold as any, + fontSize: remToPx(theme.fontSizes['x-small']), + }, + pageButtonGap: theme.space[3] as number, + }; + }, + [theme] + ); + + return React.useMemo(() => ({ getLegend }), [getLegend]); +}; + +export default useChartOptions;