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:
@@ -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"]
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user