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 // restore a dashboard that got deleted
restoreDashboard: builder.mutation<void, RestoreDashboardArgs>({ restoreDashboard: builder.mutation<{ name: string }, RestoreDashboardArgs>({
invalidatesTags: ['getFolder'], invalidatesTags: ['getFolder'],
queryFn: async ({ dashboard }) => { queryFn: async ({ dashboard }) => {
try { try {
const api = getDashboardAPI(); const api = getDashboardAPI();
const response = await api.restoreDashboard(dashboard); const response = await api.restoreDashboard(dashboard);
const name = response.spec.title; const name = response.spec.title || '';
const parentFolder = response.metadata?.annotations?.[AnnoKeyFolder]; const parentFolder = response.metadata?.annotations?.[AnnoKeyFolder];
if (name) { // Refresh the contents of the folder a dashboard was restored to
appEvents.publish({ dispatch(
type: AppEvents.alertSuccess.name, refetchChildren({
payload: [t('browse-dashboards.restore.success', 'Dashboard {{name}} restored', { name })], parentUID: parentFolder,
}); pageSize: PAGE_SIZE,
})
);
// Refresh the contents of the folder a dashboard was restored to return { data: { name } };
dispatch(
refetchChildren({
parentUID: parentFolder,
pageSize: PAGE_SIZE,
})
);
}
return { data: undefined };
} catch (error) { } catch (error) {
return handleRequestError(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 { reportInteraction } from '@grafana/runtime';
import { Button, Stack } from '@grafana/ui'; import { Button, Stack } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { ShowModalReactEvent } from 'app/types/events';
import { useDispatch } from 'app/types/store'; import { useDispatch } from 'app/types/store';
import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache'; import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache';
import { useListDeletedDashboardsQuery, useRestoreDashboardMutation } from '../api/browseDashboardsAPI'; import { useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager'; import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager';
import { useActionSelectionState } from '../state/hooks'; import { useActionSelectionState } from '../state/hooks';
import { clearFolders, setAllSelection } from '../state/slice'; import { clearFolders, setAllSelection } from '../state/slice';
@@ -21,8 +21,47 @@ export function RecentlyDeletedActions() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const selectedItemsState = useActionSelectionState(); const selectedItemsState = useActionSelectionState();
const [searchState, stateManager] = useRecentlyDeletedStateManager(); const [searchState, stateManager] = useRecentlyDeletedStateManager();
const deletedDashboards = useListDeletedDashboardsQuery(); const [restoreDashboard] = useRestoreDashboardMutation();
const [restoreDashboard, { isLoading: isRestoreLoading }] = 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(() => { const selectedDashboards = useMemo(() => {
return Object.entries(selectedItemsState.dashboard) return Object.entries(selectedItemsState.dashboard)
@@ -30,8 +69,12 @@ export function RecentlyDeletedActions() {
.map(([uid]) => uid); .map(([uid]) => uid);
}, [selectedItemsState.dashboard]); }, [selectedItemsState.dashboard]);
const selectedDashboardOrigin: string[] = []; const selectedDashboardOrigin = useMemo(() => {
if (searchState.result) { if (!searchState.result) {
return [];
}
const origins: string[] = [];
for (const selectedDashboard of selectedDashboards) { for (const selectedDashboard of selectedDashboards) {
const index = searchState.result.view.fields.uid.values.findIndex((e) => e === selectedDashboard); const index = searchState.result.view.fields.uid.values.findIndex((e) => e === selectedDashboard);
@@ -40,9 +83,10 @@ export function RecentlyDeletedActions() {
// to an empty string // to an empty string
const location = searchState.result.view.fields.location.values[index]; const location = searchState.result.view.fields.location.values[index];
const fixedLocation = location === GENERAL_FOLDER_UID ? '' : location; const fixedLocation = location === GENERAL_FOLDER_UID ? '' : location;
selectedDashboardOrigin.push(fixedLocation); origins.push(fixedLocation);
} }
} return origins;
}, [selectedDashboards, searchState.result]);
const onActionComplete = () => { const onActionComplete = () => {
dispatch(setAllSelection({ isSelected: false, folderUID: undefined })); dispatch(setAllSelection({ isSelected: false, folderUID: undefined }));
@@ -57,10 +101,14 @@ export function RecentlyDeletedActions() {
return; return;
} }
const promises = selectedDashboards.map((uid) => { setIsBulkRestoreLoading(true);
const dashboard = deletedDashboards.data?.items.find((d) => d.metadata.name === uid);
const promises = selectedDashboards.map(async (uid) => {
const deletedDashboards = await deletedDashboardsCache.getAsResourceList();
const dashboard = deletedDashboards?.items.find((d) => d.metadata.name === uid);
if (!dashboard) { 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 // Clone the dashboard to be able to edit the immutable data from the store
const copy = structuredClone(dashboard); const copy = structuredClone(dashboard);
@@ -72,7 +120,26 @@ export function RecentlyDeletedActions() {
return restoreDashboard({ dashboard: copy }); 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>(); const parentUIDs = new Set<string | undefined>();
for (const uid of selectedDashboards) { for (const uid of selectedDashboards) {
@@ -80,7 +147,6 @@ export function RecentlyDeletedActions() {
if (!foundItem) { if (!foundItem) {
continue; continue;
} }
// Search API returns items with no parent with a location of 'general', so we // Search API returns items with no parent with a location of 'general', so we
// need to convert that back to undefined // need to convert that back to undefined
const folderUID = foundItem.location === GENERAL_FOLDER_UID ? undefined : foundItem.location; const folderUID = foundItem.location === GENERAL_FOLDER_UID ? undefined : foundItem.location;
@@ -89,6 +155,8 @@ export function RecentlyDeletedActions() {
dispatch(clearFolders(Array.from(parentUIDs))); dispatch(clearFolders(Array.from(parentUIDs)));
onActionComplete(); onActionComplete();
setIsBulkRestoreLoading(false);
setIsRestoreModalOpen(false);
}; };
const showRestoreModal = () => { const showRestoreModal = () => {
@@ -97,24 +165,24 @@ export function RecentlyDeletedActions() {
dashboard: selectedDashboards.length, dashboard: selectedDashboards.length,
}, },
}); });
appEvents.publish( setIsRestoreModalOpen(true);
new ShowModalReactEvent({
component: RestoreModal,
props: {
selectedDashboards,
dashboardOrigin: selectedDashboardOrigin,
onConfirm: onRestore,
isLoading: isRestoreLoading,
},
})
);
}; };
return ( return (
<Stack gap={1}> <>
<Button onClick={showRestoreModal} variant="secondary"> <Stack gap={1}>
<Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans> <Button onClick={showRestoreModal} variant="secondary">
</Button> <Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans>
</Stack> </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} /> <Space v={1} />
<FolderPicker onChange={setRestoreTarget} value={restoreTarget} /> <FolderPicker onChange={setRestoreTarget} value={restoreTarget} />
</> </>
// TODO: replace by list of dashboards (list up to 5 dashboards) or number (from 6 dashboards)?
} }
confirmText={ confirmText={
isLoading isLoading
@@ -74,7 +73,7 @@ export const RestoreModal = ({
onDismiss={onDismiss} onDismiss={onDismiss}
onConfirm={onRestore} onConfirm={onRestore}
title={t('recently-deleted.restore-modal.title', 'Restore Dashboards')} title={t('recently-deleted.restore-modal.title', 'Restore Dashboards')}
disabled={restoreTarget === undefined} disabled={restoreTarget === undefined || isLoading}
{...props} {...props}
/> />
); );
@@ -1,4 +1,5 @@
import { isResourceList } from 'app/features/apiserver/guards'; import { isResourceList } from 'app/features/apiserver/guards';
import { ResourceList } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardDataDTO } from 'app/types/dashboard';
@@ -11,6 +12,8 @@ import { resourceToSearchResult } from './utils';
class DeletedDashboardsCache { class DeletedDashboardsCache {
private cache: SearchHit[] | null = null; private cache: SearchHit[] | null = null;
private promise: Promise<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[]> { async get(): Promise<SearchHit[]> {
if (this.cache !== null) { 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 { clear(): void {
this.cache = null; this.cache = null;
this.promise = null; this.promise = null;
this.resourceListCache = null;
this.resourceListPromise = null;
} }
private async fetch(): Promise<SearchHit[]> { private async fetchResourceList(): Promise<ResourceList<DashboardDataDTO>> {
try { try {
const api = getDashboardAPI(); const api = getDashboardAPI();
const deletedResponse = await api.listDeletedDashboards({ limit: 1000 }); const deletedResponse = await api.listDeletedDashboards({ limit: 1000 });
if (isResourceList<DashboardDataDTO>(deletedResponse)) { 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) { } catch (error) {
console.error('Failed to fetch deleted dashboards:', 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(); export const deletedDashboardsCache = new DeletedDashboardsCache();
+9 -3
View File
@@ -3663,7 +3663,13 @@
"text": "No results found for your query" "text": "No results found for your query"
}, },
"restore": { "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.", "text-this-repository-is-read-only": "If you have direct access to the target, copy the JSON and paste it there.",
"trash-state-manager": { "trash-state-manager": {
@@ -9451,9 +9457,9 @@
"type": { "type": {
"loki": { "loki": {
"indexed-label_one": "Indexed label", "indexed-label_one": "Indexed label",
"indexed-label_other": "Indexed labels", "indexed-label_other": "Indexed label",
"parsedl-label_one": "Parsed field", "parsedl-label_one": "Parsed field",
"parsedl-label_other": "Parsed fields", "parsedl-label_other": "Parsed field",
"structured-metadata_one": "Structured metadata", "structured-metadata_one": "Structured metadata",
"structured-metadata_other": "Structured metadata" "structured-metadata_other": "Structured metadata"
} }