mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import * as Sentry from '@sentry/react';
|
|
import Color from 'color';
|
|
import * as echarts from 'echarts/core';
|
|
import {CanvasRenderer} from 'echarts/renderers';
|
|
|
|
import {updateDateTime} from 'sentry/actionCreators/pageFilters';
|
|
import {transformToAreaSeries} from 'sentry/components/charts/areaChart';
|
|
import {transformToBarSeries} from 'sentry/components/charts/barChart';
|
|
import type {BaseChartProps} from 'sentry/components/charts/baseChart';
|
|
import BaseChart from 'sentry/components/charts/baseChart';
|
|
import {transformToLineSeries} from 'sentry/components/charts/lineChart';
|
|
import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
|
|
import type {DateTimeObject} from 'sentry/components/charts/utils';
|
|
import {t} from 'sentry/locale';
|
|
import type {ReactEchartsRef} from 'sentry/types/echarts';
|
|
import mergeRefs from 'sentry/utils/mergeRefs';
|
|
import {isCumulativeOp} from 'sentry/utils/metrics';
|
|
import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
|
|
import {MetricDisplayType} from 'sentry/utils/metrics/types';
|
|
import useRouter from 'sentry/utils/useRouter';
|
|
import type {FocusAreaProps} from 'sentry/views/ddm/context';
|
|
import {useFocusArea} from 'sentry/views/ddm/focusArea';
|
|
|
|
import {
|
|
defaultFormatAxisLabel,
|
|
getFormatter,
|
|
} from '../../components/charts/components/tooltip';
|
|
import {isChartHovered} from '../../components/charts/utils';
|
|
|
|
import {useChartSamples} from './useChartSamples';
|
|
import type {SamplesProps, ScatterSeries as ScatterSeriesType, Series} from './widget';
|
|
|
|
type ChartProps = {
|
|
displayType: MetricDisplayType;
|
|
series: Series[];
|
|
widgetIndex: number;
|
|
focusArea?: FocusAreaProps;
|
|
group?: string;
|
|
height?: number;
|
|
operation?: string;
|
|
scatter?: SamplesProps;
|
|
};
|
|
|
|
// We need to enable canvas renderer for echarts before we use it here.
|
|
// Once we use it in more places, this should probably move to a more global place
|
|
// But for now we keep it here to not invluence the bundle size of the main chunks.
|
|
echarts.use(CanvasRenderer);
|
|
|
|
function isNonZeroValue(value: number | null) {
|
|
return value !== null && value !== 0;
|
|
}
|
|
|
|
function addSeriesPadding(data: Series['data']) {
|
|
const hasNonZeroSibling = (index: number) => {
|
|
return (
|
|
isNonZeroValue(data[index - 1]?.value) || isNonZeroValue(data[index + 1]?.value)
|
|
);
|
|
};
|
|
const paddingIndices = new Set<number>();
|
|
return {
|
|
data: data.map(({name, value}, index) => {
|
|
const shouldAddPadding = value === null && hasNonZeroSibling(index);
|
|
if (shouldAddPadding) {
|
|
paddingIndices.add(index);
|
|
}
|
|
return {
|
|
name,
|
|
value: shouldAddPadding ? 0 : value,
|
|
};
|
|
}),
|
|
paddingIndices,
|
|
};
|
|
}
|
|
|
|
export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
|
|
(
|
|
{series, displayType, operation, widgetIndex, focusArea, height, scatter, group},
|
|
forwardedRef
|
|
) => {
|
|
const router = useRouter();
|
|
const chartRef = useRef<ReactEchartsRef>(null);
|
|
|
|
const handleZoom = useCallback(
|
|
(range: DateTimeObject) => {
|
|
Sentry.metrics.increment('ddm.enhance.zoom');
|
|
updateDateTime(range, router, {save: true});
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const focusAreaBrush = useFocusArea({
|
|
...focusArea,
|
|
chartRef,
|
|
opts: {
|
|
widgetIndex,
|
|
isDisabled: !focusArea?.onAdd || !handleZoom,
|
|
useFullYAxis: isCumulativeOp(operation),
|
|
},
|
|
onZoom: handleZoom,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!group) {
|
|
return;
|
|
}
|
|
const echartsInstance = chartRef?.current?.getEchartsInstance();
|
|
if (echartsInstance && !echartsInstance.group) {
|
|
echartsInstance.group = group;
|
|
}
|
|
});
|
|
|
|
// TODO(ddm): This assumes that all series have the same bucket size
|
|
const bucketSize = series[0]?.data[1]?.name - series[0]?.data[0]?.name;
|
|
const isSubMinuteBucket = bucketSize < 60_000;
|
|
|
|
const unit = series.find(s => !s.hidden)?.unit || series[0]?.unit || '';
|
|
const fogOfWarBuckets = getWidthFactor(bucketSize);
|
|
|
|
const seriesToShow = useMemo(
|
|
() =>
|
|
series
|
|
.filter(s => !s.hidden)
|
|
.map(s => ({
|
|
...s,
|
|
...(displayType !== MetricDisplayType.BAR
|
|
? addSeriesPadding(s.data)
|
|
: {data: s.data}),
|
|
}))
|
|
// Split series in two parts, one for the main chart and one for the fog of war
|
|
// The order is important as the tooltip will show the first series first (for overlaps)
|
|
.flatMap(s => [
|
|
{
|
|
...s,
|
|
silent: true,
|
|
data: s.data.slice(0, -fogOfWarBuckets),
|
|
},
|
|
displayType === MetricDisplayType.BAR
|
|
? createFogOfWarBarSeries(s, fogOfWarBuckets)
|
|
: displayType === MetricDisplayType.LINE
|
|
? createFogOfWarLineSeries(s, fogOfWarBuckets)
|
|
: createFogOfWarAreaSeries(s, fogOfWarBuckets),
|
|
]),
|
|
[series, fogOfWarBuckets, displayType]
|
|
);
|
|
|
|
const valueFormatter = useCallback(
|
|
(value: number) => {
|
|
return formatMetricsUsingUnitAndOp(value, unit, operation);
|
|
},
|
|
[unit, operation]
|
|
);
|
|
|
|
const samples = useChartSamples({
|
|
chartRef,
|
|
correlations: scatter?.data,
|
|
onClick: scatter?.onClick,
|
|
highlightedSampleId: scatter?.higlightedId,
|
|
operation,
|
|
timeseries: series,
|
|
valueFormatter,
|
|
});
|
|
|
|
const chartProps = useMemo(() => {
|
|
const hasMultipleUnits = new Set(seriesToShow.map(s => s.unit)).size > 1;
|
|
const seriesMeta = seriesToShow.reduce(
|
|
(acc, s) => {
|
|
acc[s.seriesName] = {
|
|
unit: s.unit,
|
|
operation: s.operation,
|
|
};
|
|
return acc;
|
|
},
|
|
{} as Record<string, {operation: string; unit: string}>
|
|
);
|
|
|
|
const timeseriesFormatters = {
|
|
valueFormatter: (value: number, seriesName?: string) => {
|
|
const meta = seriesName ? seriesMeta[seriesName] : {unit, operation};
|
|
return formatMetricsUsingUnitAndOp(value, meta.unit, meta.operation);
|
|
},
|
|
isGroupedByDate: true,
|
|
bucketSize,
|
|
showTimeInTooltip: true,
|
|
addSecondsToTimeFormat: isSubMinuteBucket,
|
|
limit: 10,
|
|
filter: (_, seriesParam) => {
|
|
return seriesParam?.axisId === 'xAxis';
|
|
},
|
|
};
|
|
|
|
const heightOptions = height ? {height} : {autoHeightResize: true};
|
|
|
|
return {
|
|
...heightOptions,
|
|
...focusAreaBrush.options,
|
|
forwardedRef: mergeRefs([forwardedRef, chartRef]),
|
|
series: seriesToShow,
|
|
devicePixelRatio: 2,
|
|
renderer: 'canvas' as const,
|
|
isGroupedByDate: true,
|
|
colors: seriesToShow.map(s => s.color),
|
|
grid: {top: 5, bottom: 0, left: 0, right: 0},
|
|
onClick: samples.handleClick,
|
|
tooltip: {
|
|
formatter: (params, asyncTicket) => {
|
|
if (focusAreaBrush.isDrawingRef.current) {
|
|
return '';
|
|
}
|
|
if (!isChartHovered(chartRef?.current)) {
|
|
return '';
|
|
}
|
|
|
|
// Hovering a single correlated sample datapoint
|
|
if (params.seriesType === 'scatter') {
|
|
return getFormatter(samples.formatters)(params, asyncTicket);
|
|
}
|
|
|
|
// The mechanism by which we add the fog of war series to the chart, duplicates the series in the chart data
|
|
// so we need to deduplicate the series before showing the tooltip
|
|
// this assumes that the first series is the main series and the second is the fog of war series
|
|
if (Array.isArray(params)) {
|
|
const uniqueSeries = new Set<string>();
|
|
const deDupedParams = params.filter(param => {
|
|
// Filter null values from tooltip
|
|
if (param.value[1] === null) {
|
|
return false;
|
|
}
|
|
|
|
// scatter series (samples) have their own tooltip
|
|
if (param.seriesType === 'scatter') {
|
|
return false;
|
|
}
|
|
|
|
// Filter padding datapoints from tooltip
|
|
if (param.value[1] === 0) {
|
|
const currentSeries = seriesToShow[param.seriesIndex];
|
|
const paddingIndices =
|
|
'paddingIndices' in currentSeries
|
|
? currentSeries.paddingIndices
|
|
: undefined;
|
|
if (paddingIndices?.has(param.dataIndex)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (uniqueSeries.has(param.seriesName)) {
|
|
return false;
|
|
}
|
|
uniqueSeries.add(param.seriesName);
|
|
return true;
|
|
});
|
|
|
|
const date = defaultFormatAxisLabel(
|
|
params[0].value[0] as number,
|
|
timeseriesFormatters.isGroupedByDate,
|
|
false,
|
|
timeseriesFormatters.showTimeInTooltip,
|
|
timeseriesFormatters.addSecondsToTimeFormat,
|
|
timeseriesFormatters.bucketSize
|
|
);
|
|
|
|
if (deDupedParams.length === 0) {
|
|
return [
|
|
'<div class="tooltip-series">',
|
|
`<center>${t('No data available')}</center>`,
|
|
'</div>',
|
|
`<div class="tooltip-footer">${date}</div>`,
|
|
].join('');
|
|
}
|
|
return getFormatter(timeseriesFormatters)(deDupedParams, asyncTicket);
|
|
}
|
|
return getFormatter(timeseriesFormatters)(params, asyncTicket);
|
|
},
|
|
},
|
|
yAxes: [
|
|
{
|
|
// used to find and convert datapoint to pixel position
|
|
id: 'yAxis',
|
|
axisLabel: {
|
|
formatter: (value: number) => {
|
|
return formatMetricsUsingUnitAndOp(
|
|
value,
|
|
hasMultipleUnits ? 'none' : unit,
|
|
operation
|
|
);
|
|
},
|
|
},
|
|
},
|
|
samples.yAxis,
|
|
],
|
|
xAxes: [
|
|
{
|
|
// used to find and convert datapoint to pixel position
|
|
id: 'xAxis',
|
|
axisPointer: {
|
|
snap: true,
|
|
},
|
|
},
|
|
samples.xAxis,
|
|
],
|
|
};
|
|
}, [
|
|
seriesToShow,
|
|
bucketSize,
|
|
isSubMinuteBucket,
|
|
height,
|
|
focusAreaBrush.options,
|
|
focusAreaBrush.isDrawingRef,
|
|
forwardedRef,
|
|
samples.handleClick,
|
|
samples.yAxis,
|
|
samples.xAxis,
|
|
samples.formatters,
|
|
unit,
|
|
operation,
|
|
]);
|
|
|
|
return (
|
|
<ChartWrapper>
|
|
{focusAreaBrush.overlay}
|
|
<CombinedChart
|
|
{...chartProps}
|
|
displayType={displayType}
|
|
scatterSeries={samples.series}
|
|
/>
|
|
</ChartWrapper>
|
|
);
|
|
}
|
|
);
|
|
|
|
interface CombinedChartProps extends BaseChartProps {
|
|
displayType: MetricDisplayType;
|
|
series: Series[];
|
|
scatterSeries?: ScatterSeriesType[];
|
|
}
|
|
|
|
function CombinedChart({
|
|
displayType,
|
|
series,
|
|
scatterSeries = [],
|
|
...chartProps
|
|
}: CombinedChartProps) {
|
|
const combinedSeries = useMemo(() => {
|
|
if (displayType === MetricDisplayType.LINE) {
|
|
return [
|
|
...transformToLineSeries({series}),
|
|
...transformToScatterSeries({series: scatterSeries, displayType}),
|
|
];
|
|
}
|
|
|
|
if (displayType === MetricDisplayType.BAR) {
|
|
return [
|
|
...transformToBarSeries({series, stacked: true, animation: false}),
|
|
...transformToScatterSeries({series: scatterSeries, displayType}),
|
|
];
|
|
}
|
|
|
|
if (displayType === MetricDisplayType.AREA) {
|
|
return [
|
|
...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
|
|
...transformToScatterSeries({series: scatterSeries, displayType}),
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}, [displayType, scatterSeries, series, chartProps.colors]);
|
|
|
|
return <BaseChart {...chartProps} series={combinedSeries} />;
|
|
}
|
|
|
|
function transformToScatterSeries({
|
|
series,
|
|
displayType,
|
|
}: {
|
|
displayType: MetricDisplayType;
|
|
series: Series[];
|
|
}) {
|
|
return series.map(({seriesName, data: seriesData, ...options}) => {
|
|
if (displayType === MetricDisplayType.BAR) {
|
|
return ScatterSeries({
|
|
...options,
|
|
name: seriesName,
|
|
data: seriesData?.map(({value, name}) => ({value: [name, value]})),
|
|
});
|
|
}
|
|
|
|
return ScatterSeries({
|
|
...options,
|
|
name: seriesName,
|
|
data: seriesData?.map(({value, name}) => [name, value]),
|
|
animation: false,
|
|
});
|
|
});
|
|
}
|
|
|
|
const EXTRAPOLATED_AREA_STRIPE_IMG =
|
|
'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAABkCAYAAAC/zKGXAAAAMUlEQVR4Ae3KoREAIAwEsMKgrMeYj8BzyIpEZyTZda16mPVJFEVRFEVRFEVRFMWO8QB4uATKpuU51gAAAABJRU5ErkJggg==';
|
|
|
|
const createFogOfWarBarSeries = (series: Series, fogBucketCnt = 0) => ({
|
|
...series,
|
|
silent: true,
|
|
data: series.data.map((data, index) => ({
|
|
...data,
|
|
// W need to set a value for the non-fog of war buckets so that the stacking still works in echarts
|
|
value: index < series.data.length - fogBucketCnt ? 0 : data.value,
|
|
})),
|
|
itemStyle: {
|
|
opacity: 1,
|
|
decal: {
|
|
symbol: EXTRAPOLATED_AREA_STRIPE_IMG,
|
|
dashArrayX: [6, 0],
|
|
dashArrayY: [6, 0],
|
|
rotation: Math.PI / 4,
|
|
},
|
|
},
|
|
});
|
|
|
|
const createFogOfWarLineSeries = (series: Series, fogBucketCnt = 0) => ({
|
|
...series,
|
|
silent: true,
|
|
// We include the last non-fog of war bucket so that the line is connected
|
|
data: series.data.slice(-fogBucketCnt - 1),
|
|
lineStyle: {
|
|
type: 'dotted',
|
|
},
|
|
});
|
|
|
|
const createFogOfWarAreaSeries = (series: Series, fogBucketCnt = 0) => ({
|
|
...series,
|
|
silent: true,
|
|
stack: 'fogOfWar',
|
|
// We include the last non-fog of war bucket so that the line is connected
|
|
data: series.data.slice(-fogBucketCnt - 1),
|
|
lineStyle: {
|
|
type: 'dotted',
|
|
color: Color(series.color).lighten(0.3).string(),
|
|
},
|
|
});
|
|
|
|
function getWidthFactor(bucketSize: number) {
|
|
// If the bucket size is >= 5 minutes the fog of war should only cover the last bucket
|
|
if (bucketSize >= 5 * 60_000) {
|
|
return 1;
|
|
}
|
|
|
|
// for buckets <= 10s we want to show a fog of war that spans last 10 buckets
|
|
// because on average, we are missing last 90 seconds of data
|
|
if (bucketSize <= 10_000) {
|
|
return 10;
|
|
}
|
|
|
|
// For smaller time frames we want to show a wider fog of war
|
|
return 2;
|
|
}
|
|
|
|
const ChartWrapper = styled('div')`
|
|
position: relative;
|
|
height: 100%;
|
|
`;
|