Scopes: Pass selected scopes to dashboard JSON fetching (#89157)

This commit is contained in:
Bogdan Matei 2024-06-20 18:49:19 +03:00 committed by GitHub
parent c88de7f4d0
commit 543e71eb28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 193 additions and 41 deletions

View File

@ -197,4 +197,5 @@ export interface FeatureToggles {
failWrongDSUID?: boolean;
databaseReadReplica?: boolean;
zanzana?: boolean;
passScopeToDashboardApi?: boolean;
}

View File

@ -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,
},
}
)

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
178 failWrongDSUID experimental @grafana/plugins-platform-backend false false false
179 databaseReadReplica experimental @grafana/grafana-backend-services-squad false false false
180 zanzana experimental @grafana/identity-access-team false false false
181 passScopeToDashboardApi experimental @grafana/dashboards-squad false false false

View File

@ -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"
)

View File

@ -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 @@
}
}
]
}
}

View File

@ -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();
});
});
});

View File

@ -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<DashboardSc
}
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
const cachedDashboard = this.getFromCache(cacheKey);
const cachedDashboard = this.getDashboardFromCache(cacheKey);
if (cachedDashboard) {
return cachedDashboard;
@ -142,7 +143,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
}
// Do not cache new dashboards
this.dashboardCache = { dashboard: rsp, ts: Date.now(), cacheKey };
this.setDashboardCache(cacheKey, rsp);
} catch (e) {
// Ignore cancelled errors
if (isFetchError(e) && e.cancelled) {
@ -220,7 +221,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
const rsp = await this.fetchDashboard(options);
const fromCache = this.cache[options.uid];
const fromCache = this.getSceneFromCache(options.uid);
// When coming from Explore, skip returnning scene from cache
if (!comingFromExplore) {
@ -234,7 +235,7 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
if (options.uid && !comingFromExplore) {
this.cache[options.uid] = scene;
this.setSceneCache(options.uid, scene);
}
return scene;
@ -249,8 +250,9 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
throw new Error('Dashboard not found');
}
public getFromCache(cacheKey: string) {
public getDashboardFromCache(cacheKey: string) {
const cachedDashboard = this.dashboardCache;
cacheKey = this.getCacheKey(cacheKey);
if (
cachedDashboard &&
@ -275,12 +277,38 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
}
public setDashboardCache(cacheKey: string, dashboard: DashboardDTO) {
cacheKey = this.getCacheKey(cacheKey);
this.dashboardCache = { dashboard, ts: Date.now(), cacheKey };
}
public clearDashboardCache() {
this.dashboardCache = undefined;
}
public getSceneFromCache(cacheKey: string) {
cacheKey = this.getCacheKey(cacheKey);
return this.cache[cacheKey];
}
public setSceneCache(cacheKey: string, scene: DashboardScene) {
cacheKey = this.getCacheKey(cacheKey);
this.cache[cacheKey] = scene;
}
public getCacheKey(cacheKey: string): string {
const scopesSearchParams = getScopesFromUrl();
if (!scopesSearchParams?.has('scopes')) {
return cacheKey;
}
scopesSearchParams.sort();
return `${cacheKey}__scp__${scopesSearchParams.toString()}`;
}
}
let stateManager: DashboardScenePageStateManager | null = null;

View File

@ -1,8 +1,9 @@
import { act, cleanup, waitFor } from '@testing-library/react';
import userEvents from '@testing-library/user-event';
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { ScopesFiltersScene } from './ScopesFiltersScene';
@ -33,8 +34,7 @@ import {
getDashboardsContainer,
getDashboardsExpand,
getDashboardsSearch,
mocksNodes,
mocksScopeDashboardBindings,
getMock,
mocksScopes,
queryAllDashboard,
queryFiltersApply,
@ -52,31 +52,7 @@ jest.mock('@grafana/runtime', () => ({
__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');
});
});
});
});

View File

@ -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`,

View File

@ -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<DashboardDTO>;
@ -35,7 +37,11 @@ class LegacyDashboardAPI implements DashboardAPI {
}
getDashboardDTO(uid: string): Promise<DashboardDTO> {
return getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`);
const scopesSearchParams = getScopesFromUrl();
const scopes = scopesSearchParams?.getAll('scopes') ?? [];
const queryParams = scopes.length > 0 ? { scopes } : undefined;
return getBackendSrv().get<DashboardDTO>(`/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;
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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)]));
}