123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- import {useState} from 'react';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import pick from 'lodash/pick';
- import Tag from 'sentry/components/badge/tag';
- import {Button} from 'sentry/components/button';
- import {openConfirmModal} from 'sentry/components/confirm';
- import {DropdownMenu} from 'sentry/components/dropdownMenu';
- import ActorBadge from 'sentry/components/idBadge/actorBadge';
- import ProjectBadge from 'sentry/components/idBadge/projectBadge';
- import Link from 'sentry/components/links/link';
- import {IconEllipsis, IconTimer, IconUser} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {fadeIn} from 'sentry/styles/animations';
- import {space} from 'sentry/styles/space';
- import type {ObjectStatus} from 'sentry/types/core';
- import {useLocation} from 'sentry/utils/useLocation';
- import useOrganization from 'sentry/utils/useOrganization';
- import type {Monitor} from 'sentry/views/monitors/types';
- import {scheduleAsText} from 'sentry/views/monitors/utils/scheduleAsText';
- import {StatusToggleButton} from '../statusToggleButton';
- import {CheckInPlaceholder} from '../timeline/checkInPlaceholder';
- import type {CheckInTimelineProps} from '../timeline/checkInTimeline';
- import {CheckInTimeline} from '../timeline/checkInTimeline';
- import {useMonitorStats} from '../timeline/hooks/useMonitorStats';
- import MonitorEnvironmentLabel from './monitorEnvironmentLabel';
- interface Props extends Omit<CheckInTimelineProps, 'bucketedData' | 'environment'> {
- monitor: Monitor;
- onDeleteEnvironment?: (env: string) => Promise<void>;
- onToggleMuteEnvironment?: (env: string, isMuted: boolean) => Promise<void>;
- onToggleStatus?: (monitor: Monitor, status: ObjectStatus) => Promise<void>;
- /**
- * Whether only one monitor is being rendered in a larger view with this component
- * turns off things like zebra striping, hover effect, and showing monitor name
- */
- singleMonitorView?: boolean;
- }
- const MAX_SHOWN_ENVIRONMENTS = 4;
- export function OverviewRow({
- monitor,
- singleMonitorView,
- timeWindowConfig,
- onDeleteEnvironment,
- onToggleMuteEnvironment,
- onToggleStatus,
- ...timelineProps
- }: Props) {
- const organization = useOrganization();
- const {data: monitorStats, isPending} = useMonitorStats({
- monitors: [monitor.id],
- timeWindowConfig,
- });
- const [isExpanded, setExpanded] = useState(
- monitor.environments.length <= MAX_SHOWN_ENVIRONMENTS
- );
- const environments = isExpanded
- ? monitor.environments
- : monitor.environments.slice(0, MAX_SHOWN_ENVIRONMENTS);
- const isDisabled = monitor.status === 'disabled';
- const location = useLocation();
- const query = pick(location.query, ['start', 'end', 'statsPeriod', 'environment']);
- const monitorDetails = singleMonitorView ? null : (
- <DetailsArea>
- <DetailsLink
- to={{
- pathname: `/organizations/${organization.slug}/crons/${monitor.project.slug}/${monitor.slug}/`,
- query,
- }}
- >
- <DetailsHeadline>
- <Name>{monitor.name}</Name>
- </DetailsHeadline>
- <DetailsContainer>
- <OwnershipDetails>
- <ProjectBadge project={monitor.project} avatarSize={12} disableLink />
- {monitor.owner ? (
- <ActorBadge actor={monitor.owner} avatarSize={12} />
- ) : (
- <UnassignedLabel>
- <IconUser size="xs" />
- {t('Unassigned')}
- </UnassignedLabel>
- )}
- </OwnershipDetails>
- <ScheduleDetails>
- <IconTimer size="xs" />
- {scheduleAsText(monitor.config)}
- </ScheduleDetails>
- <MonitorStatuses>
- {monitor.isMuted && <Tag>{t('Muted')}</Tag>}
- {isDisabled && <Tag>{t('Disabled')}</Tag>}
- </MonitorStatuses>
- </DetailsContainer>
- </DetailsLink>
- <DetailsActions>
- {onToggleStatus && (
- <StatusToggleButton
- monitor={monitor}
- size="xs"
- onToggleStatus={status => onToggleStatus(monitor, status)}
- />
- )}
- </DetailsActions>
- </DetailsArea>
- );
- const environmentActionCreators = [
- (env: string) => ({
- label: t('View Environment'),
- key: 'view',
- to: {pathname: location.pathname, query: {...query, environment: env}},
- }),
- ...(onToggleMuteEnvironment
- ? [
- (env: string, isMuted: boolean) => ({
- label:
- isMuted && !monitor.isMuted
- ? t('Unmute Environment')
- : t('Mute Environment'),
- key: 'mute',
- details: monitor.isMuted ? t('Monitor is muted') : undefined,
- disabled: monitor.isMuted,
- onAction: () => onToggleMuteEnvironment(env, !isMuted),
- }),
- ]
- : []),
- ...(onDeleteEnvironment
- ? [
- (env: string) => ({
- label: t('Delete Environment'),
- key: 'delete',
- onAction: () => {
- openConfirmModal({
- onConfirm: () => onDeleteEnvironment(env),
- header: t('Delete Environment?'),
- message: tct(
- 'Are you sure you want to permanently delete the "[envName]" environment?',
- {envName: env}
- ),
- confirmText: t('Delete'),
- priority: 'danger',
- });
- },
- }),
- ]
- : []),
- ];
- return (
- <TimelineRow
- as={singleMonitorView ? 'div' : 'li'}
- key={monitor.id}
- isDisabled={isDisabled}
- singleMonitorView={singleMonitorView}
- >
- {monitorDetails}
- <MonitorEnvContainer>
- {environments.map(env => {
- const {name, isMuted} = env;
- return (
- <EnvRow key={name}>
- <DropdownMenu
- size="sm"
- trigger={triggerProps => (
- <EnvActionButton
- {...triggerProps}
- aria-label={t('Monitor environment actions')}
- size="xs"
- icon={<IconEllipsis />}
- />
- )}
- items={environmentActionCreators.map(actionCreator =>
- actionCreator(name, isMuted)
- )}
- />
- <MonitorEnvironmentLabel monitorEnv={env} />
- </EnvRow>
- );
- })}
- {!isExpanded && (
- <Button size="xs" onClick={() => setExpanded(true)}>
- {tct('Show [num] More', {
- num: monitor.environments.length - MAX_SHOWN_ENVIRONMENTS,
- })}
- </Button>
- )}
- </MonitorEnvContainer>
- <TimelineContainer>
- {environments.map(({name}) => {
- return (
- <TimelineEnvOuterContainer key={name}>
- {isPending ? (
- <CheckInPlaceholder />
- ) : (
- <TimelineEnvContainer>
- <CheckInTimeline
- {...timelineProps}
- timeWindowConfig={timeWindowConfig}
- bucketedData={monitorStats?.[monitor.id] ?? []}
- environment={name}
- />
- </TimelineEnvContainer>
- )}
- </TimelineEnvOuterContainer>
- );
- })}
- </TimelineContainer>
- </TimelineRow>
- );
- }
- const DetailsLink = styled(Link)`
- display: block;
- padding: ${space(3)};
- color: ${p => p.theme.textColor};
- &:focus-visible {
- outline: none;
- }
- `;
- const DetailsArea = styled('div')`
- border-right: 1px solid ${p => p.theme.border};
- border-radius: 0;
- position: relative;
- `;
- const DetailsHeadline = styled('div')`
- display: grid;
- gap: ${space(1)};
- grid-template-columns: 1fr minmax(30px, max-content);
- `;
- const DetailsContainer = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${space(0.5)};
- `;
- const OwnershipDetails = styled('div')`
- display: flex;
- gap: ${space(0.75)};
- align-items: center;
- color: ${p => p.theme.subText};
- font-size: ${p => p.theme.fontSizeSmall};
- `;
- const UnassignedLabel = styled('div')`
- display: flex;
- gap: ${space(0.5)};
- align-items: center;
- `;
- const MonitorStatuses = styled('div')`
- display: flex;
- gap: ${space(0.5)};
- `;
- const Name = styled('h3')`
- font-size: ${p => p.theme.fontSizeLarge};
- word-break: break-word;
- margin-bottom: ${space(0.5)};
- `;
- const ScheduleDetails = styled('small')`
- display: flex;
- gap: ${space(0.5)};
- align-items: center;
- color: ${p => p.theme.subText};
- font-size: ${p => p.theme.fontSizeSmall};
- `;
- interface TimelineRowProps {
- isDisabled?: boolean;
- singleMonitorView?: boolean;
- }
- const TimelineRow = styled('li')<TimelineRowProps>`
- grid-column: 1/-1;
- display: grid;
- grid-template-columns: subgrid;
- ${p =>
- !p.singleMonitorView &&
- css`
- transition: background 50ms ease-in-out;
- &:nth-child(odd) {
- background: ${p.theme.backgroundSecondary};
- }
- &:hover {
- background: ${p.theme.backgroundTertiary};
- }
- &:has(*:focus-visible) {
- background: ${p.theme.backgroundTertiary};
- }
- `}
- /* Disabled monitors become more opaque */
- --disabled-opacity: ${p => (p.isDisabled ? '0.6' : 'unset')};
- &:last-child {
- border-bottom-left-radius: ${p => p.theme.borderRadius};
- border-bottom-right-radius: ${p => p.theme.borderRadius};
- }
- `;
- const DetailsActions = styled('div')`
- position: absolute;
- top: 0;
- right: 0;
- opacity: 0;
- /* Align to the center of the heading text */
- height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
- margin: ${space(3)};
- /* Show when timeline is hovered / focused */
- ${TimelineRow}:hover &,
- ${DetailsLink}:focus-visible + &,
- &:has(a:focus-visible),
- &:has(button:focus-visible) {
- opacity: 1;
- }
- `;
- const MonitorEnvContainer = styled('div')`
- display: flex;
- padding: ${space(3)} ${space(2)};
- gap: ${space(4)};
- flex-direction: column;
- border-right: 1px solid ${p => p.theme.innerBorder};
- text-align: right;
- `;
- const EnvRow = styled('div')`
- display: flex;
- gap: ${space(0.5)};
- justify-content: space-between;
- align-items: center;
- height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
- `;
- const EnvActionButton = styled(Button)`
- padding: ${space(0.5)} ${space(1)};
- display: none;
- ${EnvRow}:hover & {
- display: block;
- }
- `;
- const TimelineContainer = styled('div')`
- display: flex;
- padding: ${space(3)} 0;
- flex-direction: column;
- gap: ${space(4)};
- contain: content;
- grid-column: 3/-1;
- `;
- const TimelineEnvOuterContainer = styled('div')`
- position: relative;
- height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading});
- opacity: var(--disabled-opacity);
- `;
- const TimelineEnvContainer = styled('div')`
- position: absolute;
- inset: 0;
- opacity: 0;
- animation: ${fadeIn} 1.5s ease-out forwards;
- contain: content;
- `;
|