overviewRow.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import {useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import pick from 'lodash/pick';
  5. import Tag from 'sentry/components/badge/tag';
  6. import {Button} from 'sentry/components/button';
  7. import {CheckInPlaceholder} from 'sentry/components/checkInTimeline/checkInPlaceholder';
  8. import {CheckInTimeline} from 'sentry/components/checkInTimeline/checkInTimeline';
  9. import type {TimeWindowConfig} from 'sentry/components/checkInTimeline/types';
  10. import {openConfirmModal} from 'sentry/components/confirm';
  11. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  12. import ActorBadge from 'sentry/components/idBadge/actorBadge';
  13. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  14. import Link from 'sentry/components/links/link';
  15. import {IconEllipsis, IconTimer, IconUser} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import {fadeIn} from 'sentry/styles/animations';
  18. import {space} from 'sentry/styles/space';
  19. import type {ObjectStatus} from 'sentry/types/core';
  20. import {useLocation} from 'sentry/utils/useLocation';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import type {Monitor} from 'sentry/views/monitors/types';
  23. import {scheduleAsText} from 'sentry/views/monitors/utils/scheduleAsText';
  24. import {checkInStatusPrecedent, statusToText, tickStyle} from '../../utils';
  25. import {selectCheckInData} from '../../utils/selectCheckInData';
  26. import {useMonitorStats} from '../../utils/useMonitorStats';
  27. import {StatusToggleButton} from '../statusToggleButton';
  28. import MonitorEnvironmentLabel from './monitorEnvironmentLabel';
  29. interface Props {
  30. monitor: Monitor;
  31. timeWindowConfig: TimeWindowConfig;
  32. /**
  33. * TODO(epurkhiser): Remove once crons exists only in alerts
  34. */
  35. linkToAlerts?: boolean;
  36. onDeleteEnvironment?: (env: string) => Promise<void>;
  37. onToggleMuteEnvironment?: (env: string, isMuted: boolean) => Promise<void>;
  38. onToggleStatus?: (monitor: Monitor, status: ObjectStatus) => Promise<void>;
  39. /**
  40. * Whether only one monitor is being rendered in a larger view with this component
  41. * turns off things like zebra striping, hover effect, and showing monitor name
  42. */
  43. singleMonitorView?: boolean;
  44. }
  45. const MAX_SHOWN_ENVIRONMENTS = 4;
  46. export function OverviewRow({
  47. monitor,
  48. singleMonitorView,
  49. timeWindowConfig,
  50. onDeleteEnvironment,
  51. onToggleMuteEnvironment,
  52. onToggleStatus,
  53. linkToAlerts,
  54. }: Props) {
  55. const organization = useOrganization();
  56. const {data: monitorStats, isPending} = useMonitorStats({
  57. monitors: [monitor.id],
  58. timeWindowConfig,
  59. });
  60. const [isExpanded, setExpanded] = useState(
  61. monitor.environments.length <= MAX_SHOWN_ENVIRONMENTS
  62. );
  63. const environments = isExpanded
  64. ? monitor.environments
  65. : monitor.environments.slice(0, MAX_SHOWN_ENVIRONMENTS);
  66. const isDisabled = monitor.status === 'disabled';
  67. const location = useLocation();
  68. const query = pick(location.query, ['start', 'end', 'statsPeriod', 'environment']);
  69. const to = linkToAlerts
  70. ? {
  71. pathname: `/organizations/${organization.slug}/alerts/rules/crons/${monitor.project.slug}/${monitor.slug}/details/`,
  72. query,
  73. }
  74. : {
  75. pathname: `/organizations/${organization.slug}/crons/${monitor.project.slug}/${monitor.slug}/`,
  76. query,
  77. };
  78. const monitorDetails = singleMonitorView ? null : (
  79. <DetailsArea>
  80. <DetailsLink to={to}>
  81. <DetailsHeadline>
  82. <Name>{monitor.name}</Name>
  83. </DetailsHeadline>
  84. <DetailsContainer>
  85. <OwnershipDetails>
  86. <ProjectBadge project={monitor.project} avatarSize={12} disableLink />
  87. {monitor.owner ? (
  88. <ActorBadge actor={monitor.owner} avatarSize={12} />
  89. ) : (
  90. <UnassignedLabel>
  91. <IconUser size="xs" />
  92. {t('Unassigned')}
  93. </UnassignedLabel>
  94. )}
  95. </OwnershipDetails>
  96. <ScheduleDetails>
  97. <IconTimer size="xs" />
  98. {scheduleAsText(monitor.config)}
  99. </ScheduleDetails>
  100. <MonitorStatuses>
  101. {monitor.isMuted && <Tag>{t('Muted')}</Tag>}
  102. {isDisabled && <Tag>{t('Disabled')}</Tag>}
  103. </MonitorStatuses>
  104. </DetailsContainer>
  105. </DetailsLink>
  106. <DetailsActions>
  107. {onToggleStatus && (
  108. <StatusToggleButton
  109. monitor={monitor}
  110. size="xs"
  111. onToggleStatus={status => onToggleStatus(monitor, status)}
  112. />
  113. )}
  114. </DetailsActions>
  115. </DetailsArea>
  116. );
  117. const environmentActionCreators = [
  118. (env: string) => ({
  119. label: t('View Environment'),
  120. key: 'view',
  121. to: {pathname: location.pathname, query: {...query, environment: env}},
  122. }),
  123. ...(onToggleMuteEnvironment
  124. ? [
  125. (env: string, isMuted: boolean) => ({
  126. label:
  127. isMuted && !monitor.isMuted
  128. ? t('Unmute Environment')
  129. : t('Mute Environment'),
  130. key: 'mute',
  131. details: monitor.isMuted ? t('Monitor is muted') : undefined,
  132. disabled: monitor.isMuted,
  133. onAction: () => onToggleMuteEnvironment(env, !isMuted),
  134. }),
  135. ]
  136. : []),
  137. ...(onDeleteEnvironment
  138. ? [
  139. (env: string) => ({
  140. label: t('Delete Environment'),
  141. key: 'delete',
  142. onAction: () => {
  143. openConfirmModal({
  144. onConfirm: () => onDeleteEnvironment(env),
  145. header: t('Delete Environment?'),
  146. message: tct(
  147. 'Are you sure you want to permanently delete the "[envName]" environment?',
  148. {envName: env}
  149. ),
  150. confirmText: t('Delete'),
  151. priority: 'danger',
  152. });
  153. },
  154. }),
  155. ]
  156. : []),
  157. ];
  158. return (
  159. <TimelineRow
  160. as={singleMonitorView ? 'div' : 'li'}
  161. key={monitor.id}
  162. isDisabled={isDisabled}
  163. singleMonitorView={singleMonitorView}
  164. >
  165. {monitorDetails}
  166. <MonitorEnvContainer>
  167. {environments.map(env => {
  168. const {name, isMuted} = env;
  169. return (
  170. <EnvRow key={name}>
  171. <MonitorEnvironmentLabel monitorEnv={env} />
  172. <EnvDropdown
  173. size="sm"
  174. trigger={triggerProps => (
  175. <EnvActionButton
  176. {...triggerProps}
  177. aria-label={t('Monitor environment actions')}
  178. size="xs"
  179. icon={<IconEllipsis />}
  180. />
  181. )}
  182. items={environmentActionCreators.map(actionCreator =>
  183. actionCreator(name, isMuted)
  184. )}
  185. />
  186. </EnvRow>
  187. );
  188. })}
  189. {!isExpanded && (
  190. <Button size="xs" onClick={() => setExpanded(true)}>
  191. {tct('Show [num] More', {
  192. num: monitor.environments.length - MAX_SHOWN_ENVIRONMENTS,
  193. })}
  194. </Button>
  195. )}
  196. </MonitorEnvContainer>
  197. <TimelineContainer>
  198. {environments.map(({name: envName}) => (
  199. <TimelineEnvOuterContainer key={envName}>
  200. {isPending ? (
  201. <CheckInPlaceholder />
  202. ) : (
  203. <TimelineEnvContainer>
  204. <CheckInTimeline
  205. statusLabel={statusToText}
  206. statusStyle={tickStyle}
  207. statusPrecedent={checkInStatusPrecedent}
  208. timeWindowConfig={timeWindowConfig}
  209. bucketedData={selectCheckInData(
  210. monitorStats?.[monitor.id] ?? [],
  211. envName
  212. )}
  213. />
  214. </TimelineEnvContainer>
  215. )}
  216. </TimelineEnvOuterContainer>
  217. ))}
  218. </TimelineContainer>
  219. </TimelineRow>
  220. );
  221. }
  222. const DetailsLink = styled(Link)`
  223. display: block;
  224. padding: ${space(3)};
  225. color: ${p => p.theme.textColor};
  226. &:focus-visible {
  227. outline: none;
  228. }
  229. `;
  230. const DetailsArea = styled('div')`
  231. border-right: 1px solid ${p => p.theme.border};
  232. border-radius: 0;
  233. position: relative;
  234. `;
  235. const DetailsHeadline = styled('div')`
  236. display: grid;
  237. gap: ${space(1)};
  238. grid-template-columns: 1fr minmax(30px, max-content);
  239. `;
  240. const DetailsContainer = styled('div')`
  241. display: flex;
  242. flex-direction: column;
  243. gap: ${space(0.5)};
  244. `;
  245. const OwnershipDetails = styled('div')`
  246. display: flex;
  247. gap: ${space(0.75)};
  248. align-items: center;
  249. color: ${p => p.theme.subText};
  250. font-size: ${p => p.theme.fontSizeSmall};
  251. `;
  252. const UnassignedLabel = styled('div')`
  253. display: flex;
  254. gap: ${space(0.5)};
  255. align-items: center;
  256. `;
  257. const MonitorStatuses = styled('div')`
  258. display: flex;
  259. gap: ${space(0.5)};
  260. `;
  261. const Name = styled('h3')`
  262. font-size: ${p => p.theme.fontSizeLarge};
  263. word-break: break-word;
  264. margin-bottom: ${space(0.5)};
  265. `;
  266. const ScheduleDetails = styled('small')`
  267. display: flex;
  268. gap: ${space(0.5)};
  269. align-items: center;
  270. color: ${p => p.theme.subText};
  271. font-size: ${p => p.theme.fontSizeSmall};
  272. `;
  273. interface TimelineRowProps {
  274. isDisabled?: boolean;
  275. singleMonitorView?: boolean;
  276. }
  277. const TimelineRow = styled('li')<TimelineRowProps>`
  278. grid-column: 1/-1;
  279. display: grid;
  280. grid-template-columns: subgrid;
  281. ${p =>
  282. !p.singleMonitorView &&
  283. css`
  284. transition: background 50ms ease-in-out;
  285. &:nth-child(odd) {
  286. background: ${p.theme.backgroundSecondary};
  287. }
  288. &:hover {
  289. background: ${p.theme.backgroundTertiary};
  290. }
  291. &:has(*:focus-visible) {
  292. background: ${p.theme.backgroundTertiary};
  293. }
  294. `}
  295. /* Disabled monitors become more opaque */
  296. --disabled-opacity: ${p => (p.isDisabled ? '0.6' : 'unset')};
  297. &:last-child {
  298. border-bottom-left-radius: ${p => p.theme.borderRadius};
  299. border-bottom-right-radius: ${p => p.theme.borderRadius};
  300. }
  301. `;
  302. const DetailsActions = styled('div')`
  303. position: absolute;
  304. top: 0;
  305. right: 0;
  306. opacity: 0;
  307. /* Align to the center of the heading text */
  308. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  309. margin: ${space(3)};
  310. /* Show when timeline is hovered / focused */
  311. ${TimelineRow}:hover &,
  312. ${DetailsLink}:focus-visible + &,
  313. &:has(a:focus-visible),
  314. &:has(button:focus-visible) {
  315. opacity: 1;
  316. }
  317. `;
  318. const MonitorEnvContainer = styled('div')`
  319. display: flex;
  320. padding: ${space(3)} ${space(2)};
  321. gap: ${space(4)};
  322. flex-direction: column;
  323. border-right: 1px solid ${p => p.theme.innerBorder};
  324. text-align: right;
  325. `;
  326. const EnvDropdown = styled(DropdownMenu)`
  327. text-align: left;
  328. `;
  329. const EnvRow = styled('div')`
  330. display: flex;
  331. gap: ${space(0.5)};
  332. justify-content: space-between;
  333. align-items: center;
  334. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  335. `;
  336. const EnvActionButton = styled(Button)`
  337. padding: ${space(0.5)} ${space(1)};
  338. display: none;
  339. ${EnvRow}:hover & {
  340. display: block;
  341. }
  342. `;
  343. const TimelineContainer = styled('div')`
  344. display: flex;
  345. padding: ${space(3)} 0;
  346. flex-direction: column;
  347. gap: ${space(4)};
  348. contain: content;
  349. grid-column: 3/-1;
  350. `;
  351. const TimelineEnvOuterContainer = styled('div')`
  352. position: relative;
  353. height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
  354. opacity: var(--disabled-opacity);
  355. `;
  356. const TimelineEnvContainer = styled('div')`
  357. position: absolute;
  358. inset: 0;
  359. opacity: 0;
  360. animation: ${fadeIn} 1.5s ease-out forwards;
  361. contain: content;
  362. `;