Restore dashboards: Improve bulk restore (#110874)

* Restore dashboards: Handle partial failures

* Add resourceListCache

* Improve alerts

* Simplify message

* tweaks

* Comment

* Update message

* Fix restore state indicator
This commit is contained in:
Alex Khomenko
2025-09-11 09:42:27 +03:00
committed by GitHub
parent 22a41cdaa3
commit e4cffccbd4
5 changed files with 164 additions and 57 deletions
@@ -489,31 +489,24 @@ export const browseDashboardsAPI = createApi({
}),
// restore a dashboard that got deleted
restoreDashboard: builder.mutation<void, RestoreDashboardArgs>({
restoreDashboard: builder.mutation<{ name: string }, RestoreDashboardArgs>({
invalidatesTags: ['getFolder'],
queryFn: async ({ dashboard }) => {
try {
const api = getDashboardAPI();
const response = await api.restoreDashboard(dashboard);
const name = response.spec.title;
const name = response.spec.title || '';
const parentFolder = response.metadata?.annotations?.[AnnoKeyFolder];
if (name) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: [t('browse-dashboards.restore.success', 'Dashboard {{name}} restored', { name })],
});
// Refresh the contents of the folder a dashboard was restored to
dispatch(
refetchChildren({
parentUID: parentFolder,
pageSize: PAGE_SIZE,
})
);
// Refresh the contents of the folder a dashboard was restored to
dispatch(
refetchChildren({
parentUID: parentFolder,
pageSize: PAGE_SIZE,
})
);
}
return { data: undefined };
return { data: { name } };
} catch (error) {
return handleRequestError(error);
}
@@ -1,16 +1,16 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@grafana/i18n';
import { AppEvents } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button, Stack } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { ShowModalReactEvent } from 'app/types/events';
import { useDispatch } from 'app/types/store';
import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache';
import { useListDeletedDashboardsQuery, useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager';
import { useActionSelectionState } from '../state/hooks';
import { clearFolders, setAllSelection } from '../state/slice';
@@ -21,8 +21,47 @@ export function RecentlyDeletedActions() {
const dispatch = useDispatch();
const selectedItemsState = useActionSelectionState();
const [searchState, stateManager] = useRecentlyDeletedStateManager();
const deletedDashboards = useListDeletedDashboardsQuery();
const [restoreDashboard, { isLoading: isRestoreLoading }] = useRestoreDashboardMutation();
const [restoreDashboard] = useRestoreDashboardMutation();
const [isBulkRestoreLoading, setIsBulkRestoreLoading] = useState(false);
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const showRestoreNotifications = (successful: string[], failedCount: number) => {
const successCount = successful.length;
if (successCount === 0 && failedCount === 0) {
return;
}
let alertType = AppEvents.alertSuccess.name;
let message = t('browse-dashboards.restore.success', 'Dashboards restored successfully');
if (failedCount > 0) {
if (successCount > 0) {
// Partial success
alertType = AppEvents.alertWarning.name;
const successMessage = t(
'browse-dashboards.restore.success-count',
'{{count}} dashboard restored successfully',
{ count: successCount }
);
const failedMessage = t('browse-dashboards.restore.failed-count', '{{count}} dashboard failed', {
count: failedCount,
});
message = `${successMessage}. ${failedMessage}.`;
} else {
// All failed
alertType = AppEvents.alertError.name;
message = t('browse-dashboards.restore.all-failed', 'Failed to restore {{count}} dashboard', {
count: failedCount,
});
}
}
appEvents.publish({
type: alertType,
payload: [message],
});
};
const selectedDashboards = useMemo(() => {
return Object.entries(selectedItemsState.dashboard)
@@ -30,8 +69,12 @@ export function RecentlyDeletedActions() {
.map(([uid]) => uid);
}, [selectedItemsState.dashboard]);
const selectedDashboardOrigin: string[] = [];
if (searchState.result) {
const selectedDashboardOrigin = useMemo(() => {
if (!searchState.result) {
return [];
}
const origins: string[] = [];
for (const selectedDashboard of selectedDashboards) {
const index = searchState.result.view.fields.uid.values.findIndex((e) => e === selectedDashboard);
@@ -40,9 +83,10 @@ export function RecentlyDeletedActions() {
// to an empty string
const location = searchState.result.view.fields.location.values[index];
const fixedLocation = location === GENERAL_FOLDER_UID ? '' : location;
selectedDashboardOrigin.push(fixedLocation);
origins.push(fixedLocation);
}
}
return origins;
}, [selectedDashboards, searchState.result]);
const onActionComplete = () => {
dispatch(setAllSelection({ isSelected: false, folderUID: undefined }));
@@ -57,10 +101,14 @@ export function RecentlyDeletedActions() {
return;
}
const promises = selectedDashboards.map((uid) => {
const dashboard = deletedDashboards.data?.items.find((d) => d.metadata.name === uid);
setIsBulkRestoreLoading(true);
const promises = selectedDashboards.map(async (uid) => {
const deletedDashboards = await deletedDashboardsCache.getAsResourceList();
const dashboard = deletedDashboards?.items.find((d) => d.metadata.name === uid);
if (!dashboard) {
return Promise.resolve();
console.warn(`Dashboard ${uid} not found in deleted items`);
return { uid, error: 'not_found' };
}
// Clone the dashboard to be able to edit the immutable data from the store
const copy = structuredClone(dashboard);
@@ -72,7 +120,26 @@ export function RecentlyDeletedActions() {
return restoreDashboard({ dashboard: copy });
});
await Promise.all(promises);
const results = await Promise.allSettled(promises);
// Separate successful and failed restores
const successful: string[] = [];
const failed: string[] = [];
results.forEach((result, index) => {
const dashboardUid = selectedDashboards[index];
if (result.status === 'rejected') {
failed.push(dashboardUid);
} else if (result.value.error) {
failed.push(dashboardUid);
} 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);
const parentUIDs = new Set<string | undefined>();
for (const uid of selectedDashboards) {
@@ -80,7 +147,6 @@ export function RecentlyDeletedActions() {
if (!foundItem) {
continue;
}
// Search API returns items with no parent with a location of 'general', so we
// need to convert that back to undefined
const folderUID = foundItem.location === GENERAL_FOLDER_UID ? undefined : foundItem.location;
@@ -89,6 +155,8 @@ export function RecentlyDeletedActions() {
dispatch(clearFolders(Array.from(parentUIDs)));
onActionComplete();
setIsBulkRestoreLoading(false);
setIsRestoreModalOpen(false);
};
const showRestoreModal = () => {
@@ -97,24 +165,24 @@ export function RecentlyDeletedActions() {
dashboard: selectedDashboards.length,
},
});
appEvents.publish(
new ShowModalReactEvent({
component: RestoreModal,
props: {
selectedDashboards,
dashboardOrigin: selectedDashboardOrigin,
onConfirm: onRestore,
isLoading: isRestoreLoading,
},
})
);
setIsRestoreModalOpen(true);
};
return (
<Stack gap={1}>
<Button onClick={showRestoreModal} variant="secondary">
<Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans>
</Button>
</Stack>
<>
<Stack gap={1}>
<Button onClick={showRestoreModal} variant="secondary">
<Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans>
</Button>
</Stack>
<RestoreModal
isOpen={isRestoreModalOpen}
onConfirm={onRestore}
onDismiss={() => setIsRestoreModalOpen(false)}
selectedDashboards={selectedDashboards}
dashboardOrigin={selectedDashboardOrigin}
isLoading={isBulkRestoreLoading}
/>
</>
);
}
@@ -63,7 +63,6 @@ export const RestoreModal = ({
<Space v={1} />
<FolderPicker onChange={setRestoreTarget} value={restoreTarget} />
</>
// TODO: replace by list of dashboards (list up to 5 dashboards) or number (from 6 dashboards)?
}
confirmText={
isLoading
@@ -74,7 +73,7 @@ export const RestoreModal = ({
onDismiss={onDismiss}
onConfirm={onRestore}
title={t('recently-deleted.restore-modal.title', 'Restore Dashboards')}
disabled={restoreTarget === undefined}
disabled={restoreTarget === undefined || isLoading}
{...props}
/>
);
@@ -1,4 +1,5 @@
import { isResourceList } from 'app/features/apiserver/guards';
import { ResourceList } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardDataDTO } from 'app/types/dashboard';
@@ -11,6 +12,8 @@ import { resourceToSearchResult } from './utils';
class DeletedDashboardsCache {
private cache: SearchHit[] | null = null;
private promise: Promise<SearchHit[]> | null = null;
private resourceListCache: ResourceList<DashboardDataDTO> | null = null;
private resourceListPromise: Promise<ResourceList<DashboardDataDTO>> | null = null;
async get(): Promise<SearchHit[]> {
if (this.cache !== null) {
@@ -32,26 +35,64 @@ class DeletedDashboardsCache {
}
}
async getAsResourceList(): Promise<ResourceList<DashboardDataDTO>> {
if (this.resourceListCache !== null) {
return this.resourceListCache;
}
if (this.resourceListPromise !== null) {
return this.resourceListPromise;
}
this.resourceListPromise = this.fetchResourceList();
try {
this.resourceListCache = await this.resourceListPromise;
return this.resourceListCache;
} catch (error) {
this.resourceListPromise = null;
throw error;
}
}
clear(): void {
this.cache = null;
this.promise = null;
this.resourceListCache = null;
this.resourceListPromise = null;
}
private async fetch(): Promise<SearchHit[]> {
private async fetchResourceList(): Promise<ResourceList<DashboardDataDTO>> {
try {
const api = getDashboardAPI();
const deletedResponse = await api.listDeletedDashboards({ limit: 1000 });
if (isResourceList<DashboardDataDTO>(deletedResponse)) {
return resourceToSearchResult(deletedResponse);
return deletedResponse;
}
return [];
// Return empty ResourceList if not a valid ResourceList
return {
apiVersion: 'v1',
kind: 'List',
metadata: { resourceVersion: '0' },
items: [],
};
} catch (error) {
console.error('Failed to fetch deleted dashboards:', error);
return [];
return {
apiVersion: 'v1',
kind: 'List',
metadata: { resourceVersion: '0' },
items: [],
};
}
}
private async fetch(): Promise<SearchHit[]> {
const resourceList = await this.getAsResourceList();
return resourceToSearchResult(resourceList);
}
}
export const deletedDashboardsCache = new DeletedDashboardsCache();
+9 -3
View File
@@ -3663,7 +3663,13 @@
"text": "No results found for your query"
},
"restore": {
"success": "Dashboard {{name}} restored"
"all-failed_one": "Failed to restore {{count}} dashboard",
"all-failed_other": "Failed to restore {{count}} dashboards",
"failed-count_one": "{{count}} dashboard failed",
"failed-count_other": "{{count}} dashboards failed",
"success": "Dashboards restored successfully",
"success-count_one": "{{count}} dashboard restored successfully",
"success-count_other": "{{count}} dashboards restored successfully"
},
"text-this-repository-is-read-only": "If you have direct access to the target, copy the JSON and paste it there.",
"trash-state-manager": {
@@ -9451,9 +9457,9 @@
"type": {
"loki": {
"indexed-label_one": "Indexed label",
"indexed-label_other": "Indexed labels",
"indexed-label_other": "Indexed label",
"parsedl-label_one": "Parsed field",
"parsedl-label_other": "Parsed fields",
"parsedl-label_other": "Parsed field",
"structured-metadata_one": "Structured metadata",
"structured-metadata_other": "Structured metadata"
}