timelineTableRow.tsx 10 KB

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