Alerting: Add managed folder validation frontend (#115203)
* hide alerts tab for git synced folders * add tests for alert tab visibility * hide managed folders from folder picker * update UI so managed folders are disabled in dropdown not hidden * add folder d to folder tree * include folder d in useFolderQuery hook tests * update provisioned folders from disabled to hidden in the folder selector * remove disabled logic from NestedFolderList
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Chance } from 'chance';
|
||||
|
||||
import { DashboardsTreeItem, DashboardViewItem, UIDashboardViewItem } from '../types/browse-dashboards';
|
||||
import { DashboardsTreeItem, DashboardViewItem, ManagerKind, UIDashboardViewItem } from '../types/browse-dashboards';
|
||||
|
||||
function wellFormedEmptyFolder(
|
||||
seed = 1,
|
||||
@@ -64,13 +64,14 @@ function wellFormedFolder(
|
||||
}
|
||||
|
||||
export function treeViewersCanEdit() {
|
||||
const [, { folderA, folderC }] = wellFormedTree();
|
||||
const [, { folderA, folderC, folderD }] = wellFormedTree();
|
||||
|
||||
return [
|
||||
[folderA, folderC],
|
||||
[folderA, folderC, folderD],
|
||||
{
|
||||
folderA,
|
||||
folderC,
|
||||
folderD,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -90,6 +91,8 @@ export function wellFormedTree() {
|
||||
const folderB = wellFormedFolder(seed++);
|
||||
const folderB_empty = wellFormedEmptyFolder(seed++);
|
||||
const folderC = wellFormedFolder(seed++);
|
||||
// folderD is marked as managed by repo (git-synced) for testing disabled folder behavior
|
||||
const folderD = wellFormedFolder(seed++, {}, { managedBy: ManagerKind.Repo });
|
||||
const dashbdD = wellFormedDashboard(seed++);
|
||||
const dashbdE = wellFormedDashboard(seed++);
|
||||
|
||||
@@ -107,6 +110,7 @@ export function wellFormedTree() {
|
||||
folderB,
|
||||
folderB_empty,
|
||||
folderC,
|
||||
folderD,
|
||||
dashbdD,
|
||||
dashbdE,
|
||||
],
|
||||
@@ -123,6 +127,7 @@ export function wellFormedTree() {
|
||||
folderB,
|
||||
folderB_empty,
|
||||
folderC,
|
||||
folderD,
|
||||
dashbdD,
|
||||
dashbdE,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HttpResponse, http } from 'msw';
|
||||
import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders';
|
||||
|
||||
const [mockTree, { folderB }] = wellFormedTree();
|
||||
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
|
||||
const [mockTreeThatViewersCanEdit] = treeViewersCanEdit();
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
@@ -48,6 +49,7 @@ const listFoldersHandler = () =>
|
||||
id: random.integer({ min: 1, max: 1000 }),
|
||||
uid: folder.item.uid,
|
||||
title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen",
|
||||
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title
|
||||
@@ -76,6 +78,7 @@ const getFolderHandler = () =>
|
||||
uid: folder?.item.uid,
|
||||
...additionalProperties,
|
||||
...(accessControlQueryParam ? { accessControl: mockAccessControl } : {}),
|
||||
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { wellFormedTree } from '../../../../fixtures/folders';
|
||||
import { getErrorResponse } from '../../../helpers';
|
||||
|
||||
const [mockTree, { folderB }] = wellFormedTree();
|
||||
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
|
||||
|
||||
const baseResponse = {
|
||||
kind: 'Folder',
|
||||
@@ -24,7 +25,7 @@ const folderToAppPlatform = (folder: (typeof mockTree)[number]['item'], id?: num
|
||||
// TODO: Generalise annotations in fixture data
|
||||
'grafana.app/createdBy': 'user:1',
|
||||
'grafana.app/updatedBy': 'user:2',
|
||||
'grafana.app/managedBy': 'user',
|
||||
'grafana.app/managedBy': 'managedBy' in folder ? folder.managedBy : 'user',
|
||||
'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z',
|
||||
'grafana.app/folder': folder.kind === 'folder' ? folder.parentUID : undefined,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// @grafana/schema?
|
||||
// New package @grafana/core? @grafana/types?
|
||||
|
||||
enum ManagerKind {
|
||||
export enum ManagerKind {
|
||||
Repo = 'repo',
|
||||
Terraform = 'terraform',
|
||||
Kubectl = 'kubectl',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DashboardViewItem } from '../../../features/search/types';
|
||||
import { useFoldersQuery } from './useFoldersQuery';
|
||||
import { getCustomRootFolderItem, getRootFolderItem } from './utils';
|
||||
|
||||
const [_, { folderA, folderB, folderC }] = getFolderFixtures();
|
||||
const [_, { folderA, folderB, folderC, folderD }] = getFolderFixtures();
|
||||
|
||||
runtime.setBackendSrv(backendSrv);
|
||||
setupMockServer();
|
||||
@@ -44,7 +44,7 @@ describe('useFoldersQuery', () => {
|
||||
const [_dashboardsContainer, ...items] = await testFn();
|
||||
|
||||
const sortedItemTitles = items.map((item) => (item.item as DashboardViewItem).title).sort();
|
||||
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title].sort();
|
||||
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title, folderD.item.title].sort();
|
||||
|
||||
expect(sortedItemTitles).toEqual(expectedTitles);
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
} from '@grafana/ui';
|
||||
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
|
||||
|
||||
import { Folder } from '../../types/rule-form';
|
||||
import {
|
||||
@@ -409,9 +409,10 @@ function TargetFolderField() {
|
||||
name="targetFolder"
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Stack width={42}>
|
||||
<NestedFolderPicker
|
||||
<ProvisioningAwareFolderPicker
|
||||
permission="view"
|
||||
showRootFolder={false}
|
||||
repositoryName={undefined}
|
||||
invalid={!!errors.targetFolder?.message}
|
||||
{...field}
|
||||
value={field.value?.uid}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Field, Label, Stack } from '@grafana/ui';
|
||||
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
|
||||
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
|
||||
|
||||
import { Folder, RuleFormValues } from '../../types/rule-form';
|
||||
import { CreateNewFolder } from '../create-folder/CreateNewFolder';
|
||||
@@ -48,9 +48,10 @@ export function FolderSelector() {
|
||||
<Controller
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<div style={{ width: 420 }}>
|
||||
<NestedFolderPicker
|
||||
<ProvisioningAwareFolderPicker
|
||||
permission="view"
|
||||
showRootFolder={false}
|
||||
repositoryName={undefined}
|
||||
invalid={!!errors.folder?.message}
|
||||
{...field}
|
||||
value={folder?.uid}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = Das
|
||||
level: number;
|
||||
isOpen: boolean;
|
||||
parentUID?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RendererUserProps {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { ManagerKind } from 'app/features/apiserver/types';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { FolderDTO } from 'app/types/folders';
|
||||
|
||||
import { buildNavModel, getAlertingTabID } from './navModel';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
config: {
|
||||
unifiedAlertingEnabled: true,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
hasPermission: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('buildNavModel', () => {
|
||||
const mockFolder: FolderDTO = {
|
||||
uid: 'test-folder-uid',
|
||||
title: 'Test Folder',
|
||||
url: '/dashboards/f/test-folder-uid',
|
||||
id: 1,
|
||||
created: '',
|
||||
createdBy: '',
|
||||
hasAcl: false,
|
||||
updated: '',
|
||||
updatedBy: '',
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canAdmin: true,
|
||||
canDelete: true,
|
||||
version: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(contextSrv.hasPermission as jest.Mock).mockReturnValue(true);
|
||||
config.unifiedAlertingEnabled = true;
|
||||
});
|
||||
|
||||
describe('Alerts tab visibility', () => {
|
||||
it('should show Alerts tab for regular (non-managed) folders when user has permissions', () => {
|
||||
const navModel = buildNavModel(mockFolder);
|
||||
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
|
||||
|
||||
expect(alertingTab).toBeDefined();
|
||||
expect(alertingTab?.text).toContain('Alert rules');
|
||||
});
|
||||
|
||||
it('should hide Alerts tab for Git-synced folders', () => {
|
||||
const gitSyncedFolder: FolderDTO = {
|
||||
...mockFolder,
|
||||
managedBy: ManagerKind.Repo,
|
||||
};
|
||||
|
||||
const navModel = buildNavModel(gitSyncedFolder);
|
||||
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
|
||||
|
||||
expect(alertingTab).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hide Alerts tab when user lacks AlertingRuleRead permission', () => {
|
||||
(contextSrv.hasPermission as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const navModel = buildNavModel(mockFolder);
|
||||
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
|
||||
|
||||
expect(alertingTab).toBeUndefined();
|
||||
expect(contextSrv.hasPermission).toHaveBeenCalledWith(AccessControlAction.AlertingRuleRead);
|
||||
});
|
||||
|
||||
it('should hide Alerts tab when unified alerting is disabled', () => {
|
||||
config.unifiedAlertingEnabled = false;
|
||||
|
||||
const navModel = buildNavModel(mockFolder);
|
||||
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
|
||||
|
||||
expect(alertingTab).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show Alerts tab for regular folders with all conditions met', () => {
|
||||
const navModel = buildNavModel(mockFolder);
|
||||
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
|
||||
|
||||
expect(alertingTab).toBeDefined();
|
||||
expect(alertingTab?.icon).toBe('bell');
|
||||
expect(alertingTab?.url).toBe(`${mockFolder.url}/alerting`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,11 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
|
||||
});
|
||||
}
|
||||
|
||||
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && config.unifiedAlertingEnabled) {
|
||||
if (
|
||||
!isProvisioned &&
|
||||
contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) &&
|
||||
config.unifiedAlertingEnabled
|
||||
) {
|
||||
model.children!.push({
|
||||
active: false,
|
||||
icon: 'bell',
|
||||
|
||||
Reference in New Issue
Block a user