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:
Dominik Prokop 2025-01-02 12:23:58 +01:00 committed by GitHub
parent 8ab12aede4
commit e974cb87d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1594 additions and 422 deletions

View File

@ -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": [

View File

@ -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: "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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