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