mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: UI to edit cortex/loki namespace & group names, group eval interval (#38543)
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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' })] })],
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface CombinedRule {
|
||||
|
||||
export interface CombinedRuleGroup {
|
||||
name: string;
|
||||
interval?: string;
|
||||
rules: CombinedRule[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user