autofixRootCause.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import {type ReactNode, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import Alert from 'sentry/components/alert';
  6. import {Button} from 'sentry/components/button';
  7. import {CodeSnippet} from 'sentry/components/codeSnippet';
  8. import {AutofixShowMore} from 'sentry/components/events/autofix/autofixShowMore';
  9. import {
  10. type AutofixRootCauseData,
  11. type AutofixRootCauseSelection,
  12. type AutofixRootCauseSuggestedFix,
  13. type AutofixRootCauseSuggestedFixSnippet,
  14. AutofixStepType,
  15. } from 'sentry/components/events/autofix/types';
  16. import {
  17. type AutofixResponse,
  18. makeAutofixQueryKey,
  19. } from 'sentry/components/events/autofix/useAutofix';
  20. import TextArea from 'sentry/components/forms/controls/textarea';
  21. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  22. import {IconChevron} from 'sentry/icons';
  23. import {t, tn} from 'sentry/locale';
  24. import {space} from 'sentry/styles/space';
  25. import {getFileExtension} from 'sentry/utils/fileExtension';
  26. import {getPrismLanguage} from 'sentry/utils/prism';
  27. import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
  28. import testableTransition from 'sentry/utils/testableTransition';
  29. import useApi from 'sentry/utils/useApi';
  30. type AutofixRootCauseProps = {
  31. causes: AutofixRootCauseData[];
  32. groupId: string;
  33. rootCauseSelection: AutofixRootCauseSelection;
  34. runId: string;
  35. };
  36. const animationProps: AnimationProps = {
  37. exit: {opacity: 0},
  38. initial: {opacity: 0},
  39. animate: {opacity: 1},
  40. transition: testableTransition({duration: 0.3}),
  41. };
  42. function useSelectCause({groupId, runId}: {groupId: string; runId: string}) {
  43. const api = useApi();
  44. const queryClient = useQueryClient();
  45. return useMutation({
  46. mutationFn: (
  47. params:
  48. | {
  49. causeId: string;
  50. fixId: string;
  51. }
  52. | {
  53. customRootCause: string;
  54. }
  55. ) => {
  56. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  57. method: 'POST',
  58. data:
  59. 'customRootCause' in params
  60. ? {
  61. run_id: runId,
  62. payload: {
  63. type: 'select_root_cause',
  64. custom_root_cause: params.customRootCause,
  65. },
  66. }
  67. : {
  68. run_id: runId,
  69. payload: {
  70. type: 'select_root_cause',
  71. cause_id: params.causeId,
  72. fix_id: params.fixId,
  73. },
  74. },
  75. });
  76. },
  77. onSuccess: (_, params) => {
  78. setApiQueryData<AutofixResponse>(
  79. queryClient,
  80. makeAutofixQueryKey(groupId),
  81. data => {
  82. if (!data || !data.autofix) {
  83. return data;
  84. }
  85. return {
  86. ...data,
  87. autofix: {
  88. ...data.autofix,
  89. status: 'PROCESSING',
  90. steps: data.autofix.steps?.map(step => {
  91. if (step.type !== AutofixStepType.ROOT_CAUSE_ANALYSIS) {
  92. return step;
  93. }
  94. return {
  95. ...step,
  96. selection:
  97. 'customRootCause' in params
  98. ? {
  99. custom_root_cause: params.customRootCause,
  100. }
  101. : {
  102. cause_id: params.causeId,
  103. fix_id: params.fixId,
  104. },
  105. };
  106. }),
  107. },
  108. };
  109. }
  110. );
  111. },
  112. onError: () => {
  113. addErrorMessage(t('Something went wrong when selecting the root cause.'));
  114. },
  115. });
  116. }
  117. function RootCauseContent({
  118. selected,
  119. children,
  120. }: {
  121. children: ReactNode;
  122. selected: boolean;
  123. }) {
  124. return (
  125. <ContentWrapper selected={selected}>
  126. <AnimatePresence initial={false}>
  127. {selected && (
  128. <AnimationWrapper key="content" {...animationProps}>
  129. {children}
  130. </AnimationWrapper>
  131. )}
  132. </AnimatePresence>
  133. </ContentWrapper>
  134. );
  135. }
  136. function SuggestedFixSnippet({snippet}: {snippet: AutofixRootCauseSuggestedFixSnippet}) {
  137. const extension = getFileExtension(snippet.file_path);
  138. const lanugage = extension ? getPrismLanguage(extension) : undefined;
  139. return (
  140. <div>
  141. <StyledCodeSnippet filename={snippet.file_path} language={lanugage}>
  142. {snippet.snippet}
  143. </StyledCodeSnippet>
  144. </div>
  145. );
  146. }
  147. function CauseSuggestedFix({
  148. fixNumber,
  149. suggestedFix,
  150. groupId,
  151. runId,
  152. causeId,
  153. }: {
  154. causeId: string;
  155. fixNumber: number;
  156. groupId: string;
  157. runId: string;
  158. suggestedFix: AutofixRootCauseSuggestedFix;
  159. }) {
  160. const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
  161. return (
  162. <SuggestedFixWrapper>
  163. <SuggestedFixHeader>
  164. <strong>{t('Suggested Fix #%s: %s', fixNumber, suggestedFix.title)}</strong>
  165. <Button
  166. size="xs"
  167. onClick={() => handleSelectFix({causeId, fixId: suggestedFix.id})}
  168. busy={isLoading}
  169. analyticsEventName="Autofix: Root Cause Fix Selected"
  170. analyticsEventKey="autofix.root_cause_fix_selected"
  171. analyticsParams={{group_id: groupId}}
  172. >
  173. {t('Continue With This Fix')}
  174. </Button>
  175. </SuggestedFixHeader>
  176. <p>{suggestedFix.description}</p>
  177. {suggestedFix.snippet && <SuggestedFixSnippet snippet={suggestedFix.snippet} />}
  178. </SuggestedFixWrapper>
  179. );
  180. }
  181. function CauseOption({
  182. cause,
  183. selected,
  184. setSelectedId,
  185. runId,
  186. groupId,
  187. }: {
  188. cause: AutofixRootCauseData;
  189. groupId: string;
  190. runId: string;
  191. selected: boolean;
  192. setSelectedId: (id: string) => void;
  193. }) {
  194. return (
  195. <RootCauseOption selected={selected} onClick={() => setSelectedId(cause.id)}>
  196. {!selected && <InteractionStateLayer />}
  197. <RootCauseOptionHeader>
  198. <Title>{cause.title}</Title>
  199. <Button
  200. icon={<IconChevron size="xs" direction={selected ? 'down' : 'right'} />}
  201. aria-label={t('Select root cause')}
  202. aria-expanded={selected}
  203. size="zero"
  204. borderless
  205. />
  206. </RootCauseOptionHeader>
  207. <RootCauseContent selected={selected}>
  208. <CauseDescription>{cause.description}</CauseDescription>
  209. {cause.suggested_fixes?.map((fix, index) => (
  210. <CauseSuggestedFix
  211. causeId={cause.id}
  212. key={fix.title}
  213. suggestedFix={fix}
  214. fixNumber={index + 1}
  215. groupId={groupId}
  216. runId={runId}
  217. />
  218. )) ?? null}
  219. </RootCauseContent>
  220. </RootCauseOption>
  221. );
  222. }
  223. function SelectedRootCauseOption({
  224. selectedCause,
  225. selectedFix,
  226. }: {
  227. selectedCause: AutofixRootCauseData;
  228. selectedFix: AutofixRootCauseSuggestedFix;
  229. }) {
  230. return (
  231. <RootCauseOption selected>
  232. <Title>{t('Selected Cause: %s', selectedCause.title)}</Title>
  233. <CauseDescription>{selectedCause.description}</CauseDescription>
  234. <SuggestedFixWrapper>
  235. <SuggestedFixHeader>
  236. <strong>{t('Selected Fix: %s', selectedFix.title)}</strong>
  237. </SuggestedFixHeader>
  238. <p>{selectedFix.description}</p>
  239. {selectedFix.snippet && <SuggestedFixSnippet snippet={selectedFix.snippet} />}
  240. </SuggestedFixWrapper>
  241. </RootCauseOption>
  242. );
  243. }
  244. function ProvideYourOwn({
  245. selected,
  246. setSelectedId,
  247. groupId,
  248. runId,
  249. }: {
  250. groupId: string;
  251. runId: string;
  252. selected: boolean;
  253. setSelectedId: (id: string) => void;
  254. }) {
  255. const [text, setText] = useState('');
  256. const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
  257. return (
  258. <RootCauseOption selected={selected} onClick={() => setSelectedId('custom')}>
  259. {!selected && <InteractionStateLayer />}
  260. <RootCauseOptionHeader>
  261. <Title>{t('Provide your own')}</Title>
  262. <Button
  263. icon={<IconChevron size="xs" direction={selected ? 'down' : 'right'} />}
  264. aria-label={t('Provide your own root cause')}
  265. aria-expanded={selected}
  266. size="zero"
  267. borderless
  268. />
  269. </RootCauseOptionHeader>
  270. <RootCauseContent selected={selected}>
  271. <CustomTextArea
  272. value={text}
  273. onChange={e => setText(e.target.value)}
  274. autoFocus
  275. autosize
  276. placeholder={t(
  277. 'This error seems to be caused by ... go look at path/file to make sure it does …'
  278. )}
  279. />
  280. <OptionFooter>
  281. <Button
  282. size="xs"
  283. onClick={() => handleSelectFix({customRootCause: text})}
  284. disabled={!text}
  285. busy={isLoading}
  286. analyticsEventName="Autofix: Root Cause Custom Cause Provided"
  287. analyticsEventKey="autofix.root_cause_custom_cause_provided"
  288. analyticsParams={{group_id: groupId}}
  289. >
  290. {t('Continue With This Fix')}
  291. </Button>
  292. </OptionFooter>
  293. </RootCauseContent>
  294. </RootCauseOption>
  295. );
  296. }
  297. function AutofixRootCauseDisplay({
  298. causes,
  299. groupId,
  300. runId,
  301. rootCauseSelection,
  302. }: AutofixRootCauseProps) {
  303. const [selectedId, setSelectedId] = useState(() => causes[0].id);
  304. if (rootCauseSelection) {
  305. if ('custom_root_cause' in rootCauseSelection) {
  306. return (
  307. <CausesContainer>
  308. <CustomRootCausePadding>
  309. <Title>{t('Custom Response Provided')}</Title>
  310. <CauseDescription>{rootCauseSelection.custom_root_cause}</CauseDescription>
  311. </CustomRootCausePadding>
  312. </CausesContainer>
  313. );
  314. }
  315. const selectedCause = causes.find(cause => cause.id === rootCauseSelection.cause_id);
  316. const selectedFix = selectedCause?.suggested_fixes?.find(
  317. fix => fix.id === rootCauseSelection.fix_id
  318. );
  319. if (!selectedCause || !selectedFix) {
  320. return <Alert type="error">{t('Selected root cause not found.')}</Alert>;
  321. }
  322. const otherCauses = causes.filter(cause => cause.id !== selectedCause.id);
  323. return (
  324. <CausesContainer>
  325. <SelectedRootCauseOption
  326. selectedFix={selectedFix}
  327. selectedCause={selectedCause}
  328. />
  329. {otherCauses.length > 0 && (
  330. <AutofixShowMore title={t('Show unselected causes')}>
  331. {otherCauses.map(cause => (
  332. <RootCauseOption selected key={cause.id}>
  333. <Title>{t('Cause: %s', cause.title)}</Title>
  334. <CauseDescription>{cause.description}</CauseDescription>
  335. {cause.suggested_fixes?.map(fix => (
  336. <SuggestedFixWrapper key={fix.id}>
  337. <SuggestedFixHeader>
  338. <strong>{t('Fix: %s', fix.title)}</strong>
  339. </SuggestedFixHeader>
  340. <p>{fix.description}</p>
  341. {fix.snippet && <SuggestedFixSnippet snippet={fix.snippet} />}
  342. </SuggestedFixWrapper>
  343. ))}
  344. </RootCauseOption>
  345. ))}
  346. </AutofixShowMore>
  347. )}
  348. </CausesContainer>
  349. );
  350. }
  351. return (
  352. <CausesContainer>
  353. <CausesHeader>
  354. {tn(
  355. 'Sentry has identified %s potential root cause. You may select the presented root cause or provide your own.',
  356. 'Sentry has identified %s potential root causes. You may select one of the presented root causes or provide your own.',
  357. causes.length
  358. )}
  359. </CausesHeader>
  360. <OptionsPadding>
  361. <OptionsWrapper>
  362. {causes.map(cause => (
  363. <CauseOption
  364. key={cause.id}
  365. cause={cause}
  366. selected={cause.id === selectedId}
  367. setSelectedId={setSelectedId}
  368. runId={runId}
  369. groupId={groupId}
  370. />
  371. ))}
  372. <ProvideYourOwn
  373. selected={selectedId === 'custom'}
  374. setSelectedId={setSelectedId}
  375. groupId={groupId}
  376. runId={runId}
  377. />
  378. </OptionsWrapper>
  379. </OptionsPadding>
  380. </CausesContainer>
  381. );
  382. }
  383. export function AutofixRootCause(props: AutofixRootCauseProps) {
  384. if (props.causes.length === 0) {
  385. return (
  386. <NoCausesPadding>
  387. <Alert type="warning">
  388. {t('Autofix was not able to find a root cause. Maybe try again?')}
  389. </Alert>
  390. </NoCausesPadding>
  391. );
  392. }
  393. return <AutofixRootCauseDisplay {...props} />;
  394. }
  395. const NoCausesPadding = styled('div')`
  396. padding: 0 ${space(2)};
  397. `;
  398. const CausesContainer = styled('div')``;
  399. const CausesHeader = styled('div')`
  400. padding: 0 ${space(2)};
  401. `;
  402. const OptionsPadding = styled('div')`
  403. padding: ${space(2)};
  404. `;
  405. const OptionsWrapper = styled('div')`
  406. border: 1px solid ${p => p.theme.innerBorder};
  407. border-radius: ${p => p.theme.borderRadius};
  408. overflow: hidden;
  409. box-shadow: ${p => p.theme.dropShadowMedium};
  410. `;
  411. const RootCauseOption = styled('div')<{selected: boolean}>`
  412. position: relative;
  413. padding: ${space(2)};
  414. background: ${p => (p.selected ? p.theme.background : p.theme.backgroundElevated)};
  415. cursor: ${p => (p.selected ? 'default' : 'pointer')};
  416. :not(:first-child) {
  417. border-top: 1px solid ${p => p.theme.innerBorder};
  418. }
  419. `;
  420. const RootCauseOptionHeader = styled('div')`
  421. display: flex;
  422. justify-content: space-between;
  423. align-items: center;
  424. gap: ${space(1)};
  425. `;
  426. const Title = styled('div')`
  427. font-weight: ${p => p.theme.fontWeightBold};
  428. `;
  429. const CauseDescription = styled('div')`
  430. font-size: ${p => p.theme.fontSizeMedium};
  431. margin-top: ${space(1)};
  432. `;
  433. const SuggestedFixWrapper = styled('div')`
  434. padding: ${space(2)};
  435. border: 1px solid ${p => p.theme.alert.info.border};
  436. background-color: ${p => p.theme.alert.info.backgroundLight};
  437. border-radius: ${p => p.theme.borderRadius};
  438. margin-top: ${space(1)};
  439. p {
  440. margin: ${space(1)} 0 0 0;
  441. }
  442. `;
  443. const SuggestedFixHeader = styled('div')`
  444. display: flex;
  445. justify-content: space-between;
  446. gap: ${space(1)};
  447. margin-bottom: ${space(1)};
  448. `;
  449. const StyledCodeSnippet = styled(CodeSnippet)`
  450. margin-top: ${space(2)};
  451. `;
  452. const ContentWrapper = styled(motion.div)<{selected: boolean}>`
  453. display: grid;
  454. grid-template-rows: ${p => (p.selected ? '1fr' : '0fr')};
  455. transition: grid-template-rows 300ms;
  456. will-change: grid-template-rows;
  457. > div {
  458. /* So that focused element outlines don't get cut off */
  459. padding: 0 1px;
  460. overflow: hidden;
  461. }
  462. `;
  463. const AnimationWrapper = styled(motion.div)``;
  464. const CustomTextArea = styled(TextArea)`
  465. margin-top: ${space(2)};
  466. `;
  467. const OptionFooter = styled('div')`
  468. display: flex;
  469. justify-content: flex-end;
  470. margin-top: ${space(2)};
  471. `;
  472. const CustomRootCausePadding = styled('div')`
  473. padding: 0 ${space(2)} ${space(2)} ${space(2)};
  474. `;