mirror of https://github.com/grafana/grafana.git
Scopes: Pass selected scopes to dashboard JSON fetching (#89157)
This commit is contained in:
parent
c88de7f4d0
commit
543e71eb28
|
|
@ -197,4 +197,5 @@ export interface FeatureToggles {
|
|||
failWrongDSUID?: boolean;
|
||||
databaseReadReplica?: boolean;
|
||||
zanzana?: boolean;
|
||||
passScopeToDashboardApi?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]));
|
||||
}
|
||||
Loading…
Reference in New Issue