From eb7573828eaa41aadb1373afc8567609366a2f01 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:33:14 -0600 Subject: [PATCH] Query Library: Search and filter (#94038) * Search by query text, pagination * Support default filtering by active datasource; filter by datasource name; improve table display * Cleanup * Fix update and delete url paths * Fix test * Use Stack, remove uneccessary function wrapper * Notify when something is wrong with a row, add interaction tracking * i18n --- .../explore/QueryLibrary/QueryLibrary.tsx | 10 +- .../QueryLibraryAnalyticsEvents.ts | 6 + .../QueryLibrary/QueryTemplatesList.tsx | 162 ++++++++++++++++-- .../QueryDescriptionCell.tsx | 37 ++-- .../QueryTemplatesTable/index.tsx | 11 +- .../QueryLibrary/QueryTemplatesTable/types.ts | 2 + .../explore/QueryLibrary/utils/search.ts | 16 ++ .../explore/RichHistory/RichHistory.tsx | 19 +- .../RichHistoryQueriesTab.test.tsx | 2 + .../RichHistory/RichHistoryQueriesTab.tsx | 15 +- .../explore/spec/queryLibrary.test.tsx | 1 - .../app/features/query-library/api/factory.ts | 12 +- .../app/features/query-library/api/query.ts | 2 +- public/locales/en-US/grafana.json | 5 +- public/locales/pseudo-LOCALE/grafana.json | 5 +- 15 files changed, 242 insertions(+), 63 deletions(-) create mode 100644 public/app/features/explore/QueryLibrary/utils/search.ts diff --git a/public/app/features/explore/QueryLibrary/QueryLibrary.tsx b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx index d0e9a107664..9a296b41fbb 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibrary.tsx +++ b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx @@ -1,5 +1,11 @@ import { QueryTemplatesList } from './QueryTemplatesList'; -export function QueryLibrary() { - return ; +export interface QueryLibraryProps { + // List of active datasources to filter the query library by + // E.g in Explore the active datasources are the datasources that are currently selected in the query editor + activeDatasources?: string[]; +} + +export function QueryLibrary({ activeDatasources }: QueryLibraryProps) { + return ; } diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryAnalyticsEvents.ts b/public/app/features/explore/QueryLibrary/QueryLibraryAnalyticsEvents.ts index 018f1f914cb..8288f1d8f73 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibraryAnalyticsEvents.ts +++ b/public/app/features/explore/QueryLibrary/QueryLibraryAnalyticsEvents.ts @@ -48,3 +48,9 @@ export function queryLibraryTrackAddOrEditDescription() { item: 'add_or_edit_description', }); } + +export function queryLibraryTrackFilterDatasource() { + reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, { + item: 'filter_datasource', + }); +} diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx index eed3e100131..00a8e518d87 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx @@ -1,14 +1,110 @@ -import { EmptyState, Spinner } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { uniqBy } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; + +import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { getAppEvents, getDataSourceSrv } from '@grafana/runtime'; +import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2, Stack } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { createQueryText } from 'app/core/utils/richHistory'; import { useAllQueryTemplatesQuery } from 'app/features/query-library'; import { QueryTemplate } from 'app/features/query-library/types'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; +import { QueryLibraryProps } from './QueryLibrary'; +import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents'; import QueryTemplatesTable from './QueryTemplatesTable'; import { QueryTemplateRow } from './QueryTemplatesTable/types'; +import { searchQueryLibrary } from './utils/search'; -export function QueryTemplatesList() { +interface QueryTemplatesListProps extends QueryLibraryProps {} + +export function QueryTemplatesList(props: QueryTemplatesListProps) { const { data, isLoading, error } = useAllQueryTemplatesQuery(); + const [searchQuery, setSearchQuery] = useState(''); + const [datasourceFilters, setDatasourceFilters] = useState>>( + props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || [] + ); + + const [allQueryTemplateRows, setAllQueryTemplateRows] = useState([]); + const [isRowsLoading, setIsRowsLoading] = useState(true); + const styles = useStyles2(getStyles); + + useEffect(() => { + let shouldCancel = true; + + const fetchRows = async () => { + if (!data) { + setIsRowsLoading(false); + return; + } + + const rowsPromises = data.map(async (queryTemplate: QueryTemplate, index: number) => { + try { + const datasourceRef = queryTemplate.targets[0]?.datasource; + const datasourceApi = await getDataSourceSrv().get(datasourceRef); + const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; + const query = queryTemplate.targets[0]; + const queryText = createQueryText(query, datasourceApi); + const datasourceName = datasourceApi?.name || ''; + + return { + index: index.toString(), + uid: queryTemplate.uid, + datasourceName, + datasourceRef, + datasourceType, + createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, + query, + queryText, + description: queryTemplate.title, + user: queryTemplate.user, + }; + } catch (error) { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'query-library.query-template-get-error', + 'Error attempting to get query template from the library: {{error}}', + { error: JSON.stringify(error) } + ), + ], + }); + return { index: index.toString(), error }; + } + }); + + const results = await Promise.allSettled(rowsPromises); + const rows = results.filter((result) => result.status === 'fulfilled').map((result) => result.value); + + if (shouldCancel) { + setAllQueryTemplateRows(rows); + setIsRowsLoading(false); + } + }; + + fetchRows(); + + return () => { + shouldCancel = false; + }; + }, [data]); + + const queryTemplateRows = useMemo( + () => + searchQueryLibrary( + allQueryTemplateRows, + searchQuery, + datasourceFilters.map((f) => f.value || '') + ), + [allQueryTemplateRows, searchQuery, datasourceFilters] + ); + + const datasourceNames = useMemo(() => { + return uniqBy(allQueryTemplateRows, 'datasourceName').map((row) => row.datasourceName); + }, [allQueryTemplateRows]); if (error) { return ( @@ -18,7 +114,7 @@ export function QueryTemplatesList() { ); } - if (isLoading) { + if (isLoading || isRowsLoading) { return ; } @@ -34,20 +130,48 @@ export function QueryTemplatesList() { ); } - const queryTemplateRows: QueryTemplateRow[] = data.map((queryTemplate: QueryTemplate, index: number) => { - const datasourceRef = queryTemplate.targets[0]?.datasource; - const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; - return { - index: index.toString(), - uid: queryTemplate.uid, - datasourceRef, - datasourceType, - createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, - query: queryTemplate.targets[0], - description: queryTemplate.title, - user: queryTemplate.user, - }; - }); - - return ; + return ( + <> + + setSearchQuery(query)} + escapeRegex={false} + /> + + Datasource name(s): + + { + setDatasourceFilters(items); + actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); + }} + value={datasourceFilters} + options={datasourceNames.map((r) => { + return { value: r, label: r }; + })} + placeholder={'Filter queries for data sources(s)'} + aria-label={'Filter queries for data sources(s)'} + /> + + + + ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + searchInput: css({ + maxWidth: theme.spacing(55), + }), + multiSelect: css({ + maxWidth: theme.spacing(65), + }), + label: css({ + marginLeft: theme.spacing(1), + border: `1px solid ${theme.colors.secondary.border}`, + }), +}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx index 4dce16f9ced..f19e95f14c8 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx @@ -1,8 +1,8 @@ -import { cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { CellProps } from 'react-table'; -import { Spinner, Tooltip } from '@grafana/ui'; -import { createQueryText } from 'app/core/utils/richHistory'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Spinner, Tooltip, useStyles2 } from '@grafana/ui'; import { useDatasource } from '../utils/useDatasource'; @@ -11,7 +11,8 @@ import { QueryTemplateRow } from './types'; export function QueryDescriptionCell(props: CellProps) { const datasourceApi = useDatasource(props.row.original.datasourceRef); - const styles = useQueryLibraryListStyles(); + const queryLibraryListStyles = useQueryLibraryListStyles(); + const styles = useStyles2(getStyles); if (!datasourceApi) { return ; @@ -20,25 +21,35 @@ export function QueryDescriptionCell(props: CellProps) { if (!props.row.original.query) { return
No queries
; } - const query = props.row.original.query; - const queryDisplayText = createQueryText(query, datasourceApi); + const queryDisplayText = props.row.original.queryText; const description = props.row.original.description; - const dsName = datasourceApi?.name || ''; + const dsName = props.row.original.datasourceName; return ( -
-

+

+

{datasourceApi?.meta.info.description} {dsName}

- -

{queryDisplayText}

+ +

+ {queryDisplayText} +

-

{description}

+

{description}

); } + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + maxWidth: theme.spacing(60), + }), + queryDisplayText: css({ + backgroundColor: theme.colors.background.canvas, + }), +}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx index c95398b0956..b7809baf026 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/css'; import { SortByFn } from 'react-table'; import { Column, InteractiveTable } from '@grafana/ui'; @@ -30,14 +29,6 @@ const columns: Array> = [ }, ]; -const styles = { - tableWithSpacing: css({ - 'th:first-child': { - width: '50%', - }, - }), -}; - type Props = { queryTemplateRows: QueryTemplateRow[]; }; @@ -45,10 +36,10 @@ type Props = { export default function QueryTemplatesTable({ queryTemplateRows }: Props) { return ( row.index} + pageSize={20} /> ); } diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts index b9ca932e980..eae57422d0c 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts @@ -2,8 +2,10 @@ import { DataQuery, DataSourceRef } from '@grafana/schema'; export type QueryTemplateRow = { index: string; + datasourceName?: string; description?: string; query?: DataQuery; + queryText?: string; datasourceRef?: DataSourceRef | null; datasourceType?: string; createdAtTimestamp?: number; diff --git a/public/app/features/explore/QueryLibrary/utils/search.ts b/public/app/features/explore/QueryLibrary/utils/search.ts new file mode 100644 index 00000000000..6ad6e277322 --- /dev/null +++ b/public/app/features/explore/QueryLibrary/utils/search.ts @@ -0,0 +1,16 @@ +import { QueryTemplateRow } from '../QueryTemplatesTable/types'; + +export const searchQueryLibrary = (queryLibrary: QueryTemplateRow[], query: string, filter: string[]) => { + const result = queryLibrary.filter((item) => { + const matchesFilter = + filter.length === 0 || filter.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase())); + return ( + (item.datasourceName?.toLowerCase().includes(query.toLowerCase()) || + item.datasourceType?.toLowerCase().includes(query.toLowerCase()) || + item.description?.toLowerCase().includes(query.toLowerCase()) || + item.queryText?.toLowerCase().includes(query.toLowerCase())) && + matchesFilter + ); + }); + return result; +}; diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index 063029baaea..cb00212d199 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -5,13 +5,20 @@ import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { TabbedContainer, TabConfig } from '@grafana/ui'; import { t } from 'app/core/internationalization'; -import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory'; +import { + SortOrder, + RichHistorySearchFilters, + RichHistorySettings, + createDatasourcesList, +} from 'app/core/utils/richHistory'; +import { useSelector } from 'app/types'; import { RichHistoryQuery } from 'app/types/explore'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; import { Tabs, useQueriesDrawerContext } from '../QueriesDrawer/QueriesDrawerContext'; import { i18n } from '../QueriesDrawer/utils'; import { QueryLibrary } from '../QueryLibrary/QueryLibrary'; +import { selectExploreDSMaps } from '../state/selectors'; import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; import { RichHistorySettingsTab } from './RichHistorySettingsTab'; @@ -83,10 +90,16 @@ export function RichHistory(props: RichHistoryProps) { setLoading(false); }, [richHistory]); + const exploreActiveDS = useSelector(selectExploreDSMaps); + const listOfDatasources = createDatasourcesList(); + const activeDatasources = exploreActiveDS.dsToExplore + .map((eDs) => listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name) + .filter((name): name is string => !!name); + const QueryLibraryTab: TabConfig = { label: i18n.queryLibrary, value: Tabs.QueryLibrary, - content: , + content: , icon: 'book', }; @@ -104,6 +117,8 @@ export function RichHistory(props: RichHistoryProps) { richHistorySettings={props.richHistorySettings} richHistorySearchFilters={props.richHistorySearchFilters} height={height} + activeDatasources={activeDatasources} + listOfDatasources={listOfDatasources} /> ), icon: 'history', diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx index a996a5b5b89..7b32ea7bf26 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx @@ -20,6 +20,8 @@ const setup = (propOverrides?: Partial) => { updateFilters: jest.fn(), clearRichHistoryResults: jest.fn(), loadMoreRichHistory: jest.fn(), + activeDatasources: ['test-ds'], + listOfDatasources: [{ name: 'test-ds', uid: 'test-123' }], richHistorySearchFilters: { search: '', sortOrder: SortOrder.Descending, diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index 5b644563fe0..5c8a3636e9b 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -7,18 +7,14 @@ import { config, getDataSourceSrv } from '@grafana/runtime'; import { Button, FilterInput, MultiSelect, RangeSlider, Select, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { - createDatasourcesList, mapNumbertoTimeInSlider, mapQueriesToHeadings, SortOrder, RichHistorySearchFilters, RichHistorySettings, } from 'app/core/utils/richHistory'; -import { useSelector } from 'app/types'; import { RichHistoryQuery } from 'app/types/explore'; -import { selectExploreDSMaps } from '../state/selectors'; - import { getSortOrderOptions } from './RichHistory'; import RichHistoryCard from './RichHistoryCard'; @@ -31,6 +27,8 @@ export interface RichHistoryQueriesTabProps { loadMoreRichHistory: () => void; richHistorySettings: RichHistorySettings; richHistorySearchFilters?: RichHistorySearchFilters; + activeDatasources: string[]; + listOfDatasources: Array<{ name: string; uid: string }>; height: number; } @@ -125,21 +123,18 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) { loadMoreRichHistory, richHistorySettings, height, + listOfDatasources, + activeDatasources, } = props; - const exploreActiveDS = useSelector(selectExploreDSMaps); const styles = useStyles2(getStyles, height); - const listOfDatasources = createDatasourcesList(); - // on mount, set filter to either active datasource or all datasources useEffect(() => { const datasourceFilters = !richHistorySettings.activeDatasourcesOnly && richHistorySettings.lastUsedDatasourceFilters ? richHistorySettings.lastUsedDatasourceFilters - : exploreActiveDS.dsToExplore - .map((eDs) => listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name) - .filter((name): name is string => !!name); + : activeDatasources; const filters: RichHistorySearchFilters = { search: '', sortOrder: SortOrder.Descending, diff --git a/public/app/features/explore/spec/queryLibrary.test.tsx b/public/app/features/explore/spec/queryLibrary.test.tsx index 7b00a22ee21..d69cc001f16 100644 --- a/public/app/features/explore/spec/queryLibrary.test.tsx +++ b/public/app/features/explore/spec/queryLibrary.test.tsx @@ -105,7 +105,6 @@ describe('QueryLibrary', () => { await waitForExplore(); await openQueryLibrary(); await assertQueryLibraryTemplateExists('loki', 'Loki Query Template'); - await assertQueryLibraryTemplateExists('elastic', 'Elastic Query Template'); }); it('Shows add to query library button only when the toggle is enabled', async () => { diff --git a/public/app/features/query-library/api/factory.ts b/public/app/features/query-library/api/factory.ts index d67b8666aac..5ee9b021c59 100644 --- a/public/app/features/query-library/api/factory.ts +++ b/public/app/features/query-library/api/factory.ts @@ -5,12 +5,18 @@ import { AddQueryTemplateCommand, DeleteQueryTemplateCommand, EditQueryTemplateC import { convertAddQueryTemplateCommandToDataQuerySpec, convertDataQueryResponseToQueryTemplates } from './mappers'; import { baseQuery } from './query'; +// Currently, we are loading all query templates +// Organizations can have maximum of 1000 query templates +const GET_LIMIT = 1000; + export const queryLibraryApi = createApi({ baseQuery, tagTypes: ['QueryTemplatesList'], endpoints: (builder) => ({ allQueryTemplates: builder.query({ - query: () => ({}), + query: () => ({ + url: `?limit=${GET_LIMIT}`, + }), transformResponse: convertDataQueryResponseToQueryTemplates, providesTags: ['QueryTemplatesList'], }), @@ -23,14 +29,14 @@ export const queryLibraryApi = createApi({ }), deleteQueryTemplate: builder.mutation({ query: ({ uid }) => ({ - url: `${uid}`, + url: `/${uid}`, method: 'DELETE', }), invalidatesTags: ['QueryTemplatesList'], }), editQueryTemplate: builder.mutation({ query: (editQueryTemplateCommand) => ({ - url: `${editQueryTemplateCommand.uid}`, + url: `/${editQueryTemplateCommand.uid}`, method: 'PATCH', headers: { 'Content-Type': 'application/merge-patch+json', diff --git a/public/app/features/query-library/api/query.ts b/public/app/features/query-library/api/query.ts index eb730daf594..89b72fb3210 100644 --- a/public/app/features/query-library/api/query.ts +++ b/public/app/features/query-library/api/query.ts @@ -23,7 +23,7 @@ export enum QueryTemplateKinds { * * @alpha */ -export const BASE_URL = `/apis/${API_VERSION}/namespaces/${config.namespace}/querytemplates/`; +export const BASE_URL = `/apis/${API_VERSION}/namespaces/${config.namespace}/querytemplates`; // URL is optional for these requests interface QueryLibraryBackendRequest extends Pick { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2d48513aa12..38c42b79850 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2198,7 +2198,10 @@ } }, "query-library": { - "delete-query-button": "Delete query" + "datasource-names": "Datasource name(s):", + "delete-query-button": "Delete query", + "query-template-get-error": "Error attempting to get query template from the library: {{error}}", + "search": "Search by data source, query content or description" }, "query-operation": { "header": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b9816e154bf..77dcd640032 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2198,7 +2198,10 @@ } }, "query-library": { - "delete-query-button": "Đęľęŧę qūęřy" + "datasource-names": "Đäŧäşőūřčę ʼnämę(ş):", + "delete-query-button": "Đęľęŧę qūęřy", + "query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ qūęřy ŧęmpľäŧę ƒřőm ŧĥę ľįþřäřy: {{error}}", + "search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn" }, "query-operation": { "header": {