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
This commit is contained in:
Haris Rozajac
2024-10-02 11:33:14 -06:00
committed by GitHub
parent c38eab33f5
commit eb7573828e
15 changed files with 242 additions and 63 deletions
@@ -1,5 +1,11 @@
import { QueryTemplatesList } from './QueryTemplatesList';
export function QueryLibrary() {
return <QueryTemplatesList />;
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 <QueryTemplatesList activeDatasources={activeDatasources} />;
}
@@ -48,3 +48,9 @@ export function queryLibraryTrackAddOrEditDescription() {
item: 'add_or_edit_description',
});
}
export function queryLibraryTrackFilterDatasource() {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'filter_datasource',
});
}
@@ -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<Array<SelectableValue<string>>>(
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
);
const [allQueryTemplateRows, setAllQueryTemplateRows] = useState<QueryTemplateRow[]>([]);
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 <Spinner />;
}
@@ -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 <QueryTemplatesTable queryTemplateRows={queryTemplateRows} />;
return (
<>
<Stack gap={0.5}>
<FilterInput
className={styles.searchInput}
placeholder={t('query-library.search', 'Search by data source, query content or description')}
aria-label={t('query-library.search', 'Search by data source, query content or description')}
value={searchQuery}
onChange={(query) => setSearchQuery(query)}
escapeRegex={false}
/>
<InlineLabel className={styles.label} width="auto">
<Trans i18nKey="query-library.datasource-names">Datasource name(s):</Trans>
</InlineLabel>
<MultiSelect
className={styles.multiSelect}
onChange={(items, actionMeta) => {
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)'}
/>
</Stack>
<QueryTemplatesTable queryTemplateRows={queryTemplateRows} />
</>
);
}
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}`,
}),
});
@@ -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<QueryTemplateRow>) {
const datasourceApi = useDatasource(props.row.original.datasourceRef);
const styles = useQueryLibraryListStyles();
const queryLibraryListStyles = useQueryLibraryListStyles();
const styles = useStyles2(getStyles);
if (!datasourceApi) {
return <Spinner />;
@@ -20,25 +21,35 @@ export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) {
if (!props.row.original.query) {
return <div>No queries</div>;
}
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 (
<div aria-label={`Query template for ${dsName}: ${description}`}>
<p className={styles.header}>
<div className={styles.container} aria-label={`Query template for ${dsName}: ${description}`}>
<p className={queryLibraryListStyles.header}>
<img
className={styles.logo}
className={queryLibraryListStyles.logo}
src={datasourceApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'}
alt={datasourceApi?.meta.info.description}
/>
{dsName}
</p>
<Tooltip content={queryDisplayText} placement="bottom-start">
<p className={cx(styles.mainText, styles.singleLine)}>{queryDisplayText}</p>
<Tooltip content={queryDisplayText ?? ''} placement="bottom-start">
<p className={cx(queryLibraryListStyles.mainText, queryLibraryListStyles.singleLine, styles.queryDisplayText)}>
{queryDisplayText}
</p>
</Tooltip>
<p className={cx(styles.otherText, styles.singleLine)}>{description}</p>
<p className={cx(queryLibraryListStyles.otherText, queryLibraryListStyles.singleLine)}>{description}</p>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
maxWidth: theme.spacing(60),
}),
queryDisplayText: css({
backgroundColor: theme.colors.background.canvas,
}),
});
@@ -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<Column<QueryTemplateRow>> = [
},
];
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 (
<InteractiveTable
className={styles.tableWithSpacing}
columns={columns}
data={queryTemplateRows}
getRowId={(row: { index: string }) => row.index}
pageSize={20}
/>
);
}
@@ -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;
@@ -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;
};
@@ -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: <QueryLibrary />,
content: <QueryLibrary activeDatasources={activeDatasources} />,
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',
@@ -20,6 +20,8 @@ const setup = (propOverrides?: Partial<RichHistoryQueriesTabProps>) => {
updateFilters: jest.fn(),
clearRichHistoryResults: jest.fn(),
loadMoreRichHistory: jest.fn(),
activeDatasources: ['test-ds'],
listOfDatasources: [{ name: 'test-ds', uid: 'test-123' }],
richHistorySearchFilters: {
search: '',
sortOrder: SortOrder.Descending,
@@ -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,
@@ -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 () => {
@@ -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<QueryTemplate[], void>({
query: () => ({}),
query: () => ({
url: `?limit=${GET_LIMIT}`,
}),
transformResponse: convertDataQueryResponseToQueryTemplates,
providesTags: ['QueryTemplatesList'],
}),
@@ -23,14 +29,14 @@ export const queryLibraryApi = createApi({
}),
deleteQueryTemplate: builder.mutation<void, DeleteQueryTemplateCommand>({
query: ({ uid }) => ({
url: `${uid}`,
url: `/${uid}`,
method: 'DELETE',
}),
invalidatesTags: ['QueryTemplatesList'],
}),
editQueryTemplate: builder.mutation<void, EditQueryTemplateCommand>({
query: (editQueryTemplateCommand) => ({
url: `${editQueryTemplateCommand.uid}`,
url: `/${editQueryTemplateCommand.uid}`,
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
@@ -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<BackendSrvRequest, 'data' | 'method'> {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {