Restore dashboards: Surface restore error details (#112219)

* Restore dashboards: Better surface error messages

* Fix resource check

* Fix folder picker font size

* Add smoke tests

* cleanup

* Only show valid resources
This commit is contained in:
Alex Khomenko 2025-10-10 11:10:13 +02:00 committed by GitHub
parent 267848063d
commit 8d17c22006
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 166 additions and 12 deletions

View File

@ -21,8 +21,6 @@ export function isResource<T = object, S = object, K = string>(value: unknown):
}
return (
typeof value.apiVersion === 'string' &&
typeof value.kind === 'string' &&
typeof metadata.name === 'string' &&
typeof metadata.resourceVersion === 'string' &&
typeof metadata.creationTimestamp === 'string' &&

View File

@ -0,0 +1,120 @@
import { render, screen } from 'test/test-utils';
import { DataFrameView, FieldType, toDataFrame } from '@grafana/data';
import { setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { backendSrv } from 'app/core/services/backend_srv';
import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache';
import { DashboardQueryResult } from '../../search/service/types';
import { SearchLayout, EventTrackingNamespace } from '../../search/types';
import { TrashStateManager, useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager';
import { useActionSelectionState } from '../state/hooks';
import { RecentlyDeletedActions } from './RecentlyDeletedActions';
jest.mock('../api/useRecentlyDeletedStateManager');
jest.mock('../state/hooks');
jest.mock('../../search/service/deletedDashboardsCache');
setBackendSrv(backendSrv);
setupMockServer();
const mockUseRecentlyDeletedStateManager = useRecentlyDeletedStateManager as jest.MockedFunction<
typeof useRecentlyDeletedStateManager
>;
const mockUseActionSelectionState = useActionSelectionState as jest.MockedFunction<typeof useActionSelectionState>;
describe('RecentlyDeletedActions', () => {
const mockDoSearchWithDebounce = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
const mockDataFrame = toDataFrame({
name: 'DeletedDashboards',
fields: [
{ name: 'kind', type: FieldType.string, config: {}, values: ['dashboard'] },
{ name: 'name', type: FieldType.string, config: {}, values: ['Test Dashboard'] },
{ name: 'uid', type: FieldType.string, config: {}, values: ['dashboard-1'] },
{ name: 'url', type: FieldType.string, config: {}, values: ['/d/dashboard-1'] },
{ name: 'panel_type', type: FieldType.string, config: {}, values: [''] },
{ name: 'tags', type: FieldType.other, config: {}, values: [[]] },
{ name: 'location', type: FieldType.string, config: {}, values: ['folder-1'] },
{ name: 'ds_uid', type: FieldType.other, config: {}, values: [[]] },
{ name: 'score', type: FieldType.number, config: {}, values: [0] },
{ name: 'explain', type: FieldType.other, config: {}, values: [{}] },
],
});
const mockView = new DataFrameView<DashboardQueryResult>(mockDataFrame);
const mockStateManager = {
doSearchWithDebounce: mockDoSearchWithDebounce,
state: {
query: '',
tag: [],
starred: false,
layout: SearchLayout.List,
deleted: true,
eventTrackingNamespace: 'manage_dashboards' as EventTrackingNamespace,
result: {
view: mockView,
},
},
} as unknown as TrashStateManager;
mockUseRecentlyDeletedStateManager.mockReturnValue([
{
query: '',
tag: [],
starred: false,
layout: SearchLayout.List,
deleted: true,
eventTrackingNamespace: 'manage_dashboards',
result: {
view: mockView,
loadMoreItems: function (startIndex: number, stopIndex: number): Promise<void> {
return Promise.resolve();
},
isItemLoaded: function (index: number) {
return true;
},
totalRows: 0,
},
},
mockStateManager,
]);
(deletedDashboardsCache.clear as jest.Mock) = jest.fn();
(deletedDashboardsCache.getAsResourceList as jest.Mock) = jest.fn().mockResolvedValue({
items: [
{
metadata: { name: 'dashboard-1' },
},
],
});
});
it('renders restore button', () => {
mockUseActionSelectionState.mockReturnValue({
dashboard: {},
folder: {},
});
render(<RecentlyDeletedActions />);
expect(screen.getByRole('button', { name: 'Restore' })).toBeInTheDocument();
});
it('restore button is visible when dashboards are selected', () => {
mockUseActionSelectionState.mockReturnValue({
dashboard: { 'dashboard-1': true },
folder: {},
});
render(<RecentlyDeletedActions />);
expect(screen.getByRole('button', { name: 'Restore' })).toBeInTheDocument();
});
});

View File

@ -25,8 +25,9 @@ export function RecentlyDeletedActions() {
const [isBulkRestoreLoading, setIsBulkRestoreLoading] = useState(false);
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const showRestoreNotifications = (successful: string[], failedCount: number) => {
const showRestoreNotifications = (successful: string[], failed: Array<{ uid: string; error: string }>) => {
const successCount = successful.length;
const failedCount = failed.length;
if (successCount === 0 && failedCount === 0) {
return;
@ -36,6 +37,8 @@ export function RecentlyDeletedActions() {
let message = t('browse-dashboards.restore.success', 'Dashboards restored successfully');
if (failedCount > 0) {
const firstError = failed[0]?.error;
if (successCount > 0) {
// Partial success
alertType = AppEvents.alertWarning.name;
@ -48,12 +51,18 @@ export function RecentlyDeletedActions() {
count: failedCount,
});
message = `${successMessage}. ${failedMessage}.`;
if (firstError) {
message += `. ${firstError}`;
}
} else {
// All failed
alertType = AppEvents.alertError.name;
message = t('browse-dashboards.restore.all-failed', 'Failed to restore {{count}} dashboard', {
message = t('browse-dashboards.restore.all-failed', 'Failed to restore {{count}} dashboard.', {
count: failedCount,
});
if (firstError) {
message += `. ${firstError}`;
}
}
}
@ -95,6 +104,22 @@ export function RecentlyDeletedActions() {
stateManager.doSearchWithDebounce();
};
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object' && 'message' in error) {
return String(error.message);
}
if (error) {
return JSON.stringify(error);
}
return '';
};
const onRestore = async (restoreTarget: string) => {
const resultsView = stateManager.state.result?.view.toArray();
if (!resultsView) {
@ -124,20 +149,27 @@ export function RecentlyDeletedActions() {
// Separate successful and failed restores
const successful: string[] = [];
const failed: string[] = [];
const failed: Array<{ uid: string; error: string }> = [];
results.forEach((result, index) => {
const dashboardUid = selectedDashboards[index];
if (result.status === 'rejected' || result.value.error) {
failed.push(dashboardUid);
if (result.status === 'rejected') {
const errorMessage = getErrorMessage(result.reason);
if (errorMessage) {
failed.push({ uid: dashboardUid, error: errorMessage });
}
} else if (result.value.error) {
const errorMessage = getErrorMessage(result.value.error);
if (errorMessage) {
failed.push({ uid: dashboardUid, error: errorMessage });
}
} else if ('data' in result.value && result.value.data?.name) {
successful.push(result.value.data.name);
}
});
// Show consolidated notification
const failedCount = failed.length;
showRestoreNotifications(successful, failedCount);
showRestoreNotifications(successful, failed);
const parentUIDs = new Set<string | undefined>();
for (const uid of selectedDashboards) {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { ConfirmModal, Space, Text } from '@grafana/ui';
import { ConfirmModal, Field, Space, Text } from '@grafana/ui';
import { FolderPicker } from '../../../core/components/Select/FolderPicker';
@ -61,7 +61,10 @@ export const RestoreModal = ({
</Trans>
</Text>
<Space v={1} />
<FolderPicker onChange={setRestoreTarget} value={restoreTarget} />
{/* Field wrapper resets font-size to 14px, preventing cascade from parent Text components */}
<Field noMargin>
<FolderPicker onChange={setRestoreTarget} value={restoreTarget} />
</Field>
</>
}
confirmText={

View File

@ -80,7 +80,8 @@ export class UnifiedDashboardAPI
return {
...v2Response,
items: [...filteredV1Items, ...filteredV2Items],
// Make sure we display only valid resources
items: [...filteredV1Items, ...filteredV2Items].filter(isResource),
};
}