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 ae5e0e479e4..e29d4c2f65b 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
@@ -7,6 +7,8 @@ import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { alertingFactory } from '../mocks/server/db';
+import { RulesFilter } from '../search/rulesSearchParser';
+import { testWithFeatureToggles } from '../test/test-utils';
import RuleList, { RuleListActions } from './RuleList.v2';
@@ -23,12 +25,18 @@ jest.mock('./GroupedView', () => ({
const ui = {
filterView: byTestId('filter-view'),
groupedView: byTestId('grouped-view'),
+ modeSelector: {
+ grouped: byRole('radio', { name: /grouped/i }),
+ list: byRole('radio', { name: /list/i }),
+ },
+ searchInput: byTestId('search-query-input'),
};
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
+testWithFeatureToggles(['alertingListViewV2']);
setupMswServer();
@@ -61,8 +69,84 @@ describe('RuleList v2', () => {
expect(ui.groupedView.query()).not.toBeInTheDocument();
});
- it('should show list view when a filter is applied', () => {
- render(
, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } });
+ it('should show grouped view when only group filter is applied', () => {
+ render(
, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } });
+
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show grouped view when only namespace filter is applied', () => {
+ render(
, { historyOptions: { initialEntries: ['/?search=namespace:global'] } });
+
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show grouped view when both group and namespace filters are applied', () => {
+ render(
, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } });
+
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show list view when group and namespace filters are combined with other filter types', () => {
+ render(
, {
+ historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global state:firing'] },
+ });
+
+ expect(ui.filterView.get()).toBeInTheDocument();
+ expect(ui.groupedView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show grouped view when view parameter is empty', () => {
+ render(
, { historyOptions: { initialEntries: ['/?view='] } });
+
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show grouped view when search parameter is empty', () => {
+ render(
, { historyOptions: { initialEntries: ['/?search='] } });
+
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+ });
+
+ it.each<{ filterType: keyof RulesFilter; searchQuery: string }>([
+ { filterType: 'freeFormWords', searchQuery: 'cpu alert' },
+ { filterType: 'ruleName', searchQuery: 'rule:"cpu 80%"' },
+ { filterType: 'ruleState', searchQuery: 'state:firing' },
+ { filterType: 'ruleType', searchQuery: 'type:alerting' },
+ { filterType: 'dataSourceNames', searchQuery: 'datasource:prometheus' },
+ { filterType: 'labels', searchQuery: 'label:team=backend' },
+ { filterType: 'ruleHealth', searchQuery: 'health:error' },
+ { filterType: 'contactPoint', searchQuery: 'contactPoint:slack' },
+ ])('should show list view when %s filter is applied', ({ filterType, searchQuery }) => {
+ render(
, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } });
+
+ expect(ui.filterView.get()).toBeInTheDocument();
+ expect(ui.groupedView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show list view when "view=list" URL parameter is present with group filter', () => {
+ render(
, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } });
+
+ expect(ui.filterView.get()).toBeInTheDocument();
+ expect(ui.groupedView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show list view when "view=list" URL parameter is present with namespace filter', () => {
+ render(
, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } });
+
+ expect(ui.filterView.get()).toBeInTheDocument();
+ expect(ui.groupedView.query()).not.toBeInTheDocument();
+ });
+
+ it('should show list view when "view=list" URL parameter is present with both group and namespace filters', () => {
+ render(
, {
+ historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] },
+ });
expect(ui.filterView.get()).toBeInTheDocument();
expect(ui.groupedView.query()).not.toBeInTheDocument();
@@ -160,3 +244,47 @@ describe('RuleListActions', () => {
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
});
});
+
+describe('RuleList v2 - View switching', () => {
+ it('should preserve both group and namespace filters when switching from list view to grouped view', async () => {
+ // Start with list view and both group and namespace filters
+ const { user } = render(
, {
+ historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] },
+ });
+ expect(ui.filterView.get()).toBeInTheDocument();
+
+ // Click the "Grouped" view button
+ const groupedButton = await ui.modeSelector.grouped.find();
+ await user.click(groupedButton);
+
+ // Should preserve both filters and switch to grouped view
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+
+ // Verify filters are preserved
+ expect(ui.searchInput.get()).toHaveValue('group:cpu-usage namespace:global');
+ expect(ui.modeSelector.list.query()).not.toBeChecked();
+ });
+
+ it('should clear all filters when switching from list view to grouped view with group, namespace and other filters', async () => {
+ // Start with list view with all types of filters
+ const { user } = render(
, {
+ historyOptions: {
+ initialEntries: ['/?view=list&search=group:cpu-usage namespace:global state:firing rule:"test"'],
+ },
+ });
+ expect(ui.filterView.get()).toBeInTheDocument();
+
+ // Click the "Grouped" view button
+ const groupedButton = await ui.modeSelector.grouped.find();
+ await user.click(groupedButton);
+
+ // Should clear all filters because other filters are present
+ expect(ui.groupedView.get()).toBeInTheDocument();
+ expect(ui.filterView.query()).not.toBeInTheDocument();
+
+ // Verify all filters are cleared
+ expect(ui.searchInput.get()).toHaveValue('');
+ expect(ui.modeSelector.list.query()).not.toBeChecked();
+ });
+});
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 124e230aeef..f3c79b42b4b 100644
--- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
+++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
@@ -6,10 +6,9 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter';
-import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
+import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useRulesFilter } from '../hooks/useFilteredRules';
-import { useURLSearchParams } from '../hooks/useURLSearchParams';
import { isAdmin } from '../utils/misc';
import { FilterView } from './FilterView';
@@ -17,16 +16,17 @@ import { GroupedView } from './GroupedView';
import { RuleListPageTitle } from './RuleListPageTitle';
function RuleList() {
- const [queryParams] = useURLSearchParams();
- const { filterState, hasActiveFilters } = useRulesFilter();
-
- const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
- const showListView = hasActiveFilters || view === 'list';
+ const { filterState } = useRulesFilter();
+ const { viewMode, handleViewChange } = useListViewMode();
return (
<>
-
{}} />
- {showListView ? : }
+
+ {viewMode === 'list' ? (
+
+ ) : (
+
+ )}
>
);
}
diff --git a/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx b/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx
new file mode 100644
index 00000000000..f8778712dd9
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx
@@ -0,0 +1,24 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Trans } from '@grafana/i18n';
+import { Text, useStyles2 } from '@grafana/ui';
+
+// @TODO I don't like applying the margins to this component here, ideally the parent component should be layouting this.
+export const NoRulesFound = () => {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+ No rules found
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ noRules: css({
+ margin: theme.spacing(1.5, 0, 0.5, 4),
+ }),
+});
diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts
index e66ddb559a9..975216e498d 100644
--- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts
+++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts
@@ -14,16 +14,20 @@ import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules';
/**
* @returns True if the group matches the filter, false otherwise. Keeps rules intact
*/
-export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean {
+export function groupFilter(
+ group: PromRuleGroupDTO,
+ filterState: Pick
+): boolean {
const { name, file } = group;
+ const { namespace, groupName } = filterState;
// Add fuzzy search for namespace
- if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) {
+ if (namespace && !file.toLocaleLowerCase().includes(namespace.toLocaleLowerCase())) {
return false;
}
// Add fuzzy search for group name
- if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) {
+ if (groupName && !name.toLocaleLowerCase().includes(groupName.toLocaleLowerCase())) {
return false;
}
diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
index 5165996b05c..649594f893e 100644
--- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
+++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
@@ -10,6 +10,10 @@ import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
interface UseGeneratorHookOptions {
+ /**
+ * Whether to populate the RTKQ cache with the groups.
+ * Populating cache might harm performance when fetching a lot of groups or fetching multiple pages
+ */
populateCache?: boolean;
limitAlerts?: number;
}
diff --git a/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx b/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx
index df24ed95571..5181e1a1cd9 100644
--- a/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx
+++ b/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx
@@ -16,7 +16,8 @@ import { isLoading as isLoadingState, useAsync } from '../../hooks/useAsync';
*/
export function useLazyLoadPrometheusGroups(
groupsGenerator: AsyncIterator,
- pageSize: number
+ pageSize: number,
+ filter?: (group: TGroup) => boolean
) {
const [groups, setGroups] = useState([]);
const [hasMoreGroups, setHasMoreGroups] = useState(true);
@@ -31,7 +32,12 @@ export function useLazyLoadPrometheusGroups(
done = true;
break;
}
+
const group = generatorResult.value;
+ if (filter && !filter(group)) {
+ continue;
+ }
+
currentGroups.push(group);
}
diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts
new file mode 100644
index 00000000000..41c5293cbde
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts
@@ -0,0 +1,9 @@
+export const FRONTEND_LIST_PAGE_SIZE = 100;
+
+export const FILTERED_GROUPS_API_PAGE_SIZE = 2000;
+export const DEFAULT_GROUPS_API_PAGE_SIZE = 40;
+export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE;
+
+export function getApiGroupPageSize(hasFilters: boolean) {
+ return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx
index 5db18bd4553..a34fc45f0d6 100644
--- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx
@@ -3,6 +3,7 @@ import { capitalize } from 'lodash';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
import { useTranslate } from '@grafana/i18n';
import { t } from '@grafana/i18n/internal';
import { Button, Combobox, ComboboxOption, Field, InlineSwitch, Input, Stack, useStyles2 } from '@grafana/ui';
@@ -104,9 +105,16 @@ function GridLayoutColumns({ layoutManager }: { layoutManager: AutoGridLayoutMan
className={styles.wideSelector}
>
{isStandardMinWidth ? (
-
+
) : (
setInputRef(ref)}
@@ -114,6 +122,7 @@ function GridLayoutColumns({ layoutManager }: { layoutManager: AutoGridLayoutMan
min={50}
max={2000}
invalid={customMinWidthError}
+ data-testid={selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth}
suffix={
@@ -130,10 +140,12 @@ function GridLayoutColumns({ layoutManager }: { layoutManager: AutoGridLayoutMan
layoutManager.onMaxColumnCountChanged(parseInt(value, 10))}
width={6.5}
+ data-testid={selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns}
/>
@@ -210,9 +222,16 @@ function GridLayoutRows({ layoutManager }: { layoutManager: AutoGridLayoutManage
className={styles.wideSelector}
>
{isStandardHeight ? (
-
+
) : (
setInputRef(ref)}
@@ -220,6 +239,7 @@ function GridLayoutRows({ layoutManager }: { layoutManager: AutoGridLayoutManage
min={50}
max={2000}
invalid={customMinWidthError}
+ data-testid={selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight}
suffix={
@@ -235,7 +256,12 @@ function GridLayoutRows({ layoutManager }: { layoutManager: AutoGridLayoutManage
)}
- layoutManager.onFillScreenChanged(!fillScreen)} />
+ layoutManager.onFillScreenChanged(!fillScreen)}
+ data-testid={selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen}
+ />
);
diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
index a44652a0fe3..fae5d4a5991 100644
--- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
+++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
@@ -74,6 +74,25 @@ export function ResourceExport({
/>
)}
+ {initialSaveModelVersion === 'v2' && (
+
+
+ onExportModeChange(value)}
+ />
+
+ )}
{exportMode !== ExportMode.Classic && (
@@ -87,7 +106,9 @@ export function ResourceExport({
/>
)}
- {(isV2Dashboard || exportMode === ExportMode.Classic) && (
+ {(isV2Dashboard ||
+ exportMode === ExportMode.Classic ||
+ (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx
new file mode 100644
index 00000000000..fb416a280fb
--- /dev/null
+++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.test.tsx
@@ -0,0 +1,333 @@
+import { config } from '@grafana/runtime';
+import { SceneTimeRange } from '@grafana/scenes';
+import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
+import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
+import * as ResponseTransformers from 'app/features/dashboard/api/ResponseTransformers';
+import { DashboardJson } from 'app/features/manage-dashboards/types';
+import { DashboardDataDTO } from 'app/types/dashboard';
+
+import { DashboardScene } from '../scene/DashboardScene';
+import * as exporters from '../scene/export/exporters';
+import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
+import * as sceneToV1 from '../serialization/transformSceneToSaveModel';
+import * as sceneToV2 from '../serialization/transformSceneToSaveModelSchemaV2';
+
+import { ExportMode } from './ExportButton/ResourceExport';
+import { ShareExportTab } from './ShareExportTab';
+
+describe('ShareExportTab', () => {
+ // Spies to track function calls
+ let transformV2ToV1Spy: jest.SpyInstance;
+ let makeExportableV1Spy: jest.SpyInstance;
+ let transformSceneToV1Spy: jest.SpyInstance;
+ let transformSceneToV2Spy: jest.SpyInstance;
+
+ beforeEach(() => {
+ config.featureToggles.kubernetesDashboards = true;
+
+ // Set up spies on the functions we want to track
+ transformV2ToV1Spy = jest.spyOn(ResponseTransformers, 'transformDashboardV2SpecToV1').mockReturnValue({
+ title: 'Transformed V1',
+ uid: 'transformed-uid',
+ version: 1,
+ panels: [],
+ time: { from: 'now-6h', to: 'now' },
+ timepicker: {},
+ timezone: '',
+ weekStart: '',
+ fiscalYearStartMonth: 0,
+ refresh: '',
+ schemaVersion: 30,
+ tags: [],
+ templating: { list: [] },
+ } as DashboardDataDTO);
+
+ makeExportableV1Spy = jest.spyOn(exporters, 'makeExportableV1').mockImplementation(async (dashboard) => dashboard);
+
+ transformSceneToV1Spy = jest.spyOn(sceneToV1, 'transformSceneToSaveModel').mockReturnValue({
+ title: 'Scene V1',
+ uid: 'scene-v1-uid',
+ version: 1,
+ panels: [],
+ time: { from: 'now-6h', to: 'now' },
+ timepicker: {},
+ timezone: '',
+ weekStart: '',
+ fiscalYearStartMonth: 0,
+ refresh: '',
+ schemaVersion: 30,
+ tags: [],
+ templating: { list: [] },
+ } as Dashboard);
+
+ transformSceneToV2Spy = jest.spyOn(sceneToV2, 'transformSceneToSaveModelSchemaV2').mockReturnValue({
+ title: 'Scene V2',
+ annotations: [],
+ cursorSync: 'Off',
+ description: '',
+ editable: true,
+ elements: {},
+ layout: { kind: 'GridLayout', spec: { items: [] } },
+ links: [],
+ liveNow: false,
+ preload: false,
+ tags: [],
+ timeSettings: {
+ from: 'now-6h',
+ to: 'now',
+ autoRefresh: '',
+ autoRefreshIntervals: [],
+ hideTimepicker: false,
+ timezone: '',
+ weekStart: 'saturday',
+ fiscalYearStartMonth: 0,
+ },
+ variables: [],
+ } as DashboardV2Spec);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('V1Resource export mode', () => {
+ // If V1 dashboard → V1 Resource should export with V1 apiVersion
+ it('should export V1 dashboard as V1 resource with correct apiVersion', async () => {
+ const tab = buildV1DashboardScenario();
+ tab.setState({ exportMode: ExportMode.V1Resource });
+
+ const result = await tab.getExportableDashboardJson();
+
+ // Should use V1 API version
+ expect(result.json).toMatchObject({
+ apiVersion: 'dashboard.grafana.app/v1beta1',
+ kind: 'Dashboard',
+ status: {},
+ });
+
+ // Should call transformSceneToV1 (not transform V2→V1)
+ expect(transformSceneToV1Spy).toHaveBeenCalled();
+ expect(transformV2ToV1Spy).not.toHaveBeenCalled();
+
+ // Should report correct initial version
+ expect(result.initialSaveModelVersion).toBe('v1');
+ });
+
+ // If V2 dashboard → V1 Resource should auto-transform with V1 apiVersion
+ it('should auto-transform V2 dashboard to V1 resource with correct apiVersion', async () => {
+ const tab = buildV2DashboardScenario();
+ // user selects V1Resource even though is V2 dashboard
+ tab.setState({ exportMode: ExportMode.V1Resource });
+
+ const result = await tab.getExportableDashboardJson();
+
+ // Should use V1 API version (not V2!)
+ expect(result.json).toMatchObject({
+ apiVersion: 'dashboard.grafana.app/v1beta1',
+ kind: 'Dashboard',
+ status: {},
+ });
+
+ // Should auto-transform V2→V1
+ expect(transformSceneToV2Spy).toHaveBeenCalled(); // Get V2 spec first
+ expect(transformV2ToV1Spy).toHaveBeenCalled(); // Then transform to V1
+
+ // Should report correct initial version
+ expect(result.initialSaveModelVersion).toBe('v2');
+ });
+
+ // If V2 dashboard → V1 Resource with external sharing should transform and apply external sharing
+ it('should handle external sharing when transforming V2 to V1', async () => {
+ const tab = buildV2DashboardScenario();
+ tab.setState({
+ exportMode: ExportMode.V1Resource,
+ isSharingExternally: true,
+ });
+
+ const result = await tab.getExportableDashboardJson();
+
+ // Should use V1 API version
+ expect(result.json).toMatchObject({
+ apiVersion: 'dashboard.grafana.app/v1beta1',
+ kind: 'Dashboard',
+ status: {},
+ });
+
+ // Should auto-transform V2→V1
+ expect(transformSceneToV2Spy).toHaveBeenCalled();
+ expect(transformV2ToV1Spy).toHaveBeenCalled();
+
+ // Should call makeExportableV1 for external sharing
+ expect(makeExportableV1Spy).toHaveBeenCalled();
+
+ // Should report correct initial version
+ expect(result.initialSaveModelVersion).toBe('v2');
+ });
+ });
+
+ describe('V2Resource export mode', () => {
+ // If V2 dashboard → V2 Resource should export with V2 apiVersion
+ it('should export V2 dashboard as V2 resource with correct apiVersion', async () => {
+ const tab = buildV2DashboardScenario();
+ tab.setState({ exportMode: ExportMode.V2Resource });
+
+ const result = await tab.getExportableDashboardJson();
+
+ // Should use V2 API version
+ expect(result.json).toMatchObject({
+ apiVersion: 'dashboard.grafana.app/v2alpha1',
+ kind: 'Dashboard',
+ status: {},
+ });
+
+ // Should not call V2→V1 transformation since source is already V2
+ expect(transformV2ToV1Spy).not.toHaveBeenCalled();
+
+ // Should report correct initial version
+ expect(result.initialSaveModelVersion).toBe('v2');
+ });
+ });
+
+ describe('Classic export mode', () => {
+ // If V1 dashboard → Classic should export plain dashboard JSON
+ it('should export V1 dashboard in classic format', async () => {
+ const tab = buildV1DashboardScenario();
+ tab.setState({ exportMode: ExportMode.Classic });
+
+ const result = await tab.getExportableDashboardJson();
+
+ // Should return plain dashboard JSON (not wrapped in resource)
+ expect(result.json).toMatchObject({
+ title: 'Test Dashboard V1',
+ uid: 'test-uid-v1',
+ panels: expect.any(Array),
+ });
+
+ // Should NOT have resource wrapper properties
+ expect(result.json).not.toHaveProperty('apiVersion');
+ expect(result.json).not.toHaveProperty('kind');
+ expect(result.json).not.toHaveProperty('status');
+
+ // Should report correct initial version
+ expect(result.initialSaveModelVersion).toBe('v1');
+ });
+ });
+
+ describe('Export mode state management', () => {
+ // If switching to Classic mode should disable YAML viewing
+ it('should disable YAML viewing when switching to Classic mode', async () => {
+ const tab = buildV1DashboardScenario();
+
+ // Start with YAML viewing enabled
+ tab.setState({ isViewingYAML: true });
+ expect(tab.state.isViewingYAML).toBe(true);
+
+ // Switch to Classic mode
+ tab.onExportModeChange(ExportMode.Classic);
+
+ // Should disable YAML viewing
+ expect(tab.state.isViewingYAML).toBe(false);
+ });
+
+ // If switching to resource modes should preserve YAML viewing
+ it('should preserve YAML viewing when switching to resource modes', async () => {
+ const tab = buildV2DashboardScenario();
+
+ // Start with YAML viewing enabled
+ tab.setState({ isViewingYAML: true });
+ expect(tab.state.isViewingYAML).toBe(true);
+
+ // Switch to V1Resource mode
+ tab.onExportModeChange(ExportMode.V1Resource);
+ expect(tab.state.isViewingYAML).toBe(true); // Should preserve
+
+ // Switch to V2Resource mode
+ tab.onExportModeChange(ExportMode.V2Resource);
+ expect(tab.state.isViewingYAML).toBe(true); // Should preserve
+ });
+ });
+
+ // Helper functions to create test scenarios
+ function buildV1DashboardScenario(): ShareExportTab {
+ const mockV1Dashboard: DashboardDataDTO = {
+ title: 'Test Dashboard V1',
+ uid: 'test-uid-v1',
+ version: 1,
+ panels: [],
+ time: { from: 'now-6h', to: 'now' },
+ timepicker: {},
+ timezone: '',
+ weekStart: '',
+ fiscalYearStartMonth: 0,
+ refresh: '',
+ schemaVersion: 30,
+ tags: [],
+ templating: { list: [] },
+ };
+
+ const tab = new ShareExportTab({});
+ const scene = new DashboardScene({
+ title: 'Test Dashboard V1',
+ uid: 'test-uid-v1',
+ meta: { canEdit: true },
+ $timeRange: new SceneTimeRange({}),
+ body: DefaultGridLayoutManager.fromVizPanels([]),
+ overlay: tab,
+ });
+
+ const mockExportableDashboard: DashboardJson = {
+ ...mockV1Dashboard,
+ panels: [],
+ } as DashboardJson;
+ scene.serializer.getSaveModel = jest.fn(() => mockV1Dashboard);
+ scene.serializer.makeExportableExternally = jest.fn(() => Promise.resolve(mockExportableDashboard));
+ scene.serializer.apiVersion = 'dashboard.grafana.app/v1beta1';
+ scene.getInitialSaveModel = jest.fn(() => mockV1Dashboard);
+
+ return tab;
+ }
+
+ function buildV2DashboardScenario(): ShareExportTab {
+ const mockV2Dashboard: DashboardV2Spec = {
+ title: 'Test Dashboard V2',
+ annotations: [],
+ cursorSync: 'Off',
+ description: 'Test V2 dashboard',
+ editable: true,
+ elements: {},
+ layout: { kind: 'GridLayout', spec: { items: [] } },
+ links: [],
+ liveNow: false,
+ preload: false,
+ tags: [],
+ timeSettings: {
+ from: 'now-6h',
+ to: 'now',
+ autoRefresh: '',
+ autoRefreshIntervals: [],
+ hideTimepicker: false,
+ timezone: '',
+ weekStart: 'saturday',
+ fiscalYearStartMonth: 0,
+ },
+ variables: [],
+ };
+
+ const tab = new ShareExportTab({});
+ const scene = new DashboardScene({
+ title: 'Test Dashboard V2',
+ uid: 'test-uid-v2',
+ meta: { canEdit: true },
+ $timeRange: new SceneTimeRange({}),
+ body: DefaultGridLayoutManager.fromVizPanels([]),
+ overlay: tab,
+ });
+
+ scene.serializer.getSaveModel = jest.fn(() => mockV2Dashboard);
+ scene.serializer.makeExportableExternally = jest.fn(() => Promise.resolve(mockV2Dashboard));
+ scene.serializer.apiVersion = 'dashboard.grafana.app/v2alpha1';
+ scene.getInitialSaveModel = jest.fn(() => mockV2Dashboard);
+
+ return tab;
+ }
+});
diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
index c6878c3fcd4..4cb3d0951ea 100644
--- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx
@@ -12,12 +12,15 @@ import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui';
import { ObjectMeta } from 'app/features/apiserver/types';
+import { transformDashboardV2SpecToV1 } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
+import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1';
import { K8S_V2_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v2';
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardJson } from 'app/features/manage-dashboards/types';
+import { DashboardDataDTO } from 'app/types/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
@@ -34,7 +37,7 @@ export interface ExportableResource {
apiVersion: string;
kind: 'Dashboard';
metadata: DashboardWithAccessInfo['metadata'] | Partial;
- spec: Dashboard | DashboardModel | DashboardV2Spec | { error: unknown };
+ spec: Dashboard | DashboardModel | DashboardV2Spec | DashboardJson | DashboardDataDTO | { error: unknown };
// A placeholder for now because as code tooling expects it
status: {};
}
@@ -112,7 +115,12 @@ export class ShareExportTab extends SceneObjectBase impleme
const exportable = isSharingExternally ? exportableDashboard : origDashboard;
const metadata = getMetadata(scene, Boolean(isSharingExternally));
- if (isDashboardV2Spec(origDashboard) && 'elements' in exportable && initialSaveModelVersion === 'v2') {
+ if (
+ isDashboardV2Spec(origDashboard) &&
+ 'elements' in exportable &&
+ initialSaveModelVersion === 'v2' &&
+ exportMode !== ExportMode.V1Resource
+ ) {
this.setState({
exportMode: ExportMode.V2Resource,
});
@@ -131,19 +139,66 @@ export class ShareExportTab extends SceneObjectBase impleme
}
if (exportMode === ExportMode.V1Resource) {
- const spec = transformSceneToSaveModel(scene);
+ // Check if source is V2 and auto-transform to V1
+ if (isDashboardV2Spec(origDashboard) && initialSaveModelVersion === 'v2') {
+ try {
+ const spec = transformSceneToSaveModelSchemaV2(scene);
+ const metadata = getMetadata(scene, Boolean(isSharingExternally));
+ const spec1 = transformDashboardV2SpecToV1(spec, {
+ name: metadata.name ?? '',
+ generation: metadata.generation ?? 0,
+ resourceVersion: metadata.resourceVersion ?? '0',
+ creationTimestamp: metadata.creationTimestamp ?? '',
+ });
- return {
- json: {
- apiVersion: scene.serializer.apiVersion ?? '',
- kind: 'Dashboard',
- metadata,
- spec,
- status: {},
- },
- initialSaveModelVersion,
- hasLibraryPanels: undefined,
- };
+ let exportableV1: Dashboard | DashboardDataDTO | DashboardJson | { error: unknown };
+ if (isSharingExternally) {
+ const oldModel = new DashboardModel(spec1, undefined, {
+ getVariablesFromState: () => {
+ return getVariablesCompatibility(window.__grafanaSceneContext);
+ },
+ });
+ exportableV1 = await makeExportableV1(oldModel);
+ } else {
+ exportableV1 = spec1;
+ }
+ return {
+ json: {
+ // Forcing V1 version here to match export mode selection
+ apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
+ kind: 'Dashboard',
+ metadata,
+ spec: exportableV1,
+ status: {},
+ },
+ initialSaveModelVersion,
+ hasLibraryPanels: undefined,
+ };
+ } catch (err) {
+ return {
+ json: {
+ error: `Failed to convert dashboard to v1. ${err}`,
+ },
+ initialSaveModelVersion,
+ hasLibraryPanels: undefined,
+ };
+ }
+ } else {
+ // Source is already V1, export as-is
+ const spec = transformSceneToSaveModel(scene);
+ return {
+ json: {
+ // Forcing V1 version here to match export mode selection
+ apiVersion: `${K8S_V1_DASHBOARD_API_CONFIG.group}/${K8S_V1_DASHBOARD_API_CONFIG.version}`,
+ kind: 'Dashboard',
+ metadata,
+ spec,
+ status: {},
+ },
+ initialSaveModelVersion,
+ hasLibraryPanels: undefined,
+ };
+ }
}
if (exportMode === ExportMode.V2Resource) {
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx
index 51d805667bf..b852f1520cd 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx
@@ -273,6 +273,48 @@ describe('LokiQueryBuilderOptions', () => {
});
});
});
+
+ describe('Step validation', () => {
+ it('considers empty step as valid', async () => {
+ setup({ expr: 'rate({foo="bar"}[5m]' });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument();
+ });
+
+ it('considers variable step that exists in the datasource as valid', async () => {
+ const datasource = createLokiDatasource();
+ datasource.getVariables = jest.fn().mockReturnValue(['$interval']);
+ setup({ expr: 'rate({foo="bar"}[5m]', step: '$interval' }, undefined, { datasource });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument();
+ });
+
+ it('considers variable step that does not exist in the datasource as invalid', async () => {
+ const datasource = createLokiDatasource();
+ datasource.getVariables = jest.fn().mockReturnValue(['$interval']);
+ setup({ expr: 'rate({foo="bar"}[5m]', step: '$custom' }, undefined, { datasource });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.getByText(/Invalid step/)).toBeInTheDocument();
+ });
+
+ it('considers valid duration step as valid', async () => {
+ setup({ expr: 'rate({foo="bar"}[5m]', step: '1m' });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument();
+ });
+
+ it('considers invalid step as invalid', async () => {
+ setup({ expr: 'rate({foo="bar"}[5m]', step: 'invalid' });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.getByText(/Invalid step/)).toBeInTheDocument();
+ });
+
+ it('considers non-duration number as invalid', async () => {
+ setup({ expr: 'rate({foo="bar"}[5m]', step: '123' });
+ await userEvent.click(screen.getByRole('button', { name: /Options/ }));
+ expect(screen.getByText(/Invalid step/)).toBeInTheDocument();
+ });
+ });
});
function setup(queryOverrides: Partial = {}, onChange = jest.fn(), propOverrides: Partial = {}) {
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
index f692bf9496a..d04e9fc42f6 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
@@ -138,8 +138,18 @@ export const LokiQueryBuilderOptions = React.memo(
if (!query.step) {
return true;
}
- return typeof query.step === 'string' && isValidGrafanaDuration(query.step) && !isNaN(parseInt(query.step, 10));
- }, [query.step]);
+
+ if (typeof query.step === 'string') {
+ // If we use a variable as step, we consider it valid
+ if (datasource.getVariables().includes(query.step)) {
+ return true;
+ }
+ // Check if the step is a valid Grafana duration
+ return isValidGrafanaDuration(query.step) && !isNaN(parseInt(query.step, 10));
+ }
+
+ return false;
+ }, [query.step, datasource]);
return (
diff --git a/public/app/plugins/datasource/prometheus/configuration/DataSourceHttpSettingsOverhaulPackage.tsx b/public/app/plugins/datasource/prometheus/configuration/DataSourceHttpSettingsOverhaulPackage.tsx
index f427d8c2181..098580399e9 100644
--- a/public/app/plugins/datasource/prometheus/configuration/DataSourceHttpSettingsOverhaulPackage.tsx
+++ b/public/app/plugins/datasource/prometheus/configuration/DataSourceHttpSettingsOverhaulPackage.tsx
@@ -141,6 +141,12 @@ export const DataSourcehttpSettingsOverhaul = (props: Props) => {
Service for Prometheus data source to authenticate with SigV4.
)}
+ {azureAuthSelected && (
+
+ Azure authentication in the core Prometheus data source is deprecated. Please use the Azure Monitor Managed
+ Service for Prometheus data source to authenticate using Azure authentication.
+
+ )}
+ Please note that TraceQL metrics is an experimental feature and should not be used in production. Read more about
+ it in{' '}
+
+ documentation
+
+
+ .
+
+ );
+ const inAlerting = props.app === CoreApp.UnifiedAlerting || props.app === CoreApp.CloudAlerting;
+
return (
<>
+ {inAlerting && alertingWarning}
Build complex queries using TraceQL to select a list of traces.{' '}
diff --git a/public/app/plugins/panel/geomap/editor/layerEditor.tsx b/public/app/plugins/panel/geomap/editor/layerEditor.tsx
index 064e6189cf7..865b9f94654 100644
--- a/public/app/plugins/panel/geomap/editor/layerEditor.tsx
+++ b/public/app/plugins/panel/geomap/editor/layerEditor.tsx
@@ -122,7 +122,7 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions