mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add alert rule cloning action (#59200)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
b3284a8330
commit
d2c129fbac
284
public/app/features/alerting/unified/CloneRuleEditor.test.tsx
Normal file
284
public/app/features/alerting/unified/CloneRuleEditor.test.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
|
import { selectors } from '@grafana/e2e-selectors/src';
|
||||||
|
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import 'whatwg-fetch';
|
||||||
|
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
|
import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { CloneRuleEditor, generateCopiedRuleTitle } from './CloneRuleEditor';
|
||||||
|
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||||
|
import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks';
|
||||||
|
import { mockSearchApiResponse } from './mocks/grafanaApi';
|
||||||
|
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
|
||||||
|
import { RuleFormValues } from './types/rule-form';
|
||||||
|
import { Annotation } from './utils/constants';
|
||||||
|
import { getDefaultFormValues } from './utils/rule-form';
|
||||||
|
import { hashRulerRule } from './utils/rule-id';
|
||||||
|
|
||||||
|
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
|
||||||
|
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
setBackendSrv(backendSrv);
|
||||||
|
server.listen({ onUnhandledRequest: 'error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ui = {
|
||||||
|
inputs: {
|
||||||
|
name: byRole('textbox', { name: /rule name name for the alert rule\./i }),
|
||||||
|
expr: byTestId('expr'),
|
||||||
|
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
|
||||||
|
namespace: byTestId('namespace-picker'),
|
||||||
|
group: byTestId('group-picker'),
|
||||||
|
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
|
||||||
|
labelValue: (idx: number) => byTestId(`label-value-${idx}`),
|
||||||
|
},
|
||||||
|
loadingIndicator: byText('Loading the rule'),
|
||||||
|
loadingGroupIndicator: byText('Loading...'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProvidersWrapper() {
|
||||||
|
return function Wrapper({ children }: React.PropsWithChildren<{}>) {
|
||||||
|
const store = mockStore((store) => {
|
||||||
|
store.unifiedAlerting.dataSources['grafana'] = {
|
||||||
|
loading: false,
|
||||||
|
dispatched: true,
|
||||||
|
result: {
|
||||||
|
id: 'grafana',
|
||||||
|
name: 'grafana',
|
||||||
|
rulerConfig: {
|
||||||
|
dataSourceName: 'grafana',
|
||||||
|
apiVersion: 'legacy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
store.unifiedAlerting.dataSources['my-prom-ds'] = {
|
||||||
|
loading: false,
|
||||||
|
dispatched: true,
|
||||||
|
result: {
|
||||||
|
id: 'my-prom-ds',
|
||||||
|
name: 'my-prom-ds',
|
||||||
|
rulerConfig: {
|
||||||
|
dataSourceName: 'my-prom-ds',
|
||||||
|
apiVersion: 'config',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const formApi = useForm<RuleFormValues>({ defaultValues: getDefaultFormValues() });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemoryRouter>
|
||||||
|
<Provider store={store}>
|
||||||
|
<FormProvider {...formApi}>{children}</FormProvider>
|
||||||
|
</Provider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CloneRuleEditor', function () {
|
||||||
|
describe('Grafana-managed rules', function () {
|
||||||
|
it('should populate form values from the existing alert rule', async function () {
|
||||||
|
setDataSourceSrv(new MockDataSourceSrv({}));
|
||||||
|
|
||||||
|
const originRule: RulerGrafanaRuleDTO = mockRulerGrafanaRule(
|
||||||
|
{
|
||||||
|
for: '1m',
|
||||||
|
labels: { severity: 'critical', region: 'nasa' },
|
||||||
|
annotations: { [Annotation.summary]: 'This is a very important alert rule' },
|
||||||
|
},
|
||||||
|
{ uid: 'grafana-rule-1', title: 'First Grafana Rule', data: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
mockRulerRulesApiResponse(server, 'grafana', {
|
||||||
|
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSearchApiResponse(server, []);
|
||||||
|
|
||||||
|
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, {
|
||||||
|
wrapper: getProvidersWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(ui.loadingIndicator.query());
|
||||||
|
await waitForElementToBeRemoved(ui.loadingGroupIndicator.query(), { container: ui.inputs.group.get() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)');
|
||||||
|
expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one');
|
||||||
|
expect(ui.inputs.group.get()).toHaveTextContent('group1');
|
||||||
|
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
|
||||||
|
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
|
||||||
|
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cloud rules', function () {
|
||||||
|
it('should populate form values from the existing alert rule', async function () {
|
||||||
|
const dsSettings = mockDataSource({
|
||||||
|
name: 'my-prom-ds',
|
||||||
|
uid: 'my-prom-ds',
|
||||||
|
});
|
||||||
|
config.datasources = {
|
||||||
|
'my-prom-ds': dsSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
setDataSourceSrv(new MockDataSourceSrv({ 'my-prom-ds': dsSettings }));
|
||||||
|
|
||||||
|
const originRule = mockRulerAlertingRule({
|
||||||
|
for: '1m',
|
||||||
|
alert: 'First Ruler Rule',
|
||||||
|
expr: 'vector(1) > 0',
|
||||||
|
labels: { severity: 'critical', region: 'nasa' },
|
||||||
|
annotations: { [Annotation.summary]: 'This is a very important alert rule' },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRulerRulesApiResponse(server, 'my-prom-ds', {
|
||||||
|
'namespace-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRulerRulesGroupApiResponse(server, 'my-prom-ds', 'namespace-one', 'group1', {
|
||||||
|
name: 'group1',
|
||||||
|
interval: '20s',
|
||||||
|
rules: [originRule],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSearchApiResponse(server, []);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CloneRuleEditor
|
||||||
|
sourceRuleId={{
|
||||||
|
uid: 'prom-rule-1',
|
||||||
|
ruleSourceName: 'my-prom-ds',
|
||||||
|
namespace: 'namespace-one',
|
||||||
|
groupName: 'group1',
|
||||||
|
rulerRuleHash: hashRulerRule(originRule),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: getProvidersWrapper(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(ui.loadingIndicator.query());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
|
||||||
|
expect(ui.inputs.expr.get()).toHaveValue('vector(1) > 0');
|
||||||
|
expect(ui.inputs.namespace.get()).toHaveTextContent('namespace-one');
|
||||||
|
expect(ui.inputs.group.get()).toHaveTextContent('group1');
|
||||||
|
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
|
||||||
|
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
|
||||||
|
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCopiedRuleTitle', () => {
|
||||||
|
it('should generate copy name', () => {
|
||||||
|
const fileName = 'my file';
|
||||||
|
const expectedDuplicateName = 'my file (copy)';
|
||||||
|
|
||||||
|
const ruleWithLocation = {
|
||||||
|
rule: {
|
||||||
|
grafana_alert: {
|
||||||
|
title: fileName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
rules: [],
|
||||||
|
},
|
||||||
|
} as unknown as RuleWithLocation;
|
||||||
|
|
||||||
|
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate copy name and number from original file', () => {
|
||||||
|
const fileName = 'my file';
|
||||||
|
const duplicatedName = 'my file (copy)';
|
||||||
|
const expectedDuplicateName = 'my file (copy 2)';
|
||||||
|
|
||||||
|
const ruleWithLocation = {
|
||||||
|
rule: {
|
||||||
|
grafana_alert: {
|
||||||
|
title: fileName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
|
||||||
|
},
|
||||||
|
} as RuleWithLocation;
|
||||||
|
|
||||||
|
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate copy name and number from duplicated file', () => {
|
||||||
|
const fileName = 'my file (copy)';
|
||||||
|
const duplicatedName = 'my file (copy 2)';
|
||||||
|
const expectedDuplicateName = 'my file (copy 3)';
|
||||||
|
|
||||||
|
const ruleWithLocation = {
|
||||||
|
rule: {
|
||||||
|
grafana_alert: {
|
||||||
|
title: fileName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
|
||||||
|
},
|
||||||
|
} as RuleWithLocation;
|
||||||
|
|
||||||
|
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate copy name and number from duplicated file in gap', () => {
|
||||||
|
const fileName = 'my file (copy)';
|
||||||
|
const duplicatedName = 'my file (copy 3)';
|
||||||
|
const expectedDuplicateName = 'my file (copy 2)';
|
||||||
|
|
||||||
|
const ruleWithLocation = {
|
||||||
|
rule: {
|
||||||
|
grafana_alert: {
|
||||||
|
title: fileName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
grafana_alert: { title: fileName },
|
||||||
|
},
|
||||||
|
{ grafana_alert: { title: duplicatedName } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as RuleWithLocation;
|
||||||
|
|
||||||
|
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
|
||||||
|
});
|
||||||
|
});
|
87
public/app/features/alerting/unified/CloneRuleEditor.tsx
Normal file
87
public/app/features/alerting/unified/CloneRuleEditor.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
import { locationService } from '@grafana/runtime/src';
|
||||||
|
import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
|
||||||
|
|
||||||
|
import { useDispatch } from '../../../types';
|
||||||
|
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
|
||||||
|
import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
||||||
|
import { fetchEditableRuleAction } from './state/actions';
|
||||||
|
import { rulerRuleToFormValues } from './utils/rule-form';
|
||||||
|
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
|
||||||
|
import { createUrl } from './utils/url';
|
||||||
|
|
||||||
|
export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
value: rule,
|
||||||
|
error,
|
||||||
|
} = useAsync(() => dispatch(fetchEditableRuleAction(sourceRuleId)).unwrap(), [sourceRuleId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingPlaceholder text="Loading the rule" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule) {
|
||||||
|
const ruleClone = cloneDeep(rule);
|
||||||
|
changeRuleName(ruleClone.rule, generateCopiedRuleTitle(ruleClone));
|
||||||
|
const formPrefill = rulerRuleToFormValues(ruleClone);
|
||||||
|
|
||||||
|
// Provisioned alert rules have provisioned alert group which cannot be used in UI
|
||||||
|
if (isGrafanaRulerRule(rule.rule) && Boolean(rule.rule.grafana_alert.provenance)) {
|
||||||
|
formPrefill.group = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AlertRuleForm prefill={formPrefill} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert title="Error" severity="error">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
title="Cannot duplicate. The rule does not exist"
|
||||||
|
buttonContent="Go back to alert list"
|
||||||
|
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCopiedRuleTitle(originRuleWithLocation: RuleWithLocation): string {
|
||||||
|
const originName = getRuleName(originRuleWithLocation.rule);
|
||||||
|
const existingRulesNames = originRuleWithLocation.group.rules.map(getRuleName);
|
||||||
|
|
||||||
|
const nonDuplicateName = originName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
|
||||||
|
|
||||||
|
let newName = `${nonDuplicateName} (copy)`;
|
||||||
|
|
||||||
|
for (let i = 2; existingRulesNames.includes(newName); i++) {
|
||||||
|
newName = `${nonDuplicateName} (copy ${i})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeRuleName(rule: RulerRuleDTO, newName: string) {
|
||||||
|
if (isGrafanaRulerRule(rule)) {
|
||||||
|
rule.grafana_alert.title = newName;
|
||||||
|
}
|
||||||
|
if (isAlertingRulerRule(rule)) {
|
||||||
|
rule.alert = newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecordingRulerRule(rule)) {
|
||||||
|
rule.record = newName;
|
||||||
|
}
|
||||||
|
}
|
@ -7,9 +7,11 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
|||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
import { AlertWarning } from './AlertWarning';
|
import { AlertWarning } from './AlertWarning';
|
||||||
|
import { CloneRuleEditor } from './CloneRuleEditor';
|
||||||
import { ExistingRuleEditor } from './ExistingRuleEditor';
|
import { ExistingRuleEditor } from './ExistingRuleEditor';
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
|
||||||
|
import { useURLSearchParams } from './hooks/useURLSearchParams';
|
||||||
import { fetchAllPromBuildInfoAction } from './state/actions';
|
import { fetchAllPromBuildInfoAction } from './state/actions';
|
||||||
import { useRulesAccess } from './utils/accessControlHooks';
|
import { useRulesAccess } from './utils/accessControlHooks';
|
||||||
import * as ruleId from './utils/rule-id';
|
import * as ruleId from './utils/rule-id';
|
||||||
@ -33,9 +35,14 @@ const getPageNav = (state: 'edit' | 'add') => {
|
|||||||
|
|
||||||
const RuleEditor = ({ match }: RuleEditorProps) => {
|
const RuleEditor = ({ match }: RuleEditorProps) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const [searchParams] = useURLSearchParams();
|
||||||
|
|
||||||
const { id } = match.params;
|
const { id } = match.params;
|
||||||
const identifier = ruleId.tryParse(id, true);
|
const identifier = ruleId.tryParse(id, true);
|
||||||
|
|
||||||
|
const copyFromId = searchParams.get('copyFrom') ?? undefined;
|
||||||
|
const copyFromIdentifier = ruleId.tryParse(copyFromId);
|
||||||
|
|
||||||
const { loading = true } = useAsync(async () => {
|
const { loading = true } = useAsync(async () => {
|
||||||
await dispatch(fetchAllPromBuildInfoAction());
|
await dispatch(fetchAllPromBuildInfoAction());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
@ -59,6 +66,10 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
|
|||||||
return <ExistingRuleEditor key={id} identifier={identifier} />;
|
return <ExistingRuleEditor key={id} identifier={identifier} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (copyFromIdentifier) {
|
||||||
|
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <AlertRuleForm />;
|
return <AlertRuleForm />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -69,9 +69,10 @@ export const MINUTE = '1m';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
existing?: RuleWithLocation;
|
existing?: RuleWithLocation;
|
||||||
|
prefill?: Partial<RuleFormValues>; // Existing implies we modify existing rule. Prefill only provides default form values
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
@ -86,6 +87,14 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return rulerRuleToFormValues(existing);
|
return rulerRuleToFormValues(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prefill) {
|
||||||
|
return {
|
||||||
|
...getDefaultFormValues(),
|
||||||
|
...prefill,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getDefaultFormValues(),
|
...getDefaultFormValues(),
|
||||||
queries: getDefaultQueries(),
|
queries: getDefaultQueries(),
|
||||||
@ -94,7 +103,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
|||||||
type: RuleFormType.grafana,
|
type: RuleFormType.grafana,
|
||||||
evaluateEvery: evaluateEvery,
|
evaluateEvery: evaluateEvery,
|
||||||
};
|
};
|
||||||
}, [existing, queryParams, evaluateEvery]);
|
}, [existing, prefill, queryParams, evaluateEvery]);
|
||||||
|
|
||||||
const formAPI = useForm<RuleFormValues>({
|
const formAPI = useForm<RuleFormValues>({
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
|
@ -13,6 +13,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
|
|||||||
import { DashboardDTO } from '../../../../../types';
|
import { DashboardDTO } from '../../../../../types';
|
||||||
import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types';
|
import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types';
|
||||||
import { mockStore } from '../../mocks';
|
import { mockStore } from '../../mocks';
|
||||||
|
import { mockSearchApiResponse } from '../../mocks/grafanaApi';
|
||||||
import { RuleFormValues } from '../../types/rule-form';
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
import { Annotation } from '../../utils/constants';
|
import { Annotation } from '../../utils/constants';
|
||||||
import { getDefaultFormValues } from '../../utils/rule-form';
|
import { getDefaultFormValues } from '../../utils/rule-form';
|
||||||
@ -83,7 +84,7 @@ describe('AnnotationsField', function () {
|
|||||||
|
|
||||||
describe('Dashboard and panel picker', function () {
|
describe('Dashboard and panel picker', function () {
|
||||||
it('should display dashboard and panel selector when select button clicked', async function () {
|
it('should display dashboard and panel selector when select button clicked', async function () {
|
||||||
mockSearchResponse([]);
|
mockSearchApiResponse(server, []);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@ -96,7 +97,7 @@ describe('AnnotationsField', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should enable Confirm button only when dashboard and panel selected', async function () {
|
it('should enable Confirm button only when dashboard and panel selected', async function () {
|
||||||
mockSearchResponse([
|
mockSearchApiResponse(server, [
|
||||||
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -126,7 +127,7 @@ describe('AnnotationsField', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add selected dashboard and panel as annotations', async function () {
|
it('should add selected dashboard and panel as annotations', async function () {
|
||||||
mockSearchResponse([
|
mockSearchApiResponse(server, [
|
||||||
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -170,7 +171,7 @@ describe('AnnotationsField', function () {
|
|||||||
// this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item
|
// this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item
|
||||||
// to trigger "handleDashboardChange" – skipping it for now but has been manually tested.
|
// to trigger "handleDashboardChange" – skipping it for now but has been manually tested.
|
||||||
it.skip('should update existing dashboard and panel identifies', async function () {
|
it.skip('should update existing dashboard and panel identifies', async function () {
|
||||||
mockSearchResponse([
|
mockSearchApiResponse(server, [
|
||||||
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
|
||||||
mockDashboardSearchItem({
|
mockDashboardSearchItem({
|
||||||
title: 'My other dashboard',
|
title: 'My other dashboard',
|
||||||
@ -236,10 +237,6 @@ describe('AnnotationsField', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockSearchResponse(searchResult: DashboardSearchItem[]) {
|
|
||||||
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json<DashboardSearchItem[]>(searchResult))));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockGetDashboardResponse(dashboard: DashboardDTO) {
|
function mockGetDashboardResponse(dashboard: DashboardDTO) {
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) =>
|
rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) =>
|
||||||
|
@ -15,6 +15,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
|
|||||||
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
|
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
|
||||||
import { RuleForm, RuleFormValues } from '../../types/rule-form';
|
import { RuleForm, RuleFormValues } from '../../types/rule-form';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
|
import { isGrafanaRulerRule } from '../../utils/rules';
|
||||||
import { InfoIcon } from '../InfoIcon';
|
import { InfoIcon } from '../InfoIcon';
|
||||||
|
|
||||||
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
|
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
|
||||||
@ -27,7 +28,13 @@ const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undef
|
|||||||
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
|
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
|
||||||
? groupfoldersForGrafana[folderName] ?? []
|
? groupfoldersForGrafana[folderName] ?? []
|
||||||
: [];
|
: [];
|
||||||
return groupsForFolderResult.map((group) => group.name);
|
|
||||||
|
const folderGroups = groupsForFolderResult.map((group) => ({
|
||||||
|
name: group.name,
|
||||||
|
provisioned: group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return folderGroups.filter((group) => !group.provisioned).map((group) => group.name);
|
||||||
}, [groupfoldersForGrafana, folderName]);
|
}, [groupfoldersForGrafana, folderName]);
|
||||||
|
|
||||||
return groupOptions;
|
return groupOptions;
|
||||||
@ -40,13 +47,13 @@ interface FolderAndGroupProps {
|
|||||||
initialFolder: RuleForm | null;
|
initialFolder: RuleForm | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetGroupOptionsFromFolder = (folderTilte: string) => {
|
export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
|
||||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||||
|
|
||||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||||
|
|
||||||
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
|
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
|
||||||
useGetGroups(groupfoldersForGrafana?.result, folderTilte)
|
useGetGroups(groupfoldersForGrafana?.result, folderTitle)
|
||||||
);
|
);
|
||||||
const groupsForFolder = groupfoldersForGrafana?.result;
|
const groupsForFolder = groupfoldersForGrafana?.result;
|
||||||
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };
|
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };
|
||||||
|
@ -2,20 +2,11 @@ import { css } from '@emotion/css';
|
|||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { Stack } from '@grafana/experimental';
|
||||||
import {
|
import { config, locationService } from '@grafana/runtime';
|
||||||
Button,
|
import { Button, ClipboardButton, ConfirmModal, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
ClipboardButton,
|
|
||||||
ConfirmModal,
|
|
||||||
HorizontalGroup,
|
|
||||||
LinkButton,
|
|
||||||
Tooltip,
|
|
||||||
useStyles2,
|
|
||||||
useTheme2,
|
|
||||||
} from '@grafana/ui';
|
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
|
||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
@ -25,6 +16,7 @@ import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
|
|||||||
import { createViewLink } from '../../utils/misc';
|
import { createViewLink } from '../../utils/misc';
|
||||||
import * as ruleId from '../../utils/rule-id';
|
import * as ruleId from '../../utils/rule-id';
|
||||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||||
|
import { createUrl } from '../../utils/url';
|
||||||
|
|
||||||
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
|
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
|
||||||
|
|
||||||
@ -32,25 +24,6 @@ interface Props {
|
|||||||
rule: CombinedRule;
|
rule: CombinedRule;
|
||||||
rulesSource: RulesSource;
|
rulesSource: RulesSource;
|
||||||
}
|
}
|
||||||
function DontShowIfSmallDevice({ children }: { children: JSX.Element | string }) {
|
|
||||||
const theme = useTheme2();
|
|
||||||
const smBreakpoint = theme.breakpoints.values.xxl;
|
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState(matchesWidth(smBreakpoint));
|
|
||||||
const style = useStyles2(getStyles);
|
|
||||||
|
|
||||||
useMediaQueryChange({
|
|
||||||
breakpoint: smBreakpoint,
|
|
||||||
onChange: (e) => {
|
|
||||||
setIsSmallScreen(e.matches);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isSmallScreen) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return <div className={style.buttonText}>{children}</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -59,6 +32,7 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
const style = useStyles2(getStyles);
|
const style = useStyles2(getStyles);
|
||||||
const { namespace, group, rulerRule } = rule;
|
const { namespace, group, rulerRule } = rule;
|
||||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
||||||
|
const [provRuleCloneUrl, setProvRuleCloneUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||||
|
|
||||||
@ -96,56 +70,79 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
return window.location.href.split('?')[0];
|
return window.location.href.split('?')[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceName = getRulesSourceName(rulesSource);
|
||||||
|
|
||||||
if (!isViewMode) {
|
if (!isViewMode) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<Tooltip placement="top" content={'View'}>
|
<Tooltip placement="top" content={'View'}>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className={style.button}
|
className={style.button}
|
||||||
size="xs"
|
title="View"
|
||||||
|
size="sm"
|
||||||
key="view"
|
key="view"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="eye"
|
icon="eye"
|
||||||
href={createViewLink(rulesSource, rule, returnTo)}
|
href={createViewLink(rulesSource, rule, returnTo)}
|
||||||
>
|
></LinkButton>
|
||||||
<DontShowIfSmallDevice>View</DontShowIfSmallDevice>
|
|
||||||
</LinkButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
|
if (isEditable && rulerRule && !isFederated) {
|
||||||
const sourceName = getRulesSourceName(rulesSource);
|
|
||||||
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
|
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
|
||||||
|
|
||||||
const editURL = urlUtil.renderUrl(
|
if (!isProvisioned) {
|
||||||
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
|
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
|
||||||
{
|
|
||||||
returnTo,
|
returnTo,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
if (isViewMode) {
|
||||||
|
buttons.push(
|
||||||
|
<ClipboardButton
|
||||||
|
key="copy"
|
||||||
|
icon="copy"
|
||||||
|
onClipboardError={(copiedText) => {
|
||||||
|
notifyApp.error('Error while copying URL', copiedText);
|
||||||
|
}}
|
||||||
|
className={style.button}
|
||||||
|
size="sm"
|
||||||
|
getText={buildShareUrl}
|
||||||
|
>
|
||||||
|
Copy link to rule
|
||||||
|
</ClipboardButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isViewMode) {
|
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<ClipboardButton
|
<Tooltip placement="top" content={'Edit'}>
|
||||||
key="copy"
|
<LinkButton
|
||||||
icon="copy"
|
title="Edit"
|
||||||
onClipboardError={(copiedText) => {
|
className={style.button}
|
||||||
notifyApp.error('Error while copying URL', copiedText);
|
size="sm"
|
||||||
}}
|
key="edit"
|
||||||
className={style.button}
|
variant="secondary"
|
||||||
size="sm"
|
icon="pen"
|
||||||
getText={buildShareUrl}
|
href={editURL}
|
||||||
>
|
/>
|
||||||
Copy link to rule
|
</Tooltip>
|
||||||
</ClipboardButton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloneUrl = createUrl('/alerting/new', { copyFrom: ruleId.stringifyIdentifier(identifier) });
|
||||||
|
// For provisioned rules an additional confirmation step is required
|
||||||
|
// Users have to be aware that the cloned rule will NOT be marked as provisioned
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<Tooltip placement="top" content={'Edit'}>
|
<Tooltip placement="top" content="Duplicate">
|
||||||
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
|
<LinkButton
|
||||||
<DontShowIfSmallDevice>Edit</DontShowIfSmallDevice>
|
title="Duplicate"
|
||||||
</LinkButton>
|
className={style.button}
|
||||||
|
size="sm"
|
||||||
|
key="clone"
|
||||||
|
variant="secondary"
|
||||||
|
icon="copy"
|
||||||
|
href={isProvisioned ? undefined : cloneUrl}
|
||||||
|
onClick={isProvisioned ? () => setProvRuleCloneUrl(cloneUrl) : undefined}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -154,16 +151,15 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
buttons.push(
|
buttons.push(
|
||||||
<Tooltip placement="top" content={'Delete'}>
|
<Tooltip placement="top" content={'Delete'}>
|
||||||
<Button
|
<Button
|
||||||
|
title="Delete"
|
||||||
className={style.button}
|
className={style.button}
|
||||||
size="xs"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
key="delete"
|
key="delete"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
onClick={() => setRuleToDelete(rule)}
|
onClick={() => setRuleToDelete(rule)}
|
||||||
>
|
/>
|
||||||
<DontShowIfSmallDevice>Delete</DontShowIfSmallDevice>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -171,11 +167,11 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
if (buttons.length) {
|
if (buttons.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={style.wrapper}>
|
<Stack gap={1}>
|
||||||
<HorizontalGroup width="auto">
|
{buttons.map((button, index) => (
|
||||||
{buttons.length ? buttons.map((button, index) => <div key={index}>{button}</div>) : <div />}
|
<React.Fragment key={index}>{button}</React.Fragment>
|
||||||
</HorizontalGroup>
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
{!!ruleToDelete && (
|
{!!ruleToDelete && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
@ -187,6 +183,24 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
|||||||
onDismiss={() => setRuleToDelete(undefined)}
|
onDismiss={() => setRuleToDelete(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!provRuleCloneUrl}
|
||||||
|
title="Clone provisioned rule"
|
||||||
|
body={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
The new rule will <span className={style.bold}>NOT</span> be marked as a provisioned rule.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will need to set a new alert group for the cloned rule because the original one has been provisioned
|
||||||
|
and cannot be used for rules created in the UI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
confirmText="Clone"
|
||||||
|
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
|
||||||
|
onDismiss={() => setProvRuleCloneUrl(undefined)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -199,20 +213,10 @@ function inViewMode(pathname: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
wrapper: css`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`,
|
|
||||||
button: css`
|
button: css`
|
||||||
height: 24px;
|
padding: 0 ${theme.spacing(2)};
|
||||||
font-size: ${theme.typography.size.sm};
|
|
||||||
svg {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
buttonText: css`
|
bold: css`
|
||||||
margin-left: 8px;
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -83,13 +83,13 @@ describe('RulesTable RBAC', () => {
|
|||||||
it('Should render Edit button for users with the update permission', () => {
|
it('Should render Edit button for users with the update permission', () => {
|
||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||||
renderRulesTable(grafanaRule);
|
renderRulesTable(grafanaRule);
|
||||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should render Delete button for users with the delete permission', () => {
|
it('Should render Delete button for users with the delete permission', () => {
|
||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||||
renderRulesTable(grafanaRule);
|
renderRulesTable(grafanaRule);
|
||||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
expect(ui.actionButtons.delete.get()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -110,13 +110,13 @@ describe('RulesTable RBAC', () => {
|
|||||||
it('Should render Edit button for users with the update permission', () => {
|
it('Should render Edit button for users with the update permission', () => {
|
||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||||
renderRulesTable(cloudRule);
|
renderRulesTable(cloudRule);
|
||||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should render Delete button for users with the delete permission', () => {
|
it('Should render Delete button for users with the delete permission', () => {
|
||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||||
renderRulesTable(cloudRule);
|
renderRulesTable(cloudRule);
|
||||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
expect(ui.actionButtons.delete.get()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -196,7 +196,7 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
|||||||
renderCell: ({ data: rule }) => {
|
renderCell: ({ data: rule }) => {
|
||||||
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
|
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
|
||||||
},
|
},
|
||||||
size: '290px',
|
size: '200px',
|
||||||
});
|
});
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
|
@ -121,6 +121,7 @@ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {
|
|||||||
annotations: {
|
annotations: {
|
||||||
summary: 'test alert',
|
summary: 'test alert',
|
||||||
},
|
},
|
||||||
|
...partial,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({
|
export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({
|
||||||
|
8
public/app/features/alerting/unified/mocks/grafanaApi.ts
Normal file
8
public/app/features/alerting/unified/mocks/grafanaApi.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { rest } from 'msw';
|
||||||
|
import { SetupServerApi } from 'msw/node';
|
||||||
|
|
||||||
|
import { DashboardSearchItem } from '../../../search/types';
|
||||||
|
|
||||||
|
export function mockSearchApiResponse(server: SetupServerApi, searchResult: DashboardSearchItem[]) {
|
||||||
|
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json<DashboardSearchItem[]>(searchResult))));
|
||||||
|
}
|
30
public/app/features/alerting/unified/mocks/rulerApi.ts
Normal file
30
public/app/features/alerting/unified/mocks/rulerApi.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { rest } from 'msw';
|
||||||
|
import { SetupServerApi } from 'msw/node';
|
||||||
|
|
||||||
|
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from '../../../../types/unified-alerting-dto';
|
||||||
|
|
||||||
|
export function mockRulerRulesApiResponse(
|
||||||
|
server: SetupServerApi,
|
||||||
|
rulesSourceName: string,
|
||||||
|
response: RulerRulesConfigDTO
|
||||||
|
) {
|
||||||
|
server.use(
|
||||||
|
rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules`, (req, res, ctx) =>
|
||||||
|
res(ctx.json<RulerRulesConfigDTO>(response))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockRulerRulesGroupApiResponse(
|
||||||
|
server: SetupServerApi,
|
||||||
|
rulesSourceName: string,
|
||||||
|
namespace: string,
|
||||||
|
group: string,
|
||||||
|
response: RulerRuleGroupDTO
|
||||||
|
) {
|
||||||
|
server.use(
|
||||||
|
rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) =>
|
||||||
|
res(ctx.json(response))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { urlUtil, UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
import { UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
|
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
|
||||||
import { SortOrder } from 'app/plugins/panel/alertlist/types';
|
import { SortOrder } from 'app/plugins/panel/alertlist/types';
|
||||||
import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting';
|
import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting';
|
||||||
@ -15,6 +14,7 @@ import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
|
|||||||
import { getRulesSourceName } from './datasource';
|
import { getRulesSourceName } from './datasource';
|
||||||
import { getMatcherQueryParams } from './matchers';
|
import { getMatcherQueryParams } from './matchers';
|
||||||
import * as ruleId from './rule-id';
|
import * as ruleId from './rule-id';
|
||||||
|
import { createUrl } from './url';
|
||||||
|
|
||||||
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string {
|
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string {
|
||||||
const sourceName = getRulesSourceName(ruleSource);
|
const sourceName = getRulesSourceName(ruleSource);
|
||||||
@ -22,11 +22,11 @@ export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, retu
|
|||||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||||
const paramSource = encodeURIComponent(sourceName);
|
const paramSource = encodeURIComponent(sourceName);
|
||||||
|
|
||||||
return urlUtil.renderUrl(`${config.appSubUrl}/alerting/${paramSource}/${paramId}/view`, { returnTo });
|
return createUrl(`/alerting/${paramSource}/${paramId}/view`, { returnTo });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExploreLink(dataSourceName: string, query: string) {
|
export function createExploreLink(dataSourceName: string, query: string) {
|
||||||
return urlUtil.renderUrl(`${config.appSubUrl}/explore`, {
|
return createUrl(`/explore`, {
|
||||||
left: JSON.stringify([
|
left: JSON.stringify([
|
||||||
'now-1h',
|
'now-1h',
|
||||||
'now',
|
'now',
|
||||||
@ -95,15 +95,15 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels
|
|||||||
const matcherParams = getMatcherQueryParams(labels);
|
const matcherParams = getMatcherQueryParams(labels);
|
||||||
matcherParams.forEach((value, key) => silenceUrlParams.append(key, value));
|
matcherParams.forEach((value, key) => silenceUrlParams.append(key, value));
|
||||||
|
|
||||||
return `${config.appSubUrl}/alerting/silence/new?${silenceUrlParams.toString()}`;
|
return createUrl('/alerting/silence/new', silenceUrlParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) {
|
export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) {
|
||||||
return `${config.appSubUrl}/datasources/edit/${dataSource.uid}`;
|
return createUrl(`/datasources/edit/${dataSource.uid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeFolderLink(folderUID: string): string {
|
export function makeFolderLink(folderUID: string): string {
|
||||||
return `${config.appSubUrl}/dashboards/f/${folderUID}`;
|
return createUrl(`/dashboards/f/${folderUID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
|
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
|
||||||
|
@ -145,3 +145,18 @@ export function getFirstActiveAt(promRule: AlertingRule) {
|
|||||||
export function isFederatedRuleGroup(group: CombinedRuleGroup) {
|
export function isFederatedRuleGroup(group: CombinedRuleGroup) {
|
||||||
return Array.isArray(group.source_tenants);
|
return Array.isArray(group.source_tenants);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRuleName(rule: RulerRuleDTO) {
|
||||||
|
if (isGrafanaRulerRule(rule)) {
|
||||||
|
return rule.grafana_alert.title;
|
||||||
|
}
|
||||||
|
if (isAlertingRulerRule(rule)) {
|
||||||
|
return rule.alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecordingRulerRule(rule)) {
|
||||||
|
return rule.record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
6
public/app/features/alerting/unified/utils/url.ts
Normal file
6
public/app/features/alerting/unified/utils/url.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
export function createUrl(path: string, queryParams?: string[][] | Record<string, string> | string | URLSearchParams) {
|
||||||
|
const searchParams = new URLSearchParams(queryParams);
|
||||||
|
return `${config.appSubUrl}${path}?${searchParams.toString()}`;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user