Restore dashboards: Add API client endpoints (#106435)

* Dashboards: Add restore endpoints to the API

* Fix unified api

* Fix resource version

* Add tests

* Update api

* Update type guards

* Update comments

* Add missing type

* Cleanup

* Move spec checking logic to v1 client

* Handle mixed versions in deleted dbs list

* Update tests

* comment

* type
This commit is contained in:
Alex Khomenko
2025-06-12 15:49:55 +03:00
committed by GitHub
parent 6156e9c2d8
commit f9fb9d268f
12 changed files with 494 additions and 35 deletions
-3
View File
@@ -1849,9 +1849,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/features/dashboard/api/v1.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard/components/AddLibraryPanelWidget/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./AddLibraryPanelWidget\`)", "0"]
],
+37 -16
View File
@@ -1,26 +1,47 @@
import { Resource, ResourceList, GeneratedResource, GeneratedResourceList } from './types';
import { Resource, ResourceList } from './types';
/**
* Type guard to check if a GeneratedResource has all required fields to be a Resource
* Helper function to safely check if a value is a non-null object
*/
export function isResource<T = object, S = object, K = string>(
generated: GeneratedResource<T, S, K>
): generated is Resource<T, S, K> {
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
/**
* Type guard to check if an unknown value has all required fields to be a Resource
*/
export function isResource<T = object, S = object, K = string>(value: unknown): value is Resource<T, S, K> {
if (!isObject(value)) {
return false;
}
const metadata = value.metadata;
if (!isObject(metadata)) {
return false;
}
return (
!!generated.apiVersion &&
!!generated.kind &&
!!generated.metadata?.name &&
!!generated.metadata?.resourceVersion &&
!!generated.metadata?.creationTimestamp &&
!!generated.spec
typeof value.apiVersion === 'string' &&
typeof value.kind === 'string' &&
typeof metadata.name === 'string' &&
typeof metadata.resourceVersion === 'string' &&
typeof metadata.creationTimestamp === 'string' &&
isObject(value.spec)
);
}
/**
* Type guard to check if a GeneratedResourceList has all required fields to be a ResourceList
* Type guard to check if an unknown value has all required fields to be a ResourceList
*/
export function isResourceList<T = object, S = object, K = string>(
generatedList: GeneratedResourceList<T, S, K>
): generatedList is ResourceList<T, S, K> {
return !!generatedList.metadata?.resourceVersion && Array.isArray(generatedList.items);
export function isResourceList<T = object, S = object, K = string>(value: unknown): value is ResourceList<T, S, K> {
if (!isObject(value)) {
return false;
}
const metadata = value.metadata;
if (!isObject(metadata)) {
return false;
}
return typeof metadata.resourceVersion === 'string' && Array.isArray(value.items);
}
@@ -1,9 +1,10 @@
import { Dashboard } from '@grafana/schema/dist/esm/index';
import { Dashboard } from '@grafana/schema';
import {
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { DashboardDTO } from 'app/types';
import { ResourceList } from 'app/features/apiserver/types';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
@@ -160,4 +161,162 @@ describe('UnifiedDashboardAPI', () => {
expect(v2Client.deleteDashboard).not.toHaveBeenCalled();
});
});
describe('listDeletedDashboards', () => {
it('should try v1 first and return result if successful', async () => {
const mockV1Response = {
items: [
{ spec: { title: 'deleted-dash-1' }, metadata: { name: 'dash-1' } },
{ spec: { title: 'deleted-dash-2' }, metadata: { name: 'dash-2' } },
],
};
v1Client.listDeletedDashboards.mockResolvedValue(mockV1Response as ResourceList<DashboardDataDTO>);
const result = await api.listDeletedDashboards({ limit: 10 });
expect(result).toBe(mockV1Response);
expect(v1Client.listDeletedDashboards).toHaveBeenCalledWith({ limit: 10 });
expect(v2Client.listDeletedDashboards).not.toHaveBeenCalled();
});
it('should combine responses when v1 returns mixed v1/v2 dashboards', async () => {
const mockV1Response = {
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'DashboardList',
metadata: { resourceVersion: '123' },
items: [
{
metadata: { name: 'v2-dash', resourceVersion: '123', creationTimestamp: '2023-01-01T00:00:00Z' },
spec: null,
status: { conversion: { failed: true, storedVersion: 'v2alpha1', error: 'conversion failed' } },
},
{
kind: 'Dashboard',
apiVersion: 'dashboard.grafana.app/v1beta1',
metadata: { name: 'v1-dash', resourceVersion: '123', creationTimestamp: '2023-01-01T00:00:00Z' },
spec: { title: 'v1', schemaVersion: 30 },
status: {},
},
],
};
const mockV2Response = {
apiVersion: 'dashboard.grafana.app/v2alpha1',
kind: 'DashboardList',
metadata: { resourceVersion: '456' },
items: [
{
kind: 'Dashboard',
apiVersion: 'dashboard.grafana.app/v2alpha1',
metadata: { name: 'v2-dash', resourceVersion: '456', creationTimestamp: '2023-01-01T00:00:00Z' },
spec: { title: 'v2', elements: {} },
status: {},
},
{
metadata: { name: 'v1-dash', resourceVersion: '456', creationTimestamp: '2023-01-01T00:00:00Z' },
spec: { title: 'v1', elements: null },
status: { conversion: { failed: true, storedVersion: 'v1beta1', error: 'conversion failed' } },
},
],
};
v1Client.listDeletedDashboards.mockResolvedValue(mockV1Response as ResourceList<DashboardDataDTO>);
v2Client.listDeletedDashboards.mockResolvedValue(mockV2Response as ResourceList<DashboardV2Spec>);
const result = await api.listDeletedDashboards({ limit: 10 });
expect(result).toEqual({
...mockV2Response,
items: [
mockV1Response.items[1], // v1 dashboard
mockV2Response.items[0], // v2 dashboard
],
});
expect(v1Client.listDeletedDashboards).toHaveBeenCalledWith({ limit: 10 });
expect(v2Client.listDeletedDashboards).toHaveBeenCalledWith({ limit: 10 });
});
it('should throw error if v1 throws DashboardVersionError', async () => {
const mockError = new DashboardVersionError('unsupported version');
v1Client.listDeletedDashboards.mockRejectedValue(mockError);
await expect(api.listDeletedDashboards({ limit: 10 })).rejects.toThrow(mockError);
expect(v2Client.listDeletedDashboards).not.toHaveBeenCalled();
});
it('should throw non-DashboardVersionError from v1', async () => {
const mockError = new Error('Network error');
v1Client.listDeletedDashboards.mockRejectedValue(mockError);
await expect(api.listDeletedDashboards({ limit: 10 })).rejects.toThrow('Network error');
expect(v2Client.listDeletedDashboards).not.toHaveBeenCalled();
});
});
describe('restoreDashboard', () => {
it('should use v1 client for v1 dashboard resource', async () => {
const mockV1Dashboard = {
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
metadata: {
name: 'dash-1',
resourceVersion: '123',
creationTimestamp: '2023-01-01T00:00:00Z',
},
spec: { title: 'V1 Dashboard', panels: [], schemaVersion: 30, uid: '123' },
};
await api.restoreDashboard(mockV1Dashboard);
expect(v1Client.restoreDashboard).toHaveBeenCalledWith(mockV1Dashboard);
expect(v2Client.restoreDashboard).not.toHaveBeenCalled();
});
it('should use v2 client for v2 dashboard resource', async () => {
const mockV2Dashboard = {
apiVersion: 'dashboard.grafana.app/v2alpha1',
kind: 'Dashboard',
metadata: {
name: 'dash-1',
resourceVersion: '123',
creationTimestamp: '2023-01-01T00:00:00Z',
},
spec: {
...defaultDashboardV2Spec(),
title: 'V2 Dashboard',
},
};
await api.restoreDashboard(mockV2Dashboard);
expect(v2Client.restoreDashboard).toHaveBeenCalledWith(mockV2Dashboard);
expect(v1Client.restoreDashboard).not.toHaveBeenCalled();
});
it('should throw error for invalid dashboard resource', async () => {
const invalidDashboard = {
apiVersion: 'dashboard.grafana.app/v1beta1',
kind: 'Dashboard',
metadata: { name: 'dash-1' },
spec: { invalid: 'data' },
};
// @ts-expect-error - Invalid dashboard for testing
await expect(api.restoreDashboard(invalidDashboard)).rejects.toThrow(
'Invalid dashboard resource for restore operation'
);
expect(v1Client.restoreDashboard).not.toHaveBeenCalled();
expect(v2Client.restoreDashboard).not.toHaveBeenCalled();
});
it('should throw error for dashboard resource without metadata', async () => {
const invalidDashboard = {
spec: { title: 'Dashboard' },
};
// @ts-expect-error - Invalid dashboard for testing
await expect(api.restoreDashboard(invalidDashboard)).rejects.toThrow(
'Invalid dashboard resource for restore operation'
);
});
});
});
@@ -1,11 +1,13 @@
import { Dashboard } from '@grafana/schema/dist/esm/index';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { DashboardDTO } from 'app/types';
import { isResource } from 'app/features/apiserver/guards';
import { Resource, ResourceList } from 'app/features/apiserver/types';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
import { isV1DashboardCommand, isV2DashboardCommand } from './utils';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
import { isDashboardV2Spec, isV1DashboardCommand, isV2DashboardCommand, failedFromVersion } from './utils';
import { K8sDashboardAPI } from './v1';
import { K8sDashboardV2API } from './v2';
@@ -47,4 +49,44 @@ export class UnifiedDashboardAPI
async deleteDashboard(uid: string, showSuccessAlert: boolean) {
return await this.v1Client.deleteDashboard(uid, showSuccessAlert);
}
/**
* List deleted dashboards handling mixed v1/v2 versions or pure v2 dashboards.
*
* Steps:
* 1. Call v1 client to get all deleted dashboards
* 2. Check if any items have failed conversion from v2 versions
* 3. If v2 dashboards are detected, call v2 client
* 4. Filter and combine v1 and v2 dashboards into one response
*/
async listDeletedDashboards(
options: ListDeletedDashboardsOptions
): Promise<ResourceList<Dashboard | DashboardV2Spec>> {
const v1Response = await this.v1Client.listDeletedDashboards(options);
const filteredV1Items = v1Response.items.filter((item) => !failedFromVersion(item, 'v2'));
if (filteredV1Items.length === v1Response.items.length) {
return v1Response;
}
const v2Response = await this.v2Client.listDeletedDashboards(options);
const filteredV2Items = v2Response.items.filter((item) => !failedFromVersion(item, 'v1'));
return {
...v2Response,
items: [...filteredV1Items, ...filteredV2Items],
};
}
async restoreDashboard(dashboard: Resource<DashboardDataDTO | DashboardV2Spec>) {
// Await returned promise to support proper error handling with try/catch
if (isDashboardV2Spec(dashboard.spec) && isResource<DashboardV2Spec>(dashboard)) {
return await this.v2Client.restoreDashboard(dashboard);
}
if (isResource<DashboardDataDTO>(dashboard)) {
return await this.v1Client.restoreDashboard(dashboard);
}
throw new Error('Invalid dashboard resource for restore operation');
}
}
+21 -1
View File
@@ -3,13 +3,14 @@ import { t } from '@grafana/i18n';
import { FetchError, getBackendSrv } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { Resource, ResourceList } from 'app/features/apiserver/types';
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';
import { DashboardAPI, ListDeletedDashboardsOptions } from './types';
export class LegacyDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
constructor() {}
@@ -48,4 +49,23 @@ export class LegacyDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard>
return result;
}
/**
* No-op for legacy API
*/
listDeletedDashboards(options: ListDeletedDashboardsOptions): Promise<ResourceList<Dashboard>> {
return Promise.resolve({
apiVersion: 'v1',
kind: 'List',
metadata: { resourceVersion: '0' },
items: [],
});
}
/**
* No-op for legacy API
*/
restoreDashboard(dashboard: Resource<Dashboard>): Promise<Resource<Dashboard>> {
return Promise.reject(new Error('Restore functionality not supported in legacy API'));
}
}
+7 -1
View File
@@ -1,11 +1,13 @@
import { UrlQueryMap } from '@grafana/data';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { Resource } from 'app/features/apiserver/types';
import { ListOptions, Resource, ResourceList } 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 type ListDeletedDashboardsOptions = Omit<ListOptions, 'labelSelector'>;
export interface DashboardAPI<G, T> {
/** Get a dashboard with the access control metadata */
getDashboardDTO(uid: string, params?: UrlQueryMap): Promise<G>;
@@ -13,6 +15,10 @@ export interface DashboardAPI<G, T> {
saveDashboard(options: SaveDashboardCommand<T>): Promise<SaveDashboardResponseDTO>;
/** Delete a dashboard */
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse>;
/** List all deleted dashboards */
listDeletedDashboards(options: ListDeletedDashboardsOptions): Promise<ResourceList<T>>;
/** Restore a deleted dashboard by re-creating it */
restoreDashboard(dashboard: Resource<T>): Promise<Resource<T>>;
}
// Implemented using /api/dashboards/*
@@ -1,6 +1,8 @@
import { config, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { Resource } from 'app/features/apiserver/types';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
@@ -77,3 +79,28 @@ export function isV2DashboardCommand(
): cmd is SaveDashboardCommand<DashboardV2Spec> {
return isDashboardV2Spec(cmd.dashboard);
}
/**
* Helper function to extract the stored version from a dashboard resource if conversion failed
* @param item - Dashboard resource item
* @returns The stored version string if conversion failed, undefined otherwise
*/
export function getFailedVersion(
item: Resource<Dashboard | DashboardV2Spec | DashboardDataDTO, Status>
): string | undefined {
return item.status?.conversion?.failed ? item.status.conversion.storedVersion : undefined;
}
/**
* Helper function to check if a dashboard resource has a failed conversion from a specific version family
* @param item - Dashboard resource item
* @param versionPrefix - Version prefix to check (e.g., 'v1', 'v2')
* @returns True if conversion failed and stored version starts with the specified prefix
*/
export function failedFromVersion(
item: Resource<Dashboard | DashboardV2Spec | DashboardDataDTO, Status>,
versionPrefix: string
): boolean {
const storedVersion = getFailedVersion(item);
return !!storedVersion && storedVersion.startsWith(versionPrefix);
}
+76 -2
View File
@@ -90,13 +90,15 @@ const saveDashboardResponse = {
};
const mockGet = jest.fn().mockResolvedValue(mockDashboardDto);
const mockPost = jest.fn().mockResolvedValue(saveDashboardResponse);
const mockPut = jest.fn().mockResolvedValue(saveDashboardResponse);
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: mockGet,
put: jest.fn().mockResolvedValue(saveDashboardResponse),
post: jest.fn().mockResolvedValue(saveDashboardResponse),
put: mockPut,
post: mockPost,
}),
config: {
...jest.requireActual('@grafana/runtime').config,
@@ -111,6 +113,10 @@ jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
}));
describe('v1 dashboard API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should provide folder annotations', async () => {
mockGet.mockResolvedValueOnce({
...mockDashboardDto,
@@ -311,4 +317,72 @@ describe('v1 dashboard API', () => {
await expect(api.getDashboardDTO('test')).resolves.toBeDefined();
});
});
describe('listDeletedDashboards', () => {
it('should return list of deleted dashboards', async () => {
const mockDeletedDashboards = {
items: [
{
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, name: 'deleted-dash-1' },
},
{
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, name: 'deleted-dash-2' },
},
],
};
mockGet.mockResolvedValueOnce(mockDeletedDashboards);
const api = new K8sDashboardAPI();
const result = await api.listDeletedDashboards({ limit: 10 });
expect(result).toEqual(mockDeletedDashboards);
expect(result.items).toHaveLength(2);
});
});
describe('restoreDashboard', () => {
it('should reset resource version and return created dashboard', async () => {
const dashboardToRestore = {
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
resourceVersion: '123456',
},
};
const api = new K8sDashboardAPI();
const result = await api.restoreDashboard(dashboardToRestore);
expect(dashboardToRestore.metadata.resourceVersion).toBe('');
expect(mockPost).toHaveBeenCalledWith(
expect.stringContaining('/apis/dashboard.grafana.app/v1beta1/'),
expect.objectContaining({
metadata: expect.objectContaining({
resourceVersion: '',
}),
}),
expect.anything()
);
expect(result).toEqual(saveDashboardResponse);
});
it('should handle dashboard with empty resource version', async () => {
const dashboardToRestore = {
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
resourceVersion: '',
},
};
const api = new K8sDashboardAPI();
await api.restoreDashboard(dashboardToRestore);
expect(dashboardToRestore.metadata.resourceVersion).toBe('');
expect(mockPost).toHaveBeenCalled();
});
});
});
+16 -3
View File
@@ -1,6 +1,7 @@
import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
@@ -24,7 +25,7 @@ import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/ty
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
export const K8S_V1_DASHBOARD_API_CONFIG = {
group: 'dashboard.grafana.app',
@@ -33,20 +34,22 @@ export const K8S_V1_DASHBOARD_API_CONFIG = {
};
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
private client: ResourceClient<DashboardDataDTO>;
private client: ResourceClient<DashboardDataDTO, Status>;
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>(K8S_V1_DASHBOARD_API_CONFIG);
}
saveDashboard(options: SaveDashboardCommand<Dashboard>): Promise<SaveDashboardResponseDTO> {
const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property
const dashboard = options.dashboard;
const obj: ResourceForCreate<DashboardDataDTO> = {
metadata: {
...options?.k8s,
},
spec: {
...dashboard,
title: dashboard.title ?? '',
uid: dashboard.uid ?? '',
},
};
@@ -172,4 +175,14 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
throw e;
}
}
async listDeletedDashboards(options: ListDeletedDashboardsOptions) {
return await this.client.list({ ...options, labelSelector: 'grafana.app/get-trash=true' });
}
restoreDashboard(dashboard: Resource<DashboardDataDTO>) {
// reset the resource version to create a new resource
dashboard.metadata.resourceVersion = '';
return this.client.create(dashboard);
}
}
+86 -1
View File
@@ -32,7 +32,7 @@ const mockDashboardDto: DashboardWithAccessInfo<DashboardV2Spec> = {
access: {},
};
// Create mock get and put functions that we can spy on
// Create mock get, put, and post functions that we can spy on
const mockGet = jest.fn().mockResolvedValue(mockDashboardDto);
const mockPut = jest.fn().mockImplementation((url, data) => {
@@ -51,11 +51,28 @@ const mockPut = jest.fn().mockImplementation((url, data) => {
};
});
const mockPost = jest.fn().mockImplementation((url, data) => {
return {
apiVersion: 'dashboard.grafana.app/v2alpha1',
kind: 'Dashboard',
metadata: {
name: data.metadata?.name || 'restored-dash',
generation: 1,
resourceVersion: '1',
creationTimestamp: new Date().toISOString(),
labels: data.metadata?.labels,
annotations: data.metadata?.annotations,
},
spec: data.spec,
};
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: mockGet,
put: mockPut,
post: mockPost,
}),
config: {
...jest.requireActual('@grafana/runtime').config,
@@ -323,4 +340,72 @@ describe('v2 dashboard API', () => {
await expect(api.getDashboardDTO('test')).resolves.toBeDefined();
});
});
describe('listDeletedDashboards', () => {
it('should return list of deleted dashboards', async () => {
const mockDeletedDashboards = {
items: [
{
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, name: 'deleted-dash-1' },
},
{
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, name: 'deleted-dash-2' },
},
],
};
mockGet.mockResolvedValueOnce(mockDeletedDashboards);
const api = new K8sDashboardV2API();
const result = await api.listDeletedDashboards({ limit: 10 });
expect(result).toEqual(mockDeletedDashboards);
expect(result.items).toHaveLength(2);
});
});
describe('restoreDashboard', () => {
it('should reset resource version and return created dashboard', async () => {
const dashboardToRestore = {
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
resourceVersion: '123456',
},
};
const api = new K8sDashboardV2API();
const result = await api.restoreDashboard(dashboardToRestore);
expect(dashboardToRestore.metadata.resourceVersion).toBe('');
expect(mockPost).toHaveBeenCalledWith(
expect.stringContaining('/apis/dashboard.grafana.app/v2alpha1/'),
expect.objectContaining({
metadata: expect.objectContaining({
resourceVersion: '',
}),
}),
expect.anything()
);
expect(result.metadata.name).toBe('dash-uid');
});
it('should handle dashboard with empty resource version', async () => {
const dashboardToRestore = {
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
resourceVersion: '',
},
};
const api = new K8sDashboardV2API();
await api.restoreDashboard(dashboardToRestore);
expect(dashboardToRestore.metadata.resourceVersion).toBe('');
expect(mockPost).toHaveBeenCalled();
});
});
});
+13 -2
View File
@@ -1,6 +1,7 @@
import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
@@ -22,7 +23,7 @@ import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo } from './types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
import { isDashboardV2Spec } from './utils';
export const K8S_V2_DASHBOARD_API_CONFIG = {
@@ -34,7 +35,7 @@ export const K8S_V2_DASHBOARD_API_CONFIG = {
export class K8sDashboardV2API
implements DashboardAPI<DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO, DashboardV2Spec>
{
private client: ResourceClient<DashboardV2Spec>;
private client: ResourceClient<DashboardV2Spec, Status>;
constructor() {
this.client = new ScopedResourceClient<DashboardV2Spec>(K8S_V2_DASHBOARD_API_CONFIG);
@@ -166,4 +167,14 @@ export class K8sDashboardV2API
slug: '',
};
}
listDeletedDashboards(options: ListDeletedDashboardsOptions) {
return this.client.list({ ...options, labelSelector: 'grafana.app/get-trash=true' });
}
restoreDashboard(dashboard: Resource<DashboardV2Spec>) {
// reset the resource version to create a new resource
dashboard.metadata.resourceVersion = '';
return this.client.create(dashboard);
}
}
@@ -47,11 +47,15 @@ describe('validateUid', () => {
getDashboardDTO: jest.fn().mockResolvedValue(legacyDashboard),
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
listDeletedDashboards: jest.fn(),
restoreDashboard: jest.fn(),
},
v2: {
getDashboardDTO: jest.fn().mockResolvedValue(v2Dashboard),
deleteDashboard: jest.fn(),
saveDashboard: jest.fn(),
listDeletedDashboards: jest.fn(),
restoreDashboard: jest.fn(),
},
});
});