Add query library behind dev-mode-only feature flag (#55947)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
Co-authored-by: drew08t <drew08@gmail.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Artur Wierzbicki
2022-12-01 03:33:40 +04:00
committed by GitHub
parent 14a080ec12
commit 009d65b794
37 changed files with 2371 additions and 6 deletions

1
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -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<boolean>(false);
@ -75,7 +76,7 @@ export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNa
onClick={onEditLayer}
data-testid="layer-name-div"
>
<span className={styles.layerName}>{name}</span>
<span className={overrideStyles ? '' : styles.layerName}>{name}</span>
<Icon name="pen" className={styles.layerEditIcon} size="sm" />
</button>
)}

View File

@ -36,6 +36,7 @@ export class PanelEditorQueries extends PureComponent<Props> {
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,

View File

@ -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;

View File

@ -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<TQuery extends DataQuery = DataQuery> = {
queries: TQuery[];
};
export type SavedQuery<TQuery extends DataQuery = DataQuery> = SavedQueryMeta & SavedQueryData<TQuery> & 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<SavedQuery[] | null, SavedQueryRef[]>({
async queryFn(arg, queryApi, extraOptions, baseQuery) {
return { data: await getSavedQuerySrv().getSavedQueries(arg) };
},
}),
deleteSavedQuery: build.mutation<null, SavedQueryRef>({
async queryFn(arg) {
await getSavedQuerySrv().deleteSavedQuery(arg);
return {
data: null,
};
},
}),
updateSavedQuery: build.mutation<null, { query: SavedQuery; opts: SavedQueryUpdateOpts }>({
async queryFn(arg) {
await getSavedQuerySrv().updateSavedQuery(arg.query, arg.opts);
return {
data: null,
};
},
}),
}),
});
export const { useUpdateSavedQueryMutation } = api;

View File

@ -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<SavedQuery[]> => {
if (!refs.length) {
return [];
}
const uidParams = refs.map((r) => `uid=${r.uid}`).join('&');
return getBackendSrv().get<SavedQuery[]>(`/api/query-library?${uidParams}`);
};
deleteSavedQuery = async (ref: SavedQueryRef): Promise<void> => {
return getBackendSrv().delete(`/api/query-library?uid=${ref.uid}`);
};
updateSavedQuery = async (query: SavedQuery, options: SavedQueryUpdateOpts): Promise<void> => {
return getBackendSrv().post(`/api/query-library`, query);
};
}
const savedQuerySrv = new SavedQuerySrv();
export const getSavedQuerySrv = () => savedQuerySrv;

View File

@ -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 (
<>
<CodeEditor
containerStyles={styles.editor}
width="80%"
height="70vh"
language="json"
showLineNumbers={false}
showMiniMap={true}
value={JSON.stringify(query.val, null, 2)}
onBlur={(val) => setQuery(() => ({ val: JSON.parse(val) }))}
onSave={(val) => setQuery(() => ({ val: JSON.parse(val) }))}
readOnly={false}
/>
<Button
type="submit"
className={styles.submitButton}
onClick={async () => {
await updateSavedQuery({ query: query.val, opts: options });
onDismiss();
updateComponent?.();
}}
>
Save query
</Button>
</>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
editor: css``,
submitButton: css`
align-self: flex-end;
margin-bottom: 25px;
margin-top: 25px;
`,
};
};

View File

@ -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 (
<div aria-label={selectors.components.DataSourcePicker.container}>
<Select
aria-label={selectors.components.DataSourcePicker.inputV2}
inputId={inputId || 'data-source-picker'}
className="ds-picker select-container"
isMulti={false}
isClearable={true}
backspaceRemovesValue={true}
options={options}
autoFocus={autoFocus}
onBlur={onBlur}
width={width}
value={current}
onChange={(newValue) => {
onChange(newValue?.value ?? null);
}}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={500}
placeholder={placeholder ?? 'Select datasource type'}
noOptionsMessage="No datasources found"
getOptionLabel={(o) => {
if (o.meta && isUnsignedPluginSignature(o.meta.signature)) {
return (
<HorizontalGroup align="center" justify="space-between">
<span>{o.label}</span> <PluginSignatureBadge status={o.meta.signature} />
</HorizontalGroup>
);
}
return o.label || '';
}}
/>
</div>
);
};

View File

@ -0,0 +1,27 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export const HistoryTab = () => {
const styles = useStyles2(getStyles);
// @TODO Implement history
return (
<div className={styles.wrap}>
<p className={styles.tabDescription}>No history.</p>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
wrap: css`
padding: 20px 5px 5px 5px;
`,
tabDescription: css`
color: ${theme.colors.text.secondary};
`,
};
};

View File

@ -0,0 +1,25 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { useStyles2 } from '@grafana/ui/src';
import QueryLibrarySearchTable from './QueryLibrarySearchTable';
export const Queries = () => {
const styles = useStyles2(getStyles);
return (
<div className={styles.tableWrapper}>
<QueryLibrarySearchTable />
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
tableWrapper: css`
height: 100%;
`,
};
};

View File

@ -0,0 +1,118 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, Drawer, Icon, ModalsController, useStyles2 } from '@grafana/ui';
import { SavedQuery } from '../api/SavedQueriesApi';
import { QueryEditorDrawer, SavedQueryUpdateOpts } from './QueryEditorDrawer';
import { QueryImportDrawer } from './QueryImportDrawer';
type Props = {
onDismiss: () => void;
updateComponent: () => void;
};
export const QueryCreateDrawer = ({ onDismiss, updateComponent }: Props) => {
const styles = useStyles2(getStyles);
const type: SavedQueryUpdateOpts['type'] = 'create-new';
const closeDrawer = () => {
onDismiss();
updateComponent();
};
return (
<Drawer
title="Add new query"
subtitle="You can create a new query from builder or import from file"
onClose={onDismiss}
width={'1000px'}
expandable
scrollableContent
>
<div>
<Card>
<Card.Heading>Create by query builder</Card.Heading>
<Card.Description></Card.Description>
<Card.Figure>
<Icon name={'list-ui-alt'} className={styles.cardIcon} />
</Card.Figure>
<Card.Tags>
<ModalsController>
{({ showModal, hideModal }) => {
return (
<Button
icon="plus"
size="md"
onClick={() => {
const savedQuery: SavedQuery = {
title: 'New Query',
variables: [],
queries: [
{
refId: 'A',
datasource: {
type: 'datasource',
uid: 'grafana',
},
queryType: 'randomWalk',
},
],
};
showModal(QueryEditorDrawer, {
onDismiss: closeDrawer,
options: { type },
savedQuery,
});
}}
>
Create query
</Button>
);
}}
</ModalsController>
</Card.Tags>
</Card>
<Card>
<Card.Heading>Import from file</Card.Heading>
<Card.Description>Supported formats: JSON</Card.Description>
<Card.Figure>
<Icon name={'import'} className={styles.cardIcon} />
</Card.Figure>
<Card.Tags>
<ModalsController>
{({ showModal, hideModal }) => {
return (
<Button
icon="arrow-right"
size="md"
onClick={() => {
showModal(QueryImportDrawer, {
onDismiss: closeDrawer,
options: { type },
});
}}
>
Next
</Button>
);
}}
</ModalsController>
</Card.Tags>
</Card>
</div>
</Drawer>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
cardIcon: css`
width: 30px;
height: 30px;
`,
};
};

View File

