mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Schema v2: Read API integration (#97953)
* Introduce DashboardScenePageStateManagerLike interface * Implement dash loader for handling v2 api * Transformation improvements * Update response transformer test * v2 schema: Remove defaultOptionEnabled from ds variable schema * v2 schema: Make annotations filter optional * WIP render dashboard from v2 schema * Force dashbaord scene for v2 api * V2 schema -> scene meta transformation * v2 api: Handle home dashboard * Use correct api client in DashboardScenePage * Correctly use v2 dashboard scene serializer * Remove unnecesary type assertions * Handle v2 dashboard not found * Fix type * Fix test * Some more tests fix * snapshot * Add dashboard id annotation * Nits * Nits * Rename v2 api * Update public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com> * add getDashboardsApiVersion test for forcing scenes through URL * add links to ResponseTransformers * Update public/app/features/dashboard/api/ResponseTransformers.test.ts Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com> * Nit rename test case * Add tests for DashboardScenePageStateManagerV2 * Update test * Typecheck * Add console error for debugging * Fix typo --------- Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com> Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
parent
8ab12aede4
commit
e974cb87d8
@ -3433,7 +3433,10 @@ exports[`better eslint`] = {
|
||||
"public/app/features/dashboard/api/ResponseTransformers.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
],
|
||||
"public/app/features/dashboard/api/v0.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@ -4837,10 +4840,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"]
|
||||
],
|
||||
"public/app/features/logs/components/LogRows.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/logs/components/log-context/LogContextButtons.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
@ -6778,27 +6777,6 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/datasource/parca/webpack.config.ts:5381": [
|
||||
[0, 0, 0, "Do not re-export imported variable (\`config\`)", "0"]
|
||||
],
|
||||
"public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "18"]
|
||||
],
|
||||
"public/app/plugins/datasource/tempo/QueryField.tsx:5381": [
|
||||
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
|
||||
],
|
||||
@ -7739,11 +7717,6 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx:5381": [
|
||||
|
@ -477,18 +477,17 @@ export const defaultVizConfigKind = (): VizConfigKind => ({
|
||||
export interface AnnotationQuerySpec {
|
||||
datasource?: DataSourceRef;
|
||||
query?: DataQueryKind;
|
||||
builtIn?: boolean;
|
||||
enable: boolean;
|
||||
filter: AnnotationPanelFilter;
|
||||
hide: boolean;
|
||||
iconColor: string;
|
||||
name: string;
|
||||
builtIn?: boolean;
|
||||
filter?: AnnotationPanelFilter;
|
||||
}
|
||||
|
||||
export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({
|
||||
builtIn: false,
|
||||
enable: false,
|
||||
filter: defaultAnnotationPanelFilter(),
|
||||
hide: false,
|
||||
iconColor: "",
|
||||
name: "",
|
||||
|
@ -370,12 +370,12 @@ VizConfigKind: {
|
||||
AnnotationQuerySpec: {
|
||||
datasource?: DataSourceRef
|
||||
query?: DataQueryKind
|
||||
builtIn?: bool | *false
|
||||
enable: bool
|
||||
filter: AnnotationPanelFilter
|
||||
hide: bool
|
||||
iconColor: string
|
||||
name: string
|
||||
builtIn?: bool | *false
|
||||
filter?: AnnotationPanelFilter
|
||||
}
|
||||
|
||||
AnnotationQueryKind: {
|
||||
|
@ -38,6 +38,7 @@ export const AnnoKeyFolderId = 'grafana.app/folderId';
|
||||
export const AnnoKeyFolderUrl = 'grafana.app/folderUrl';
|
||||
export const AnnoKeyMessage = 'grafana.app/message';
|
||||
export const AnnoKeySlug = 'grafana.app/slug';
|
||||
export const AnnoKeyDashboardId = 'grafana.app/dashboardId';
|
||||
|
||||
// Identify where values came from
|
||||
export const AnnoKeyRepoName = 'grafana.app/repoName';
|
||||
@ -46,6 +47,8 @@ export const AnnoKeyRepoHash = 'grafana.app/repoHash';
|
||||
export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
|
||||
|
||||
export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui';
|
||||
export const AnnoKeyDashboardNotFound = 'grafana.app/dashboard-not-found';
|
||||
export const AnnoKeyDashboardIsSnapshot = 'grafana.app/dashboard-is-snapshot';
|
||||
export const AnnoKeyDashboardIsNew = 'grafana.app/dashboard-is-new';
|
||||
|
||||
// Annotations provided by the API
|
||||
@ -55,6 +58,7 @@ type GrafanaAnnotations = {
|
||||
[AnnoKeyUpdatedBy]?: string;
|
||||
[AnnoKeyFolder]?: string;
|
||||
[AnnoKeySlug]?: string;
|
||||
[AnnoKeyDashboardId]?: number;
|
||||
|
||||
[AnnoKeyRepoName]?: string;
|
||||
[AnnoKeyRepoPath]?: string;
|
||||
@ -70,6 +74,8 @@ type GrafanaClientAnnotations = {
|
||||
[AnnoKeyFolderId]?: number;
|
||||
[AnnoKeyFolderId]?: number;
|
||||
[AnnoKeySavedFromUI]?: string;
|
||||
[AnnoKeyDashboardNotFound]?: boolean;
|
||||
[AnnoKeyDashboardIsSnapshot]?: boolean;
|
||||
[AnnoKeyDashboardIsNew]?: boolean;
|
||||
};
|
||||
|
||||
|
@ -243,7 +243,7 @@ describe('DashboardScenePage', () => {
|
||||
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
|
||||
// Hacking a bit, accessing private cache property to get access to the underlying DashboardScene object
|
||||
const dashboardScenesCache = getDashboardScenePageStateManager()['cache'];
|
||||
const dashboardScenesCache = getDashboardScenePageStateManager().getCache();
|
||||
const dashboard = dashboardScenesCache['my-dash-uid'];
|
||||
const panels = dashboardSceneGraph.getVizPanels(dashboard);
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom-v5-compat';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Alert, Box } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
@ -23,7 +24,9 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
|
||||
const params = useParams();
|
||||
const { type, slug, uid } = params;
|
||||
const prevMatch = usePrevious({ params });
|
||||
const stateManager = getDashboardScenePageStateManager();
|
||||
const stateManager = config.featureToggles.useV2DashboardsAPI
|
||||
? getDashboardScenePageStateManager('v2')
|
||||
: getDashboardScenePageStateManager();
|
||||
const { dashboard, isLoading, loadError } = stateManager.useState();
|
||||
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
|
||||
const routeReloadCounter = (location.state as any)?.routeReloadCounter;
|
||||
|
@ -1,15 +1,29 @@
|
||||
import { advanceBy } from 'jest-date-mock';
|
||||
|
||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
||||
import {
|
||||
DashboardV2Spec,
|
||||
defaultDashboardV2Spec,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import store from 'app/core/store';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { setupLoadDashboardMock } from '../utils/test-utils';
|
||||
|
||||
import { DashboardScenePageStateManager, DASHBOARD_CACHE_TTL } from './DashboardScenePageStateManager';
|
||||
import {
|
||||
DashboardScenePageStateManager,
|
||||
DASHBOARD_CACHE_TTL,
|
||||
DashboardScenePageStateManagerV2,
|
||||
} from './DashboardScenePageStateManager';
|
||||
|
||||
describe('DashboardScenePageStateManager', () => {
|
||||
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
|
||||
getDashboardAPI: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('DashboardScenePageStateManager v1', () => {
|
||||
afterEach(() => {
|
||||
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||
});
|
||||
@ -205,3 +219,354 @@ describe('DashboardScenePageStateManager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardScenePageStateManager v2', () => {
|
||||
afterEach(() => {
|
||||
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||
});
|
||||
|
||||
describe('when fetching/loading a dashboard', () => {
|
||||
const setupDashboardAPI = (
|
||||
d: DashboardWithAccessInfo<DashboardV2Spec> | undefined,
|
||||
spy: jest.Mock,
|
||||
effect?: () => void
|
||||
) => {
|
||||
(getDashboardAPI as jest.Mock).mockImplementation(() => {
|
||||
// Return whatever you want for this mock
|
||||
return {
|
||||
getDashboardDTO: async () => {
|
||||
spy();
|
||||
effect?.();
|
||||
return d;
|
||||
},
|
||||
deleteDashboard: jest.fn(),
|
||||
saveDashboard: jest.fn(),
|
||||
};
|
||||
});
|
||||
};
|
||||
it('should call loader from server if the dashboard is not cached', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// should use cache second time
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// TODO: Fix this test, v2 does not return undefined dashboard, but throws instead. The code needs to be updated.
|
||||
it.skip("should error when the dashboard doesn't exist", async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(undefined, getDashSpy, () => {
|
||||
throw new Error('Dashhboard not found');
|
||||
});
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
// expect(loader.state.dashboard).toBeUndefined();
|
||||
expect(loader.state.isLoading).toBe(false);
|
||||
expect(loader.state.loadError).toBe('Dashboard not found');
|
||||
});
|
||||
|
||||
it('should clear current dashboard while loading next', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.state.dashboard).toBeDefined();
|
||||
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash2',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
loader.loadDashboard({ uid: 'fake-dash2', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.state.isLoading).toBe(true);
|
||||
expect(loader.state.dashboard).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should initialize the dashboard scene with the loaded dashboard', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.state.dashboard?.state.uid).toBe('fake-dash');
|
||||
expect(loader.state.loadError).toBe(undefined);
|
||||
expect(loader.state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should use DashboardScene creator to initialize the scene', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
|
||||
expect(loader.state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should use DashboardScene creator to initialize the snapshot scene', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadSnapshot('fake-slug');
|
||||
|
||||
expect(loader.state.dashboard).toBeInstanceOf(DashboardScene);
|
||||
expect(loader.state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
describe('Home dashboard', () => {
|
||||
// TODO: Unskip when redirect is implemented in v2 API
|
||||
it.skip('should handle home dashboard redirect', async () => {
|
||||
setBackendSrv({
|
||||
get: () => Promise.resolve({ redirectUri: '/d/asd' }),
|
||||
} as unknown as BackendSrv);
|
||||
|
||||
const loader = new DashboardScenePageStateManager({});
|
||||
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
|
||||
|
||||
expect(loader.state.dashboard).toBeUndefined();
|
||||
expect(loader.state.loadError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle invalid home dashboard request', async () => {
|
||||
setBackendSrv({
|
||||
get: () =>
|
||||
Promise.reject({
|
||||
status: 500,
|
||||
data: { message: 'Failed to load home dashboard' },
|
||||
}),
|
||||
} as unknown as BackendSrv);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
|
||||
|
||||
expect(loader.state.dashboard).toBeUndefined();
|
||||
expect(loader.state.loadError).toEqual('Failed to load home dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('New dashboards', () => {
|
||||
it('Should have new empty model with meta.isNew and should not be cached', async () => {
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
|
||||
await loader.loadDashboard({ uid: '', route: DashboardRoutes.New });
|
||||
const dashboard = loader.state.dashboard!;
|
||||
|
||||
expect(dashboard.state.meta.isNew).toBe(true);
|
||||
expect(dashboard.state.isEditing).toBe(undefined);
|
||||
expect(dashboard.state.isDirty).toBe(false);
|
||||
|
||||
dashboard.setState({ title: 'Changed' });
|
||||
|
||||
await loader.loadDashboard({ uid: '', route: DashboardRoutes.New });
|
||||
const dashboard2 = loader.state.dashboard!;
|
||||
|
||||
expect(dashboard2.state.title).toBe('New dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('should take scene from cache if it exists', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
loader.state.dashboard?.onEnterEditMode();
|
||||
|
||||
expect(loader.state.dashboard?.state.isEditing).toBe(true);
|
||||
|
||||
loader.clearState();
|
||||
|
||||
// now load it again
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
// should still be editing
|
||||
expect(loader.state.dashboard?.state.isEditing).toBe(true);
|
||||
expect(loader.state.dashboard?.state.version).toBe(1);
|
||||
|
||||
loader.clearState();
|
||||
|
||||
loader.setDashboardCache('fake-dash', {
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '2',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
});
|
||||
|
||||
// now load a third time
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.state.dashboard!.state.isEditing).toBe(undefined);
|
||||
expect(loader.state.dashboard!.state.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should cache the dashboard DTO', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
|
||||
expect(loader.getDashboardFromCache('fake-dash')).toBeNull();
|
||||
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
|
||||
expect(loader.getDashboardFromCache('fake-dash')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should load dashboard DTO from cache if requested again within 2s', async () => {
|
||||
const getDashSpy = jest.fn();
|
||||
setupDashboardAPI(
|
||||
{
|
||||
access: {},
|
||||
apiVersion: 'v2alpha1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'fake-dash',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '1',
|
||||
},
|
||||
spec: { ...defaultDashboardV2Spec() },
|
||||
},
|
||||
getDashSpy
|
||||
);
|
||||
|
||||
const loader = new DashboardScenePageStateManagerV2({});
|
||||
|
||||
expect(loader.getDashboardFromCache('fake-dash')).toBeNull();
|
||||
|
||||
await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
advanceBy(DASHBOARD_CACHE_TTL / 2);
|
||||
await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
advanceBy(DASHBOARD_CACHE_TTL / 2 + 1);
|
||||
await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
expect(getDashSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,10 +2,14 @@ import { isEqual } from 'lodash';
|
||||
|
||||
import { locationUtil, UrlQueryMap } from '@grafana/data';
|
||||
import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { StateManagerBase } from 'app/core/services/StateManagerBase';
|
||||
import { getMessageFromError } from 'app/core/utils/errors';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
||||
import { AnnoKeyFolder } from 'app/features/apiserver/types';
|
||||
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
|
||||
import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking';
|
||||
@ -13,7 +17,8 @@ import { DashboardDTO, DashboardRoutes } from 'app/types';
|
||||
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel';
|
||||
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel';
|
||||
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
|
||||
|
||||
@ -34,8 +39,8 @@ const LOAD_SCENE_MEASUREMENT = 'loadDashboardScene';
|
||||
/** Only used by cache in loading home in DashboardPageProxy and initDashboard (Old arch), can remove this after old dashboard arch is gone */
|
||||
export const HOME_DASHBOARD_CACHE_KEY = '__grafana_home_uid__';
|
||||
|
||||
interface DashboardCacheEntry {
|
||||
dashboard: DashboardDTO;
|
||||
interface DashboardCacheEntry<T> {
|
||||
dashboard: T;
|
||||
ts: number;
|
||||
cacheKey: string;
|
||||
}
|
||||
@ -55,103 +60,36 @@ export interface LoadDashboardOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export class DashboardScenePageStateManager extends StateManagerBase<DashboardScenePageState> {
|
||||
private cache: Record<string, DashboardScene> = {};
|
||||
interface DashboardScenePageStateManagerLike<T> {
|
||||
fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
|
||||
getDashboardFromCache(cacheKey: string): T | null;
|
||||
loadDashboard(options: LoadDashboardOptions): Promise<void>;
|
||||
transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
|
||||
reloadDashboard(params: LoadDashboardOptions['params']): Promise<void>;
|
||||
loadSnapshot(slug: string): Promise<void>;
|
||||
setDashboardCache(cacheKey: string, dashboard: T): void;
|
||||
clearSceneCache(): void;
|
||||
clearDashboardCache(): void;
|
||||
clearState(): void;
|
||||
getCache(): Record<string, DashboardScene>;
|
||||
useState: () => DashboardScenePageState;
|
||||
}
|
||||
|
||||
abstract class DashboardScenePageStateManagerBase<T>
|
||||
extends StateManagerBase<DashboardScenePageState>
|
||||
implements DashboardScenePageStateManagerLike<T>
|
||||
{
|
||||
abstract fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
|
||||
abstract reloadDashboard(params: LoadDashboardOptions['params']): Promise<void>;
|
||||
abstract transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
|
||||
|
||||
protected cache: Record<string, DashboardScene> = {};
|
||||
|
||||
// This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span.
|
||||
private dashboardCache?: DashboardCacheEntry;
|
||||
protected dashboardCache?: DashboardCacheEntry<T>;
|
||||
|
||||
// To eventualy replace the fetchDashboard function from Dashboard redux state management.
|
||||
// For now it's a simplistic version to support Home and Normal dashboard routes.
|
||||
public async fetchDashboard({
|
||||
uid,
|
||||
route,
|
||||
urlFolderUid,
|
||||
params,
|
||||
}: LoadDashboardOptions): Promise<DashboardDTO | null> {
|
||||
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
|
||||
|
||||
if (!params) {
|
||||
const cachedDashboard = this.getDashboardFromCache(cacheKey);
|
||||
|
||||
if (cachedDashboard) {
|
||||
return cachedDashboard;
|
||||
}
|
||||
}
|
||||
|
||||
let rsp: DashboardDTO;
|
||||
|
||||
try {
|
||||
switch (route) {
|
||||
case DashboardRoutes.New:
|
||||
rsp = await buildNewDashboardSaveModel(urlFolderUid);
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Home:
|
||||
rsp = await getBackendSrv().get('/api/dashboards/home');
|
||||
|
||||
if (rsp.redirectUri) {
|
||||
return rsp;
|
||||
}
|
||||
|
||||
if (rsp?.meta) {
|
||||
rsp.meta.canSave = false;
|
||||
rsp.meta.canShare = false;
|
||||
rsp.meta.canStar = false;
|
||||
}
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Public: {
|
||||
return await dashboardLoaderSrv.loadDashboard('public', '', uid);
|
||||
}
|
||||
default:
|
||||
const queryParams = params
|
||||
? {
|
||||
version: params.version,
|
||||
scopes: params.scopes,
|
||||
from: params.timeRange.from,
|
||||
to: params.timeRange.to,
|
||||
...params.variables,
|
||||
}
|
||||
: undefined;
|
||||
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams);
|
||||
|
||||
if (route === DashboardRoutes.Embedded) {
|
||||
rsp.meta.isEmbedded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rsp.meta.url && route === DashboardRoutes.Normal) {
|
||||
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
|
||||
if (dashboardUrl !== currentPath) {
|
||||
// Spread current location to persist search params used for navigation
|
||||
locationService.replace({
|
||||
...locationService.getLocation(),
|
||||
pathname: dashboardUrl,
|
||||
});
|
||||
console.log('not correct url correcting', dashboardUrl, currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate nav model in global store according to the folder
|
||||
if (rsp.meta.folderUid) {
|
||||
await updateNavModel(rsp.meta.folderUid);
|
||||
}
|
||||
|
||||
// Do not cache new dashboards
|
||||
this.setDashboardCache(cacheKey, rsp);
|
||||
} catch (e) {
|
||||
// Ignore cancelled errors
|
||||
if (isFetchError(e) && e.cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return rsp;
|
||||
getCache(): Record<string, DashboardScene> {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
public async loadSnapshot(slug: string) {
|
||||
@ -205,6 +143,177 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
||||
}
|
||||
}
|
||||
|
||||
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
|
||||
this.setState({ dashboard: undefined, isLoading: true });
|
||||
const rsp = await this.fetchDashboard(options);
|
||||
return this.transformResponseToScene(rsp, options);
|
||||
}
|
||||
|
||||
public getDashboardFromCache(cacheKey: string): T | null {
|
||||
const cachedDashboard = this.dashboardCache;
|
||||
|
||||
if (
|
||||
cachedDashboard &&
|
||||
cachedDashboard.cacheKey === cacheKey &&
|
||||
Date.now() - cachedDashboard?.ts < DASHBOARD_CACHE_TTL
|
||||
) {
|
||||
return cachedDashboard.dashboard;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public clearState() {
|
||||
getDashboardSrv().setCurrent(undefined);
|
||||
|
||||
this.setState({
|
||||
dashboard: undefined,
|
||||
loadError: undefined,
|
||||
isLoading: false,
|
||||
panelEditor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public setDashboardCache(cacheKey: string, dashboard: T) {
|
||||
this.dashboardCache = { dashboard, ts: Date.now(), cacheKey };
|
||||
}
|
||||
|
||||
public clearDashboardCache() {
|
||||
this.dashboardCache = undefined;
|
||||
}
|
||||
|
||||
public getSceneFromCache(cacheKey: string) {
|
||||
return this.cache[cacheKey];
|
||||
}
|
||||
|
||||
public setSceneCache(cacheKey: string, scene: DashboardScene) {
|
||||
this.cache[cacheKey] = scene;
|
||||
}
|
||||
|
||||
public clearSceneCache() {
|
||||
this.cache = {};
|
||||
}
|
||||
}
|
||||
|
||||
export class DashboardScenePageStateManager extends DashboardScenePageStateManagerBase<DashboardDTO> {
|
||||
transformResponseToScene(rsp: DashboardDTO | null, options: LoadDashboardOptions): DashboardScene | null {
|
||||
const fromCache = this.getSceneFromCache(options.uid);
|
||||
|
||||
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
if (rsp?.dashboard) {
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
|
||||
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
|
||||
if (options.uid) {
|
||||
this.setSceneCache(options.uid, scene);
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (rsp?.redirectUri) {
|
||||
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
|
||||
locationService.replace(newUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error('Dashboard not found');
|
||||
}
|
||||
public async fetchDashboard({
|
||||
uid,
|
||||
route,
|
||||
urlFolderUid,
|
||||
params,
|
||||
}: LoadDashboardOptions): Promise<DashboardDTO | null> {
|
||||
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
|
||||
|
||||
if (!params) {
|
||||
const cachedDashboard = this.getDashboardFromCache(cacheKey);
|
||||
|
||||
if (cachedDashboard) {
|
||||
return cachedDashboard;
|
||||
}
|
||||
}
|
||||
|
||||
let rsp: DashboardDTO;
|
||||
|
||||
try {
|
||||
switch (route) {
|
||||
case DashboardRoutes.New:
|
||||
rsp = await buildNewDashboardSaveModel(urlFolderUid);
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Home:
|
||||
rsp = await getBackendSrv().get('/api/dashboards/home');
|
||||
|
||||
if (rsp.redirectUri) {
|
||||
return rsp;
|
||||
}
|
||||
|
||||
if (rsp?.meta) {
|
||||
rsp.meta.canSave = false;
|
||||
rsp.meta.canShare = false;
|
||||
rsp.meta.canStar = false;
|
||||
}
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Public: {
|
||||
return await dashboardLoaderSrv.loadDashboard('public', '', uid);
|
||||
}
|
||||
default:
|
||||
const queryParams = params
|
||||
? {
|
||||
version: params.version,
|
||||
scopes: params.scopes,
|
||||
from: params.timeRange.from,
|
||||
to: params.timeRange.to,
|
||||
...params.variables,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams);
|
||||
|
||||
if (route === DashboardRoutes.Embedded) {
|
||||
rsp.meta.isEmbedded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rsp.meta.url && route === DashboardRoutes.Normal) {
|
||||
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
|
||||
if (dashboardUrl !== currentPath) {
|
||||
// Spread current location to persist search params used for navigation
|
||||
locationService.replace({
|
||||
...locationService.getLocation(),
|
||||
pathname: dashboardUrl,
|
||||
});
|
||||
console.log('not correct url correcting', dashboardUrl, currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate nav model in global store according to the folder
|
||||
if (rsp.meta.folderUid) {
|
||||
await updateNavModel(rsp.meta.folderUid);
|
||||
}
|
||||
|
||||
// Do not cache new dashboards
|
||||
this.setDashboardCache(cacheKey, rsp);
|
||||
} catch (e) {
|
||||
// Ignore cancelled errors
|
||||
if (isFetchError(e) && e.cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return rsp;
|
||||
}
|
||||
|
||||
public async reloadDashboard(params: LoadDashboardOptions['params']) {
|
||||
const stateOptions = this.state.options;
|
||||
|
||||
@ -254,19 +363,30 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
||||
this.setState({ isLoading: false, loadError: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
|
||||
this.setState({ dashboard: undefined, isLoading: true });
|
||||
|
||||
const rsp = await this.fetchDashboard(options);
|
||||
export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateManagerBase<
|
||||
DashboardWithAccessInfo<DashboardV2Spec>
|
||||
> {
|
||||
private dashboardLoader = new DashboardLoaderSrvV2();
|
||||
|
||||
transformResponseToScene(
|
||||
rsp: DashboardWithAccessInfo<DashboardV2Spec> | null,
|
||||
options: LoadDashboardOptions
|
||||
): DashboardScene | null {
|
||||
const fromCache = this.getSceneFromCache(options.uid);
|
||||
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
|
||||
|
||||
// TODO[schema v2]: Dashboard scene state is incorrectly save, it must use the resourceVersion
|
||||
if (
|
||||
fromCache &&
|
||||
rsp?.metadata.resourceVersion &&
|
||||
fromCache.state.version === parseInt(rsp?.metadata.resourceVersion, 10)
|
||||
) {
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
if (rsp?.dashboard) {
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
if (rsp) {
|
||||
const scene = transformSaveModelSchemaV2ToScene(rsp);
|
||||
|
||||
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
|
||||
if (options.uid) {
|
||||
@ -276,67 +396,127 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (rsp?.redirectUri) {
|
||||
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
|
||||
locationService.replace(newUrl);
|
||||
return null;
|
||||
}
|
||||
// TOD)[schema v2]: Figure out redirect utl
|
||||
// if (rsp?.redirectUri) {
|
||||
// const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
|
||||
// locationService.replace(newUrl);
|
||||
// return null;
|
||||
// }
|
||||
|
||||
throw new Error('Dashboard not found');
|
||||
}
|
||||
|
||||
public getDashboardFromCache(cacheKey: string) {
|
||||
const cachedDashboard = this.dashboardCache;
|
||||
reloadDashboard(params: LoadDashboardOptions['params']): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
if (
|
||||
cachedDashboard &&
|
||||
cachedDashboard.cacheKey === cacheKey &&
|
||||
Date.now() - cachedDashboard?.ts < DASHBOARD_CACHE_TTL
|
||||
) {
|
||||
return cachedDashboard.dashboard;
|
||||
public async fetchDashboard({
|
||||
uid,
|
||||
route,
|
||||
urlFolderUid,
|
||||
params,
|
||||
}: LoadDashboardOptions): Promise<DashboardWithAccessInfo<DashboardV2Spec> | null> {
|
||||
// throw new Error('Method not implemented.');
|
||||
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
|
||||
if (!params) {
|
||||
const cachedDashboard = this.getDashboardFromCache(cacheKey);
|
||||
if (cachedDashboard) {
|
||||
return cachedDashboard;
|
||||
}
|
||||
}
|
||||
let rsp: DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
try {
|
||||
switch (route) {
|
||||
case DashboardRoutes.New:
|
||||
rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
|
||||
break;
|
||||
case DashboardRoutes.Home:
|
||||
// throw new Error('Method not implemented.');
|
||||
const dto = await getBackendSrv().get<DashboardDTO>('/api/dashboards/home');
|
||||
rsp = ResponseTransformers.ensureV2Response(dto);
|
||||
rsp.access.canSave = false;
|
||||
rsp.access.canShare = false;
|
||||
rsp.access.canStar = false;
|
||||
|
||||
// if (rsp.redirectUri) {
|
||||
// return rsp;
|
||||
// }
|
||||
|
||||
break;
|
||||
case DashboardRoutes.Public: {
|
||||
return await this.dashboardLoader.loadDashboard('public', '', uid);
|
||||
}
|
||||
default:
|
||||
const queryParams = params
|
||||
? {
|
||||
version: params.version,
|
||||
scopes: params.scopes,
|
||||
from: params.timeRange.from,
|
||||
to: params.timeRange.to,
|
||||
...params.variables,
|
||||
}
|
||||
: undefined;
|
||||
rsp = await this.dashboardLoader.loadDashboard('db', '', uid, queryParams);
|
||||
if (route === DashboardRoutes.Embedded) {
|
||||
throw new Error('Method not implemented.');
|
||||
// rsp.meta.isEmbedded = true;
|
||||
}
|
||||
}
|
||||
if (rsp.access.url && route === DashboardRoutes.Normal) {
|
||||
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.access.url);
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
if (dashboardUrl !== currentPath) {
|
||||
// Spread current location to persist search params used for navigation
|
||||
locationService.replace({
|
||||
...locationService.getLocation(),
|
||||
pathname: dashboardUrl,
|
||||
});
|
||||
console.log('not correct url correcting', dashboardUrl, currentPath);
|
||||
}
|
||||
}
|
||||
// Populate nav model in global store according to the folder
|
||||
if (rsp.metadata.annotations?.[AnnoKeyFolder]) {
|
||||
await updateNavModel(rsp.metadata.annotations?.[AnnoKeyFolder]);
|
||||
}
|
||||
// Do not cache new dashboards
|
||||
this.setDashboardCache(cacheKey, rsp);
|
||||
} catch (e) {
|
||||
// Ignore cancelled errors
|
||||
if (isFetchError(e) && e.cancelled) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return rsp;
|
||||
}
|
||||
}
|
||||
|
||||
const managers: {
|
||||
v1?: DashboardScenePageStateManager;
|
||||
v2?: DashboardScenePageStateManagerV2;
|
||||
} = {
|
||||
v1: undefined,
|
||||
v2: undefined,
|
||||
};
|
||||
|
||||
export function getDashboardScenePageStateManager(
|
||||
v: 'v2'
|
||||
): DashboardScenePageStateManagerLike<DashboardWithAccessInfo<DashboardV2Spec>>;
|
||||
export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike<DashboardDTO>;
|
||||
|
||||
export function getDashboardScenePageStateManager(
|
||||
v?: 'v2'
|
||||
): DashboardScenePageStateManagerLike<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>> {
|
||||
if (v === 'v2') {
|
||||
if (!managers.v2) {
|
||||
managers.v2 = new DashboardScenePageStateManagerV2({});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public clearState() {
|
||||
getDashboardSrv().setCurrent(undefined);
|
||||
|
||||
this.setState({
|
||||
dashboard: undefined,
|
||||
loadError: undefined,
|
||||
isLoading: false,
|
||||
panelEditor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public setDashboardCache(cacheKey: string, dashboard: DashboardDTO) {
|
||||
this.dashboardCache = { dashboard, ts: Date.now(), cacheKey };
|
||||
}
|
||||
|
||||
public clearDashboardCache() {
|
||||
this.dashboardCache = undefined;
|
||||
}
|
||||
|
||||
public getSceneFromCache(cacheKey: string) {
|
||||
return this.cache[cacheKey];
|
||||
}
|
||||
|
||||
public setSceneCache(cacheKey: string, scene: DashboardScene) {
|
||||
this.cache[cacheKey] = scene;
|
||||
}
|
||||
|
||||
public clearSceneCache() {
|
||||
this.cache = {};
|
||||
return managers.v2;
|
||||
} else {
|
||||
if (!managers.v1) {
|
||||
managers.v1 = new DashboardScenePageStateManager({});
|
||||
}
|
||||
return managers.v1;
|
||||
}
|
||||
}
|
||||
|
||||
let stateManager: DashboardScenePageStateManager | null = null;
|
||||
|
||||
export function getDashboardScenePageStateManager(): DashboardScenePageStateManager {
|
||||
if (!stateManager) {
|
||||
stateManager = new DashboardScenePageStateManager({});
|
||||
}
|
||||
|
||||
return stateManager;
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ NavToolbarActions.displayName = 'NavToolbarActions';
|
||||
*/
|
||||
export function ToolbarActions({ dashboard }: Props) {
|
||||
const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState();
|
||||
|
||||
const { isPlaying } = playlistSrv.useState();
|
||||
const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false);
|
||||
|
||||
@ -66,6 +67,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
const isViewingPanel = Boolean(viewPanelScene);
|
||||
const isEditedPanelDirty = usePanelEditDirty(editPanel);
|
||||
const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve());
|
||||
const isNotFound = Boolean(meta.dashboardNotFound);
|
||||
const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY);
|
||||
// Means we are not in settings view, fullscreen panel or edit panel
|
||||
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
|
||||
@ -73,6 +75,10 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
const showScopesSelector = config.featureToggles.scopeFilters && !isEditing;
|
||||
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
|
||||
|
||||
if (isNotFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isEditingPanel) {
|
||||
// This adds the precence indicators in enterprise
|
||||
addDynamicActions(toolbarActions, dynamicDashNavActions.left, 'left-actions');
|
||||
|
@ -12,10 +12,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"uid": "-- Grafana --",
|
||||
},
|
||||
"enable": true,
|
||||
"filter": {
|
||||
"exclude": false,
|
||||
"ids": [],
|
||||
},
|
||||
"filter": undefined,
|
||||
"hide": false,
|
||||
"iconColor": "red",
|
||||
"name": "query1",
|
||||
@ -30,10 +27,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"uid": "abcdef",
|
||||
},
|
||||
"enable": true,
|
||||
"filter": {
|
||||
"exclude": false,
|
||||
"ids": [],
|
||||
},
|
||||
"filter": undefined,
|
||||
"hide": true,
|
||||
"iconColor": "blue",
|
||||
"name": "query2",
|
||||
@ -48,10 +42,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"uid": "Loki",
|
||||
},
|
||||
"enable": true,
|
||||
"filter": {
|
||||
"exclude": false,
|
||||
"ids": [],
|
||||
},
|
||||
"filter": undefined,
|
||||
"hide": true,
|
||||
"iconColor": "green",
|
||||
"name": "query3",
|
||||
|
@ -46,11 +46,20 @@ const defaultDashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
name: 'dashboard-uid',
|
||||
namespace: 'default',
|
||||
labels: {},
|
||||
resourceVersion: '',
|
||||
creationTimestamp: '',
|
||||
resourceVersion: '123',
|
||||
creationTimestamp: 'creationTs',
|
||||
annotations: {
|
||||
'grafana.app/createdBy': 'user:createBy',
|
||||
'grafana.app/folder': 'folder-uid',
|
||||
'grafana.app/updatedBy': 'user:updatedBy',
|
||||
'grafana.app/updatedTimestamp': 'updatedTs',
|
||||
},
|
||||
},
|
||||
spec: handyTestingSchema,
|
||||
access: {},
|
||||
access: {
|
||||
url: '/d/abc',
|
||||
slug: 'what-a-dashboard',
|
||||
},
|
||||
apiVersion: 'v2',
|
||||
};
|
||||
|
||||
@ -80,7 +89,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
expect(scene.state.description).toEqual(dash.description);
|
||||
expect(scene.state.editable).toEqual(dash.editable);
|
||||
expect(scene.state.preload).toEqual(false);
|
||||
expect(scene.state.version).toEqual(dash.schemaVersion);
|
||||
expect(scene.state.version).toEqual(123);
|
||||
expect(scene.state.tags).toEqual(dash.tags);
|
||||
|
||||
const liveNow = scene.state.$behaviors?.find((b) => b instanceof behaviors.LiveNowTimer);
|
||||
@ -307,24 +316,142 @@ describe('transformSaveModelSchemaV2ToScene', () => {
|
||||
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
|
||||
});
|
||||
|
||||
describe('is new dashboard handling', () => {
|
||||
it('handles undefined is new dashbaord annotation', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(defaultDashboard);
|
||||
expect(scene.state.meta.isNew).toBe(false);
|
||||
});
|
||||
it('handles defined is new dashbaord annotation', () => {
|
||||
const dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
...defaultDashboard,
|
||||
metadata: {
|
||||
...defaultDashboard.metadata,
|
||||
annotations: {
|
||||
...defaultDashboard.metadata.annotations,
|
||||
[AnnoKeyDashboardIsNew]: true,
|
||||
describe('meta', () => {
|
||||
describe('initializes meta based on k8s resource', () => {
|
||||
it('handles undefined access values', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(defaultDashboard);
|
||||
// when access metadata undefined
|
||||
expect(scene.state.meta.canShare).toBe(true);
|
||||
expect(scene.state.meta.canSave).toBe(true);
|
||||
expect(scene.state.meta.canStar).toBe(true);
|
||||
expect(scene.state.meta.canEdit).toBe(true);
|
||||
expect(scene.state.meta.canDelete).toBe(true);
|
||||
expect(scene.state.meta.canAdmin).toBe(true);
|
||||
expect(scene.state.meta.annotationsPermissions).toBe(undefined);
|
||||
|
||||
expect(scene.state.meta.url).toBe('/d/abc');
|
||||
expect(scene.state.meta.slug).toBe('what-a-dashboard');
|
||||
expect(scene.state.meta.created).toBe('creationTs');
|
||||
expect(scene.state.meta.createdBy).toBe('user:createBy');
|
||||
expect(scene.state.meta.updated).toBe('updatedTs');
|
||||
expect(scene.state.meta.updatedBy).toBe('user:updatedBy');
|
||||
expect(scene.state.meta.folderUid).toBe('folder-uid');
|
||||
expect(scene.state.meta.version).toBe(123);
|
||||
});
|
||||
|
||||
it('handles access metadata values', () => {
|
||||
const dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
...defaultDashboard,
|
||||
access: {
|
||||
canSave: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
annotationsPermissions: {
|
||||
dashboard: {
|
||||
canAdd: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
},
|
||||
organization: {
|
||||
canAdd: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
expect(scene.state.meta.isNew).toBe(true);
|
||||
};
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
|
||||
expect(scene.state.meta.canShare).toBe(false);
|
||||
expect(scene.state.meta.canSave).toBe(false);
|
||||
expect(scene.state.meta.canStar).toBe(false);
|
||||
expect(scene.state.meta.canEdit).toBe(false);
|
||||
expect(scene.state.meta.canDelete).toBe(false);
|
||||
expect(scene.state.meta.canAdmin).toBe(false);
|
||||
expect(scene.state.meta.annotationsPermissions).toEqual(dashboard.access.annotationsPermissions);
|
||||
expect(scene.state.meta.version).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editable false dashboard', () => {
|
||||
let dashboard: DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
|
||||
beforeEach(() => {
|
||||
dashboard = {
|
||||
...cloneDeep(defaultDashboard),
|
||||
spec: {
|
||||
...defaultDashboard.spec,
|
||||
editable: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
it('Should set meta canEdit and canSave to false', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
expect(scene.state.meta.canMakeEditable).toBe(true);
|
||||
|
||||
expect(scene.state.meta.canSave).toBe(false);
|
||||
expect(scene.state.meta.canEdit).toBe(false);
|
||||
expect(scene.state.meta.canDelete).toBe(false);
|
||||
});
|
||||
|
||||
describe('when does not have save permissions', () => {
|
||||
it('Should set meta correct meta', () => {
|
||||
dashboard.access.canSave = false;
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
expect(scene.state.meta.canMakeEditable).toBe(false);
|
||||
|
||||
expect(scene.state.meta.canSave).toBe(false);
|
||||
expect(scene.state.meta.canEdit).toBe(false);
|
||||
expect(scene.state.meta.canDelete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editable true dashboard', () => {
|
||||
let dashboard: DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
|
||||
beforeEach(() => {
|
||||
dashboard = {
|
||||
...cloneDeep(defaultDashboard),
|
||||
spec: {
|
||||
...defaultDashboard.spec,
|
||||
editable: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
it('Should set meta canEdit and canSave to false', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
|
||||
expect(scene.state.meta.canMakeEditable).toBe(false);
|
||||
|
||||
expect(scene.state.meta.canSave).toBe(true);
|
||||
expect(scene.state.meta.canEdit).toBe(true);
|
||||
expect(scene.state.meta.canDelete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is new dashboard handling', () => {
|
||||
it('handles undefined is new dashbaord annotation', () => {
|
||||
const scene = transformSaveModelSchemaV2ToScene(defaultDashboard);
|
||||
expect(scene.state.meta.isNew).toBe(false);
|
||||
});
|
||||
it('handles defined is new dashbaord annotation', () => {
|
||||
const dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
...defaultDashboard,
|
||||
metadata: {
|
||||
...defaultDashboard.metadata,
|
||||
annotations: {
|
||||
...defaultDashboard.metadata.annotations,
|
||||
[AnnoKeyDashboardIsNew]: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const scene = transformSaveModelSchemaV2ToScene(dashboard);
|
||||
expect(scene.state.meta.isNew).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -51,9 +51,17 @@ import {
|
||||
QueryVariableKind,
|
||||
TextVariableKind,
|
||||
} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { AnnoKeyDashboardIsNew } from 'app/features/apiserver/types';
|
||||
import {
|
||||
AnnoKeyCreatedBy,
|
||||
AnnoKeyDashboardNotFound,
|
||||
AnnoKeyFolder,
|
||||
AnnoKeyUpdatedBy,
|
||||
AnnoKeyUpdatedTimestamp,
|
||||
AnnoKeyDashboardIsNew,
|
||||
} from 'app/features/apiserver/types';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { DashboardMeta } from 'app/types';
|
||||
|
||||
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
|
||||
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
|
||||
@ -113,6 +121,41 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
});
|
||||
});
|
||||
|
||||
const isDashboardEditable = Boolean(dashboard.editable);
|
||||
const canSave = dto.access.canSave !== false;
|
||||
|
||||
const meta: DashboardMeta = {
|
||||
canShare: dto.access.canShare !== false,
|
||||
canSave,
|
||||
canStar: dto.access.canStar !== false,
|
||||
canEdit: dto.access.canEdit !== false,
|
||||
canDelete: dto.access.canDelete !== false,
|
||||
canAdmin: dto.access.canAdmin !== false,
|
||||
url: dto.access.url,
|
||||
slug: dto.access.slug,
|
||||
annotationsPermissions: dto.access.annotationsPermissions,
|
||||
created: metadata.creationTimestamp,
|
||||
createdBy: metadata.annotations?.[AnnoKeyCreatedBy],
|
||||
updated: metadata.annotations?.[AnnoKeyUpdatedTimestamp],
|
||||
updatedBy: metadata.annotations?.[AnnoKeyUpdatedBy],
|
||||
folderUid: metadata.annotations?.[AnnoKeyFolder],
|
||||
|
||||
// UI-only metadata, ref: DashboardModel.initMeta
|
||||
showSettings: Boolean(dto.access.canEdit),
|
||||
canMakeEditable: canSave && !isDashboardEditable,
|
||||
hasUnsavedFolderChange: false,
|
||||
dashboardNotFound: Boolean(dto.metadata.annotations?.[AnnoKeyDashboardNotFound]),
|
||||
version: parseInt(metadata.resourceVersion, 10),
|
||||
isNew: Boolean(dto.metadata.annotations?.[AnnoKeyDashboardIsNew]),
|
||||
};
|
||||
|
||||
// Ref: DashboardModel.initMeta
|
||||
if (!isDashboardEditable) {
|
||||
meta.canEdit = false;
|
||||
meta.canDelete = false;
|
||||
meta.canSave = false;
|
||||
}
|
||||
|
||||
const dashboardScene = new DashboardScene({
|
||||
description: dashboard.description,
|
||||
editable: dashboard.editable,
|
||||
@ -120,15 +163,11 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
id: dashboard.id,
|
||||
isDirty: false,
|
||||
links: dashboard.links,
|
||||
// TODO: Combine access and metadata to compose the V1 meta object
|
||||
meta: {
|
||||
version: parseInt(metadata.resourceVersion, 10),
|
||||
isNew: Boolean(dto.metadata.annotations?.[AnnoKeyDashboardIsNew]),
|
||||
},
|
||||
meta,
|
||||
tags: dashboard.tags,
|
||||
title: dashboard.title,
|
||||
uid: metadata.name,
|
||||
version: dashboard.schemaVersion,
|
||||
version: parseInt(metadata.resourceVersion, 10),
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: dashboard.preload ? false : true,
|
||||
@ -180,6 +219,8 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
}),
|
||||
});
|
||||
|
||||
dashboardScene.setInitialSaveModel(dto.spec);
|
||||
|
||||
return dashboardScene;
|
||||
}
|
||||
|
||||
|
@ -35,7 +35,6 @@ import {
|
||||
GroupByVariableKind,
|
||||
AdhocVariableKind,
|
||||
AnnotationQueryKind,
|
||||
defaultAnnotationPanelFilter,
|
||||
DataLink,
|
||||
} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
@ -408,7 +407,7 @@ function getAnnotations(state: DashboardSceneState): AnnotationQueryKind[] {
|
||||
datasource: layer.state.query.datasource || getDefaultDataSourceRef(),
|
||||
enable: Boolean(layer.state.isEnabled),
|
||||
hide: Boolean(layer.state.isHidden),
|
||||
filter: layer.state.query.filter ?? defaultAnnotationPanelFilter(),
|
||||
filter: layer.state.query.filter,
|
||||
iconColor: layer.state.query.iconColor,
|
||||
},
|
||||
};
|
||||
|
@ -3,7 +3,9 @@ import {
|
||||
VariableRefresh as VariableRefreshV1,
|
||||
VariableSort as VariableSortV1,
|
||||
DashboardCursorSync as DashboardCursorSyncV1,
|
||||
DataTopic,
|
||||
} from '@grafana/schema';
|
||||
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
defaultDashboardV2Spec,
|
||||
@ -68,3 +70,16 @@ export function transformSortVariableToEnum(sort?: VariableSortV1): VariableSort
|
||||
return defaultVariableSort();
|
||||
}
|
||||
}
|
||||
|
||||
export function transformDataTopic(topic: DataTransformerConfig['topic']): DataTopic | undefined {
|
||||
switch (topic) {
|
||||
case 'annotations':
|
||||
return DataTopic.Annotations;
|
||||
case 'alertStates':
|
||||
return DataTopic.AlertStates;
|
||||
case 'series':
|
||||
return DataTopic.Series;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,65 @@
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
import {
|
||||
AnnoKeyCreatedBy,
|
||||
AnnoKeyDashboardId,
|
||||
AnnoKeyFolder,
|
||||
AnnoKeySlug,
|
||||
AnnoKeyUpdatedBy,
|
||||
AnnoKeyUpdatedTimestamp,
|
||||
} from 'app/features/apiserver/types';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types';
|
||||
|
||||
import { ResponseTransformers } from './ResponseTransformers';
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
|
||||
describe('ResponseTransformers', () => {
|
||||
describe('v1 transformation', () => {
|
||||
describe('v1 -> v2 transformation', () => {
|
||||
it('should transform DashboardDTO to DashboardWithAccessInfo<DashboardV2Spec>', () => {
|
||||
const dashboardDTO: DashboardDTO = {
|
||||
meta: {
|
||||
created: '2023-01-01T00:00:00Z',
|
||||
createdBy: 'user1',
|
||||
updated: '2023-01-02T00:00:00Z',
|
||||
updatedBy: 'user2',
|
||||
folderUid: 'folder1',
|
||||
const dashboardV1: DashboardDataDTO = {
|
||||
uid: 'dashboard-uid',
|
||||
id: 123,
|
||||
title: 'Dashboard Title',
|
||||
description: 'Dashboard Description',
|
||||
tags: ['tag1', 'tag2'],
|
||||
schemaVersion: 1,
|
||||
graphTooltip: 0,
|
||||
preload: true,
|
||||
liveNow: false,
|
||||
editable: true,
|
||||
time: { from: 'now-6h', to: 'now' },
|
||||
timezone: 'browser',
|
||||
refresh: '5m',
|
||||
timepicker: {
|
||||
refresh_intervals: ['5s', '10s', '30s'],
|
||||
hidden: false,
|
||||
time_options: ['5m', '15m', '1h'],
|
||||
nowDelay: '1m',
|
||||
},
|
||||
fiscalYearStartMonth: 1,
|
||||
weekStart: 'monday',
|
||||
version: 1,
|
||||
links: [
|
||||
{
|
||||
title: 'Link 1',
|
||||
url: 'https://grafana.com',
|
||||
asDropdown: false,
|
||||
targetBlank: true,
|
||||
includeVars: true,
|
||||
keepTime: true,
|
||||
tags: ['tag1', 'tag2'],
|
||||
icon: 'external link',
|
||||
type: 'link',
|
||||
tooltip: 'Link 1 Tooltip',
|
||||
},
|
||||
],
|
||||
annotations: {
|
||||
list: [],
|
||||
},
|
||||
};
|
||||
|
||||
const dto: DashboardWithAccessInfo<DashboardDataDTO> = {
|
||||
spec: dashboardV1,
|
||||
access: {
|
||||
slug: 'dashboard-slug',
|
||||
url: '/d/dashboard-slug',
|
||||
canAdmin: true,
|
||||
@ -27,73 +73,59 @@ describe('ResponseTransformers', () => {
|
||||
organization: { canAdd: true, canEdit: true, canDelete: true },
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
uid: 'dashboard1',
|
||||
title: 'Dashboard Title',
|
||||
description: 'Dashboard Description',
|
||||
tags: ['tag1', 'tag2'],
|
||||
schemaVersion: 1,
|
||||
graphTooltip: 0,
|
||||
preload: true,
|
||||
liveNow: false,
|
||||
editable: true,
|
||||
time: { from: 'now-6h', to: 'now' },
|
||||
timezone: 'browser',
|
||||
refresh: '5m',
|
||||
timepicker: {
|
||||
refresh_intervals: ['5s', '10s', '30s'],
|
||||
hidden: false,
|
||||
time_options: ['5m', '15m', '1h'],
|
||||
nowDelay: '1m',
|
||||
},
|
||||
fiscalYearStartMonth: 1,
|
||||
weekStart: 'monday',
|
||||
version: 1,
|
||||
links: [],
|
||||
apiVersion: 'v1',
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'dashboard-uid',
|
||||
resourceVersion: '1',
|
||||
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
annotations: {
|
||||
list: [],
|
||||
[AnnoKeyCreatedBy]: 'user1',
|
||||
[AnnoKeyUpdatedBy]: 'user2',
|
||||
[AnnoKeyUpdatedTimestamp]: '2023-01-02T00:00:00Z',
|
||||
[AnnoKeyFolder]: 'folder1',
|
||||
[AnnoKeySlug]: 'dashboard-slug',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transformed = ResponseTransformers.ensureV2Response(dashboardDTO);
|
||||
const transformed = ResponseTransformers.ensureV2Response(dto);
|
||||
|
||||
expect(transformed.apiVersion).toBe('v2alpha1');
|
||||
expect(transformed.kind).toBe('DashboardWithAccessInfo');
|
||||
expect(transformed.metadata.creationTimestamp).toBe(dashboardDTO.meta.created);
|
||||
expect(transformed.metadata.name).toBe(dashboardDTO.dashboard.uid);
|
||||
expect(transformed.metadata.resourceVersion).toBe(dashboardDTO.dashboard.version?.toString());
|
||||
expect(transformed.metadata.annotations?.['grafana.app/createdBy']).toBe(dashboardDTO.meta.createdBy);
|
||||
expect(transformed.metadata.annotations?.['grafana.app/updatedBy']).toBe(dashboardDTO.meta.updatedBy);
|
||||
expect(transformed.metadata.annotations?.['grafana.app/updatedTimestamp']).toBe(dashboardDTO.meta.updated);
|
||||
expect(transformed.metadata.annotations?.['grafana.app/folder']).toBe(dashboardDTO.meta.folderUid);
|
||||
expect(transformed.metadata.annotations?.['grafana.app/slug']).toBe(dashboardDTO.meta.slug);
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyCreatedBy]).toEqual('user1');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyUpdatedBy]).toEqual('user2');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyUpdatedTimestamp]).toEqual('2023-01-02T00:00:00Z');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyFolder]).toEqual('folder1');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeySlug]).toEqual('dashboard-slug');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyDashboardId]).toBe(123);
|
||||
|
||||
const spec = transformed.spec;
|
||||
expect(spec.title).toBe(dashboardDTO.dashboard.title);
|
||||
expect(spec.description).toBe(dashboardDTO.dashboard.description);
|
||||
expect(spec.tags).toEqual(dashboardDTO.dashboard.tags);
|
||||
expect(spec.schemaVersion).toBe(dashboardDTO.dashboard.schemaVersion);
|
||||
expect(spec.title).toBe(dashboardV1.title);
|
||||
expect(spec.description).toBe(dashboardV1.description);
|
||||
expect(spec.tags).toEqual(dashboardV1.tags);
|
||||
expect(spec.schemaVersion).toBe(dashboardV1.schemaVersion);
|
||||
expect(spec.cursorSync).toBe('Off'); // Assuming transformCursorSynctoEnum(0) returns 'Off'
|
||||
expect(spec.preload).toBe(dashboardDTO.dashboard.preload);
|
||||
expect(spec.liveNow).toBe(dashboardDTO.dashboard.liveNow);
|
||||
expect(spec.editable).toBe(dashboardDTO.dashboard.editable);
|
||||
expect(spec.timeSettings.from).toBe(dashboardDTO.dashboard.time?.from);
|
||||
expect(spec.timeSettings.to).toBe(dashboardDTO.dashboard.time?.to);
|
||||
expect(spec.timeSettings.timezone).toBe(dashboardDTO.dashboard.timezone);
|
||||
expect(spec.timeSettings.autoRefresh).toBe(dashboardDTO.dashboard.refresh);
|
||||
expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardDTO.dashboard.timepicker?.refresh_intervals);
|
||||
expect(spec.timeSettings.hideTimepicker).toBe(dashboardDTO.dashboard.timepicker?.hidden);
|
||||
expect(spec.timeSettings.quickRanges).toEqual(dashboardDTO.dashboard.timepicker?.time_options);
|
||||
expect(spec.timeSettings.nowDelay).toBe(dashboardDTO.dashboard.timepicker?.nowDelay);
|
||||
expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardDTO.dashboard.fiscalYearStartMonth);
|
||||
expect(spec.timeSettings.weekStart).toBe(dashboardDTO.dashboard.weekStart);
|
||||
expect(spec.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns []
|
||||
expect(spec.preload).toBe(dashboardV1.preload);
|
||||
expect(spec.liveNow).toBe(dashboardV1.liveNow);
|
||||
expect(spec.editable).toBe(dashboardV1.editable);
|
||||
expect(spec.timeSettings.from).toBe(dashboardV1.time?.from);
|
||||
expect(spec.timeSettings.to).toBe(dashboardV1.time?.to);
|
||||
expect(spec.timeSettings.timezone).toBe(dashboardV1.timezone);
|
||||
expect(spec.timeSettings.autoRefresh).toBe(dashboardV1.refresh);
|
||||
expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardV1.timepicker?.refresh_intervals);
|
||||
expect(spec.timeSettings.hideTimepicker).toBe(dashboardV1.timepicker?.hidden);
|
||||
expect(spec.timeSettings.quickRanges).toEqual(dashboardV1.timepicker?.time_options);
|
||||
expect(spec.timeSettings.nowDelay).toBe(dashboardV1.timepicker?.nowDelay);
|
||||
expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardV1.fiscalYearStartMonth);
|
||||
expect(spec.timeSettings.weekStart).toBe(dashboardV1.weekStart);
|
||||
expect(spec.links).toEqual(dashboardV1.links);
|
||||
expect(spec.annotations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 transformation', () => {
|
||||
describe('v2 -> v1 transformation', () => {
|
||||
it('should return the same object if it is already a DashboardDTO', () => {
|
||||
const dashboard: DashboardDTO = {
|
||||
dashboard: {
|
||||
@ -145,7 +177,20 @@ describe('ResponseTransformers', () => {
|
||||
fiscalYearStartMonth: 1,
|
||||
weekStart: 'monday',
|
||||
},
|
||||
links: [],
|
||||
links: [
|
||||
{
|
||||
title: 'Link 1',
|
||||
url: 'https://grafana.com',
|
||||
asDropdown: false,
|
||||
targetBlank: true,
|
||||
includeVars: true,
|
||||
keepTime: true,
|
||||
tags: ['tag1', 'tag2'],
|
||||
icon: 'external link',
|
||||
type: 'link',
|
||||
tooltip: 'Link 1 Tooltip',
|
||||
},
|
||||
],
|
||||
annotations: [],
|
||||
variables: [],
|
||||
elements: {},
|
||||
@ -209,7 +254,7 @@ describe('ResponseTransformers', () => {
|
||||
expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay);
|
||||
expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth);
|
||||
expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart);
|
||||
expect(dashboard.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns []
|
||||
expect(dashboard.links).toEqual(dashboardV2.spec.links);
|
||||
expect(dashboard.annotations).toEqual({ list: [] });
|
||||
});
|
||||
});
|
||||
|
@ -1,38 +1,68 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AnnotationQuery, DataQuery, Panel, VariableModel } from '@grafana/schema';
|
||||
import {
|
||||
AnnotationQueryKind,
|
||||
DashboardV2Spec,
|
||||
DataLink,
|
||||
DatasourceVariableKind,
|
||||
defaultDashboardV2Spec,
|
||||
defaultFieldConfigSource,
|
||||
defaultTimeSettingsSpec,
|
||||
PanelQueryKind,
|
||||
QueryVariableKind,
|
||||
TransformationKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { transformCursorSynctoEnum } from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils';
|
||||
import { DataTransformerConfig } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen';
|
||||
import {
|
||||
AnnoKeyCreatedBy,
|
||||
AnnoKeyDashboardId,
|
||||
AnnoKeyFolder,
|
||||
AnnoKeySlug,
|
||||
AnnoKeyUpdatedBy,
|
||||
AnnoKeyUpdatedTimestamp,
|
||||
} from 'app/features/apiserver/types';
|
||||
import { transformCursorSyncV2ToV1 } from 'app/features/dashboard-scene/serialization/transformToV1TypesUtils';
|
||||
import {
|
||||
transformCursorSynctoEnum,
|
||||
transformDataTopic,
|
||||
transformSortVariableToEnum,
|
||||
transformVariableHideToEnum,
|
||||
transformVariableRefreshToEnum,
|
||||
} from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types';
|
||||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
import { isDashboardResource, isDashboardV0Spec, isDashboardV2Spec } from './utils';
|
||||
import { isDashboardResource, isDashboardV0Spec, isDashboardV2Resource } from './utils';
|
||||
|
||||
export function ensureV2Response(
|
||||
dto: DashboardDTO | DashboardWithAccessInfo<DashboardDataDTO> | DashboardWithAccessInfo<DashboardV2Spec>
|
||||
): DashboardWithAccessInfo<DashboardV2Spec> {
|
||||
if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) {
|
||||
return dto as DashboardWithAccessInfo<DashboardV2Spec>;
|
||||
if (isDashboardV2Resource(dto)) {
|
||||
return dto;
|
||||
}
|
||||
let dashboard: DashboardDataDTO;
|
||||
|
||||
// after discarding the dto is not a v2 spec, we can safely assume it's a v0 spec or a dashboardDTO
|
||||
dto = dto as unknown as DashboardWithAccessInfo<DashboardDataDTO> | DashboardDTO;
|
||||
if (isDashboardResource(dto)) {
|
||||
dashboard = dto.spec;
|
||||
} else {
|
||||
dashboard = dto.dashboard;
|
||||
}
|
||||
|
||||
const timeSettingsDefaults = defaultTimeSettingsSpec();
|
||||
const dashboardDefaults = defaultDashboardV2Spec();
|
||||
|
||||
const dashboard = isDashboardResource(dto) ? dto.spec : dto.dashboard;
|
||||
const [elements, layout] = getElementsFromPanels(dashboard.panels || []);
|
||||
const variables = getVariables(dashboard.templating?.list || []);
|
||||
const annotations = getAnnotations(dashboard.annotations?.list || []);
|
||||
|
||||
const accessAndMeta = isDashboardResource(dto)
|
||||
? {
|
||||
...dto.access,
|
||||
created: dto.metadata.creationTimestamp,
|
||||
createdBy: dto.metadata.annotations?.['grafana.app/createdBy'],
|
||||
updatedBy: dto.metadata.annotations?.['grafana.app/updatedBy'],
|
||||
updated: dto.metadata.annotations?.['grafana.app/updatedTimestamp'],
|
||||
folderUid: dto.metadata.annotations?.['grafana.app/folder'],
|
||||
slug: dto.metadata.annotations?.['grafana.app/slug'],
|
||||
createdBy: dto.metadata.annotations?.[AnnoKeyCreatedBy],
|
||||
updatedBy: dto.metadata.annotations?.[AnnoKeyUpdatedBy],
|
||||
updated: dto.metadata.annotations?.[AnnoKeyUpdatedTimestamp],
|
||||
folderUid: dto.metadata.annotations?.[AnnoKeyFolder],
|
||||
slug: dto.metadata.annotations?.[AnnoKeySlug],
|
||||
}
|
||||
: dto.meta;
|
||||
|
||||
@ -58,16 +88,10 @@ export function ensureV2Response(
|
||||
nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay,
|
||||
},
|
||||
links: dashboard.links || [],
|
||||
annotations: [], // TODO
|
||||
variables: [], // todo
|
||||
elements: {}, // todo
|
||||
layout: {
|
||||
// todo
|
||||
kind: 'GridLayout',
|
||||
spec: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
annotations,
|
||||
variables,
|
||||
elements,
|
||||
layout,
|
||||
};
|
||||
|
||||
return {
|
||||
@ -78,11 +102,12 @@ export function ensureV2Response(
|
||||
name: dashboard.uid,
|
||||
resourceVersion: dashboard.version?.toString() || '0',
|
||||
annotations: {
|
||||
'grafana.app/createdBy': accessAndMeta.createdBy,
|
||||
'grafana.app/updatedBy': accessAndMeta.updatedBy,
|
||||
'grafana.app/updatedTimestamp': accessAndMeta.updated,
|
||||
'grafana.app/folder': accessAndMeta.folderUid,
|
||||
'grafana.app/slug': accessAndMeta.slug,
|
||||
[AnnoKeyCreatedBy]: accessAndMeta.createdBy,
|
||||
[AnnoKeyUpdatedBy]: accessAndMeta.updatedBy,
|
||||
[AnnoKeyUpdatedTimestamp]: accessAndMeta.updated,
|
||||
[AnnoKeyFolder]: accessAndMeta.folderUid,
|
||||
[AnnoKeySlug]: accessAndMeta.slug,
|
||||
[AnnoKeyDashboardId]: dashboard.id ?? undefined,
|
||||
},
|
||||
},
|
||||
spec,
|
||||
@ -127,11 +152,11 @@ export function ensureV1Response(
|
||||
return {
|
||||
meta: {
|
||||
created: dashboard.metadata.creationTimestamp,
|
||||
createdBy: dashboard.metadata.annotations?.['grafana.app/createdBy'] ?? '',
|
||||
updated: dashboard.metadata.annotations?.['grafana.app/updatedTimestamp'],
|
||||
updatedBy: dashboard.metadata.annotations?.['grafana.app/updatedBy'],
|
||||
folderUid: dashboard.metadata.annotations?.['grafana.app/folder'],
|
||||
slug: dashboard.metadata.annotations?.['grafana.app/slug'],
|
||||
createdBy: dashboard.metadata.annotations?.[AnnoKeyCreatedBy] ?? '',
|
||||
updated: dashboard.metadata.annotations?.[AnnoKeyUpdatedTimestamp],
|
||||
updatedBy: dashboard.metadata.annotations?.[AnnoKeyUpdatedBy],
|
||||
folderUid: dashboard.metadata.annotations?.[AnnoKeyFolder],
|
||||
slug: dashboard.metadata.annotations?.[AnnoKeySlug],
|
||||
url: dashboard.access.url,
|
||||
canAdmin: dashboard.access.canAdmin,
|
||||
canDelete: dashboard.access.canDelete,
|
||||
@ -147,8 +172,7 @@ export function ensureV1Response(
|
||||
description: spec.description,
|
||||
tags: spec.tags,
|
||||
schemaVersion: spec.schemaVersion,
|
||||
// @ts-ignore TODO: Use transformers for these enums
|
||||
// graphTooltip: spec.cursorSync, // Assuming transformCursorSynctoEnum is reversible
|
||||
graphTooltip: transformCursorSyncV2ToV1(spec.cursorSync),
|
||||
preload: spec.preload,
|
||||
liveNow: spec.liveNow,
|
||||
editable: spec.editable,
|
||||
@ -167,8 +191,10 @@ export function ensureV1Response(
|
||||
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth,
|
||||
weekStart: spec.timeSettings.weekStart,
|
||||
version: parseInt(dashboard.metadata.resourceVersion, 10),
|
||||
links: spec.links, // Assuming transformDashboardLinksToEnums is reversible
|
||||
links: spec.links,
|
||||
annotations: { list: [] }, // TODO
|
||||
panels: [], // TODO
|
||||
templating: { list: [] }, // TODO
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -178,3 +204,219 @@ export const ResponseTransformers = {
|
||||
ensureV2Response,
|
||||
ensureV1Response,
|
||||
};
|
||||
|
||||
// TODO[schema v2]: handle rows
|
||||
function getElementsFromPanels(panels: Panel[]): [DashboardV2Spec['elements'], DashboardV2Spec['layout']] {
|
||||
const elements: DashboardV2Spec['elements'] = {};
|
||||
const layout: DashboardV2Spec['layout'] = {
|
||||
kind: 'GridLayout',
|
||||
spec: {
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (!panels) {
|
||||
return [elements, layout];
|
||||
}
|
||||
|
||||
// iterate over panels
|
||||
for (const p of panels) {
|
||||
const queries = getPanelQueries(
|
||||
(p.targets as unknown as DataQuery[]) || [],
|
||||
p.datasource?.type || getDefaultDatasourceType()
|
||||
);
|
||||
|
||||
const transformations = getPanelTransformations(p.transformations || []);
|
||||
|
||||
elements[p.id!] = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: p.title || '',
|
||||
description: p.description || '',
|
||||
vizConfig: {
|
||||
kind: p.type,
|
||||
spec: {
|
||||
fieldConfig: (p.fieldConfig as any) || defaultFieldConfigSource(),
|
||||
options: p.options as any,
|
||||
pluginVersion: p.pluginVersion!,
|
||||
},
|
||||
},
|
||||
links:
|
||||
p.links?.map<DataLink>((l) => ({
|
||||
title: l.title,
|
||||
url: l.url || '',
|
||||
targetBlank: l.targetBlank,
|
||||
})) || [],
|
||||
id: p.id!,
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
transformations, // TODO[schema v2]: handle transformations
|
||||
queryOptions: {
|
||||
cacheTimeout: p.cacheTimeout,
|
||||
maxDataPoints: p.maxDataPoints,
|
||||
interval: p.interval,
|
||||
hideTimeOverride: p.hideTimeOverride,
|
||||
queryCachingTTL: p.queryCachingTTL,
|
||||
timeFrom: p.timeFrom,
|
||||
timeShift: p.timeShift,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
layout.spec.items.push({
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
x: p.gridPos!.x,
|
||||
y: p.gridPos!.y,
|
||||
width: p.gridPos!.w,
|
||||
height: p.gridPos!.h,
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: p.id!.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [elements, layout];
|
||||
}
|
||||
|
||||
function getDefaultDatasourceType() {
|
||||
const datasources = config.datasources;
|
||||
// find default datasource in datasources
|
||||
return Object.values(datasources).find((ds) => ds.isDefault)!.type;
|
||||
}
|
||||
|
||||
function getPanelQueries(targets: DataQuery[], panelDatasourceType: string): PanelQueryKind[] {
|
||||
return targets.map((t) => {
|
||||
const { refId, hide, datasource, ...query } = t;
|
||||
const q: PanelQueryKind = {
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: t.refId,
|
||||
hidden: t.hide ?? false,
|
||||
// TODO[schema v2]: ds coming from panel ?!?!!?! AAAAAAAAAAAAA! Send help!
|
||||
datasource: t.datasource ? t.datasource : undefined,
|
||||
query: {
|
||||
kind: t.datasource?.type || panelDatasourceType,
|
||||
spec: {
|
||||
...query,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return q;
|
||||
});
|
||||
}
|
||||
|
||||
function getPanelTransformations(transformations: DataTransformerConfig[]): TransformationKind[] {
|
||||
return transformations.map((t) => {
|
||||
return {
|
||||
kind: t.id,
|
||||
spec: {
|
||||
...t,
|
||||
topic: transformDataTopic(t.topic),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getVariables(vars: VariableModel[]): DashboardV2Spec['variables'] {
|
||||
const variables: DashboardV2Spec['variables'] = [];
|
||||
for (const v of vars) {
|
||||
switch (v.type) {
|
||||
case 'query':
|
||||
let query = v.query || {};
|
||||
|
||||
if (typeof query === 'string') {
|
||||
console.error('Query variable query is a string. It needs to extend DataQuery.');
|
||||
query = {};
|
||||
}
|
||||
|
||||
const qv: QueryVariableKind = {
|
||||
kind: 'QueryVariable',
|
||||
spec: {
|
||||
name: v.name,
|
||||
label: v.label,
|
||||
hide: transformVariableHideToEnum(v.hide),
|
||||
skipUrlSync: Boolean(v.skipUrlSync),
|
||||
multi: Boolean(v.multi),
|
||||
includeAll: Boolean(v.includeAll),
|
||||
allValue: v.allValue,
|
||||
current: v.current || { text: '', value: '' },
|
||||
options: v.options || [],
|
||||
refresh: transformVariableRefreshToEnum(v.refresh),
|
||||
datasource: v.datasource ?? undefined,
|
||||
regex: v.regex || '',
|
||||
sort: transformSortVariableToEnum(v.sort),
|
||||
query: {
|
||||
kind: v.datasource?.type || getDefaultDatasourceType(),
|
||||
spec: {
|
||||
...query,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
variables.push(qv);
|
||||
break;
|
||||
case 'datasource':
|
||||
let pluginId = getDefaultDatasourceType();
|
||||
|
||||
if (v.query && typeof v.query === 'string') {
|
||||
pluginId = v.query;
|
||||
}
|
||||
|
||||
const dv: DatasourceVariableKind = {
|
||||
kind: 'DatasourceVariable',
|
||||
spec: {
|
||||
name: v.name,
|
||||
label: v.label,
|
||||
hide: transformVariableHideToEnum(v.hide),
|
||||
skipUrlSync: Boolean(v.skipUrlSync),
|
||||
multi: Boolean(v.multi),
|
||||
includeAll: Boolean(v.includeAll),
|
||||
allValue: v.allValue,
|
||||
current: v.current || { text: '', value: '' },
|
||||
options: v.options || [],
|
||||
refresh: transformVariableRefreshToEnum(v.refresh),
|
||||
pluginId,
|
||||
regex: v.regex || '',
|
||||
description: v.description || '',
|
||||
},
|
||||
};
|
||||
variables.push(dv);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Variable transformation not implemented: ${v.type}`);
|
||||
}
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
function getAnnotations(annotations: AnnotationQuery[]): DashboardV2Spec['annotations'] {
|
||||
return annotations.map((a) => {
|
||||
const aq: AnnotationQueryKind = {
|
||||
kind: 'AnnotationQuery',
|
||||
spec: {
|
||||
name: a.name,
|
||||
datasource: a.datasource ?? undefined,
|
||||
enable: a.enable,
|
||||
hide: Boolean(a.hide),
|
||||
iconColor: a.iconColor,
|
||||
builtIn: Boolean(a.builtIn),
|
||||
query: {
|
||||
kind: a.datasource?.type || getDefaultDatasourceType(),
|
||||
spec: {
|
||||
...a.target,
|
||||
},
|
||||
},
|
||||
filter: a.filter,
|
||||
},
|
||||
};
|
||||
return aq;
|
||||
});
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { config } from '@grafana/runtime';
|
||||
import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardV2APIStub } from './v2';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
describe('DashboardApi', () => {
|
||||
it('should use legacy api by default', () => {
|
||||
@ -36,7 +36,7 @@ describe('DashboardApi', () => {
|
||||
|
||||
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2APIStub);
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2API);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { LegacyDashboardAPI } from './legacy';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { getDashboardsApiVersion } from './utils';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardV2APIStub } from './v2';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
type DashboardAPIClients = {
|
||||
legacy: DashboardAPI<DashboardDTO>;
|
||||
@ -36,12 +36,12 @@ export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI<Dashboar
|
||||
clients = {
|
||||
legacy: new LegacyDashboardAPI(),
|
||||
v0: new K8sDashboardAPI(),
|
||||
v2: new K8sDashboardV2APIStub(isConvertingToV1),
|
||||
v2: new K8sDashboardV2API(isConvertingToV1),
|
||||
};
|
||||
}
|
||||
|
||||
if (v === 'v2' && requestV2Response === 'v2') {
|
||||
return new K8sDashboardV2APIStub(false);
|
||||
return new K8sDashboardV2API(false);
|
||||
}
|
||||
|
||||
if (!clients[v]) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
|
||||
import { getDashboardsApiVersion } from './utils';
|
||||
|
||||
@ -48,4 +48,30 @@ describe('getDashboardsApiVersion', () => {
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('legacy');
|
||||
});
|
||||
|
||||
describe('forcing scenes through URL', () => {
|
||||
beforeAll(() => {
|
||||
locationService.push('/test?scenes=false');
|
||||
});
|
||||
|
||||
it('should return legacy when kubernetesDashboards is disabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: false,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: false,
|
||||
};
|
||||
|
||||
expect(getDashboardsApiVersion()).toBe('legacy');
|
||||
});
|
||||
|
||||
it('should return legacy when kubernetesDashboards is disabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: false,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
|
||||
expect(getDashboardsApiVersion()).toBe('v0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types';
|
||||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
|
||||
export function getDashboardsApiVersion() {
|
||||
const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false';
|
||||
|
||||
// if dashboard scene is disabled, use legacy API response for the old architecture
|
||||
if (!config.featureToggles.dashboardScene) {
|
||||
if (!config.featureToggles.dashboardScene || forcingOldDashboardArch) {
|
||||
// for old architecture, use v0 API for k8s dashboards
|
||||
if (config.featureToggles.kubernetesDashboards) {
|
||||
return 'v0';
|
||||
@ -38,10 +40,16 @@ export function isDashboardResource(
|
||||
return isK8sDashboard;
|
||||
}
|
||||
|
||||
export function isDashboardV2Spec(obj: object): obj is DashboardV2Spec {
|
||||
export function isDashboardV2Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardV2Spec {
|
||||
return 'elements' in obj;
|
||||
}
|
||||
|
||||
export function isDashboardV0Spec(obj: object): obj is DashboardDataDTO {
|
||||
export function isDashboardV0Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardDataDTO {
|
||||
return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec
|
||||
}
|
||||
|
||||
export function isDashboardV2Resource(
|
||||
obj: DashboardDTO | DashboardWithAccessInfo<DashboardDataDTO> | DashboardWithAccessInfo<DashboardV2Spec>
|
||||
): obj is DashboardWithAccessInfo<DashboardV2Spec> {
|
||||
return isDashboardResource(obj) && isDashboardV2Spec(obj.spec);
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl } from 'app/features/apiserver/types';
|
||||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
import { K8sDashboardV2APIStub } from './v2';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
const mockDashboardDto: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
@ -56,7 +56,7 @@ describe('v2 dashboard API', () => {
|
||||
});
|
||||
|
||||
const convertToV1 = false;
|
||||
const api = new K8sDashboardV2APIStub(convertToV1);
|
||||
const api = new K8sDashboardV2API(convertToV1);
|
||||
// because the API can currently return both DashboardDTO and DashboardWithAccessInfo<DashboardV2Spec> based on the
|
||||
// parameter convertToV1, we need to cast the result to DashboardWithAccessInfo<DashboardV2Spec> to be able to
|
||||
// access
|
||||
|
@ -17,7 +17,7 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
|
||||
import { ResponseTransformers } from './ResponseTransformers';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
|
||||
export class K8sDashboardV2APIStub implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO> {
|
||||
export class K8sDashboardV2API implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO> {
|
||||
private client: ResourceClient<DashboardV2Spec>;
|
||||
|
||||
constructor(private convertToV1: boolean) {
|
||||
|
@ -23,6 +23,12 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
|
||||
const params = useParams<DashboardPageParams>();
|
||||
const location = useLocation();
|
||||
|
||||
// Force scenes if v2 api and scenes are enabled
|
||||
if (config.featureToggles.useV2DashboardsAPI && config.featureToggles.dashboardScene && !forceOld) {
|
||||
console.log('DashboardPageProxy: forcing scenes because of v2 api');
|
||||
return <DashboardScenePage {...props} />;
|
||||
}
|
||||
|
||||
if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) {
|
||||
return <DashboardScenePage {...props} />;
|
||||
}
|
||||
|
@ -3,23 +3,121 @@ import _, { isFunction } from 'lodash'; // eslint-disable-line lodash/import-sco
|
||||
import moment from 'moment'; // eslint-disable-line no-restricted-imports
|
||||
|
||||
import { AppEvents, dateMath, UrlQueryMap, UrlQueryValue } from '@grafana/data';
|
||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
DashboardV2Spec,
|
||||
defaultDashboardV2Spec,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import impressionSrv from 'app/core/services/impression_srv';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { AnnoKeyDashboardIsSnapshot, AnnoKeyDashboardNotFound } from 'app/features/apiserver/types';
|
||||
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { DashboardDTO } from 'app/types';
|
||||
|
||||
import { appEvents } from '../../../core/core';
|
||||
import { ResponseTransformers } from '../api/ResponseTransformers';
|
||||
import { getDashboardAPI } from '../api/dashboard_api';
|
||||
import { DashboardWithAccessInfo } from '../api/types';
|
||||
|
||||
import { getDashboardSrv } from './DashboardSrv';
|
||||
import { getDashboardSnapshotSrv } from './SnapshotSrv';
|
||||
|
||||
export class DashboardLoaderSrv {
|
||||
constructor() {}
|
||||
_dashboardLoadFailed(title: string, snapshot?: boolean): DashboardDTO {
|
||||
interface DashboardLoaderSrvLike<T> {
|
||||
_dashboardLoadFailed(title: string, snapshot?: boolean): T;
|
||||
loadDashboard(
|
||||
type: UrlQueryValue,
|
||||
slug: string | undefined,
|
||||
uid: string | undefined,
|
||||
params?: UrlQueryMap
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
|
||||
abstract _dashboardLoadFailed(title: string, snapshot?: boolean): T;
|
||||
abstract loadDashboard(
|
||||
type: UrlQueryValue,
|
||||
slug: string | undefined,
|
||||
uid: string | undefined,
|
||||
params?: UrlQueryMap
|
||||
): Promise<T>;
|
||||
|
||||
protected loadScriptedDashboard(file: string) {
|
||||
const url = 'public/dashboards/' + file.replace(/\.(?!js)/, '/') + '?' + new Date().getTime();
|
||||
|
||||
return getBackendSrv()
|
||||
.get(url)
|
||||
.then(this.executeScript.bind(this))
|
||||
.then(
|
||||
(result: any) => {
|
||||
return {
|
||||
meta: {
|
||||
fromScript: true,
|
||||
canDelete: false,
|
||||
canSave: false,
|
||||
canStar: false,
|
||||
},
|
||||
dashboard: result.data,
|
||||
};
|
||||
},
|
||||
(err) => {
|
||||
console.error('Script dashboard error ' + err);
|
||||
appEvents.emit(AppEvents.alertError, [
|
||||
'Script Error',
|
||||
'Please make sure it exists and returns a valid dashboard',
|
||||
]);
|
||||
return this._dashboardLoadFailed('Scripted dashboard');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private executeScript(result: any) {
|
||||
const services = {
|
||||
dashboardSrv: getDashboardSrv(),
|
||||
datasourceSrv: getDatasourceSrv(),
|
||||
};
|
||||
const scriptFunc = new Function(
|
||||
'ARGS',
|
||||
'kbn',
|
||||
'dateMath',
|
||||
'_',
|
||||
'moment',
|
||||
'window',
|
||||
'document',
|
||||
'$',
|
||||
'jQuery',
|
||||
'services',
|
||||
result
|
||||
);
|
||||
const scriptResult = scriptFunc(
|
||||
locationService.getSearchObject(),
|
||||
kbn,
|
||||
dateMath,
|
||||
_,
|
||||
moment,
|
||||
window,
|
||||
document,
|
||||
$,
|
||||
$,
|
||||
services
|
||||
);
|
||||
|
||||
// Handle async dashboard scripts
|
||||
if (isFunction(scriptResult)) {
|
||||
return new Promise((resolve) => {
|
||||
scriptResult((dashboard: any) => {
|
||||
resolve({ data: dashboard });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { data: scriptResult };
|
||||
}
|
||||
}
|
||||
|
||||
export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
|
||||
_dashboardLoadFailed(title: string, snapshot?: boolean) {
|
||||
snapshot = snapshot || false;
|
||||
return {
|
||||
meta: {
|
||||
@ -45,7 +143,7 @@ export class DashboardLoaderSrv {
|
||||
let promise;
|
||||
|
||||
if (type === 'script' && slug) {
|
||||
promise = this._loadScriptedDashboard(slug);
|
||||
promise = this.loadScriptedDashboard(slug);
|
||||
} else if (type === 'snapshot' && slug) {
|
||||
promise = getDashboardSnapshotSrv()
|
||||
.getSnapshot(slug)
|
||||
@ -115,77 +213,119 @@ export class DashboardLoaderSrv {
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
_loadScriptedDashboard(file: string) {
|
||||
const url = 'public/dashboards/' + file.replace(/\.(?!js)/, '/') + '?' + new Date().getTime();
|
||||
|
||||
return getBackendSrv()
|
||||
.get(url)
|
||||
.then(this._executeScript.bind(this))
|
||||
.then(
|
||||
(result: any) => {
|
||||
return {
|
||||
meta: {
|
||||
fromScript: true,
|
||||
canDelete: false,
|
||||
canSave: false,
|
||||
canStar: false,
|
||||
},
|
||||
dashboard: result.data,
|
||||
};
|
||||
export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAccessInfo<DashboardV2Spec>> {
|
||||
_dashboardLoadFailed(title: string, snapshot?: boolean) {
|
||||
const dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
spec: {
|
||||
...defaultDashboardV2Spec(),
|
||||
title,
|
||||
},
|
||||
access: {
|
||||
canSave: false,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
},
|
||||
apiVersion: 'v2alpha1',
|
||||
metadata: {
|
||||
creationTimestamp: '',
|
||||
name: title,
|
||||
namespace: '',
|
||||
resourceVersion: '',
|
||||
annotations: {
|
||||
[AnnoKeyDashboardNotFound]: true,
|
||||
[AnnoKeyDashboardIsSnapshot]: Boolean(snapshot),
|
||||
},
|
||||
(err) => {
|
||||
console.error('Script dashboard error ' + err);
|
||||
appEvents.emit(AppEvents.alertError, [
|
||||
'Script Error',
|
||||
'Please make sure it exists and returns a valid dashboard',
|
||||
]);
|
||||
return this._dashboardLoadFailed('Scripted dashboard');
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
_executeScript(result: any) {
|
||||
const services = {
|
||||
dashboardSrv: getDashboardSrv(),
|
||||
datasourceSrv: getDatasourceSrv(),
|
||||
};
|
||||
const scriptFunc = new Function(
|
||||
'ARGS',
|
||||
'kbn',
|
||||
'dateMath',
|
||||
'_',
|
||||
'moment',
|
||||
'window',
|
||||
'document',
|
||||
'$',
|
||||
'jQuery',
|
||||
'services',
|
||||
result
|
||||
);
|
||||
const scriptResult = scriptFunc(
|
||||
locationService.getSearchObject(),
|
||||
kbn,
|
||||
dateMath,
|
||||
_,
|
||||
moment,
|
||||
window,
|
||||
document,
|
||||
$,
|
||||
$,
|
||||
services
|
||||
);
|
||||
loadDashboard(
|
||||
type: UrlQueryValue,
|
||||
slug: string | undefined,
|
||||
uid: string | undefined,
|
||||
params?: UrlQueryMap
|
||||
): Promise<DashboardWithAccessInfo<DashboardV2Spec>> {
|
||||
const stateManager = getDashboardScenePageStateManager('v2');
|
||||
let promise;
|
||||
|
||||
// Handle async dashboard scripts
|
||||
if (isFunction(scriptResult)) {
|
||||
return new Promise((resolve) => {
|
||||
scriptResult((dashboard: any) => {
|
||||
resolve({ data: dashboard });
|
||||
if (type === 'script' && slug) {
|
||||
promise = this.loadScriptedDashboard(slug).then((r) => ResponseTransformers.ensureV2Response(r));
|
||||
} else if (type === 'snapshot' && slug) {
|
||||
promise = getDashboardSnapshotSrv()
|
||||
.getSnapshot(slug)
|
||||
.then((r) => ResponseTransformers.ensureV2Response(r))
|
||||
.catch(() => {
|
||||
return this._dashboardLoadFailed('Snapshot not found', true);
|
||||
});
|
||||
});
|
||||
} else if (type === 'public' && uid) {
|
||||
promise = backendSrv
|
||||
.getPublicDashboardByUid(uid)
|
||||
.then((result) => {
|
||||
return ResponseTransformers.ensureV2Response(result);
|
||||
})
|
||||
.catch((e) => {
|
||||
const isPublicDashboardPaused =
|
||||
e.data.statusCode === 403 && e.data.messageId === 'publicdashboards.notEnabled';
|
||||
// const isPublicDashboardNotFound =
|
||||
// e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.notFound';
|
||||
// const isDashboardNotFound =
|
||||
// e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.dashboardNotFound';
|
||||
const dashboardModel = this._dashboardLoadFailed(
|
||||
isPublicDashboardPaused ? 'Public Dashboard paused' : 'Public Dashboard Not found',
|
||||
true
|
||||
);
|
||||
|
||||
return dashboardModel;
|
||||
// TODO[schema v2]:
|
||||
// return {
|
||||
// ...dashboardModel,
|
||||
// meta: {
|
||||
// ...dashboardModel.meta,
|
||||
// publicDashboardEnabled: isPublicDashboardNotFound ? undefined : !isPublicDashboardPaused,
|
||||
// dashboardNotFound: isPublicDashboardNotFound || isDashboardNotFound,
|
||||
// },
|
||||
// };
|
||||
});
|
||||
} else if (uid) {
|
||||
if (!params) {
|
||||
const cachedDashboard = stateManager.getDashboardFromCache(uid);
|
||||
if (cachedDashboard) {
|
||||
return Promise.resolve(cachedDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
promise = getDashboardAPI('v2')
|
||||
.getDashboardDTO(uid, params)
|
||||
.catch((e) => {
|
||||
console.error('Failed to load dashboard', e);
|
||||
if (isFetchError(e)) {
|
||||
e.isHandled = true;
|
||||
}
|
||||
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
|
||||
const dash = this._dashboardLoadFailed('Not found', true);
|
||||
|
||||
return dash;
|
||||
});
|
||||
} else {
|
||||
throw new Error('Dashboard uid or slug required');
|
||||
}
|
||||
|
||||
return { data: scriptResult };
|
||||
promise.then((result: DashboardWithAccessInfo<DashboardV2Spec>) => {
|
||||
if (result.metadata.annotations?.[AnnoKeyDashboardNotFound] !== true) {
|
||||
impressionSrv.addDashboardImpression(result.metadata.name);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user