mirror of
https://github.com/grafana/grafana.git
synced 2024-12-01 04:59:15 -06:00
Alerting: better detect cortex/loki ruler api (#36030)
* wip * beter detect non existing rules stuff * fix useIsRuleEditable * test for detecting editable-ness of a rules datasource * tests! * fix lint errors
This commit is contained in:
parent
d4e53a9be4
commit
3ea8880d7f
320
public/app/features/alerting/unified/RuleEditor.test.tsx
Normal file
320
public/app/features/alerting/unified/RuleEditor.test.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import { Matcher, render, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { locationService, setDataSourceSrv, setBackendSrv, BackendSrv } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import RuleEditor from './RuleEditor';
|
||||
import { Router, Route } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { mockDataSource, MockDataSourceSrv } from './mocks';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
||||
import { getAllDataSources } from './utils/config';
|
||||
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
|
||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { getDefaultQueries } from './utils/rule-form';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
|
||||
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)} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./api/ruler');
|
||||
jest.mock('./utils/config');
|
||||
|
||||
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
|
||||
// lets just skip it
|
||||
jest.mock('app/features/query/components/QueryEditorRow', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
QueryEditorRow: () => <p>hi</p>,
|
||||
}));
|
||||
|
||||
const mocks = {
|
||||
getAllDataSources: typeAsJestMock(getAllDataSources),
|
||||
|
||||
api: {
|
||||
fetchRulerRulesGroup: typeAsJestMock(fetchRulerRulesGroup),
|
||||
setRulerRuleGroup: typeAsJestMock(setRulerRuleGroup),
|
||||
fetchRulerRulesNamespace: typeAsJestMock(fetchRulerRulesNamespace),
|
||||
fetchRulerRules: typeAsJestMock(fetchRulerRules),
|
||||
},
|
||||
};
|
||||
|
||||
function renderRuleEditor(identifier?: string) {
|
||||
const store = configureStore();
|
||||
|
||||
locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`);
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<Route path={['/alerting/new', '/alerting/:id/edit']} component={RuleEditor} />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ui = {
|
||||
inputs: {
|
||||
name: byLabelText('Alert name'),
|
||||
alertType: byTestId('alert-type-picker'),
|
||||
dataSource: byTestId('datasource-picker'),
|
||||
folder: byTestId('folder-picker'),
|
||||
namespace: byTestId('namespace-picker'),
|
||||
group: byTestId('group-picker'),
|
||||
annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
|
||||
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
|
||||
labelKey: (idx: number) => byTestId(`label-key-${idx}`),
|
||||
labelValue: (idx: number) => byTestId(`label-value-${idx}`),
|
||||
expr: byTestId('expr'),
|
||||
},
|
||||
buttons: {
|
||||
save: byRole('button', { name: 'Save' }),
|
||||
addAnnotation: byRole('button', { name: /Add info/ }),
|
||||
addLabel: byRole('button', { name: /Add label/ }),
|
||||
},
|
||||
};
|
||||
|
||||
describe('RuleEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
contextSrv.isEditor = true;
|
||||
});
|
||||
|
||||
it('can create a new cloud alert', async () => {
|
||||
const dataSources = {
|
||||
default: mockDataSource(
|
||||
{
|
||||
type: 'prometheus',
|
||||
name: 'Prom',
|
||||
isDefault: true,
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
};
|
||||
|
||||
setDataSourceSrv(new MockDataSourceSrv(dataSources));
|
||||
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
||||
mocks.api.setRulerRuleGroup.mockResolvedValue();
|
||||
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
|
||||
mocks.api.fetchRulerRulesGroup.mockResolvedValue({
|
||||
name: 'group2',
|
||||
rules: [],
|
||||
});
|
||||
mocks.api.fetchRulerRules.mockResolvedValue({
|
||||
namespace1: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
namespace2: [
|
||||
{
|
||||
name: 'group2',
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderRuleEditor();
|
||||
await userEvent.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
|
||||
const dataSourceSelect = ui.inputs.dataSource.get();
|
||||
userEvent.click(byRole('textbox').get(dataSourceSelect));
|
||||
userEvent.click(await byText('Prom (default)').find(dataSourceSelect));
|
||||
await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
|
||||
clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
|
||||
clickSelectOption(ui.inputs.group.get(), 'group2');
|
||||
|
||||
await userEvent.type(ui.inputs.expr.get(), 'up == 1');
|
||||
|
||||
await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
|
||||
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
|
||||
userEvent.click(ui.buttons.addLabel.get());
|
||||
|
||||
await userEvent.type(ui.inputs.labelKey(0).get(), 'severity');
|
||||
await userEvent.type(ui.inputs.labelValue(0).get(), 'warn');
|
||||
await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
|
||||
await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
|
||||
|
||||
// save and check what was sent to backend
|
||||
userEvent.click(ui.buttons.save.get());
|
||||
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
|
||||
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', {
|
||||
name: 'group2',
|
||||
rules: [
|
||||
{
|
||||
alert: 'my great new rule',
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
labels: { severity: 'warn', team: 'the a-team' },
|
||||
expr: 'up == 1',
|
||||
for: '1m',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('can create new grafana managed alert', async () => {
|
||||
const searchFolderMock = jest.fn().mockResolvedValue([
|
||||
{
|
||||
title: 'Folder A',
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
title: 'Folder B',
|
||||
id: 2,
|
||||
},
|
||||
] as DashboardSearchHit[]);
|
||||
|
||||
const dataSources = {
|
||||
default: mockDataSource({
|
||||
type: 'prometheus',
|
||||
name: 'Prom',
|
||||
isDefault: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const backendSrv = ({
|
||||
search: searchFolderMock,
|
||||
} as any) as BackendSrv;
|
||||
setBackendSrv(backendSrv);
|
||||
setDataSourceSrv(new MockDataSourceSrv(dataSources));
|
||||
mocks.api.setRulerRuleGroup.mockResolvedValue();
|
||||
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
|
||||
|
||||
// fill out the form
|
||||
await renderRuleEditor();
|
||||
userEvent.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
clickSelectOption(ui.inputs.alertType.get(), /Classic Grafana alerts based on thresholds/);
|
||||
const folderInput = await ui.inputs.folder.find();
|
||||
await waitFor(() => expect(searchFolderMock).toHaveBeenCalled());
|
||||
clickSelectOption(folderInput, 'Folder A');
|
||||
|
||||
await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
|
||||
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
|
||||
userEvent.click(ui.buttons.addLabel.get());
|
||||
|
||||
await userEvent.type(ui.inputs.labelKey(0).get(), 'severity');
|
||||
await userEvent.type(ui.inputs.labelValue(0).get(), 'warn');
|
||||
await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
|
||||
await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
|
||||
|
||||
// save and check what was sent to backend
|
||||
userEvent.click(ui.buttons.save.get());
|
||||
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
|
||||
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', {
|
||||
interval: '1m',
|
||||
name: 'my great new rule',
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
labels: { severity: 'warn', team: 'the a-team' },
|
||||
for: '5m',
|
||||
grafana_alert: {
|
||||
condition: 'B',
|
||||
data: getDefaultQueries(),
|
||||
exec_err_state: 'Alerting',
|
||||
no_data_state: 'NoData',
|
||||
title: 'my great new rule',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('for cloud alerts, should only allow to select editable rules sources', async () => {
|
||||
const dataSources: Record<string, DataSourceInstanceSettings<any>> = {
|
||||
// can edit rules
|
||||
loki: mockDataSource(
|
||||
{
|
||||
type: DataSourceType.Loki,
|
||||
name: 'loki with ruler',
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
// can edit rules
|
||||
prom: mockDataSource(
|
||||
{
|
||||
type: DataSourceType.Prometheus,
|
||||
name: 'cortex with ruler',
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
// cannot edit rules
|
||||
loki_local_rule_store: mockDataSource(
|
||||
{
|
||||
type: DataSourceType.Loki,
|
||||
name: 'loki with local rule store',
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
// cannot edit rules
|
||||
prom_no_ruler_api: mockDataSource(
|
||||
{
|
||||
type: DataSourceType.Loki,
|
||||
name: 'cortex without ruler api',
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
// not a supported datasource type
|
||||
splunk: mockDataSource(
|
||||
{
|
||||
type: 'splunk',
|
||||
name: 'splunk',
|
||||
},
|
||||
{ alerting: true }
|
||||
),
|
||||
};
|
||||
|
||||
mocks.api.fetchRulerRulesGroup.mockImplementation(async (dataSourceName: string) => {
|
||||
if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') {
|
||||
return null;
|
||||
}
|
||||
if (dataSourceName === 'loki with local rule store') {
|
||||
throw {
|
||||
status: 400,
|
||||
data: {
|
||||
message: 'GetRuleGroup unsupported in rule local store',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (dataSourceName === 'cortex without ruler api') {
|
||||
throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
setDataSourceSrv(new MockDataSourceSrv(dataSources));
|
||||
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
||||
|
||||
// render rule editor, select cortex/loki managed alerts
|
||||
await renderRuleEditor();
|
||||
await ui.inputs.name.find();
|
||||
clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
|
||||
|
||||
// wait for ui theck each datasource if it supports rule editing
|
||||
await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4));
|
||||
|
||||
// check that only rules sources that have ruler available are there
|
||||
const dataSourceSelect = ui.inputs.dataSource.get();
|
||||
userEvent.click(byRole('textbox').get(dataSourceSelect));
|
||||
expect(await byText('loki with ruler').find(dataSourceSelect)).toBeInTheDocument();
|
||||
expect(byText('cortex with ruler').query(dataSourceSelect)).toBeInTheDocument();
|
||||
expect(byText('loki with local rule store').query(dataSourceSelect)).not.toBeInTheDocument();
|
||||
expect(byText('prom without ruler api').query(dataSourceSelect)).not.toBeInTheDocument();
|
||||
expect(byText('splunk').query(dataSourceSelect)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const clickSelectOption = (selectElement: HTMLElement, optionText: Matcher): void => {
|
||||
userEvent.click(byRole('textbox').get(selectElement));
|
||||
userEvent.click(byText(optionText).get(selectElement));
|
||||
};
|
@ -22,7 +22,7 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
|
||||
useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule);
|
||||
const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
|
||||
const dispatch = useDispatch();
|
||||
const { isEditable } = useIsRuleEditable(result?.rule);
|
||||
const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dispatched) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { RuleNamespace } from 'app/types/unified-alerting';
|
||||
import { PromRulesResponse } from 'app/types/unified-alerting-dto';
|
||||
import { getAllRulesSourceNames, getDatasourceAPIId } from '../utils/datasource';
|
||||
import { getDatasourceAPIId } from '../utils/datasource';
|
||||
|
||||
export async function fetchRules(dataSourceName: string): Promise<RuleNamespace[]> {
|
||||
const response = await getBackendSrv()
|
||||
@ -10,12 +10,18 @@ export async function fetchRules(dataSourceName: string): Promise<RuleNamespace[
|
||||
showErrorAlert: false,
|
||||
showSuccessAlert: false,
|
||||
})
|
||||
.toPromise();
|
||||
.toPromise()
|
||||
.catch((e) => {
|
||||
if ('status' in e && e.status === 404) {
|
||||
throw new Error('404 from rule state endpoint. Perhaps ruler API is not enabled?');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
const nsMap: { [key: string]: RuleNamespace } = {};
|
||||
response.data.data.groups.forEach((group) => {
|
||||
group.rules.forEach((rule) => {
|
||||
rule.query = rule.query || ''; // @TODO temp fix, backend response ism issing query. remove once it's there
|
||||
rule.query = rule.query || '';
|
||||
});
|
||||
if (!nsMap[group.file]) {
|
||||
nsMap[group.file] = {
|
||||
@ -30,17 +36,3 @@ export async function fetchRules(dataSourceName: string): Promise<RuleNamespace[
|
||||
|
||||
return Object.values(nsMap);
|
||||
}
|
||||
|
||||
export async function fetchAllRules(): Promise<RuleNamespace[]> {
|
||||
const namespaces = [] as Array<Promise<RuleNamespace[]>>;
|
||||
getAllRulesSourceNames().forEach(async (name) => {
|
||||
namespaces.push(
|
||||
fetchRules(name).catch((e) => {
|
||||
return [];
|
||||
// TODO add error comms
|
||||
})
|
||||
);
|
||||
});
|
||||
const promises = await Promise.all(namespaces);
|
||||
return promises.flat();
|
||||
}
|
||||
|
@ -75,8 +75,11 @@ async function rulerGetRequest<T>(url: string, empty: T): Promise<T> {
|
||||
.toPromise();
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) {
|
||||
return empty;
|
||||
if (e?.status === 404) {
|
||||
if (e?.data?.message?.includes('group does not exist') || e?.data?.message?.includes('no rule groups found')) {
|
||||
return empty;
|
||||
}
|
||||
throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?');
|
||||
} else if (
|
||||
e?.status === 500 &&
|
||||
e?.data?.message?.includes('unexpected content type from upstream. expected YAML, got text/html')
|
||||
|
@ -1,32 +1,14 @@
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Field, Input, InputControl, Select, useStyles2 } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { DataSourcePicker } from '@grafana/runtime';
|
||||
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
|
||||
import { RuleFolderPicker } from './RuleFolderPicker';
|
||||
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
const alertTypeOptions: SelectableValue[] = [
|
||||
{
|
||||
label: 'Grafana managed alert',
|
||||
value: RuleFormType.grafana,
|
||||
description: 'Classic Grafana alerts based on thresholds.',
|
||||
},
|
||||
];
|
||||
|
||||
if (contextSrv.isEditor) {
|
||||
alertTypeOptions.push({
|
||||
label: 'Cortex/Loki managed alert',
|
||||
value: RuleFormType.cloud,
|
||||
description: 'Alert based on a system or application behavior. Based on Prometheus.',
|
||||
});
|
||||
}
|
||||
import { CloudRulesSourcePicker } from './CloudRulesSourcePicker';
|
||||
|
||||
interface Props {
|
||||
editingExistingRule: boolean;
|
||||
@ -46,21 +28,25 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
const ruleFormType = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
|
||||
useEffect(() => {}, [ruleFormType]);
|
||||
const alertTypeOptions = useMemo((): SelectableValue[] => {
|
||||
const result = [
|
||||
{
|
||||
label: 'Grafana managed alert',
|
||||
value: RuleFormType.grafana,
|
||||
description: 'Classic Grafana alerts based on thresholds.',
|
||||
},
|
||||
];
|
||||
|
||||
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
|
||||
if (contextSrv.isEditor) {
|
||||
result.push({
|
||||
label: 'Cortex/Loki managed alert',
|
||||
value: RuleFormType.cloud,
|
||||
description: 'Alert based on a system or application behavior. Based on Prometheus.',
|
||||
});
|
||||
}
|
||||
|
||||
const dataSourceFilter = useCallback(
|
||||
(ds: DataSourceInstanceSettings): boolean => {
|
||||
if (ruleFormType === RuleFormType.grafana) {
|
||||
return !!ds.meta.alerting;
|
||||
} else {
|
||||
// filter out only rules sources that support ruler and thus can have alerts edited
|
||||
return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id);
|
||||
}
|
||||
},
|
||||
[ruleFormType, rulesSourcesWithRuler]
|
||||
);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RuleEditorSection stepNo={1} title="Alert type">
|
||||
@ -71,6 +57,7 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
invalid={!!errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name', { required: { value: true, message: 'Must enter an alert name' } })}
|
||||
autoFocus={true}
|
||||
/>
|
||||
@ -82,25 +69,11 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
className={styles.formInput}
|
||||
error={errors.type?.message}
|
||||
invalid={!!errors.type?.message}
|
||||
data-testid="alert-type-picker"
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
options={alertTypeOptions}
|
||||
onChange={(v: SelectableValue) => {
|
||||
const value = v?.value;
|
||||
// when switching to system alerts, null out data source selection if it's not a rules source with ruler
|
||||
if (
|
||||
value === RuleFormType.cloud &&
|
||||
dataSourceName &&
|
||||
!rulesSourcesWithRuler.find(({ name }) => name === dataSourceName)
|
||||
) {
|
||||
setValue('dataSourceName', null);
|
||||
}
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
<Select {...field} options={alertTypeOptions} onChange={(v: SelectableValue) => onChange(v?.value)} />
|
||||
)}
|
||||
name="type"
|
||||
control={control}
|
||||
@ -115,15 +88,12 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
label="Select data source"
|
||||
error={errors.dataSourceName?.message}
|
||||
invalid={!!errors.dataSourceName?.message}
|
||||
data-testid="datasource-picker"
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, value, ...field } }) => (
|
||||
<DataSourcePicker
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<CloudRulesSourcePicker
|
||||
{...field}
|
||||
current={value}
|
||||
filter={dataSourceFilter}
|
||||
noDefault
|
||||
alerting
|
||||
onChange={(ds: DataSourceInstanceSettings) => {
|
||||
// reset location if switching data sources, as different rules source will have different groups and namespaces
|
||||
setValue('location', undefined);
|
||||
@ -149,6 +119,7 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
invalid={!!errors.folder?.message}
|
||||
data-testid="folder-picker"
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
|
@ -37,6 +37,7 @@ const AnnotationsField: FC = () => {
|
||||
className={styles.field}
|
||||
invalid={!!errors.annotations?.[index]?.key?.message}
|
||||
error={errors.annotations?.[index]?.key?.message}
|
||||
data-testid={`annotation-key-${index}`}
|
||||
>
|
||||
<InputControl
|
||||
name={`annotations[${index}].key`}
|
||||
@ -53,6 +54,7 @@ const AnnotationsField: FC = () => {
|
||||
error={errors.annotations?.[index]?.value?.message}
|
||||
>
|
||||
<ValueInputComponent
|
||||
data-testid={`annotation-value-${index}`}
|
||||
className={cx(styles.annotationValueInput, { [styles.textarea]: !isUrl })}
|
||||
{...register(`annotations[${index}].value`)}
|
||||
placeholder={isUrl ? 'https://' : `Text`}
|
||||
|
@ -0,0 +1,24 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { DataSourcePicker } from '@grafana/runtime';
|
||||
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
|
||||
|
||||
interface Props {
|
||||
onChange: (ds: DataSourceInstanceSettings) => void;
|
||||
value: string | null;
|
||||
onBlur?: () => void;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function CloudRulesSourcePicker({ value, ...props }: Props): JSX.Element {
|
||||
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
|
||||
|
||||
const dataSourceFilter = useCallback(
|
||||
(ds: DataSourceInstanceSettings): boolean => {
|
||||
return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id);
|
||||
},
|
||||
[rulesSourcesWithRuler]
|
||||
);
|
||||
|
||||
return <DataSourcePicker noDefault alerting filter={dataSourceFilter} current={value} {...props} />;
|
||||
}
|
@ -6,13 +6,13 @@ import { useAsync } from 'react-use';
|
||||
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
|
||||
import { LokiQuery } from 'app/plugins/datasource/loki/types';
|
||||
|
||||
interface Props {
|
||||
export interface ExpressionEditorProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSourceName: string; // will be a prometheus or loki datasource
|
||||
}
|
||||
|
||||
export const ExpressionEditor: FC<Props> = ({ value, onChange, dataSourceName }) => {
|
||||
export const ExpressionEditor: FC<ExpressionEditorProps> = ({ value, onChange, dataSourceName }) => {
|
||||
const { mapToValue, mapToQuery } = useQueryMappers(dataSourceName);
|
||||
const [query, setQuery] = useState(mapToQuery({ refId: 'A', hide: false }, value));
|
||||
const { error, loading, value: dataSource } = useAsync(() => {
|
||||
|
@ -49,7 +49,12 @@ export const GroupAndNamespaceFields: FC<Props> = ({ dataSourceName }) => {
|
||||
|
||||
return (
|
||||
<div className={style.flexRow}>
|
||||
<Field label="Namespace" error={errors.namespace?.message} invalid={!!errors.namespace?.message}>
|
||||
<Field
|
||||
data-testid="namespace-picker"
|
||||
label="Namespace"
|
||||
error={errors.namespace?.message}
|
||||
invalid={!!errors.namespace?.message}
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<SelectWithAdd
|
||||
@ -73,7 +78,7 @@ export const GroupAndNamespaceFields: FC<Props> = ({ dataSourceName }) => {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Group" error={errors.group?.message} invalid={!!errors.group?.message}>
|
||||
<Field data-testid="group-picker" label="Group" error={errors.group?.message} invalid={!!errors.group?.message}>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<SelectWithAdd {...field} options={groupOptions} width={42} custom={customGroup} className={style.input} />
|
||||
|
@ -41,6 +41,7 @@ const LabelsField: FC<Props> = ({ className }) => {
|
||||
required: { value: !!labels[index]?.value, message: 'Required.' },
|
||||
})}
|
||||
placeholder="key"
|
||||
data-testid={`label-key-${index}`}
|
||||
defaultValue={field.key}
|
||||
/>
|
||||
</Field>
|
||||
@ -55,6 +56,7 @@ const LabelsField: FC<Props> = ({ className }) => {
|
||||
required: { value: !!labels[index]?.key, message: 'Required.' },
|
||||
})}
|
||||
placeholder="value"
|
||||
data-testid={`label-value-${index}`}
|
||||
defaultValue={field.value}
|
||||
/>
|
||||
</Field>
|
||||
|
@ -28,7 +28,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
const leftButtons: JSX.Element[] = [];
|
||||
const rightButtons: JSX.Element[] = [];
|
||||
|
||||
const { isEditable } = useIsRuleEditable(rulerRule);
|
||||
const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule);
|
||||
const returnTo = location.pathname + location.search;
|
||||
const isViewMode = inViewMode(location.pathname);
|
||||
|
||||
|
@ -2,17 +2,29 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { isGrafanaRulerRule } from '../utils/rules';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { useFolder } from './useFolder';
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useEffect } from 'react';
|
||||
import { checkIfLotexSupportsEditingRulesAction } from '../state/actions';
|
||||
|
||||
interface ResultBag {
|
||||
isEditable?: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useIsRuleEditable(rule?: RulerRuleDTO): ResultBag {
|
||||
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
|
||||
const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing);
|
||||
const dispatch = useDispatch();
|
||||
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
|
||||
|
||||
const { folder, loading } = useFolder(folderUID);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkEditingRequests[rulesSourceName] === undefined) {
|
||||
dispatch(checkIfLotexSupportsEditingRulesAction(rulesSourceName));
|
||||
}
|
||||
}, [rulesSourceName, checkEditingRequests, dispatch]);
|
||||
|
||||
if (!rule) {
|
||||
return { isEditable: false, loading: false };
|
||||
}
|
||||
@ -30,9 +42,9 @@ export function useIsRuleEditable(rule?: RulerRuleDTO): ResultBag {
|
||||
};
|
||||
}
|
||||
|
||||
// prom rules are only editable by users with Editor role
|
||||
// prom rules are only editable by users with Editor role and only if rules source supports editing
|
||||
return {
|
||||
isEditable: contextSrv.isEditor,
|
||||
loading: false,
|
||||
isEditable: contextSrv.isEditor && !!checkEditingRequests[rulesSourceName]?.result,
|
||||
loading: !!checkEditingRequests[rulesSourceName]?.loading,
|
||||
};
|
||||
}
|
||||
|
@ -1,24 +1,22 @@
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { fetchRulerRulesIfNotFetchedYet } from '../state/actions';
|
||||
import { getAllDataSources } from '../utils/config';
|
||||
import { DataSourceType, getRulesDataSources } from '../utils/datasource';
|
||||
import { checkIfLotexSupportsEditingRulesAction } from '../state/actions';
|
||||
import { getRulesDataSources } from '../utils/datasource';
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] {
|
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// try fetching rules for each prometheus to see if it has ruler
|
||||
useEffect(() => {
|
||||
getAllDataSources()
|
||||
.filter((ds) => ds.type === DataSourceType.Prometheus)
|
||||
.forEach((ds) => dispatch(fetchRulerRulesIfNotFetchedYet(ds.name)));
|
||||
}, [dispatch]);
|
||||
getRulesDataSources()
|
||||
.filter((ds) => checkEditingRequests[ds.name] === undefined)
|
||||
.forEach((ds) => dispatch(checkIfLotexSupportsEditingRulesAction(ds.name)));
|
||||
}, [dispatch, checkEditingRequests]);
|
||||
|
||||
return useMemo(
|
||||
() => getRulesDataSources().filter((ds) => ds.type === DataSourceType.Loki || !!rulerRequests[ds.name]?.result),
|
||||
[rulerRequests]
|
||||
);
|
||||
return useMemo(() => getRulesDataSources().filter((ds) => checkEditingRequests[ds.name]?.result), [
|
||||
checkEditingRequests,
|
||||
]);
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ import {
|
||||
|
||||
let nextDataSourceId = 1;
|
||||
|
||||
export const mockDataSource = (partial: Partial<DataSourceInstanceSettings> = {}): DataSourceInstanceSettings => {
|
||||
export const mockDataSource = (
|
||||
partial: Partial<DataSourceInstanceSettings> = {},
|
||||
meta: Partial<DataSourcePluginMeta> = {}
|
||||
): DataSourceInstanceSettings => {
|
||||
const id = partial.id ?? nextDataSourceId++;
|
||||
|
||||
return {
|
||||
@ -30,6 +33,7 @@ export const mockDataSource = (partial: Partial<DataSourceInstanceSettings> = {}
|
||||
large: 'https://prometheus.io/assets/prometheus_logo_grey.svg',
|
||||
},
|
||||
},
|
||||
...meta,
|
||||
} as any) as DataSourcePluginMeta,
|
||||
...partial,
|
||||
};
|
||||
@ -140,6 +144,7 @@ export const mockAlertGroup = (partial: Partial<AlertmanagerGroup> = {}): Alertm
|
||||
};
|
||||
|
||||
export class MockDataSourceSrv implements DataSourceSrv {
|
||||
datasources: Record<string, DataSourceApi> = {};
|
||||
// @ts-ignore
|
||||
private settingsMapByName: Record<string, DataSourceInstanceSettings> = {};
|
||||
private settingsMapByUid: Record<string, DataSourceInstanceSettings> = {};
|
||||
@ -149,8 +154,10 @@ export class MockDataSourceSrv implements DataSourceSrv {
|
||||
getVariables: () => [],
|
||||
replace: (name: any) => name,
|
||||
};
|
||||
defaultName = '';
|
||||
|
||||
constructor(datasources: Record<string, DataSourceInstanceSettings>) {
|
||||
this.datasources = {};
|
||||
this.settingsMapByName = Object.values(datasources).reduce<Record<string, DataSourceInstanceSettings>>(
|
||||
(acc, ds) => {
|
||||
acc[ds.name] = ds;
|
||||
@ -161,11 +168,15 @@ export class MockDataSourceSrv implements DataSourceSrv {
|
||||
for (const dsSettings of Object.values(this.settingsMapByName)) {
|
||||
this.settingsMapByUid[dsSettings.uid] = dsSettings;
|
||||
this.settingsMapById[dsSettings.id] = dsSettings;
|
||||
if (dsSettings.isDefault) {
|
||||
this.defaultName = dsSettings.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
|
||||
return Promise.reject(new Error('not implemented'));
|
||||
return DatasourceSrv.prototype.get.call(this, name, scopedVars);
|
||||
//return Promise.reject(new Error('not implemented'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -184,6 +195,10 @@ export class MockDataSourceSrv implements DataSourceSrv {
|
||||
(({ meta: { info: { logos: {} } } } as unknown) as DataSourceInstanceSettings)
|
||||
);
|
||||
}
|
||||
|
||||
async loadDatasource(name: string): Promise<DataSourceApi<any, any>> {
|
||||
return DatasourceSrv.prototype.loadDatasource.call(this, name);
|
||||
}
|
||||
}
|
||||
|
||||
export const mockGrafanaReceiver = (
|
||||
|
@ -37,7 +37,7 @@ import {
|
||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { makeAMLink } from '../utils/misc';
|
||||
import { withAppEvents, withSerializedError } from '../utils/redux';
|
||||
import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
|
||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||
import {
|
||||
isCloudRuleIdentifier,
|
||||
@ -543,3 +543,29 @@ export const fetchAlertGroupsAction = createAsyncThunk(
|
||||
return withSerializedError(fetchAlertGroups(alertManagerSourceName));
|
||||
}
|
||||
);
|
||||
|
||||
export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk(
|
||||
'unifiedalerting/checkIfLotexRuleEditingSupported',
|
||||
async (rulesSourceName: string): Promise<boolean> =>
|
||||
withAppEvents(
|
||||
(async () => {
|
||||
try {
|
||||
await fetchRulerRulesGroup(rulesSourceName, 'test', 'test');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (
|
||||
(isFetchError(e) &&
|
||||
(e.data.message?.includes('GetRuleGroup unsupported in rule local store') || // "local" rule storage
|
||||
e.data.message?.includes('page not found'))) || // ruler api disabled
|
||||
e.message?.includes('404 from rules config endpoint') // ruler api disabled
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})(),
|
||||
{
|
||||
errorMessage: `Failed to determine if "${rulesSourceName}" allows editing rules`,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
createOrUpdateSilenceAction,
|
||||
fetchFolderAction,
|
||||
fetchAlertGroupsAction,
|
||||
checkIfLotexSupportsEditingRulesAction,
|
||||
} from './actions';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
@ -40,6 +41,11 @@ export const reducer = combineReducers({
|
||||
fetchAlertGroupsAction,
|
||||
(alertManagerSourceName) => alertManagerSourceName
|
||||
).reducer,
|
||||
lotexSupportsRuleEditing: createAsyncMapSlice(
|
||||
'lotexSupportsRuleEditing',
|
||||
checkIfLotexSupportsEditingRulesAction,
|
||||
(source) => source
|
||||
).reducer,
|
||||
});
|
||||
|
||||
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
||||
|
@ -126,7 +126,7 @@ export function withAppEvents<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function isFetchError(e: unknown): e is FetchError {
|
||||
export function isFetchError(e: unknown): e is FetchError {
|
||||
return typeof e === 'object' && e !== null && 'status' in e && 'data' in e;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CombinedRule, Rule, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import {
|
||||
isAlertingRule,
|
||||
isAlertingRulerRule,
|
||||
@ -211,3 +212,7 @@ function hashRule(rule: Rule): number {
|
||||
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
|
||||
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
|
||||
}
|
||||
|
||||
export function ruleIdentifierToRuleSourceName(identifier: RuleIdentifier): string {
|
||||
return isGrafanaRuleIdentifier(identifier) ? GRAFANA_RULES_SOURCE_NAME : identifier.ruleSourceName;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user