overviewRow.tsx 11 KB

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