mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: grafana managed group names (#47785)
This commit is contained in:
parent
c1490a464a
commit
be3f52abb1
@ -1,4 +1,4 @@
|
|||||||
import { Matcher, render, waitFor } from '@testing-library/react';
|
import { Matcher, render, waitFor, screen } from '@testing-library/react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BackendSrv, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
import { BackendSrv, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
@ -249,6 +249,9 @@ describe('RuleEditor', () => {
|
|||||||
const folderInput = await ui.inputs.folder.find();
|
const folderInput = await ui.inputs.folder.find();
|
||||||
await clickSelectOption(folderInput, 'Folder A');
|
await clickSelectOption(folderInput, 'Folder A');
|
||||||
|
|
||||||
|
const groupInput = screen.getByRole('textbox', { name: /^Group/ });
|
||||||
|
userEvent.type(groupInput, 'my group');
|
||||||
|
|
||||||
userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
|
userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
|
||||||
userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||||
|
|
||||||
@ -268,7 +271,7 @@ describe('RuleEditor', () => {
|
|||||||
'Folder A',
|
'Folder A',
|
||||||
{
|
{
|
||||||
interval: '1m',
|
interval: '1m',
|
||||||
name: 'my great new rule',
|
name: 'my group',
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
annotations: { description: 'some description', summary: 'some summary' },
|
annotations: { description: 'some description', summary: 'some summary' },
|
||||||
|
@ -192,10 +192,10 @@ describe('RuleList', () => {
|
|||||||
expect(groups).toHaveLength(5);
|
expect(groups).toHaveLength(5);
|
||||||
|
|
||||||
expect(groups[0]).toHaveTextContent('foofolder');
|
expect(groups[0]).toHaveTextContent('foofolder');
|
||||||
expect(groups[1]).toHaveTextContent('default > group-1');
|
expect(groups[1]).toHaveTextContent('default group-1');
|
||||||
expect(groups[2]).toHaveTextContent('default > group-1');
|
expect(groups[2]).toHaveTextContent('default group-1');
|
||||||
expect(groups[3]).toHaveTextContent('default > group-2');
|
expect(groups[3]).toHaveTextContent('default group-2');
|
||||||
expect(groups[4]).toHaveTextContent('lokins > group-1');
|
expect(groups[4]).toHaveTextContent('lokins group-1');
|
||||||
|
|
||||||
const errors = await ui.cloudRulesSourceErrors.find();
|
const errors = await ui.cloudRulesSourceErrors.find();
|
||||||
|
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
import { Icon } from '@grafana/ui';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
|
interface RuleLocationProps {
|
||||||
|
namespace: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleLocation: FC<RuleLocationProps> = ({ namespace, group }) => {
|
||||||
|
if (!group) {
|
||||||
|
return <>{namespace}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{namespace} <Icon name="angle-right" /> {group}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { RuleLocation };
|
@ -1,6 +1,6 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Field, Input, InputControl, useStyles2 } from '@grafana/ui';
|
import { Field, Icon, Input, InputControl, Label, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { RuleEditorSection } from './RuleEditorSection';
|
import { RuleEditorSection } from './RuleEditorSection';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
@ -12,6 +12,7 @@ import { checkForPathSeparator } from './util';
|
|||||||
import { RuleTypePicker } from './rule-types/RuleTypePicker';
|
import { RuleTypePicker } from './rule-types/RuleTypePicker';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction } from 'app/types';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editingExistingRule: boolean;
|
editingExistingRule: boolean;
|
||||||
@ -120,26 +121,60 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
|||||||
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
|
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
|
||||||
|
|
||||||
{ruleFormType === RuleFormType.grafana && (
|
{ruleFormType === RuleFormType.grafana && (
|
||||||
<Field
|
<div className={styles.flexRow}>
|
||||||
label="Folder"
|
<Field
|
||||||
className={styles.formInput}
|
label={
|
||||||
error={errors.folder?.message}
|
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
|
||||||
invalid={!!errors.folder?.message}
|
<Stack gap={0.5}>
|
||||||
data-testid="folder-picker"
|
Folder
|
||||||
>
|
<Tooltip
|
||||||
<InputControl
|
placement="top"
|
||||||
render={({ field: { ref, ...field } }) => (
|
content={
|
||||||
<RuleFolderPicker {...field} enableCreateNew={true} enableReset={true} />
|
<div>
|
||||||
)}
|
Each folder has unique folder permission. When you store multiple rules in a folder, the folder
|
||||||
name="folder"
|
access permissions get assigned to the rules.
|
||||||
rules={{
|
</div>
|
||||||
required: { value: true, message: 'Please select a folder' },
|
}
|
||||||
validate: {
|
>
|
||||||
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
|
<Icon name="info-circle" size="xs" />
|
||||||
},
|
</Tooltip>
|
||||||
}}
|
</Stack>
|
||||||
/>
|
</Label>
|
||||||
</Field>
|
}
|
||||||
|
className={styles.formInput}
|
||||||
|
error={errors.folder?.message}
|
||||||
|
invalid={!!errors.folder?.message}
|
||||||
|
data-testid="folder-picker"
|
||||||
|
>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { ref, ...field } }) => (
|
||||||
|
<RuleFolderPicker inputId="folder" {...field} enableCreateNew={true} enableReset={true} />
|
||||||
|
)}
|
||||||
|
name="folder"
|
||||||
|
rules={{
|
||||||
|
required: { value: true, message: 'Please select a folder' },
|
||||||
|
validate: {
|
||||||
|
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Group"
|
||||||
|
data-testid="group-picker"
|
||||||
|
description="Rules within the same group are evaluated after the same time interval."
|
||||||
|
className={styles.formInput}
|
||||||
|
error={errors.group?.message}
|
||||||
|
invalid={!!errors.group?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="group"
|
||||||
|
{...register('group', {
|
||||||
|
required: { value: true, message: 'Must enter a group name' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</RuleEditorSection>
|
</RuleEditorSection>
|
||||||
);
|
);
|
||||||
@ -172,5 +207,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
align-items: flex-end;
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -67,10 +67,8 @@ export const GrafanaConditionsStep: FC = () => {
|
|||||||
Evaluate every
|
Evaluate every
|
||||||
</InlineLabel>
|
</InlineLabel>
|
||||||
<Field
|
<Field
|
||||||
className={styles.inlineField}
|
label="Evaluate"
|
||||||
error={errors.evaluateEvery?.message}
|
description="Evaluation internal applies to every rule within a group. It can overwrite the interval of an existing alert rule."
|
||||||
invalid={!!errors.evaluateEvery?.message}
|
|
||||||
validationMessageHorizontalOverflow={true}
|
|
||||||
>
|
>
|
||||||
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
|
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
@ -7,6 +7,8 @@ import { RulesGroup } from './RulesGroup';
|
|||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||||
import { initialAsyncRequestState } from '../../utils/redux';
|
import { initialAsyncRequestState } from '../../utils/redux';
|
||||||
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
|
import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
namespaces: CombinedRuleNamespace[];
|
namespaces: CombinedRuleNamespace[];
|
||||||
@ -15,10 +17,15 @@ interface Props {
|
|||||||
|
|
||||||
export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
|
export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
|
const [queryParams] = useQueryParams();
|
||||||
|
|
||||||
const { loading } = useUnifiedAlertingSelector(
|
const { loading } = useUnifiedAlertingSelector(
|
||||||
(state) => state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState
|
(state) => state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wantsGroupedView = queryParams['view'] === 'grouped';
|
||||||
|
const namespacesFormat = wantsGroupedView ? namespaces : flattenGrafanaManagedRules(namespaces);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.wrapper}>
|
<section className={styles.wrapper}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
@ -26,7 +33,7 @@ export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
|
|||||||
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />}
|
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{namespaces?.map((namespace) =>
|
{namespacesFormat?.map((namespace) =>
|
||||||
namespace.groups.map((group) => (
|
namespace.groups.map((group) => (
|
||||||
<RulesGroup
|
<RulesGroup
|
||||||
group={group}
|
group={group}
|
||||||
@ -36,7 +43,7 @@ export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{namespaces?.length === 0 && <p>No rules found.</p>}
|
{namespacesFormat?.length === 0 && <p>No rules found.</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
@ -5,6 +6,7 @@ import { AccessControlAction } from 'app/types';
|
|||||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
import { Router } from 'react-router-dom';
|
||||||
import { byRole } from 'testing-library-selector';
|
import { byRole } from 'testing-library-selector';
|
||||||
import { mockCombinedRule, mockDataSource } from '../../mocks';
|
import { mockCombinedRule, mockDataSource } from '../../mocks';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
@ -76,7 +78,9 @@ function renderRuleList(namespaces: CombinedRuleNamespace[]) {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<RuleListGroupView namespaces={namespaces} expandAll />
|
<Router history={locationService.getHistory()}>
|
||||||
|
<RuleListGroupView namespaces={namespaces} expandAll />
|
||||||
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,15 @@ import { alertStateToReadable } from '../../utils/rules';
|
|||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
|
|
||||||
const ViewOptions: SelectableValue[] = [
|
const ViewOptions: SelectableValue[] = [
|
||||||
|
{
|
||||||
|
icon: 'list-ul',
|
||||||
|
label: 'List',
|
||||||
|
value: 'list',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: 'folder',
|
icon: 'folder',
|
||||||
label: 'Groups',
|
label: 'Grouped',
|
||||||
value: 'group',
|
value: 'grouped',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'heart-rate',
|
icon: 'heart-rate',
|
||||||
@ -147,7 +152,7 @@ const RulesFilter = () => {
|
|||||||
<Label>View as</Label>
|
<Label>View as</Label>
|
||||||
<RadioButtonGroup
|
<RadioButtonGroup
|
||||||
options={ViewOptions}
|
options={ViewOptions}
|
||||||
value={String(queryParams['view'] || 'group')}
|
value={String(queryParams['view'] ?? ViewOptions[0].value)}
|
||||||
onChange={handleViewChange}
|
onChange={handleViewChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,6 +14,7 @@ import { deleteRulesGroupAction } from '../../state/actions';
|
|||||||
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
|
||||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||||
import { CollapseToggle } from '../CollapseToggle';
|
import { CollapseToggle } from '../CollapseToggle';
|
||||||
|
import { RuleLocation } from '../RuleLocation';
|
||||||
import { ActionIcon } from './ActionIcon';
|
import { ActionIcon } from './ActionIcon';
|
||||||
import { EditCloudGroupModal } from './EditCloudGroupModal';
|
import { EditCloudGroupModal } from './EditCloudGroupModal';
|
||||||
import { RulesTable } from './RulesTable';
|
import { RulesTable } from './RulesTable';
|
||||||
@ -118,7 +119,13 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
|
// ungrouped rules are rules that are in the "default" group name
|
||||||
|
const isUngrouped = group.name === 'default';
|
||||||
|
const groupName = isUngrouped ? (
|
||||||
|
<RuleLocation namespace={namespace.name} />
|
||||||
|
) : (
|
||||||
|
<RuleLocation namespace={namespace.name} group={group.name} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} data-testid="rule-group">
|
<div className={styles.wrapper} data-testid="rule-group">
|
||||||
|
@ -3,7 +3,6 @@ import { useStyles2 } from '@grafana/ui';
|
|||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { RuleDetails } from './RuleDetails';
|
import { RuleDetails } from './RuleDetails';
|
||||||
import { isCloudRulesSource } from '../../utils/datasource';
|
|
||||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||||
import { CombinedRule } from 'app/types/unified-alerting';
|
import { CombinedRule } from 'app/types/unified-alerting';
|
||||||
import { Annotation } from '../../utils/constants';
|
import { Annotation } from '../../utils/constants';
|
||||||
@ -11,6 +10,7 @@ import { RuleState } from './RuleState';
|
|||||||
import { RuleHealth } from './RuleHealth';
|
import { RuleHealth } from './RuleHealth';
|
||||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||||
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
||||||
|
import { RuleLocation } from '../RuleLocation';
|
||||||
|
|
||||||
type RuleTableColumnProps = DynamicTableColumnProps<CombinedRule>;
|
type RuleTableColumnProps = DynamicTableColumnProps<CombinedRule>;
|
||||||
type RuleTableItemProps = DynamicTableItemProps<CombinedRule>;
|
type RuleTableItemProps = DynamicTableItemProps<CombinedRule>;
|
||||||
@ -137,8 +137,15 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
|||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
renderCell: ({ data: rule }) => {
|
renderCell: ({ data: rule }) => {
|
||||||
const { namespace, group } = rule;
|
const { namespace, group } = rule;
|
||||||
const { rulesSource } = namespace;
|
// ungrouped rules are rules that are in the "default" group name
|
||||||
return isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
|
const isUngrouped = group.name === 'default';
|
||||||
|
const groupName = isUngrouped ? (
|
||||||
|
<RuleLocation namespace={namespace.name} />
|
||||||
|
) : (
|
||||||
|
<RuleLocation namespace={namespace.name} group={group.name} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return groupName;
|
||||||
},
|
},
|
||||||
size: 5,
|
size: 5,
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||||
|
import { sortRulesByName, flattenGrafanaManagedRules } from './useCombinedRuleNamespaces';
|
||||||
|
|
||||||
|
describe('flattenGrafanaManagedRules', () => {
|
||||||
|
it('should properly transform grafana managed namespaces', () => {
|
||||||
|
// the rules from both ungrouped groups should go in the default group
|
||||||
|
const ungroupedGroup1 = {
|
||||||
|
name: 'my-rule',
|
||||||
|
rules: [{ name: 'my-rule' }],
|
||||||
|
} as CombinedRuleGroup;
|
||||||
|
|
||||||
|
const ungroupedGroup2 = {
|
||||||
|
name: 'another-rule',
|
||||||
|
rules: [{ name: 'another-rule' }],
|
||||||
|
} as CombinedRuleGroup;
|
||||||
|
|
||||||
|
// the rules from both these groups should go in their own group name
|
||||||
|
const group1 = {
|
||||||
|
name: 'group1',
|
||||||
|
rules: [{ name: 'rule-1' }, { name: 'rule-2' }],
|
||||||
|
} as CombinedRuleGroup;
|
||||||
|
|
||||||
|
const group2 = {
|
||||||
|
name: 'group2',
|
||||||
|
rules: [{ name: 'rule-1' }, { name: 'rule-2' }],
|
||||||
|
} as CombinedRuleGroup;
|
||||||
|
|
||||||
|
const namespace1 = {
|
||||||
|
rulesSource: 'grafana',
|
||||||
|
name: 'ns1',
|
||||||
|
groups: [ungroupedGroup1, ungroupedGroup2, group1, group2],
|
||||||
|
};
|
||||||
|
|
||||||
|
const namespace2 = {
|
||||||
|
rulesSource: 'grafana',
|
||||||
|
name: 'ns2',
|
||||||
|
groups: [ungroupedGroup1],
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = [namespace1, namespace2] as CombinedRuleNamespace[];
|
||||||
|
const [ns1, ns2] = flattenGrafanaManagedRules(input);
|
||||||
|
|
||||||
|
expect(ns1.groups).toEqual([
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
rules: sortRulesByName([...ungroupedGroup1.rules, ...ungroupedGroup2.rules, ...group1.rules, ...group2.rules]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ns2.groups).toEqual([
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
rules: ungroupedGroup1.rules,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -81,17 +81,7 @@ export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRul
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = Object.values(namespaces);
|
const result = Object.values(namespaces);
|
||||||
if (isGrafanaRulesSource(rulesSource)) {
|
|
||||||
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as gorups
|
|
||||||
result.forEach((namespace) => {
|
|
||||||
namespace.groups = [
|
|
||||||
{
|
|
||||||
name: 'default',
|
|
||||||
rules: namespace.groups.flatMap((g) => g.rules).sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cache.current[rulesSourceName] = { promRules, rulerRules, result };
|
cache.current[rulesSourceName] = { promRules, rulerRules, result };
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
@ -100,6 +90,28 @@ export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRul
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups
|
||||||
|
export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) {
|
||||||
|
return namespaces.map((namespace) => {
|
||||||
|
const newNamespace: CombinedRuleNamespace = {
|
||||||
|
...namespace,
|
||||||
|
groups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// add default group with ungrouped rules
|
||||||
|
newNamespace.groups.push({
|
||||||
|
name: 'default',
|
||||||
|
rules: sortRulesByName(namespace.groups.flatMap((group) => group.rules)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return newNamespace;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortRulesByName(rules: CombinedRule[]) {
|
||||||
|
return rules.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[]): void {
|
function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[]): void {
|
||||||
namespace.groups = groups.map((group) => {
|
namespace.groups = groups.map((group) => {
|
||||||
const combinedGroup: CombinedRuleGroup = {
|
const combinedGroup: CombinedRuleGroup = {
|
||||||
|
@ -390,8 +390,14 @@ export const saveRuleFormAction = createAsyncThunk(
|
|||||||
if (redirectOnSave) {
|
if (redirectOnSave) {
|
||||||
locationService.push(redirectOnSave);
|
locationService.push(redirectOnSave);
|
||||||
} else {
|
} else {
|
||||||
|
// if the identifier comes up empty (this happens when Grafana managed rule moves to another namespace or group)
|
||||||
|
const stringifiedIdentifier = ruleId.stringifyIdentifier(identifier);
|
||||||
|
if (!stringifiedIdentifier) {
|
||||||
|
locationService.push('/alerting/list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// redirect to edit page
|
// redirect to edit page
|
||||||
const newLocation = `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`;
|
const newLocation = `/alerting/${encodeURIComponent(stringifiedIdentifier)}/edit`;
|
||||||
if (locationService.getLocation().pathname !== newLocation) {
|
if (locationService.getLocation().pathname !== newLocation) {
|
||||||
locationService.replace(newLocation);
|
locationService.replace(newLocation);
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ export interface RuleFormValues {
|
|||||||
name: string;
|
name: string;
|
||||||
type?: RuleFormType;
|
type?: RuleFormType;
|
||||||
dataSourceName: string | null;
|
dataSourceName: string | null;
|
||||||
|
group: string;
|
||||||
|
|
||||||
labels: Array<{ key: string; value: string }>;
|
labels: Array<{ key: string; value: string }>;
|
||||||
annotations: Array<{ key: string; value: string }>;
|
annotations: Array<{ key: string; value: string }>;
|
||||||
@ -26,7 +27,6 @@ export interface RuleFormValues {
|
|||||||
|
|
||||||
// cortex / loki rules
|
// cortex / loki rules
|
||||||
namespace: string;
|
namespace: string;
|
||||||
group: string;
|
|
||||||
forTime: number;
|
forTime: number;
|
||||||
forTimeUnit: string;
|
forTimeUnit: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
|
@ -45,6 +45,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
|||||||
],
|
],
|
||||||
dataSourceName: null,
|
dataSourceName: null,
|
||||||
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
||||||
|
group: '',
|
||||||
|
|
||||||
// grafana
|
// grafana
|
||||||
folder: null,
|
folder: null,
|
||||||
@ -56,7 +57,6 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
|||||||
evaluateFor: '5m',
|
evaluateFor: '5m',
|
||||||
|
|
||||||
// cortex / loki
|
// cortex / loki
|
||||||
group: '',
|
|
||||||
namespace: '',
|
namespace: '',
|
||||||
expression: '',
|
expression: '',
|
||||||
forTime: 1,
|
forTime: 1,
|
||||||
@ -118,6 +118,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
|||||||
...defaultFormValues,
|
...defaultFormValues,
|
||||||
name: ga.title,
|
name: ga.title,
|
||||||
type: RuleFormType.grafana,
|
type: RuleFormType.grafana,
|
||||||
|
group: group.name,
|
||||||
evaluateFor: rule.for || '0',
|
evaluateFor: rule.for || '0',
|
||||||
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
||||||
noDataState: ga.no_data_state,
|
noDataState: ga.no_data_state,
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
|
||||||
import { getUniqueGroupName } from './rulerClient';
|
|
||||||
|
|
||||||
describe('getUniqueGroupName', () => {
|
|
||||||
it('Should return the original value when there are no duplicates', () => {
|
|
||||||
// Arrange
|
|
||||||
const originalGroupName = 'file-system-out-of-space';
|
|
||||||
const existingGroups: RulerRuleGroupDTO[] = [];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const groupName = getUniqueGroupName(originalGroupName, existingGroups);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(groupName).toBe(originalGroupName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Should increment suffix counter until a unique name created', () => {
|
|
||||||
// Arrange
|
|
||||||
const originalGroupName = 'file-system-out-of-space';
|
|
||||||
const existingGroups: RulerRuleGroupDTO[] = [
|
|
||||||
{ name: 'file-system-out-of-space', rules: [] },
|
|
||||||
{ name: 'file-system-out-of-space-2', rules: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const groupName = getUniqueGroupName(originalGroupName, existingGroups);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(groupName).toBe('file-system-out-of-space-3');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,15 +1,14 @@
|
|||||||
import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting';
|
import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting';
|
||||||
import { PostableRulerRuleGroupDTO, RulerGrafanaRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
|
||||||
import {
|
import {
|
||||||
deleteRulerRulesGroup,
|
PostableRuleGrafanaRuleDTO,
|
||||||
fetchRulerRulesGroup,
|
PostableRulerRuleGroupDTO,
|
||||||
fetchRulerRulesNamespace,
|
RulerGrafanaRuleDTO,
|
||||||
fetchRulerRules,
|
RulerRuleGroupDTO,
|
||||||
setRulerRuleGroup,
|
} from 'app/types/unified-alerting-dto';
|
||||||
} from '../api/ruler';
|
import { deleteRulerRulesGroup, fetchRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
|
||||||
import { RuleFormValues } from '../types/rule-form';
|
import { RuleFormValues } from '../types/rule-form';
|
||||||
import * as ruleId from '../utils/rule-id';
|
import * as ruleId from '../utils/rule-id';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||||
import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO } from './rule-form';
|
import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO } from './rule-form';
|
||||||
import {
|
import {
|
||||||
isCloudRuleIdentifier,
|
isCloudRuleIdentifier,
|
||||||
@ -25,16 +24,6 @@ export interface RulerClient {
|
|||||||
saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUniqueGroupName(currentGroupName: string, existingGroups: RulerRuleGroupDTO[]) {
|
|
||||||
let newGroupName = currentGroupName;
|
|
||||||
let idx = 1;
|
|
||||||
while (!!existingGroups.find((g) => g.name === newGroupName)) {
|
|
||||||
newGroupName = `${currentGroupName}-${++idx}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newGroupName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
|
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
|
||||||
const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> => {
|
const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> => {
|
||||||
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
|
||||||
@ -90,13 +79,8 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise<void> => {
|
const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise<void> => {
|
||||||
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
|
const { namespace, group, rule } = ruleWithLocation;
|
||||||
// in case of GRAFANA, each group implicitly only has one rule. delete the group.
|
|
||||||
if (isGrafanaRulesSource(ruleSourceName)) {
|
|
||||||
await deleteRulerRulesGroup(rulerConfig, namespace, group.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// in case of CLOUD
|
|
||||||
// it was the last rule, delete the entire group
|
// it was the last rule, delete the entire group
|
||||||
if (group.rules.length === 1) {
|
if (group.rules.length === 1) {
|
||||||
await deleteRulerRulesGroup(rulerConfig, namespace, group.name);
|
await deleteRulerRulesGroup(rulerConfig, namespace, group.name);
|
||||||
@ -157,65 +141,104 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveGrafanaRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> => {
|
const saveGrafanaRule = async (values: RuleFormValues, existingRule?: RuleWithLocation): Promise<RuleIdentifier> => {
|
||||||
const { folder, evaluateEvery } = values;
|
const { folder, group, evaluateEvery } = values;
|
||||||
const formRule = formValuesToRulerGrafanaRuleDTO(values);
|
|
||||||
|
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
throw new Error('Folder must be specified');
|
throw new Error('Folder must be specified');
|
||||||
}
|
}
|
||||||
|
|
||||||
// updating an existing rule...
|
const newRule = formValuesToRulerGrafanaRuleDTO(values);
|
||||||
if (existing) {
|
const namespace = folder.title;
|
||||||
// refetch it to be sure we have the latest
|
const groupSpec = { name: group, interval: evaluateEvery };
|
||||||
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
|
|
||||||
if (!freshExisting) {
|
|
||||||
throw new Error('Rule not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if same folder, repost the group with updated rule
|
if (!existingRule) {
|
||||||
if (freshExisting.namespace === folder.title) {
|
return addRuleToNamespaceAndGroup(namespace, groupSpec, newRule);
|
||||||
const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!;
|
|
||||||
formRule.grafana_alert.uid = uid;
|
|
||||||
await setRulerRuleGroup(rulerConfig, freshExisting.namespace, {
|
|
||||||
name: freshExisting.group.name,
|
|
||||||
interval: evaluateEvery,
|
|
||||||
rules: [formRule],
|
|
||||||
});
|
|
||||||
return { uid, ruleSourceName: 'grafana' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if creating new rule or folder was changed, create rule in a new group
|
const sameNamespace = existingRule.namespace === namespace;
|
||||||
const targetFolderGroups = await fetchRulerRulesNamespace(rulerConfig, folder.title);
|
const sameGroup = existingRule.group.name === values.group;
|
||||||
|
const sameLocation = sameNamespace && sameGroup;
|
||||||
|
|
||||||
// set group name to rule name, but be super paranoid and check that this group does not already exist
|
if (sameLocation) {
|
||||||
const groupName = getUniqueGroupName(values.name, targetFolderGroups);
|
// we're update a rule in the same namespace and group
|
||||||
formRule.grafana_alert.title = groupName;
|
return updateGrafanaRule(existingRule, newRule, evaluateEvery);
|
||||||
|
} else {
|
||||||
|
// we're moving a rule to either a different group or namespace
|
||||||
|
return moveGrafanaRule(namespace, groupSpec, existingRule, newRule);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRuleToNamespaceAndGroup = async (
|
||||||
|
namespace: string,
|
||||||
|
group: { name: string; interval: string },
|
||||||
|
newRule: PostableRuleGrafanaRuleDTO
|
||||||
|
): Promise<RuleIdentifier> => {
|
||||||
|
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group.name);
|
||||||
|
if (!existingGroup) {
|
||||||
|
throw new Error(`No group found with name "${group.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const payload: PostableRulerRuleGroupDTO = {
|
const payload: PostableRulerRuleGroupDTO = {
|
||||||
name: groupName,
|
name: group.name,
|
||||||
interval: evaluateEvery,
|
interval: group.interval,
|
||||||
rules: [formRule],
|
rules: (existingGroup.rules ?? []).concat(newRule as RulerGrafanaRuleDTO),
|
||||||
};
|
};
|
||||||
await setRulerRuleGroup(rulerConfig, folder.title, payload);
|
|
||||||
|
|
||||||
// now refetch this group to get the uid, hah
|
await setRulerRuleGroup(rulerConfig, namespace, payload);
|
||||||
const result = await fetchRulerRulesGroup(rulerConfig, folder.title, groupName);
|
|
||||||
const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid;
|
|
||||||
if (newUid) {
|
|
||||||
// if folder has changed, delete the old one
|
|
||||||
if (existing) {
|
|
||||||
const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
|
|
||||||
if (freshExisting && freshExisting.namespace !== folder.title) {
|
|
||||||
await deleteRule(freshExisting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { uid: newUid, ruleSourceName: 'grafana' };
|
return { uid: '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
|
||||||
} else {
|
};
|
||||||
throw new Error('Failed to fetch created rule.');
|
|
||||||
|
// we can't move the rule in a single atomic operation so we have to
|
||||||
|
// 1. add the rule to the new group
|
||||||
|
// 2. remove the rule from the old one
|
||||||
|
const moveGrafanaRule = async (
|
||||||
|
namespace: string,
|
||||||
|
group: { name: string; interval: string },
|
||||||
|
existingRule: RuleWithLocation,
|
||||||
|
newRule: PostableRuleGrafanaRuleDTO
|
||||||
|
): Promise<RuleIdentifier> => {
|
||||||
|
// add the new rule to the requested namespace and group
|
||||||
|
const identifier = await addRuleToNamespaceAndGroup(namespace, group, newRule);
|
||||||
|
|
||||||
|
// remove the rule from the previous namespace and group
|
||||||
|
await deleteRule({
|
||||||
|
ruleSourceName: existingRule.ruleSourceName,
|
||||||
|
namespace: existingRule.namespace,
|
||||||
|
group: existingRule.group,
|
||||||
|
rule: newRule as RulerGrafanaRuleDTO,
|
||||||
|
});
|
||||||
|
|
||||||
|
return identifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGrafanaRule = async (
|
||||||
|
existingRule: RuleWithLocation,
|
||||||
|
newRule: PostableRuleGrafanaRuleDTO,
|
||||||
|
interval: string
|
||||||
|
): Promise<RuleIdentifier> => {
|
||||||
|
// type guard to make sure we're working with a Grafana managed rule
|
||||||
|
if (!isGrafanaRulerRule(existingRule.rule)) {
|
||||||
|
throw new Error('The rule is not a Grafana managed rule');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure our updated alert has the same UID as before
|
||||||
|
const uid = existingRule.rule.grafana_alert.uid;
|
||||||
|
newRule.grafana_alert.uid = uid;
|
||||||
|
|
||||||
|
// create the new array of rules we want to send to the group
|
||||||
|
const newRules = existingRule.group.rules
|
||||||
|
.filter((rule): rule is RulerGrafanaRuleDTO => isGrafanaRulerRule(rule))
|
||||||
|
.filter((rule) => rule.grafana_alert.uid !== uid)
|
||||||
|
.concat(newRule as RulerGrafanaRuleDTO);
|
||||||
|
|
||||||
|
await setRulerRuleGroup(rulerConfig, existingRule.namespace, {
|
||||||
|
name: existingRule.group.name,
|
||||||
|
interval: interval,
|
||||||
|
rules: newRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { uid: '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Would be nice to somehow align checking of ruler type between different methods
|
// Would be nice to somehow align checking of ruler type between different methods
|
||||||
|
Loading…
Reference in New Issue
Block a user