@ -0,0 +1,143 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import {
CoreApp,
DataQuery,
DataSourceApi,
DataSourceInstanceSettings,
getDefaultTimeRange,
GrafanaTheme2,
LoadingState,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors/src';
import { DataSourcePicker, getDataSourceSrv } from '@grafana/runtime';
import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows';
import { addQuery } from '../../../core/utils/query';
import { dataSource as expressionDatasource } from '../../expressions/ExpressionDatasource';
import { updateQueries } from '../../query/state/updateQueries';
import { isQueryWithMixedDatasource, SavedQuery } from '../api/SavedQueriesApi';
import { defaultQuery } from '../utils';
type Props = {
savedQuery: SavedQuery;
onSavedQueryChange: (newQuery: SavedQuery) => void;
};
export const QueryEditor = ({ savedQuery, onSavedQueryChange }: Props) => {
const styles = useStyles2(getStyles);
const [queries, setQueries] = useState<DataQuery[]>(savedQuery.queries ?? [defaultQuery]);
const dsRef = isQueryWithMixedDatasource(savedQuery)
? { uid: '-- Mixed --', type: 'datasource' }
: queries[0].datasource;
const [dsSettings, setDsSettings] = useState(getDataSourceSrv().getInstanceSettings(dsRef));
const data = {
state: LoadingState.NotStarted,
series: [],
timeRange: getDefaultTimeRange(),
};
const onQueriesChange = (newQueries: DataQuery[]) => {
setQueries(newQueries);
onSavedQueryChange({
...savedQuery,
queries: newQueries,
});
};
const onDsChange = async (newDsSettings: DataSourceInstanceSettings) => {
const newDs = await getDataSourceSrv().get(newDsSettings.uid);
const currentDS = dsSettings ? await getDataSourceSrv().get(dsSettings.uid) : undefined;
const newQueries = await updateQueries(newDs, newDs.uid, queries, currentDS);
onQueriesChange(newQueries);
setDsSettings(newDsSettings);
};
const newQuery = async (): Promise<Partial<DataQuery>> => {
const ds: DataSourceApi = !dsSettings?.meta.mixed // TODO remove the asyncs and use prefetched ds apis
? await getDataSourceSrv().get(dsSettings!.uid)
: await getDataSourceSrv().get();
return {
...ds?.getDefaultQuery?.(CoreApp.PanelEditor),
datasource: { uid: ds?.uid, type: ds?.type },
};
};
const onAddQueryClick = async () => {
const newQ = await newQuery();
onQueriesChange(addQuery(queries, newQ));
};
const onAddExpressionClick = () => {
const newExpr = expressionDatasource.newQuery();
onQueriesChange(addQuery(queries, newExpr));
};
return (
<div>
<HorizontalGroup>
<div className={styles.dataSourceHeader}>Data source</div>
<div className={styles.dataSourcePickerWrapper}>
<DataSourcePicker
onChange={onDsChange}
current={dsSettings}
metrics={true}
mixed={true}
dashboard={true}
variables={true}
/>
</div>
</HorizontalGroup>
<QueryEditorRows
queries={queries}
dsSettings={dsSettings!}
onQueriesChange={onQueriesChange}
onAddQuery={onAddQueryClick}
onRunQueries={() => {}}
data={data}
/>
<HorizontalGroup spacing="md" align="flex-start">
{
<Button
disabled={false}
icon="plus"
onClick={onAddQueryClick}
variant="secondary"
aria-label={selectors.components.QueryTab.addQuery}
>
Query
</Button>
}
{(dsSettings?.meta.alerting || dsSettings?.meta.mixed) && (
<Button icon="plus" onClick={onAddExpressionClick} variant="secondary" className={styles.expressionButton}>
<span>Expression&nbsp;</span>
</Button>
)}
</HorizontalGroup>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
dataSourceHeader: css`
font-size: ${theme.typography.size.sm};
margin-top: 5px;
margin-bottom: 20px;
`,
dataSourcePickerWrapper: css`
margin-top: 5px;
margin-bottom: 20px;
`,
expressionButton: css`
margin-right: ${theme.spacing(2)};
`,
};
};

View File

@ -0,0 +1,108 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { DataQuery } from '@grafana/data/src/types/query';
import { Drawer, IconName, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { SavedQuery } from '../api/SavedQueriesApi';
import { HistoryTab } from './HistoryTab';
import { QueryEditor } from './QueryEditor';
import { QueryEditorDrawerHeader } from './QueryEditorDrawerHeader';
import { UsagesTab } from './UsagesTab';
import { VariablesTab } from './VariablesTab';
export type SavedQueryUpdateOpts = { message?: string } & (
| {
type: 'create-new';
}
| {
type: 'edit';
}
);
type Props = {
onDismiss: () => void;
savedQuery: SavedQuery<DataQuery>;
options: SavedQueryUpdateOpts;
};
type tab = {
label: string;
active: boolean;
icon: IconName;
};
const initialTabs: tab[] = [
{
label: 'Usages',
active: true,
icon: 'link',
},
{
label: 'Variables',
active: false,
icon: 'info-circle',
},
{
label: 'History',
active: false,
icon: 'history',
},
];
export const QueryEditorDrawer = (props: Props) => {
const { onDismiss, options } = props;
const styles = useStyles2(getStyles);
const [tabs, setTabs] = useState(initialTabs);
const [query, setSavedQuery] = useState(props.savedQuery);
return (
<Drawer onClose={onDismiss} width={'1000px'} expandable scrollableContent>
<div>
<QueryEditorDrawerHeader
options={options}
onSavedQueryChange={setSavedQuery}
savedQuery={query}
onDismiss={onDismiss}
/>
<div className={styles.queryWrapper}>
<QueryEditor onSavedQueryChange={setSavedQuery} savedQuery={query} />
</div>
<TabsBar>
{tabs.map((tab, index) => (
<Tab
key={index}
label={tab.label}
active={tab.active}
icon={tab.icon}
onChangeTab={() => setTabs(tabs.map((tab, idx) => ({ ...tab, active: idx === index })))}
/>
))}
</TabsBar>
<TabContent>
<div className={styles.tabWrapper}>
{tabs[0].active && <UsagesTab savedQuery={query} />}
{tabs[1].active && <VariablesTab savedQuery={query} options={options} />}
{tabs[2].active && <HistoryTab />}
</div>
</TabContent>
</div>
</Drawer>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
queryWrapper: css`
max-height: calc(60vh);
overflow-y: scroll;
margin-bottom: 50px;
`,
tabWrapper: css`
overflow-y: scroll;
max-height: calc(27vh);
`,
};
};

View File

@ -0,0 +1,198 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useRef, useState } from 'react';
import InlineSVG from 'react-inlinesvg/esm';
import { GrafanaTheme2 } from '@grafana/data/src';
import { Button, HorizontalGroup, Icon, IconName, useStyles2 } from '@grafana/ui';
import { useAppNotification } from '../../../core/copy/appNotification';
import { SavedQuery } from '../api/SavedQueriesApi';
import { getSavedQuerySrv } from '../api/SavedQueriesSrv';
import { implementationComingSoonAlert } from '../utils';
import { SavedQueryUpdateOpts } from './QueryEditorDrawer';
import { QueryName } from './QueryName';
type Props = {
onSavedQueryChange: (newQuery: SavedQuery) => void;
savedQuery: SavedQuery;
onDismiss: () => void;
options: SavedQueryUpdateOpts;
};
export const QueryEditorDrawerHeader = ({ savedQuery, onDismiss, onSavedQueryChange, options }: Props) => {
const notifyApp = useAppNotification();
const styles = useStyles2(getStyles);
const dropdownRef = useRef(null);
const [queryName, setQueryName] = useState(savedQuery.title);
const [showUseQueryOptions, setShowUseQueryOptions] = useState(false);
const nameEditingEnabled = !Boolean(savedQuery?.uid?.length);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current !== event.target) {
setShowUseQueryOptions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
}, [dropdownRef]);
const deleteQuery = async () => {
await getSavedQuerySrv().deleteSavedQuery({ uid: savedQuery.uid });
onDismiss();
};
type queryOption = {
label: string;
value: string;
icon: IconName;
src?: string;
};
const useQueryOptions: queryOption[] = [
{ label: 'Add to dashboard', value: 'dashboard-panel', icon: 'apps' },
{ label: 'Create alert rule', value: 'alert-rule', icon: 'bell' },
{ label: 'View in explore', value: 'explore', icon: 'compass' },
{
label: 'Create recorded query',
value: 'recorded-query',
icon: 'record-audio',
},
{ label: 'Create SLO', value: 'slo', icon: 'crosshair' },
{
label: 'Add to incident in Grafana OnCall',
value: 'incident-oncall',
icon: 'record-audio',
src: 'public/app/features/query-library/img/grafana_incident.svg',
},
{
label: 'Create incident in Grafana Incident',
value: 'incident-grafana',
icon: 'heart-break',
src: 'public/app/features/query-library/img/grafana_oncall.svg',
},
{
label: 'Create forecast in Grafana ML',
value: 'grafana-ml',
icon: 'grafana-ml',
src: 'public/app/features/query-library/img/grafana_ml.svg',
},
];
const onQueryNameChange = (name: string) => {
setQueryName(name);
onSavedQueryChange({
...savedQuery,
title: name,
});
};
const onQuerySave = async (options: SavedQueryUpdateOpts) => {
await getSavedQuerySrv()
.updateSavedQuery(savedQuery, options)
.then(() => notifyApp.success('Query updated'))
.catch((err) => {
const msg = err.data?.message || err;
notifyApp.warning(msg);
});
onDismiss();
};
return (
<>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'}>
<QueryName name={queryName} onChange={onQueryNameChange} editingEnabled={nameEditingEnabled} />
<HorizontalGroup>
<Button icon="times" size="md" variant={'secondary'} onClick={onDismiss} autoFocus={false}>
Close
</Button>
<Button
icon={'grafana'}
variant="secondary"
size="md"
onClick={() => {
setShowUseQueryOptions(!showUseQueryOptions);
}}
>
Use query
</Button>
<Button icon="sync" size="md" variant={'secondary'} onClick={implementationComingSoonAlert}>
Run
</Button>
{/*<Button icon="share-alt" size="sm" variant={'secondary'}>Export</Button>*/}
<Button icon="lock" size="md" variant={'secondary'} onClick={implementationComingSoonAlert} />
<Button size="md" variant={'primary'} onClick={() => onQuerySave(options)}>
Save
</Button>
<Button icon="trash-alt" size="md" variant={'destructive'} onClick={() => deleteQuery()} />
</HorizontalGroup>
</HorizontalGroup>
{/*@TODO Nicer submenu*/}
<HorizontalGroup>
{showUseQueryOptions && (
<div
className="panel-menu-container dropdown open"
style={{ height: 0 }}
ref={dropdownRef}
onClick={() => {
setShowUseQueryOptions(false);
}}
>
<ul className={cx('dropdown-menu dropdown-menu--menu panel-menu', styles.dropdown)}>
{useQueryOptions.map((option, key) => {
return (
<li key={key}>
{/*eslint-disable-next-line jsx-a11y/anchor-is-valid*/}
<a onClick={implementationComingSoonAlert}>
<div>
{option.src ? (
<InlineSVG src={option.src} className={styles.optionSvg} />
) : (
<Icon name={option.icon} className={styles.menuIconClassName} />
)}
</div>
<span className="dropdown-item-text">{option.label}</span>
<span className="dropdown-menu-item-shortcut" />
</a>
</li>
);
})}
</ul>
</div>
)}
</HorizontalGroup>
</div>
</>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
cascaderButton: css`
height: 24px;
`,
header: css`
padding-top: 5px;
padding-bottom: 15px;
`,
menuIconClassName: css`
margin-right: ${theme.v1.spacing.sm};
a::after {
display: none;
}
`,
optionSvg: css`
margin-right: 8px;
width: 16px;
height: 16px;
`,
dropdown: css`
left: 609px;
top: 2px;
`,
};
};

View File

@ -0,0 +1,55 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Drawer, FileDropzone, useStyles2 } from '@grafana/ui';
import { CreateNewQuery } from './CreateNewQuery';
import { SavedQueryUpdateOpts } from './QueryEditorDrawer';
type Props = {
options: SavedQueryUpdateOpts;
onDismiss: () => void;
};
export const QueryImportDrawer = ({ onDismiss, options }: Props) => {
const styles = useStyles2(getStyles);
const [file, setFile] = useState<File | undefined>(undefined);
return (
<Drawer title="Import query" onClose={onDismiss} width={'1000px'} expandable scrollableContent>
<FileDropzone
readAs="readAsBinaryString"
onFileRemove={() => {
setFile(undefined);
}}
options={{
accept: '.json',
multiple: false,
onDrop: (acceptedFiles: File[]) => {
setFile(acceptedFiles[0]);
},
}}
>
<div>Drag and drop here or browse</div>
</FileDropzone>
{Boolean(file) && (
<div className={styles.queryPreview}>
<CreateNewQuery options={options} onDismiss={onDismiss} />
</div>
)}
</Drawer>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
queryPreview: css`
margin-top: 20px;
margin-bottom: 20px;
margin-left: 170px;
`,
};
};

View File

@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { config } from '@grafana/runtime/src';
import { Alert, Tab, TabsBar, TabContent } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from '../../../core/hooks/useNavModel';
import { Queries } from './Queries';
const initialTabs = [
{
label: 'Queries',
active: true,
},
];
const QueryLibraryPage = () => {
const navModel = useNavModel('query');
const [tabs, setTabs] = useState(initialTabs);
if (!config.featureToggles.panelTitleSearch) {
return <Alert title="Missing feature toggle: panelTitleSearch">Query library requires searchV2</Alert>;
}
return (
<Page navModel={navModel}>
<Page.Contents>
<TabsBar>
{tabs.map((tab, index) => (
<Tab
key={index}
label={tab.label}
active={tab.active}
onChangeTab={() => setTabs(tabs.map((tab, idx) => ({ ...tab, active: idx === index })))}
/>
))}
</TabsBar>
<TabContent>{tabs[0].active && <Queries />}</TabContent>
</Page.Contents>
</Page>
);
};
export default QueryLibraryPage;

View File

