timelineTableRow.tsx 9.6 KB

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