mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
618 lines
19 KiB
TypeScript
618 lines
19 KiB
TypeScript
import {Component} from 'react';
|
|
import type {InjectedRouter} from 'react-router';
|
|
import type {Theme} from '@emotion/react';
|
|
import {withTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import type {DataZoomComponentOption, LegendComponentOption} from 'echarts';
|
|
import type {Location} from 'history';
|
|
import isEqual from 'lodash/isEqual';
|
|
import omit from 'lodash/omit';
|
|
|
|
import {AreaChart} from 'sentry/components/charts/areaChart';
|
|
import {BarChart} from 'sentry/components/charts/barChart';
|
|
import ChartZoom from 'sentry/components/charts/chartZoom';
|
|
import ErrorPanel from 'sentry/components/charts/errorPanel';
|
|
import {LineChart} from 'sentry/components/charts/lineChart';
|
|
import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
|
|
import TransitionChart from 'sentry/components/charts/transitionChart';
|
|
import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
|
|
import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import type {PlaceholderProps} from 'sentry/components/placeholder';
|
|
import Placeholder from 'sentry/components/placeholder';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {IconWarning} from 'sentry/icons';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization, PageFilters} from 'sentry/types';
|
|
import type {
|
|
EChartDataZoomHandler,
|
|
EChartEventHandler,
|
|
ReactEchartsRef,
|
|
Series,
|
|
} from 'sentry/types/echarts';
|
|
import {
|
|
axisLabelFormatter,
|
|
axisLabelFormatterUsingAggregateOutputType,
|
|
getDurationUnit,
|
|
tooltipFormatter,
|
|
} from 'sentry/utils/discover/charts';
|
|
import {getFieldFormatter} from 'sentry/utils/discover/fieldRenderers';
|
|
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
|
|
import {
|
|
aggregateOutputType,
|
|
getAggregateArg,
|
|
getEquation,
|
|
getMeasurementSlug,
|
|
isEquation,
|
|
maybeEquationAlias,
|
|
stripDerivedMetricsPrefix,
|
|
stripEquationPrefix,
|
|
} from 'sentry/utils/discover/fields';
|
|
import getDynamicText from 'sentry/utils/getDynamicText';
|
|
import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
|
|
|
|
import {getFormatter} from '../../../components/charts/components/tooltip';
|
|
import {getDatasetConfig} from '../datasetConfig/base';
|
|
import type {Widget} from '../types';
|
|
import {DisplayType} from '../types';
|
|
|
|
import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
|
|
|
|
const OTHER = 'Other';
|
|
export const SLIDER_HEIGHT = 60;
|
|
|
|
export type AugmentedEChartDataZoomHandler = (
|
|
params: Parameters<EChartDataZoomHandler>[0] & {
|
|
seriesEnd: string | number;
|
|
seriesStart: string | number;
|
|
},
|
|
instance: Parameters<EChartDataZoomHandler>[1]
|
|
) => void;
|
|
|
|
type TableResultProps = Pick<
|
|
GenericWidgetQueriesChildrenProps,
|
|
'errorMessage' | 'loading' | 'tableResults'
|
|
>;
|
|
|
|
type WidgetCardChartProps = Pick<
|
|
GenericWidgetQueriesChildrenProps,
|
|
'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
|
|
> & {
|
|
location: Location;
|
|
organization: Organization;
|
|
router: InjectedRouter;
|
|
selection: PageFilters;
|
|
theme: Theme;
|
|
widget: Widget;
|
|
chartGroup?: string;
|
|
chartZoomOptions?: DataZoomComponentOption;
|
|
expandNumbers?: boolean;
|
|
isMobile?: boolean;
|
|
legendOptions?: LegendComponentOption;
|
|
noPadding?: boolean;
|
|
onLegendSelectChanged?: EChartEventHandler<{
|
|
name: string;
|
|
selected: Record<string, boolean>;
|
|
type: 'legendselectchanged';
|
|
}>;
|
|
onZoom?: AugmentedEChartDataZoomHandler;
|
|
showSlider?: boolean;
|
|
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
|
|
windowWidth?: number;
|
|
};
|
|
|
|
type State = {
|
|
// For tracking height of the container wrapping BigNumber widgets
|
|
// so we can dynamically scale font-size
|
|
containerHeight: number;
|
|
};
|
|
|
|
class WidgetCardChart extends Component<WidgetCardChartProps, State> {
|
|
state = {containerHeight: 0};
|
|
|
|
shouldComponentUpdate(nextProps: WidgetCardChartProps, nextState: State): boolean {
|
|
if (
|
|
this.props.widget.displayType === DisplayType.BIG_NUMBER &&
|
|
nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
|
|
(this.props.windowWidth !== nextProps.windowWidth ||
|
|
!isEqual(this.props.widget?.layout, nextProps.widget?.layout))
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Widget title changes should not update the WidgetCardChart component tree
|
|
const currentProps = {
|
|
...omit(this.props, ['windowWidth']),
|
|
widget: {
|
|
...this.props.widget,
|
|
title: '',
|
|
},
|
|
};
|
|
|
|
nextProps = {
|
|
...omit(nextProps, ['windowWidth']),
|
|
widget: {
|
|
...nextProps.widget,
|
|
title: '',
|
|
},
|
|
};
|
|
|
|
return !isEqual(currentProps, nextProps) || !isEqual(this.state, nextState);
|
|
}
|
|
|
|
tableResultComponent({
|
|
loading,
|
|
errorMessage,
|
|
tableResults,
|
|
}: TableResultProps): React.ReactNode {
|
|
const {location, widget, selection} = this.props;
|
|
if (errorMessage) {
|
|
return (
|
|
<StyledErrorPanel>
|
|
<IconWarning color="gray500" size="lg" />
|
|
</StyledErrorPanel>
|
|
);
|
|
}
|
|
|
|
if (typeof tableResults === 'undefined') {
|
|
// Align height to other charts.
|
|
return <LoadingPlaceholder />;
|
|
}
|
|
|
|
const datasetConfig = getDatasetConfig(widget.widgetType);
|
|
|
|
return tableResults.map((result, i) => {
|
|
const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
|
|
const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
|
|
const eventView = eventViewFromWidget(widget.title, widget.queries[0], selection);
|
|
|
|
return (
|
|
<StyledSimpleTableChart
|
|
key={`table:${result.title}`}
|
|
eventView={eventView}
|
|
fieldAliases={fieldAliases}
|
|
location={location}
|
|
fields={fields}
|
|
title={tableResults.length > 1 ? result.title : ''}
|
|
loading={loading}
|
|
loader={<LoadingPlaceholder />}
|
|
metadata={result.meta}
|
|
data={result.data}
|
|
stickyHeaders
|
|
fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
|
|
getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
|
|
/>
|
|
);
|
|
});
|
|
}
|
|
|
|
bigNumberComponent({
|
|
loading,
|
|
errorMessage,
|
|
tableResults,
|
|
}: TableResultProps): React.ReactNode {
|
|
if (errorMessage) {
|
|
return (
|
|
<StyledErrorPanel>
|
|
<IconWarning color="gray500" size="lg" />
|
|
</StyledErrorPanel>
|
|
);
|
|
}
|
|
|
|
if (typeof tableResults === 'undefined' || loading) {
|
|
return <BigNumber>{'\u2014'}</BigNumber>;
|
|
}
|
|
|
|
const {containerHeight} = this.state;
|
|
const {location, organization, widget, isMobile, expandNumbers} = this.props;
|
|
|
|
return tableResults.map(result => {
|
|
const tableMeta = {...result.meta};
|
|
const fields = Object.keys(tableMeta);
|
|
|
|
const field = fields[0];
|
|
|
|
// Change tableMeta for the field from integer to string since we will be rendering with toLocaleString
|
|
const shouldExpandInteger = !!expandNumbers && tableMeta[field] === 'integer';
|
|
if (shouldExpandInteger) {
|
|
tableMeta[field] = 'string';
|
|
}
|
|
|
|
if (!field || !result.data?.length) {
|
|
return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
|
|
}
|
|
|
|
const dataRow = result.data[0];
|
|
const fieldRenderer = getFieldFormatter(field, tableMeta, false);
|
|
|
|
const unit = tableMeta.units?.[field];
|
|
const rendered = fieldRenderer(
|
|
shouldExpandInteger ? {[field]: dataRow[field].toLocaleString()} : dataRow,
|
|
{location, organization, unit}
|
|
);
|
|
|
|
const isModalWidget = !(widget.id || widget.tempId);
|
|
if (isModalWidget || isMobile) {
|
|
return <BigNumber key={`big_number:${result.title}`}>{rendered}</BigNumber>;
|
|
}
|
|
|
|
// The font size is the container height, minus the top and bottom padding
|
|
const fontSize = !expandNumbers
|
|
? containerHeight - parseInt(space(1), 10) - parseInt(space(3), 10)
|
|
: `max(min(8vw, 90px), ${space(4)})`;
|
|
|
|
return (
|
|
<BigNumber
|
|
key={`big_number:${result.title}`}
|
|
style={{
|
|
fontSize,
|
|
...(expandNumbers ? {padding: `${space(1)} ${space(3)} 0 ${space(3)}`} : {}),
|
|
}}
|
|
>
|
|
<Tooltip title={rendered} showOnlyOnOverflow>
|
|
{rendered}
|
|
</Tooltip>
|
|
</BigNumber>
|
|
);
|
|
});
|
|
}
|
|
|
|
chartRef: ReactEchartsRef | null = null;
|
|
|
|
handleRef = (chartRef: ReactEchartsRef): void => {
|
|
if (chartRef && !this.chartRef) {
|
|
this.chartRef = chartRef;
|
|
// add chart to the group so that it has synced cursors
|
|
const instance = chartRef.getEchartsInstance?.();
|
|
if (instance && !instance.group && this.props.chartGroup) {
|
|
instance.group = this.props.chartGroup;
|
|
}
|
|
}
|
|
|
|
if (!chartRef) {
|
|
this.chartRef = null;
|
|
}
|
|
};
|
|
|
|
chartComponent(chartProps): React.ReactNode {
|
|
const {widget} = this.props;
|
|
const stacked = widget.queries[0]?.columns.length > 0;
|
|
|
|
switch (widget.displayType) {
|
|
case 'bar':
|
|
return <BarChart {...chartProps} stacked={stacked} />;
|
|
case 'area':
|
|
case 'top_n':
|
|
return <AreaChart stacked {...chartProps} />;
|
|
case 'line':
|
|
default:
|
|
return <LineChart {...chartProps} />;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
theme,
|
|
tableResults,
|
|
timeseriesResults,
|
|
errorMessage,
|
|
loading,
|
|
widget,
|
|
onZoom,
|
|
legendOptions,
|
|
expandNumbers,
|
|
showSlider,
|
|
noPadding,
|
|
chartZoomOptions,
|
|
timeseriesResultsTypes,
|
|
} = this.props;
|
|
|
|
if (widget.displayType === 'table') {
|
|
return getDynamicText({
|
|
value: (
|
|
<TransitionChart loading={loading} reloading={loading}>
|
|
<LoadingScreen loading={loading} />
|
|
{this.tableResultComponent({tableResults, loading, errorMessage})}
|
|
</TransitionChart>
|
|
),
|
|
fixed: <Placeholder height="200px" testId="skeleton-ui" />,
|
|
});
|
|
}
|
|
|
|
if (widget.displayType === 'big_number') {
|
|
return (
|
|
<TransitionChart loading={loading} reloading={loading}>
|
|
<LoadingScreen loading={loading} />
|
|
<BigNumberResizeWrapper
|
|
ref={el => {
|
|
if (el !== null && !expandNumbers) {
|
|
const {height} = el.getBoundingClientRect();
|
|
if (height !== this.state.containerHeight) {
|
|
this.setState({containerHeight: height});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{this.bigNumberComponent({tableResults, loading, errorMessage})}
|
|
</BigNumberResizeWrapper>
|
|
</TransitionChart>
|
|
);
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return (
|
|
<StyledErrorPanel>
|
|
<IconWarning color="gray500" size="lg" />
|
|
</StyledErrorPanel>
|
|
);
|
|
}
|
|
|
|
const {location, router, selection, onLegendSelectChanged} = this.props;
|
|
const {start, end, period, utc} = selection.datetime;
|
|
|
|
// Only allow height resizing for widgets that are on a dashboard
|
|
const autoHeightResize = Boolean(widget.id || widget.tempId);
|
|
|
|
const legend = {
|
|
left: 0,
|
|
top: 0,
|
|
selected: getSeriesSelection(location),
|
|
formatter: (seriesName: string) => {
|
|
const arg = getAggregateArg(seriesName);
|
|
if (arg !== null) {
|
|
const slug = getMeasurementSlug(arg);
|
|
if (slug !== null) {
|
|
seriesName = slug.toUpperCase();
|
|
}
|
|
}
|
|
if (maybeEquationAlias(seriesName)) {
|
|
seriesName = stripEquationPrefix(seriesName);
|
|
}
|
|
return seriesName;
|
|
},
|
|
...legendOptions,
|
|
};
|
|
|
|
const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
|
|
const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
|
|
|
|
// Check to see if all series output types are the same. If not, then default to number.
|
|
const outputType =
|
|
timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
|
|
? timeseriesResultsTypes[axisLabel]
|
|
: 'number';
|
|
const isDurationChart = outputType === 'duration';
|
|
const durationUnit = isDurationChart
|
|
? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
|
|
: undefined;
|
|
const bucketSize = getBucketSize(timeseriesResults);
|
|
|
|
const valueFormatter = (value: number, seriesName?: string) => {
|
|
const aggregateName = seriesName?.split(':').pop()?.trim();
|
|
if (aggregateName) {
|
|
return timeseriesResultsTypes
|
|
? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
|
|
: tooltipFormatter(value, aggregateOutputType(aggregateName));
|
|
}
|
|
return tooltipFormatter(value, 'number');
|
|
};
|
|
|
|
const chartOptions = {
|
|
autoHeightResize,
|
|
grid: {
|
|
left: 0,
|
|
right: 4,
|
|
top: '40px',
|
|
bottom: showSlider ? SLIDER_HEIGHT : 0,
|
|
},
|
|
seriesOptions: {
|
|
showSymbol: false,
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
formatter: (params, asyncTicket) => {
|
|
const {chartGroup} = this.props;
|
|
const isInGroup =
|
|
chartGroup && chartGroup === this.chartRef?.getEchartsInstance().group;
|
|
|
|
// tooltip is triggered whenever any chart in the group is hovered,
|
|
// so we need to check if the mouse is actually over this chart
|
|
if (isInGroup && !isChartHovered(this.chartRef)) {
|
|
return '';
|
|
}
|
|
|
|
return getFormatter({
|
|
valueFormatter,
|
|
isGroupedByDate: true,
|
|
bucketSize,
|
|
addSecondsToTimeFormat: false,
|
|
showTimeInTooltip: true,
|
|
})(params, asyncTicket);
|
|
},
|
|
},
|
|
yAxis: {
|
|
axisLabel: {
|
|
color: theme.chartLabel,
|
|
formatter: (value: number) => {
|
|
if (timeseriesResultsTypes) {
|
|
return axisLabelFormatterUsingAggregateOutputType(
|
|
value,
|
|
outputType,
|
|
undefined,
|
|
durationUnit
|
|
);
|
|
}
|
|
return axisLabelFormatter(value, aggregateOutputType(axisLabel));
|
|
},
|
|
},
|
|
minInterval: durationUnit ?? 0,
|
|
},
|
|
xAxis: {
|
|
axisPointer: {
|
|
snap: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
return (
|
|
<ChartZoom
|
|
router={router}
|
|
period={period}
|
|
start={start}
|
|
end={end}
|
|
utc={utc}
|
|
showSlider={showSlider}
|
|
chartZoomOptions={chartZoomOptions}
|
|
>
|
|
{zoomRenderProps => {
|
|
if (errorMessage) {
|
|
return (
|
|
<StyledErrorPanel>
|
|
<IconWarning color="gray500" size="lg" />
|
|
</StyledErrorPanel>
|
|
);
|
|
}
|
|
|
|
const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
|
|
const shouldColorOther = timeseriesResults?.some(({seriesName}) =>
|
|
seriesName?.match(otherRegex)
|
|
);
|
|
const colors = timeseriesResults
|
|
? theme.charts.getColorPalette(
|
|
timeseriesResults.length - (shouldColorOther ? 3 : 2)
|
|
)
|
|
: [];
|
|
// TODO(wmak): Need to change this when updating dashboards to support variable topEvents
|
|
if (shouldColorOther) {
|
|
colors[colors.length] = theme.chartOther;
|
|
}
|
|
|
|
// Create a list of series based on the order of the fields,
|
|
const series = timeseriesResults
|
|
? timeseriesResults.map((values, i: number) => {
|
|
let seriesName = '';
|
|
if (values.seriesName !== undefined) {
|
|
seriesName = isEquation(values.seriesName)
|
|
? getEquation(values.seriesName)
|
|
: values.seriesName;
|
|
}
|
|
return {
|
|
...values,
|
|
seriesName,
|
|
color: colors[i],
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const seriesStart = series[0]?.data[0]?.name;
|
|
const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
|
|
|
|
const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
|
|
|
|
return (
|
|
<TransitionChart loading={loading} reloading={loading}>
|
|
<LoadingScreen loading={loading} />
|
|
<ChartWrapper autoHeightResize={autoHeightResize} noPadding={noPadding}>
|
|
{getDynamicText({
|
|
value: this.chartComponent({
|
|
...zoomRenderProps,
|
|
...chartOptions,
|
|
// Override default datazoom behaviour for updating Global Selection Header
|
|
...(onZoom
|
|
? {
|
|
onDataZoom: (evt, chartProps) =>
|
|
// Need to pass seriesStart and seriesEnd to onZoom since slider zooms
|
|
// callback with percentage instead of datetime values. Passing seriesStart
|
|
// and seriesEnd allows calculating datetime values with percentage.
|
|
onZoom({...evt, seriesStart, seriesEnd}, chartProps),
|
|
}
|
|
: {}),
|
|
legend,
|
|
series,
|
|
onLegendSelectChanged,
|
|
forwardedRef,
|
|
}),
|
|
fixed: <Placeholder height="200px" testId="skeleton-ui" />,
|
|
})}
|
|
</ChartWrapper>
|
|
</TransitionChart>
|
|
);
|
|
}}
|
|
</ChartZoom>
|
|
);
|
|
}
|
|
}
|
|
|
|
const getBucketSize = (series: Series[] | undefined) => {
|
|
if (!series || series.length < 2) {
|
|
return 0;
|
|
}
|
|
|
|
return Number(series[0].data[1]?.name) - Number(series[0].data[0]?.name);
|
|
};
|
|
|
|
export default withTheme(WidgetCardChart);
|
|
|
|
const StyledTransparentLoadingMask = styled(props => (
|
|
<TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
|
|
))`
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
`;
|
|
|
|
function LoadingScreen({loading}: {loading: boolean}) {
|
|
if (!loading) {
|
|
return null;
|
|
}
|
|
return (
|
|
<StyledTransparentLoadingMask visible={loading}>
|
|
<LoadingIndicator mini />
|
|
</StyledTransparentLoadingMask>
|
|
);
|
|
}
|
|
|
|
const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
|
|
<Placeholder height="200px" className={className} />
|
|
))`
|
|
background-color: ${p => p.theme.surface300};
|
|
`;
|
|
|
|
const BigNumberResizeWrapper = styled('div')`
|
|
height: 100%;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const BigNumber = styled('div')`
|
|
line-height: 1;
|
|
display: inline-flex;
|
|
flex: 1;
|
|
width: 100%;
|
|
min-height: 0;
|
|
font-size: 32px;
|
|
color: ${p => p.theme.headingColor};
|
|
padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
|
|
|
|
* {
|
|
text-align: left !important;
|
|
}
|
|
`;
|
|
|
|
const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
|
|
${p => p.autoHeightResize && 'height: 100%;'}
|
|
padding: ${p => (p.noPadding ? `0` : `0 ${space(3)} ${space(3)}`)};
|
|
`;
|
|
|
|
const StyledSimpleTableChart = styled(SimpleTableChart)`
|
|
margin-top: ${space(1.5)};
|
|
border-bottom-left-radius: ${p => p.theme.borderRadius};
|
|
border-bottom-right-radius: ${p => p.theme.borderRadius};
|
|
font-size: ${p => p.theme.fontSizeMedium};
|
|
box-shadow: none;
|
|
`;
|
|
|
|
const StyledErrorPanel = styled(ErrorPanel)`
|
|
padding: ${space(2)};
|
|
`;
|