Restore dashboards: Use k8s API (#105823)

* Add dashboardRestore toggle

* Restore the toggle on FE

* Add navtree item

* Add restore dashboard call

* Fix lint

* Add transformer

* Rename feature toggle

* Use the renamed toggle

* update API url

* List deleted dashboards from sql searcher

* Upd imports

* Remove permanently delete action

* Fix type assertions

* Add restore endpoints to the api

* Fix unified api

* Use db api in search

* Fix resource version

* Add tests

* Cleanup

* Extract getting db

* Add listDeletedDashboards

* Add listDeletedDashboards

* Enable restoring dashboards

* Restore to a folder

* Extract logic

* Remove type assertion

* betterer

* Cleanup

* i18n
pull/106916/head
Alex Khomenko 1 month ago committed by GitHub
parent 94fb519a2b
commit 8893b9a6eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .betterer.results
  2. 69
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  3. 56
      public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx
  4. 39
      public/app/features/search/service/sql.ts
  5. 45
      public/app/features/search/service/unified.ts
  6. 71
      public/app/features/search/service/utils.ts
  7. 7
      public/locales/en-US/grafana.json

@ -2788,13 +2788,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/search/service/unified.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/search/service/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/search/state/SearchStateManager.ts:5381": [
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "0"],
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "1"],

@ -9,6 +9,7 @@ import { folderAPIv1beta1 as folderAPI } from 'app/api/clients/folder/v1beta1';
import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { Resource, ResourceList } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } from 'app/features/dashboard/api/utils';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
@ -53,12 +54,7 @@ interface ImportOptions {
}
interface RestoreDashboardArgs {
dashboardUID: string;
targetFolderUID: string;
}
interface HardDeleteDashboardArgs {
dashboardUID: string;
dashboard: Resource<Dashboard | DashboardV2Spec>;
}
export interface ListFolderQueryArgs {
@ -419,42 +415,39 @@ export const browseDashboardsAPI = createApi({
},
}),
// restore a dashboard that got soft deleted
restoreDashboard: builder.mutation<void, RestoreDashboardArgs>({
query: ({ dashboardUID, targetFolderUID }) => ({
url: `/dashboards/uid/${dashboardUID}/trash`,
body: {
folderUid: targetFolderUID,
},
method: 'PATCH',
}),
// RTK wrapper for the dashboard API
listDeletedDashboards: builder.query<ResourceList<Dashboard | DashboardV2Spec>, void>({
queryFn: async () => {
try {
const api = getDashboardAPI();
const response = await api.listDeletedDashboards({});
return { data: response };
} catch (error) {
return handleRequestError(error);
}
},
}),
// permanently delete a dashboard. used in PermanentlyDeleteModal.
hardDeleteDashboard: builder.mutation<void, HardDeleteDashboardArgs>({
queryFn: async ({ dashboardUID }, _api, _extraOptions, baseQuery) => {
const response = await baseQuery({
url: `/dashboards/uid/${dashboardUID}/trash`,
method: 'DELETE',
showSuccessAlert: false,
});
// restore a dashboard that got deleted
restoreDashboard: builder.mutation<void, RestoreDashboardArgs>({
queryFn: async ({ dashboard }) => {
try {
const api = getDashboardAPI();
const response = await api.restoreDashboard(dashboard);
const name = response.spec.title;
// @ts-expect-error
const name = response?.data?.title;
if (name) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: [t('browse-dashboards.restore.success', 'Dashboard {{name}} restored', { name })],
});
}
if (name) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: [t('browse-dashboards.hard-delete.success', 'Dashboard {{name}} deleted', { name })],
});
return { data: undefined };
} catch (error) {
return handleRequestError(error);
}
return { data: undefined };
},
onQueryStarted: ({ dashboardUID }, { queryFulfilled, dispatch }) => {
queryFulfilled.then(() => {
dispatch(refreshParents([dashboardUID]));
});
},
}),
}),
@ -473,5 +466,5 @@ export const {
useSaveDashboardMutation,
useSaveFolderMutation,
useRestoreDashboardMutation,
useHardDeleteDashboardMutation,
useListDeletedDashboardsQuery,
} = browseDashboardsAPI;

