index.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {BarChart} from 'sentry/components/charts/barChart';
  4. import type {LineChartSeries} from 'sentry/components/charts/lineChart';
  5. import {LineChart} from 'sentry/components/charts/lineChart';
  6. import {DateTime} from 'sentry/components/dateTime';
  7. import Link from 'sentry/components/links/link';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelBody from 'sentry/components/panels/panelBody';
  12. import PanelFooter from 'sentry/components/panels/panelFooter';
  13. import PanelHeader from 'sentry/components/panels/panelHeader';
  14. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {SentryApp} from 'sentry/types/integrations';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import {useParams} from 'sentry/utils/useParams';
  21. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  22. import RequestLog from './requestLog';
  23. type Interactions = {
  24. componentInteractions: {
  25. [key: string]: Array<[number, number]>;
  26. };
  27. views: Array<[number, number]>;
  28. };
  29. type Stats = {
  30. installStats: Array<[number, number]>;
  31. totalInstalls: number;
  32. totalUninstalls: number;
  33. uninstallStats: Array<[number, number]>;
  34. };
  35. function SentryApplicationDashboard() {
  36. const organization = useOrganization();
  37. const {appSlug} = useParams<{appSlug: string}>();
  38. // Default time range for now: 90 days ago to now
  39. const now = Math.floor(new Date().getTime() / 1000);
  40. const ninety_days_ago = 3600 * 24 * 90;
  41. const {
  42. data: app,
  43. isPending: isAppPending,
  44. isError: isAppError,
  45. } = useApiQuery<SentryApp>([`/sentry-apps/${appSlug}/`], {staleTime: 0});
  46. const {
  47. data: interactions,
  48. isPending: isInteractionsPending,
  49. isError: isInteractionsError,
  50. } = useApiQuery<Interactions>(
  51. [
  52. `/sentry-apps/${appSlug}/interaction/`,
  53. {query: {since: now - ninety_days_ago, until: now}},
  54. ],
  55. {staleTime: 0}
  56. );
  57. const {
  58. data: stats,
  59. isPending: isStatsPending,
  60. isError: isStatsError,
  61. } = useApiQuery<Stats>(
  62. [
  63. `/sentry-apps/${appSlug}/stats/`,
  64. {query: {since: now - ninety_days_ago, until: now}},
  65. ],
  66. {staleTime: 0}
  67. );
  68. if (isAppPending || isStatsPending || isInteractionsPending) {
  69. return <LoadingIndicator />;
  70. }
  71. if (isAppError || isStatsError || isInteractionsError) {
  72. return <LoadingError />;
  73. }
  74. const {installStats, uninstallStats, totalUninstalls, totalInstalls} = stats;
  75. const {views, componentInteractions} = interactions;
  76. const renderInstallData = () => {
  77. return (
  78. <Fragment>
  79. <h5>{t('Installation & Interaction Data')}</h5>
  80. <Row>
  81. {app.datePublished ? (
  82. <StatsSection>
  83. <StatsHeader>{t('Date published')}</StatsHeader>
  84. <DateTime dateOnly date={app.datePublished} />
  85. </StatsSection>
  86. ) : null}
  87. <StatsSection data-test-id="installs">
  88. <StatsHeader>{t('Total installs')}</StatsHeader>
  89. <p>{totalInstalls}</p>
  90. </StatsSection>
  91. <StatsSection data-test-id="uninstalls">
  92. <StatsHeader>{t('Total uninstalls')}</StatsHeader>
  93. <p>{totalUninstalls}</p>
  94. </StatsSection>
  95. </Row>
  96. {renderInstallCharts()}
  97. </Fragment>
  98. );
  99. };
  100. const renderInstallCharts = () => {
  101. const installSeries = {
  102. data: installStats.map(point => ({
  103. name: point[0] * 1000,
  104. value: point[1],
  105. })),
  106. seriesName: t('installed'),
  107. };
  108. const uninstallSeries = {
  109. data: uninstallStats.map(point => ({
  110. name: point[0] * 1000,
  111. value: point[1],
  112. })),
  113. seriesName: t('uninstalled'),
  114. };
  115. return (
  116. <Panel>
  117. <PanelHeader>{t('Installations/Uninstallations over Last 90 Days')}</PanelHeader>
  118. <ChartWrapper>
  119. <BarChart
  120. series={[installSeries, uninstallSeries]}
  121. height={150}
  122. stacked
  123. isGroupedByDate
  124. legend={{
  125. show: true,
  126. orient: 'horizontal',
  127. data: ['installed', 'uninstalled'],
  128. itemWidth: 15,
  129. }}
  130. yAxis={{type: 'value', minInterval: 1, max: 'dataMax'}}
  131. xAxis={{type: 'time'}}
  132. grid={{left: space(4), right: space(4)}}
  133. />
  134. </ChartWrapper>
  135. </Panel>
  136. );
  137. };
  138. const renderIntegrationViews = () => {
  139. return (
  140. <Panel>
  141. <PanelHeader>{t('Integration Views')}</PanelHeader>
  142. <PanelBody>
  143. <InteractionsChart data={{Views: views}} />
  144. </PanelBody>
  145. <PanelFooter>
  146. <StyledFooter>
  147. {t('Integration views are measured through views on the ')}
  148. <Link to={`/sentry-apps/${appSlug}/external-install/`}>
  149. {t('external installation page')}
  150. </Link>
  151. {t(' and views on the Learn More/Install modal on the ')}
  152. <Link to={`/settings/${organization.slug}/integrations/`}>
  153. {t('integrations page')}
  154. </Link>
  155. </StyledFooter>
  156. </PanelFooter>
  157. </Panel>
  158. );
  159. };
  160. const renderComponentInteractions = () => {
  161. const componentInteractionsDetails = {
  162. 'stacktrace-link': t(
  163. 'Each link click or context menu open counts as one interaction'
  164. ),
  165. 'issue-link': t('Each open of the issue link modal counts as one interaction'),
  166. };
  167. return (
  168. <Panel>
  169. <PanelHeader>{t('Component Interactions')}</PanelHeader>
  170. <PanelBody>
  171. <InteractionsChart data={componentInteractions} />
  172. </PanelBody>
  173. <PanelFooter>
  174. <StyledFooter>
  175. {Object.keys(componentInteractions).map(
  176. (component, idx) =>
  177. componentInteractionsDetails[
  178. component as keyof typeof componentInteractionsDetails
  179. ] && (
  180. <Fragment key={idx}>
  181. <strong>{`${component}: `}</strong>
  182. {
  183. componentInteractionsDetails[
  184. component as keyof typeof componentInteractionsDetails
  185. ]
  186. }
  187. <br />
  188. </Fragment>
  189. )
  190. )}
  191. </StyledFooter>
  192. </PanelFooter>
  193. </Panel>
  194. );
  195. };
  196. return (
  197. <div>
  198. <SentryDocumentTitle title={t('Integration Dashboard')} />
  199. <SettingsPageHeader title={`${t('Integration Dashboard')} - ${app.name}`} />
  200. {app.status === 'published' && renderInstallData()}
  201. {app.status === 'published' && renderIntegrationViews()}
  202. {app.schema.elements && renderComponentInteractions()}
  203. <RequestLog app={app} />
  204. </div>
  205. );
  206. }
  207. export default SentryApplicationDashboard;
  208. type InteractionsChartProps = {
  209. data: {
  210. [key: string]: Array<[number, number]>;
  211. };
  212. };
  213. function InteractionsChart({data}: InteractionsChartProps) {
  214. const elementInteractionsSeries: LineChartSeries[] = Object.keys(data).map(
  215. (key: string) => {
  216. const seriesData = data[key]!.map(point => ({
  217. value: point[1],
  218. name: point[0] * 1000,
  219. }));
  220. return {
  221. seriesName: key,
  222. data: seriesData,
  223. };
  224. }
  225. );
  226. return (
  227. <ChartWrapper>
  228. <LineChart
  229. isGroupedByDate
  230. series={elementInteractionsSeries}
  231. grid={{left: space(4), right: space(4)}}
  232. legend={{
  233. show: true,
  234. orient: 'horizontal',
  235. data: Object.keys(data),
  236. }}
  237. />
  238. </ChartWrapper>
  239. );
  240. }
  241. const Row = styled('div')`
  242. display: flex;
  243. `;
  244. const StatsSection = styled('div')`
  245. margin-right: ${space(4)};
  246. `;
  247. const StatsHeader = styled('h6')`
  248. margin-bottom: ${space(1)};
  249. font-size: 12px;
  250. text-transform: uppercase;
  251. color: ${p => p.theme.subText};
  252. `;
  253. const StyledFooter = styled('div')`
  254. padding: ${space(1.5)};
  255. `;
  256. const ChartWrapper = styled('div')`
  257. padding-top: ${space(3)};
  258. `;