Skip to content

Commit 5464dcd

Browse files
committed
feat(investigations): Apply update_detector action requests
Part of a series that adds follow-up actions to Seer investigations: when a run completes, Seer suggests one next step, and the user can apply a typed action, such as a monitor change, with one click. This adds an Apply button to a suggested monitor change. When the card shows, it asks the backend with a dry run whether the change would apply, and says why when it can't, so the user only clicks a change that will save. Depends on #128681 and #128723
1 parent b40ebfd commit 5464dcd

12 files changed

Lines changed: 694 additions & 11 deletions

File tree

‎static/app/views/investigations/__stories__/investigationFixtureApi.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
InvestigationQueryOutputFixture,
2424
InvestigationTranscriptBlockFixture,
2525
} from 'sentry/views/investigations/fixtures';
26+
import {getSourceMonitor} from 'sentry/views/investigations/source';
2627
import type {
28+
InvestigationActionRequest,
2729
InvestigationComment,
2830
InvestigationDetail,
2931
InvestigationExecutionDetail,
@@ -455,6 +457,50 @@ function handleFixtureRequest(
455457
};
456458
}
457459

460+
if (
461+
parts[1] === 'action-requests' &&
462+
parts[3] === 'apply' &&
463+
parts.length === 4 &&
464+
method === 'POST'
465+
) {
466+
const detail = getFixtureDetail(state, investigationId);
467+
const actionRequest = detail.actionRequests?.find(
468+
candidate => candidate.id === parts[2]
469+
);
470+
if (!actionRequest?.action) {
471+
throw new Error(`Unknown fixture action request: ${parts[2]}`);
472+
}
473+
if (data.dryRun === true) {
474+
return {body: {actionRequest: cloneFixture(actionRequest)}};
475+
}
476+
const {action} = actionRequest;
477+
const applied: InvestigationActionRequest = {
478+
...actionRequest,
479+
state: 'applied',
480+
appliedBy: state.viewer,
481+
appliedDetails:
482+
action.kind === 'update_detector'
483+
? {
484+
detectorId: action.detectorId,
485+
detectorName:
486+
getSourceMonitor(detail.source, action.detectorId)?.name ??
487+
action.detectorId,
488+
fields: Object.keys(action.changes),
489+
}
490+
: null,
491+
canApply: false,
492+
cannotApplyReason: 'not_suggested',
493+
dateApplied: new Date().toISOString(),
494+
};
495+
setFixtureDetail(state, {
496+
...detail,
497+
actionRequests: detail.actionRequests?.map(candidate =>
498+
candidate.id === applied.id ? applied : candidate
499+
),
500+
});
501+
return {body: {actionRequest: cloneFixture(applied)}};
502+
}
503+
458504
if (parts[1] === 'title-generation' && method === 'GET') {
459505
const detail = getFixtureDetail(state, investigationId);
460506
return {

‎static/app/views/investigations/actionRequests/actionRequestCard.tsx‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ import {useId} from 'react';
22

33
import {Stack} from '@sentry/scraps/layout';
44
import {Heading, Text} from '@sentry/scraps/text';
5+
import {toast} from '@sentry/scraps/toast';
56

6-
import {tct} from 'sentry/locale';
7-
import {ActionRequestDetails} from 'sentry/views/investigations/actionRequests/actionRequestDetails';
7+
import {t, tct} from 'sentry/locale';
8+
import {useOrganization} from 'sentry/utils/useOrganization';
9+
import {
10+
ActionRequestApply,
11+
ActionRequestDetails,
12+
getApplySuccessMessage,
13+
useApplyCheck,
14+
} from 'sentry/views/investigations/actionRequests/actionRequestDetails';
15+
import {
16+
parseApplyError,
17+
useApplyInvestigationActionRequestMutation,
18+
} from 'sentry/views/investigations/api';
819
import {ensureSentenceEnd} from 'sentry/views/investigations/sentence';
920
import type {
1021
InvestigationActionRequest,
@@ -13,16 +24,35 @@ import type {
1324

1425
type ActionRequestCardProps = {
1526
actionRequest: InvestigationActionRequest;
27+
investigationId: string;
1628
heading?: string;
1729
source?: InvestigationDetail['source'];
1830
};
1931

2032
export function ActionRequestCard({
2133
actionRequest,
2234
heading,
35+
investigationId,
2336
source,
2437
}: ActionRequestCardProps) {
2538
const headingId = useId();
39+
const organization = useOrganization();
40+
const check = useApplyCheck(investigationId, actionRequest);
41+
const applyMutation = useApplyInvestigationActionRequestMutation(
42+
organization.slug,
43+
investigationId,
44+
{
45+
onSuccess: (_response, applied) => {
46+
const message = getApplySuccessMessage(applied.action);
47+
if (message) {
48+
toast.success(message);
49+
}
50+
},
51+
onError: error => {
52+
toast.error(parseApplyError(error)?.detail ?? t('Unable to apply this change.'));
53+
},
54+
}
55+
);
2656

2757
if (actionRequest.state !== 'suggested' && actionRequest.state !== 'withdrawn') {
2858
return null;
@@ -63,6 +93,14 @@ export function ActionRequestCard({
6393
})}
6494
</Text>
6595
<ActionRequestDetails actionRequest={actionRequest} source={source} />
96+
<ActionRequestApply
97+
actionRequest={actionRequest}
98+
applying={
99+
applyMutation.isPending && applyMutation.variables?.id === actionRequest.id
100+
}
101+
check={check}
102+
onApply={() => applyMutation.mutate(actionRequest)}
103+
/>
66104
</Stack>
67105
);
68106
}

‎static/app/views/investigations/actionRequests/actionRequestDetails.tsx‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1+
import {IconSiren} from '@sentry/icons/siren';
2+
import {useQuery, useQueryClient} from '@tanstack/react-query';
3+
14
import {Text} from '@sentry/scraps/text';
25

6+
import {t} from 'sentry/locale';
7+
import {useOrganization} from 'sentry/utils/useOrganization';
8+
import {ApplyButton} from 'sentry/views/investigations/actionRequests/applyButton';
39
import {MonitorChanges} from 'sentry/views/investigations/actionRequests/monitorChanges';
10+
import {
11+
actionRequestDryRunQueryOptions,
12+
getInvestigationDetailQueryOptions,
13+
parseApplyError,
14+
type ApplyError,
15+
} from 'sentry/views/investigations/api';
416
import {getSourceMonitor} from 'sentry/views/investigations/source';
517
import type {
18+
InvestigationAction,
19+
InvestigationActionCannotApplyReason,
620
InvestigationActionRequest,
721
InvestigationDetail,
822
} from 'sentry/views/investigations/types';
@@ -32,3 +46,94 @@ export function ActionRequestDetails({actionRequest, source}: ActionRequestDetai
3246
) : null;
3347
}
3448
}
49+
50+
type ActionRequestApplyProps = {
51+
actionRequest: InvestigationActionRequest;
52+
applying: boolean;
53+
check: ApplyError | null;
54+
onApply: () => void;
55+
};
56+
57+
export function ActionRequestApply({
58+
actionRequest,
59+
applying,
60+
check,
61+
onApply,
62+
}: ActionRequestApplyProps) {
63+
switch (actionRequest.action?.kind) {
64+
case 'update_detector':
65+
return (
66+
<ApplyButton
67+
actionRequest={actionRequest}
68+
applying={applying}
69+
check={check}
70+
onApply={onApply}
71+
icon={<IconSiren />}
72+
label={t('Update monitor')}
73+
busyLabel={t('Updating monitor…')}
74+
cannotApplyMessage={getMonitorCannotApplyMessage(
75+
actionRequest.cannotApplyReason
76+
)}
77+
/>
78+
);
79+
default:
80+
return null;
81+
}
82+
}
83+
84+
export function useApplyCheck(
85+
investigationId: string,
86+
actionRequest: InvestigationActionRequest
87+
): ApplyError | null {
88+
const organization = useOrganization();
89+
const queryClient = useQueryClient();
90+
const investigationVersion = queryClient.getQueryData(
91+
getInvestigationDetailQueryOptions(organization.slug, investigationId).queryKey
92+
)?.json.version;
93+
const {error} = useQuery({
94+
...actionRequestDryRunQueryOptions(
95+
organization.slug,
96+
investigationId,
97+
actionRequest.id,
98+
investigationVersion
99+
),
100+
enabled:
101+
hasApplyCheck(actionRequest) &&
102+
actionRequest.state === 'suggested' &&
103+
actionRequest.canApply,
104+
});
105+
return error ? parseApplyError(error) : null;
106+
}
107+
108+
function hasApplyCheck(actionRequest: InvestigationActionRequest) {
109+
switch (actionRequest.action?.kind) {
110+
case 'update_detector':
111+
return true;
112+
default:
113+
return false;
114+
}
115+
}
116+
117+
export function getApplySuccessMessage(action: InvestigationAction | null) {
118+
switch (action?.kind) {
119+
case 'update_detector':
120+
return t('Monitor updated.');
121+
default:
122+
return null;
123+
}
124+
}
125+
126+
function getMonitorCannotApplyMessage(
127+
reason: InvestigationActionCannotApplyReason | null
128+
) {
129+
switch (reason) {
130+
case 'permission_denied':
131+
return t("You don't have permission to edit this monitor.");
132+
case 'detector_not_found':
133+
return t('This monitor is no longer available.');
134+
case 'not_target_detector':
135+
return t('This change targets a different monitor than this investigation.');
136+
default:
137+
return null;
138+
}
139+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {Button} from '@sentry/scraps/button';
2+
import {Flex} from '@sentry/scraps/layout';
3+
import {Text} from '@sentry/scraps/text';
4+
5+
import {t} from 'sentry/locale';
6+
import type {ApplyError} from 'sentry/views/investigations/api';
7+
import type {
8+
InvestigationActionCannotApplyReason,
9+
InvestigationActionRequest,
10+
} from 'sentry/views/investigations/types';
11+
12+
type ApplyButtonProps = {
13+
actionRequest: InvestigationActionRequest;
14+
applying: boolean;
15+
busyLabel: string;
16+
cannotApplyMessage: string | null;
17+
check: ApplyError | null;
18+
icon: React.ReactNode;
19+
label: string;
20+
onApply: () => void;
21+
};
22+
23+
export function ApplyButton({
24+
actionRequest,
25+
applying,
26+
busyLabel,
27+
cannotApplyMessage,
28+
check,
29+
icon,
30+
label,
31+
onApply,
32+
}: ApplyButtonProps) {
33+
const {canApply, cannotApplyReason, state} = actionRequest;
34+
const message =
35+
!canApply && state === 'suggested'
36+
? (cannotApplyMessage ?? getCannotApplyMessage(cannotApplyReason))
37+
: (check?.detail ?? null);
38+
return (
39+
<Flex align="center" gap="md" wrap="wrap">
40+
<Button
41+
variant="primary"
42+
icon={icon}
43+
busy={applying}
44+
disabled={applying || !canApply || check !== null}
45+
onClick={onApply}
46+
>
47+
{applying ? busyLabel : label}
48+
</Button>
49+
{message ? (
50+
<Text size="sm" variant="muted">
51+
{message}
52+
</Text>
53+
) : null}
54+
</Flex>
55+
);
56+
}
57+
58+
function getCannotApplyMessage(reason: InvestigationActionCannotApplyReason | null) {
59+
switch (reason) {
60+
case 'investigation_archived':
61+
return t('Archived investigations are read-only.');
62+
case 'not_suggested':
63+
return t('This can no longer be applied.');
64+
case 'unsupported_action':
65+
return t("Seer can't apply this kind of change yet.");
66+
case 'no_access':
67+
return t("You don't have access to apply this.");
68+
default:
69+
return t("This change can't be applied.");
70+
}
71+
}

0 commit comments

Comments
 (0)