overviewRow.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import {useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import pick from 'lodash/pick';
  5. import Tag from 'sentry/components/badge/tag';
  6. import {Button} from 'sentry/components/button';
  7. import {openConfirmModal} from 'sentry/components/confirm';
  8. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  9. import ActorBadge from 'sentry/components/idBadge/actorBadge';
  10. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  11. import Link from 'sentry/components/links/link';
  12. import {IconEllipsis, IconTimer, IconUser} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import {fadeIn} from 'sentry/styles/animations';
  15. import {space} from 'sentry/styles/space';
  16. import type {ObjectStatus} from 'sentry/types/core';
  17. import {useLocation} from 'sentry/utils/useLocation';
  18. import useOrganization from 'sentry/utils/useOrganization';
  19. import type {Monitor} from 'sentry/views/monitors/types';
  20. import {scheduleAsText} from 'sentry/views/monitors/utils/scheduleAsText';
  21. import {StatusToggleButton} from '../statusToggleButton';
  22. import {CheckInPlaceholder} from '../timeline/checkInPlaceholder';
  23. import type {CheckInTimelineProps} from '../timeline/checkInTimeline';
  24. import {CheckInTimeline} from '../timeline/checkInTimeline';
  25. import {useMonitorStats} from '../timeline/hooks/useMonitorStats';
  26. import MonitorEnvironmentLabel from './monitorEnvironmentLabel';
  27. interface Props extends Omit<CheckInTimelineProps, 'bucketedData' | 'environment'> {
  28. monitor: Monitor;
  29. onDeleteEnvironment?: (env: string) => Promise<void>;
  30. onToggleMuteEnvironment?: (env: string, isMuted: boolean) => Promise<void>;
  31. onToggleStatus?: (monitor: Monitor, status: ObjectStatus) => Promise<void>;
  32. /**
  33. * Whether only one monitor is being rendered in a larger view with this component
  34. * turns off things like zebra striping, hover effect, and showing monitor name
  35. */
  36. singleMonitorView?: boolean;
  37. }
  38. const MAX_SHOWN_ENVIRONMENTS = 4;
  39. export function OverviewRow({
  40. monitor,
  41. singleMonitorView,
  42. timeWindowConfig,
  43. onDeleteEnvironment,
  44. onToggleMuteEnvironment,
  45. onToggleStatus,
  46. ...timelineProps
  47. }: Props) {
  48. const organization = useOrganization();
  49. const {data: monitorStats, isPending} = useMonitorStats({
  50. monitors: [monitor.id],
  51. timeWindowConfig,
  52. });
  53. const [isExpanded, setExpanded] = useState(
  54. monitor.environments.length <= MAX_SHOWN_ENVIRONMENTS
  55. );
  56. const environments = isExpanded
  57. ? monitor.environments
  58. : monitor.environments.slice(0, MAX_SHOWN_ENVIRONMENTS);
  59. const isDisabled = monitor.status === 'disabled';
  60. const location = useLocation();
  61. const query = pick(location.query, ['start', 'end', 'statsPeriod', 'environment']);
  62. const monitorDetails = singleMonitorView ? null : (
  63. <DetailsArea>
  64. <DetailsLink
  65. to={{
  66. pathname: `/organizations/${organization.slug}/crons/${monitor.project.slug}/${monitor.slug}/`,
  67. query,
  68. }}
  69. >
  70. <DetailsHeadline>
  71. <Name>{monitor.name}</Name>
  72. </DetailsHeadline>
  73. <DetailsContainer>
  74. <OwnershipDetails>
  75. <ProjectBadge project={monitor.project} avatarSize={12} disableLink />
  76. {monitor.owner ? (
  77. <ActorBadge actor={monitor.owner} avatarSize={12} />
  78. ) : (
  79. <UnassignedLabel>
  80. <IconUser size="xs" />
  81. {t('Unassigned')}
  82. </UnassignedLabel>
  83. )}
  84. </OwnershipDetails>
  85. <ScheduleDetails>
  86. <IconTimer size="xs" />
  87. {scheduleAsText(monitor.config)}
  88. </ScheduleDetails>
  89. <MonitorStatuses>
  90. {monitor.isMuted && <Tag>{t('Muted')}</Tag>}
  91. {isDisabled && <Tag>{t('Disabled')}</Tag>}
  92. </MonitorStatuses>
  93. </DetailsContainer>
  94. </DetailsLink>
  95. <DetailsActions>
  96. {onToggleStatus && (
  97. <StatusToggleButton
  98. monitor={monitor}
  99. size="xs"
  100. onToggleStatus={status => onToggleStatus(monitor, status)}
  101. />
  102. )}
  103. </DetailsActions>
  104. </DetailsArea>
  105. );
  106. const environmentActionCreators = [
  107. (env: string) => ({
  108. label: t('View Environment'),
  109. key: 'view',
  110. to: {pathname: location.pathname, query: {...query, environment: env}},
  111. }),
  112. ...(onToggleMuteEnvironment
  113. ? [
  114. (env: string, isMuted: boolean) => ({
  115. label:
  116. isMuted && !monitor.isMuted
  117. ? t('Unmute Environment')
  118. : t('Mute Environment'),
  119. key: 'mute',
  120. details: monitor.isMuted ? t('Monitor is muted') : undefined,
  121. disabled: monitor.isMuted,
  122. onAction: () => onToggleMuteEnvironment(env, !isMuted),
  123. }),
  124. ]
  125. : []),
  126. ...(onDeleteEnvironment
  127. ? [
  128. (env: string) => ({
  129. label: t('Delete Environment'),
  130. key: 'delete',
  131. onAction: () => {
  132. openConfirmModal({
  133. onConfirm: () => onDeleteEnvironment(env),
  134. header: t('Delete Environment?'),
  135. message: tct(
  136. 'Are you sure you want to permanently delete the "[envName]" environment?',
  137. {envName: env}
  138. ),
  139. confirmText: t('Delete'),
  140. priority: 'danger',
  141. });
  142. },
  143. }),
  144. ]
  145. : []),
  146. ];
  147. return (
  148. <TimelineRow
  149. as={singleMonitorView ? 'div' : 'li'}
  150. key={monitor.id}
  151. isDisabled={isDisabled}
  152. singleMonitorView={singleMonitorView}
  153. >
  154. {monitorDetails}
  155. <MonitorEnvContainer>
  156. {environments.map(env => {
  157. const {name, isMuted} = env;
  158. return (
  159. <EnvRow key={name}>
  160. <DropdownMenu
  161. size="sm"
  162. trigger={triggerProps => (
  163. <EnvActionButton
  164. {...triggerProps}
  165. aria-label={t('Monitor environment actions')}
  166. size="xs"
  167. icon={<IconEllipsis />}
  168. />
  169. )}
  170. items={environmentActionCreators.map(actionCreator =>
  171. actionCreator(name, isMuted)
  172. )}
  173. />
  174. <MonitorEnvironmentLabel monitorEnv={env} />
  175. </EnvRow>
  176. );
  177. })}
  178. {!isExpanded && (
  179. <Button size="xs" onClick={() => setExpanded(true)}>
  180. {tct('Show [num] More', {
  181. num: monitor.environments.length - MAX_SHOWN_ENVIRONMENTS,
  182. })}
  183. </Button>
  184. )}
  185. </MonitorEnvContainer>
  186. <TimelineContainer>
  187. {environments.map(({name}) => {
  188. return (
  189. <TimelineEnvOuterContainer key={name}>
  190. {isPending ? (
  191. <CheckInPlaceholder />
  192. ) : (
  193. <TimelineEnvContainer>
  194. <CheckInTimeline
  195. {...timelineProps}
  196. timeWindowConfig={timeWindowConfig}
  197. bucketedData={monitorStats?.[monitor.id] ?? []}
  198. environment={name}
  199. />
  200. </TimelineEnvContainer>
  201. )}
  202. </TimelineEnvOuterContainer>
  203. );
  204. })}
  205. </TimelineContainer>
  206. </TimelineRow>
  207. );
  208. }
  209. const DetailsLink = styled(Link)`
  210. display: block;
  211. padding: ${space(3)};
  212. color: ${p => p.theme.textColor};
  213. &:focus-visible {
  214. outline: none;
  215. }
  216. `;
  217. const DetailsArea = styled('div')`
  218. border-right: 1px solid ${p => p.theme.border};
  219. border-radius: 0;
  220. position: relative;
  221. `;
  222. const DetailsHeadline = styled('div')`
  223. display: grid;
  224. gap: ${space(1)};
  225. grid-template-columns: 1fr minmax(30px, max-content);
  226. `;
  227. const DetailsContainer = styled('div')`
  228. display: flex;
  229. flex-direction: column;
  230. gap: ${space(0.5)};
  231. `;
  232. const OwnershipDetails = styled('div')`
  233. display: flex;
  234. gap: ${space(0.75)};
  235. align-items: center;
  236. color: ${p => p.theme.subText};
  237. font-size: ${p => p.theme.fontSizeSmall};
  238. `;
  239. const UnassignedLabel = styled('div')`
  240. display: flex;
  241. gap: ${space(0.5)};
  242. align-items: center;
  243. `;
  244. const MonitorStatuses = styled('div')`
  245. display: flex;
  246. gap: ${space(0.5)};
  247. `;
  248. const Name = styled('h3')`
  249. font-size: ${p => p.theme.fontSizeLarge};
  250. word-break: break-word;
  251. margin-bottom: ${space(0.5)};
  252. `;
  253. const ScheduleDetails = styled('small')`
  254. display: flex;
  255. gap: ${space(0.5)};
  256. align-items: center;
  257. color: ${p => p.theme.subText};
  258. font-size: ${p => p.theme.fontSizeSmall};
  259. `;
  260. interface TimelineRowProps {
  261. isDisabled?: boolean;
  262. singleMonitorView?: boolean;
  263. }
  264. const TimelineRow = styled('li')<TimelineRowProps>`
  265. grid-column: 1/-1;
  266. display: grid;
  267. grid-template-columns: subgrid;
  268. ${p =>
  269. !p.singleMonitorView &&
  270. css`
  271. transition: background 50ms ease-in-out;
  272. &:nth-child(odd) {
  273. background: ${p.theme.backgroundSecondary};
  274. }
  275. &:hover {
  276. background: ${p.theme.backgroundTertiary};
  277. }
  278. &:has(*:focus-visible) {
  279. background: ${p.theme.backgroundTertiary};
  280. }
  281. `}
  282. /* Disabled monitors become more opaque */
  283. --disabled-opacity: ${p => (p.isDisabled ? '0.6' : 'unset')};
  284. &:last-child {
  285. border-bottom-left-radius: ${p => p.theme.borderRadius};
  286. border-bottom-right-radius: ${p => p.theme.borderRadius};
  287. }
  288. `;
  289. const DetailsActions = styled('div')`
  290. position: absolute;
  291. top: 0;
  292. right: 0;
  293. opacity: 0;
  294. /* Align to the center of the heading text */
  295. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  296. margin: ${space(3)};
  297. /* Show when timeline is hovered / focused */
  298. ${TimelineRow}:hover &,
  299. ${DetailsLink}:focus-visible + &,
  300. &:has(a:focus-visible),
  301. &:has(button:focus-visible) {
  302. opacity: 1;
  303. }
  304. `;
  305. const MonitorEnvContainer = styled('div')`
  306. display: flex;
  307. padding: ${space(3)} ${space(2)};
  308. gap: ${space(4)};
  309. flex-direction: column;
  310. border-right: 1px solid ${p => p.theme.innerBorder};
  311. text-align: right;
  312. `;
  313. const EnvRow = styled('div')`
  314. display: flex;
  315. gap: ${space(0.5)};
  316. justify-content: space-between;
  317. align-items: center;
  318. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  319. `;
  320. const EnvActionButton = styled(Button)`
  321. padding: ${space(0.5)} ${space(1)};
  322. display: none;
  323. ${EnvRow}:hover & {
  324. display: block;
  325. }
  326. `;
  327. const TimelineContainer = styled('div')`
  328. display: flex;
  329. padding: ${space(3)} 0;
  330. flex-direction: column;
  331. gap: ${space(4)};
  332. contain: content;
  333. grid-column: 3/-1;
  334. `;
  335. const TimelineEnvOuterContainer = styled('div')`
  336. position: relative;
  337. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  338. opacity: var(--disabled-opacity);
  339. `;
  340. const TimelineEnvContainer = styled('div')`
  341. position: absolute;
  342. inset: 0;
  343. opacity: 0;
  344. animation: ${fadeIn} 1.5s ease-out forwards;
  345. contain: content;
  346. `;