From 009d65b7943bf1cca9f7fbd0681a96d1241bb83e Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Thu, 1 Dec 2022 03:33:40 +0400 Subject: [PATCH] Add query library behind dev-mode-only feature flag (#55947) Co-authored-by: nmarrs Co-authored-by: Adela Almasan Co-authored-by: drew08t Co-authored-by: Ryan McKinley --- .github/CODEOWNERS | 1 + packages/grafana-data/src/types/icon.ts | 2 + .../querylibrary/querylibraryimpl/service.go | 10 +- .../app/core/components/Layers/LayerName.tsx | 5 +- .../PanelEditor/PanelEditorQueries.tsx | 1 + .../features/dashboard/state/PanelModel.ts | 15 ++ .../query-library/api/SavedQueriesApi.ts | 72 ++++++ .../query-library/api/SavedQueriesSrv.ts | 26 +++ .../components/CreateNewQuery.tsx | 123 ++++++++++ .../components/DatasourceTypePicker.tsx | 113 +++++++++ .../query-library/components/HistoryTab.tsx | 27 +++ .../query-library/components/Queries.tsx | 25 ++ .../components/QueryCreateDrawer.tsx | 118 ++++++++++ .../query-library/components/QueryEditor.tsx | 143 ++++++++++++ .../components/QueryEditorDrawer.tsx | 108 +++++++++ .../components/QueryEditorDrawerHeader.tsx | 198 ++++++++++++++++ .../components/QueryImportDrawer.tsx | 55 +++++ .../components/QueryLibraryPage.tsx | 46 ++++ .../components/QueryLibrarySearchTable.tsx | 219 ++++++++++++++++++ .../components/QueryListItem.tsx | 198 ++++++++++++++++ .../query-library/components/QueryName.tsx | 119 ++++++++++ .../components/SaveQueryWorkflowModal.tsx | 79 +++++++ .../query-library/components/UsagesTab.tsx | 166 +++++++++++++ .../query-library/components/VariablesTab.tsx | 166 +++++++++++++ .../features/query-library/globalStyles.ts | 28 +++ .../query-library/img/grafana_incident.svg | 10 + .../features/query-library/img/grafana_ml.svg | 1 + .../query-library/img/grafana_oncall.svg | 9 + public/app/features/query-library/routes.tsx | 20 ++ public/app/features/query-library/types.ts | 30 +++ public/app/features/query-library/utils.ts | 14 ++ .../features/query/components/QueryGroup.tsx | 108 ++++++++- .../query/components/SavedQueryPicker.tsx | 116 ++++++++++ public/app/features/sandbox/TestStuffPage.tsx | 1 + public/app/features/search/service/types.ts | 2 + public/app/routes/routes.tsx | 2 + public/app/types/query.ts | 1 + 37 files changed, 2371 insertions(+), 6 deletions(-) create mode 100644 public/app/features/query-library/api/SavedQueriesApi.ts create mode 100644 public/app/features/query-library/api/SavedQueriesSrv.ts create mode 100644 public/app/features/query-library/components/CreateNewQuery.tsx create mode 100644 public/app/features/query-library/components/DatasourceTypePicker.tsx create mode 100644 public/app/features/query-library/components/HistoryTab.tsx create mode 100644 public/app/features/query-library/components/Queries.tsx create mode 100644 public/app/features/query-library/components/QueryCreateDrawer.tsx create mode 100644 public/app/features/query-library/components/QueryEditor.tsx create mode 100644 public/app/features/query-library/components/QueryEditorDrawer.tsx create mode 100644 public/app/features/query-library/components/QueryEditorDrawerHeader.tsx create mode 100644 public/app/features/query-library/components/QueryImportDrawer.tsx create mode 100644 public/app/features/query-library/components/QueryLibraryPage.tsx create mode 100644 public/app/features/query-library/components/QueryLibrarySearchTable.tsx create mode 100644 public/app/features/query-library/components/QueryListItem.tsx create mode 100644 public/app/features/query-library/components/QueryName.tsx create mode 100644 public/app/features/query-library/components/SaveQueryWorkflowModal.tsx create mode 100644 public/app/features/query-library/components/UsagesTab.tsx create mode 100644 public/app/features/query-library/components/VariablesTab.tsx create mode 100644 public/app/features/query-library/globalStyles.ts create mode 100644 public/app/features/query-library/img/grafana_incident.svg create mode 100644 public/app/features/query-library/img/grafana_ml.svg create mode 100644 public/app/features/query-library/img/grafana_oncall.svg create mode 100644 public/app/features/query-library/routes.tsx create mode 100644 public/app/features/query-library/types.ts create mode 100644 public/app/features/query-library/utils.ts create mode 100644 public/app/features/query/components/SavedQueryPicker.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b405e168fe3..f6f5271bdba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -146,6 +146,7 @@ tsconfig.json @grafana/frontend-ops /public/app/features/geo/ @grafana/grafana-edge-squad /public/app/features/storage/ @grafana/grafana-edge-squad /public/app/features/live/ @grafana/grafana-edge-squad +/public/app/features/query-library/ @grafana/grafana-edge-squad /public/app/features/explore/ @grafana/observability-experience-squad /public/app/features/plugins @grafana/plugins-platform-frontend /public/app/features/transformers/spatial @grafana/grafana-edge-squad diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index b39bdb0a1fe..34fff4877dc 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -58,6 +58,7 @@ export const availableIconsIndex = { compass: true, copy: true, 'credit-card': true, + crosshair: true, cube: true, dashboard: true, database: true, @@ -104,6 +105,7 @@ export const availableIconsIndex = { 'gf-show-context': true, grafana: true, 'graph-bar': true, + 'grafana-ml': true, heart: true, 'heart-rate': true, 'heart-break': true, diff --git a/pkg/services/querylibrary/querylibraryimpl/service.go b/pkg/services/querylibrary/querylibraryimpl/service.go index cf3cc193491..6078c41af82 100644 --- a/pkg/services/querylibrary/querylibraryimpl/service.go +++ b/pkg/services/querylibrary/querylibraryimpl/service.go @@ -262,8 +262,16 @@ func (s *service) GetBatch(ctx context.Context, user *user.SignedInUser, uids [] func (s *service) Update(ctx context.Context, user *user.SignedInUser, query *querylibrary.Query) error { if query.UID == "" { - query.UID = util.GenerateShortUID() + queriesWithTheSameTitle, err := s.Search(ctx, user, querylibrary.QuerySearchOptions{Query: query.Title}) + if err != nil { + return err + } + if len(queriesWithTheSameTitle) != 0 { + return fmt.Errorf("can't create query with title '%s'. existing query with similar name: '%s'", query.Title, queriesWithTheSameTitle[0].Title) + } + + query.UID = util.GenerateShortUID() return s.collection.Insert(ctx, namespaceFromUser(user), query) } diff --git a/public/app/core/components/Layers/LayerName.tsx b/public/app/core/components/Layers/LayerName.tsx index 660fc295636..1ed35e1a3a1 100644 --- a/public/app/core/components/Layers/LayerName.tsx +++ b/public/app/core/components/Layers/LayerName.tsx @@ -8,9 +8,10 @@ export interface LayerNameProps { name: string; onChange: (v: string) => void; verifyLayerNameUniqueness?: (nameToCheck: string) => boolean; + overrideStyles?: boolean; } -export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNameProps) => { +export const LayerName = ({ name, onChange, verifyLayerNameUniqueness, overrideStyles }: LayerNameProps) => { const styles = useStyles2(getStyles); const [isEditing, setIsEditing] = useState(false); @@ -75,7 +76,7 @@ export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNa onClick={onEditLayer} data-testid="layer-name-div" > - {name} + {name} )} diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx index ca3ce045036..454dbba3e2e 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx @@ -36,6 +36,7 @@ export class PanelEditorQueries extends PureComponent { queries: panel.targets, maxDataPoints: panel.maxDataPoints, minInterval: panel.interval, + savedQueryUid: panel.savedQueryLink?.ref.uid ?? null, // Used by experimental feature queryLibrary timeRange: { from: panel.timeFrom, shift: panel.timeShift, diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index ebf120deb6b..da359a94c3f 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -21,6 +21,7 @@ import { getTemplateSrv, RefreshEvent } from '@grafana/runtime'; import config from 'app/core/config'; import { safeStringifyValue } from 'app/core/utils/explore'; import { getNextRefIdChar } from 'app/core/utils/query'; +import { SavedQueryLink } from 'app/features/query-library/types'; import { QueryGroupOptions } from 'app/types'; import { PanelOptionsChangedEvent, @@ -131,6 +132,7 @@ const defaults: any = { overrides: [], }, title: '', + savedQueryLink: null, }; export class PanelModel implements DataConfigSource, IPanelModel { @@ -155,6 +157,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { datasource: DataSourceRef | null = null; thresholds?: any; pluginVersion?: string; + savedQueryLink: SavedQueryLink | null = null; // Used by the experimental feature queryLibrary snapshotData?: DataFrameDTO[]; timeFrom?: any; @@ -514,6 +517,18 @@ export class PanelModel implements DataConfigSource, IPanelModel { uid: dataSource.uid, type: dataSource.type, }; + + if (options.savedQueryUid) { + this.savedQueryLink = { + ref: { + uid: options.savedQueryUid, + }, + variables: [], + }; + } else { + this.savedQueryLink = null; + } + this.cacheTimeout = options.cacheTimeout; this.timeFrom = options.timeRange?.from; this.timeShift = options.timeRange?.shift; diff --git a/public/app/features/query-library/api/SavedQueriesApi.ts b/public/app/features/query-library/api/SavedQueriesApi.ts new file mode 100644 index 00000000000..54544a65f9e --- /dev/null +++ b/public/app/features/query-library/api/SavedQueriesApi.ts @@ -0,0 +1,72 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import { DataQuery } from '@grafana/data/src'; + +import { SavedQueryUpdateOpts } from '../components/QueryEditorDrawer'; + +import { getSavedQuerySrv } from './SavedQueriesSrv'; + +export type SavedQueryRef = { + uid?: string; +}; + +export type Variable = { + name: string; + type?: string; + current: { + value: string | number; + }; +}; + +type SavedQueryMeta = { + title: string; + description?: string; + tags?: string[]; + schemaVersion?: number; + variables: Variable[]; +}; + +type SavedQueryData = { + queries: TQuery[]; +}; + +export type SavedQuery = SavedQueryMeta & SavedQueryData & SavedQueryRef; + +export const isQueryWithMixedDatasource = (savedQuery: SavedQuery): boolean => { + if (!savedQuery?.queries?.length) { + return false; + } + + const firstDs = savedQuery.queries[0].datasource; + return savedQuery.queries.some((q) => q.datasource?.uid !== firstDs?.uid || q.datasource?.type !== firstDs?.type); +}; + +const api = createApi({ + reducerPath: 'savedQueries', + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getSavedQueryByUids: build.query({ + async queryFn(arg, queryApi, extraOptions, baseQuery) { + return { data: await getSavedQuerySrv().getSavedQueries(arg) }; + }, + }), + deleteSavedQuery: build.mutation({ + async queryFn(arg) { + await getSavedQuerySrv().deleteSavedQuery(arg); + return { + data: null, + }; + }, + }), + updateSavedQuery: build.mutation({ + async queryFn(arg) { + await getSavedQuerySrv().updateSavedQuery(arg.query, arg.opts); + return { + data: null, + }; + }, + }), + }), +}); + +export const { useUpdateSavedQueryMutation } = api; diff --git a/public/app/features/query-library/api/SavedQueriesSrv.ts b/public/app/features/query-library/api/SavedQueriesSrv.ts new file mode 100644 index 00000000000..122077626ee --- /dev/null +++ b/public/app/features/query-library/api/SavedQueriesSrv.ts @@ -0,0 +1,26 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { SavedQueryUpdateOpts } from 'app/features/query-library/components/QueryEditorDrawer'; + +import { SavedQuery, SavedQueryRef } from './SavedQueriesApi'; + +export class SavedQuerySrv { + getSavedQueries = async (refs: SavedQueryRef[]): Promise => { + if (!refs.length) { + return []; + } + const uidParams = refs.map((r) => `uid=${r.uid}`).join('&'); + return getBackendSrv().get(`/api/query-library?${uidParams}`); + }; + + deleteSavedQuery = async (ref: SavedQueryRef): Promise => { + return getBackendSrv().delete(`/api/query-library?uid=${ref.uid}`); + }; + + updateSavedQuery = async (query: SavedQuery, options: SavedQueryUpdateOpts): Promise => { + return getBackendSrv().post(`/api/query-library`, query); + }; +} + +const savedQuerySrv = new SavedQuerySrv(); + +export const getSavedQuerySrv = () => savedQuerySrv; diff --git a/public/app/features/query-library/components/CreateNewQuery.tsx b/public/app/features/query-library/components/CreateNewQuery.tsx new file mode 100644 index 00000000000..8f05e018117 --- /dev/null +++ b/public/app/features/query-library/components/CreateNewQuery.tsx @@ -0,0 +1,123 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, CodeEditor, useStyles2 } from '@grafana/ui'; + +import { SavedQuery, useUpdateSavedQueryMutation } from '../api/SavedQueriesApi'; + +import { SavedQueryUpdateOpts } from './QueryEditorDrawer'; + +type Props = { + options: SavedQueryUpdateOpts; + onDismiss: () => void; + updateComponent?: () => void; +}; + +interface QueryForm { + val: SavedQuery; +} + +const initialForm: QueryForm = { + val: { + title: 'ds-variables', + tags: [], + description: 'example description', + schemaVersion: 1, + time: { + from: 'now-6h', + to: 'now', + }, + variables: [ + { + name: 'var1', + type: 'text', + current: { + value: 'hello world', + }, + }, + ], + queries: [ + { + // @ts-ignore + channel: 'plugin/testdata/random-flakey-stream', + datasource: { + type: 'datasource', + uid: 'grafana', + }, + filter: { + fields: ['Time', 'Value'], + }, + queryType: 'measurements', + refId: 'A', + search: { + query: '', + }, + }, + { + // @ts-ignore + alias: 'my-alias', + datasource: { + type: 'testdata', + uid: 'PD8C576611E62080A', + }, + drop: 11, + hide: false, + max: 1000, + min: 10, + noise: 5, + refId: 'B', + scenarioId: 'random_walk', + startValue: 10, + }, + ], + }, +}; + +export const CreateNewQuery = ({ onDismiss, updateComponent, options }: Props) => { + const styles = useStyles2(getStyles); + + const [updateSavedQuery] = useUpdateSavedQueryMutation(); + + const [query, setQuery] = useState(initialForm); + + return ( + <> + setQuery(() => ({ val: JSON.parse(val) }))} + onSave={(val) => setQuery(() => ({ val: JSON.parse(val) }))} + readOnly={false} + /> + + + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + editor: css``, + submitButton: css` + align-self: flex-end; + margin-bottom: 25px; + margin-top: 25px; + `, + }; +}; diff --git a/public/app/features/query-library/components/DatasourceTypePicker.tsx b/public/app/features/query-library/components/DatasourceTypePicker.tsx new file mode 100644 index 00000000000..e5c34bd52f4 --- /dev/null +++ b/public/app/features/query-library/components/DatasourceTypePicker.tsx @@ -0,0 +1,113 @@ +// Libraries +import { uniqBy } from 'lodash'; +import React from 'react'; + +// Components +import { DataSourceInstanceSettings, isUnsignedPluginSignature } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime/src'; +import { HorizontalGroup, PluginSignatureBadge, Select } from '@grafana/ui'; + +export type DatasourceTypePickerProps = { + onChange: (ds: string | null) => void; + current: string | null; // type + hideTextValue?: boolean; + onBlur?: () => void; + autoFocus?: boolean; + openMenuOnFocus?: boolean; + placeholder?: string; + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + /** If true,we show only DSs with logs; and if true, pluginId shouldnt be passed in */ + logs?: boolean; + width?: number; + inputId?: string; + filter?: (dataSource: DataSourceInstanceSettings) => boolean; + onClear?: () => void; +}; + +const getDataSourceTypeOptions = (props: DatasourceTypePickerProps) => { + const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } = props; + + return uniqBy( + getDataSourceSrv() + .getList({ + alerting, + tracing, + metrics, + logs, + dashboard, + mixed, + variables, + annotations, + pluginId, + filter, + type, + }) + .map((ds) => { + if (ds.type === 'datasource') { + return { + value: ds.type, + label: ds.type, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + }; + } + + return { + value: ds.type, + label: ds.type, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + }; + }), + (opt) => opt.value + ); +}; + +export const DatasourceTypePicker = (props: DatasourceTypePickerProps) => { + const { autoFocus, onBlur, onChange, current, openMenuOnFocus, placeholder, width, inputId } = props; + const options = getDataSourceTypeOptions(props); + + return ( +
+ + {validationError && {validationError}} + + )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css` + display: flex; + align-items: center; + margin-left: ${theme.v1.spacing.xs}; + `, + nameEditIcon: css` + cursor: pointer; + color: ${theme.colors.text.secondary}; + width: 12px; + height: 12px; + `, + nameInput: css` + max-width: 300px; + margin: -8px 0; + `, + h2Style: css` + margin-bottom: 0; + `, + }; +}; diff --git a/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx b/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx new file mode 100644 index 00000000000..674376f9991 --- /dev/null +++ b/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx @@ -0,0 +1,79 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Form, Modal, VerticalGroup, TextArea } from '@grafana/ui'; + +import { WorkflowID } from '../../storage/types'; +import { SavedQuery } from '../api/SavedQueriesApi'; + +interface FormDTO { + message: string; +} + +export interface SaveQueryOptions { + savedQuery: SavedQuery; + workflow: WorkflowID; + message?: string; +} + +export type SaveProps = { + onCancel: () => void; + onSuccess: () => void; + onSubmit?: (options: SaveQueryOptions) => Promise<{ success: boolean }>; + options: SaveQueryOptions; + onOptionsChange: (opts: SaveQueryOptions) => void; +}; + +export const SaveQueryWorkflowModal = ({ options, onSubmit, onCancel, onSuccess }: SaveProps) => { + const [saving, setSaving] = useState(false); + + return ( + +
{ + console.log('hello submitting!'); + if (!onSubmit) { + return; + } + setSaving(true); + options = { ...options, message: data.message }; + const result = await onSubmit(options); + if (result.success) { + onSuccess(); + } else { + setSaving(false); + } + }} + > + {({ register, errors }) => ( + +