diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8af56fc11c3..91d4bcf2a65 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -197,4 +197,5 @@ export interface FeatureToggles { failWrongDSUID?: boolean; databaseReadReplica?: boolean; zanzana?: boolean; + passScopeToDashboardApi?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3d763445ea2..2c2cdbd1763 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1344,6 +1344,17 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "passScopeToDashboardApi", + Description: "Enables the passing of scopes to dashboards fetching in Grafana", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c0cf306fe54..d3552651a93 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -178,3 +178,4 @@ ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false failWrongDSUID,experimental,@grafana/plugins-platform-backend,false,false,false databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false zanzana,experimental,@grafana/identity-access-team,false,false,false +passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 01c91f4b044..2ddff1bb681 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -722,4 +722,8 @@ const ( // FlagZanzana // Use openFGA as authorization engine. FlagZanzana = "zanzana" + + // FlagPassScopeToDashboardApi + // Enables the passing of scopes to dashboards fetching in Grafana + FlagPassScopeToDashboardApi = "passScopeToDashboardApi" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4650fac0999..1535a47673d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1678,6 +1678,20 @@ "requiresDevMode": true } }, + { + "metadata": { + "name": "passScopeToDashboardApi", + "resourceVersion": "1718290335877", + "creationTimestamp": "2024-06-13T14:52:15Z" + }, + "spec": { + "description": "Enables the passing of scopes to dashboards fetching in Grafana", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "pdfTables", @@ -2335,4 +2349,4 @@ } } ] -} \ No newline at end of file +} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 8ae39b7b575..b67870e1b90 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -173,11 +173,11 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); - expect(loader.getFromCache('fake-dash')).toBeDefined(); + expect(loader.getDashboardFromCache('fake-dash')).toBeDefined(); }); it('should load dashboard DTO from cache if requested again within 2s', async () => { @@ -186,7 +186,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashSpy).toHaveBeenCalledTimes(1); @@ -228,7 +228,7 @@ describe('DashboardScenePageStateManager', () => { keepDashboardFromExploreInLocalStorage: false, }); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 551d4208308..b55f281e4a8 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -16,6 +16,7 @@ import { import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; import { DashboardDTO, DashboardRoutes } from 'app/types'; +import { getScopesFromUrl } from '../../dashboard/utils/getScopesFromUrl'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; @@ -83,7 +84,7 @@ export class DashboardScenePageStateManager extends StateManagerBase ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ - get: jest.fn().mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { - return { - items: mocksNodes.filter( - ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') - ), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); - - return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { - return { - items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => - params.scope.includes(bindingScope) - ), - }; - } - - return {}; - }), + get: getMock, }), })); @@ -109,6 +85,7 @@ describe('ScopesScene', () => { fetchScopeSpy.mockClear(); fetchSelectedScopesSpy.mockClear(); fetchSuggestedDashboardsSpy.mockClear(); + getMock.mockClear(); dashboardScene = buildTestScene(); scopesScene = dashboardScene.state.scopes!; @@ -418,4 +395,54 @@ describe('ScopesScene', () => { }); }); }); + + describe('Dashboards API', () => { + describe('Feature flag off', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = false; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + }); + + it('Legacy API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = false; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined); + }); + + it('K8s API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = true; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1'); + }); + }); + + describe('Feature flag on', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = true; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + }); + + it('Legacy API should pass the scopes', () => { + config.featureToggles.kubernetesDashboards = false; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] }); + }); + + it('K8s API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = true; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1'); + }); + }); + }); }); diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx index 7ca1fcaa8de..8e81fc1b162 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx @@ -252,6 +252,46 @@ export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes'); export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards'); +export const getMock = jest + .fn() + .mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { + return { + items: mocksNodes.filter( + ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') + ), + }; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { + const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); + + return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { + return { + items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => + params.scope.includes(bindingScope) + ), + }; + } + + if (url.startsWith('/api/dashboards/uid/')) { + return {}; + } + + if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) { + return { + metadata: { + name: '1', + }, + }; + } + + return {}; + }); + const selectors = { tree: { search: (nodeId: string) => `scopes-tree-${nodeId}-search`, diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 146349d8a10..f13a68102a3 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -6,6 +6,8 @@ import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { DashboardDTO, DashboardDataDTO } from 'app/types'; +import { getScopesFromUrl } from '../utils/getScopesFromUrl'; + export interface DashboardAPI { /** Get a dashboard with the access control metadata */ getDashboardDTO(uid: string): Promise; @@ -35,7 +37,11 @@ class LegacyDashboardAPI implements DashboardAPI { } getDashboardDTO(uid: string): Promise { - return getBackendSrv().get(`/api/dashboards/uid/${uid}`); + const scopesSearchParams = getScopesFromUrl(); + const scopes = scopesSearchParams?.getAll('scopes') ?? []; + const queryParams = scopes.length > 0 ? { scopes } : undefined; + + return getBackendSrv().get(`/api/dashboards/uid/${uid}`, queryParams); } } @@ -82,3 +88,10 @@ export function getDashboardAPI() { } return instance; } + +export function setDashboardAPI(override: DashboardAPI | undefined) { + if (process.env.NODE_ENV !== 'test') { + throw new Error('dashboardAPI can be only overridden in test environment'); + } + instance = override; +} diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index ae0ba93f42e..ba06819bc6c 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -77,7 +77,7 @@ export class DashboardLoaderSrv { }; }); } else if (uid) { - const cachedDashboard = stateManager.getFromCache(uid); + const cachedDashboard = stateManager.getDashboardFromCache(uid); if (cachedDashboard) { return Promise.resolve(cachedDashboard); } diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 4d77b54728d..599c4ad2a8b 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -61,7 +61,7 @@ async function fetchDashboard( switch (args.routeName) { case DashboardRoutes.Home: { const stateManager = getDashboardScenePageStateManager(); - const cachedDashboard = stateManager.getFromCache(HOME_DASHBOARD_CACHE_KEY); + const cachedDashboard = stateManager.getDashboardFromCache(HOME_DASHBOARD_CACHE_KEY); if (cachedDashboard) { return cachedDashboard; diff --git a/public/app/features/dashboard/utils/getScopesFromUrl.ts b/public/app/features/dashboard/utils/getScopesFromUrl.ts new file mode 100644 index 00000000000..e9539037fe5 --- /dev/null +++ b/public/app/features/dashboard/utils/getScopesFromUrl.ts @@ -0,0 +1,13 @@ +import { config, locationService } from '@grafana/runtime'; + +export function getScopesFromUrl(): URLSearchParams | undefined { + if (!config.featureToggles.scopeFilters || !config.featureToggles.passScopeToDashboardApi) { + return undefined; + } + + const queryParams = locationService.getSearchObject(); + const rawScopes = queryParams['scopes'] ?? []; + const scopes = Array.isArray(rawScopes) ? rawScopes : [rawScopes]; + + return new URLSearchParams(scopes.map((scope) => ['scopes', String(scope)])); +}