overviewRow.tsx 10 KB

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