Alerting: Fix editing group of nested folder (#81665)
This commit is contained in:
@@ -6,6 +6,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
decodeGrafanaNamespace,
|
decodeGrafanaNamespace,
|
||||||
|
encodeGrafanaNamespace,
|
||||||
formatLabels,
|
formatLabels,
|
||||||
getSeriesLabels,
|
getSeriesLabels,
|
||||||
getSeriesName,
|
getSeriesName,
|
||||||
@@ -58,7 +59,8 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'my_rule_namespace');
|
||||||
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work for Grafana namespaces in nested folders format', () => {
|
it('should work for Grafana namespaces in nested folders format', () => {
|
||||||
@@ -74,7 +76,8 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'my_rule_namespace');
|
||||||
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', ['parentUID']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should default to name if format is invalid: invalid JSON', () => {
|
it('should default to name if format is invalid: invalid JSON', () => {
|
||||||
@@ -90,7 +93,8 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`["parentUID"`);
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', `["parentUID"`);
|
||||||
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should default to name if format is invalid: empty array', () => {
|
it('should default to name if format is invalid: empty array', () => {
|
||||||
@@ -106,7 +110,8 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`[]`);
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', `[]`);
|
||||||
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grab folder name if format is long array', () => {
|
it('grab folder name if format is long array', () => {
|
||||||
@@ -122,7 +127,8 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('another_part');
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('name', 'another_part');
|
||||||
|
expect(decodeGrafanaNamespace(grafanaNamespace)).toHaveProperty('parents', ['parentUID', 'my_rule_namespace']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not change output for cloud namespaces', () => {
|
it('should not change output for cloud namespaces', () => {
|
||||||
@@ -138,7 +144,23 @@ describe('decodeGrafanaNamespace', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(decodeGrafanaNamespace(cloudNamespace)).toBe(`["parentUID","my_rule_namespace"]`);
|
expect(decodeGrafanaNamespace(cloudNamespace)).toHaveProperty('name', `["parentUID","my_rule_namespace"]`);
|
||||||
|
expect(decodeGrafanaNamespace(cloudNamespace)).toHaveProperty('parents', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeGrafanaNamespace', () => {
|
||||||
|
it('should encode with parents', () => {
|
||||||
|
const name = 'folder';
|
||||||
|
const parents = ['1', '2', '3'];
|
||||||
|
|
||||||
|
expect(encodeGrafanaNamespace(name, parents)).toBe(`["1","2","3","folder"]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encode without parents', () => {
|
||||||
|
const name = 'folder';
|
||||||
|
|
||||||
|
expect(encodeGrafanaNamespace(name)).toBe(`["folder"]`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { dropRight, last } from 'lodash';
|
||||||
|
|
||||||
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
|
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
|
||||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
@@ -40,29 +42,66 @@ const formatLabels = (labels: Labels): string => {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DecodedNamespace {
|
||||||
|
name: string;
|
||||||
|
parents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After https://github.com/grafana/grafana/pull/74600,
|
* After https://github.com/grafana/grafana/pull/74600,
|
||||||
* Grafana folder names will be returned from the API as a combination of the folder name and parent UID in a format of JSON array,
|
* Grafana folder names will be returned from the API as a combination of the folder name and parent UID in a format of JSON array,
|
||||||
* where first element is parent UID and the second element is Title.
|
* where first element is parent UID and the second element is Title.
|
||||||
|
*
|
||||||
|
* Here we parse this to return the name of the last folder and the array of parent folders
|
||||||
*/
|
*/
|
||||||
const decodeGrafanaNamespace = (namespace: CombinedRuleNamespace): string => {
|
const decodeGrafanaNamespace = (namespace: CombinedRuleNamespace): DecodedNamespace => {
|
||||||
const namespaceName = namespace.name;
|
const namespaceName = namespace.name;
|
||||||
|
|
||||||
if (isCloudRulesSource(namespace.rulesSource)) {
|
if (isCloudRulesSource(namespace.rulesSource)) {
|
||||||
return namespaceName;
|
return {
|
||||||
|
name: namespaceName,
|
||||||
|
parents: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try to parse the folder as a nested folder, if it fails fall back to returning the folder name as-is.
|
||||||
try {
|
try {
|
||||||
return JSON.parse(namespaceName).at(-1) ?? namespaceName;
|
const folderParts: string[] = JSON.parse(namespaceName);
|
||||||
|
if (!Array.isArray(folderParts)) {
|
||||||
|
throw new Error('not a nested Grafana folder');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = last(folderParts) ?? namespaceName;
|
||||||
|
const parents = dropRight(folderParts, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
parents,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return namespaceName;
|
return {
|
||||||
|
name: namespace.name,
|
||||||
|
parents: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodeGrafanaNamespace = (name: string, parents: string[] | undefined = []) => {
|
||||||
|
return JSON.stringify(parents.concat(name));
|
||||||
|
};
|
||||||
|
|
||||||
const isEmptySeries = (series: DataFrame[]): boolean => {
|
const isEmptySeries = (series: DataFrame[]): boolean => {
|
||||||
const isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null)));
|
const isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null)));
|
||||||
|
|
||||||
return isEmpty;
|
return isEmpty;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { decodeGrafanaNamespace, formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries };
|
export {
|
||||||
|
decodeGrafanaNamespace,
|
||||||
|
encodeGrafanaNamespace,
|
||||||
|
formatLabels,
|
||||||
|
getSeriesLabels,
|
||||||
|
getSeriesName,
|
||||||
|
getSeriesValue,
|
||||||
|
isEmptySeries,
|
||||||
|
};
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
|||||||
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
|
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
|
||||||
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
|
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
|
||||||
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
|
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
|
||||||
{decodeGrafanaNamespace(rule.namespace)} / {rule.group.name}
|
{decodeGrafanaNamespace(rule.namespace).name} / {rule.group.name}
|
||||||
</DetailsField>
|
</DetailsField>
|
||||||
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
|
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function usePageNav(rule: CombinedRule) {
|
|||||||
const isAlertType = isAlertingRule(promRule);
|
const isAlertType = isAlertingRule(promRule);
|
||||||
const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined;
|
const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined;
|
||||||
|
|
||||||
const namespaceName = decodeGrafanaNamespace(rule.namespace);
|
const namespaceName = decodeGrafanaNamespace(rule.namespace).name;
|
||||||
const groupName = rule.group.name;
|
const groupName = rule.group.name;
|
||||||
|
|
||||||
const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType;
|
const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType;
|
||||||
@@ -309,6 +309,7 @@ function usePageNav(rule: CombinedRule) {
|
|||||||
['namespace', namespaceName],
|
['namespace', namespaceName],
|
||||||
['group', groupName],
|
['group', groupName],
|
||||||
]),
|
]),
|
||||||
|
// @TODO support nested folders here
|
||||||
parentItem: {
|
parentItem: {
|
||||||
text: namespaceName,
|
text: namespaceName,
|
||||||
url: createListFilterLink([['namespace', namespaceName]]),
|
url: createListFilterLink([['namespace', namespaceName]]),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules
|
|||||||
import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time';
|
import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time';
|
||||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
||||||
|
import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util';
|
||||||
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
|
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10;
|
const ITEMS_PER_PAGE = 10;
|
||||||
@@ -173,7 +174,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
|||||||
|
|
||||||
const defaultValues = useMemo(
|
const defaultValues = useMemo(
|
||||||
(): FormValues => ({
|
(): FormValues => ({
|
||||||
namespaceName: namespace.name,
|
namespaceName: decodeGrafanaNamespace(namespace).name,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
groupInterval: group.interval ?? '',
|
groupInterval: group.interval ?? '',
|
||||||
}),
|
}),
|
||||||
@@ -183,6 +184,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
|||||||
const rulesSourceName = getRulesSourceName(namespace.rulesSource);
|
const rulesSourceName = getRulesSourceName(namespace.rulesSource);
|
||||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||||
|
|
||||||
|
// parse any parent folders the alert rule might be stored in
|
||||||
|
const nestedFolderParents = decodeGrafanaNamespace(namespace).parents;
|
||||||
|
|
||||||
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
||||||
|
|
||||||
// close modal if successfully saved
|
// close modal if successfully saved
|
||||||
@@ -194,13 +198,18 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
|||||||
|
|
||||||
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
|
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
|
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
|
||||||
|
const newNamespaceName = isGrafanaManagedGroup
|
||||||
|
? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents)
|
||||||
|
: values.namespaceName;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateLotexNamespaceAndGroupAction({
|
updateLotexNamespaceAndGroupAction({
|
||||||
rulesSourceName: rulesSourceName,
|
rulesSourceName: rulesSourceName,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
newGroupName: values.groupName,
|
newGroupName: values.groupName,
|
||||||
namespaceName: namespace.name,
|
namespaceName: namespace.name,
|
||||||
newNamespaceName: values.namespaceName,
|
newNamespaceName: newNamespaceName,
|
||||||
groupInterval: values.groupInterval || undefined,
|
groupInterval: values.groupInterval || undefined,
|
||||||
folderUid,
|
folderUid,
|
||||||
})
|
})
|
||||||
@@ -212,6 +221,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
shouldFocusError: true,
|
shouldFocusError: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
|
|||||||
@@ -205,9 +205,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
|||||||
|
|
||||||
// ungrouped rules are rules that are in the "default" group name
|
// ungrouped rules are rules that are in the "default" group name
|
||||||
const groupName = isListView ? (
|
const groupName = isListView ? (
|
||||||
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} />
|
<RuleLocation namespace={decodeGrafanaNamespace(namespace).name} />
|
||||||
) : (
|
) : (
|
||||||
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} group={group.name} />
|
<RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={group.name} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeEditModal = (saved = false) => {
|
const closeEditModal = (saved = false) => {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function makeFolderAlertsLink(folderUID: string, title: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeFolderSettingsLink(folder: FolderDTO): string {
|
export function makeFolderSettingsLink(folder: FolderDTO): string {
|
||||||
return createUrl(`/dashboards/f/${folder.uid}/${folder.title}/settings`);
|
return createUrl(`/dashboards/f/${folder.uid}/settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeDashboardLink(dashboardUID: string): string {
|
export function makeDashboardLink(dashboardUID: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user