timelineTableRow.tsx 10 KB

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