diff --git a/public/app/features/alerting/unified/components/expressions/util.test.ts b/public/app/features/alerting/unified/components/expressions/util.test.ts index 18600e64d85..f8cb2d39d23 100644 --- a/public/app/features/alerting/unified/components/expressions/util.test.ts +++ b/public/app/features/alerting/unified/components/expressions/util.test.ts @@ -6,6 +6,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { decodeGrafanaNamespace, + encodeGrafanaNamespace, formatLabels, getSeriesLabels, 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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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"]`); }); }); diff --git a/public/app/features/alerting/unified/components/expressions/util.ts b/public/app/features/alerting/unified/components/expressions/util.ts index e6794e4ab26..07d732eb27b 100644 --- a/public/app/features/alerting/unified/components/expressions/util.ts +++ b/public/app/features/alerting/unified/components/expressions/util.ts @@ -1,3 +1,5 @@ +import { dropRight, last } from 'lodash'; + import { DataFrame, Labels, roundDecimals } from '@grafana/data'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; @@ -40,29 +42,66 @@ const formatLabels = (labels: Labels): string => { .join(', '); }; +interface DecodedNamespace { + name: string; + parents: string[]; +} + /** * 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, * 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; 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 { - 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 { - 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 isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null))); return isEmpty; }; -export { decodeGrafanaNamespace, formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries }; +export { + decodeGrafanaNamespace, + encodeGrafanaNamespace, + formatLabels, + getSeriesLabels, + getSeriesName, + getSeriesValue, + isEmptySeries, +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx index 78d793b1a31..d1ff060bdd7 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx @@ -154,7 +154,7 @@ export function RuleViewer({ match }: RuleViewerProps) { {isFederatedRule && } - {decodeGrafanaNamespace(rule.namespace)} / {rule.group.name} + {decodeGrafanaNamespace(rule.namespace).name} / {rule.group.name} {isGrafanaRulerRule(rule.rulerRule) && } diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx index 29d4c0607c4..0088ddd0833 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx @@ -259,7 +259,7 @@ function usePageNav(rule: CombinedRule) { const isAlertType = isAlertingRule(promRule); const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined; - const namespaceName = decodeGrafanaNamespace(rule.namespace); + const namespaceName = decodeGrafanaNamespace(rule.namespace).name; const groupName = rule.group.name; const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType; @@ -309,6 +309,7 @@ function usePageNav(rule: CombinedRule) { ['namespace', namespaceName], ['group', groupName], ]), + // @TODO support nested folders here parentItem: { text: namespaceName, url: createListFilterLink([['namespace', namespaceName]]), diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index bc24dd8be88..4864237c17c 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -20,6 +20,7 @@ import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; +import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; const ITEMS_PER_PAGE = 10; @@ -173,7 +174,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const defaultValues = useMemo( (): FormValues => ({ - namespaceName: namespace.name, + namespaceName: decodeGrafanaNamespace(namespace).name, groupName: group.name, groupInterval: group.interval ?? '', }), @@ -183,6 +184,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const rulesSourceName = getRulesSourceName(namespace.rulesSource); 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'; // close modal if successfully saved @@ -194,13 +198,18 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState)); 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( updateLotexNamespaceAndGroupAction({ rulesSourceName: rulesSourceName, groupName: group.name, newGroupName: values.groupName, namespaceName: namespace.name, - newNamespaceName: values.namespaceName, + newNamespaceName: newNamespaceName, groupInterval: values.groupInterval || undefined, folderUid, }) @@ -212,6 +221,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { defaultValues, shouldFocusError: true, }); + const { handleSubmit, register, diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index f98ad2b7424..fd6072e359f 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -205,9 +205,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: // ungrouped rules are rules that are in the "default" group name const groupName = isListView ? ( - + ) : ( - + ); const closeEditModal = (saved = false) => { diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index cb41bea1e15..21115d42ec7 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -150,7 +150,7 @@ export function makeFolderAlertsLink(folderUID: string, title: string): 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 {