diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 725e9395a65..ecc76556591 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -419,6 +419,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/playlist/ @grafana/dashboards-squad
/public/app/features/plugins/ @grafana/plugins-platform-frontend
/public/app/features/profile/ @grafana/grafana-frontend-platform
+/public/app/features/query-library/ @grafana/explore-squad
/public/app/features/runtime/ @ryantxu
/public/app/features/query/ @grafana/dashboards-squad
/public/app/features/sandbox/ @grafana/grafana-frontend-platform
diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts
index 54e8c634072..ec33626bc43 100644
--- a/public/app/core/reducers/root.ts
+++ b/public/app/core/reducers/root.ts
@@ -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 = {};
diff --git a/public/app/features/explore/QueriesDrawer/QueriesDrawerDropdown.tsx b/public/app/features/explore/QueriesDrawer/QueriesDrawerDropdown.tsx
index 238cfc06da1..3cd4c211236 100644
--- a/public/app/features/explore/QueriesDrawer/QueriesDrawerDropdown.tsx
+++ b/public/app/features/explore/QueriesDrawer/QueriesDrawerDropdown.tsx
@@ -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}
diff --git a/public/app/features/explore/QueryLibrary/QueryLibrary.tsx b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx
new file mode 100644
index 00000000000..11173ca0384
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+import { QueryTemplatesList } from './QueryTemplatesList';
+
+export function QueryLibrary() {
+ return ;
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx
new file mode 100644
index 00000000000..a1e8bb5c568
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx
@@ -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 (
+
+ {error.message}
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!data || data.length === 0) {
+ return (
+
+
+ {
+ "You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab."
+ }
+
+
+ );
+ }
+
+ 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 ;
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx
new file mode 100644
index 00000000000..ccc18049a78
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+
+import { Button } from '@grafana/ui';
+
+export function ActionsCell() {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx
new file mode 100644
index 00000000000..4f344fc7002
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Avatar } from '@grafana/ui';
+
+import { useQueryLibraryListStyles } from './styles';
+
+export function AddedByCell() {
+ const styles = useQueryLibraryListStyles();
+
+ return (
+
+ );
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx
new file mode 100644
index 00000000000..2c9db92cd04
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx
@@ -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) {
+ const datasourceApi = useDatasource(props.row.original.datasourceRef);
+ const styles = useQueryLibraryListStyles();
+
+ return {datasourceApi?.meta.name}
;
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx
new file mode 100644
index 00000000000..3a8b59f08df
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx
@@ -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) {
+ const styles = useQueryLibraryListStyles();
+ const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss');
+
+ return {formattedTime}
;
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx
new file mode 100644
index 00000000000..95b8a7d9221
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx
@@ -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) {
+ const datasourceApi = useDatasource(props.row.original.datasourceRef);
+ const styles = useQueryLibraryListStyles();
+
+ if (!datasourceApi) {
+ return ;
+ }
+
+ if (!props.row.original.query) {
+ return No queries
;
+ }
+ const query = props.row.original.query;
+ const description = props.row.original.description;
+ const dsName = datasourceApi?.name || '';
+
+ return (
+
+
+
+ {dsName}
+
+
{datasourceApi?.getQueryDisplayText?.(query)}
+
{description}
+
+ );
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx
new file mode 100644
index 00000000000..53a54848d24
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx
@@ -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 = (rowA, rowB, _, desc) => {
+ const timeA = rowA.original.createdAtTimestamp || 0;
+ const timeB = rowB.original.createdAtTimestamp || 0;
+ return desc ? timeA - timeB : timeB - timeA;
+};
+
+const columns: Array> = [
+ { 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 (
+ row.index}
+ />
+ );
+}
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx
new file mode 100644
index 00000000000..34b9ff33d4e
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx
@@ -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',
+ }),
+});
diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts
new file mode 100644
index 00000000000..62ddcf12420
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts
@@ -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;
+};
diff --git a/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx b/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx
new file mode 100644
index 00000000000..1ea4fd29b76
--- /dev/null
+++ b/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx
@@ -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;
+}
diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx
index 8382edc323b..297a6a578f4 100644
--- a/public/app/features/explore/RichHistory/RichHistory.tsx
+++ b/public/app/features/explore/RichHistory/RichHistory.tsx
@@ -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: ,
+ content: ,
icon: 'book',
};
diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts
index 6e8f97d15e3..f68e477254d 100644
--- a/public/app/features/explore/spec/helper/assert.ts
+++ b/public/app/features/explore/spec/helper/assert.ts
@@ -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');
diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts
index 5304115ead0..19db9f932b5 100644
--- a/public/app/features/explore/spec/helper/interactions.ts
+++ b/public/app/features/explore/spec/helper/interactions.ts
@@ -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' });
diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx
index 2611e1c30f1..da69dba6881 100644
--- a/public/app/features/explore/spec/helper/setup.tsx
+++ b/public/app/features/explore/spec/helper/setup.tsx
@@ -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 = {};
+ let data: Record = {};
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 });
}),
diff --git a/public/app/features/explore/spec/queryLibrary.test.tsx b/public/app/features/explore/spec/queryLibrary.test.tsx
new file mode 100644
index 00000000000..33badc3fd61
--- /dev/null
+++ b/public/app/features/explore/spec/queryLibrary.test.tsx
@@ -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 {props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}
;
+ },
+ };
+});
+
+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');
+ });
+});
diff --git a/public/app/features/query-library/api/factory.ts b/public/app/features/query-library/api/factory.ts
new file mode 100644
index 00000000000..083680e8855
--- /dev/null
+++ b/public/app/features/query-library/api/factory.ts
@@ -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({
+ query: () => undefined,
+ transformResponse: convertDataQueryResponseToQueryTemplates,
+ }),
+ }),
+ reducerPath: 'queryLibrary',
+});
diff --git a/public/app/features/query-library/api/mappers.ts b/public/app/features/query-library/api/mappers.ts
new file mode 100644
index 00000000000..9dce0c19bfa
--- /dev/null
+++ b/public/app/features/query-library/api/mappers.ts
@@ -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(),
+ };
+ });
+};
diff --git a/public/app/features/query-library/api/mocks.ts b/public/app/features/query-library/api/mocks.ts
new file mode 100644
index 00000000000..470dae005ae
--- /dev/null
+++ b/public/app/features/query-library/api/mocks.ts
@@ -0,0 +1,9 @@
+import { BASE_URL } from './query';
+import { getTestQueryList } from './testdata/testQueryList';
+
+export const mockData = {
+ all: {
+ url: BASE_URL,
+ response: getTestQueryList(),
+ },
+};
diff --git a/public/app/features/query-library/api/query.ts b/public/app/features/query-library/api/query.ts
new file mode 100644
index 00000000000..b3f39234579
--- /dev/null
+++ b/public/app/features/query-library/api/query.ts
@@ -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 = async () => {
+ try {
+ const responseObservable = getBackendSrv().fetch({
+ 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') };
+ }
+ }
+};
diff --git a/public/app/features/query-library/api/testdata/testQueryList.ts b/public/app/features/query-library/api/testdata/testQueryList.ts
new file mode 100644
index 00000000000..2a941716b7d
--- /dev/null
+++ b/public/app/features/query-library/api/testdata/testQueryList.ts
@@ -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"}',
+ },
+ },
+ ],
+ },
+ },
+ ],
+});
diff --git a/public/app/features/query-library/api/types.ts b/public/app/features/query-library/api/types.ts
new file mode 100644
index 00000000000..e2feb99057f
--- /dev/null
+++ b/public/app/features/query-library/api/types.ts
@@ -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[];
+};
diff --git a/public/app/features/query-library/index.ts b/public/app/features/query-library/index.ts
new file mode 100644
index 00000000000..b9b339b096b
--- /dev/null
+++ b/public/app/features/query-library/index.ts
@@ -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,
+};
diff --git a/public/app/features/query-library/types.ts b/public/app/features/query-library/types.ts
new file mode 100644
index 00000000000..0629b327d1a
--- /dev/null
+++ b/public/app/features/query-library/types.ts
@@ -0,0 +1,8 @@
+import { DataQuery } from '@grafana/schema';
+
+export type QueryTemplate = {
+ uid: string;
+ title: string;
+ targets: DataQuery[];
+ createdAtTimestamp: number;
+};
diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts
index 3fed0e7644f..a8dfc096915 100644
--- a/public/app/store/configureStore.ts
+++ b/public/app/store/configureStore.ts
@@ -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) {
alertingApi.middleware,
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
- cloudMigrationAPI.middleware
+ cloudMigrationAPI.middleware,
+ queryLibraryApi.middleware
),
devTools: process.env.NODE_ENV !== 'production',
preloadedState: {