@ -0,0 +1,219 @@
import { css, cx } from '@emotion/css';
import { Global } from '@emotion/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, FilterInput, HorizontalGroup, ModalsController, useStyles2, useTheme2 } from '@grafana/ui';
import { getGrafanaSearcher, SearchQuery } from '../../search/service';
import { getGlobalStyles } from '../globalStyles';
import { QueryItem } from '../types';
import { DatasourceTypePicker } from './DatasourceTypePicker';
import { QueryCreateDrawer } from './QueryCreateDrawer';
import { QueryListItem } from './QueryListItem';
const QueryLibrarySearchTable = () => {
const styles = useStyles2(getStyles);
const [datasourceType, setDatasourceType] = useState<string | null>(null);
const [searchQueryBy, setSearchByQuery] = useState<string>('');
const [reload, setReload] = useState(0);
const theme = useTheme2();
const globalCSS = getGlobalStyles(theme);
// @TODO update with real data
const authors = ['Artur Wierzbicki', 'Drew Slobodnjak', 'Nathan Marrs', 'Raphael Batyrbaev', 'Adela Almasan'];
const dates = [
'August 17, 2022, 2:32pm',
'August 17, 2022, 4:10pm',
'August 18, 2022, 1:00am',
'August 18, 2022, 12:00pm',
'August 19, 2022, 2:33pm',
];
const searchQuery = useMemo<SearchQuery>(() => {
const query: SearchQuery = {
query: '*',
sort: 'name_sort',
explain: true,
kind: ['query'],
};
if (datasourceType?.length) {
query.ds_type = datasourceType;
}
if (searchQueryBy) {
query.query = searchQueryBy;
}
return query;
}, [datasourceType, searchQueryBy]);
useEffect(() => {}, [reload]);
const results = useAsync(async () => {
const raw = await getGrafanaSearcher().search(searchQuery);
return raw.view.map<QueryItem>((item) => ({
uid: item.uid,
title: item.name,
url: item.url,
uri: item.url,
type: item.kind,
id: 123, // do not use me!
tags: item.tags ?? [],
ds_uid: item.ds_uid,
}));
}, [searchQuery, reload]);
const found = results.value;
return (
<>
<Global styles={globalCSS} />
<div className={styles.tableWrapper}>
<HorizontalGroup width="100%" justify="space-between" spacing={'md'} height={25}>
<HorizontalGroup>
<FilterInput
placeholder="Search queries by name, source, or variable"
autoFocus={true}
value={searchQueryBy}
onChange={setSearchByQuery}
width={50}
className={styles.searchBy}
/>
Filter by datasource type
<DatasourceTypePicker
current={datasourceType}
onChange={(newDsType) => {
setDatasourceType(() => newDsType);
}}
/>
</HorizontalGroup>
<ModalsController>
{({ showModal, hideModal }) => {
return (
<div className={styles.createQueryButton}>
<Button
icon="plus"
size="md"
onClick={() => {
showModal(QueryCreateDrawer, {
onDismiss: hideModal,
updateComponent: () => {
setReload(reload + 1);
},
});
}}
>
Create query
</Button>
</div>
);
}}
</ModalsController>
</HorizontalGroup>
<ModalsController>
{({ showModal, hideModal }) => {
return (
<AutoSizer className={styles.autosizer} style={{ width: '100%', height: '100%' }}>
{({ width, height }) => {
return (
<table className={cx('filter-table form-inline filter-table--hover', styles.table)}>
<thead>
<tr>
<th />
<th>Status</th>
<th>Name and raw query</th>
<th>Data Source</th>
<th>User</th>
<th>Date</th>
<th />
</tr>
</thead>
<tbody>
{!Boolean(found?.length) && (
<tr className={styles.transparentBg}>
<td />
<td />
<td />
<td>
<div className={styles.noData}>No data</div>
</td>
<td />
<td />
<th />
</tr>
)}
{Boolean(found?.length) &&
found!.map((item, key) => {
return (
<QueryListItem
query={item}
key={item.uid}
showModal={showModal}
hideModal={hideModal}
updateComponent={() => setReload(reload + 1)}
author={key < authors.length ? authors[key] : authors[key - authors.length]}
date={key < dates.length ? dates[key] : dates[key - dates.length]}
/>
);
})}
</tbody>
</table>
);
}}
</AutoSizer>
);
}}
</ModalsController>
</div>
</>
);
};
export default QueryLibrarySearchTable;
export const getStyles = (theme: GrafanaTheme2) => {
return {
tableWrapper: css`
height: 100%;
margin-top: 20px;
margin-bottom: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
`,
autosizer: css`
margin-top: 40px;
`,
createQueryButton: css`
text-align: center;
`,
filtersGroup: css`
padding-top: 10px;
margin-top: 30px;
`,
searchBy: css`
margin-right: 15px;
`,
table: css`
font-size: 14px;
&tbody {
&tr: {
background: ${theme.colors.background.secondary};
}
}
`,
noData: css`
color: ${theme.colors.text.secondary};
`,
transparentBg: css`
background: transparent !important;
`,
};
};

View File

@ -0,0 +1,198 @@
import { css, cx } from '@emotion/css';
import { uniq } from 'lodash';
import React, { memo, useEffect, useState } from 'react';
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data/src';
import { getDataSourceSrv } from '@grafana/runtime/src';
import { Icon, Tooltip } from '@grafana/ui';
import { Badge, IconButton, useStyles2 } from '@grafana/ui/src';
import { useAppNotification } from '../../../core/copy/appNotification';
import { getSavedQuerySrv } from '../api/SavedQueriesSrv';
import { QueryItem } from '../types';
import { implementationComingSoonAlert } from '../utils';
import { QueryEditorDrawer } from './QueryEditorDrawer';
type QueryListItemProps = {
query: QueryItem;
showModal: <T>(component: React.ComponentType<T>, props: T) => void;
hideModal: () => void;
updateComponent: () => void;
author: string;
date: string;
};
const options = {
type: 'edit',
} as const;
export const QueryListItem = memo(
({ query, showModal, hideModal, updateComponent, author, date }: QueryListItemProps) => {
const notifyApp = useAppNotification();
const styles = useStyles2(getStyles);
const [dsInfo, setDsInfo] = useState<DataSourceApi[]>([]);
useEffect(() => {
const getQueryDsInstance = async () => {
const uniqueUids = uniq(query?.ds_uid ?? []);
setDsInfo((await Promise.all(uniqueUids.map((dsUid) => getDataSourceSrv().get(dsUid)))).filter(Boolean));
};
getQueryDsInstance();
}, [query.ds_uid]);
const closeDrawer = () => {
hideModal();
updateComponent();
};
const openDrawer = async () => {
const result = await getSavedQuerySrv().getSavedQueries([{ uid: query.uid }]);
const savedQuery = result[0];
showModal(QueryEditorDrawer, { onDismiss: closeDrawer, savedQuery: savedQuery, options });
};
const deleteQuery = async () => {
await getSavedQuerySrv().deleteSavedQuery({ uid: query.uid });
updateComponent();
};
const getDsType = () => {
const dsType = dsInfo?.length > 1 ? 'mixed' : dsInfo?.[0]?.type ?? 'datasource';
return startWithUpperCase(dsType);
};
const startWithUpperCase = (dsType: string) => {
return dsType.charAt(0).toUpperCase() + dsType.slice(1);
};
const getTooltip = () => {
return (
<div>
<ul className={styles.dsTooltipList}>
{dsInfo.map((dsI, key) => {
return (
<li key={key}>
<img className={styles.dsTooltipIcon} src={dsI?.meta?.info.logos.small} alt="datasource" />
&nbsp;
{startWithUpperCase(dsI.type)}
</li>
);
})}
</ul>
</div>
);
};
const copyToClipboard = async () => {
const models = await getSavedQuerySrv().getSavedQueries([{ uid: query.uid }]);
if (!models?.length) {
implementationComingSoonAlert();
return;
}
await navigator.clipboard.writeText(
JSON.stringify(
{
...models[0],
uid: undefined,
storageOptions: undefined,
},
null,
2
)
);
notifyApp.success('Query JSON copied to clipboard!');
};
return (
<tr key={query.uid} className={styles.row}>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions*/}
<td onClick={implementationComingSoonAlert}>
<Icon name={'lock'} className={styles.disabled} title={'Implementation coming soon!'} />
</td>
<td>
<Badge color={'green'} text={'1'} icon={'link'} tooltip={'Implementation coming soon!'} />
</td>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions*/}
<td onClick={openDrawer}>{query.title}</td>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions*/}
<td onClick={openDrawer}>
<img
className={styles.dsIcon}
src={getDsType() === 'Mixed' ? 'public/img/icn-datasource.svg' : dsInfo[0]?.meta?.info.logos.small}
alt="datasource"
style={{ width: '16px', height: '16px' }}
/>
&nbsp;&nbsp;{getDsType()}&nbsp;
{getDsType() === 'Mixed' && (
<Tooltip content={getTooltip()}>
<Icon name={'question-circle'} className={styles.infoIcon} />
</Tooltip>
)}
</td>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions*/}
<td onClick={openDrawer}>
<img
className={cx('filter-table__avatar', styles.dsIcon)}
src={'/avatar/46d229b033af06a191ff2267bca9ae56'}
alt={`Avatar for ${author}`}
/>
&nbsp;&nbsp;{author}
</td>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions*/}
<td onClick={openDrawer}>{date}</td>
<td className={styles.tableTr}>
<IconButton name="share-alt" tooltip={'Share'} onClick={implementationComingSoonAlert} />
<IconButton name="copy" tooltip={'Copy'} onClick={copyToClipboard} />
<IconButton name="upload" tooltip={'Upload'} onClick={implementationComingSoonAlert} />
<IconButton name="cog" tooltip={'Settings'} onClick={implementationComingSoonAlert} />
<IconButton name="trash-alt" tooltip={'Delete'} onClick={deleteQuery} />
</td>
</tr>
);
}
);
QueryListItem.displayName = 'QueryListItem';
const getStyles = (theme: GrafanaTheme2) => {
return {
row: css`
height: 70px;
cursor: pointer;
`,
tableTr: css`
display: flex;
justify-content: space-between;
margin-top: 22px;
`,
disabled: css`
color: ${theme.colors.text.secondary};
`,
gitIcon: css`
width: 30px;
height: 30px;
margin-left: 10px;
margin-top: 1px;
opacity: 0.8;
`,
infoIcon: css`
margin-top: -2px;
`,
dsTooltipIcon: css`
width: 11px;
height: 11px;
`,
dsIcon: css`
width: 16px !important;
height: 16px !important;
`,
dsTooltipList: css`
list-style-type: none;
`,
};
};

