timelineTableRow.tsx 9.6 KB

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