Skip to content

Commit

Permalink
Extend TimeSeriesChart to displays bars & Tooltip based on metadata (…
Browse files Browse the repository at this point in the history
…#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 fa7884e.

* Added default tooltip for charts

* Updated types

Co-authored-by: panther-bot <[email protected]>
  • Loading branch information
alexmylonas and panther-bot authored Nov 20, 2020
1 parent 561d56e commit 7eef8e5
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 74 deletions.
55 changes: 55 additions & 0 deletions web/src/components/charts/TimeSeriesChart/ChartTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ChartTooltipProps> = ({ params, units }) => {
return (
<Box font="primary" minWidth={200} boxShadow="dark250" p={2} borderRadius="medium">
<Text fontSize="small-medium" mb={3}>
{formatDatetime(params[0].value[0], true)}
</Text>
<Flex direction="column" spacing={2} fontSize="x-small">
{params.map((seriesInfo, i) => {
return (
<Flex key={`chart-tooltip ${i}`} direction="column" spacing={2} fontSize="x-small">
<Flex key={seriesInfo.seriesName} justify="space-between">
<Box as="dt">
<span dangerouslySetInnerHTML={{ __html: seriesInfo.marker }} />
{seriesInfo.seriesName}
</Box>
<Box as="dd" font="mono" fontWeight="bold">
{seriesInfo.value[1].toLocaleString('en')}
{units ? ` ${units}` : ''}
</Box>
</Flex>
</Flex>
);
})}
</Flex>
</Box>
);
};

export default ChartTooltip;
138 changes: 64 additions & 74 deletions web/src/components/charts/TimeSeriesChart/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -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<ChartTooltipProps>;
}

const severityColors = mapKeys(SEVERITY_COLOR_MAP, (val, key) => capitalize(key.toLowerCase()));
Expand All @@ -79,21 +113,25 @@ function formatDateString(timestamp) {
return `${hourFormat(timestamp)}\n${dateFormat(timestamp).toUpperCase()}`;
}

const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({
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<ECharts>(null);
const container = React.useRef<HTMLDivElement>(null);
const tooltip = React.useRef<HTMLDivElement>(document.createElement('div'));

/*
* Defining ChartOptions
*/
Expand All @@ -102,34 +140,34 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
* 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
* like 'type', 'symbol' and 'itemStyle' and most importantly 'data' which
* 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.
Expand All @@ -143,7 +181,7 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({

const options: EChartOption = {
grid: {
left: 180,
left: hideLegend ? 0 : 180,
right: 50,
bottom: 50,
containLabel: true,
Expand Down Expand Up @@ -178,67 +216,20 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
}),
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 = (
<Box font="primary" minWidth={200} boxShadow="dark250" p={2} borderRadius="medium">
<Text fontSize="small-medium" mb={3}>
{formatDatetime(params[0].value[0], true)}
</Text>
<Flex as="dl" direction="column" spacing={2} fontSize="x-small">
{params.map(seriesTooltip => (
<Flex key={seriesTooltip.seriesName} justify="space-between">
<Box as="dt">
<span dangerouslySetInnerHTML={{ __html: seriesTooltip.marker }} />
{seriesTooltip.seriesName}
</Box>
<Box as="dd" font="mono" fontWeight="bold">
{seriesTooltip.value[1].toLocaleString('en')}
{units ? ` ${units}` : ''}
</Box>
</Flex>
))}
</Flex>
</Box>
);

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,
Expand Down Expand Up @@ -296,7 +287,7 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
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
Expand Down Expand Up @@ -343,7 +334,7 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
// 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;
Expand All @@ -370,8 +361,7 @@ const TimeSeriesChart: React.FC<TimeSeriesLinesProps> = ({
<Box position="absolute" width="200px" ml={1} fontWeight="bold">
{title}
</Box>

<Box position="absolute" pl="210px" pr="50px" width={1}>
<Box position="absolute" pl={hideLegend ? '50px' : '210px'} pr="50px" width={1}>
<Flex align="center" justify="space-between">
{scaleControls && <ScaleControls scaleType={scaleType} onSelect={setScaleType} />}
<Box zIndex={5}>
Expand Down
77 changes: 77 additions & 0 deletions web/src/components/charts/TimeSeriesChart/useChartOptions.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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;

0 comments on commit 7eef8e5

Please sign in to comment.