diff --git a/.betterer.results b/.betterer.results index 67d05506a29..82da11f2cc6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1493,10 +1493,6 @@ exports[`better eslint`] = { "public/app/features/auth-config/utils/data.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.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"] - ], "public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], diff --git a/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx b/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx index 25d5c0d8f5f..3e6a25cfadf 100644 --- a/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx +++ b/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx @@ -59,7 +59,7 @@ const RecentlyDeletedPage = memo(() => { ) { - const sort = state.sort || this.state.sort || localStorage.getItem(SEARCH_SELECTED_SORT) || undefined; + const sort = state.sort || this.state.sort || store.get(SEARCH_SELECTED_SORT) || undefined; const query = state.query ?? this.state.query; const tags = state.tag ?? this.state.tag; @@ -39,6 +43,46 @@ export class TrashStateManager extends SearchStateManager { this.doSearchWithDebounce(); } } + + // Get tags from deleted dashboards cache + getTagOptions = async (): Promise => { + try { + const deletedHits = await deletedDashboardsCache.get(); + const tagCounts = new Map(); + + deletedHits.forEach((hit) => { + hit.tags.forEach((tag) => { + if (tag) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + } + }); + }); + + const termCounts: TermCount[] = Array.from(tagCounts.entries()).map(([term, count]) => ({ + term, + count, + })); + + return termCounts.sort((a, b) => b.count - a.count); + } catch (error) { + console.error('Failed to get tags from deleted dashboards:', error); + return []; + } + }; + + // Only alphabetical sorting is supported for deleted dashboards + getSortOptions = async (): Promise => { + return Promise.resolve([ + { + label: t('browse-dashboards.trash-state-manager.label.alphabetically-az', 'Alphabetically (A–Z)'), + value: 'alpha-asc', + }, + { + label: t('browse-dashboards.trash-state-manager.label.alphabetically-za', 'Alphabetically (Z–A)'), + value: 'alpha-desc', + }, + ]); + }; } let recentlyDeletedStateManager: TrashStateManager; diff --git a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx index 9471a753838..6f553f478b7 100644 --- a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx @@ -9,6 +9,7 @@ import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { useDispatch } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; +import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache'; import { useListDeletedDashboardsQuery, useRestoreDashboardMutation } from '../api/browseDashboardsAPI'; import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager'; import { clearFolders, setAllSelection, useActionSelectionState } from '../state'; @@ -45,6 +46,7 @@ export function RecentlyDeletedActions() { const onActionComplete = () => { dispatch(setAllSelection({ isSelected: false, folderUID: undefined })); + deletedDashboardsCache.clear(); stateManager.doSearchWithDebounce(); }; diff --git a/public/app/features/search/service/deletedDashboardsCache.ts b/public/app/features/search/service/deletedDashboardsCache.ts new file mode 100644 index 00000000000..cab6774b97b --- /dev/null +++ b/public/app/features/search/service/deletedDashboardsCache.ts @@ -0,0 +1,58 @@ +import { isResourceList } from 'app/features/apiserver/guards'; +import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; + +import { DashboardDataDTO } from '../../../types'; + +import { SearchHit } from './unified'; +import { resourceToSearchResult } from './utils'; + +/** + * Store deleted dashboards in the cache to avoid multiple calls to the API. + */ +class DeletedDashboardsCache { + private cache: SearchHit[] | null = null; + private promise: Promise | null = null; + + async get(): Promise { + if (this.cache !== null) { + return this.cache; + } + + if (this.promise !== null) { + return this.promise; + } + + this.promise = this.fetch(); + + try { + this.cache = await this.promise; + return this.cache; + } catch (error) { + this.promise = null; + throw error; + } + } + + clear(): void { + this.cache = null; + this.promise = null; + } + + private async fetch(): Promise { + try { + const api = getDashboardAPI(); + const deletedResponse = await api.listDeletedDashboards({ limit: 1000 }); + + if (isResourceList(deletedResponse)) { + return resourceToSearchResult(deletedResponse); + } + + return []; + } catch (error) { + console.error('Failed to fetch deleted dashboards:', error); + return []; + } + } +} + +export const deletedDashboardsCache = new DeletedDashboardsCache(); diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 710462fcdf2..d8f468ee774 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -2,15 +2,14 @@ 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 { isResourceList } from 'app/features/apiserver/guards'; -import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; -import { DashboardDataDTO, PermissionLevelString } from 'app/types'; +import { PermissionLevelString } from 'app/types'; import { DEFAULT_MAX_VALUES, GENERAL_FOLDER_UID, TYPE_KIND_MAP } from '../constants'; import { DashboardSearchHit, DashboardSearchItemType } from '../types'; +import { deletedDashboardsCache } from './deletedDashboardsCache'; import { DashboardQueryResult, GrafanaSearcher, LocationInfo, QueryResponse, SearchQuery, SortOptions } from './types'; -import { replaceCurrentFolderQuery, resourceToSearchResult, searchHitsToDashboardSearchHits } from './utils'; +import { filterSearchResults, replaceCurrentFolderQuery, searchHitsToDashboardSearchHits } from './utils'; interface APIQuery { query?: string; @@ -135,16 +134,8 @@ export class SQLSearcher implements GrafanaSearcher { 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 = []; - } + const allDeletedHits = await deletedDashboardsCache.get(); + rsp = searchHitsToDashboardSearchHits(filterSearchResults(allDeletedHits, query)); } else { rsp = await backendSrv.get('/api/search', query); } diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index ab7606d14af..a3881b09acc 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -6,9 +6,8 @@ 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 { isResourceList } from 'app/features/apiserver/guards'; -import { DashboardDataDTO } from 'app/types'; +import { deletedDashboardsCache } from './deletedDashboardsCache'; import { DashboardQueryResult, GrafanaSearcher, @@ -17,7 +16,7 @@ import { SearchQuery, SearchResultMeta, } from './types'; -import { replaceCurrentFolderQuery, resourceToSearchResult } from './utils'; +import { replaceCurrentFolderQuery, filterSearchResults } 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. @@ -115,8 +114,16 @@ export class UnifiedSearcher implements GrafanaSearcher { async doSearchQuery(query: SearchQuery): Promise { const uri = await this.newRequest(query); - const rsp = await this.fetchResponse(uri); + let rsp: SearchAPIResponse; + + if (query.deleted) { + const data = await deletedDashboardsCache.get(); + const results = filterSearchResults(data, query); + rsp = { hits: results, totalHits: results.length }; + } else { + rsp = await this.fetchResponse(uri); + } const first = toDashboardResults(rsp, query.sort ?? ''); if (first.name === loadingFrameName) { return this.fallbackSearcher.search(query); @@ -209,11 +216,6 @@ 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; diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index 3371f08d3f3..94fb1e7d345 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -1,4 +1,4 @@ -import { DataFrameView, IconName } from '@grafana/data'; +import { DataFrameView, IconName, fuzzySearch } from '@grafana/data'; 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'; @@ -127,10 +127,10 @@ export function resourceToSearchResult(resource: ResourceList) const hit = { resource: 'dashboards', name: item.metadata.name, - title: item.spec.title, + title: item.spec?.title, location: 'general', folder: item?.metadata?.annotations?.[AnnoKeyFolder] ?? 'general', - tags: item.spec.tags || [], + tags: item.spec?.tags || [], field: {}, url: '', }; @@ -161,3 +161,34 @@ export function searchHitsToDashboardSearchHits(searchHits: SearchHit[]): Dashbo return dashboardHit; }); } + +/** + * Filters search results based on query parameters + * This is used when backend filtering is not available (e.g., for deleted dashboards) + * Supports fuzzy search for tags and titles and alphabetical sorting + */ +export function filterSearchResults( + results: SearchHit[], + query: { + query?: string; + tag?: string[]; + sort?: string; + } +): SearchHit[] { + let filtered = results; + + if ((query.query && query.query.trim() !== '' && query.query !== '*') || (query.tag && query.tag.length > 0)) { + const searchString = query.query || query.tag?.join(',') || ''; + const haystack = results.map((hit) => `${hit.title},${hit.tags.join(',')}`); + const indices = fuzzySearch(haystack, searchString); + filtered = indices.map((index) => results[index]); + } + + if (query.sort) { + const collator = new Intl.Collator(); + const mult = query.sort === 'alpha-desc' ? -1 : 1; + filtered.sort((a, b) => mult * collator.compare(a.title, b.title)); + } + + return filtered; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9dee0fe9b47..c4b99447c32 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3480,7 +3480,13 @@ "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." + "text-this-repository-is-read-only": "If you have direct access to the target, copy the JSON and paste it there.", + "trash-state-manager": { + "label": { + "alphabetically-az": "Alphabetically (A–Z)", + "alphabetically-za": "Alphabetically (Z–A)" + } + } }, "candlestick": { "additional-fields-options": {