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
This commit is contained in:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user