Skip to content

Commit 39825bf

Browse files
authored
feat(aci): Add chart zoom to metric detector details (#97653)
Removes page filters from the metric detectors page since the available time periods are dictated by the interval chosen in the metric detector. Updates the chart/hooks to use start and end query parameters if available
1 parent af33e23 commit 39825bf

File tree

11 files changed

+210
-50
lines changed

11 files changed

+210
-50
lines changed

static/app/components/charts/useChartZoom.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ export function useChartZoom({
162162
if (usePageDate) {
163163
const newQuery = {
164164
...location.query,
165-
pageStart: startFormatted,
166-
pageEnd: endFormatted,
167-
pageStatsPeriod: newPeriod.period ?? undefined,
165+
start: startFormatted,
166+
end: endFormatted,
167+
statsPeriod: newPeriod.period ?? undefined,
168168
};
169169

170170
// Only push new location if query params has changed because this will cause a heavy re-render

static/app/views/detectors/components/details/index.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
12
import type {Project} from 'sentry/types/project';
23
import type {Detector} from 'sentry/types/workflowEngine/detectors';
34
import {unreachable} from 'sentry/utils/unreachable';
@@ -16,15 +17,32 @@ export function DetectorDetailsContent({detector, project}: DetectorDetailsConte
1617
const detectorType = detector.type;
1718
switch (detectorType) {
1819
case 'metric_issue':
20+
// Metric issue detectors do not support time period filters since the interval dictates what time periods are available.
1921
return <MetricDetectorDetails detector={detector} project={project} />;
2022
case 'uptime_domain_failure':
21-
return <UptimeDetectorDetails detector={detector} project={project} />;
23+
return (
24+
<PageFiltersContainer>
25+
<UptimeDetectorDetails detector={detector} project={project} />
26+
</PageFiltersContainer>
27+
);
2228
case 'error':
23-
return <ErrorDetectorDetails detector={detector} project={project} />;
29+
return (
30+
<PageFiltersContainer>
31+
<ErrorDetectorDetails detector={detector} project={project} />
32+
</PageFiltersContainer>
33+
);
2434
case 'uptime_subscription':
25-
return <CronDetectorDetails detector={detector} project={project} />;
35+
return (
36+
<PageFiltersContainer>
37+
<CronDetectorDetails detector={detector} project={project} />
38+
</PageFiltersContainer>
39+
);
2640
default:
2741
unreachable(detectorType);
28-
return <FallbackDetectorDetails detector={detector} project={project} />;
42+
return (
43+
<PageFiltersContainer>
44+
<FallbackDetectorDetails detector={detector} project={project} />
45+
</PageFiltersContainer>
46+
);
2947
}
3048
}

static/app/views/detectors/components/details/metric/chart.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,39 @@ import type {YAXisComponentOption} from 'echarts';
44

55
import {AreaChart} from 'sentry/components/charts/areaChart';
66
import ErrorPanel from 'sentry/components/charts/errorPanel';
7+
import {useChartZoom} from 'sentry/components/charts/useChartZoom';
78
import {Flex} from 'sentry/components/core/layout';
89
import Placeholder from 'sentry/components/placeholder';
910
import {IconWarning} from 'sentry/icons';
1011
import {t} from 'sentry/locale';
1112
import {space} from 'sentry/styles/space';
1213
import type {MetricDetector, SnubaQuery} from 'sentry/types/workflowEngine/detectors';
13-
import type {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
14+
import {useLocation} from 'sentry/utils/useLocation';
1415
import {getDetectorDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
1516
import {useIncidentMarkers} from 'sentry/views/detectors/hooks/useIncidentMarkers';
1617
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
1718
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
1819

1920
interface MetricDetectorDetailsChartProps {
2021
detector: MetricDetector;
21-
statsPeriod: TimePeriod;
2222
}
2323
const CHART_HEIGHT = 180;
2424

2525
interface MetricDetectorChartProps {
2626
detector: MetricDetector;
2727
snubaQuery: SnubaQuery;
2828
/**
29-
* The time period for the chart data (optional, defaults to 7d)
29+
* Relative time period (e.g., '7d'). Use either statsPeriod or absolute start/end.
3030
*/
31-
statsPeriod: TimePeriod;
31+
end?: string;
32+
start?: string;
33+
statsPeriod?: string;
3234
}
3335

3436
function MetricDetectorChart({
3537
statsPeriod,
38+
start,
39+
end,
3640
snubaQuery,
3741
detector,
3842
}: MetricDetectorChartProps) {
@@ -47,8 +51,10 @@ function MetricDetectorChart({
4751
query: snubaQuery.query,
4852
environment: snubaQuery.environment,
4953
projectId: detector.projectId,
50-
statsPeriod,
5154
comparisonDelta,
55+
statsPeriod,
56+
start,
57+
end,
5258
});
5359

5460
const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} =
@@ -58,7 +64,7 @@ function MetricDetectorChart({
5864
comparisonSeries,
5965
});
6066

61-
// TODO: Fetch open periodos and transform them into the right format
67+
// TODO: Fetch open periods and transform them into the right format
6268
const openPeriods: any[] = [];
6369
const openPeriodMarkerResult = useIncidentMarkers({
6470
incidents: openPeriods,
@@ -67,6 +73,10 @@ function MetricDetectorChart({
6773
yAxisIndex: 1, // Use index 1 to avoid conflict with main chart axis
6874
});
6975

76+
const chartZoomProps = useChartZoom({
77+
usePageDate: true,
78+
});
79+
7080
// Calculate y-axis bounds to ensure all thresholds are visible
7181
const maxValue = useMemo(() => {
7282
// Get max from series data
@@ -153,7 +163,6 @@ function MetricDetectorChart({
153163

154164
return (
155165
<AreaChart
156-
isGroupedByDate
157166
showTimeInTooltip
158167
height={CHART_HEIGHT}
159168
stacked={false}
@@ -164,16 +173,20 @@ function MetricDetectorChart({
164173
grid={grid}
165174
xAxis={openPeriodMarkerResult.incidentMarkerXAxis}
166175
ref={openPeriodMarkerResult.connectIncidentMarkerChartRef}
176+
{...chartZoomProps}
167177
/>
168178
);
169179
}
170180

171-
export function MetricDetectorDetailsChart({
172-
detector,
173-
statsPeriod,
174-
}: MetricDetectorDetailsChartProps) {
181+
export function MetricDetectorDetailsChart({detector}: MetricDetectorDetailsChartProps) {
175182
const dataSource = detector.dataSources[0];
176183
const snubaQuery = dataSource.queryObj?.snubaQuery;
184+
const location = useLocation();
185+
const statsPeriod = location.query?.statsPeriod as string | undefined;
186+
const start = location.query?.start as string | undefined;
187+
const end = location.query?.end as string | undefined;
188+
const dateParams =
189+
start && end ? {start, end} : statsPeriod ? {statsPeriod} : {statsPeriod: '7d'};
177190

178191
if (!snubaQuery) {
179192
// Unlikely, helps narrow types
@@ -187,7 +200,7 @@ export function MetricDetectorDetailsChart({
187200
detector={detector}
188201
// Pass snubaQuery separately to avoid checking null in all places
189202
snubaQuery={snubaQuery}
190-
statsPeriod={statsPeriod}
203+
{...dateParams}
191204
/>
192205
</ChartContainerBody>
193206
</ChartContainer>

static/app/views/detectors/components/details/metric/index.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {CompactSelect} from 'sentry/components/core/compactSelect';
21
import DetailLayout from 'sentry/components/workflowEngine/layout/detail';
32
import type {Project} from 'sentry/types/project';
43
import type {MetricDetector} from 'sentry/types/workflowEngine/detectors';
@@ -8,7 +7,7 @@ import {DetectorDetailsHeader} from 'sentry/views/detectors/components/details/c
87
import {DetectorDetailsOngoingIssues} from 'sentry/views/detectors/components/details/common/ongoingIssues';
98
import {MetricDetectorDetailsChart} from 'sentry/views/detectors/components/details/metric/chart';
109
import {MetricDetectorDetailsSidebar} from 'sentry/views/detectors/components/details/metric/sidebar';
11-
import {useTimePeriodSelection} from 'sentry/views/detectors/hooks/useTimePeriodSelection';
10+
import {MetricTimePeriodSelect} from 'sentry/views/detectors/components/details/metric/timePeriodSelect';
1211

1312
type MetricDetectorDetailsProps = {
1413
detector: MetricDetector;
@@ -19,31 +18,17 @@ export function MetricDetectorDetails({detector, project}: MetricDetectorDetails
1918
const dataSource = detector.dataSources[0];
2019
const snubaQuery = dataSource.queryObj?.snubaQuery;
2120

22-
const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
23-
useTimePeriodSelection({
24-
dataset: snubaQuery?.dataset ?? Dataset.ERRORS,
25-
interval: snubaQuery?.timeWindow,
26-
});
21+
const dataset = snubaQuery?.dataset ?? Dataset.ERRORS;
22+
const interval = snubaQuery?.timeWindow;
2723

2824
return (
2925
<DetailLayout>
3026
<DetectorDetailsHeader detector={detector} project={project} />
3127
<DetailLayout.Body>
3228
<DetailLayout.Main>
33-
<CompactSelect
34-
size="sm"
35-
options={timePeriodOptions}
36-
value={selectedTimePeriod}
37-
onChange={opt => setSelectedTimePeriod(opt.value)}
38-
/>
39-
<MetricDetectorDetailsChart
40-
detector={detector}
41-
statsPeriod={selectedTimePeriod}
42-
/>
43-
<DetectorDetailsOngoingIssues
44-
detectorId={detector.id}
45-
query={{statsPeriod: selectedTimePeriod}}
46-
/>
29+
<MetricTimePeriodSelect dataset={dataset} interval={interval} />
30+
<MetricDetectorDetailsChart detector={detector} />
31+
<DetectorDetailsOngoingIssues detectorId={detector.id} />
4732
<DetectorDetailsAutomations detector={detector} />
4833
</DetailLayout.Main>
4934
<DetailLayout.Sidebar>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
2+
3+
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
4+
import {MetricTimePeriodSelect} from 'sentry/views/detectors/components/details/metric/timePeriodSelect';
5+
6+
describe('MetricTimePeriodSelect', () => {
7+
it('navigates by updating statsPeriod in the query when selecting an option', async () => {
8+
const {router} = render(
9+
<MetricTimePeriodSelect dataset={Dataset.ERRORS} interval={300} />
10+
);
11+
12+
// Opens the select and chooses a different period
13+
await userEvent.click(
14+
// Default selected should be Last 7 days for this interval/dataset
15+
screen.getByRole('button', {name: /last 7 days/i})
16+
);
17+
18+
await userEvent.click(screen.getByText(/last 14 days/i));
19+
20+
await waitFor(() => {
21+
expect(router.location.query.statsPeriod).toBe('14d');
22+
});
23+
24+
// Ensure absolute range is cleared
25+
expect(router.location.query.start).toBeUndefined();
26+
expect(router.location.query.end).toBeUndefined();
27+
});
28+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {Fragment, useMemo} from 'react';
2+
import moment from 'moment-timezone';
3+
4+
import {CompactSelect} from 'sentry/components/core/compactSelect';
5+
import {DateTime} from 'sentry/components/dateTime';
6+
import {t} from 'sentry/locale';
7+
import {useLocation} from 'sentry/utils/useLocation';
8+
import {useNavigate} from 'sentry/utils/useNavigate';
9+
import {Dataset, TimePeriod, TimeWindow} from 'sentry/views/alerts/rules/metric/types';
10+
import {getTimePeriodOptions} from 'sentry/views/alerts/utils/timePeriods';
11+
12+
type BaseOption = {label: React.ReactNode; value: TimePeriod};
13+
14+
const CUSTOM_TIME_VALUE = '__custom_time__';
15+
16+
interface TimePeriodSelectProps {
17+
dataset: Dataset;
18+
interval: number | undefined;
19+
}
20+
21+
export function MetricTimePeriodSelect({dataset, interval}: TimePeriodSelectProps) {
22+
const location = useLocation();
23+
const navigate = useNavigate();
24+
25+
const start = location.query?.start as string | undefined;
26+
const end = location.query?.end as string | undefined;
27+
28+
const hasCustomRange = Boolean(start && end);
29+
30+
function mapIntervalToTimeWindow(intervalSeconds: number): TimeWindow | undefined {
31+
const intervalMinutes = Math.floor(intervalSeconds / 60);
32+
if (Object.values(TimeWindow).includes(intervalMinutes as TimeWindow)) {
33+
return intervalMinutes as TimeWindow;
34+
}
35+
return undefined;
36+
}
37+
38+
const options: BaseOption[] = useMemo(() => {
39+
if (!dataset || !interval) {
40+
return [];
41+
}
42+
const timeWindow = mapIntervalToTimeWindow(interval);
43+
if (!timeWindow) {
44+
return [];
45+
}
46+
return getTimePeriodOptions({dataset, timeWindow});
47+
}, [dataset, interval]);
48+
49+
// Determine selected period from query or fallback (prefer statsPeriod, else default 7d, else largest)
50+
const selected: TimePeriod = useMemo(() => {
51+
const urlStatsPeriod = location.query?.statsPeriod as string | undefined;
52+
const optionValues = new Set(options.map(o => o.value));
53+
if (urlStatsPeriod && optionValues.has(urlStatsPeriod as TimePeriod)) {
54+
return urlStatsPeriod as TimePeriod;
55+
}
56+
if (optionValues.has(TimePeriod.SEVEN_DAYS)) {
57+
return TimePeriod.SEVEN_DAYS;
58+
}
59+
const largestOption = options[options.length - 1];
60+
return (largestOption?.value as TimePeriod) ?? TimePeriod.SEVEN_DAYS;
61+
}, [location.query, options]);
62+
63+
const selectOptions = useMemo(() => {
64+
if (hasCustomRange) {
65+
const custom = {
66+
label: (
67+
<Fragment>
68+
{t('Custom time')}: <DateTime date={moment.utc(start)} /> {' — '}
69+
<DateTime date={moment.utc(end)} />
70+
</Fragment>
71+
),
72+
value: CUSTOM_TIME_VALUE,
73+
textValue: t('Custom time'),
74+
disabled: true,
75+
};
76+
return [custom, ...options];
77+
}
78+
79+
return options;
80+
}, [hasCustomRange, start, end, options]);
81+
82+
const value = hasCustomRange ? CUSTOM_TIME_VALUE : selected;
83+
84+
return (
85+
<CompactSelect
86+
size="sm"
87+
options={selectOptions}
88+
value={value}
89+
onChange={opt => {
90+
// Ignore clicks on the custom option; only propagate real TimePeriod values
91+
if (opt.value === CUSTOM_TIME_VALUE) {
92+
return;
93+
}
94+
navigate({
95+
pathname: location.pathname,
96+
query: {
97+
...location.query,
98+
statsPeriod: opt.value,
99+
start: undefined,
100+
end: undefined,
101+
},
102+
});
103+
}}
104+
/>
105+
);
106+
}

static/app/views/detectors/datasetConfig/base.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/
77
import type {QueryFieldValue} from 'sentry/utils/discover/fields';
88
import type {DiscoverDatasets} from 'sentry/utils/discover/types';
99
import type {ApiQueryKey} from 'sentry/utils/queryClient';
10-
import {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
1110
import type {FieldValue} from 'sentry/views/discover/table/types';
1211

1312
export interface DetectorSearchBarProps {
@@ -39,10 +38,12 @@ export interface DetectorSeriesQueryOptions {
3938
* The filter query. eg: `span.op:http`
4039
*/
4140
query: string;
41+
end?: string;
42+
start?: string;
4243
/**
43-
* The time period for the query. eg: `7d`
44+
* Relative time period for the query. Example: '7d'.
4445
*/
45-
statsPeriod: TimePeriod;
46+
statsPeriod?: string;
4647
}
4748

4849
/**

0 commit comments

Comments
 (0)