diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index d9788cda50b..4eb6aff8dca 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -489,31 +489,24 @@ export const browseDashboardsAPI = createApi({ }), // restore a dashboard that got deleted - restoreDashboard: builder.mutation({ + 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); } diff --git a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx index 18cc800213e..a8da562add4 100644 --- a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx @@ -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(); 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 ( - - - + <> + + + + setIsRestoreModalOpen(false)} + selectedDashboards={selectedDashboards} + dashboardOrigin={selectedDashboardOrigin} + isLoading={isBulkRestoreLoading} + /> + ); } diff --git a/public/app/features/browse-dashboards/components/RestoreModal.tsx b/public/app/features/browse-dashboards/components/RestoreModal.tsx index f18037defea..ebf5443e841 100644 --- a/public/app/features/browse-dashboards/components/RestoreModal.tsx +++ b/public/app/features/browse-dashboards/components/RestoreModal.tsx @@ -63,7 +63,6 @@ export const RestoreModal = ({ - // 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} /> ); diff --git a/public/app/features/search/service/deletedDashboardsCache.ts b/public/app/features/search/service/deletedDashboardsCache.ts index 7559d00605c..1779ec6b2b7 100644 --- a/public/app/features/search/service/deletedDashboardsCache.ts +++ b/public/app/features/search/service/deletedDashboardsCache.ts @@ -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 | null = null; + private resourceListCache: ResourceList | null = null; + private resourceListPromise: Promise> | null = null; async get(): Promise { if (this.cache !== null) { @@ -32,26 +35,64 @@ class DeletedDashboardsCache { } } + async getAsResourceList(): Promise> { + 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 { + private async fetchResourceList(): Promise> { try { const api = getDashboardAPI(); const deletedResponse = await api.listDeletedDashboards({ limit: 1000 }); if (isResourceList(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 { + const resourceList = await this.getAsResourceList(); + return resourceToSearchResult(resourceList); + } } export const deletedDashboardsCache = new DeletedDashboardsCache(); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1acffbfbfaa..edd1aea6fd8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" }