Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-08-26 10:51:34 +03:00
17 changed files with 156 additions and 34 deletions
-4
View File
@@ -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"]
],
@@ -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,
],
},
]
`;
@@ -333,4 +333,31 @@ describe('Grouping to Matrix', () => {
`);
});
});
it('generates Matrix ignoring special value when value type is frame', async () => {
const cfg: DataTransformerConfig<GroupingToMatrixTransformerOptions> = {
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();
});
});
});
@@ -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<GroupingToMatrixTr
for (const columnName of columnValues) {
let values = [];
for (const rowName of rowValues) {
const value = matrixValues[columnName][rowName] ?? getSpecialValue(emptyValue);
// nested dataframes need to be undefined when empty
const value =
matrixValues[columnName][rowName] ??
(valueField.type === FieldType.frame ? undefined : getSpecialValue(emptyValue));
values.push(value);
}
@@ -527,10 +527,14 @@ export interface FeatureToggles {
*/
grafanaManagedRecordingRules?: boolean;
/**
* Enables Query Library feature in Explore
* Renamed feature toggle, enables Saved queries feature
*/
queryLibrary?: boolean;
/**
* Enables Saved Queries feature
*/
savedQueries?: boolean;
/**
* Sets the logs table as default visualisation in logs explore
*/
logsExploreTableDefaultVisualization?: boolean;
@@ -25,7 +25,12 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
value = JSON.parse(value);
} catch {} // ignore errors
} else {
displayValue = JSON.stringify(value, null, ' ');
try {
// JSON may refer to itself, which errors on stringify
displayValue = JSON.stringify(value, null, ' ');
} catch {
displayValue = undefined; // if it won't stringify, mark undefined
}
}
const links = getCellLinks(field, row) || [];
+9 -1
View File
@@ -905,7 +905,15 @@ var (
},
{
Name: "queryLibrary",
Description: "Enables Query Library feature in Explore",
Description: "Renamed feature toggle, enables Saved queries feature",
Stage: FeatureStagePrivatePreview,
Owner: grafanaSharingSquad,
FrontendOnly: false,
AllowSelfServe: false,
},
{
Name: "savedQueries",
Description: "Enables Saved Queries feature",
Stage: FeatureStagePrivatePreview,
Owner: grafanaSharingSquad,
FrontendOnly: false,
+1
View File
@@ -118,6 +118,7 @@ cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false
disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false
grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false
queryLibrary,privatePreview,@grafana/sharing-squad,false,false,false
savedQueries,privatePreview,@grafana/sharing-squad,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
newDashboardSharingComponent,GA,@grafana/sharing-squad,false,false,true
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
118 disableNumericMetricsSortingInExpressions experimental @grafana/oss-big-tent false true false
119 grafanaManagedRecordingRules experimental @grafana/alerting-squad false false false
120 queryLibrary privatePreview @grafana/sharing-squad false false false
121 savedQueries privatePreview @grafana/sharing-squad false false false
122 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
123 newDashboardSharingComponent GA @grafana/sharing-squad false false true
124 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
+5 -1
View File
@@ -480,9 +480,13 @@ const (
FlagGrafanaManagedRecordingRules = "grafanaManagedRecordingRules"
// FlagQueryLibrary
// Enables Query Library feature in Explore
// Renamed feature toggle, enables Saved queries feature
FlagQueryLibrary = "queryLibrary"
// FlagSavedQueries
// Enables Saved Queries feature
FlagSavedQueries = "savedQueries"
// FlagLogsExploreTableDefaultVisualization
// Sets the logs table as default visualisation in logs explore
FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization"
+18 -3
View File
@@ -2667,12 +2667,15 @@
{
"metadata": {
"name": "queryLibrary",
"resourceVersion": "1753448760331",
"resourceVersion": "1755721444487",
"creationTimestamp": "2022-10-07T18:31:45Z",
"deletionTimestamp": "2023-03-20T16:00:14Z"
"deletionTimestamp": "2023-03-20T16:00:14Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-08-20 20:24:04.487598 +0000 UTC"
}
},
"spec": {
"description": "Enables Query Library feature in Explore",
"description": "Renamed feature toggle, enables Saved queries feature",
"stage": "privatePreview",
"codeowner": "@grafana/sharing-squad"
}
@@ -2849,6 +2852,18 @@
"codeowner": "@grafana/identity-access-team"
}
},
{
"metadata": {
"name": "savedQueries",
"resourceVersion": "1755721444487",
"creationTimestamp": "2025-08-20T20:24:04Z"
},
"spec": {
"description": "Enables Saved Queries feature",
"stage": "privatePreview",
"codeowner": "@grafana/sharing-squad"
}
},
{
"metadata": {
"name": "scanRowInvalidDashboardParseFallbackEnabled",
+3 -1
View File
@@ -711,7 +711,9 @@ func (b *backend) ListModifiedSince(ctx context.Context, key resource.Namespaced
if mr.Key.Name <= lastSeen {
// resource names should be sorted alphabetically. So if not, the query is not correct.
yield(nil, fmt.Errorf("listModifiedSince: resources are not sorted by name ASC, lastSeen: %q, current: %q", lastSeen, mr.Key.Name))
if !yield(nil, fmt.Errorf("listModifiedSince: resources are not sorted by name ASC, lastSeen: %q, current: %q", lastSeen, mr.Key.Name)) {
return
}
}
lastSeen = mr.Key.Name
+4 -1
View File
@@ -226,7 +226,10 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
},
{
path: '/alerting/import-datasource-managed-rules',
roles: () => ['Admin'],
roles: evaluateAccess([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingProvisioningSetStatus,
]),
component: config.featureToggles.alertingMigrationUI
? importAlertingComponent(
() =>
@@ -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 (
<section className={styles.wrapper}>
@@ -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(<RuleListActions />);
@@ -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(<RuleListActions />);
@@ -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(<RuleListActions />);
@@ -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);
@@ -326,7 +326,7 @@ describe('<EditDataSource>', () => {
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(<div>{message}</div>);
const Component = jest.fn().mockReturnValue(<div>{message}</div>);
setPluginComponentsHook(
jest.fn().mockReturnValue({
@@ -337,7 +337,7 @@ describe('<EditDataSource>', () => {
pluginId: 'grafana-pdc-app',
title: 'Example component',
description: 'Example description',
component,
component: Component,
},
'1'
),
@@ -353,9 +353,9 @@ describe('<EditDataSource>', () => {
},
});
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('<EditDataSource>', () => {
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('<EditDataSource>', () => {
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('<EditDataSource>', () => {
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('<EditDataSource>', () => {
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'
),
+5
View File
@@ -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',