@ -3,25 +3,24 @@ import { useMemo } from 'react';
import { 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 { useDispatch } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
import appEvents from '../../../core/app_events';
import { useDispatch } from '../../../types';
import { ShowModalReactEvent } from '../../../types/events';
import { useHardDeleteDashboardMutation, useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useListDeletedDashboardsQuery, useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager';
import { clearFolders, setAllSelection, useActionSelectionState } from '../state';
import { PermanentlyDeleteModal } from './PermanentlyDeleteModal';
import { RestoreModal } from './RestoreModal';
export function RecentlyDeletedActions() {
const dispatch = useDispatch();
const selectedItemsState = useActionSelectionState();
const [searchState, stateManager] = useRecentlyDeletedStateManager();
const deletedDashboards = useListDeletedDashboardsQuery();
const [restoreDashboard, { isLoading: isRestoreLoading }] = useRestoreDashboardMutation();
const [deleteDashboard, { isLoading: isDeleteLoading }] = useHardDeleteDashboardMutation();
const selectedDashboards = useMemo(() => {
return Object.entries(selectedItemsState.dashboard)
@ -34,7 +33,7 @@ export function RecentlyDeletedActions() {
for (const selectedDashboard of selectedDashboards) {
const index = searchState.result.view.fields.uid.values.findIndex((e) => e === selectedDashboard);
// SQLSearcher changes the location from empty string to 'general' for items with no parent
// SQLSearcher changes the location from empty string to 'general' for items with no parent,
// but the restore API doesn't work with 'general' folder UID, so we need to convert it back
// to an empty string
const location = searchState.result.view.fields.location.values[index];
@ -56,7 +55,18 @@ export function RecentlyDeletedActions() {
}
const promises = selectedDashboards.map((uid) => {
return restoreDashboard({ dashboardUID: uid, targetFolderUID: restoreTarget });
const dashboard = deletedDashboards.data?.items.find((d) => d.metadata.name === uid);
if (!dashboard) {
return Promise.resolve();
}
// Clone the dashboard to be able to edit the immutable data from the store
const copy = structuredClone(dashboard);
copy.metadata = {
...copy.metadata,
annotations: { ...copy.metadata?.annotations, [AnnoKeyFolder]: restoreTarget },
};
return restoreDashboard({ dashboard: copy });
});
await Promise.all(promises);
@ -78,13 +88,6 @@ export function RecentlyDeletedActions() {
onActionComplete();
};
const onDelete = async () => {
const promises = selectedDashboards.map((uid) => deleteDashboard({ dashboardUID: uid }));
await Promise.all(promises);
onActionComplete();
};
const showRestoreModal = () => {
reportInteraction('grafana_restore_clicked', {
item_counts: {
@ -104,32 +107,11 @@ export function RecentlyDeletedActions() {
);
};
const showDeleteModal = () => {
reportInteraction('grafana_delete_permanently_clicked', {
item_counts: {
dashboard: selectedDashboards.length,
},
});
appEvents.publish(
new ShowModalReactEvent({
component: PermanentlyDeleteModal,
props: {
selectedDashboards,
onConfirm: onDelete,
isLoading: isDeleteLoading,
},
})
);
};
return (
<Stack gap={1}>
<Button onClick={showRestoreModal} variant="secondary">
<Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans>
</Button>
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="recently-deleted.buttons.delete">Permanently delete</Trans>
</Button>
</Stack>
);
}

@ -2,13 +2,15 @@ import { DataFrame, DataFrameView, FieldType, getDisplayProcessor, SelectableVal
import { config } from '@grafana/runtime';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types';
import { isResourceList } from 'app/features/apiserver/guards';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardDataDTO, PermissionLevelString } from 'app/types';
import { DEFAULT_MAX_VALUES, GENERAL_FOLDER_UID, TYPE_KIND_MAP } from '../constants';
import { DashboardSearchHit, DashboardSearchItemType } from '../types';
import { DashboardQueryResult, GrafanaSearcher, LocationInfo, QueryResponse, SearchQuery, SortOptions } from './types';
import { replaceCurrentFolderQuery } from './utils';
import { replaceCurrentFolderQuery, resourceToSearchResult, searchHitsToDashboardSearchHits } from './utils';
interface APIQuery {
query?: string;
@ -116,22 +118,6 @@ export class SQLSearcher implements GrafanaSearcher {
// returns the appropriate sorting options
async getSortOptions(): Promise<SelectableValue[]> {
// {
// "sortOptions": [
// {
// "description": "Sort results in an alphabetically ascending order",
// "displayName": "Alphabetically (A–Z)",
// "meta": "",
// "name": "alpha-asc"
// },
// {
// "description": "Sort results in an alphabetically descending order",
// "displayName": "Alphabetically (Z–A)",
// "meta": "",
// "name": "alpha-desc"
// }
// ]
// }
const opts = await backendSrv.get<SortOptions>('/api/search/sorting');
return opts.sortOptions.map((v) => ({
value: v.name,
@ -146,7 +132,22 @@ export class SQLSearcher implements GrafanaSearcher {
}
async doAPIQuery(query: APIQuery): Promise<QueryResponse> {
const rsp = await backendSrv.get<DashboardSearchHit[]>('/api/search', query);
let rsp: DashboardSearchHit[];
if (query.deleted) {
// Deleted dashboards are fetched from a k8s API
const api = getDashboardAPI();
const deletedResponse = await api.listDeletedDashboards({});
if (isResourceList<DashboardDataDTO>(deletedResponse)) {
const searchHits = resourceToSearchResult(deletedResponse);
rsp = searchHitsToDashboardSearchHits(searchHits);
} else {
rsp = [];
}
} else {
rsp = await backendSrv.get<DashboardSearchHit[]>('/api/search', query);
}
// Field values (columnar)
const kind: string[] = [];

@ -3,10 +3,11 @@ import { isEmpty } from 'lodash';
import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getBackendSrv } from '@grafana/runtime';
import { getAPIBaseURL } from 'app/api/utils';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import kbn from 'app/core/utils/kbn';
import { getAPINamespace } from '../../../api/utils';
import { isResourceList } from 'app/features/apiserver/guards';
import { DashboardDataDTO } from 'app/types';
import {
DashboardQueryResult,
@ -16,13 +17,14 @@ import {
SearchQuery,
SearchResultMeta,
} from './types';
import { replaceCurrentFolderQuery } from './utils';
import { replaceCurrentFolderQuery, resourceToSearchResult } from './utils';
// The backend returns an empty frame with a special name to indicate that the indexing engine is being rebuilt,
// and that it can not serve any search requests. We are temporarily using the old SQL Search API as a fallback when that happens.
const loadingFrameName = 'Loading';
const searchURI = `apis/dashboard.grafana.app/v0alpha1/namespaces/${getAPINamespace()}/search`;
const baseURL = getAPIBaseURL('dashboard.grafana.app', 'v0alpha1');
const searchURI = `${baseURL}/search`;
export type SearchHit = {
resource: string; // dashboards | folders
@ -120,9 +122,20 @@ export class UnifiedSearcher implements GrafanaSearcher {
return this.fallbackSearcher.search(query);
}
const meta = first.meta?.custom || ({} as SearchResultMeta);
const customMeta = first.meta?.custom;
const meta: SearchResultMeta = {
count: customMeta?.count ?? first.length,
max_score: customMeta?.max_score ?? 1,
locationInfo: customMeta?.locationInfo ?? {},
sortBy: customMeta?.sortBy,
};
meta.locationInfo = await this.locationInfo;
// Update the DataFrame meta to point to the typed meta object
if (first.meta) {
first.meta.custom = meta;
}
// Set the field name to a better display name
if (meta.sortBy?.length) {
const field = first.fields.find((f) => f.name === meta.sortBy);
@ -165,10 +178,12 @@ export class UnifiedSearcher implements GrafanaSearcher {
view.dataFrame.length = length;
// Add all the location lookup info
const submeta = frame.meta?.custom as SearchResultMeta;
const submeta = frame.meta?.custom;
if (submeta?.locationInfo && meta) {
for (const [key, value] of Object.entries(submeta.locationInfo)) {
meta.locationInfo[key] = value;
// Merge locationInfo from submeta into meta
const subLocationInfo = submeta.locationInfo;
if (subLocationInfo && typeof subLocationInfo === 'object') {
Object.assign(meta.locationInfo, subLocationInfo);
}
}
}
@ -194,11 +209,16 @@ export class UnifiedSearcher implements GrafanaSearcher {
async fetchResponse(uri: string) {
const rsp = await getBackendSrv().get<SearchAPIResponse>(uri);
if (isResourceList<DashboardDataDTO>(rsp)) {
const hits = resourceToSearchResult(rsp);
const totalHits = rsp.items.length;
return { ...rsp, hits, totalHits };
}
const isFolderCacheStale = await this.isFolderCacheStale(rsp.hits);
if (!isFolderCacheStale) {
return rsp;
}
// sync the location info ( folders )
// sync the location info (folders)
this.locationInfo = loadLocationInfo();
// recheck for missing folders
const hasMissing = await this.isFolderCacheStale(rsp.hits);
@ -212,13 +232,14 @@ export class UnifiedSearcher implements GrafanaSearcher {
return { ...hit, location: 'general', folder: 'general' };
}
// this means user has permission to see this dashboard, but not the folder contents
// this means a user has permission to see this dashboard, but not the folder contents
if (locationInfo[hit.folder] === undefined) {
return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' };
}
return hit;
});
const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
return { ...rsp, hits, totalHits };
}
@ -266,6 +287,10 @@ export class UnifiedSearcher implements GrafanaSearcher {
// legacy support for filtering by dashboard uid
uri += '&' + query.uid.map((name) => `name=${encodeURIComponent(name)}`).join('&');
}
if (query.deleted) {
uri = `${getAPIBaseURL('dashboard.grafana.app', 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`;
}
return uri;
}

@ -3,9 +3,12 @@ import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils'
import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardViewItem, DashboardViewItemKind } from '../types';
import { DashboardDataDTO } from '../../../types';
import { AnnoKeyFolder, ResourceList } from '../../apiserver/types';
import { DashboardSearchHit, DashboardSearchItemType, DashboardViewItem, DashboardViewItemKind } from '../types';
import { DashboardQueryResult, SearchQuery, SearchResultMeta } from './types';
import { SearchHit } from './unified';
/** prepare the query replacing folder:current */
export async function replaceCurrentFolderQuery(query: SearchQuery): Promise<SearchQuery> {
@ -64,25 +67,6 @@ export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: bool
}
}
// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind;
// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind;
// if (kind === 'dashboard') {
// return 'apps';
// }
// if (item && isSharedWithMe(item.uid)) {
// return 'users-alt';
// }
// if (kind === 'folder') {
// return isOpen ? 'folder-open' : 'folder';
// }
// return 'question-circle';
// }
function parseKindString(kind: string): DashboardViewItemKind {
switch (kind) {
case 'dashboard':
@ -94,11 +78,16 @@ function parseKindString(kind: string): DashboardViewItemKind {
}
}
function isSearchResultMeta(obj: unknown): obj is SearchResultMeta {
return obj !== null && typeof obj === 'object' && 'locationInfo' in obj;
}
export function queryResultToViewItem(
item: DashboardQueryResult,
view?: DataFrameView<DashboardQueryResult>
): DashboardViewItem {
const meta = view?.dataFrame.meta?.custom as SearchResultMeta | undefined;
const customMeta = view?.dataFrame.meta?.custom;
const meta: SearchResultMeta | undefined = isSearchResultMeta(customMeta) ? customMeta : undefined;
const viewItem: DashboardViewItem = {
kind: parseKindString(item.kind),
@ -132,3 +121,43 @@ export function queryResultToViewItem(
return viewItem;
}
export function resourceToSearchResult(resource: ResourceList<DashboardDataDTO>): SearchHit[] {
return resource.items.map((item) => {
const hit = {
resource: 'dashboards',
name: item.metadata.name,
title: item.spec.title,
location: 'general',
folder: item?.metadata?.annotations?.[AnnoKeyFolder] ?? 'general',
tags: item.spec.tags || [],
field: {},
url: '',
};
if (!hit.folder) {
return { ...hit, location: 'general', folder: 'general' };
}
return hit;
});
}
export function searchHitsToDashboardSearchHits(searchHits: SearchHit[]): DashboardSearchHit[] {
return searchHits.map((hit) => {
const dashboardHit: DashboardSearchHit = {
type: hit.resource === 'folders' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB,
title: hit.title,
uid: hit.name, // k8s name is the uid
url: hit.url,
tags: hit.tags || [],
isDeleted: true, // All results from trash are deleted
sortMeta: 0, // Default value for deleted items
};
if (hit.folder && hit.folder !== 'general') {
dashboardHit.folderUid = hit.folder;
}
return dashboardHit;
});
}

@ -3267,9 +3267,6 @@
"search-placeholder": "Search folders",
"unknown-error": "Unknown error"
},
"hard-delete": {
"success": "Dashboard {{name}} deleted"
},
"manage-folder-nav": {
"alert-rules": "Alert rules",
"dashboards": "Dashboards",
@ -3307,6 +3304,9 @@
"clear": "Clear search and filters",
"text": "No results found for your query"
},
"restore": {
"success": "Dashboard {{name}} restored"
},
"text-this-repository-is-read-only": "If you have direct access to the target, copy the JSON and paste it there."
},
"canvas": {
@ -9750,7 +9750,6 @@
},
"recently-deleted": {
"buttons": {
"delete": "Permanently delete",
"restore": "Restore"
},
"page": {

Loading…
Cancel
Save