diff --git a/.betterer.results b/.betterer.results index 4f50207e279..ff36ae089fe 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 55da4c9ba10..74dd25ccef1 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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; } export interface ListFolderQueryArgs { @@ -419,42 +415,39 @@ export const browseDashboardsAPI = createApi({ }, }), - // restore a dashboard that got soft deleted - restoreDashboard: builder.mutation({ - query: ({ dashboardUID, targetFolderUID }) => ({ - url: `/dashboards/uid/${dashboardUID}/trash`, - body: { - folderUid: targetFolderUID, - }, - method: 'PATCH', - }), + // RTK wrapper for the dashboard API + listDeletedDashboards: builder.query, 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({ - 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({ + 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; diff --git a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx index b05ee5a4a48..9471a753838 100644 --- a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx @@ -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 ( - ); } diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 1a169ba568d..710462fcdf2 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -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 { - // { - // "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('/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 { - const rsp = await backendSrv.get('/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(deletedResponse)) { + const searchHits = resourceToSearchResult(deletedResponse); + rsp = searchHitsToDashboardSearchHits(searchHits); + } else { + rsp = []; + } + } else { + rsp = await backendSrv.get('/api/search', query); + } // Field values (columnar) const kind: string[] = []; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 834698a5bcf..ab7606d14af 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -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(uri); + if (isResourceList(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; } diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index 2bd9f900360..3371f08d3f3 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -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 { @@ -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 ): 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): 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; + }); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 456c2493fe3..a83c42b9ddb 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {