Dashboard API versions handling (#96666)

* structure apic

* API versioning proposal

* Make api service independent from version

* Update v2

* Fix public dashboards page test

* Uncomment reload dashboard feature code

* Revert

* Betterer

* Fix imports

* useV2DashboardsAPI feature toggle

* POC/v2 schema: Add v1<-> v2 transformers (#97058)

* Make dshboard access interface more precise

* Add first pass for schema v1<->v2 transformers

* Update response transformer test

* Import fixes

* Manage dashboards validation: Handle v2 schema

* Handle new dashboard with v2

* Fix tests

* Move dashboard is folder error handling to legacy API implementation

* Add tests for dashboard api client

* betterer

* Use dashboard DTO when capturing dashbaord impression

* prettier

* Dashboard API: resolve folder metadata

* Add tests for resolving folder metadata

* Fix DashboardPicker

* Renames and nits

* POC Alternative Suggestion for  Dashboard API versions handling (#97789)

* Add transitional_dashboard_api, reset components that are not ready for v2 schema, and start working on migrating
DashboardPicker to use v2 schema

* reset DashboardScenePageStateManager

* Improve logic in transitional api, also remove isDashboardResource checks from components

* REmove transitional_dashboard_api and apply PR feedback

* Apply PR feedback, use 'v2' as a parameter and remove unnecesary if

* Fix tests

* Adding missing comments from original PR and also changing order to improve diffing in github :)

* update betterer

* fix prettier

* Add tests for DashboardPicker

* Do not use unified alerting mocks

* Fix unused type in dashboard test

* Improve comments in DahboardPicker

* Update folder validation fn

* Validation update

* Update legacy api test

* Lint

---------

Co-authored-by: alexandra vargas <alexa1866@gmail.com>
Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com>
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
pull/98151/head
Dominik Prokop 6 months ago committed by GitHub
parent 31e67b5ba3
commit 070f0e4457
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 7
      pkg/services/featuremgmt/registry.go
  5. 1
      pkg/services/featuremgmt/toggles_gen.csv
  6. 4
      pkg/services/featuremgmt/toggles_gen.go
  7. 13
      pkg/services/featuremgmt/toggles_gen.json
  8. 135
      public/app/core/components/Select/DashboardPicker.test.tsx
  9. 35
      public/app/core/components/Select/DashboardPicker.tsx
  10. 3
      public/app/features/apiserver/client.ts
  11. 21
      public/app/features/apiserver/types.ts
  12. 32
      public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts
  13. 83
      public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts
  14. 2
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
  15. 2
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
  16. 216
      public/app/features/dashboard/api/ResponseTransformers.test.ts
  17. 179
      public/app/features/dashboard/api/ResponseTransformers.ts
  18. 75
      public/app/features/dashboard/api/dashboard_api.test.ts
  19. 169
      public/app/features/dashboard/api/dashboard_api.ts
  20. 55
      public/app/features/dashboard/api/legacy.test.ts
  21. 40
      public/app/features/dashboard/api/legacy.ts
  22. 31
      public/app/features/dashboard/api/types.ts
  23. 51
      public/app/features/dashboard/api/utils.test.ts
  24. 47
      public/app/features/dashboard/api/utils.ts
  25. 65
      public/app/features/dashboard/api/v0.test.ts
  26. 109
      public/app/features/dashboard/api/v0.ts
  27. 69
      public/app/features/dashboard/api/v2.test.ts
  28. 68
      public/app/features/dashboard/api/v2.ts
  29. 64
      public/app/features/manage-dashboards/utils/validation.test.ts
  30. 1
      public/app/types/dashboard.ts

@ -3433,7 +3433,12 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/dashboard/api/dashboard_api.ts:5381": [
"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"]
],
"public/app/features/dashboard/api/v0.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [

@ -227,6 +227,7 @@ Experimental features might be changed or removed without prior notice.
| `crashDetection` | Enables browser crash detection reporting to Faro. |
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section |
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
## Development feature toggles

