123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783 |
- import * as React from 'react';
- import {RouteComponentProps} from 'react-router';
- import {PlainRoute} from 'react-router/lib/Route';
- import styled from '@emotion/styled';
- import {
- addErrorMessage,
- addSuccessMessage,
- clearIndicators,
- Indicator,
- } from 'app/actionCreators/indicator';
- import {fetchOrganizationTags} from 'app/actionCreators/tags';
- import Access from 'app/components/acl/access';
- import Feature from 'app/components/acl/feature';
- import AsyncComponent from 'app/components/asyncComponent';
- import Button from 'app/components/button';
- import Confirm from 'app/components/confirm';
- import List from 'app/components/list';
- import ListItem from 'app/components/list/listItem';
- import {t} from 'app/locale';
- import IndicatorStore from 'app/stores/indicatorStore';
- import space from 'app/styles/space';
- import {Organization, Project} from 'app/types';
- import {defined} from 'app/utils';
- import {metric, trackAnalyticsEvent} from 'app/utils/analytics';
- import {AlertWizardAlertNames} from 'app/views/alerts/wizard/options';
- import {getAlertTypeFromAggregateDataset} from 'app/views/alerts/wizard/utils';
- import Form from 'app/views/settings/components/forms/form';
- import FormModel from 'app/views/settings/components/forms/model';
- import RuleNameOwnerForm from 'app/views/settings/incidentRules/ruleNameOwnerForm';
- import Triggers from 'app/views/settings/incidentRules/triggers';
- import TriggersChart from 'app/views/settings/incidentRules/triggers/chart';
- import {getEventTypeFilter} from 'app/views/settings/incidentRules/utils/getEventTypeFilter';
- import hasThresholdValue from 'app/views/settings/incidentRules/utils/hasThresholdValue';
- import {addOrUpdateRule} from '../actions';
- import {createDefaultTrigger} from '../constants';
- import RuleConditionsForm from '../ruleConditionsForm';
- import RuleConditionsFormForWizard from '../ruleConditionsFormForWizard';
- import {
- AlertRuleThresholdType,
- Dataset,
- EventTypes,
- IncidentRule,
- MetricActionTemplate,
- Trigger,
- UnsavedIncidentRule,
- } from '../types';
- const POLLING_MAX_TIME_LIMIT = 3 * 60000;
- type RuleTaskResponse = {
- status: 'pending' | 'failed' | 'success';
- alertRule?: IncidentRule;
- error?: string;
- };
- type Props = {
- organization: Organization;
- project: Project;
- routes: PlainRoute[];
- rule: IncidentRule;
- userTeamIds: Set<string>;
- ruleId?: string;
- sessionId?: string;
- isCustomMetric?: boolean;
- } & RouteComponentProps<{orgId: string; projectId: string; ruleId?: string}, {}> & {
- onSubmitSuccess?: Form['props']['onSubmitSuccess'];
- } & AsyncComponent['props'];
- type State = {
- triggers: Trigger[];
- resolveThreshold: UnsavedIncidentRule['resolveThreshold'];
- thresholdType: UnsavedIncidentRule['thresholdType'];
- projects: Project[];
- triggerErrors: Map<number, {[fieldName: string]: string}>;
- // `null` means loading
- availableActions: MetricActionTemplate[] | null;
- // Rule conditions form inputs
- // Needed for TriggersChart
- dataset: Dataset;
- query: string;
- aggregate: string;
- timeWindow: number;
- environment: string | null;
- uuid?: string;
- eventTypes?: EventTypes[];
- } & AsyncComponent['state'];
- const isEmpty = (str: unknown): boolean => str === '' || !defined(str);
- class RuleFormContainer extends AsyncComponent<Props, State> {
- componentDidMount() {
- const {organization, project} = this.props;
- // SearchBar gets its tags from Reflux.
- fetchOrganizationTags(this.api, organization.slug, [project.id]);
- }
- getDefaultState(): State {
- const {rule} = this.props;
- const triggersClone = [...rule.triggers];
- // Warning trigger is removed if it is blank when saving
- if (triggersClone.length !== 2) {
- triggersClone.push(createDefaultTrigger('warning'));
- }
- return {
- ...super.getDefaultState(),
- dataset: rule.dataset,
- eventTypes: rule.eventTypes,
- aggregate: rule.aggregate,
- query: rule.query || '',
- timeWindow: rule.timeWindow,
- environment: rule.environment || null,
- triggerErrors: new Map(),
- availableActions: null,
- triggers: triggersClone,
- resolveThreshold: rule.resolveThreshold,
- thresholdType: rule.thresholdType,
- projects: [this.props.project],
- owner: rule.owner,
- };
- }
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
- const {orgId} = this.props.params;
- // TODO(incidents): This is temporary until new API endpoints
- // We should be able to just fetch the rule if rule.id exists
- return [
- ['availableActions', `/organizations/${orgId}/alert-rules/available-actions/`],
- ];
- }
- goBack() {
- const {router} = this.props;
- const {orgId} = this.props.params;
- router.push(`/organizations/${orgId}/alerts/rules/`);
- }
- resetPollingState = (loadingSlackIndicator: Indicator) => {
- IndicatorStore.remove(loadingSlackIndicator);
- this.setState({loading: false, uuid: undefined});
- };
- fetchStatus(model: FormModel) {
- const loadingSlackIndicator = IndicatorStore.addMessage(
- t('Looking for your slack channel (this can take a while)'),
- 'loading'
- );
- // pollHandler calls itself until it gets either a success
- // or failed status but we don't want to poll forever so we pass
- // in a hard stop time of 3 minutes before we bail.
- const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
- setTimeout(() => {
- this.pollHandler(model, quitTime, loadingSlackIndicator);
- }, 1000);
- }
- pollHandler = async (
- model: FormModel,
- quitTime: number,
- loadingSlackIndicator: Indicator
- ) => {
- if (Date.now() > quitTime) {
- addErrorMessage(t('Looking for that channel took too long :('));
- this.resetPollingState(loadingSlackIndicator);
- return;
- }
- const {
- organization,
- project,
- onSubmitSuccess,
- params: {ruleId},
- } = this.props;
- const {uuid} = this.state;
- try {
- const response: RuleTaskResponse = await this.api.requestPromise(
- `/projects/${organization.slug}/${project.slug}/alert-rule-task/${uuid}/`
- );
- const {status, alertRule, error} = response;
- if (status === 'pending') {
- setTimeout(() => {
- this.pollHandler(model, quitTime, loadingSlackIndicator);
- }, 1000);
- return;
- }
- this.resetPollingState(loadingSlackIndicator);
- if (status === 'failed') {
- this.handleRuleSaveFailure(error);
- }
- if (alertRule) {
- addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
- if (onSubmitSuccess) {
- onSubmitSuccess(alertRule, model);
- }
- }
- } catch {
- this.handleRuleSaveFailure(t('An error occurred'));
- this.resetPollingState(loadingSlackIndicator);
- }
- };
- /**
- * Checks to see if threshold is valid given target value, and state of
- * inverted threshold as well as the *other* threshold
- *
- * @param type The threshold type to be updated
- * @param value The new threshold value
- */
- isValidTrigger = (
- triggerIndex: number,
- trigger: Trigger,
- errors,
- resolveThreshold: number | '' | null
- ): boolean => {
- const {alertThreshold} = trigger;
- const {thresholdType} = this.state;
- // If value and/or other value is empty
- // then there are no checks to perform against
- if (!hasThresholdValue(alertThreshold) || !hasThresholdValue(resolveThreshold)) {
- return true;
- }
- // If this is alert threshold and not inverted, it can't be below resolve
- // If this is alert threshold and inverted, it can't be above resolve
- // If this is resolve threshold and not inverted, it can't be above resolve
- // If this is resolve threshold and inverted, it can't be below resolve
- // Since we're comparing non-inclusive thresholds here (>, <), we need
- // to modify the values when we compare. An example of why:
- // Alert > 0, resolve < 1. This means that we want to alert on values
- // of 1 or more, and resolve on values of 0 or less. This is valid, but
- // without modifying the values, this boundary case will fail.
- const isValid =
- thresholdType === AlertRuleThresholdType.BELOW
- ? alertThreshold - 1 <= resolveThreshold + 1
- : alertThreshold + 1 >= resolveThreshold - 1;
- const otherErrors = errors.get(triggerIndex) || {};
- if (isValid) {
- return true;
- }
- // Not valid... let's figure out an error message
- const isBelow = thresholdType === AlertRuleThresholdType.BELOW;
- let errorMessage = '';
- if (typeof resolveThreshold !== 'number') {
- errorMessage = isBelow
- ? t('Resolution threshold must be greater than alert')
- : t('Resolution threshold must be less than alert');
- } else {
- errorMessage = isBelow
- ? t('Alert threshold must be less than resolution')
- : t('Alert threshold must be greater than resolution');
- }
- errors.set(triggerIndex, {
- ...otherErrors,
- alertThreshold: errorMessage,
- });
- return false;
- };
- validateFieldInTrigger({errors, triggerIndex, field, message, isValid}) {
- // If valid, reset error for fieldName
- if (isValid()) {
- const {[field]: _validatedField, ...otherErrors} = errors.get(triggerIndex) || {};
- if (Object.keys(otherErrors).length > 0) {
- errors.set(triggerIndex, otherErrors);
- } else {
- errors.delete(triggerIndex);
- }
- return errors;
- }
- if (!errors.has(triggerIndex)) {
- errors.set(triggerIndex, {});
- }
- const currentErrors = errors.get(triggerIndex);
- errors.set(triggerIndex, {
- ...currentErrors,
- [field]: message,
- });
- return errors;
- }
- /**
- * Validate triggers
- *
- * @return Returns true if triggers are valid
- */
- validateTriggers(
- triggers = this.state.triggers,
- thresholdType = this.state.thresholdType,
- resolveThreshold = this.state.resolveThreshold,
- changedTriggerIndex?: number
- ) {
- const triggerErrors = new Map();
- const requiredFields = ['label', 'alertThreshold'];
- triggers.forEach((trigger, triggerIndex) => {
- requiredFields.forEach(field => {
- // check required fields
- this.validateFieldInTrigger({
- errors: triggerErrors,
- triggerIndex,
- isValid: (): boolean => {
- if (trigger.label === 'critical') {
- return !isEmpty(trigger[field]);
- }
- // If warning trigger has actions, it must have a value
- return trigger.actions.length === 0 || !isEmpty(trigger[field]);
- },
- field,
- message: t('Field is required'),
- });
- });
- // Check thresholds
- this.isValidTrigger(
- changedTriggerIndex ?? triggerIndex,
- trigger,
- triggerErrors,
- resolveThreshold
- );
- });
- // If we have 2 triggers, we need to make sure that the critical and warning
- // alert thresholds are valid (e.g. if critical is above x, warning must be less than x)
- const criticalTriggerIndex = triggers.findIndex(({label}) => label === 'critical');
- const warningTriggerIndex = criticalTriggerIndex ^ 1;
- const criticalTrigger = triggers[criticalTriggerIndex];
- const warningTrigger = triggers[warningTriggerIndex];
- const isEmptyWarningThreshold = isEmpty(warningTrigger.alertThreshold);
- const warningThreshold = warningTrigger.alertThreshold ?? 0;
- const criticalThreshold = criticalTrigger.alertThreshold ?? 0;
- const hasError =
- thresholdType === AlertRuleThresholdType.ABOVE
- ? warningThreshold > criticalThreshold
- : warningThreshold < criticalThreshold;
- if (hasError && !isEmptyWarningThreshold) {
- [criticalTriggerIndex, warningTriggerIndex].forEach(index => {
- const otherErrors = triggerErrors.get(index) ?? {};
- triggerErrors.set(index, {
- ...otherErrors,
- alertThreshold:
- thresholdType === AlertRuleThresholdType.BELOW
- ? t('Warning threshold must be greater than critical alert')
- : t('Warning threshold must be less than critical alert'),
- });
- });
- }
- return triggerErrors;
- }
- handleFieldChange = (name: string, value: unknown) => {
- if (
- ['dataset', 'eventTypes', 'timeWindow', 'environment', 'aggregate'].includes(name)
- ) {
- this.setState({[name]: value});
- }
- };
- // We handle the filter update outside of the fieldChange handler since we
- // don't want to update the filter on every input change, just on blurs and
- // searches.
- handleFilterUpdate = (query: string) => {
- const {organization, sessionId} = this.props;
- trackAnalyticsEvent({
- eventKey: 'alert_builder.filter',
- eventName: 'Alert Builder: Filter',
- query,
- organization_id: organization.id,
- session_id: sessionId,
- });
- this.setState({query});
- };
- handleSubmit = async (
- _data: Partial<IncidentRule>,
- _onSubmitSuccess,
- _onSubmitError,
- _e,
- model: FormModel
- ) => {
- // This validates all fields *except* for Triggers
- const validRule = model.validateForm();
- // Validate Triggers
- const triggerErrors = this.validateTriggers();
- const validTriggers = Array.from(triggerErrors).length === 0;
- if (!validTriggers) {
- this.setState(state => ({
- triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
- }));
- }
- if (!validRule || !validTriggers) {
- addErrorMessage(t('Alert not valid'));
- return;
- }
- const {organization, params, rule, onSubmitSuccess, location, sessionId} = this.props;
- const {ruleId} = this.props.params;
- const {resolveThreshold, triggers, thresholdType, uuid} = this.state;
- // Remove empty warning trigger
- const sanitizedTriggers = triggers.filter(
- trigger => trigger.label !== 'warning' || !isEmpty(trigger.alertThreshold)
- );
- // form model has all form state data, however we use local state to keep
- // track of the list of triggers (and actions within triggers)
- const loadingIndicator = IndicatorStore.addMessage(
- t('Saving your alert rule, hold on...'),
- 'loading'
- );
- try {
- const transaction = metric.startTransaction({name: 'saveAlertRule'});
- transaction.setTag('type', 'metric');
- transaction.setTag('operation', !rule.id ? 'create' : 'edit');
- for (const trigger of sanitizedTriggers) {
- for (const action of trigger.actions) {
- if (action.type === 'slack') {
- transaction.setTag(action.type, true);
- if (action.integrationId) {
- transaction.setTag(`integrationId:${action.integrationId}`, true);
- }
- }
- }
- }
- transaction.setData('actions', sanitizedTriggers);
- this.setState({loading: true});
- const [resp, , xhr] = await addOrUpdateRule(
- this.api,
- organization.slug,
- params.projectId,
- {
- ...rule,
- ...model.getTransformedData(),
- triggers: sanitizedTriggers,
- resolveThreshold: isEmpty(resolveThreshold) ? null : resolveThreshold,
- thresholdType,
- },
- {
- referrer: location?.query?.referrer,
- sessionId,
- }
- );
- // if we get a 202 back it means that we have an async task
- // running to lookup and verify the channel id for Slack.
- if (xhr && xhr.status === 202) {
- // if we have a uuid in state, no need to start a new polling cycle
- if (!uuid) {
- this.setState({loading: true, uuid: resp.uuid});
- this.fetchStatus(model);
- }
- } else {
- IndicatorStore.remove(loadingIndicator);
- this.setState({loading: false});
- addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
- if (onSubmitSuccess) {
- onSubmitSuccess(resp, model);
- }
- }
- } catch (err) {
- IndicatorStore.remove(loadingIndicator);
- this.setState({loading: false});
- const errors = err?.responseJSON
- ? Array.isArray(err?.responseJSON)
- ? err?.responseJSON
- : Object.values(err?.responseJSON)
- : [];
- const apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
- this.handleRuleSaveFailure(t('Unable to save alert%s', apiErrors));
- }
- };
- /**
- * Callback for when triggers change
- *
- * Re-validate triggers on every change and reset indicators when no errors
- */
- handleChangeTriggers = (triggers: Trigger[], triggerIndex?: number) => {
- this.setState(state => {
- let triggerErrors = state.triggerErrors;
- const newTriggerErrors = this.validateTriggers(
- triggers,
- state.thresholdType,
- state.resolveThreshold,
- triggerIndex
- );
- triggerErrors = newTriggerErrors;
- if (Array.from(newTriggerErrors).length === 0) {
- clearIndicators();
- }
- return {triggers, triggerErrors};
- });
- };
- handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
- const {triggers} = this.state;
- const triggerErrors = this.validateTriggers(triggers, thresholdType);
- this.setState(state => ({
- thresholdType,
- triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
- }));
- };
- handleResolveThresholdChange = (
- resolveThreshold: UnsavedIncidentRule['resolveThreshold']
- ) => {
- const {triggers} = this.state;
- const triggerErrors = this.validateTriggers(triggers, undefined, resolveThreshold);
- this.setState(state => ({
- resolveThreshold,
- triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
- }));
- };
- handleDeleteRule = async () => {
- const {params} = this.props;
- const {orgId, projectId, ruleId} = params;
- try {
- await this.api.requestPromise(
- `/projects/${orgId}/${projectId}/alert-rules/${ruleId}/`,
- {
- method: 'DELETE',
- }
- );
- this.goBack();
- } catch (_err) {
- addErrorMessage(t('Error deleting rule'));
- }
- };
- handleRuleSaveFailure = (msg: React.ReactNode) => {
- addErrorMessage(msg);
- metric.endTransaction({name: 'saveAlertRule'});
- };
- handleCancel = () => {
- this.goBack();
- };
- renderLoading() {
- return this.renderBody();
- }
- renderBody() {
- const {
- organization,
- ruleId,
- rule,
- params,
- onSubmitSuccess,
- project,
- userTeamIds,
- isCustomMetric,
- } = this.props;
- const {
- query,
- timeWindow,
- triggers,
- aggregate,
- environment,
- thresholdType,
- resolveThreshold,
- loading,
- eventTypes,
- dataset,
- } = this.state;
- const eventTypeFilter = getEventTypeFilter(this.state.dataset, eventTypes);
- const queryWithTypeFilter = `${query} ${eventTypeFilter}`.trim();
- const chartProps = {
- organization,
- projects: this.state.projects,
- triggers,
- query: queryWithTypeFilter,
- aggregate,
- timeWindow,
- environment,
- resolveThreshold,
- thresholdType,
- };
- const alertType = getAlertTypeFromAggregateDataset({aggregate, dataset});
- const wizardBuilderChart = (
- <TriggersChart
- {...chartProps}
- header={
- <ChartHeader>
- <AlertName>{AlertWizardAlertNames[alertType]}</AlertName>
- <AlertInfo>
- {aggregate} | event.type:{eventTypes?.join(',')}
- </AlertInfo>
- </ChartHeader>
- }
- />
- );
- const chart = <TriggersChart {...chartProps} />;
- const ownerId = rule.owner?.split(':')[1];
- const canEdit = ownerId ? userTeamIds.has(ownerId) : true;
- const triggerForm = (hasAccess: boolean) => (
- <Triggers
- disabled={!hasAccess || !canEdit}
- projects={this.state.projects}
- errors={this.state.triggerErrors}
- triggers={triggers}
- aggregate={aggregate}
- resolveThreshold={resolveThreshold}
- thresholdType={thresholdType}
- currentProject={params.projectId}
- organization={organization}
- ruleId={ruleId}
- availableActions={this.state.availableActions}
- onChange={this.handleChangeTriggers}
- onThresholdTypeChange={this.handleThresholdTypeChange}
- onResolveThresholdChange={this.handleResolveThresholdChange}
- />
- );
- const ruleNameOwnerForm = (hasAccess: boolean) => (
- <RuleNameOwnerForm
- disabled={!hasAccess || !canEdit}
- organization={organization}
- project={project}
- userTeamIds={userTeamIds}
- />
- );
- return (
- <Access access={['alerts:write']}>
- {({hasAccess}) => (
- <Form
- apiMethod={ruleId ? 'PUT' : 'POST'}
- apiEndpoint={`/organizations/${organization.slug}/alert-rules/${
- ruleId ? `${ruleId}/` : ''
- }`}
- submitDisabled={!hasAccess || loading || !canEdit}
- initialData={{
- name: rule.name || '',
- dataset: rule.dataset,
- eventTypes: rule.eventTypes,
- aggregate: rule.aggregate,
- query: rule.query || '',
- timeWindow: rule.timeWindow,
- environment: rule.environment || null,
- owner: rule.owner,
- }}
- saveOnBlur={false}
- onSubmit={this.handleSubmit}
- onSubmitSuccess={onSubmitSuccess}
- onCancel={this.handleCancel}
- onFieldChange={this.handleFieldChange}
- extraButton={
- !!rule.id ? (
- <Confirm
- disabled={!hasAccess || !canEdit}
- message={t('Are you sure you want to delete this alert rule?')}
- header={t('Delete Alert Rule?')}
- priority="danger"
- confirmText={t('Delete Rule')}
- onConfirm={this.handleDeleteRule}
- >
- <Button type="button" priority="danger">
- {t('Delete Rule')}
- </Button>
- </Confirm>
- ) : null
- }
- submitLabel={t('Save Rule')}
- >
- <Feature organization={organization} features={['alert-wizard']}>
- {({hasFeature}) =>
- hasFeature ? (
- <List symbol="colored-numeric">
- <RuleConditionsFormForWizard
- api={this.api}
- projectSlug={params.projectId}
- organization={organization}
- disabled={!hasAccess || !canEdit}
- thresholdChart={wizardBuilderChart}
- onFilterSearch={this.handleFilterUpdate}
- allowChangeEventTypes={dataset === Dataset.ERRORS}
- alertType={isCustomMetric ? 'custom' : alertType}
- />
- <AlertListItem>{t('Set thresholds to trigger alert')}</AlertListItem>
- {triggerForm(hasAccess)}
- <StyledListItem>{t('Add a rule name and team')}</StyledListItem>
- {ruleNameOwnerForm(hasAccess)}
- </List>
- ) : (
- <React.Fragment>
- <RuleConditionsForm
- api={this.api}
- projectSlug={params.projectId}
- organization={organization}
- disabled={!hasAccess || !canEdit}
- thresholdChart={chart}
- onFilterSearch={this.handleFilterUpdate}
- />
- <List symbol="colored-numeric" initialCounterValue={2}>
- {triggerForm(hasAccess)}
- {ruleNameOwnerForm(hasAccess)}
- </List>
- </React.Fragment>
- )
- }
- </Feature>
- </Form>
- )}
- </Access>
- );
- }
- }
- const StyledListItem = styled(ListItem)`
- margin: ${space(2)} 0 ${space(1)} 0;
- font-size: ${p => p.theme.fontSizeExtraLarge};
- `;
- const AlertListItem = styled(StyledListItem)`
- margin-top: 0;
- `;
- const ChartHeader = styled('div')`
- padding: ${space(3)} ${space(3)} 0 ${space(3)};
- `;
- const AlertName = styled('div')`
- font-size: ${p => p.theme.fontSizeExtraLarge};
- font-weight: normal;
- color: ${p => p.theme.textColor};
- `;
- const AlertInfo = styled('div')`
- font-size: ${p => p.theme.fontSizeMedium};
- font-family: ${p => p.theme.text.familyMono};
- font-weight: normal;
- color: ${p => p.theme.subText};
- `;
- export {RuleFormContainer};
- export default RuleFormContainer;
|