am2CompatibilityCheckModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import {Fragment, useEffect, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {Alert} from 'sentry/components/core/alert';
  6. import {Badge} from 'sentry/components/core/badge';
  7. import {Button} from 'sentry/components/core/button';
  8. import {ModalBody} from 'sentry/components/globalModal/components';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import List from 'sentry/components/list';
  11. import ListItem from 'sentry/components/list/listItem';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import {PanelTable} from 'sentry/components/panels/panelTable';
  14. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  15. import {IconRefresh} from 'sentry/icons';
  16. import {space} from 'sentry/styles/space';
  17. import textStyles from 'sentry/styles/text';
  18. import type {Organization} from 'sentry/types/organization';
  19. import {useApiQuery} from 'sentry/utils/queryClient';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import {semverCompare} from 'sentry/utils/versions/semverCompare';
  22. import {OrganizationContext} from 'sentry/views/organizationContext';
  23. type AM2CompatibilityReport = {
  24. alerts: UnsupportedAlert[];
  25. ondemand_widgets: Array<{
  26. dashboard_id: number;
  27. ondemand_supported: UnsupportedWidget[];
  28. unsupported: never; // On-demand adds new support for previously unsupported widgets // On-demand adds new support for previously unsupported widgets..
  29. }>;
  30. sdks: {
  31. projects: Array<{
  32. project: string;
  33. unsupported: UnsupportedSDK[];
  34. }>;
  35. url: string;
  36. };
  37. widgets: Array<{
  38. dashboard_id: number;
  39. ondemand_supported: never;
  40. unsupported: UnsupportedWidget[];
  41. }>;
  42. };
  43. type UnsupportedAlert = {
  44. aggregate: string;
  45. id: number;
  46. query: string;
  47. url: string;
  48. };
  49. type UnsupportedSDK = {
  50. sdk_name: string;
  51. sdk_versions: Array<{
  52. found: string;
  53. required: string;
  54. }>;
  55. };
  56. type UnsupportedWidget = UnsupportedAlert & {
  57. conditions: string;
  58. fields: string[];
  59. };
  60. type AM2CompatibilityReportResponse = {
  61. // 0 = done, 1 = in_progress, 2 = error
  62. errors: string[];
  63. results: AM2CompatibilityReport;
  64. status: 0 | 1 | 2;
  65. };
  66. const FETCH_INTERVAL = 5000;
  67. const FETCH_LIMIT = 15 * 6; // 15 minutes
  68. type Props = {
  69. organization: Organization;
  70. };
  71. const isInProgress = (status?: 0 | 1 | 2) => status === 1;
  72. const useFetchAM2CompatibilityReport = ({refresh}: any) => {
  73. const organization = useOrganization();
  74. const {data, isPending, error} = useApiQuery<AM2CompatibilityReportResponse>(
  75. [
  76. `${organization.links.regionUrl}/api/0/internal/check-am2-compatibility/`,
  77. {query: {orgId: organization.id, refresh}},
  78. ],
  79. {
  80. staleTime: 0,
  81. // endpoint runs an async and potentially long-running task, so we need to poll
  82. refetchInterval: query => {
  83. if (!query.state.data) {
  84. return false;
  85. }
  86. const newData = query.state.data[0];
  87. if (isInProgress(newData.status) && query.state.dataUpdateCount < FETCH_LIMIT) {
  88. return FETCH_INTERVAL;
  89. }
  90. return false;
  91. },
  92. }
  93. );
  94. return {data, isPending, error};
  95. };
  96. function AM2CompatibilityCheckModal() {
  97. const [refresh, setRefresh] = useState(false);
  98. const {data, isPending, error} = useFetchAM2CompatibilityReport({refresh});
  99. const isFetched = !isPending && !isInProgress(data?.status);
  100. useEffect(() => {
  101. if (refresh && isInProgress(data?.status)) {
  102. setRefresh(false);
  103. }
  104. }, [isFetched, refresh, data?.status]);
  105. return (
  106. <Fragment>
  107. <ModalHeader>
  108. <span>AM2 Compatibility Check</span>
  109. <Button
  110. size="sm"
  111. disabled={!isFetched}
  112. aria-label="refresh"
  113. icon={<IconRefresh />}
  114. onClick={() => setRefresh(true)}
  115. />
  116. </ModalHeader>
  117. <AM2ReportModalBody>
  118. {!isFetched && (
  119. <LoadingIndicator>Hang on, this might take a while!</LoadingIndicator>
  120. )}
  121. {error && (
  122. <Alert.Container>
  123. <Alert type="error">Something went wrong!</Alert>
  124. </Alert.Container>
  125. )}
  126. {data?.errors && <ErrorBox errors={data.errors} />}
  127. {data?.results && <AM2Report data={data.results} />}
  128. </AM2ReportModalBody>
  129. </Fragment>
  130. );
  131. }
  132. function ErrorBox({errors}: {errors: string[]}) {
  133. if (!errors.length) {
  134. return null;
  135. }
  136. return (
  137. <Alert.Container>
  138. <Alert
  139. type="error"
  140. showIcon
  141. expand={
  142. <List>
  143. {errors.map((error, index) => (
  144. <ListItem key={index}>{error}</ListItem>
  145. ))}
  146. </List>
  147. }
  148. >
  149. {errors.length} problem(s) occurred while processing this request.
  150. </Alert>
  151. </Alert.Container>
  152. );
  153. }
  154. function InfoBox({numOfIssues}: {numOfIssues: number}) {
  155. const message = numOfIssues
  156. ? `Found ${numOfIssues} issues. Check the details below for more info.`
  157. : 'No issues found!';
  158. return (
  159. <Alert.Container>
  160. <Alert showIcon type={numOfIssues ? 'warning' : 'success'}>
  161. {message}
  162. </Alert>
  163. </Alert.Container>
  164. );
  165. }
  166. function OnDemandBanner({onDemandWidgetCount}: {onDemandWidgetCount: number}) {
  167. if (!onDemandWidgetCount) {
  168. return null;
  169. }
  170. return (
  171. <Alert.Container>
  172. <Alert showIcon type="info">
  173. On-demand widgets fix support for many of the AM2 incompatible widgets. They are
  174. listed here so they can be checked for consistency with AM1 data.
  175. </Alert>
  176. </Alert.Container>
  177. );
  178. }
  179. function AM2Report({data}: {data: AM2CompatibilityReport}) {
  180. const sum = (arr: number[]) => arr.reduce((acc, item) => acc + item, 0);
  181. const alertCount = data.alerts.length;
  182. const widgetCount = sum(data.widgets.map(w => w.unsupported.length));
  183. const onDemandWidgetCount = sum(
  184. data.ondemand_widgets.map(w => w.ondemand_supported.length)
  185. );
  186. const sdkCount = sum(data.sdks.projects.map(p => p.unsupported.length));
  187. const numOfIssues = alertCount + widgetCount + sdkCount;
  188. const showTabs = numOfIssues > 0 || onDemandWidgetCount > 0;
  189. return (
  190. <Fragment>
  191. <ModalSectionHeader>Results</ModalSectionHeader>
  192. <InfoBox numOfIssues={alertCount + widgetCount + sdkCount} />
  193. {showTabs && (
  194. <Tabs>
  195. <TabList>
  196. <TabList.Item key="alerts">
  197. Alerts <Badge type="default">{alertCount}</Badge>
  198. </TabList.Item>
  199. <TabList.Item key="widgets">
  200. Widgets <Badge type="default">{widgetCount}</Badge>
  201. </TabList.Item>
  202. <TabList.Item key="sdks">
  203. SDKs <Badge type="default">{sdkCount}</Badge>
  204. </TabList.Item>
  205. <TabList.Item key="ondemand_widgets">
  206. On-demand Widgets <Badge type="new">{onDemandWidgetCount}</Badge>
  207. </TabList.Item>
  208. </TabList>
  209. <TabPanels>
  210. <TabPanels.Item key="alerts">
  211. <AlertPanel alerts={data.alerts} />
  212. </TabPanels.Item>
  213. <TabPanels.Item key="widgets">
  214. <WidgetPanel widgets={data.widgets} />
  215. </TabPanels.Item>
  216. <TabPanels.Item key="ondemand_widgets">
  217. <OnDemandBanner onDemandWidgetCount={onDemandWidgetCount} />
  218. <WidgetPanel widgets={data.ondemand_widgets} onDemand />
  219. </TabPanels.Item>
  220. <TabPanels.Item key="sdks">
  221. <SDKPanel sdks={data.sdks} />
  222. </TabPanels.Item>
  223. </TabPanels>
  224. </Tabs>
  225. )}
  226. </Fragment>
  227. );
  228. }
  229. function AlertPanel({alerts}: {alerts: AM2CompatibilityReport['alerts']}) {
  230. return (
  231. <TabPanelTable headers={['query', 'aggregate', 'link']}>
  232. {alerts.map(alert => (
  233. <Fragment key={alert.id}>
  234. <div>{alert.query}</div>
  235. <div>{alert.aggregate}</div>
  236. <div>
  237. <ExternalLink href={alert.url}>Go to alert</ExternalLink>
  238. </div>
  239. </Fragment>
  240. ))}
  241. </TabPanelTable>
  242. );
  243. }
  244. function WidgetPanel({
  245. widgets,
  246. onDemand,
  247. }: {
  248. widgets: AM2CompatibilityReport['widgets'] | AM2CompatibilityReport['ondemand_widgets'];
  249. onDemand?: boolean;
  250. }) {
  251. return (
  252. <TabPanelTable headers={['conditions', 'fields', 'link']}>
  253. {widgets.map(dashboard => (
  254. <Fragment key={`dashboard-${dashboard.dashboard_id}`}>
  255. <GroupHeader>Dashboard {dashboard.dashboard_id}</GroupHeader>
  256. {(dashboard.unsupported || dashboard.ondemand_supported).map((widget, i) => (
  257. <Fragment key={`widget-${widget.id}-${i}`}>
  258. <div>{widget.conditions}</div>
  259. <FieldsCell fields={widget.fields} />
  260. <div>
  261. <ExternalLink href={widget.url + (onDemand ? '?forceOnDemand=true' : '')}>
  262. Go to widget
  263. </ExternalLink>
  264. </div>
  265. </Fragment>
  266. ))}
  267. </Fragment>
  268. ))}
  269. </TabPanelTable>
  270. );
  271. }
  272. function SDKPanel({sdks}: {sdks: AM2CompatibilityReport['sdks']}) {
  273. return (
  274. <Fragment>
  275. <StyledExternalLink href={sdks.url}>View in Discover</StyledExternalLink>
  276. <TabPanelTable headers={['SDK', 'Version', 'Required version']}>
  277. {sdks.projects.map(project => (
  278. <Fragment key={`project-${project.project}`}>
  279. <GroupHeader>Project {project.project}</GroupHeader>
  280. {project.unsupported.map(sdk => {
  281. const foundSdkVersions = sdk.sdk_versions
  282. .map(sdkVersion => sdkVersion.found)
  283. .sort(semverCompare)
  284. .reverse();
  285. const foundSdkVersionsSuffix =
  286. foundSdkVersions.length > 1
  287. ? `(and ${foundSdkVersions.length - 1} lower)`
  288. : '';
  289. return (
  290. <Fragment key={`sdk-${project}`}>
  291. <div>{sdk.sdk_name}</div>
  292. <div>
  293. {foundSdkVersions[0]} {foundSdkVersionsSuffix}
  294. </div>
  295. <div>{sdk.sdk_versions[0]!.required ?? '(not found)'}</div>
  296. </Fragment>
  297. );
  298. })}
  299. </Fragment>
  300. ))}
  301. </TabPanelTable>
  302. </Fragment>
  303. );
  304. }
  305. const TabPanelTable = styled(PanelTable)`
  306. margin-top: ${space(2)};
  307. margin-bottom: 0;
  308. `;
  309. const ModalHeader = styled('h3')`
  310. ${textStyles};
  311. margin-bottom: ${space(2)};
  312. display: flex;
  313. justify-content: space-between;
  314. align-items: center;
  315. `;
  316. const ModalSectionHeader = styled('h5')`
  317. ${textStyles};
  318. margin-bottom: ${space(2)};
  319. `;
  320. const StyledExternalLink = styled(ExternalLink)`
  321. display: block;
  322. margin-top: ${space(2)};
  323. `;
  324. type GroupHeaderProps = {
  325. children: React.ReactNode;
  326. cells?: number;
  327. };
  328. function GroupHeader({children, cells = 3}: GroupHeaderProps) {
  329. return (
  330. <Fragment>
  331. <GroupHeaderCell>{children}</GroupHeaderCell>
  332. {new Array(cells - 1).fill(null).map((_, i) => (
  333. <GroupHeaderCell key={`gh=${i}`} />
  334. ))}
  335. </Fragment>
  336. );
  337. }
  338. const GroupHeaderCell = styled('div')`
  339. font-weight: bold;
  340. padding: ${space(1)} ${space(2)};
  341. color: ${p => p.theme.subText};
  342. background: ${p => p.theme.backgroundTertiary};
  343. `;
  344. function FieldsCell({fields}: {fields: string[]}) {
  345. return (
  346. <div>
  347. {fields.map((field, i) => (
  348. <div key={`field-${i}`}>{field}</div>
  349. ))}
  350. </div>
  351. );
  352. }
  353. const AM2ReportModalBody = styled(ModalBody)`
  354. max-height: 80vh;
  355. overflow-y: auto;
  356. `;
  357. const modalCss = css`
  358. width: 80%;
  359. `;
  360. export const triggerAM2CompatibilityCheck = ({organization}: Props) => {
  361. return openModal(
  362. () => (
  363. <OrganizationContext.Provider value={organization}>
  364. <AM2CompatibilityCheckModal />
  365. </OrganizationContext.Provider>
  366. ),
  367. {
  368. modalCss,
  369. }
  370. );
  371. };