@ -240,6 +240,7 @@ export interface FeatureToggles {
alertingUIOptimizeReducer?: boolean;
azureMonitorEnableUserAuth?: boolean;
alertingNotificationsStepMode?: boolean;
useV2DashboardsAPI?: boolean;
feedbackButton?: boolean;
elasticsearchCrossClusterSearch?: boolean;
unifiedHistory?: boolean;

@ -1662,6 +1662,13 @@ var (
Owner: grafanaAlertingSquad,
FrontendOnly: true,
},
{
Name: "useV2DashboardsAPI",
Description: "Use the v2 kubernetes API in the frontend for dashboards",
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
RequiresRestart: true, // changes the API routing
},
{
Name: "feedbackButton",
Description: "Enables a button to send feedback from the Grafana UI",

@ -221,6 +221,7 @@ reportingUseRawTimeRange,preview,@grafana/sharing-squad,false,false,false
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false
unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
221 alertingUIOptimizeReducer GA @grafana/alerting-squad false false true
222 azureMonitorEnableUserAuth GA @grafana/partner-datasources false false false
223 alertingNotificationsStepMode experimental @grafana/alerting-squad false false true
224 useV2DashboardsAPI experimental @grafana/dashboards-squad false true false
225 feedbackButton experimental @grafana/grafana-operator-experience-squad false false false
226 elasticsearchCrossClusterSearch preview @grafana/aws-datasources false false false
227 unifiedHistory experimental @grafana/grafana-frontend-platform false false true

@ -895,6 +895,10 @@ const (
// Enables simplified step mode in the notifications section
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
// FlagUseV2DashboardsAPI
// Use the v2 kubernetes API in the frontend for dashboards
FlagUseV2DashboardsAPI = "useV2DashboardsAPI"
// FlagFeedbackButton
// Enables a button to send feedback from the Grafana UI
FlagFeedbackButton = "feedbackButton"

@ -3620,6 +3620,19 @@
"expression": "true"
}
},
{
"metadata": {
"name": "useV2DashboardsAPI",
"resourceVersion": "1732535420861",
"creationTimestamp": "2024-11-25T11:50:20Z"
},
"spec": {
"description": "Use the v2 kubernetes API in the frontend for dashboards",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad",
"requiresRestart": true
}
},
{
"metadata": {
"name": "userStorageAPI",

@ -0,0 +1,135 @@
import { noop } from 'lodash';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { defaultDashboard as defaultDashboardData } from '@grafana/schema';
import {
DashboardV2Spec,
defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { DashboardSearchItemType } from 'app/features/search/types';
import { DashboardDTO } from 'app/types';
import { DashboardPicker } from './DashboardPicker';
jest.mock('app/core/services/backend_srv', () => ({
...jest.requireActual('app/core/services/backend_srv'),
backendSrv: {
...jest.requireActual('app/core/services/backend_srv').backendSrv,
search: jest.fn(),
},
}));
const getDashboardDTO = jest.fn();
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: getDashboardDTO,
}),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
height: 600,
scaledHeight: 600,
scaledWidth: 1,
width: 1,
});
});
jest.mocked(backendSrv.search).mockResolvedValue([
{
uid: 'dash-1',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 1',
uri: '',
url: '',
tags: [],
isStarred: false,
},
{
uid: 'dash-2',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 2',
uri: '',
url: '',
tags: [],
isStarred: false,
},
{
uid: 'dash-3',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 3',
uri: '',
url: '',
tags: [],
isStarred: false,
},
]);
const mockDashboard: DashboardDTO = {
dashboard: {
...defaultDashboardData,
uid: 'dash-2',
title: 'Dashboard 2',
},
meta: {},
};
const mockDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
apiVersion: 'v2alpha0',
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'Dashboard 2',
},
metadata: {
name: 'dash-2',
resourceVersion: '0',
creationTimestamp: '0',
annotations: {},
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
};
describe('DashboardPicker', () => {
describe.each([
['v1', mockDashboard],
['v2', mockDashboardV2],
])('Dashboard %s', (format, dashboard) => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = format === 'v2';
getDashboardDTO.mockResolvedValue(dashboard);
});
it('should fetch and display dashboards', async () => {
render(<DashboardPicker value="dash-2" onChange={noop} />);
await waitFor(() => {
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument();
expect(getDashboardDTO).toHaveBeenCalledWith('dash-2', undefined);
});
});
it('should search for dashboards', async () => {
render(<DashboardPicker onChange={noop} />);
await userEvent.type(screen.getByRole('combobox'), 'Dashboard 2');
await waitFor(() => {
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument();
});
expect(backendSrv.search).toHaveBeenCalledWith({ type: 'dash-db', query: 'Dashboard 2', limit: 100 });
});
});
});

@ -2,8 +2,10 @@ import debounce from 'debounce-promise';
import { useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardSearchItem } from 'app/features/search/types';
import { DashboardDTO } from 'app/types';
@ -55,17 +57,36 @@ export const DashboardPicker = ({
(async () => {
// value was manually changed from outside or we are rendering for the first time.
// We need to fetch dashboard information.
const res = await getDashboardAPI().getDashboardDTO(value);
if (res.dashboard) {
const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI;
if (isUIReadyForV2) {
// When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop
// That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape
const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined);
setCurrent({
value: {
uid: res.dashboard.uid,
title: res.dashboard.title,
folderTitle: res.meta.folderTitle,
folderUid: res.meta.folderUid,
uid: resWithSchemaV2.metadata.name,
title: resWithSchemaV2.spec.title,
folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle],
folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder],
},
label: formatLabel(res.meta?.folderTitle, res.dashboard.title),
label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title),
});
} else {
// when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version
const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined);
if (resWithSchemaV1.dashboard) {
setCurrent({
value: {
uid: resWithSchemaV1.dashboard.uid,
title: resWithSchemaV1.dashboard.title,
folderTitle: resWithSchemaV1.meta.folderTitle,
folderUid: resWithSchemaV1.meta.folderUid,
},
label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title),
});
}
}
})();
// we don't need to rerun this effect every time `current` changes

