diff --git a/.betterer.results b/.betterer.results
index e1f60c1392c..5d26d83e12c 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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 or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"]
],
- "public/app/features/logs/components/LogRows.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "1"]
- ],
"public/app/features/logs/components/log-context/LogContextButtons.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "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": [
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
index 0dfb65702fa..29841139f3d 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
@@ -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: "",
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
index b2774bbd894..feb7199b91b 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
@@ -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: {
diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts
index fbe18f64bbf..66b1b21548a 100644
--- a/public/app/features/apiserver/types.ts
+++ b/public/app/features/apiserver/types.ts
@@ -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;
};
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
index 27d9fb29245..2de08282a92 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
@@ -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);
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
index 1974b1e44de..23d1f70305b 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
@@ -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;
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
index b23e6884b0b..9bd7abc085c 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
@@ -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 | 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);
+ });
+ });
+ });
+});
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
index d70663f1491..8fd7ca26ce5 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
@@ -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 {
+ dashboard: T;
ts: number;
cacheKey: string;
}
@@ -55,103 +60,36 @@ export interface LoadDashboardOptions {
};
}
-export class DashboardScenePageStateManager extends StateManagerBase {
- private cache: Record = {};
+interface DashboardScenePageStateManagerLike {
+ fetchDashboard(options: LoadDashboardOptions): Promise;
+ getDashboardFromCache(cacheKey: string): T | null;
+ loadDashboard(options: LoadDashboardOptions): Promise;
+ transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
+ reloadDashboard(params: LoadDashboardOptions['params']): Promise;
+ loadSnapshot(slug: string): Promise;
+ setDashboardCache(cacheKey: string, dashboard: T): void;
+ clearSceneCache(): void;
+ clearDashboardCache(): void;
+ clearState(): void;
+ getCache(): Record;
+ useState: () => DashboardScenePageState;
+}
+
+abstract class DashboardScenePageStateManagerBase
+ extends StateManagerBase
+ implements DashboardScenePageStateManagerLike
+{
+ abstract fetchDashboard(options: LoadDashboardOptions): Promise;
+ abstract reloadDashboard(params: LoadDashboardOptions['params']): Promise;
+ abstract transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
+
+ protected cache: Record = {};
// 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;
- // 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 {
- 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 {
+ return this.cache;
}
public async loadSnapshot(slug: string) {
@@ -205,6 +143,177 @@ export class DashboardScenePageStateManager extends StateManagerBase {
+ 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 {
+ 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 {
+ 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 {
- this.setState({ dashboard: undefined, isLoading: true });
-
- const rsp = await this.fetchDashboard(options);
+export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateManagerBase<
+ DashboardWithAccessInfo
+> {
+ private dashboardLoader = new DashboardLoaderSrvV2();
+ transformResponseToScene(
+ rsp: DashboardWithAccessInfo | 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 {
+ 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 | 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;
+ 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('/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>;
+export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike;
+
+export function getDashboardScenePageStateManager(
+ v?: 'v2'
+): DashboardScenePageStateManagerLike> {
+ 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;
-}
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index ad32cd854f0..fd6b419538f 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -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');
diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
index 02f1b4b1c8e..c8f8c8b8fe6 100644
--- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
+++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
@@ -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",
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
index dc94cd02c21..c5fa51af6d5 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
@@ -46,11 +46,20 @@ const defaultDashboard: DashboardWithAccessInfo = {
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 = {
- ...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 = {
+ ...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;
+
+ 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;
+
+ 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 = {
+ ...defaultDashboard,
+ metadata: {
+ ...defaultDashboard.metadata,
+ annotations: {
+ ...defaultDashboard.metadata.annotations,
+ [AnnoKeyDashboardIsNew]: true,
+ },
+ },
+ };
+ const scene = transformSaveModelSchemaV2ToScene(dashboard);
+ expect(scene.state.meta.isNew).toBe(true);
+ });
});
});
});
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
index c0479b61946..8fd9404757b 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
@@ -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 {
- describe('v1 transformation', () => {
+ describe('v1 -> v2 transformation', () => {
it('should transform DashboardDTO to DashboardWithAccessInfo', () => {
- 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 = {
+ 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: [] });
});
});
diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts
index ec8708f1f65..13b35d94e49 100644
--- a/public/app/features/dashboard/api/ResponseTransformers.ts
+++ b/public/app/features/dashboard/api/ResponseTransformers.ts
@@ -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 | DashboardWithAccessInfo
): DashboardWithAccessInfo {
- if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) {
- return dto as DashboardWithAccessInfo;
+ 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 | 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((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;
+ });
+}
diff --git a/public/app/features/dashboard/api/dashboard_api.test.ts b/public/app/features/dashboard/api/dashboard_api.test.ts
index 9467d9d1385..ce234f47ae6 100644
--- a/public/app/features/dashboard/api/dashboard_api.test.ts
+++ b/public/app/features/dashboard/api/dashboard_api.test.ts
@@ -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);
});
});
diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts
index 0a5f45c137e..16c9bc07ce0 100644
--- a/public/app/features/dashboard/api/dashboard_api.ts
+++ b/public/app/features/dashboard/api/dashboard_api.ts
@@ -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;
@@ -36,12 +36,12 @@ export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI {
};
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');
+ });
+ });
});
diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts
index 092c6b269cf..f24781a03ca 100644
--- a/public/app/features/dashboard/api/utils.ts
+++ b/public/app/features/dashboard/api/utils.ts
@@ -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 | DashboardWithAccessInfo
+): obj is DashboardWithAccessInfo {
+ return isDashboardResource(obj) && isDashboardV2Spec(obj.spec);
+}
diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts
index a3d0bdec9ca..ec0d744a307 100644
--- a/public/app/features/dashboard/api/v2.test.ts
+++ b/public/app/features/dashboard/api/v2.test.ts
@@ -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 = {
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 based on the
// parameter convertToV1, we need to cast the result to DashboardWithAccessInfo to be able to
// access
diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts
index 2ee98a65e76..91a8f88a9a0 100644
--- a/public/app/features/dashboard/api/v2.ts
+++ b/public/app/features/dashboard/api/v2.ts
@@ -17,7 +17,7 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
-export class K8sDashboardV2APIStub implements DashboardAPI | DashboardDTO> {
+export class K8sDashboardV2API implements DashboardAPI | DashboardDTO> {
private client: ResourceClient;
constructor(private convertToV1: boolean) {
diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx
index ee0ce912106..39b13676fa9 100644
--- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx
+++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx
@@ -23,6 +23,12 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
const params = useParams();
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 ;
+ }
+
if (forceScenes || (config.featureToggles.dashboardScene && !forceOld)) {
return ;
}
diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts
index f18b2c21aad..ddcc2e6d6e3 100644
--- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts
+++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts
@@ -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 {
+ _dashboardLoadFailed(title: string, snapshot?: boolean): T;
+ loadDashboard(
+ type: UrlQueryValue,
+ slug: string | undefined,
+ uid: string | undefined,
+ params?: UrlQueryMap
+ ): Promise;
+}
+
+abstract class DashboardLoaderSrvBase implements DashboardLoaderSrvLike {
+ abstract _dashboardLoadFailed(title: string, snapshot?: boolean): T;
+ abstract loadDashboard(
+ type: UrlQueryValue,
+ slug: string | undefined,
+ uid: string | undefined,
+ params?: UrlQueryMap
+ ): Promise;
+
+ 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 {
+ _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> {
+ _dashboardLoadFailed(title: string, snapshot?: boolean) {
+ const dashboard: DashboardWithAccessInfo = {
+ 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> {
+ 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) => {
+ if (result.metadata.annotations?.[AnnoKeyDashboardNotFound] !== true) {
+ impressionSrv.addDashboardImpression(result.metadata.name);
+ }
+
+ return result;
+ });
+
+ return promise;
}
}