autofixRootCause.tsx 16 KB

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