diff --git a/.gitignore b/.gitignore index f1a90d05693..5302a698c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ public/css/*.min.css .vs/ .cursor/ .devcontainer/ +.claude/ .eslintcache .stylelintcache diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts index 56c46ad8d68..a6f52738ff8 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts @@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json'; +import { checkRepeatedPanelTitles } from './utils'; + test.use({ featureToggles: { kubernetesDashboards: true, @@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d test.afterAll(async ({ request }) => { if (uid) { - await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`); + await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`); } }); @@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible(); await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible(); }); + + test.describe('Variable repeat', () => { + const repeatOptions = ['a', 'b', 'c']; + + async function failTestDataRequestForOption(page: Page, option: string) { + await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => { + const rawPostData = route.request().postData(); + if (!rawPostData) { + return; + } + + // the first panel query has a label set to the current variable value + if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) { + await route.fulfill({ status: 500, body: '{}' }); + } else { + await route.continue(); + } + }); + } + + test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => { + const dashboardPage = await loadDashboard(page, gotoDashboardPage); + + await getTab(dashboardPage, selectors, 'repeated items').click(); + + const optionForHiddenPanels = repeatOptions[0]; + + await failTestDataRequestForOption(page, optionForHiddenPanels); + + await checkRepeatedPanelTitles( + dashboardPage, + selectors, + 'Hide panel - ', + [ + `custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`, + `no data (current = ${optionForHiddenPanels})`, + ], + true + ); + + const optionsForVisiblePanels = repeatOptions.slice(1); + + await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [ + ...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`), + ...optionsForVisiblePanels.map((o) => `no data (current = ${o})`), + ]); + }); + }); }); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index c1e00e1a67b..89f4de0eec3 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -97,12 +97,18 @@ export async function checkRepeatedPanelTitles( dashboardPage: DashboardPage, selectors: E2ESelectorGroups, title: string, - options: Array + options: Array, + expectHidden = false ) { for (const option of options) { - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`)) - ).toBeVisible(); + const titleLocator = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Panel.title(`${title}${option}`) + ); + if (expectHidden) { + await expect(titleLocator).toBeHidden(); + } else { + await expect(titleLocator).toBeVisible(); + } } } diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 48a3c00359f..6765d299e53 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index af0d65adc29..8ae318bcefa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index 3b3c6d26fac..a97a04a14b8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -6,7 +6,8 @@ test.use({ featureToggles: { scenes: true, sharingDashboardImage: true, // Enable the export image feature - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 18145a6c739..428193ab5fa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index dd420093696..b0ecf44f9f1 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 530c74c2485..0a982e148b5 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 07aacd5b3fb..20f455ea3a8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index 5c52138ee08..fd5dc979d81 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index 82a2670ef9e..c59e323076d 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index d0a6652ccc6..3398e9aaa35 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 98d394ccc91..26f8b85d13e 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 3ac515e36e6..1a7e03d6243 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index ded0abd61ef..78c35dc5de7 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk'; test.use({ timezoneId: 'Pacific/Easter', featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 2d56cd14512..937224290b0 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index f7a5c4b9dec..4ffd65b83a3 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -16,7 +16,8 @@ test.use({ origins: [], }, featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index 342b912a48a..f457eddf19e 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 3cdd27ed954..99f84cb6d9f 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 7ec014c728b..5fdca6954aa 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index c2f45459431..ca06e31528a 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index 3e28a6f49bc..fa0b5fc1bfd 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index cc3bda551d9..c14a952e1d9 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -52,7 +52,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index 988d79f78ca..cc19e67cda4 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index 0310f6c3500..d76b5291c42 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -18,7 +18,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 212f0ecd018..088f4bd9b12 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index 5c49254284f..c669dc563c4 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index 920bc343275..a55f14b8643 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index 3dc9cfcbcc1..bb188c87e8d 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index 31a60079444..e31c5792062 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 556ec524713..53290345e73 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 78c6a6a44c9..123aa7f3279 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 813815f7f39..1d8fd32ff06 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index c992456a1bc..4fb56ef8b8e 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json index f0d81daa233..522a5d670ac 100644 --- a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json +++ b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json @@ -3308,6 +3308,170 @@ } } }, + "panel-37": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "group": "", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 37, + "links": [], + "title": "Hide panel - custom variable equals a (current = ${myCustomVariable})", + "vizConfig": { + "group": "text", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "", + "mode": "markdown" + } + }, + "version": "12.2.0-pre" + } + } + }, + "panel-38": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": { + "labels": "key=$myCustomVariable", + "scenarioId": "random_walk", + "seriesCount": 1 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 38, + "links": [], + "title": "Hide panel - no data (current = ${myCustomVariable})", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "12.2.0-pre" + } + } + }, "panel-4": { "kind": "Panel", "spec": { @@ -5091,6 +5255,80 @@ }, "title": "Tab - hide - time range <7d" } + }, + { + "kind": "TabsLayoutTab", + "spec": { + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingVariable", + "spec": { + "operator": "equals", + "value": "a", + "variable": "myCustomVariable" + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-37" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingData", + "spec": { + "value": false + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-38" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "title": "Tab - repeated items" + } } ] } @@ -5122,6 +5360,39 @@ "query": "", "skipUrlSync": false } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": false, + "current": { + "text": "All", + "value": "$__all" + }, + "hide": "dontHide", + "includeAll": true, + "multi": false, + "name": "myCustomVariable", + "options": [ + { + "selected": false, + "text": "a", + "value": "a" + }, + { + "selected": false, + "text": "b", + "value": "b" + }, + { + "selected": false, + "text": "c", + "value": "c" + } + ], + "query": "a, b, c", + "skipUrlSync": false + } } ] }, diff --git a/eslint-suppressions.json b/eslint-suppressions.json index fde5e53587d..3f5773bf84f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1912,11 +1912,6 @@ "count": 4 } }, - "public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": { "no-restricted-syntax": { "count": 3 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 33750126afa..900f86ba33f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -361,6 +361,10 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** + * Use the v2 kubernetes API in the frontend for dashboards + */ + kubernetesDashboardsV2?: boolean; + /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 651d858e799..7c542121a7b 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = { label: 'Custom Variable', multi: true, name: 'customVar', - options: [ - { - selected: true, - text: 'option1', - value: 'option1', - }, - { - selected: false, - text: 'option2', - value: 'option2', - }, - ], + options: [], query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, @@ -490,5 +479,18 @@ export const handyTestingSchema: Spec = { allowCustomValue: true, }, }, + { + kind: 'SwitchVariable', + spec: { + name: 'switchVar', + label: 'Switch Variable', + description: 'A switch variable', + current: 'false', + enabledValue: 'true', + disabledValue: 'false', + hide: 'dontHide', + skipUrlSync: false, + }, + }, ], }; diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index fb42543de33..ad598f62f9f 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 99c9dda7d8d..2417c84aed8 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -346,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore } //nolint:staticcheck // not yet migrated to OpenFeature diff --git a/pkg/registry/apis/iam/role_binding_hooks.go b/pkg/registry/apis/iam/role_binding_hooks.go new file mode 100644 index 00000000000..c88c46976eb --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks.go @@ -0,0 +1,302 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +const resourceType = "rolebinding" + +// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "create" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("writing role binding to zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to write role binding to zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("deleting role binding from zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to delete role binding from zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates. +// It performs the zanzana write after K8s update succeeds. +func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract role bindings from both old and new objects + oldRB, ok := oldObj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + newRB, ok := obj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) { + return nil, nil // No changes to the role binding + } + + if newRB.Spec.Subject.Name == "" { + b.logger.Error("invalid role binding", + "namespace", newRB.Namespace, + "name", newRB.Name, + "subject", newRB.Spec.Subject.Name, + "roleRefs", newRB.Spec.RoleRefs, + ) + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("updating role binding in zanzana", + "namespace", newRB.Namespace, + "name", newRB.Name, + "oldSubject", oldRB.Spec.Subject.Name, + "newSubject", newRB.Spec.Subject.Name, + "oldRoleRefs", oldRB.Spec.RoleRefs, + "newRoleRefs", newRB.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs)) + for _, roleRef := range oldRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(oldRB.Spec.Subject.Kind), + SubjectName: oldRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + for _, roleRef := range newRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(newRB.Spec.Subject.Kind), + SubjectName: newRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + // Only make the request if there are deletes or writes + if len(operations) == 0 { + b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name) + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: newRB.Namespace, + Operations: operations, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update role binding in zanzana", + "err", err, + "namespace", newRB.Namespace, + "name", newRB.Name, + ) + } + }() + }, nil +} + +func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool { + if len(oldRoleRefs) != len(newRoleRefs) { + return false + } + + oldRoleRefsMap := make(map[string]string) + for _, roleRef := range oldRoleRefs { + oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind) + } + for _, roleRef := range newRoleRefs { + refKind, ok := oldRoleRefsMap[roleRef.Name] + if !ok { + return false + } + if refKind != string(roleRef.Kind) { + return false + } + } + return true +} diff --git a/pkg/registry/apis/iam/role_binding_hooks_test.go b/pkg/registry/apis/iam/role_binding_hooks_test.go new file mode 100644 index 00000000000..dd9646282fe --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks_test.go @@ -0,0 +1,448 @@ +package iam + +import ( + "context" + "slices" + "sync" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/require" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +func TestAfterRoleBindingCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for role binding", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) + require.Equal(t, "org-1", req.Namespace) + + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + } + + actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + + require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind) + require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName) + require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind) + require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding} + b.AfterRoleBindingCreate(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingCreate(&roleBinding, nil) + }) +} + +func TestBeginRoleBindingUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when role binding changed", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-foo", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-bar", + }, + }, + }, + } + + testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 3) + + // Should write new binding and delete old one + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-foo", + }, + }, + })) + + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-bar", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should return nil finish func when bindings are identical", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when bindings are identical") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when bindings are identical + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + }) + + t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "", + Name: "", // Empty name - should cause early return + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when new binding has empty subject name") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + }) +} + +func TestAfterRoleBindingDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + })) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-2", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete} + b.AfterRoleBindingDelete(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingDelete(&roleBinding, nil) + }) +} + +func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool { + return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool { + switch operation.Operation.(type) { + case *v1.MutateOperation_DeleteRoleBinding: + deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding) + deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding) + if !ok { + return false + } + return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind && + deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName && + deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind && + deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName + case *v1.MutateOperation_CreateRoleBinding: + createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding) + createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding) + if !ok { + return false + } + return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind && + createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName && + createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind && + createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName + } + return false + }) +} diff --git a/pkg/services/datasources/fakes/fake_datasource_service.go b/pkg/services/datasources/fakes/fake_datasource_service.go index 19e7bbb43a5..7637657f7cf 100644 --- a/pkg/services/datasources/fakes/fake_datasource_service.go +++ b/pkg/services/datasources/fakes/fake_datasource_service.go @@ -15,6 +15,9 @@ type FakeDataSourceService struct { lastID int64 DataSources []*datasources.DataSource SimulatePluginFailure bool + + // UID -> Headers + DataSourceHeaders map[string]http.Header } var _ datasources.DataSourceService = &FakeDataSourceService{} @@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas } func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) { - return nil, nil + return s.DataSourceHeaders[ds.UID], nil } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 37ee3001e99..4fe92fe90d6 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -579,6 +579,13 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, + { + Name: "kubernetesDashboardsV2", + Description: "Use the v2 kubernetes API in the frontend for dashboards", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaDashboardsSquad, + }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f66bc50bbc7..9caa916283d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -80,6 +80,7 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1217d1f5a28..89a171a76ba 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,6 +259,10 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" + // FlagKubernetesDashboardsV2 + // Use the v2 kubernetes API in the frontend for dashboards + FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" + // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 46e5eda90a7..aeaf5a408af 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1911,6 +1911,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "kubernetesDashboardsV2", + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z" + }, + "spec": { + "description": "Use the v2 kubernetes API in the frontend for dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "kubernetesExternalGroupMapping", @@ -3547,6 +3559,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "v2DashboardAPIVersion", + "resourceVersion": "1762457740470", + "creationTimestamp": "2025-11-06T19:22:05Z", + "deletionTimestamp": "2025-11-27T09:34:14Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC" + } + }, + "spec": { + "description": "Enables the v2 dashboard API version", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "vizActionsAuth", @@ -3589,4 +3617,4 @@ } } ] -} \ No newline at end of file +} diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 2eb4d82dce3..0a589f81a49 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -239,6 +239,30 @@ const CHECK_INTERVAL = 1 * 1000; + function getCookie(name) { + const cookies = document.cookie.split(";").map(c => c.trim()); + + for (const cookie of cookies) { + if (cookie.startsWith(name + "=")) { + return cookie.substring(name.length + 1); + } + } + + return null; + } + + function getSessionExpiration() { + const value = getCookie("grafana_session_expiry") || "0"; + const realExpiresSeconds = parseInt(value, 10); + const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration + const expiration = new Date(expiresSeconds * 1000); + return expiration; + } + + async function rotateSession() { + await fetch('/api/user/auth-tokens/rotate', { method: 'POST' }); + } + /** * Fetches boot data from the server. If it returns undefined, it should be retried later. * Will return a rejected promise on unrecoverable errors. @@ -295,6 +319,19 @@ function loadBootData() { return new Promise((resolve, reject) => { const attemptFetch = async () => { + try { + const sessionExpiration = getSessionExpiration(); + const now = new Date(); + + // If the session has expired, don't continue trying to fetch boot data + if (now >= sessionExpiration) { + await rotateSession(); + } + } catch (error) { + // Just ignore any errors in session rotation. The user can just log in again. + console.warn("Failed to rotate session", error); + } + try { const bootData = await fetchBootData(); diff --git a/pkg/services/ngalert/writer/datasourcewriter.go b/pkg/services/ngalert/writer/datasourcewriter.go index 19af0db0a5b..cc7036974e7 100644 --- a/pkg/services/ngalert/writer/datasourcewriter.go +++ b/pkg/services/ngalert/writer/datasourcewriter.go @@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st return nil, err } + // We need to add the writer headers (valid for any data source) and any data-source-specific headers. headers := make(http.Header) for k, v := range w.cfg.CustomHeaders { headers.Add(k, v) } + dsHeaders, err := w.datasources.CustomHeaders(ctx, ds) + if err != nil { + return nil, fmt.Errorf("failed to get headers for data source: %w", err) + } + + for k, values := range dsHeaders { + for _, v := range values { + headers.Add(k, v) + } + } + var backend backendType if dsUID == string(grafanaCloudPromType) { backend = grafanaCloudPromType diff --git a/pkg/services/ngalert/writer/datasourcewriter_test.go b/pkg/services/ngalert/writer/datasourcewriter_test.go index f06d2d95de4..349d8b9191a 100644 --- a/pkg/services/ngalert/writer/datasourcewriter_test.go +++ b/pkg/services/ngalert/writer/datasourcewriter_test.go @@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl type testDataSources struct { dsfakes.FakeDataSourceService - prom1, prom2, prom3 *TestRemoteWriteTarget + prom1, prom2, prom3, prom4 *TestRemoteWriteTarget } func (t *testDataSources) Reset() { t.prom1.Reset() t.prom2.Reset() t.prom3.Reset() + t.prom4.Reset() } func setupDataSources(t *testing.T) *testDataSources { @@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources { prom1: NewTestRemoteWriteTarget(t), prom2: NewTestRemoteWriteTarget(t), prom3: NewTestRemoteWriteTarget(t), + prom4: NewTestRemoteWriteTarget(t), } + res.DataSourceHeaders = make(map[string]http.Header) t.Cleanup(func() { res.prom1.Close() @@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources { t.Cleanup(func() { res.prom3.Close() }) + t.Cleanup(func() { + res.prom4.Close() + }) p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-1", @@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources { Type: datasources.DS_LOKI, }) - // Add a third Prometheus datasource that uses PDC + // Add a third Prometheus datasource that uses PDC. p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-3", UID: "prom-3", @@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources { require.True(t, p3.IsSecureSocksDSProxyEnabled()) + // Add a fourth Prometheus datasource with headers in the JSON config. + p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Name: "prom-4", + UID: "prom-4", + Type: datasources.DS_PROMETHEUS, + JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)), + }) + p4.URL = res.prom4.srv.URL + res.prom4.ExpectedPath = "/api/v1/write" + res.DataSourceHeaders["prom-4"] = http.Header{ + "X-Scope-OrgID": []string{"test-user"}, + "X-Test-Header": []string{"test-value"}, + "X-Double-Header": []string{"one", "two", "three"}, + } + return res } @@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) { assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2)) }) + t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) { + testDS.Reset() + overwrittenHeader := "X-Test-Header" + cHeaders := map[string]string{ + "X-Custom-Header": "test-value", + "X-Another-Header": "another-value", + overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers. + } + + cfg = DatasourceWriterConfig{ + Timeout: time.Second * 5, + DefaultDatasourceUID: "prom-1", + CustomHeaders: cHeaders, + } + writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + + uid := "prom-4" + err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{}) + require.NoError(t, err) + + dsHeaders := testDS.DataSourceHeaders[uid] + require.Len(t, dsHeaders, 3) + + // We're confirming we have a data source header with the same name but different value. + // This one should not be sent in the request. + require.NotEmpty(t, dsHeaders[overwrittenHeader]) + require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader]) + + // All headers (except for the one that was overwritten) should have been used. + for k, vv := range dsHeaders { + if k != overwrittenHeader { + assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k)) + } + } + for k, v := range cHeaders { + assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k)) + } + }) + t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) { testDS.Reset() diff --git a/pkg/services/ngalert/writer/testing.go b/pkg/services/ngalert/writer/testing.go index 91b1bdb6b10..8b764657d6e 100644 --- a/pkg/services/ngalert/writer/testing.go +++ b/pkg/services/ngalert/writer/testing.go @@ -1,6 +1,7 @@ package writer import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget { handler := func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != target.ExpectedPath { - require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path) + require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path)) } target.mtx.Lock() diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index d4842bee884..1fb8d06d313 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -581,10 +581,16 @@ func TestMain(m *testing.M) { // nolint:staticcheck testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally - if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { - return nil, err + skipTruncate := false + if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present { + skipTruncate = strings.ToLower(skip) == "true" + } + if !skipTruncate { + if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { + return nil, err + } + testSQLStore.engine.ResetSequenceGenerator() } - testSQLStore.engine.ResetSequenceGenerator() if err := testSQLStore.Reset(); err != nil { return nil, err diff --git a/pkg/storage/unified/migrations/folders_dashboards_test.go b/pkg/storage/unified/migrations/folders_dashboards_test.go new file mode 100644 index 00000000000..27b1c0e8ba6 --- /dev/null +++ b/pkg/storage/unified/migrations/folders_dashboards_test.go @@ -0,0 +1,208 @@ +package migrations_test + +import ( + "fmt" + "net/http" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration +type foldersAndDashboardsTestCase struct { + parentFolderUID string + childFolderUID string + dashboardUID string + libPanelUID string +} + +// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator +func newFoldersAndDashboardsTestCase() resourceMigratorTestCase { + return &foldersAndDashboardsTestCase{ + parentFolderUID: "parent-folder-uid", + childFolderUID: "child-folder-uid", + dashboardUID: "", // Will be generated during setup + libPanelUID: "", // Will be generated during setup + } +} + +func (tc *foldersAndDashboardsTestCase) name() string { + return "folders-dashboards" +} + +func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource { + return []schema.GroupVersionResource{ + { + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + { + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + } +} + +func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) { + t.Helper() + + // Create parent folder + parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "") + + // Create child folder (nested under parent) + child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID) + + // Create library panel in child folder + tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID) + + // Create dashboard with library panel in child folder + tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel", + tc.libPanelUID, "Test LP in dashboard", child.UID) +} + +func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) { + t.Helper() + + // Build maps of UIDs by resource type + folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID} + dashboardUIDs := []string{tc.dashboardUID} + + expectedFolderCount := 0 + if shouldExist { + expectedFolderCount = len(folderUIDs) + } + orgID := helper.Org1.OrgID + namespace := authlib.OrgNamespaceFormatter(orgID) + + // Verify folders + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + }) + verifyResourceCount(t, folderCli, expectedFolderCount) + for _, uid := range folderUIDs { + verifyResource(t, folderCli, uid, shouldExist) + } + + // Verify dashboards + expectedDashboardCount := 0 + if shouldExist { + expectedDashboardCount = len(dashboardUIDs) + } + dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + }) + verifyResourceCount(t, dashboardCli, expectedDashboardCount) + for _, uid := range dashboardUIDs { + verifyResource(t, dashboardCli, uid, shouldExist) + } +} + +// createTestFolder creates a folder with specified UID and optional parent +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + require.Equal(t, uid, folderCreate.Result.UID) + + return folderCreate.Result +} + +// createTestLibraryPanel creates a library panel in a folder +func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string { + t.Helper() + + libPanelPayload := fmt.Sprintf(`{ + "kind": 1, + "name": "%s", + "folderUid": "%s", + "model": { + "type": "text", + "title": "%s" + } + }`, name, folderUID, name) + + libCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/library-elements", + Body: []byte(libPanelPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, libCreate.Response) + require.Equal(t, http.StatusOK, libCreate.Response.StatusCode) + + libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string) + require.NotEmpty(t, libPanelUID) + + return libPanelUID +} + +// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel +func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string { + t.Helper() + + dashPayload := fmt.Sprintf(`{ + "dashboard": { + "title": "%s", + "panels": [{ + "id": 1, + "libraryPanel": { + "uid": "%s", + "name": "%s" + } + }] + }, + "folderUid": "%s", + "overwrite": false + }`, dashTitle, libPanelUID, libPanelName, folderUID) + + dashCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/dashboards/db", + Body: []byte(dashPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, dashCreate.Response) + require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode) + + dashUID := (*dashCreate.Result)["uid"].(string) + require.NotEmpty(t, dashUID) + return dashUID +} diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go new file mode 100644 index 00000000000..e75ed26b9e3 --- /dev/null +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -0,0 +1,210 @@ +package migrations_test + +import ( + "context" + "fmt" + "os" + "testing" + + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// resourceMigratorTestCase defines the interface for testing a resource migrator. +type resourceMigratorTestCase interface { + // name returns the test case name + name() string + // resources returns the GVRs that this migrator handles + resources() []schema.GroupVersionResource + // setup creates test resources in legacy storage (Mode0) + setup(t *testing.T, helper *apis.K8sTestHelper) + // verify checks that resources exist (or don't exist) in unified storage + verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) +} + +// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage. +// The test follows a three-step process: +// Step 1: inserts legacy data (migration disabled at startup) +// Step 2: verifies that the data is not in unified storage +// Step 3: migration runs at startup, and the test verifies that the data is in unified storage +func TestIntegrationMigrations(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + migrationTestCases := []resourceMigratorTestCase{ + newFoldersAndDashboardsTestCase(), + } + + runMigrationTestSuite(t, migrationTestCases) +} + +// runMigrationTestSuite executes the migration test suite for the given test cases +func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-migration-test-suite.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + // Store UIDs created by each test case + type testCaseState struct { + tc resourceMigratorTestCase + } + testStates := make([]testCaseState, len(testCases)) + for i, tc := range testCases { + testStates[i].tc = tc + } + + // reuse org users throughout the tests + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + t.Run("Step 1: Create data in legacy", func(t *testing.T) { + // Enforce Mode0 for all migrated resources + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode0, + } + } + } + + // Set up test environment with Mode0 (writes only to legacy) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + t.Cleanup(helper.Shutdown) + org1 = &helper.Org1 + orgB = &helper.OrgB + + for i := range testStates { + state := &testStates[i] + t.Run(state.tc.name(), func(t *testing.T) { + state.tc.setup(t, helper) + // Verify resources were created in legacy storage + state.tc.verify(t, helper, true) + }) + } + }) + + // Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1 + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) { + // Build unified storage config for Mode5 + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode5, + } + } + } + + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources don't exist in unified storage yet + state.tc.verify(t, helper, false) + }) + } + }) + + t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) { + // Migrations will run automatically at startup and mode 5 is enforced by the config + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + // EnableLog: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Run migrations at startup + APIServerStorageType: "unified", + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources now exist in unified storage after migration + state.tc.verify(t, helper, true) + }) + } + }) +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} + +// verifyResource verifies that a resource with the given UID exists in K8s storage +func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) { + t.Helper() + + _, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + if shouldExist { + require.NoError(t, err) + } else { + require.Error(t, err) + } +} diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index b69b5e9e1d1..35c349184bf 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -93,7 +93,18 @@ type K8sTestHelper struct { userSvc user.Service } +type K8sTestHelperOpts struct { + testinfra.GrafanaOpts + // If provided, these users will be used instead of creating new ones + Org1Users *OrgUsers + OrgBUsers *OrgUsers +} + func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { + return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts}) +} + +func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper { t.Helper() // Use GRPC server when not configured @@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { path = opts.DirPath ) if opts.Dir == "" && opts.DirPath == "" { - dir, path = testinfra.CreateGrafDir(t, opts) + dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts) + } + listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path) + if !opts.DisableDBCleanup { + t.Cleanup(testDB.Cleanup) } - listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path) c := &K8sTestHelper{ env: *env, @@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { _ = c.CreateOrg(Org1) _ = c.CreateOrg(Org2) - c.Org1 = c.createTestUsers(Org1) - c.OrgB = c.createTestUsers(Org2) + if opts.Org1Users != nil { + c.Org1 = *opts.Org1Users + c.Org1.Admin.baseURL = listenerAddress + c.Org1.Editor.baseURL = listenerAddress + c.Org1.Viewer.baseURL = listenerAddress + c.Org1.None.baseURL = listenerAddress + } else { + c.Org1 = c.createTestUsers(Org1) + } + if opts.OrgBUsers != nil { + c.OrgB = *opts.OrgBUsers + c.OrgB.Admin.baseURL = listenerAddress + c.OrgB.Editor.baseURL = listenerAddress + c.OrgB.Viewer.baseURL = listenerAddress + c.OrgB.None.baseURL = listenerAddress + } else { + c.OrgB = c.createTestUsers(Org2) + } c.loadAPIGroups() diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index d95f108a231..25b2d4b177b 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) { } func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) { + addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath) + t.Cleanup(testDB.Cleanup) + return addr, env +} + +func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) { t.Helper() ctx := context.Background() @@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes // Use proper database type based on the environment variable GRAFANA_TEST_DB in tests testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType()) require.NoError(t, err) - t.Cleanup(testDB.Cleanup) dbCfg := cfg.Raw.Section("database") dbCfg.Key("type").SetValue(testDB.DriverName) @@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes t.Logf("Grafana is listening on %s", addr) - return addr, env + return addr, env, testDB } // CreateGrafDir creates the Grafana directory. @@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes)) require.NoError(t, err) } + if opts.DisableDataMigrations { + section, err := getOrCreateSection("unified_storage") + require.NoError(t, err) + _, err = section.NewKey("disable_data_migrations", "true") + require.NoError(t, err) + } if opts.PermittedProvisioningPaths != "" { _, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths) require.NoError(t, err) @@ -637,6 +648,8 @@ type GrafanaOpts struct { EnableSCIM bool APIServerRuntimeConfig string DisableControllers bool + DisableDBCleanup bool + DisableDataMigrations bool SecretsManagerEnableDBMigrations bool // Allow creating grafana dir beforehand diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 565a50e46ad..a8e4279de7f 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -39,6 +39,7 @@ export type GrafanaPromRulesOptions = Omit { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts index e6aa3089e55..b2360913581 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts @@ -22,6 +22,27 @@ import { ruleTypeFilter, } from './filterPredicates'; +/** + * Determines if client-side filtering is needed for data source-managed rules. + */ +export function hasDatasourceClientSideFilters(filterState: Partial): boolean { + // Check if any filter that applies to datasource rules is active + return ( + (filterState.freeFormWords && filterState.freeFormWords.length > 0) || + Boolean(filterState.ruleName) || + Boolean(filterState.ruleState) || + Boolean(filterState.ruleType) || + (filterState.dataSourceNames && filterState.dataSourceNames.length > 0) || + (filterState.labels && filterState.labels.length > 0) || + Boolean(filterState.ruleHealth) || + Boolean(filterState.dashboardUid) || + Boolean(filterState.plugins) || + Boolean(filterState.contactPoint) || + Boolean(filterState.namespace) || + Boolean(filterState.groupName) + ); +} + /** * Builds filter configurations for data source-managed alert rules. * diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts index d8594135463..7bcf22e30dc 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts @@ -32,12 +32,14 @@ export function buildTitleSearch(filterState: RulesFilter): string | undefined { * Normalize filter state for case-insensitive matching * Lowercase free form words, rule name, group name and namespace */ -export function normalizeFilterState(filterState: RulesFilter): RulesFilter { +export function normalizeFilterState(filterState: Partial): RulesFilter { return { ...filterState, - freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), + freeFormWords: filterState.freeFormWords?.map((word) => word.toLowerCase()) ?? [], ruleName: filterState.ruleName?.toLowerCase(), groupName: filterState.groupName?.toLowerCase(), namespace: filterState.namespace?.toLowerCase(), + dataSourceNames: filterState.dataSourceNames ?? [], + labels: filterState.labels ?? [], }; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 56be25ee248..6a714382a96 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -8,7 +8,7 @@ import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter'; +import { getGrafanaFilter, hasGrafanaClientSideFilters } from './grafanaFilter'; jest.mock('../../utils/datasource'); @@ -670,41 +670,41 @@ describe('grafana-managed rules', () => { }); }); - describe('hasClientSideFilters', () => { + describe('hasGrafanaClientSideFilters', () => { describe('when alertingUIUseBackendFilters is disabled', () => { testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return true for title-related filters (freeFormWords, ruleName)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); }); it('should return true for ruleType filter', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); }); it('should return true for dashboardUid filter', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); }); it('should return true for groupName filter', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -712,36 +712,36 @@ describe('grafana-managed rules', () => { testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return false for title-related filters (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); }); it('should return false for ruleType filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); }); it('should return false for dashboardUid filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); }); it('should return false for groupName filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -750,20 +750,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: frontend-handled filters - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); @@ -772,20 +772,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, all backend-handled filters, and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: always-frontend filters only - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 5b5ddd81f9b..0cc89ceafcf 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -24,26 +24,26 @@ import { /** * Determines if client-side filtering is needed for Grafana-managed rules. */ -export function hasClientSideFilters(filterState: RulesFilter): boolean { +export function hasGrafanaClientSideFilters(filterState: Partial): boolean { const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); // Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveRuleFilters = - (ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) || - (ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) || - (ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) || - (ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) || - (ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) || - (ruleFilterConfig.labels !== null && filterState.labels.length > 0) || - (ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) || - (ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) || - (ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) || - (ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint)); + (ruleFilterConfig.freeFormWords !== null && Boolean(filterState?.freeFormWords?.length)) || + (ruleFilterConfig.ruleName !== null && Boolean(filterState?.ruleName)) || + (ruleFilterConfig.ruleState !== null && Boolean(filterState?.ruleState)) || + (ruleFilterConfig.ruleType !== null && Boolean(filterState?.ruleType)) || + (ruleFilterConfig.dataSourceNames !== null && Boolean(filterState?.dataSourceNames?.length)) || + (ruleFilterConfig.labels !== null && Boolean(filterState?.labels?.length)) || + (ruleFilterConfig.ruleHealth !== null && Boolean(filterState?.ruleHealth)) || + (ruleFilterConfig.dashboardUid !== null && Boolean(filterState?.dashboardUid)) || + (ruleFilterConfig.plugins !== null && Boolean(filterState?.plugins)) || + (ruleFilterConfig.contactPoint !== null && Boolean(filterState?.contactPoint)); // Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveGroupFilters = - (groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) || - (groupFilterConfig.groupName !== null && Boolean(filterState.groupName)); + (groupFilterConfig.namespace !== null && Boolean(filterState?.namespace)) || + (groupFilterConfig.groupName !== null && Boolean(filterState?.groupName)); return hasActiveRuleFilters || hasActiveGroupFilters; } @@ -55,7 +55,7 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { * The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled, * while the frontend filter provides client-side matching functions for rules and groups. */ -export function getGrafanaFilter(filterState: RulesFilter) { +export function getGrafanaFilter(filterState: Partial) { const normalizedFilterState = normalizeFilterState(filterState); const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); 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 db2d5df077d..add1097fa0f 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { MergeExclusive } from 'type-fest'; import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting'; import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; @@ -16,27 +17,23 @@ interface UseGeneratorHookOptions { limitAlerts?: number; } -interface FetchGroupsOptions { - groupLimit?: number; - groupNextToken?: string; -} - export function usePrometheusGroupsGenerator() { const [getGroups] = useLazyGetGroupsQuery(); return useCallback( async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) { - const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => { + const getRuleSourceGroupsWithCache = async (fetchOptions: GroupsNextPageOptions) => { const response = await getGroups({ ruleSource: { uid: ruleSource.uid }, notificationOptions: { showErrorAlert: false }, + groupLimit, ...fetchOptions, }).unwrap(); return response; }; - yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit); + yield* genericGroupsGenerator(getRuleSourceGroupsWithCache); }, [getGroups] ); @@ -52,8 +49,21 @@ interface GrafanaPromApiFilter { dashboardUid?: string; } -interface GrafanaFetchGroupsOptions extends FetchGroupsOptions { +interface GrafanaFetchGroupsOptions extends GroupsNextPageOptions { filter?: GrafanaPromApiFilter; + groupLimit?: number; + // Limits the number of total rules returned across all groups + // Rounds up to full groups, so the response may contain more rules than the group limit + ruleLimit?: number; +} + +export type GrafanaFetchGroupsLimit = MergeExclusive<{ groupLimit: number }, { ruleLimit: number }>; + +export type DataSourceFetchGroupsLimit = { groupLimit: number }; + +export interface FetchGroupsLimitOptions { + grafanaManagedLimit: GrafanaFetchGroupsLimit; + datasourceManagedLimit: DataSourceFetchGroupsLimit; } export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) { @@ -78,11 +88,16 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = ); return useCallback( - async function* (groupLimit: number, filter?: GrafanaPromApiFilter) { - yield* genericGroupsGenerator( - (fetchOptions) => getGroupsAndProvideCache({ ...fetchOptions, filter }), - groupLimit - ); + async function* (limit: GrafanaFetchGroupsLimit, filter?: GrafanaPromApiFilter) { + const fetchGroups = (fetchOptions: GroupsNextPageOptions) => + getGroupsAndProvideCache({ + ...fetchOptions, + filter, + groupLimit: 'groupLimit' in limit ? limit.groupLimit : undefined, + ruleLimit: 'ruleLimit' in limit ? limit.ruleLimit : undefined, + }); + + yield* genericGroupsGenerator(fetchGroups); }, [getGroupsAndProvideCache] ); @@ -105,21 +120,24 @@ export function toIndividualRuleGroups( })(); } +interface GroupsNextPageOptions { + groupNextToken?: string; +} + // Generator lazily provides groups one by one only when needed // This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources // For unpaginated data sources we fetch everything in one go // For paginated we fetch the next page when needed async function* genericGroupsGenerator( - fetchGroups: (options: FetchGroupsOptions) => Promise>, - groupLimit: number + fetchGroups: (options: GroupsNextPageOptions) => Promise> ) { - let response = await fetchGroups({ groupLimit }); + let response = await fetchGroups({ groupNextToken: undefined }); yield response.data.groups; let lastToken: string | undefined = response.data?.groupNextToken; while (lastToken) { - response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit }); + response = await fetchGroups({ groupNextToken: lastToken }); yield response.data.groups; lastToken = response.data?.groupNextToken; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 407426bb3e1..7c5533beea5 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -26,7 +26,11 @@ import { RulePositionHash, createRulePositionHash } from '../rulePositionHash'; import { getDatasourceFilter } from './datasourceFilter'; import { getGrafanaFilter } from './grafanaFilter'; -import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; +import { + FetchGroupsLimitOptions, + useGrafanaGroupsGenerator, + usePrometheusGroupsGenerator, +} from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -74,7 +78,7 @@ export function useFilteredRulesIteratorProvider() { const prometheusGroupsGenerator = usePrometheusGroupsGenerator(); const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ limitAlerts: 0 }); - const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => { + const getFilteredRulesIterable = (filterState: RulesFilter, options: FetchGroupsLimitOptions): GetIteratorResult => { /* this is the abort controller that allows us to stop an AsyncIterable */ const abortController = new AbortController(); @@ -83,7 +87,7 @@ export function useFilteredRulesIteratorProvider() { const { backendFilter, frontendFilter } = getGrafanaFilter(filterState); const grafanaRulesGenerator: AsyncIterableX = from( - grafanaGroupsGenerator(groupLimit, backendFilter) + grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter) ).pipe( withAbort(abortController.signal), concatMap((groups) => @@ -110,7 +114,7 @@ export function useFilteredRulesIteratorProvider() { const dataSourceGenerators: Array> = externalRulesSourcesToFetchFrom.map( (dataSourceIdentifier) => { const promGroupsGenerator: AsyncIterableX = from( - prometheusGroupsGenerator(dataSourceIdentifier, groupLimit) + prometheusGroupsGenerator(dataSourceIdentifier, options.datasourceManagedLimit.groupLimit) ).pipe( withAbort(abortController.signal), concatMap((groups) => diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts new file mode 100644 index 00000000000..5ef8431aced --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -0,0 +1,181 @@ +import { testWithFeatureToggles } from 'test/test-utils'; + +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { RuleHealth, RulesFilter } from '../search/rulesSearchParser'; +import { getFilter } from '../utils/search'; + +import { + FILTERED_GROUPS_LARGE_API_PAGE_SIZE, + FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + RULE_LIMIT_WITH_BACKEND_FILTERS, + getFilteredRulesLimits, +} from './paginationLimits'; + +describe('paginationLimits', () => { + describe('getFilteredRulesLimits', () => { + describe('when backend filters are disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return small limits when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])('should return small grafana limit + large datasource limit for backend-only filter: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { dashboardUid: 'test-dashboard' }, + { plugins: 'hide' as const }, + { namespace: 'production' }, + { groupName: 'test-group' }, + { namespace: 'production', freeFormWords: ['cpu'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { groupName: 'test-group' }, + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when both backend filter toggles are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts index a8e03ac5a48..a5d359ded7b 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -1,3 +1,10 @@ +import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../featureToggles'; +import { RulesFilter } from '../search/rulesSearchParser'; + +import { hasDatasourceClientSideFilters } from './hooks/datasourceFilter'; +import { hasGrafanaClientSideFilters } from './hooks/grafanaFilter'; +import { FetchGroupsLimitOptions } from './hooks/prometheusGroupsGenerator'; + export const FRONTEND_LIST_PAGE_SIZE = 100; export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000; @@ -6,6 +13,8 @@ export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100; export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; +export const RULE_LIMIT_WITH_BACKEND_FILTERS = 100; + export function getApiGroupPageSize(hasFilters: boolean) { return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; } @@ -13,3 +22,27 @@ export function getApiGroupPageSize(hasFilters: boolean) { export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) { return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE; } + +export function getFilteredRulesLimits(filterState: RulesFilter): FetchGroupsLimitOptions { + return { + grafanaManagedLimit: getGrafanaFilterLimits(filterState), + datasourceManagedLimit: { + groupLimit: hasDatasourceClientSideFilters(filterState) + ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE + : FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + }, + }; +} + +function getGrafanaFilterLimits(filterState: RulesFilter) { + const backendFiltersEnabled = shouldUseFullyCompatibleBackendFilters() || shouldUseBackendFilters(); + + const frontendFiltersInUse = hasGrafanaClientSideFilters(filterState); + const onlyBackendFiltersInUse = frontendFiltersInUse === false; + + if (backendFiltersEnabled && onlyBackendFiltersInUse) { + return { ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }; + } + + return { groupLimit: getSearchApiGroupPageSize(frontendFiltersInUse) }; +} diff --git a/public/app/features/commandPalette/actions/scopeActions.test.tsx b/public/app/features/commandPalette/actions/scopeActions.test.tsx index 0d828d66a81..126877224de 100644 --- a/public/app/features/commandPalette/actions/scopeActions.test.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.test.tsx @@ -22,7 +22,7 @@ jest.mock('./scopesUtils', () => { }); const mockScopeServicesState = { - updateNode: jest.fn(), + filterNode: jest.fn(), selectScope: jest.fn(), resetSelection: jest.fn(), nodes: {}, @@ -99,12 +99,12 @@ describe('useRegisterScopesActions', () => { }); it('should register scope tree actions and return scopesRow when scopes are selected', () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }], }); @@ -112,14 +112,14 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('', jest.fn()); }); - expect(mockUpdateNode).toHaveBeenCalledWith('', true, ''); + expect(mockFilterNode).toHaveBeenCalledWith('', ''); expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]); expect(result.current.scopesRow).toBeDefined(); // Simulate loading of scopes in the service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }], nodes, tree, @@ -151,12 +151,12 @@ describe('useRegisterScopesActions', () => { }); it('should load next level of scopes', () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -165,7 +165,7 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('', jest.fn(), 'scopes/scope1'); }); - expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, ''); + expect(mockFilterNode).toHaveBeenCalledWith('scope1', ''); }); it('does not return component if no scopes are selected', () => { @@ -259,12 +259,12 @@ describe('useRegisterScopesActions', () => { }); it('should not use global scope search when searching in some deeper scope category', async () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -273,17 +273,17 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1'); }); - expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something'); + expect(mockFilterNode).toHaveBeenCalledWith('scope1', 'something'); expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled(); }); it('should not use global scope search if feature flag is off', async () => { config.featureToggles.scopeSearchAllLevels = false; - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -292,7 +292,7 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('something', jest.fn(), ''); }); - expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something'); + expect(mockFilterNode).toHaveBeenCalledWith('', 'something'); expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled(); }); diff --git a/public/app/features/commandPalette/actions/scopeActions.tsx b/public/app/features/commandPalette/actions/scopeActions.tsx index bde41152237..e85d136d2f6 100644 --- a/public/app/features/commandPalette/actions/scopeActions.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.tsx @@ -58,22 +58,22 @@ export function useRegisterScopesActions( * @param parentId */ function useScopeTreeActions(searchQuery: string, parentId?: string | null) { - const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState(); + const { filterNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState(); // Initialize the scopes the first time this runs and reset the scopes that were selected on unmount. useEffect(() => { - updateNode('', true, ''); + filterNode('', ''); resetSelection(); return () => { resetSelection(); }; - }, [updateNode, resetSelection]); + }, [filterNode, resetSelection]); // Load the next level of scopes when the parentId changes. useEffect(() => { const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!; - updateNode(parentScopeId, true, searchQuery); - }, [updateNode, searchQuery, parentId]); + filterNode(parentScopeId, searchQuery); + }, [filterNode, searchQuery, parentId]); return useMemo( () => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope), diff --git a/public/app/features/commandPalette/actions/scopesUtils.ts b/public/app/features/commandPalette/actions/scopesUtils.ts index 2848569247a..d269a58abb1 100644 --- a/public/app/features/commandPalette/actions/scopesUtils.ts +++ b/public/app/features/commandPalette/actions/scopesUtils.ts @@ -14,7 +14,7 @@ export function useScopeServicesState() { const services = useScopesServices(); if (!services) { return { - updateNode: () => {}, + filterNode: () => Promise.resolve(), selectScope: () => {}, resetSelection: () => {}, searchAllNodes: () => Promise.resolve([]), @@ -32,7 +32,7 @@ export function useScopeServicesState() { }, }; } - const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } = + const { filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } = services.scopesSelectorService; const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( services.scopesSelectorService.stateObservable ?? new Observable(), @@ -42,7 +42,6 @@ export function useScopeServicesState() { return { getScopeNodes, filterNode, - updateNode, selectScope, resetSelection, searchAllNodes, diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx index bc1078b7f1b..a7941569a0a 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx @@ -68,22 +68,29 @@ export class ConditionalRenderingData extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx index a717b68ea79..d22ad02b8f9 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx @@ -80,6 +80,10 @@ export class ConditionalRenderingTimeRangeSize extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx index 8f82b1f459f..827f7d9ca39 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx @@ -20,7 +20,7 @@ import { getLowerTranslatedObjectType } from '../object'; import { ConditionalRenderingConditionWrapper } from './ConditionalRenderingConditionWrapper'; import { ConditionalRenderingConditionsSerializerRegistryItem } from './serializers'; -import { checkGroup, getObjectType } from './utils'; +import { checkGroup, getObject, getObjectType } from './utils'; type VariableConditionValueOperator = '=' | '!=' | '=~' | '!~'; @@ -40,14 +40,6 @@ export class ConditionalRenderingVariable extends SceneObjectBase { - if (v.state.name === this.state.variable) { - this._check(); - } - }, - }); - public constructor(state: ConditionalRenderingVariableState) { super(state); @@ -55,6 +47,20 @@ export class ConditionalRenderingVariable extends SceneObjectBase { + if (v.state.name === this.state.variable) { + this._check(); + } + }, + }); + this.forEachChild((child) => { if (!child.isActive) { this._subs.add(child.activate()); @@ -78,7 +84,13 @@ export class ConditionalRenderingVariable extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts index af8a4110a11..6ce89e17610 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts @@ -14,6 +14,13 @@ export function getGroup(condition: ConditionalRenderingConditions): Conditional } export function getObject(condition: ConditionalRenderingConditions): SceneObject | undefined { + const group = getGroup(condition); + const groupTarget = group.getTarget(); + + if (groupTarget) { + return groupTarget; + } + return getGroup(condition).parent; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx index 5f0b3319e25..8343bd1914c 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx @@ -2,7 +2,14 @@ import { lowerCase } from 'lodash'; import { useMemo } from 'react'; import { t } from '@grafana/i18n'; -import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { + SceneComponentProps, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, +} from '@grafana/scenes'; import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Stack } from '@grafana/ui'; @@ -33,6 +40,7 @@ export class ConditionalRenderingGroup extends SceneObjectBase; public constructor(state: ConditionalRenderingGroupState) { super(state); @@ -52,6 +60,19 @@ export class ConditionalRenderingGroup extends SceneObjectBase condition.forceCheck()); + } + public check() { // Filter out undefined results // Because we negate the result if shouldShow is false, we can use `condition.state.result ?? true` directly below diff --git a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx index 2292b63500a..b8b77fd44d6 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx @@ -1,12 +1,13 @@ import { ReactNode } from 'react'; -import { SceneObject, useSceneObjectState } from '@grafana/scenes'; +import { useSceneObjectState } from '@grafana/scenes'; import { ConditionalRenderingGroup } from '../group/ConditionalRenderingGroup'; import { ConditionalRenderingOverlay } from './ConditionalRenderingOverlay'; let placeholderConditionalRendering: ConditionalRenderingGroup | undefined; + function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { if (!placeholderConditionalRendering) { placeholderConditionalRendering = ConditionalRenderingGroup.createEmpty(); @@ -14,13 +15,10 @@ function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { return placeholderConditionalRendering; } -export function useIsConditionallyHidden(scene: SceneObject): [boolean, string | undefined, ReactNode | null, boolean] { - const conditionalRenderingToRender = - 'conditionalRendering' in scene.state && scene.state.conditionalRendering instanceof ConditionalRenderingGroup - ? scene.state.conditionalRendering - : getPlaceholderConditionalRendering(); - - const { result, renderHidden } = useSceneObjectState(conditionalRenderingToRender, { +export function useIsConditionallyHidden( + conditionalRendering: ConditionalRenderingGroup = getPlaceholderConditionalRendering() +): [boolean, string | undefined, ReactNode | null, boolean] { + const { result, renderHidden } = useSceneObjectState(conditionalRendering, { shouldActivateOrKeepAlive: true, }); diff --git a/public/app/features/dashboard-scene/conditional-rendering/object.ts b/public/app/features/dashboard-scene/conditional-rendering/object.ts index 67fca6942be..f29ad53c231 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/object.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/object.ts @@ -1,7 +1,7 @@ import { capitalize, lowerCase } from 'lodash'; import { t } from '@grafana/i18n'; -import { SceneObject } from '@grafana/scenes'; +import { SceneObject, VizPanel } from '@grafana/scenes'; import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem'; import { RowItem } from '../scene/layout-rows/RowItem'; @@ -50,7 +50,7 @@ export function getLowerTranslatedObjectType(type: ObjectsWithConditionalRenderi export function extractObjectType(object: SceneObject | undefined): ObjectsWithConditionalRendering { if (!object) { return 'element'; - } else if (object instanceof AutoGridItem) { + } else if (object instanceof AutoGridItem || object instanceof VizPanel) { return 'panel'; } else if (object instanceof RowItem) { return 'row'; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 4ce6536ac5c..97e17c34780 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { SceneObject } from '@grafana/scenes'; -import { Box, Icon, Sidebar, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui'; +import { Box, Icon, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui'; import { isRepeatCloneOrChildOf } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; @@ -85,6 +85,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } aria-selected={isSelected} className={styles.container} onClick={onNodeClicked} + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions style={{ '--depth': depth } as React.CSSProperties} >
@@ -99,7 +100,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } )}