Alerting: UI to edit cortex/loki namespace & group names, group eval interval (#38543)

This commit is contained in:
Domas
2021-08-26 16:40:27 +03:00
committed by GitHub
parent 00886afaf1
commit 2847f25781
9 changed files with 445 additions and 23 deletions

View File

@@ -3,10 +3,11 @@ import { render, waitFor } from '@testing-library/react';
import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux';
import { RuleList } from './RuleList';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { getAllDataSources } from './utils/config';
import { fetchRules } from './api/prometheus';
import { fetchRulerRules, deleteRulerRulesGroup, deleteNamespace, setRulerRuleGroup } from './api/ruler';
import {
mockDataSource,
mockPromAlert,
@@ -15,6 +16,8 @@ import {
mockPromRuleGroup,
mockPromRuleNamespace,
MockDataSourceSrv,
somePromRules,
someRulerRules,
} from './mocks';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { SerializedError } from '@reduxjs/toolkit';
@@ -24,18 +27,32 @@ import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { Router } from 'react-router-dom';
jest.mock('./api/prometheus');
jest.mock('./api/ruler');
jest.mock('./utils/config');
jest.mock('app/core/core', () => ({
appEvents: {
subscribe: () => {
return { unsubscribe: () => {} };
},
emit: () => {},
},
}));
const mocks = {
getAllDataSourcesMock: typeAsJestMock(getAllDataSources),
api: {
fetchRules: typeAsJestMock(fetchRules),
fetchRulerRules: typeAsJestMock(fetchRulerRules),
deleteGroup: typeAsJestMock(deleteRulerRulesGroup),
deleteNamespace: typeAsJestMock(deleteNamespace),
setRulerRuleGroup: typeAsJestMock(setRulerRuleGroup),
},
};
const renderRuleList = () => {
const store = configureStore();
locationService.push('/');
return render(
<Provider store={store}>
@@ -78,6 +95,14 @@ const ui = {
expandedContent: byTestId('expanded-content'),
rulesFilterInput: byTestId('search-query-input'),
moreErrorsButton: byRole('button', { name: /more errors/ }),
editCloudGroupIcon: byTestId('edit-group'),
editGroupModal: {
namespaceInput: byLabelText('Namespace'),
ruleGroupInput: byLabelText('Rule group'),
intervalInput: byLabelText('Rule group evaluation interval'),
saveButton: byRole('button', { name: /Save changes/ }),
},
};
describe('RuleList', () => {
@@ -309,7 +334,7 @@ describe('RuleList', () => {
it('filters rules and alerts by labels', async () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.fetchRulerRules.mockResolvedValue({});
mocks.api.fetchRules.mockImplementation((dataSourceName: string) => {
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
return Promise.resolve([]);
@@ -406,13 +431,12 @@ describe('RuleList', () => {
expect(groups).toHaveLength(2);
const filterInput = ui.rulesFilterInput.get();
userEvent.type(filterInput, '{foo="bar"}');
await userEvent.type(filterInput, '{foo="bar"}');
// Input is debounced so wait for it to be visible
waitFor(() => expect(filterInput).toHaveTextContent('{foo="bar"}'));
await waitFor(() => expect(filterInput).toHaveValue('{foo="bar"}'));
// Group doesn't contain matching labels
waitFor(() => expect(groups[1]).not.toBeVisible());
expect(groups[0]).toBeVisible();
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
userEvent.click(ui.groupCollapseToggle.get(groups[0]));
@@ -425,20 +449,126 @@ describe('RuleList', () => {
expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar');
// Check for different label matchers
userEvent.type(filterInput, '{foo!="bar"}');
waitFor(() => expect(filterInput).toHaveTextContent('{foo!="bar"}'));
await userEvent.type(filterInput, '{selectall}{del}{foo!="bar",foo!="baz"}');
// Group doesn't contain matching labels
waitFor(() => expect(groups[0]).not.toBeVisible());
expect(groups[1]).toBeVisible();
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
userEvent.type(filterInput, '{foo=~"b.+"}');
waitFor(() => expect(filterInput).toHaveTextContent('{foo=~"b.+"}'));
expect(groups[0]).toBeVisible();
expect(groups[1]).toBeVisible();
await userEvent.type(filterInput, '{selectall}{del}{foo=~"b.+"}');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2));
userEvent.type(filterInput, '{region="US"}');
waitFor(() => expect(filterInput).toHaveTextContent('{region="US"}'));
waitFor(() => expect(groups[0]).not.toBeVisible());
expect(groups[1]).toBeVisible();
await userEvent.type(filterInput, '{selectall}{del}{region="US"}');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
});
describe('edit lotex groups, namespaces', () => {
const testDatasources = {
prom: dataSources.prom,
};
function testCase(name: string, fn: () => Promise<void>) {
it(name, async () => {
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources));
setDataSourceSrv(new MockDataSourceSrv(testDatasources));
mocks.api.fetchRules.mockImplementation((sourceName) =>
Promise.resolve(sourceName === testDatasources.prom.name ? somePromRules() : [])
);
mocks.api.fetchRulerRules.mockImplementation((sourceName) =>
Promise.resolve(sourceName === testDatasources.prom.name ? someRulerRules : {})
);
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.deleteNamespace.mockResolvedValue();
await renderRuleList();
expect(await ui.rulesFilterInput.find()).toHaveValue('');
const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(3);
// open edit dialog
userEvent.click(ui.editCloudGroupIcon.get(groups[0]));
expect(ui.editGroupModal.namespaceInput.get()).toHaveValue('namespace1');
expect(ui.editGroupModal.ruleGroupInput.get()).toHaveValue('group1');
await fn();
});
}
testCase('rename both lotex namespace and group', async () => {
// make changes to form
userEvent.clear(ui.editGroupModal.namespaceInput.get());
await userEvent.type(ui.editGroupModal.namespaceInput.get(), 'super namespace');
userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made
userEvent.click(ui.editGroupModal.saveButton.get());
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(2);
expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1);
expect(mocks.api.deleteGroup).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'super namespace', {
...someRulerRules['namespace1'][0],
name: 'super group',
interval: '5m',
});
expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
2,
testDatasources.prom.name,
'super namespace',
someRulerRules['namespace1'][1]
);
expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith('Prometheus', 'namespace1');
});
testCase('rename just the lotex group', async () => {
// make changes to form
userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made
userEvent.click(ui.editGroupModal.saveButton.get());
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1);
expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1);
expect(mocks.api.deleteNamespace).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', {
...someRulerRules['namespace1'][0],
name: 'super group',
interval: '5m',
});
expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith('Prometheus', 'namespace1', 'group1');
});
testCase('edit lotex group eval interval, no renaming', async () => {
// make changes to form
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made
userEvent.click(ui.editGroupModal.saveButton.get());
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1);
expect(mocks.api.deleteGroup).not.toHaveBeenCalled();
expect(mocks.api.deleteNamespace).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', {
...someRulerRules['namespace1'][0],
interval: '5m',
});
});
});
});

View File

@@ -53,7 +53,7 @@ export async function fetchRulerRulesGroup(
}
export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) {
return await lastValueFrom(
await lastValueFrom(
getBackendSrv().fetch({
url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(
namespace
@@ -97,3 +97,14 @@ async function rulerGetRequest<T>(url: string, empty: T): Promise<T> {
throw e;
}
}
export async function deleteNamespace(dataSourceName: string, namespace: string): Promise<void> {
await lastValueFrom(
getBackendSrv().fetch<unknown>({
method: 'DELETE',
url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
showErrorAlert: false,
showSuccessAlert: false,
})
);
}

View File

@@ -0,0 +1,123 @@
import React, { useEffect, useMemo } from 'react';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui';
import { durationValidationPattern } from '../../utils/time';
import { css } from '@emotion/css';
import { useDispatch } from 'react-redux';
import { updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { getRulesSourceName } from '../../utils/datasource';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { initialAsyncRequestState } from '../../utils/redux';
import { useCleanup } from 'app/core/hooks/useCleanup';
interface Props {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
onClose: () => void;
}
interface FormValues {
namespaceName: string;
groupName: string;
groupInterval: string;
}
export function EditCloudGroupModal(props: Props): React.ReactElement {
const { namespace, group, onClose } = props;
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const { loading, error, dispatched } =
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
const defaultValues = useMemo(
(): FormValues => ({
namespaceName: namespace.name,
groupName: group.name,
groupInterval: group.interval ?? '',
}),
[namespace, group]
);
// close modal if successfully saved
useEffect(() => {
if (dispatched && !loading && !error) {
onClose();
}
}, [dispatched, loading, onClose, error]);
useCleanup((state) => state.unifiedAlerting.updateLotexNamespaceAndGroup);
const onSubmit = (values: FormValues) => {
dispatch(
updateLotexNamespaceAndGroupAction({
rulesSourceName: getRulesSourceName(namespace.rulesSource),
groupName: group.name,
newGroupName: values.groupName,
namespaceName: namespace.name,
newNamespaceName: values.namespaceName,
groupInterval: values.groupInterval || undefined,
})
);
};
return (
<Modal
className={styles.modal}
isOpen={true}
title="Edit namespace or rule group"
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={JSON.stringify(defaultValues)}>
{({ register, errors, formState: { isDirty } }) => (
<>
<Field label="Namespace" invalid={!!errors.namespaceName} error={errors.namespaceName?.message}>
<Input
id="namespaceName"
{...register('namespaceName', {
required: 'Namespace name is required.',
})}
/>
</Field>
<Field label="Rule group" invalid={!!errors.groupName} error={errors.groupName?.message}>
<Input
id="groupName"
{...register('groupName', {
required: 'Rule group name is required.',
})}
/>
</Field>
<Field
label="Rule group evaluation interval"
invalid={!!errors.groupInterval}
error={errors.groupInterval?.message}
>
<Input
id="groupInterval"
placeholder="1m"
{...register('groupInterval', {
pattern: durationValidationPattern,
})}
/>
</Field>
<Modal.ButtonRow>
<Button variant="secondary" type="button" disabled={loading} onClick={onClose} fill="outline">
Close
</Button>
<Button type="submit" disabled={!isDirty || loading}>
{loading ? 'Saving...' : 'Save changes'}
</Button>
</Modal.ButtonRow>
</>
)}
</Form>
</Modal>
);
}
const getStyles = () => ({
modal: css`
max-width: 560px;
`,
});

View File

@@ -1,6 +1,6 @@
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import React, { FC, useState } from 'react';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { isGrafanaRulerRule } from '../../utils/rules';
@@ -12,6 +12,7 @@ import { useHasRuler } from '../../hooks/useHasRuler';
import kbn from 'app/core/utils/kbn';
import { useFolder } from '../../hooks/useFolder';
import { RuleStats } from './RuleStats';
import { EditCloudGroupModal } from './EditCloudGroupModal';
interface Props {
namespace: CombinedRuleNamespace;
@@ -23,16 +24,27 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isEditingGroup, setIsEditingGroup] = useState(false);
const hasRuler = useHasRuler();
const rulerRule = group.rules[0]?.rulerRule;
const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined;
const { folder } = useFolder(folderUID);
// group "is deleting" if rules source has ruler, but this group has no rules that are in ruler
const isDeleting = hasRuler(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule);
const actionIcons: React.ReactNode[] = [];
// for grafana, link to folder views
if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
if (isDeleting) {
actionIcons.push(
<HorizontalGroup key="is-deleting">
<Spinner />
deleting
</HorizontalGroup>
);
} else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
if (folderUID) {
const baseUrl = `/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
if (folder?.canSave) {
@@ -51,9 +63,17 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
/>
);
}
} else if (hasRuler(rulesSource)) {
actionIcons.push(<ActionIcon key="edit" icon="pen" tooltip="edit" />); // @TODO
}
} else if (hasRuler(rulesSource)) {
actionIcons.push(
<ActionIcon
data-testid="edit-group"
key="edit"
icon="pen"
tooltip="edit"
onClick={() => setIsEditingGroup(true)}
/>
);
}
const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
@@ -88,6 +108,9 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
{!isCollapsed && (
<RulesTable showSummaryColumn={true} className={styles.rulesTable} showGuidelines={true} rules={group.rules} />
)}
{isEditingGroup && (
<EditCloudGroupModal group={group} namespace={namespace} onClose={() => setIsEditingGroup(false)} />
)}
</div>
);
});

View File

@@ -104,6 +104,7 @@ function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, gro
namespace.groups = groups.map((group) => {
const combinedGroup: CombinedRuleGroup = {
name: group.name,
interval: group.interval,
rules: [],
};
combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup));

View File

@@ -4,7 +4,10 @@ import {
GrafanaRuleDefinition,
PromAlertingRuleState,
PromRuleType,
RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
@@ -94,6 +97,23 @@ export const mockRulerGrafanaRule = (
};
};
export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {}): RulerAlertingRuleDTO => ({
alert: 'alert1',
expr: 'up = 1',
labels: {
severity: 'warning',
},
annotations: {
summary: 'test alert',
},
});
export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({
name: 'group1',
rules: [mockRulerAlertingRule()],
...partial,
});
export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): AlertingRule => {
return {
type: PromRuleType.Alerting,
@@ -350,3 +370,26 @@ export const someCloudAlertManagerConfig: AlertManagerCortexConfig = {
],
},
};
export const somePromRules = (dataSourceName = 'Prometheus'): RuleNamespace[] => [
{
dataSourceName,
name: 'namespace1',
groups: [
mockPromRuleGroup({ name: 'group1', rules: [mockPromAlertingRule({ name: 'alert1' })] }),
mockPromRuleGroup({ name: 'group2', rules: [mockPromAlertingRule({ name: 'alert2' })] }),
],
},
{
dataSourceName,
name: 'namespace2',
groups: [mockPromRuleGroup({ name: 'group3', rules: [mockPromAlertingRule({ name: 'alert3' })] })],
},
];
export const someRulerRules: RulerRulesConfigDTO = {
namespace1: [
mockRulerRuleGroup({ name: 'group1', rules: [mockRulerAlertingRule({ alert: 'alert1' })] }),
mockRulerRuleGroup({ name: 'group2', rules: [mockRulerAlertingRule({ alert: 'alert2' })] }),
],
namespace2: [mockRulerRuleGroup({ name: 'group3', rules: [mockRulerAlertingRule({ alert: 'alert3' })] })],
};

View File

@@ -31,6 +31,7 @@ import {
} from '../api/alertmanager';
import { fetchRules } from '../api/prometheus';
import {
deleteNamespace,
deleteRulerRulesGroup,
fetchRulerRules,
fetchRulerRulesGroup,
@@ -620,3 +621,89 @@ export const testReceiversAction = createAsyncThunk(
});
}
);
interface UpdateNamespaceAndGroupOptions {
rulesSourceName: string;
namespaceName: string;
groupName: string;
newNamespaceName: string;
newGroupName: string;
groupInterval?: string;
}
// allows renaming namespace, renaming group and changing group interval, all in one go
export const updateLotexNamespaceAndGroupAction = createAsyncThunk(
'unifiedalerting/updateLotexNamespaceAndGroup',
async (options: UpdateNamespaceAndGroupOptions, thunkAPI): Promise<void> => {
return withAppEvents(
withSerializedError(
(async () => {
const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options;
if (options.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) {
throw new Error(`this action does not support Grafana rules`);
}
// fetch rules and perform sanity checks
const rulesResult = await fetchRulerRules(rulesSourceName);
if (!rulesResult[namespaceName]) {
throw new Error(`Namespace "${namespaceName}" not found.`);
}
const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName);
if (!existingGroup) {
throw new Error(`Group "${groupName}" not found.`);
}
if (newGroupName !== groupName && !!rulesResult[namespaceName].find((group) => group.name === newGroupName)) {
throw new Error(`Group "${newGroupName}" already exists.`);
}
if (newNamespaceName !== namespaceName && !!rulesResult[newNamespaceName]) {
throw new Error(`Namespace "${newNamespaceName}" already exists.`);
}
if (
newNamespaceName === namespaceName &&
groupName === newGroupName &&
groupInterval === existingGroup.interval
) {
throw new Error('Nothing changed.');
}
// if renaming namespace - make new copies of all groups, then delete old namespace
if (newNamespaceName !== namespaceName) {
for (const group of rulesResult[namespaceName]) {
await setRulerRuleGroup(
rulesSourceName,
newNamespaceName,
group.name === groupName
? {
...group,
name: newGroupName,
interval: groupInterval,
}
: group
);
}
await deleteNamespace(rulesSourceName, namespaceName);
// if only modifying group...
} else {
// save updated group
await setRulerRuleGroup(rulesSourceName, namespaceName, {
...existingGroup,
name: newGroupName,
interval: groupInterval,
});
// if group name was changed, delete old group
if (newGroupName !== groupName) {
await deleteRulerRulesGroup(rulesSourceName, namespaceName, groupName);
}
}
// refetch all rules
await thunkAPI.dispatch(fetchRulerRulesAction(rulesSourceName));
})()
),
{
errorMessage: 'Failed to update namespace / group',
successMessage: 'Update successful',
}
);
}
);

View File

@@ -16,6 +16,7 @@ import {
checkIfLotexSupportsEditingRulesAction,
deleteAlertManagerConfigAction,
testReceiversAction,
updateLotexNamespaceAndGroupAction,
} from './actions';
export const reducer = combineReducers({
@@ -50,6 +51,8 @@ export const reducer = combineReducers({
(source) => source
).reducer,
testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer,
updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction)
.reducer,
});
export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@@ -85,6 +85,7 @@ export interface CombinedRule {
export interface CombinedRuleGroup {
name: string;
interval?: string;
rules: CombinedRule[];
}