mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
One family. Font stacks name Zen and fall back to the native faces, tokens name the role rather than a face, and the packages that used to supply a face now point at @hanzo/font. Self-hosted @font-face blocks are removed rather than renamed: rewriting the family in one would leave a src pointing at a file this repo no longer ships. @hanzo/font carries the binaries and declares the faces, so the local copies go with the declaration that used them. Comments naming a face we no longer use are deleted, not reworded.
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import {Fragment, useCallback, useMemo, useState} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import pick from 'lodash/pick';
|
|
import * as qs from 'query-string';
|
|
|
|
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
|
|
import {Button} from 'sentry/components/button';
|
|
import {AreaChart} from 'sentry/components/charts/areaChart';
|
|
import {getFormatter} from 'sentry/components/charts/components/tooltip';
|
|
import {HeaderTitleLegend} from 'sentry/components/charts/styles';
|
|
import CircleIndicator from 'sentry/components/circleIndicator';
|
|
import SelectControl from 'sentry/components/forms/controls/selectControl';
|
|
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import Panel from 'sentry/components/panels/panel';
|
|
import PanelBody from 'sentry/components/panels/panelBody';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {PageFilters, Project} from 'sentry/types';
|
|
import {parsePeriodToHours, statsPeriodToDays} from 'sentry/utils/dates';
|
|
import {
|
|
getDDMInterval,
|
|
getFieldFromMetricsQuery as getAlertAggregate,
|
|
} from 'sentry/utils/metrics';
|
|
import {formatMetricUsingFixedUnit} from 'sentry/utils/metrics/formatters';
|
|
import {formatMRIField, getUseCaseFromMRI, parseMRI} from 'sentry/utils/metrics/mri';
|
|
import type {MetricsQuery} from 'sentry/utils/metrics/types';
|
|
import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
|
|
import useOrganization from 'sentry/utils/useOrganization';
|
|
import useProjects from 'sentry/utils/useProjects';
|
|
import useRouter from 'sentry/utils/useRouter';
|
|
import {AVAILABLE_TIME_PERIODS} from 'sentry/views/alerts/rules/metric/triggers/chart';
|
|
import {
|
|
Dataset,
|
|
EventTypes,
|
|
TimePeriod,
|
|
TimeWindow,
|
|
} from 'sentry/views/alerts/rules/metric/types';
|
|
import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
|
|
import {createChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
|
|
import {getChartTimeseries} from 'sentry/views/ddm/widget';
|
|
|
|
interface FormState {
|
|
environment: string | null;
|
|
project: string | null;
|
|
}
|
|
|
|
function getInitialFormState(metricsQuery: MetricsQuery): FormState {
|
|
const project =
|
|
metricsQuery.projects.length === 1 ? metricsQuery.projects[0].toString() : null;
|
|
const environment =
|
|
metricsQuery.environments.length === 1 && project
|
|
? metricsQuery.environments[0]
|
|
: null;
|
|
|
|
return {
|
|
project,
|
|
environment,
|
|
};
|
|
}
|
|
|
|
function getAlertPeriod(metricsQuery: MetricsQuery) {
|
|
const {period, start, end} = metricsQuery.datetime;
|
|
const inHours = statsPeriodToDays(period, start, end) * 24;
|
|
|
|
switch (true) {
|
|
case inHours <= 6:
|
|
return TimePeriod.SIX_HOURS;
|
|
case inHours <= 24:
|
|
return TimePeriod.ONE_DAY;
|
|
case inHours <= 3 * 24:
|
|
return TimePeriod.THREE_DAYS;
|
|
case inHours <= 7 * 24:
|
|
return TimePeriod.SEVEN_DAYS;
|
|
case inHours <= 14 * 24:
|
|
return TimePeriod.FOURTEEN_DAYS;
|
|
default:
|
|
return TimePeriod.SEVEN_DAYS;
|
|
}
|
|
}
|
|
|
|
const TIME_WINDOWS_TO_CHECK = [
|
|
TimeWindow.ONE_MINUTE,
|
|
TimeWindow.FIVE_MINUTES,
|
|
TimeWindow.TEN_MINUTES,
|
|
TimeWindow.FIFTEEN_MINUTES,
|
|
TimeWindow.THIRTY_MINUTES,
|
|
TimeWindow.ONE_HOUR,
|
|
TimeWindow.TWO_HOURS,
|
|
TimeWindow.FOUR_HOURS,
|
|
TimeWindow.ONE_DAY,
|
|
];
|
|
|
|
export function getAlertInterval(metricsQuery, period: TimePeriod) {
|
|
const useCase = getUseCaseFromMRI(metricsQuery.mri) ?? 'custom';
|
|
const interval = getDDMInterval(metricsQuery.datetime, useCase);
|
|
const inMinutes = parsePeriodToHours(interval) * 60;
|
|
|
|
function toInterval(timeWindow: TimeWindow) {
|
|
return `${timeWindow}m`;
|
|
}
|
|
|
|
for (let index = 0; index < TIME_WINDOWS_TO_CHECK.length; index++) {
|
|
const timeWindow = TIME_WINDOWS_TO_CHECK[index];
|
|
if (inMinutes <= timeWindow && AVAILABLE_TIME_PERIODS[timeWindow].includes(period)) {
|
|
return toInterval(timeWindow);
|
|
}
|
|
}
|
|
|
|
return toInterval(TimeWindow.ONE_HOUR);
|
|
}
|
|
|
|
interface Props extends ModalRenderProps {
|
|
metricsQuery: MetricsQuery;
|
|
}
|
|
|
|
export function CreateAlertModal({Header, Body, Footer, metricsQuery}: Props) {
|
|
const router = useRouter();
|
|
const organization = useOrganization();
|
|
const {projects} = useProjects();
|
|
const [formState, setFormState] = useState<FormState>(() =>
|
|
getInitialFormState(metricsQuery)
|
|
);
|
|
|
|
const selectedProject = projects.find(p => p.id === formState.project);
|
|
const isFormValid = formState.project !== null;
|
|
|
|
const alertPeriod = useMemo(() => getAlertPeriod(metricsQuery), [metricsQuery]);
|
|
const alertInterval = useMemo(
|
|
() => getAlertInterval(metricsQuery, alertPeriod),
|
|
[metricsQuery, alertPeriod]
|
|
);
|
|
|
|
const alertChartQuery = pick(metricsQuery, 'mri', 'op', 'query');
|
|
|
|
const aggregate = useMemo(() => getAlertAggregate(metricsQuery), [metricsQuery]);
|
|
|
|
const {data, isLoading, refetch, isError} = useMetricsQuery(
|
|
[alertChartQuery],
|
|
{
|
|
projects: formState.project ? [parseInt(formState.project, 10)] : [],
|
|
environments: formState.environment ? [formState.environment] : [],
|
|
datetime: {period: alertPeriod} as PageFilters['datetime'],
|
|
},
|
|
{
|
|
interval: alertInterval,
|
|
}
|
|
);
|
|
|
|
const chartSeries = useMemo(
|
|
() =>
|
|
data &&
|
|
getChartTimeseries(data, [alertChartQuery], {
|
|
// We are limited to one series in this chart, so we can just use the first color
|
|
getChartPalette: createChartPalette,
|
|
}),
|
|
[alertChartQuery, data]
|
|
);
|
|
|
|
const projectOptions = useMemo(() => {
|
|
const nonMemberProjects: Project[] = [];
|
|
const memberProjects: Project[] = [];
|
|
projects
|
|
.filter(
|
|
project =>
|
|
metricsQuery.projects.length === 0 ||
|
|
metricsQuery.projects.includes(parseInt(project.id, 10))
|
|
)
|
|
.forEach(project =>
|
|
project.isMember ? memberProjects.push(project) : nonMemberProjects.push(project)
|
|
);
|
|
|
|
return [
|
|
{
|
|
label: t('My Projects'),
|
|
options: memberProjects.map(p => ({
|
|
value: p.id,
|
|
label: p.slug,
|
|
leadingItems: <ProjectBadge project={p} avatarSize={16} hideName disableLink />,
|
|
})),
|
|
},
|
|
{
|
|
label: t('All Projects'),
|
|
options: nonMemberProjects.map(p => ({
|
|
value: p.id,
|
|
label: p.slug,
|
|
leadingItems: <ProjectBadge project={p} avatarSize={16} hideName disableLink />,
|
|
})),
|
|
},
|
|
];
|
|
}, [metricsQuery.projects, projects]);
|
|
|
|
const environmentOptions = useMemo(
|
|
() => [
|
|
{
|
|
value: null,
|
|
label: t('All Environments'),
|
|
},
|
|
...(selectedProject?.environments.map(env => ({
|
|
value: env,
|
|
label: env,
|
|
})) ?? []),
|
|
],
|
|
[selectedProject?.environments]
|
|
);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
router.push(
|
|
`/organizations/${organization.slug}/alerts/new/metric/?${qs.stringify({
|
|
aggregate,
|
|
query: `${metricsQuery.query} event.type:transaction`.trim(),
|
|
createFromDiscover: true,
|
|
dataset: Dataset.GENERIC_METRICS,
|
|
interval: alertInterval,
|
|
statsPeriod: alertPeriod,
|
|
environment: formState.environment ?? undefined,
|
|
project: selectedProject!.slug,
|
|
referrer: 'ddm',
|
|
// Event type also needs to be added to the query
|
|
eventTypes: EventTypes.TRANSACTION,
|
|
})}`
|
|
);
|
|
}, [
|
|
router,
|
|
aggregate,
|
|
metricsQuery.query,
|
|
organization.slug,
|
|
alertInterval,
|
|
alertPeriod,
|
|
formState.environment,
|
|
selectedProject,
|
|
]);
|
|
|
|
const unit = parseMRI(metricsQuery.mri)?.unit ?? 'none';
|
|
const operation = metricsQuery.op;
|
|
const chartOptions = useMemo(() => {
|
|
const bucketSize =
|
|
(chartSeries?.[0]?.data[1]?.name ?? 0) - (chartSeries?.[0]?.data[0]?.name ?? 0);
|
|
|
|
const formatters = {
|
|
valueFormatter: value => formatMetricUsingFixedUnit(value, unit, operation),
|
|
isGroupedByDate: true,
|
|
bucketSize,
|
|
showTimeInTooltip: true,
|
|
};
|
|
|
|
return {
|
|
isGroupedByDate: true,
|
|
height: 200,
|
|
grid: {top: 20, bottom: 20, left: 15, right: 25},
|
|
tooltip: {
|
|
formatter: getFormatter(formatters),
|
|
},
|
|
yAxis: {
|
|
axisLabel: {
|
|
formatter: value => formatMetricUsingFixedUnit(value, unit, operation),
|
|
},
|
|
},
|
|
};
|
|
}, [chartSeries, operation, unit]);
|
|
|
|
return (
|
|
<Fragment>
|
|
<Header closeButton>
|
|
<h4>{t('Create Alert')}</h4>
|
|
</Header>
|
|
<Body>
|
|
<ContentWrapper>
|
|
<SelectControl
|
|
placeholder={t('Select a project')}
|
|
options={projectOptions}
|
|
value={formState.project}
|
|
onChange={({value}) =>
|
|
setFormState(prev => ({
|
|
project: value,
|
|
environment: projects
|
|
.find(p => p.id === value)
|
|
?.environments.includes(prev.environment ?? '')
|
|
? prev.environment
|
|
: null,
|
|
}))
|
|
}
|
|
/>
|
|
<SelectControl
|
|
placeholder={t('Select an environment')}
|
|
options={environmentOptions}
|
|
disabled={!selectedProject}
|
|
value={formState.environment}
|
|
onChange={({value}) => setFormState(prev => ({...prev, environment: value}))}
|
|
/>
|
|
<div>
|
|
{t(
|
|
'Grouped series are not supported by alerts. This is a preview of the data the alert will use.'
|
|
)}
|
|
</div>
|
|
|
|
<ChartPanel isLoading={isLoading}>
|
|
<PanelBody withPadding>
|
|
<ChartHeader>
|
|
<HeaderTitleLegend>
|
|
{AlertWizardAlertNames.custom_metrics}
|
|
</HeaderTitleLegend>
|
|
</ChartHeader>
|
|
<ChartFilters>
|
|
<StyledCircleIndicator size={8} />
|
|
<Tooltip
|
|
title={
|
|
<Fragment>
|
|
<Filters>{formatMRIField(aggregate)}</Filters>
|
|
{metricsQuery.query}
|
|
</Fragment>
|
|
}
|
|
isHoverable
|
|
skipWrapper
|
|
overlayStyle={{
|
|
maxWidth: '90vw',
|
|
lineBreak: 'anywhere',
|
|
textAlign: 'left',
|
|
}}
|
|
showOnlyOnOverflow
|
|
>
|
|
<QueryFilters>
|
|
<Filters>{formatMRIField(aggregate)}</Filters>
|
|
{metricsQuery.query}
|
|
</QueryFilters>
|
|
</Tooltip>
|
|
</ChartFilters>
|
|
</PanelBody>
|
|
{isLoading && <StyledLoadingIndicator />}
|
|
{isError && <LoadingError onRetry={refetch} />}
|
|
{chartSeries && <AreaChart series={chartSeries} {...chartOptions} />}
|
|
</ChartPanel>
|
|
</ContentWrapper>
|
|
</Body>
|
|
<Footer>
|
|
<Tooltip disabled={isFormValid} title={t('Please select a project')}>
|
|
<Button priority="primary" disabled={!isFormValid} onClick={handleSubmit}>
|
|
{t('Continue')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Footer>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
const ContentWrapper = styled('div')`
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: ${space(2)};
|
|
`;
|
|
|
|
const ChartPanel = styled(Panel)<{isLoading: boolean}>`
|
|
${p => p.isLoading && `opacity: 0.6;`}
|
|
`;
|
|
|
|
const ChartHeader = styled('div')`
|
|
margin-bottom: ${space(3)};
|
|
`;
|
|
|
|
const StyledCircleIndicator = styled(CircleIndicator)`
|
|
background: ${p => p.theme.formText};
|
|
height: ${space(1)};
|
|
margin-right: ${space(0.5)};
|
|
`;
|
|
|
|
const ChartFilters = styled('div')`
|
|
font-size: ${p => p.theme.fontSizeSmall};
|
|
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif{p => p.theme.text.family};
|
|
color: ${p => p.theme.textColor};
|
|
display: inline-grid;
|
|
grid-template-columns: max-content auto;
|
|
align-items: center;
|
|
`;
|
|
|
|
const Filters = styled('span')`
|
|
margin-right: ${space(1)};
|
|
`;
|
|
|
|
const QueryFilters = styled('span')`
|
|
min-width: 0px;
|
|
${p => p.theme.overflowEllipsis}
|
|
`;
|
|
|
|
// Totals to a height of 200px -> the height of the chart
|
|
const StyledLoadingIndicator = styled(LoadingIndicator)`
|
|
height: 64px;
|
|
margin-top: 58px;
|
|
margin-bottom: 78px;
|
|
`;
|