overviewRow.tsx 10 KB

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