diff --git a/.betterer.results b/.betterer.results index 4439c212997..c635282bf6b 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/public/app/features/apiserver/guards.ts b/public/app/features/apiserver/guards.ts index e08bc76fc5d..a43a0734dd5 100644 --- a/public/app/features/apiserver/guards.ts +++ b/public/app/features/apiserver/guards.ts @@ -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( - generated: GeneratedResource -): generated is Resource { +function isObject(value: unknown): value is Record { + 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(value: unknown): value is Resource { + 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( - generatedList: GeneratedResourceList -): generatedList is ResourceList { - return !!generatedList.metadata?.resourceVersion && Array.isArray(generatedList.items); +export function isResourceList(value: unknown): value is ResourceList { + if (!isObject(value)) { + return false; + } + + const metadata = value.metadata; + if (!isObject(metadata)) { + return false; + } + + return typeof metadata.resourceVersion === 'string' && Array.isArray(value.items); } diff --git a/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts b/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts index 36dddf6000d..6dc00b3190f 100644 --- a/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts +++ b/public/app/features/dashboard/api/UnifiedDashboardAPI.test.ts @@ -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); + + 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); + v2Client.listDeletedDashboards.mockResolvedValue(mockV2Response as ResourceList); + + 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' + ); + }); + }); }); diff --git a/public/app/features/dashboard/api/UnifiedDashboardAPI.ts b/public/app/features/dashboard/api/UnifiedDashboardAPI.ts index 4b1af126dbc..344f3ba9cd2 100644 --- a/public/app/features/dashboard/api/UnifiedDashboardAPI.ts +++ b/public/app/features/dashboard/api/UnifiedDashboardAPI.ts @@ -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> { + 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) { + // Await returned promise to support proper error handling with try/catch + if (isDashboardV2Spec(dashboard.spec) && isResource(dashboard)) { + return await this.v2Client.restoreDashboard(dashboard); + } + + if (isResource(dashboard)) { + return await this.v1Client.restoreDashboard(dashboard); + } + throw new Error('Invalid dashboard resource for restore operation'); + } } diff --git a/public/app/features/dashboard/api/legacy.ts b/public/app/features/dashboard/api/legacy.ts index 5aaac00e4b6..8833958ec5f 100644 --- a/public/app/features/dashboard/api/legacy.ts +++ b/public/app/features/dashboard/api/legacy.ts @@ -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 { constructor() {} @@ -48,4 +49,23 @@ export class LegacyDashboardAPI implements DashboardAPI return result; } + + /** + * No-op for legacy API + */ + listDeletedDashboards(options: ListDeletedDashboardsOptions): Promise> { + return Promise.resolve({ + apiVersion: 'v1', + kind: 'List', + metadata: { resourceVersion: '0' }, + items: [], + }); + } + + /** + * No-op for legacy API + */ + restoreDashboard(dashboard: Resource): Promise> { + return Promise.reject(new Error('Restore functionality not supported in legacy API')); + } } diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts index e9c66c24cfc..568fcfcf4a5 100644 --- a/public/app/features/dashboard/api/types.ts +++ b/public/app/features/dashboard/api/types.ts @@ -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; + export interface DashboardAPI { /** Get a dashboard with the access control metadata */ getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; @@ -13,6 +15,10 @@ export interface DashboardAPI { saveDashboard(options: SaveDashboardCommand): Promise; /** Delete a dashboard */ deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; + /** List all deleted dashboards */ + listDeletedDashboards(options: ListDeletedDashboardsOptions): Promise>; + /** Restore a deleted dashboard by re-creating it */ + restoreDashboard(dashboard: Resource): Promise>; } // Implemented using /api/dashboards/* diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index 9c0da73697a..f49fcb9de12 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -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 { 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 +): 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, + versionPrefix: string +): boolean { + const storedVersion = getFailedVersion(item); + return !!storedVersion && storedVersion.startsWith(versionPrefix); +} diff --git a/public/app/features/dashboard/api/v1.test.ts b/public/app/features/dashboard/api/v1.test.ts index 9490e490168..a037136eeb9 100644 --- a/public/app/features/dashboard/api/v1.test.ts +++ b/public/app/features/dashboard/api/v1.test.ts @@ -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(); + }); + }); }); diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index b3434b4b551..58edcc56d70 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -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 { - private client: ResourceClient; + private client: ResourceClient; constructor() { this.client = new ScopedResourceClient(K8S_V1_DASHBOARD_API_CONFIG); } saveDashboard(options: SaveDashboardCommand): Promise { - const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property + const dashboard = options.dashboard; const obj: ResourceForCreate = { metadata: { ...options?.k8s, }, spec: { ...dashboard, + title: dashboard.title ?? '', + uid: dashboard.uid ?? '', }, }; @@ -172,4 +175,14 @@ export class K8sDashboardAPI implements DashboardAPI { throw e; } } + + async listDeletedDashboards(options: ListDeletedDashboardsOptions) { + return await this.client.list({ ...options, labelSelector: 'grafana.app/get-trash=true' }); + } + + restoreDashboard(dashboard: Resource) { + // reset the resource version to create a new resource + dashboard.metadata.resourceVersion = ''; + return this.client.create(dashboard); + } } diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts index 2ac3cb361fe..a3b540ae6b6 100644 --- a/public/app/features/dashboard/api/v2.test.ts +++ b/public/app/features/dashboard/api/v2.test.ts @@ -32,7 +32,7 @@ const mockDashboardDto: DashboardWithAccessInfo = { 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(); + }); + }); }); diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index bc0fcdb13cc..0a0830e4ffa 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -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 | DashboardDTO, DashboardV2Spec> { - private client: ResourceClient; + private client: ResourceClient; constructor() { this.client = new ScopedResourceClient(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) { + // reset the resource version to create a new resource + dashboard.metadata.resourceVersion = ''; + return this.client.create(dashboard); + } } diff --git a/public/app/features/manage-dashboards/utils/validation.test.ts b/public/app/features/manage-dashboards/utils/validation.test.ts index 6acf9fe4a7f..40d1a8ee2b4 100644 --- a/public/app/features/manage-dashboards/utils/validation.test.ts +++ b/public/app/features/manage-dashboards/utils/validation.test.ts @@ -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(), }, }); });