timelineTableRow.tsx 9.5 KB

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