View File

@ -0,0 +1,119 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Input, FieldValidationMessage, HorizontalGroup, useStyles2 } from '@grafana/ui';
export interface QueryNameProps {
name: string;
editingEnabled: boolean;
onChange: (v: string) => void;
}
export const QueryName = ({ name, onChange, editingEnabled }: QueryNameProps) => {
const styles = useStyles2(getStyles);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [validationError, setValidationError] = useState<string | null>(null);
const onEditQueryName = (event: React.SyntheticEvent) => {
setIsEditing(true);
};
const onEndEditName = (newName: string) => {
setIsEditing(false);
if (validationError) {
setValidationError(null);
return;
}
if (name !== newName) {
onChange(newName);
}
};
const onInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newName = event.currentTarget.value.trim();
if (newName.length === 0) {
setValidationError('An empty name is not allowed');
return;
}
if (validationError) {
setValidationError(null);
}
};
const onEditLayerBlur = (event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditName(event.currentTarget.value.trim());
};
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
if (!(event.target instanceof HTMLInputElement)) {
return;
}
onEndEditName(event.target.value);
}
};
return (
<>
<div className={styles.wrapper}>
{!isEditing && (
<HorizontalGroup>
<h2 className={styles.h2Style}>{name}</h2>
{editingEnabled && <Icon name="pen" className={styles.nameEditIcon} size="md" onClick={onEditQueryName} />}
</HorizontalGroup>
)}
{isEditing && (
<>
<Input
type="text"
defaultValue={name}
onBlur={onEditLayerBlur}
onFocus={onFocus}
autoFocus={true}
onKeyDown={onKeyDown}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.nameInput}
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
</div>
</>
);
};
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;
`,
};
};

View File

@ -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 (
<Modal
isOpen={true}
title={options.workflow === WorkflowID.PR ? 'Create a Pull Request' : 'Push changes'}
onDismiss={onCancel}
icon="exclamation-triangle"
className={css`
width: 500px;
`}
>
<Form
onSubmit={async (data: FormDTO) => {
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 }) => (
<VerticalGroup>
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
<VerticalGroup>
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button
type="submit"
disabled={false}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
{options.workflow === WorkflowID.PR ? 'Submit PR' : 'Push'}
</Button>
</VerticalGroup>
</VerticalGroup>
)}
</Form>
</Modal>
);
};

View File

@ -0,0 +1,166 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data/src';
import { Button, Card, Icon, IconName, Spinner, useStyles2 } from '@grafana/ui/src';
import { HorizontalGroup } from '../../plugins/admin/components/HorizontalGroup';
import { getGrafanaSearcher, SearchQuery } from '../../search/service';
import { SavedQuery } from '../api/SavedQueriesApi';
import { QueryItem } from '../types';
type Props = {
savedQuery: SavedQuery;
};
export const UsagesTab = ({ savedQuery }: Props) => {
const styles = useStyles2(getStyles);
const searchQuery = useMemo<SearchQuery>(() => {
const query: SearchQuery = {
query: '*',
kind: savedQuery.uid ? ['dashboard', 'alert'] : ['newQuery'], // workaround for new queries
saved_query_uid: savedQuery.uid,
};
return query;
}, [savedQuery.uid]);
const results = useAsync(async () => {
const raw = await getGrafanaSearcher().search(searchQuery);
return raw.view.map<QueryItem>((item) => ({
uid: item.uid,
title: item.name,
url: item.url,
uri: item.url,
type: item.kind,
id: 321, // do not use me!
tags: item.tags ?? [],
ds_uid: item.ds_uid,
location: item.location,
panel_type: item.panel_type,
}));
}, [searchQuery]);
if (results.loading) {
return <Spinner />;
}
const found = results.value;
const getIconForKind = (kind: string): IconName => {
let icon: IconName = 'question-circle';
switch (kind) {
case 'dashboard':
icon = 'apps';
break;
case 'folder':
icon = 'folder';
break;
case 'alert':
icon = 'bell';
break;
default:
icon = 'question-circle';
break;
}
return icon;
};
if (found?.length === 0) {
return (
<div className={styles.wrap}>
<p className={styles.usagesDescription}>This query is not used anywhere.</p>
</div>
);
}
return (
<div className={styles.wrap}>
<p className={styles.usagesDescription}>
This query is used in the places below. Modifying will affect all its usages.
</p>
{found?.map((item) => {
return (
<div key={item.uid}>
<Card>
<Card.Heading>
<span className={styles.cardHeading}>
{item.title}
<a
href={item.url}
title={'Open in new tab'}
target="_blank"
rel="noopener noreferrer"
className={styles.externalLink}
>
<Icon name="external-link-alt" className={styles.cardHeadingIcon} />
</a>
</span>
</Card.Heading>
<Card.Description>
<a href={'dashboards'} target="_blank" rel="noopener noreferrer" className={styles.externalLink}>
<Icon name="folder" className={styles.cardDescriptionIcon} />
</a>
{item.location}
</Card.Description>
<Card.Figure className={styles.cardFigure}>
<Icon name={getIconForKind(item.type)} />
</Card.Figure>
<Card.Tags>
<HorizontalGroup>
<Button icon="eye" size="sm" variant={'secondary'} />
<Button icon="link" size="sm" variant={'secondary'}>
Unlink
</Button>
</HorizontalGroup>
</Card.Tags>
</Card>
</div>
);
})}
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
wrap: css`
padding: 20px 5px 5px 5px;
`,
info: css`
padding-bottom: 30px;
`,
folderIcon: css`
margin-right: 5px;
`,
cardFigure: css`
margin-right: 0;
margin-top: 15px;
`,
externalLink: css`
margin-left: 5px;
`,
cardHeading: css`
display: flex;
`,
cardHeadingIcon: css`
width: 13px;
height: 13px;
color: ${theme.colors.text.secondary};
display: flex;
align-self: center;
`,
usagesDescription: css`
color: ${theme.colors.text.secondary};
`,
cardDescriptionIcon: css`
width: 16px;
height: 16px;
color: ${theme.colors.text.secondary};
margin-right: 5px;
`,
};
};

View File

@ -0,0 +1,166 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { LayerName } from 'app/core/components/Layers/LayerName';
import { SavedQuery, useUpdateSavedQueryMutation, Variable } from '../api/SavedQueriesApi';
import { SavedQueryUpdateOpts } from './QueryEditorDrawer';
type Props = {
savedQuery: SavedQuery;
options: SavedQueryUpdateOpts;
};
export const VariablesTab = ({ savedQuery, options }: Props) => {
const styles = useStyles2(getStyles);
const [updateSavedQuery] = useUpdateSavedQueryMutation();
const onVariableNameChange = (variable: Variable, newName: string) => {
const newVariables = savedQuery.variables.map((v: Variable) => {
if (v.name === variable.name) {
v.name = newName;
}
return v;
});
updateSavedQuery({
query: {
...savedQuery,
variables: newVariables,
},
opts: options,
});
};
const onVariableValueChange = (variable: Variable, newValue: string) => {
const newVariables = savedQuery.variables.map((v: Variable) => {
if (v.name === variable.name) {
v.current.value = newValue;
}
return v;
});
updateSavedQuery({
query: {
...savedQuery,
variables: newVariables,
},
opts: options,
});
};
const onAddVariable = () => {
// NOTE: doing mutation to force re-render
savedQuery.variables.unshift({
name: 'New variable',
current: {
value: 'General',
},
});
updateSavedQuery({ query: savedQuery, opts: options });
};
const onRemoveVariable = (variable: Variable) => {
const varIndex = savedQuery.variables.map((v: Variable, index: number) => {
if (v.name === variable.name) {
return index;
}
return;
});
if (typeof varIndex === 'number') {
// NOTE: doing mutation vs filter to force re-render
savedQuery.variables.splice(varIndex, 1);
updateSavedQuery({ query: savedQuery, opts: options });
}
};
return (
<div className={styles.tabWrapper}>
<div className={styles.variablesHeader}>
<HorizontalGroup width="100%" justify="space-between" spacing={'md'} height={25}>
<div className={styles.tabDescription}>
Variables enable more interactive and dynamic queries. Instead of hard-coding things like server or sensor
names in your metric queries you can use variables in their place. <br />
<b>Variable support is coming soon!</b>
</div>
<Button icon="plus" size="md" className={styles.addVariableButton} onClick={onAddVariable}>
Add variable
</Button>
</HorizontalGroup>
</div>
<div className={styles.variableList}>
<ul>
{savedQuery &&
savedQuery.variables &&
savedQuery.variables.map((variable: Variable) => (
<li key={variable && variable.name} className={styles.variableListItem}>
<Card>
<Card.Heading>
<LayerName
name={variable && variable.name}
onChange={(v) => onVariableNameChange(variable, v)}
overrideStyles
/>
</Card.Heading>
<Card.Description>
<LayerName
name={variable && variable.current.value.toString()}
onChange={(v) => onVariableValueChange(variable, v)}
overrideStyles
/>
</Card.Description>
<Card.Tags>
<Button
icon="trash-alt"
size="sm"
variant={'secondary'}
tooltip="Delete this variable"
onClick={() => onRemoveVariable(variable)}
>
Delete
</Button>
</Card.Tags>
</Card>
</li>
))}
</ul>
</div>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
tabWrapper: css`
flex: 1;
padding: 20px 5px 5px 5px;
`,
tabDescription: css`
color: ${theme.colors.text.secondary};
`,
variableList: css`
padding-bottom: 20px;
`,
variableListItem: css`
list-style: none;
`,
addVariableButton: css`
display: flex;
align-self: center;
margin: auto;
margin-bottom: 15px;
`,
variablesHeader: css`
margin-top: 15px;
margin-bottom: 20px;
`,
};
};

View File

@ -0,0 +1,28 @@
import { css } from '@emotion/react';
import { GrafanaTheme2 } from '@grafana/data';
export function getGlobalStyles(theme: GrafanaTheme2) {
return css`
.filter-table {
border-collapse: separate;
border-spacing: 0 5px;
tbody {
tr:nth-child(odd) {
background: ${theme.colors.background.secondary};
}
tr {
background: ${theme.colors.background.secondary};
}
}
&--hover {
tbody tr:hover {
background: ${theme.colors.background.primary};
}
}
}
`;
}

View File

@ -0,0 +1,10 @@
<svg width="1024" height="1024" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.25 2.5V1M2.5 10H1m1.5-7.5L4 4m18 6h1.5M22 2.5 20.5 4" stroke="#F3C90E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.682 4.75h5.143a3.75 3.75 0 0 1 3.7 3.338l1.133 10.22A3.75 3.75 0 0 1 22.75 22a2.25 2.25 0 0 1-2.25 2.25H4A2.25 2.25 0 0 1 1.75 22a3.75 3.75 0 0 1 3.051-3.684L5.935 8.088A3.75 3.75 0 0 1 9.682 4.75Zm9.298 15H5.484A2.25 2.25 0 0 0 3.25 22a.75.75 0 0 0 .75.75h16.5a.75.75 0 0 0 .75-.75A2.25 2.25 0 0 0 19 19.75h-.02ZM17.035 8.253l1.107 9.997H6.318l1.107-9.997a2.25 2.25 0 0 1 2.25-2.003h5.142a2.25 2.25 0 0 1 2.218 2.003Zm-1.382 2.115a.75.75 0 0 0-1.306-.736l-3.145 5.574v.002l-1.603-2.123a.75.75 0 1 0-1.198.904l1.627 2.155c.144.193.334.36.564.47.227.11.48.156.737.128a1.41 1.41 0 0 0 .69-.253c.204-.143.366-.33.484-.536l.004-.006 3.146-5.579Z" fill="url(#a)"/>
<defs>
<linearGradient id="a" x1="12.25" y1="5" x2="12.25" y2="24.25" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAC00E"/>
<stop offset="1" stop-color="#F26526"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 135.46 118.24"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="67.73" y1="137.67" x2="67.73" y2="-0.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f9ea1c"/><stop offset="1" stop-color="#ed5a29"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M135.37,64.86l-5.6-26.43a4.05,4.05,0,0,0-.9-1.82L113.19,18.24a4.11,4.11,0,0,0-1-.87L83.71.57A4.18,4.18,0,0,0,81.62,0H58.54a4.19,4.19,0,0,0-1,.12L26,8a4.16,4.16,0,0,0-2,1.17L1.12,33.32A4.1,4.1,0,0,0,0,36.14v24.2A4.1,4.1,0,0,0,2.46,64.1l22.85,10L39,95.07a4.13,4.13,0,0,0,3.45,1.86H68.66L81.59,116.4A4.09,4.09,0,0,0,85,118.24h0l6,0a4.11,4.11,0,0,0,4.09-4.11V90.21H117a4.09,4.09,0,0,0,3.36-1.75l14.34-20.38A4.1,4.1,0,0,0,135.37,64.86ZM86.12,34.1a3.29,3.29,0,0,0-.67.77l0,.05H59.83L57,30.53A3.38,3.38,0,0,0,54.24,29a3.45,3.45,0,0,0-2.86,1.45l-3.15,4.51H10.92L21.64,23.58H98.3Zm-3,4.52L72.77,55.23,62.19,38.62ZM37.75,50H8.22V38.62H45.66ZM29.12,15.65,59.05,8.22H80.49l19.74,11.66H25.13Zm-20.9,38h27L26.73,65.78,8.22,57.65ZM114.87,82h-4V46.21a2.3,2.3,0,0,0-2.3-2.3h-1.39a2.3,2.3,0,0,0-2.3,2.3V82h-12V63.92a2.31,2.31,0,0,0-2.3-2.31H89.21a2.31,2.31,0,0,0-2.3,2.31v45.64L74.48,90.84V78.36a2.3,2.3,0,0,0-2.3-2.3H70.79a2.3,2.3,0,0,0-2.3,2.3V88.71H56.23V58.54a2.32,2.32,0,0,0-2.31-2.31H52.54a2.32,2.32,0,0,0-2.31,2.31V88.71H44.64l-12.39-19L53.93,38.62h.21l15.8,24.81A3.32,3.32,0,0,0,72.82,65a3.39,3.39,0,0,0,2.87-1.6L91,38.9l16.73-14.45,14.26,16.7,5,23.66Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,9 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.3009 29.8017H30.6991C32.0765 29.8017 32.9322 28.313 32.2435 27.1235L25.5443 15.52C24.8557 14.3304 23.1374 14.3304 22.4557 15.52L15.7565 27.1235C15.0678 28.313 15.9304 29.8017 17.3009 29.8017ZM24 0C10.7478 0 0 10.7478 0 24C0 37.2522 10.7478 48 24 48C37.2522 48 48 37.2522 48 24C48 10.7478 37.2522 0 24 0ZM24.0626 10.9774C31.2557 11.0122 37.0574 16.8696 37.0226 24.0626C36.9878 31.2557 31.1304 37.0574 23.9374 37.0226C16.7443 36.9878 10.9426 31.1304 10.9774 23.9374C11.0122 16.7443 16.8696 10.9426 24.0626 10.9774ZM6.94261 31.3809C6.79652 31.4296 6.65739 31.4504 6.5113 31.4504C5.92696 31.4504 5.37739 31.0748 5.18957 30.4904C4.53565 28.48 4.2087 26.3165 4.2087 24.0626C4.2087 22.8104 4.31304 21.5026 4.52174 20.1878C6.09391 12.2017 12.2296 6.03826 20.16 4.4313C20.9183 4.27826 21.6487 4.76522 21.8017 5.51652C21.9548 6.26783 21.4678 7.00522 20.7165 7.15826C13.8922 8.53565 8.61217 13.8435 7.26957 20.6678C7.09565 21.7878 7.00522 22.9496 7.00522 24.0557C7.00522 26.0104 7.29043 27.8887 7.85391 29.6209C8.09044 30.3513 7.69391 31.1374 6.95652 31.3739L6.94261 31.3809ZM39.1791 37.127C35.52 41.4817 30.2539 43.8748 24.3548 43.8748C18.4557 43.8748 12.8626 41.447 9.05739 37.2174C8.54261 36.647 8.5913 35.7635 9.16174 35.2557C9.73217 34.7409 10.6157 34.7896 11.1235 35.36C14.3513 38.9496 19.2904 41.0991 24.3478 41.0991C29.4052 41.0991 33.92 39.0539 37.0435 35.3391C37.5374 34.7548 38.4139 34.6713 39.0052 35.1722C39.5965 35.6661 39.6661 36.5426 39.1722 37.1339L39.1791 37.127ZM42.9426 30.4C42.7409 30.9774 42.2052 31.3322 41.6278 31.3322C41.4748 31.3322 41.3217 31.3043 41.1687 31.2557C40.4452 31.0052 40.0626 30.2122 40.313 29.4817C40.5565 28.7722 40.7791 28.0278 40.96 27.2626C41.1687 26.2122 41.28 25.0852 41.28 23.9722C41.28 15.8052 35.4574 8.73044 27.4296 7.14435C26.6783 6.99826 26.1565 6.26783 26.2957 5.51652C26.4348 4.76522 27.1304 4.26435 27.8748 4.39652C27.8887 4.39652 27.9513 4.41043 27.9652 4.41739C37.287 6.25391 44.0557 14.4835 44.0557 23.9722C44.0557 25.2661 43.9304 26.5739 43.673 27.8539C43.4574 28.7652 43.2139 29.6 42.9357 30.4H42.9426Z" fill="url(#paint0_linear_911_12416)"/>
<defs>
<linearGradient id="paint0_linear_911_12416" x1="24.3556" y1="47.7468" x2="24.3556" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAC10D"/>
<stop offset="1" stop-color="#F05A28"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,20 @@
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
export function getRoutes(): RouteDescriptor[] {
if (config.featureToggles.queryLibrary) {
return [
{
path: `/query-library`,
exact: true,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "QueryLibraryPage" */ 'app/features/query-library/components/QueryLibraryPage')
),
},
];
}
return [];
}

View File

@ -0,0 +1,30 @@
import { SavedQueryRef } from './api/SavedQueriesApi';
export interface QueryItem {
id: number;
selected?: boolean;
tags: string[];
title: string;
type: string;
uid: string;
ds_uid: string[];
uri: string;
url: string;
sortMeta?: number;
sortMetaName?: string;
location?: string;
}
type SavedQueryVariable<T = unknown> = {
type: 'text' | 'datasource' | string; // TODO: enumify
name: string;
current: {
// current.value follows the structure from dashboard variables
value: T;
};
};
export type SavedQueryLink = {
ref: SavedQueryRef;
variables: SavedQueryVariable[];
};

View File

@ -0,0 +1,14 @@
import { DataQuery } from '@grafana/data/src';
export const defaultQuery: DataQuery = {
refId: 'A',
datasource: {
type: 'datasource',
uid: 'grafana',
},
queryType: 'measurements',
};
export const implementationComingSoonAlert = () => {
alert('Implementation coming soon!');
};

View File

@ -20,14 +20,17 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { addQuery } from 'app/core/utils/query';
import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard';
import { QueryGroupOptions } from 'app/types';
import { QueryGroupDataSource, QueryGroupOptions } from 'app/types';
import { isQueryWithMixedDatasource } from '../../query-library/api/SavedQueriesApi';
import { getSavedQuerySrv } from '../../query-library/api/SavedQueriesSrv';
import { PanelQueryRunner } from '../state/PanelQueryRunner';
import { updateQueries } from '../state/updateQueries';
import { GroupActionComponents } from './QueryActionComponent';
import { QueryEditorRows } from './QueryEditorRows';
import { QueryGroupOptionsEditor } from './QueryGroupOptions';
import { SavedQueryPicker } from './SavedQueryPicker';
interface Props {
queryRunner: PanelQueryRunner;
@ -49,6 +52,12 @@ interface State {
isHelpOpen: boolean;
defaultDataSource?: DataSourceApi;
scrollElement?: HTMLDivElement;
savedQueryUid?: string | null;
initialState: {
queries: DataQuery[];
dataSource?: QueryGroupDataSource;
savedQueryUid?: string | null;
};
}
export class QueryGroup extends PureComponent<Props, State> {
@ -63,6 +72,11 @@ export class QueryGroup extends PureComponent<Props, State> {
isAddingMixed: false,
isHelpOpen: false,
queries: [],
savedQueryUid: null,
initialState: {
queries: [],
savedQueryUid: null,
},
data: {
state: LoadingState.NotStarted,
series: [],
@ -71,7 +85,7 @@ export class QueryGroup extends PureComponent<Props, State> {
};
async componentDidMount() {
const { queryRunner, options } = this.props;
const { options, queryRunner } = this.props;
this.querySubscription = queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({
next: (data: PanelData) => this.onPanelDataUpdate(data),
@ -83,7 +97,18 @@ export class QueryGroup extends PureComponent<Props, State> {
const defaultDataSource = await this.dataSourceSrv.get();
const datasource = ds.getRef();
const queries = options.queries.map((q) => (q.datasource ? q : { ...q, datasource }));
this.setState({ queries, dataSource: ds, dsSettings, defaultDataSource });
this.setState({
queries,
dataSource: ds,
dsSettings,
defaultDataSource,
savedQueryUid: options.savedQueryUid,
initialState: {
queries: options.queries.map((q) => ({ ...q })),
dataSource: { ...options.dataSource },
savedQueryUid: options.savedQueryUid,
},
});
} catch (error) {
console.log('failed to load data source', error);
}
@ -111,6 +136,7 @@ export class QueryGroup extends PureComponent<Props, State> {
const dataSource = await this.dataSourceSrv.get(newSettings.name);
this.onChange({
queries,
savedQueryUid: null,
dataSource: {
name: newSettings.name,
uid: newSettings.uid,
@ -121,11 +147,75 @@ export class QueryGroup extends PureComponent<Props, State> {
this.setState({
queries,
savedQueryUid: null,
dataSource: dataSource,
dsSettings: newSettings,
});
};
onChangeSavedQuery = async (savedQueryUid: string | null) => {
if (!savedQueryUid?.length) {
// leave the queries, remove the link
this.onChange({
queries: this.state.queries,
savedQueryUid: null,
dataSource: {
name: this.state.dsSettings?.name,
uid: this.state.dsSettings?.uid,
type: this.state.dsSettings?.meta.id,
default: this.state.dsSettings?.isDefault,
},
});
this.setState({
queries: this.state.queries,
savedQueryUid: null,
dataSource: this.state.dataSource,
dsSettings: this.state.dsSettings,
});
return;
}
const { dsSettings } = this.state;
const currentDS = dsSettings ? await getDataSourceSrv().get(dsSettings.uid) : undefined;
const resp = await getSavedQuerySrv().getSavedQueries([{ uid: savedQueryUid }]);
if (!resp?.length) {
throw new Error('TODO error handling');
}
const savedQuery = resp[0];
const isMixedDatasource = isQueryWithMixedDatasource(savedQuery);
const nextDS = isMixedDatasource
? await getDataSourceSrv().get('-- Mixed --')
: await getDataSourceSrv().get(savedQuery.queries[0].datasource?.uid);
// We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value
const queries = await updateQueries(nextDS, nextDS.uid, savedQuery.queries, currentDS);
const newDsSettings = await getDataSourceSrv().getInstanceSettings(nextDS.uid);
if (!newDsSettings) {
throw new Error('TODO error handling');
}
this.onChange({
queries,
savedQueryUid: savedQueryUid,
dataSource: {
name: newDsSettings.name,
uid: newDsSettings.uid,
type: newDsSettings.meta.id,
default: newDsSettings.isDefault,
},
});
this.setState({
queries,
savedQueryUid,
dataSource: nextDS,
dsSettings: newDsSettings,
});
};
onAddQueryClick = () => {
const { queries } = this.state;
this.onQueriesChange(addQuery(queries, this.newQuery()));
@ -220,6 +310,18 @@ export class QueryGroup extends PureComponent<Props, State> {
</>
)}
</div>
{config.featureToggles.queryLibrary && (
<>
<div className={styles.dataSourceRow}>
<InlineFormLabel htmlFor="saved-query-picker" width={'auto'}>
Saved query
</InlineFormLabel>
<div className={styles.dataSourceRowItem}>
<SavedQueryPicker current={this.state.savedQueryUid} onChange={this.onChangeSavedQuery} />
</div>
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,116 @@
// Libraries
import React, { useMemo } from 'react';
// Components
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsyncFn';
import { DataSourceInstanceSettings, isUnsignedPluginSignature, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getDataSourceSrv } from '@grafana/runtime/src';
import { HorizontalGroup, PluginSignatureBadge, Select } from '@grafana/ui';
import { getGrafanaSearcher, QueryResponse, SearchQuery } from '../../search/service';
export type SavedQueryPickerProps = {
onChange: (savedQueryUid: 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;
};
function getSavedQueryPickerOptions(results: AsyncState<QueryResponse>): Array<SelectableValue<string>> {
if (results?.loading) {
return [];
}
if (!results?.value?.totalRows) {
return [];
}
const hits = results.value.view.toArray();
return hits.map((h) => {
const dsSettings = h.ds_uid?.length ? getDataSourceSrv().getInstanceSettings(h.ds_uid[0]) : undefined;
return {
value: h.uid,
label: h.name,
imgUrl: dsSettings?.meta.info.logos.small,
meta: dsSettings?.meta,
};
});
}
export const SavedQueryPicker = (props: SavedQueryPickerProps) => {
const { autoFocus, onBlur, onChange, current, openMenuOnFocus, placeholder, width, inputId } = props;
const searchQuery = useMemo<SearchQuery>(() => {
// TODO: ensure we fetch all saved queries?
const query: SearchQuery = {
query: '*',
explain: true,
kind: ['query'],
};
return query;
}, []);
const results = useAsync(async () => {
return getGrafanaSearcher().search(searchQuery);
}, [searchQuery]);
const options = getSavedQueryPickerOptions(results);
return (
<div aria-label={selectors.components.DataSourcePicker.container}>
<Select
aria-label={selectors.components.DataSourcePicker.inputV2}
inputId={inputId || 'data-source-picker'}
className="ds-picker select-container"
isMulti={false}
isClearable={true}
backspaceRemovesValue={true}
options={options}
autoFocus={autoFocus}
onBlur={onBlur}
width={width}
value={current}
onChange={(newValue) => {
onChange(newValue?.value ?? null);
}}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={500}
placeholder={placeholder ?? 'Select query from the library'}
noOptionsMessage="No queries found"
getOptionLabel={(o) => {
if (o.meta && isUnsignedPluginSignature(o.meta.signature)) {
return (
<HorizontalGroup align="center" justify="space-between">
<span>{o.label}</span> <PluginSignatureBadge status={o.meta.signature} />
</HorizontalGroup>
);
}
return o.label || '';
}}
/>
</div>
);
};

View File

@ -145,6 +145,7 @@ export function getDefaultState(): State {
name: 'gdev-testdata',
},
maxDataPoints: 100,
savedQueryUid: null,
},
};
}

View File

@ -11,6 +11,8 @@ export interface SearchQuery {
location?: string;
sort?: string;
ds_uid?: string;
ds_type?: string;
saved_query_uid?: string; // TODO: not implemented yet
tags?: string[];
kind?: string[];
panel_type?: string;

View File

@ -15,6 +15,7 @@ import { getLiveRoutes } from 'app/features/live/pages/routes';
import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes';
import { getAppPluginRoutes } from 'app/features/plugins/routes';
import { getProfileRoutes } from 'app/features/profile/routes';
import { getRoutes as getQueryLibraryRoutes } from 'app/features/query-library/routes';
import { AccessControlAction, DashboardRoutes } from 'app/types';
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
@ -502,6 +503,7 @@ export function getAppRoutes(): RouteDescriptor[] {
...getLiveRoutes(),
...getAlertingRoutes(),
...getProfileRoutes(),
...getQueryLibraryRoutes(),
...extraRoutes,
...getPublicDashboardRoutes(),
...getDataConnectionsRoutes(),

View File

@ -3,6 +3,7 @@ import { DataQuery, DataSourceRef } from '@grafana/data';
export interface QueryGroupOptions {
queries: DataQuery[];
dataSource: QueryGroupDataSource;
savedQueryUid?: string | null;
maxDataPoints?: number | null;
minInterval?: string | null;
cacheTimeout?: string | null;