Explore: List query templates (#86897)

* Create basic feature toggle

* Rename context to reflect it contains query history and query library

* Update icons and variants

* Rename hooks

* Update tests

* Fix mock

* Add tracking

* Turn button into a toggle

* Make dropdown active as well

This is required to have better UI and an indication of selected state in split view

* Update Query Library icon

This is to make it consistent with the toolbar button

* Hide query history button when query library is available

This is to avoid confusing UX with 2 button triggering the drawer but with slightly different behavior

* Make the drawer bigger for query library

To avoid confusion for current users and test it internally a bit more it's behind a feature toggle. Bigger drawer may obstruct the view and add more friction in the UX.

* Fix tests

The test was failing because queryLibraryAvailable was set to true for tests. This change makes it more explicit what use case is being tested

* Remove active state underline from the dropdown

* Add basic types and api methods

This is just moved from the app. To be cleaned up and refactored later.

* Move API utils from Query Library app to Grafana packages

* Move API utils from Query Library app to Grafana packages

* Move API utils from Query Library app to Grafana packages

* Add basic table for query templates

* Add sorting

* Style cells

* Style table cells

* Allow closing Query Library drawer from the toolbar

* Remove Private Query toggle

It will be moved to the kebab

* Add empty state

* Remove variables detection for now

Just to simplify the PR, it's not needed for Explore yet.

* Simplify getting useDatasource.tsx

* Rename cell

* Move QueryTemplatesTable to a separate folder

* Use RTK Query to get list of query templates

* Clean up query templates table

* Simplify useDatasource hook

* Add a test

* Retrigger the build

* Remove unused code

* Small clean up

* Update import

* Add reduxjs/toolkit as a peer dependecy

* Revert "Add reduxjs/toolkit as a peer dependecy"

This reverts commit aa9da6e442.

* Update import

* Add reduxjs/toolkit as a peer dependecy

* Revert "Add reduxjs/toolkit as a peer dependecy"

This reverts commit 2e68a62ab6.

* Add @reduxjs/toolkit as peer dependency

* Add @reduxjs/toolkit as peer dependecy

* Move reactjs/toolkit to dev dependecies

* Minor clean up and use react-redux as a peer dependency

* Move query library code to core features

* Update code owners

* Update export

* Update exports

* Use Redux store instead of APIProvider

* Await for query templates to show during the test

* Add more explicit docs that the feature is experimental

---------

Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
Piotr Jamróz
2024-05-14 10:05:39 +02:00
committed by GitHub
parent 6b1a662f6b
commit fd218edca4
28 changed files with 622 additions and 4 deletions

View File

@@ -28,6 +28,7 @@ import usersReducers from 'app/features/users/state/reducers';
import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
import { alertingApi } from '../../features/alerting/unified/api/alertingApi';
import { queryLibraryApi } from '../../features/query-library/api/factory';
import { cleanUpAction } from '../actions/cleanUp';
const rootReducers = {
@@ -57,6 +58,7 @@ const rootReducers = {
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
[cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer,
[queryLibraryApi.reducerPath]: queryLibraryApi.reducer,
};
const addedReducers = {};

View File

@@ -40,6 +40,7 @@ export function QueriesDrawerDropdown({ variant }: Props) {
icon="book"
variant={drawerOpened ? 'active' : 'canvas'}
onClick={() => setDrawerOpened(!drawerOpened)}
aria-label={selectedTab}
>
{variant === 'full' ? selectedTab : undefined}
</ToolbarButton>

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { QueryTemplatesList } from './QueryTemplatesList';
export function QueryLibrary() {
return <QueryTemplatesList />;
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { EmptyState, Spinner } from '@grafana/ui';
import { useAllQueryTemplatesQuery } from 'app/features/query-library';
import { QueryTemplate } from 'app/features/query-library/types';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import QueryTemplatesTable from './QueryTemplatesTable';
import { QueryTemplateRow } from './QueryTemplatesTable/types';
export function QueryTemplatesList() {
const { data, isLoading, error } = useAllQueryTemplatesQuery();
if (error) {
return (
<EmptyState variant="not-found" message={`Something went wrong`}>
{error.message}
</EmptyState>
);
}
if (isLoading) {
return <Spinner />;
}
if (!data || data.length === 0) {
return (
<EmptyState message={`Query Library`} variant="not-found">
<p>
{
"You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab."
}
</p>
</EmptyState>
);
}
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(),
datasourceRef,
datasourceType,
createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0,
query: queryTemplate.targets[0],
description: queryTemplate.title,
};
});
return <QueryTemplatesTable queryTemplateRows={queryTemplateRows} />;
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Button } from '@grafana/ui';
export function ActionsCell() {
return (
<>
<Button disabled={true} variant="primary">
Run
</Button>
</>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Avatar } from '@grafana/ui';
import { useQueryLibraryListStyles } from './styles';
export function AddedByCell() {
const styles = useQueryLibraryListStyles();
return (
<div>
<span className={styles.logo}>
<Avatar src="https://secure.gravatar.com/avatar" alt="unknown" />
</span>
<span className={styles.otherText}>Unknown</span>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { CellProps } from 'react-table';
import { useDatasource } from '../utils/useDatasource';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function DatasourceTypeCell(props: CellProps<QueryTemplateRow>) {
const datasourceApi = useDatasource(props.row.original.datasourceRef);
const styles = useQueryLibraryListStyles();
return <p className={styles.otherText}>{datasourceApi?.meta.name}</p>;
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { CellProps } from 'react-table';
import { dateTime } from '@grafana/data';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function DateAddedCell(props: CellProps<QueryTemplateRow>) {
const styles = useQueryLibraryListStyles();
const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss');
return <p className={styles.otherText}>{formattedTime}</p>;
}

View File

@@ -0,0 +1,41 @@
import { cx } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { Spinner } from '@grafana/ui';
import { useDatasource } from '../utils/useDatasource';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) {
const datasourceApi = useDatasource(props.row.original.datasourceRef);
const styles = useQueryLibraryListStyles();
if (!datasourceApi) {
return <Spinner />;
}
if (!props.row.original.query) {
return <div>No queries</div>;
}
const query = props.row.original.query;
const description = props.row.original.description;
const dsName = datasourceApi?.name || '';
return (
<div aria-label={`Query template for ${dsName}: ${description}`}>
<p className={styles.header}>
<img
className={styles.logo}
src={datasourceApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'}
alt={datasourceApi?.meta.info.description}
/>
{dsName}
</p>
<p className={cx(styles.mainText, styles.singleLine)}>{datasourceApi?.getQueryDisplayText?.(query)}</p>
<p className={cx(styles.otherText, styles.singleLine)}>{description}</p>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { css } from '@emotion/css';
import React from 'react';
import { SortByFn } from 'react-table';
import { Column, InteractiveTable } from '@grafana/ui';
import { ActionsCell } from './ActionsCell';
import { AddedByCell } from './AddedByCell';
import { DatasourceTypeCell } from './DatasourceTypeCell';
import { DateAddedCell } from './DateAddedCell';
import { QueryDescriptionCell } from './QueryDescriptionCell';
import { QueryTemplateRow } from './types';
const timestampSort: SortByFn<QueryTemplateRow> = (rowA, rowB, _, desc) => {
const timeA = rowA.original.createdAtTimestamp || 0;
const timeB = rowB.original.createdAtTimestamp || 0;
return desc ? timeA - timeB : timeB - timeA;
};
const columns: Array<Column<QueryTemplateRow>> = [
{ id: 'description', header: 'Data source and query', cell: QueryDescriptionCell },
{ id: 'addedBy', header: 'Added by', cell: AddedByCell },
{ id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' },
{ id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort },
{ id: 'actions', header: '', cell: ActionsCell },
];
const styles = {
tableWithSpacing: css({
'th:first-child': {
width: '50%',
},
}),
};
type Props = {
queryTemplateRows: QueryTemplateRow[];
};
export default function QueryTemplatesTable({ queryTemplateRows }: Props) {
return (
<InteractiveTable
className={styles.tableWithSpacing}
columns={columns}
data={queryTemplateRows}
getRowId={(row: { index: string }) => row.index}
/>
);
}

View File

@@ -0,0 +1,37 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data/';
import { useStyles2 } from '@grafana/ui/';
export const useQueryLibraryListStyles = () => {
return useStyles2(getStyles);
};
const getStyles = (theme: GrafanaTheme2) => ({
logo: css({
marginRight: theme.spacing(2),
width: '16px',
}),
header: css({
margin: 0,
fontSize: theme.typography.h5.fontSize,
color: theme.colors.text.secondary,
}),
mainText: css({
margin: 0,
fontSize: theme.typography.body.fontSize,
textOverflow: 'ellipsis',
}),
otherText: css({
margin: 0,
fontSize: theme.typography.body.fontSize,
color: theme.colors.text.secondary,
textOverflow: 'ellipsis',
}),
singleLine: css({
display: '-webkit-box',
'-webkit-box-orient': 'vertical',
'-webkit-line-clamp': '1',
overflow: 'hidden',
}),
});

View File

@@ -0,0 +1,10 @@
import { DataQuery, DataSourceRef } from '@grafana/schema';
export type QueryTemplateRow = {
index: string;
description?: string;
query?: DataQuery;
datasourceRef?: DataSourceRef | null;
datasourceType?: string;
createdAtTimestamp?: number;
};

View File

@@ -0,0 +1,9 @@
import { useAsync } from 'react-use';
import { getDataSourceSrv } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
export function useDatasource(dataSourceRef?: DataSourceRef | null) {
const { value } = useAsync(async () => await getDataSourceSrv().get(dataSourceRef), [dataSourceRef]);
return value;
}

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EmptyState, TabbedContainer, TabConfig } from '@grafana/ui';
import { TabbedContainer, TabConfig } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
import { RichHistoryQuery } from 'app/types/explore';
@@ -11,6 +11,7 @@ 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 { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
import { RichHistorySettingsTab } from './RichHistorySettingsTab';
@@ -85,7 +86,7 @@ export function RichHistory(props: RichHistoryProps) {
const QueryLibraryTab: TabConfig = {
label: i18n.queryLibrary,
value: Tabs.QueryLibrary,
content: <EmptyState message="Coming soon!" variant="not-found" />,
content: <QueryLibrary />,
icon: 'book',
};

View File

@@ -22,6 +22,17 @@ export const assertQueryHistory = async (expectedQueryTexts: string[]) => {
});
};
export const assertQueryLibraryTemplateExists = async (datasource: string, description: string) => {
const selector = withinQueryHistory();
await waitFor(() => {
const cell = selector.getByRole('cell', {
name: new RegExp(`query template for ${datasource.toLowerCase()}: ${description.toLowerCase()}`, 'i'),
});
expect(cell).toBeInTheDocument();
});
};
export const assertQueryHistoryIsEmpty = async () => {
const selector = withinQueryHistory();
const queryTexts = selector.queryAllByLabelText('Query text');

View File

@@ -32,6 +32,12 @@ export const openQueryHistory = async () => {
expect(await screen.findByPlaceholderText('Search queries')).toBeInTheDocument();
};
export const openQueryLibrary = async () => {
const explore = withinExplore('left');
const button = explore.getByRole('button', { name: 'Query library' });
await userEvent.click(button);
};
export const closeQueryHistory = async () => {
const selector = withinQueryHistory();
const closeButton = selector.getByRole('button', { name: 'Close query history' });

View File

@@ -34,6 +34,7 @@ import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
import { setLastUsedDatasourceUID } from 'app/core/utils/explore';
import { QueryLibraryMocks } from 'app/features/query-library';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { configureStore } from 'app/store/configureStore';
@@ -70,12 +71,14 @@ export function setupExplore(options?: SetupOptions): {
datasourceRequest: jest.fn().mockRejectedValue(undefined),
delete: jest.fn().mockRejectedValue(undefined),
fetch: jest.fn().mockImplementation((req) => {
const data: Record<string, object | number> = {};
let data: Record<string, string | object | number> = {};
if (req.url.startsWith('/api/datasources/correlations') && req.method === 'GET') {
data.correlations = [];
data.totalCount = 0;
} else if (req.url.startsWith('/api/query-history') && req.method === 'GET') {
data.result = options?.queryHistory || {};
} else if (req.url.startsWith(QueryLibraryMocks.data.all.url)) {
data = QueryLibraryMocks.data.all.response;
}
return of({ data });
}),

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Props } from 'react-virtualized-auto-sizer';
import { EventBusSrv } from '@grafana/data';
import { config } from '@grafana/runtime';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
import { assertQueryLibraryTemplateExists } from './helper/assert';
import { openQueryLibrary } from './helper/interactions';
import { setupExplore, waitForExplore } from './helper/setup';
const reportInteractionMock = jest.fn();
const testEventBus = new EventBusSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: (...args: object[]) => {
reportInteractionMock(...args);
},
getAppEvents: () => testEventBus,
}));
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: () => true,
isSignedIn: true,
getValidIntervals: (defaultIntervals: string[]) => defaultIntervals,
},
}));
jest.mock('app/core/services/PreferencesService', () => ({
PreferencesService: function () {
return {
patch: jest.fn(),
load: jest.fn().mockResolvedValue({
queryHistory: {
homeTab: 'query',
},
}),
};
},
}));
jest.mock('../hooks/useExplorePageTitle', () => ({
useExplorePageTitle: jest.fn(),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return {
__esModule: true,
default(props: Props) {
return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>;
},
};
});
describe('QueryLibrary', () => {
silenceConsoleOutput();
beforeAll(() => {
config.featureToggles.queryLibrary = true;
});
afterAll(() => {
config.featureToggles.queryLibrary = false;
});
it('Load query templates', async () => {
setupExplore();
await waitForExplore();
await openQueryLibrary();
await assertQueryLibraryTemplateExists('loki', 'Loki Query Template');
await assertQueryLibraryTemplateExists('elastic', 'Elastic Query Template');
});
});

View File

@@ -0,0 +1,17 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { QueryTemplate } from '../types';
import { convertDataQueryResponseToQueryTemplates } from './mappers';
import { baseQuery } from './query';
export const queryLibraryApi = createApi({
baseQuery,
endpoints: (builder) => ({
allQueryTemplates: builder.query<QueryTemplate[], void>({
query: () => undefined,
transformResponse: convertDataQueryResponseToQueryTemplates,
}),
}),
reducerPath: 'queryLibrary',
});

View File

@@ -0,0 +1,17 @@
import { QueryTemplate } from '../types';
import { DataQuerySpecResponse, DataQueryTarget } from './types';
export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecResponse): QueryTemplate[] => {
if (!result.items) {
return [];
}
return result.items.map((spec) => {
return {
uid: spec.metadata.name || '',
title: spec.spec.title,
targets: spec.spec.targets.map((target: DataQueryTarget) => target.properties),
createdAtTimestamp: new Date(spec.metadata.creationTimestamp || '').getTime(),
};
});
};

View File

@@ -0,0 +1,9 @@
import { BASE_URL } from './query';
import { getTestQueryList } from './testdata/testQueryList';
export const mockData = {
all: {
url: BASE_URL,
response: getTestQueryList(),
},
};

View File

@@ -0,0 +1,34 @@
import { BaseQueryFn } from '@reduxjs/toolkit/query/react';
import { lastValueFrom } from 'rxjs';
import { getBackendSrv, isFetchError } from '@grafana/runtime/src/services/backendSrv';
import { DataQuerySpecResponse } from './types';
/**
* Query Library is an experimental feature. API (including the URL path) will likely change.
*
* @alpha
*/
export const BASE_URL = '/apis/peakq.grafana.app/v0alpha1/namespaces/default/querytemplates/';
/**
* TODO: similar code is duplicated in many places. To be unified in #86960
*/
export const baseQuery: BaseQueryFn<void, DataQuerySpecResponse, Error> = async () => {
try {
const responseObservable = getBackendSrv().fetch<DataQuerySpecResponse>({
url: BASE_URL,
showErrorAlert: true,
});
return await lastValueFrom(responseObservable);
} catch (error) {
if (isFetchError(error)) {
return { error: new Error(error.data.message) };
} else if (error instanceof Error) {
return { error };
} else {
return { error: new Error('Unknown error') };
}
}
};

View File

@@ -0,0 +1,122 @@
export const getTestQueryList = () => ({
kind: 'QueryTemplateList',
apiVersion: 'peakq.grafana.app/v0alpha1',
metadata: {
resourceVersion: '1783293408052252672',
remainingItemCount: 0,
},
items: [
{
kind: 'QueryTemplate',
apiVersion: 'peakq.grafana.app/v0alpha1',
metadata: {
name: 'AElastic2nkf9',
generateName: 'AElastic',
namespace: 'default',
uid: '65327fce-c545-489d-ada5-16f909453d12',
resourceVersion: '1783293341664808960',
creationTimestamp: '2024-04-25T20:32:58Z',
},
spec: {
title: 'Elastic Query Template',
targets: [
{
variables: {},
properties: {
refId: 'A',
datasource: {
type: 'elasticsearch',
uid: 'elastic-uid',
},
alias: '',
metrics: [
{
id: '1',
type: 'count',
},
],
bucketAggs: [
{
field: '@timestamp',
id: '2',
settings: {
interval: 'auto',
},
type: 'date_histogram',
},
],
timeField: '@timestamp',
query: 'test:test ',
},
},
],
},
},
{
kind: 'QueryTemplate',
apiVersion: 'peakq.grafana.app/v0alpha1',
metadata: {
name: 'ALoki296tj',
generateName: 'ALoki',
namespace: 'default',
uid: '3e71de65-efa7-40e3-8f23-124212cca455',
resourceVersion: '1783214217151647744',
creationTimestamp: '2024-04-25T11:05:55Z',
},
spec: {
title: 'Loki Query Template',
vars: [
{
key: '__value',
defaultValues: [''],
valueListDefinition: {
customValues: '',
},
},
],
targets: [
{
variables: {
__value: [
{
path: '$.datasource.jsonData.derivedFields.0.url',
position: {
start: 0,
end: 14,
},
format: 'raw',
},
{
path: '$.datasource.jsonData.derivedFields.1.url',
position: {
start: 0,
end: 14,
},
format: 'raw',
},
{
path: '$.datasource.jsonData.derivedFields.2.url',
position: {
start: 0,
end: 14,
},
format: 'raw',
},
],
},
properties: {
refId: 'A',
datasource: {
type: 'loki',
uid: 'loki-uid',
},
queryType: 'range',
editorMode: 'code',
expr: '{test="test"}',
},
},
],
},
},
],
});

View File

@@ -0,0 +1,26 @@
import { DataQuery } from '@grafana/schema/dist/esm/index';
export type DataQueryTarget = {
variables: object; // TODO: Detect variables in #86838
properties: DataQuery;
};
export type DataQuerySpec = {
apiVersion: string;
kind: string;
metadata: {
generateName: string;
name?: string;
creationTimestamp?: string;
};
spec: {
title: string;
vars: object[]; // TODO: Detect variables in #86838
targets: DataQueryTarget[];
};
};
export type DataQuerySpecResponse = {
apiVersion: string;
items: DataQuerySpec[];
};

View File

@@ -0,0 +1,17 @@
/**
* This is a temporary place for Query Library API and data types.
* To be exposed via grafana-runtime/data in the future.
*
* Query Library is an experimental feature, the API and components are subject to change
*
* @alpha
*/
import { queryLibraryApi } from './api/factory';
import { mockData } from './api/mocks';
export const { useAllQueryTemplatesQuery } = queryLibraryApi;
export const QueryLibraryMocks = {
data: mockData,
};

View File

@@ -0,0 +1,8 @@
import { DataQuery } from '@grafana/schema';
export type QueryTemplate = {
uid: string;
title: string;
targets: DataQuery[];
createdAtTimestamp: number;
};

View File

@@ -9,6 +9,7 @@ import { StoreState } from 'app/types/store';
import { buildInitialState } from '../core/reducers/navModel';
import { addReducer, createRootReducer } from '../core/reducers/root';
import { alertingApi } from '../features/alerting/unified/api/alertingApi';
import { queryLibraryApi } from '../features/query-library/api/factory';
import { setStore } from './store';
@@ -30,7 +31,8 @@ export function configureStore(initialState?: Partial<StoreState>) {
alertingApi.middleware,
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
cloudMigrationAPI.middleware
cloudMigrationAPI.middleware,
queryLibraryApi.middleware
),
devTools: process.env.NODE_ENV !== 'production',
preloadedState: {