@ -12,6 +12,7 @@ import {
ResourceClient,
ObjectMeta,
K8sAPIGroupList,
AnnoKeySavedFromUI,
} from './types';
export interface GroupVersionResource {
@ -104,7 +105,7 @@ function setSavedFromUIAnnotation(meta: Partial<ObjectMeta>) {
if (!meta.annotations) {
meta.annotations = {};
}
meta.annotations['grafana.app/saved-from-ui'] = config.buildInfo.versionString;
meta.annotations[AnnoKeySavedFromUI] = config.buildInfo.versionString;
}
export class DatasourceAPIVersions {

@ -24,7 +24,7 @@ export interface ObjectMeta {
// The first time this was saved
creationTimestamp: string;
// General resource annotations -- including the common grafana.app values
annotations?: GrafanaAnnotations;
annotations?: GrafanaAnnotations & GrafanaClientAnnotations;
// General application level key+value pairs
labels?: Record<string, string>;
}
@ -33,6 +33,9 @@ export const AnnoKeyCreatedBy = 'grafana.app/createdBy';
export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp';
export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy';
export const AnnoKeyFolder = 'grafana.app/folder';
export const AnnoKeyFolderTitle = 'grafana.app/folderTitle';
export const AnnoKeyFolderId = 'grafana.app/folderId';
export const AnnoKeyFolderUrl = 'grafana.app/folderUrl';
export const AnnoKeyMessage = 'grafana.app/message';
export const AnnoKeySlug = 'grafana.app/slug';
@ -40,8 +43,11 @@ export const AnnoKeySlug = 'grafana.app/slug';
export const AnnoKeyRepoName = 'grafana.app/repoName';
export const AnnoKeyRepoPath = 'grafana.app/repoPath';
export const AnnoKeyRepoHash = 'grafana.app/repoHash';
const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui';
// Annotations provided by the API
type GrafanaAnnotations = {
[AnnoKeyCreatedBy]?: string;
[AnnoKeyUpdatedTimestamp]?: string;
@ -53,9 +59,16 @@ type GrafanaAnnotations = {
[AnnoKeyRepoPath]?: string;
[AnnoKeyRepoHash]?: string;
[AnnoKeyRepoTimestamp]?: string;
};
// Any key value
[key: string]: string | undefined;
// Annotations provided by the front-end client
type GrafanaClientAnnotations = {
[AnnoKeyMessage]?: string;
[AnnoKeyFolderTitle]?: string;
[AnnoKeyFolderUrl]?: string;
[AnnoKeyFolderId]?: number;
[AnnoKeyFolderId]?: number;
[AnnoKeySavedFromUI]?: string;
};
export interface Resource<T = object, K = string> extends TypeMeta<K> {

@ -1,7 +1,7 @@
import { DataSourceApi, PluginType, VariableSupportType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel';
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from './buildNewDashboardSaveModel';
const fakeDsMock: DataSourceApi = {
name: 'fake-std',
@ -58,7 +58,7 @@ jest.mock('@grafana/runtime', () => ({
}),
}));
describe('buildNewDashboardSaveModel', () => {
describe('buildNewDashboardSaveModelV1', () => {
it('should not have template variables defined by default', async () => {
const result = await buildNewDashboardSaveModel();
expect(result.dashboard.templating).toBeUndefined();
@ -85,3 +85,31 @@ describe('buildNewDashboardSaveModel', () => {
});
});
});
describe('buildNewDashboardSaveModelV2', () => {
it('should not have template variables defined by default', async () => {
const result = await buildNewDashboardSaveModelV2();
expect(result.spec.variables).toEqual([]);
});
describe('when featureToggles.newDashboardWithFiltersAndGroupBy is true', () => {
beforeAll(() => {
config.featureToggles.newDashboardWithFiltersAndGroupBy = true;
});
afterAll(() => {
config.featureToggles.newDashboardWithFiltersAndGroupBy = false;
});
it('should add filter and group by variables if the datasource supports it and is set as default', async () => {
const result = await buildNewDashboardSaveModelV2();
expect(result.spec.variables).toHaveLength(2);
expect(result.spec.variables[0].kind).toBe('AdhocVariable');
expect(result.spec.variables[1].kind).toBe('GroupByVariable');
});
it("should set the new dashboard's timezone to the user's timezone", async () => {
const result = await buildNewDashboardSaveModelV2();
expect(result.spec.timeSettings.timezone).toEqual('Africa/Abidjan');
});
});
});

@ -1,5 +1,16 @@
import { config } from '@grafana/runtime';
import { VariableModel, defaultDashboard } from '@grafana/schema';
import {
AdhocVariableKind,
DashboardV2Spec,
defaultAdhocVariableSpec,
defaultDashboardV2Spec,
defaultGroupByVariableSpec,
defaultTimeSettingsSpec,
GroupByVariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { DashboardDTO } from 'app/types';
@ -62,3 +73,75 @@ export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise
return data;
}
export async function buildNewDashboardSaveModelV2(
urlFolderUid?: string
): Promise<DashboardWithAccessInfo<DashboardV2Spec>> {
let variablesList = defaultDashboardV2Spec().variables;
if (config.featureToggles.newDashboardWithFiltersAndGroupBy) {
// Add filter and group by variables if the datasource supports it
const defaultDs = await getDatasourceSrv().get();
if (defaultDs.getTagKeys) {
const datasourceRef = {
type: defaultDs.meta.id,
uid: defaultDs.uid,
};
const filterVariable: AdhocVariableKind = {
kind: 'AdhocVariable',
spec: { ...defaultAdhocVariableSpec(), name: 'Filter', datasource: datasourceRef },
};
const groupByVariable: GroupByVariableKind = {
kind: 'GroupByVariable',
spec: {
...defaultGroupByVariableSpec(),
datasource: datasourceRef,
name: 'Group by',
},
};
variablesList = (variablesList || []).concat([filterVariable, groupByVariable]);
}
}
const data: DashboardWithAccessInfo<DashboardV2Spec> = {
apiVersion: 'v2alpha0',
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'New dashboard',
timeSettings: {
...defaultTimeSettingsSpec(),
timezone: config.bootData.user?.timezone || defaultTimeSettingsSpec().timezone,
},
},
access: {
canStar: false,
canShare: false,
canDelete: false,
// Not sure this should belong here or to metadata.annotations
isNew: true,
},
metadata: {
name: '',
resourceVersion: '0',
creationTimestamp: '0',
annotations: {
[AnnoKeyFolder]: '',
},
},
};
if (variablesList) {
data.spec.variables = variablesList;
}
if (urlFolderUid && data.metadata.annotations) {
data.metadata.annotations[AnnoKeyFolder] = urlFolderUid;
}
return data;
}

@ -26,7 +26,7 @@ import {
TextVariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';

@ -51,7 +51,7 @@ import {
QueryVariableKind,
TextVariableKind,
} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';

@ -0,0 +1,216 @@
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { DashboardDTO } from 'app/types';
import { ResponseTransformers } from './ResponseTransformers';
import { DashboardWithAccessInfo } from './types';
describe('ResponseTransformers', () => {
describe('v1 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',
slug: 'dashboard-slug',
url: '/d/dashboard-slug',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
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: [],
annotations: {
list: [],
},
},
};
const transformed = ResponseTransformers.ensureV2Response(dashboardDTO);
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);
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.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.annotations).toEqual([]);
});
});
describe('v2 transformation', () => {
it('should return the same object if it is already a DashboardDTO', () => {
const dashboard: DashboardDTO = {
dashboard: {
schemaVersion: 1,
title: 'Dashboard Title',
uid: 'dashboard1',
version: 1,
},
meta: {},
};
expect(ResponseTransformers.ensureV1Response(dashboard)).toBe(dashboard);
});
it('should transform DashboardWithAccessInfo<DashboardV2Spec> to DashboardDTO', () => {
const dashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
creationTimestamp: '2023-01-01T00:00:00Z',
name: 'dashboard1',
resourceVersion: '1',
annotations: {
'grafana.app/createdBy': 'user1',
'grafana.app/updatedBy': 'user2',
'grafana.app/updatedTimestamp': '2023-01-02T00:00:00Z',
'grafana.app/folder': 'folder1',
'grafana.app/slug': 'dashboard-slug',
},
},
spec: {
title: 'Dashboard Title',
description: 'Dashboard Description',
tags: ['tag1', 'tag2'],
schemaVersion: 1,
cursorSync: 'Off',
preload: true,
liveNow: false,
editable: true,
timeSettings: {
from: 'now-6h',
to: 'now',
timezone: 'browser',
autoRefresh: '5m',
autoRefreshIntervals: ['5s', '10s', '30s'],
hideTimepicker: false,
quickRanges: ['5m', '15m', '1h'],
nowDelay: '1m',
fiscalYearStartMonth: 1,
weekStart: 'monday',
},
links: [],
annotations: [],
variables: [],
elements: {},
layout: {
kind: 'GridLayout',
spec: {
items: [],
},
},
},
access: {
url: '/d/dashboard-slug',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
canShare: true,
canStar: true,
slug: 'dashboard-slug',
annotationsPermissions: {
dashboard: { canAdd: true, canEdit: true, canDelete: true },
organization: { canAdd: true, canEdit: true, canDelete: true },
},
},
};
const transformed = ResponseTransformers.ensureV1Response(dashboardV2);
expect(transformed.meta.created).toBe(dashboardV2.metadata.creationTimestamp);
expect(transformed.meta.createdBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/createdBy']);
expect(transformed.meta.updated).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedTimestamp']);
expect(transformed.meta.updatedBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedBy']);
expect(transformed.meta.folderUid).toBe(dashboardV2.metadata.annotations?.['grafana.app/folder']);
expect(transformed.meta.slug).toBe(dashboardV2.metadata.annotations?.['grafana.app/slug']);
expect(transformed.meta.url).toBe(dashboardV2.access.url);
expect(transformed.meta.canAdmin).toBe(dashboardV2.access.canAdmin);
expect(transformed.meta.canDelete).toBe(dashboardV2.access.canDelete);
expect(transformed.meta.canEdit).toBe(dashboardV2.access.canEdit);
expect(transformed.meta.canSave).toBe(dashboardV2.access.canSave);
expect(transformed.meta.canShare).toBe(dashboardV2.access.canShare);
expect(transformed.meta.canStar).toBe(dashboardV2.access.canStar);
expect(transformed.meta.annotationsPermissions).toEqual(dashboardV2.access.annotationsPermissions);
const dashboard = transformed.dashboard;
expect(dashboard.uid).toBe(dashboardV2.metadata.name);
expect(dashboard.title).toBe(dashboardV2.spec.title);
expect(dashboard.description).toBe(dashboardV2.spec.description);
expect(dashboard.tags).toEqual(dashboardV2.spec.tags);
expect(dashboard.schemaVersion).toBe(dashboardV2.spec.schemaVersion);
// expect(dashboard.graphTooltip).toBe(0); // Assuming transformCursorSynctoEnum('Off') returns 0
expect(dashboard.preload).toBe(dashboardV2.spec.preload);
expect(dashboard.liveNow).toBe(dashboardV2.spec.liveNow);
expect(dashboard.editable).toBe(dashboardV2.spec.editable);
expect(dashboard.time?.from).toBe(dashboardV2.spec.timeSettings.from);
expect(dashboard.time?.to).toBe(dashboardV2.spec.timeSettings.to);
expect(dashboard.timezone).toBe(dashboardV2.spec.timeSettings.timezone);
expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh);
expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals);
expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker);
expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges);
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.annotations).toEqual({ list: [] });
});
});
});

@ -0,0 +1,179 @@
import {
DashboardV2Spec,
defaultDashboardV2Spec,
defaultTimeSettingsSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { transformCursorSynctoEnum } from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
import { DashboardWithAccessInfo } from './types';
import { isDashboardResource, isDashboardV0Spec, isDashboardV2Spec } from './utils';
export function ensureV2Response(
dto: DashboardDTO | DashboardWithAccessInfo<DashboardDataDTO> | DashboardWithAccessInfo<DashboardV2Spec>
): DashboardWithAccessInfo<DashboardV2Spec> {
if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) {
return dto as DashboardWithAccessInfo<DashboardV2Spec>;
}
// 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;
const timeSettingsDefaults = defaultTimeSettingsSpec();
const dashboardDefaults = defaultDashboardV2Spec();
const dashboard = isDashboardResource(dto) ? dto.spec : dto.dashboard;
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'],
}
: dto.meta;
const spec: DashboardV2Spec = {
title: dashboard.title,
description: dashboard.description,
tags: dashboard.tags,
schemaVersion: dashboard.schemaVersion,
cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip),
preload: dashboard.preload || dashboardDefaults.preload,
liveNow: dashboard.liveNow,
editable: dashboard.editable,
timeSettings: {
from: dashboard.time?.from || timeSettingsDefaults.from,
to: dashboard.time?.to || timeSettingsDefaults.to,
timezone: dashboard.timezone || timeSettingsDefaults.timezone,
autoRefresh: dashboard.refresh || timeSettingsDefaults.autoRefresh,
autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals,
fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth,
hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker,
quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges,
weekStart: dashboard.weekStart || timeSettingsDefaults.weekStart,
nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay,
},
links: dashboard.links || [],
annotations: [], // TODO
variables: [], // todo
elements: {}, // todo
layout: {
// todo
kind: 'GridLayout',
spec: {
items: [],
},
},
};
return {
apiVersion: 'v2alpha1',
kind: 'DashboardWithAccessInfo',
metadata: {
creationTimestamp: accessAndMeta.created || '', // TODO verify this empty string is valid
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,
},
},
spec,
access: {
url: accessAndMeta.url || '',
canAdmin: accessAndMeta.canAdmin,
canDelete: accessAndMeta.canDelete,
canEdit: accessAndMeta.canEdit,
canSave: accessAndMeta.canSave,
canShare: accessAndMeta.canShare,
canStar: accessAndMeta.canStar,
slug: accessAndMeta.slug,
annotationsPermissions: accessAndMeta.annotationsPermissions,
},
};
}
export function ensureV1Response(
dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO>
): DashboardDTO {
// if dashboard is not on v0 schema or v2 schema, return as is
if (!isDashboardResource(dashboard)) {
return dashboard;
}
const spec = dashboard.spec;
// if dashboard is on v0 schema
if (isDashboardV0Spec(spec)) {
return {
meta: {
...dashboard.access,
isNew: false,
isFolder: false,
uid: dashboard.metadata.name,
k8s: dashboard.metadata,
},
dashboard: spec,
};
} else {
// if dashboard is on v2 schema convert to v1 schema
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'],
url: dashboard.access.url,
canAdmin: dashboard.access.canAdmin,
canDelete: dashboard.access.canDelete,
canEdit: dashboard.access.canEdit,
canSave: dashboard.access.canSave,
canShare: dashboard.access.canShare,
canStar: dashboard.access.canStar,
annotationsPermissions: dashboard.access.annotationsPermissions,
},
dashboard: {
uid: dashboard.metadata.name,
title: spec.title,
description: spec.description,
tags: spec.tags,
schemaVersion: spec.schemaVersion,
// @ts-ignore TODO: Use transformers for these enums
// graphTooltip: spec.cursorSync, // Assuming transformCursorSynctoEnum is reversible
preload: spec.preload,
liveNow: spec.liveNow,
editable: spec.editable,
time: {
from: spec.timeSettings.from,
to: spec.timeSettings.to,
},
timezone: spec.timeSettings.timezone,
refresh: spec.timeSettings.autoRefresh,
timepicker: {
refresh_intervals: spec.timeSettings.autoRefreshIntervals,
hidden: spec.timeSettings.hideTimepicker,
time_options: spec.timeSettings.quickRanges,
nowDelay: spec.timeSettings.nowDelay,
},
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth,
weekStart: spec.timeSettings.weekStart,
version: parseInt(dashboard.metadata.resourceVersion, 10),
links: spec.links, // Assuming transformDashboardLinksToEnums is reversible
annotations: { list: [] }, // TODO
},
};
}
}
export const ResponseTransformers = {
ensureV2Response,
ensureV1Response,
};

