diff --git a/.betterer.results b/.betterer.results index ada77c000ef..38ab3e6a0d5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2358,10 +2358,6 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] ], - "public/app/features/datasources/components/EditDataSource.test.tsx:5381": [ - [0, 0, 0, "React Hook \\"useEffect\\" is called in function \\"component\\" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word \\"use\\".", "0"], - [0, 0, 0, "React Hook \\"useEffect\\" is called in function \\"component\\" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word \\"use\\".", "1"] - ], "public/app/features/datasources/components/picker/DataSourceCard.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], diff --git a/packages/grafana-data/src/transformations/transformers/__snapshots__/groupingToMatrix.test.ts.snap b/packages/grafana-data/src/transformations/transformers/__snapshots__/groupingToMatrix.test.ts.snap new file mode 100644 index 00000000000..97177391a02 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/__snapshots__/groupingToMatrix.test.ts.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Grouping to Matrix generates Matrix ignoring special value when value type is frame 1`] = ` +[ + { + "config": {}, + "name": "Row\\Column", + "type": "string", + "values": [ + "R1", + "R2", + ], + }, + { + "config": {}, + "name": "C1", + "type": "frame", + "values": [ + {}, + undefined, + ], + }, + { + "config": {}, + "name": "C2", + "type": "frame", + "values": [ + {}, + undefined, + ], + }, +] +`; diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts index 9b3956a12f2..6b5ab80911f 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts @@ -333,4 +333,31 @@ describe('Grouping to Matrix', () => { `); }); }); + + it('generates Matrix ignoring special value when value type is frame', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupingToMatrix, + options: { + columnField: 'Column', + rowField: 'Row', + valueField: 'Temp', + emptyValue: SpecialValue.Zero, + }, + }; + + const seriesA = toDataFrame({ + name: 'C', + fields: [ + { name: 'Column', type: FieldType.string, values: ['C1', 'C1', 'C2'] }, + { name: 'Row', type: FieldType.string, values: ['R1', 'R2', 'R1'] }, + { name: 'Temp', type: FieldType.frame, values: [{}, null, {}] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed[0].fields).toMatchSnapshot(); + }); + }); }); diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts index 39614c52042..4e388bf3257 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -1,7 +1,7 @@ import { map } from 'rxjs/operators'; import { getFieldDisplayName } from '../../field/fieldState'; -import { DataFrame, Field } from '../../types/dataFrame'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; import { SpecialValue, DataTransformerInfo, @@ -114,7 +114,10 @@ export const groupingToMatrixTransformer: DataTransformerInfo ['Admin'], + roles: evaluateAccess([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingProvisioningSetStatus, + ]), component: config.featureToggles.alertingMigrationUI ? importAlertingComponent( () => diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index e92d40c2969..efb102eb7ea 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -6,6 +6,8 @@ import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Badge, LinkButton, LoadingPlaceholder, Pagination, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types/accessControl'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; @@ -14,7 +16,6 @@ import { usePagination } from '../../hooks/usePagination'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { getPaginationStyles } from '../../styles/pagination'; import { getRulesDataSources, getRulesSourceUid } from '../../utils/datasource'; -import { isAdmin } from '../../utils/misc'; import { isAsyncRequestStatePending } from '../../utils/redux'; import { createRelativeUrl } from '../../utils/url'; @@ -49,7 +50,11 @@ export const CloudRules = ({ namespaces, expandAll }: Props) => { DEFAULT_PER_PAGE_PAGINATION ); - const canMigrateToGMA = hasDataSourcesConfigured && isAdmin() && config.featureToggles.alertingMigrationUI; + const canMigrateToGMA = + hasDataSourcesConfigured && + config.featureToggles.alertingMigrationUI && + contextSrv.hasPermission(AccessControlAction.AlertingRuleCreate) && + contextSrv.hasPermission(AccessControlAction.AlertingProvisioningSetStatus); return (
diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 3b150a698df..33024dd123a 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -255,9 +255,12 @@ describe('RuleListActions', () => { describe('Import Alert Rules', () => { testWithFeatureToggles(['alertingMigrationUI']); - it('should show "Import alert rules" option when user is admin and feature toggle is enabled', async () => { - grantUserRole(OrgRole.Admin); - grantUserPermissions([AccessControlAction.AlertingRuleRead]); + it('should show "Import alert rules" option when user has required permissions and feature toggle is enabled', async () => { + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingProvisioningSetStatus, + ]); const { user } = render(); @@ -267,8 +270,8 @@ describe('RuleListActions', () => { expect(ui.menuOptions.importAlertRules.query(menu)).toBeInTheDocument(); }); - it('should not show "Import alert rules" option when user is not admin', async () => { - // Keep default Viewer role + it('should not show "Import alert rules" option when user lacks required permissions', async () => { + // Keep default Viewer role and only read permissions grantUserPermissions([AccessControlAction.AlertingRuleRead]); const { user } = render(); @@ -280,8 +283,11 @@ describe('RuleListActions', () => { }); it('should have correct URL for "Import alert rules" menu item', async () => { - grantUserRole(OrgRole.Admin); - grantUserPermissions([AccessControlAction.AlertingRuleRead]); + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingProvisioningSetStatus, + ]); const { user } = render(); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 7018bc3f9c9..e828b00fa16 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -4,6 +4,8 @@ import { useToggle } from 'react-use'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types/accessControl'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; @@ -11,7 +13,6 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; -import { isAdmin } from '../utils/misc'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -44,7 +45,11 @@ export function RuleListActions() { const canExportRules = exportRulesSupported && exportRulesAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; - const canImportRulesToGMA = isAdmin() && config.featureToggles.alertingMigrationUI; + // Align import UI permission with convert endpoint requirements: rule create + provisioning set status + const canImportRulesToGMA = + config.featureToggles.alertingMigrationUI && + contextSrv.hasPermission(AccessControlAction.AlertingRuleCreate) && + contextSrv.hasPermission(AccessControlAction.AlertingProvisioningSetStatus); const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); diff --git a/public/app/features/datasources/components/EditDataSource.test.tsx b/public/app/features/datasources/components/EditDataSource.test.tsx index 6a1f6832485..c11627ad514 100644 --- a/public/app/features/datasources/components/EditDataSource.test.tsx +++ b/public/app/features/datasources/components/EditDataSource.test.tsx @@ -326,7 +326,7 @@ describe('', () => { it('should pass a context prop to the rendered UI extension component', () => { const message = "I'm a UI extension component!"; - const component = jest.fn().mockReturnValue(
{message}
); + const Component = jest.fn().mockReturnValue(
{message}
); setPluginComponentsHook( jest.fn().mockReturnValue({ @@ -337,7 +337,7 @@ describe('', () => { pluginId: 'grafana-pdc-app', title: 'Example component', description: 'Example description', - component, + component: Component, }, '1' ), @@ -353,9 +353,9 @@ describe('', () => { }, }); - expect(component).toHaveBeenCalled(); + expect(Component).toHaveBeenCalled(); - const props = component.mock.calls[0][0]; + const props = Component.mock.calls[0][0]; expect(props.context).toBeDefined(); expect(props.context.dataSource).toBeDefined(); @@ -368,7 +368,7 @@ describe('', () => { it('should be possible to update the `jsonData` first and `secureJsonData` directly afterwards from the extension component', () => { const message = "I'm a UI extension component!"; - const component = ({ context }: { context: PluginExtensionDataSourceConfigContext }) => { + const Component = ({ context }: { context: PluginExtensionDataSourceConfigContext }) => { useEffect(() => { context.setJsonData({ test: 'test' } as unknown as DataSourceJsonData); context.setSecureJsonData({ test: 'test' }); @@ -387,7 +387,7 @@ describe('', () => { pluginId: 'grafana-pdc-app', title: 'Example component', description: 'Example description', - component: component as unknown as React.ComponentType<{}>, + component: Component as unknown as React.ComponentType<{}>, }, '1' ), @@ -413,7 +413,7 @@ describe('', () => { it('should be possible to update the `secureJsonData` first and `jsonData` directly afterwards from the extension component', () => { const message = "I'm a UI extension component!"; - const component = ({ context }: { context: PluginExtensionDataSourceConfigContext }) => { + const Component = ({ context }: { context: PluginExtensionDataSourceConfigContext }) => { useEffect(() => { context.setSecureJsonData({ test: 'test' }); context.setJsonData({ test: 'test' } as unknown as DataSourceJsonData); @@ -432,7 +432,7 @@ describe('', () => { pluginId: 'grafana-pdc-app', title: 'Example component', description: 'Example description', - component: component as unknown as React.ComponentType<{}>, + component: Component as unknown as React.ComponentType<{}>, }, '1' ), diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index 1f86e39ea34..7ef86382edc 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -129,6 +129,11 @@ export enum AccessControlAction { AlertingProvisioningReadSecrets = 'alert.provisioning.secrets:read', AlertingProvisioningRead = 'alert.provisioning:read', AlertingProvisioningWrite = 'alert.provisioning:write', + AlertingRulesProvisioningRead = 'alert.rules.provisioning:read', + AlertingRulesProvisioningWrite = 'alert.rules.provisioning:write', + AlertingNotificationsProvisioningRead = 'alert.notifications.provisioning:read', + AlertingNotificationsProvisioningWrite = 'alert.notifications.provisioning:write', + AlertingProvisioningSetStatus = 'alert.provisioning.provenance:write', // Alerting receivers actions AlertingReceiversPermissionsRead = 'receivers.permissions:read',