Restore dashboards: Add filters and search (#106994)

* Restore dashboards: Enable search and filtering

* Remove sorting

* Configurable sort

* Move cache to a separate file

* Get tags

* Reset cache on delete

* Use store

* Add sort

* Use fuzzyFind for search

* Move fuzzy search to grafana/data

* Move @leeoniya/ufuzzy package

* Use the new util

* Improve sort

* Error handling
pull/107251/head^2
Alex Khomenko 3 weeks ago committed by GitHub
parent 30e72ca774
commit ecb93ed7f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .betterer.results
  2. 2
      public/app/features/browse-dashboards/RecentlyDeletedPage.tsx
  3. 48
      public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.ts
  4. 2
      public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx
  5. 58
      public/app/features/search/service/deletedDashboardsCache.ts
  6. 19
      public/app/features/search/service/sql.ts
  7. 20
      public/app/features/search/service/unified.ts
  8. 37
      public/app/features/search/service/utils.ts
  9. 8
      public/locales/en-US/grafana.json

@ -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"]
],

@ -59,7 +59,7 @@ const RecentlyDeletedPage = memo(() => {
<ActionRow
state={searchState}
getTagOptions={stateManager.getTagOptions}
getSortOptions={getGrafanaSearcher().getSortOptions}
getSortOptions={stateManager.getSortOptions}
sortPlaceholder={getGrafanaSearcher().sortPlaceholder}
onLayoutChange={stateManager.onLayoutChange}
onSortChange={stateManager.onSortChange}

@ -1,14 +1,18 @@
import { SelectableValue, store } from '@grafana/data';
import { t } from '@grafana/i18n';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { SEARCH_SELECTED_SORT } from 'app/features/search/constants';
import { SearchState } from 'app/features/search/types';
import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache';
import { initialState, SearchStateManager } from '../../search/state/SearchStateManager';
// Subclass SearchStateMananger to customise the setStateAndDoSearch behaviour.
// Subclass SearchStateManager to customize the setStateAndDoSearch behavior.
// We want to clear the search results when the user clears any search input
// to trigger the skeleton state.
export class TrashStateManager extends SearchStateManager {
setStateAndDoSearch(state: Partial<SearchState>) {
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<TermCount[]> => {
try {
const deletedHits = await deletedDashboardsCache.get();
const tagCounts = new Map<string, number>();
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<SelectableValue[]> => {
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;

@ -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();
};

@ -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<SearchHit[]> | null = null;
async get(): Promise<SearchHit[]> {
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<SearchHit[]> {
try {
const api = getDashboardAPI();
const deletedResponse = await api.listDeletedDashboards({ limit: 1000 });
if (isResourceList<DashboardDataDTO>(deletedResponse)) {
return resourceToSearchResult(deletedResponse);
}
return [];
} catch (error) {
console.error('Failed to fetch deleted dashboards:', error);
return [];
}
}
}
export const deletedDashboardsCache = new DeletedDashboardsCache();

@ -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<DashboardDataDTO>(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<DashboardSearchHit[]>('/api/search', query);
}

@ -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<QueryResponse> {
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<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;

@ -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<DashboardDataDTO>)
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;
}

@ -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": {

Loading…
Cancel
Save