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:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user