@ -0,0 +1,75 @@
import { config } from '@grafana/runtime';
import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
import { LegacyDashboardAPI } from './legacy';
import { K8sDashboardAPI } from './v0';
import { K8sDashboardV2APIStub } from './v2';
describe('DashboardApi', () => {
it('should use legacy api by default', () => {
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
it('should allow overriding clients in test environment', () => {
process.env.NODE_ENV = 'test';
const mockClient = { legacy: new LegacyDashboardAPI() };
setDashboardAPI(mockClient);
const api = getDashboardAPI();
expect(api).toBe(mockClient.legacy);
setDashboardAPI(undefined);
});
describe('when scenes enabled', () => {
beforeEach(() => {
config.featureToggles.dashboardScene = true;
});
it('should use legacy api kubernetesDashboards toggle is disabled', () => {
config.featureToggles.kubernetesDashboards = false;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
it('should use v0 api when and kubernetesDashboards toggle is enabled', () => {
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
});
it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2APIStub);
});
});
describe('when scenes disabled', () => {
beforeEach(() => {
config.featureToggles.dashboardScene = false;
});
it('should use legacy api when kubernetesDashboards toggle is disabled', () => {
config.featureToggles.kubernetesDashboards = undefined;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
it('should use legacy api when kubernetesDashboards toggle is disabled', () => {
config.featureToggles.kubernetesDashboards = false;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
it('should use v0 api when kubernetesDashboards toggle is enabled', () => {
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
});
it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
config.featureToggles.kubernetesDashboards = true;
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
});
it('should use legacy useV2DashboardsAPI toggle is enabled', () => {
config.featureToggles.useV2DashboardsAPI = true;
config.featureToggles.kubernetesDashboards = undefined;
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
});
});
});

@ -1,147 +1,52 @@
import { UrlQueryMap } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
AnnoKeyFolder,
AnnoKeyMessage,
Resource,
ResourceClient,
ResourceForCreate,
} from 'app/features/apiserver/types';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types';
export interface DashboardAPI {
/** Get a dashboard with the access control metadata */
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<DashboardDTO>;
/** Save dashboard */
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO>;
/** Delete a dashboard */
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse>;
}
// Implemented using /api/dashboards/*
class LegacyDashboardAPI implements DashboardAPI {
constructor() {}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
dashboardWatcher.ignoreNextSave();
return getBackendSrv().post<SaveDashboardResponseDTO>('/api/dashboards/db/', {
dashboard: options.dashboard,
message: options.message ?? '',
overwrite: options.overwrite ?? false,
folderUid: options.folderUid,
});
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert });
}
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<DashboardDTO> {
return getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`, params);
}
}
export interface DashboardWithAccessInfo<T = DashboardDataDTO> extends Resource<T, 'DashboardWithAccessInfo'> {
access: Object; // TODO...
}
// Implemented using /apis/dashboard.grafana.app/*
class K8sDashboardAPI implements DashboardAPI {
private client: ResourceClient<DashboardDataDTO>;
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>({
group: 'dashboard.grafana.app',
version: 'v0alpha1',
resource: 'dashboards',
});
}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
const obj: ResourceForCreate<DashboardDataDTO> = {
metadata: {
...options?.k8s,
},
spec: {
...dashboard,
},
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { DashboardDTO } from 'app/types';
import { LegacyDashboardAPI } from './legacy';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
import { getDashboardsApiVersion } from './utils';
import { K8sDashboardAPI } from './v0';
import { K8sDashboardV2APIStub } from './v2';
type DashboardAPIClients = {
legacy: DashboardAPI<DashboardDTO>;
v0: DashboardAPI<DashboardDTO>;
// v1: DashboardDTO; TODO[schema]: enable v1 when available
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>>;
};
if (options.message) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyMessage]: options.message,
};
} else if (obj.metadata.annotations) {
delete obj.metadata.annotations[AnnoKeyMessage];
}
type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>;
if (options.folderUid) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyFolder]: options.folderUid,
};
}
let clients: Partial<DashboardAPIClients> | undefined;
if (dashboard.uid) {
obj.metadata.name = dashboard.uid;
return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v));
}
return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v));
export function setDashboardAPI(override: Partial<DashboardAPIClients> | undefined) {
if (process.env.NODE_ENV !== 'test') {
throw new Error('dashboardAPI can be only overridden in test environment');
}
asSaveDashboardResponseDTO(v: Resource<DashboardDataDTO>): SaveDashboardResponseDTO {
return {
uid: v.metadata.name,
version: v.spec.version ?? 0,
id: v.spec.id ?? 0,
status: 'success',
slug: '',
url: '',
};
clients = override;
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
return this.client.delete(uid).then((v) => ({
id: 0,
message: v.message,
title: 'deleted',
}));
}
// Overloads
export function getDashboardAPI(): DashboardAPI<DashboardDTO>;
export function getDashboardAPI(requestV2Response: 'v2'): DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec>>;
export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI<DashboardReturnTypes> {
const v = getDashboardsApiVersion();
const isConvertingToV1 = !requestV2Response;
async getDashboardDTO(uid: string): Promise<DashboardDTO> {
const dash = await this.client.subresource<DashboardWithAccessInfo>(uid, 'dto');
return {
meta: {
...dash.access,
isNew: false,
isFolder: false,
uid: dash.metadata.name,
k8s: dash.metadata,
},
dashboard: dash.spec,
if (!clients) {
clients = {
legacy: new LegacyDashboardAPI(),
v0: new K8sDashboardAPI(),
v2: new K8sDashboardV2APIStub(isConvertingToV1),
};
}
}
let instance: DashboardAPI | undefined = undefined;
export function getDashboardAPI() {
if (!instance) {
instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI() : new LegacyDashboardAPI();
}
return instance;
if (v === 'v2' && requestV2Response === 'v2') {
return new K8sDashboardV2APIStub(false);
}
export function setDashboardAPI(override: DashboardAPI | undefined) {
if (process.env.NODE_ENV !== 'test') {
throw new Error('dashboardAPI can be only overridden in test environment');
if (!clients[v]) {
throw new Error(`Unknown Dashboard API version: ${v}`);
}
instance = override;
return clients[v];
}

@ -0,0 +1,55 @@
import { UrlQueryMap } from '@grafana/data';
import { DashboardDTO } from 'app/types';
import { LegacyDashboardAPI } from './legacy';
const mockDashboardDto: DashboardDTO = {
meta: {
isFolder: false,
},
dashboard: {
title: 'test',
uid: 'test',
schemaVersion: 0,
},
};
const backendSrvGetSpy = jest.fn();
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
get: (dashUrl: string, params: Object) => {
backendSrvGetSpy(dashUrl, params);
const uid = dashUrl.split('/').pop();
if (uid === 'folderUid') {
return Promise.resolve({
meta: {
isFolder: true,
},
});
}
return mockDashboardDto;
},
}),
}));
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
ignoreNextSave: jest.fn(),
}));
describe('Legacy dashboard API', () => {
it('should throw an error if requesting a folder', async () => {
const api = new LegacyDashboardAPI();
expect(async () => await api.getDashboardDTO('folderUid')).rejects.toThrowError('Dashboard not found');
});
it('should return a valid dashboard', async () => {
const api = new LegacyDashboardAPI();
const params: UrlQueryMap = {
param: 1,
};
const result = await api.getDashboardDTO('validUid', params);
expect(result).toEqual(mockDashboardDto);
expect(backendSrvGetSpy).toHaveBeenLastCalledWith('/api/dashboards/uid/validUid', params);
});
});

@ -0,0 +1,40 @@
import { AppEvents, UrlQueryMap } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { SaveDashboardResponseDTO, DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI } from './types';
export class LegacyDashboardAPI implements DashboardAPI<DashboardDTO> {
constructor() {}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
dashboardWatcher.ignoreNextSave();
return getBackendSrv().post<SaveDashboardResponseDTO>('/api/dashboards/db/', {
dashboard: options.dashboard,
message: options.message ?? '',
overwrite: options.overwrite ?? false,
folderUid: options.folderUid,
});
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert });
}
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
const result = await getBackendSrv().get<DashboardDTO>(`/api/dashboards/uid/${uid}`, params);
if (result.meta.isFolder) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
throw new Error('Dashboard not found');
}
return result;
}
}

@ -0,0 +1,31 @@
import { UrlQueryMap } from '@grafana/data';
import { Resource } from 'app/features/apiserver/types';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
export interface DashboardAPI<G> {
/** Get a dashboard with the access control metadata */
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<G>;
/** Save dashboard */
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO>;
/** Delete a dashboard */
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse>;
}
// Implemented using /api/dashboards/*
export interface DashboardWithAccessInfo<T> extends Resource<T, 'DashboardWithAccessInfo'> {
access: {
url?: string;
slug?: string;
canSave?: boolean;
canEdit?: boolean;
canDelete?: boolean;
canShare?: boolean;
canStar?: boolean;
canAdmin?: boolean;
annotationsPermissions?: AnnotationsPermissions;
isNew?: boolean;
}; // TODO...
}

@ -0,0 +1,51 @@
import { config } from '@grafana/runtime';
import { getDashboardsApiVersion } from './utils';
describe('getDashboardsApiVersion', () => {
beforeEach(() => {
jest.resetModules();
});
it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v0');
});
it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => {
config.featureToggles = {
dashboardScene: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
});
it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: true,
};
expect(getDashboardsApiVersion()).toBe('v2');
});
it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: false,
kubernetesDashboards: true,
};
expect(getDashboardsApiVersion()).toBe('v0');
});
it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => {
config.featureToggles = {
dashboardScene: true,
useV2DashboardsAPI: false,
kubernetesDashboards: false,
};
expect(getDashboardsApiVersion()).toBe('legacy');
});
});

@ -0,0 +1,47 @@
import { config } 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() {
// if dashboard scene is disabled, use legacy API response for the old architecture
if (!config.featureToggles.dashboardScene) {
// for old architecture, use v0 API for k8s dashboards
if (config.featureToggles.kubernetesDashboards) {
return 'v0';
}
return 'legacy';
}
if (config.featureToggles.useV2DashboardsAPI) {
return 'v2';
}
if (config.featureToggles.kubernetesDashboards) {
return 'v0';
}
return 'legacy';
}
// This function is used to determine if the dashboard is in v2 format or also v0 format
export function isDashboardResource(
obj?: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> | null
): obj is DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> {
if (!obj) {
return false;
}
// is v0 or v2 format?
const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo';
return isK8sDashboard;
}
export function isDashboardV2Spec(obj: object): obj is DashboardV2Spec {
return 'elements' in obj;
}
export function isDashboardV0Spec(obj: object): obj is DashboardDataDTO {
return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec
}

@ -0,0 +1,65 @@
import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { DashboardDataDTO } from 'app/types';
import { DashboardWithAccessInfo } from './types';
import { K8sDashboardAPI } from './v0';
const mockDashboardDto: DashboardWithAccessInfo<DashboardDataDTO> = {
kind: 'DashboardWithAccessInfo',
apiVersion: 'v0alpha1',
metadata: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: '1',
annotations: {
[AnnoKeyFolder]: 'new-folder',
},
},
spec: {
title: 'test',
uid: 'test',
schemaVersion: 0,
},
access: {},
};
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
get: () => mockDashboardDto,
}),
config: {},
}));
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
ignoreNextSave: jest.fn(),
}));
describe('v0 dashboard API', () => {
it('should provide folder annotations', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({
id: 1,
uid: 'new-folder',
title: 'New Folder',
url: '/folder/url',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
created: '',
createdBy: '',
hasAcl: false,
updated: '',
updatedBy: '',
});
const api = new K8sDashboardAPI();
const result = await api.getDashboardDTO('test');
expect(result.meta.isFolder).toBe(false);
expect(result.meta.folderId).toBe(1);
expect(result.meta.folderTitle).toBe('New Folder');
expect(result.meta.folderUrl).toBe('/folder/url');
expect(result.meta.folderUid).toBe('new-folder');
});
});

@ -0,0 +1,109 @@
import { backendSrv } from 'app/core/services/backend_srv';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
ResourceClient,
ResourceForCreate,
AnnoKeyMessage,
AnnoKeyFolder,
Resource,
} from 'app/features/apiserver/types';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO> {
private client: ResourceClient<DashboardDataDTO>;
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>({
group: 'dashboard.grafana.app',
version: 'v0alpha1',
resource: 'dashboards',
});
}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
const obj: ResourceForCreate<DashboardDataDTO> = {
metadata: {
...options?.k8s,
},
spec: {
...dashboard,
},
};
if (options.message) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyMessage]: options.message,
};
} else if (obj.metadata.annotations) {
delete obj.metadata.annotations[AnnoKeyMessage];
}
if (options.folderUid) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyFolder]: options.folderUid,
};
}
if (dashboard.uid) {
obj.metadata.name = dashboard.uid;
return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v));
}
return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v));
}
asSaveDashboardResponseDTO(v: Resource<DashboardDataDTO>): SaveDashboardResponseDTO {
return {
uid: v.metadata.name,
version: v.spec.version ?? 0,
id: v.spec.id ?? 0,
status: 'success',
slug: '',
url: '',
};
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
return this.client.delete(uid).then((v) => ({
id: 0,
message: v.message,
title: 'deleted',
}));
}
async getDashboardDTO(uid: string) {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
const result: DashboardDTO = {
meta: {
...dash.access,
isNew: false,
isFolder: false,
uid: dash.metadata.name,
k8s: dash.metadata,
},
dashboard: dash.spec,
};
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
result.meta.folderTitle = folder.title;
result.meta.folderUrl = folder.url;
result.meta.folderUid = folder.uid;
result.meta.folderId = folder.id;
} catch (e) {
console.error('Failed to load a folder', e);
}
}
return result;
}
}

@ -0,0 +1,69 @@
import {
DashboardV2Spec,
defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
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';
const mockDashboardDto: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
apiVersion: 'v0alpha1',
metadata: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: '1',
annotations: {
[AnnoKeyFolder]: 'new-folder',
},
},
spec: {
...defaultDashboardV2Spec(),
},
access: {},
};
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
get: () => mockDashboardDto,
}),
config: {},
}));
jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
ignoreNextSave: jest.fn(),
}));
describe('v2 dashboard API', () => {
it('should provide folder annotations', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({
id: 1,
uid: 'new-folder',
title: 'New Folder',
url: '/folder/url',
canAdmin: true,
canDelete: true,
canEdit: true,
canSave: true,
created: '',
createdBy: '',
hasAcl: false,
updated: '',
updatedBy: '',
});
const convertToV1 = false;
const api = new K8sDashboardV2APIStub(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
const result = (await api.getDashboardDTO('test')) as DashboardWithAccessInfo<DashboardV2Spec>;
expect(result.metadata.annotations![AnnoKeyFolderId]).toBe(1);
expect(result.metadata.annotations![AnnoKeyFolderTitle]).toBe('New Folder');
expect(result.metadata.annotations![AnnoKeyFolderUrl]).toBe('/folder/url');
expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder');
});
});

@ -0,0 +1,68 @@
import { UrlQueryMap } from '@grafana/data';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { backendSrv } from 'app/core/services/backend_srv';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
AnnoKeyFolder,
AnnoKeyFolderId,
AnnoKeyFolderTitle,
AnnoKeyFolderUrl,
ResourceClient,
} from 'app/features/apiserver/types';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { ResponseTransformers } from './ResponseTransformers';
import { DashboardAPI, DashboardWithAccessInfo } from './types';
export class K8sDashboardV2APIStub implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO> {
private client: ResourceClient<DashboardV2Spec>;
constructor(private convertToV1: boolean) {
this.client = new ScopedResourceClient<DashboardV2Spec>({
group: 'dashboard.grafana.app',
version: 'v2alpha1',
resource: 'dashboards',
});
}
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined;
// TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses.
result = ResponseTransformers.ensureV2Response(dashboard);
// load folder info if available
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]);
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
result.metadata.annotations[AnnoKeyFolderId] = folder.id;
} catch (e) {
console.error('Failed to load a folder', e);
}
}
// Depending on the ui components readiness, we might need to convert the response to v1
if (this.convertToV1) {
// Always return V1 format
result = ResponseTransformers.ensureV1Response(result);
return result;
}
// return the v2 response
return result;
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
throw new Error('Method not implemented.');
}
saveDashboard(options: SaveDashboardCommand): Promise<SaveDashboardResponseDTO> {
throw new Error('Method not implemented.');
}
}

@ -0,0 +1,64 @@
import {
DashboardV2Spec,
defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { DashboardDTO } from 'app/types';
import { validateUid } from './validation';
const legacyDashboard: DashboardDTO = {
dashboard: {
title: 'Legacy Dashboard',
schemaVersion: 16,
uid: 'dashboard-uid',
},
meta: {
folderTitle: 'Folder title',
folderUid: 'folder-uid',
},
};
const v2Dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
apiVersion: 'v2alpha0',
metadata: {
creationTimestamp: '2021-09-29T14:00:00Z',
name: 'dashboard-uid',
resourceVersion: '1',
annotations: {
[AnnoKeyFolder]: 'folder-uid',
[AnnoKeyFolderTitle]: 'folder-title',
},
},
access: {},
spec: {
...defaultDashboardV2Spec(),
title: 'V2 Dashboard',
},
};
describe('validateUid', () => {
beforeAll(() => {
setDashboardAPI({
legacy: {
getDashboardDTO: jest.fn().mockResolvedValue(legacyDashboard),
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
},
v2: {
getDashboardDTO: jest.fn().mockResolvedValue(v2Dashboard),
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
},
});
});
describe('Dashboards API v1', () => {
it('should return a message with the existing dashboard title and folder title', async () => {
const result = await validateUid('dashboard-uid');
expect(result).toBe(`Dashboard named 'Legacy Dashboard' in folder 'Folder title' has the same UID`);
});
});
});

@ -56,6 +56,7 @@ export interface DashboardMeta {
isSnapshot?: boolean;
folderTitle?: string;
folderUrl?: string;
folderId?: number;
created?: string;
createdBy?: string;
updated?: string;

Loading…
Cancel
Save