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:
Domas 2021-07-13 00:10:13 +03:00 committed by GitHub
parent d4e53a9be4
commit 3ea8880d7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 481 additions and 100 deletions

View 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));
};

View File

@ -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) {

View File

@ -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();
}

View File

@ -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')

View File

@ -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 } }) => (

View File

@ -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`}

View File

@ -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} />;
}

View File

@ -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(() => {

View File

@ -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} />

View File

@ -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>

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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,
]);
}

View File

@ -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 = (

View File

@ -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`,
}
)
);

View File

@ -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>;

View File

@ -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;
}

View File

@ -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;
}