mirror of
https://github.com/grafana/grafana.git
synced 2025-09-20 11:23:00 +08:00
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:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
72
public/app/features/query-library/api/SavedQueriesApi.ts
Normal file
72
public/app/features/query-library/api/SavedQueriesApi.ts
Normal 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;
|
26
public/app/features/query-library/api/SavedQueriesSrv.ts
Normal file
26
public/app/features/query-library/api/SavedQueriesSrv.ts
Normal 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;
|
123
public/app/features/query-library/components/CreateNewQuery.tsx
Normal file
123
public/app/features/query-library/components/CreateNewQuery.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
27
public/app/features/query-library/components/HistoryTab.tsx
Normal file
27
public/app/features/query-library/components/HistoryTab.tsx
Normal 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};
|
||||
`,
|
||||
};
|
||||
};
|
25
public/app/features/query-library/components/Queries.tsx
Normal file
25
public/app/features/query-library/components/Queries.tsx
Normal 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%;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
143
public/app/features/query-library/components/QueryEditor.tsx
Normal file
143
public/app/features/query-library/components/QueryEditor.tsx
Normal 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 </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)};
|
||||
`,
|
||||
};
|
||||
};
|
@ -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);
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
198
public/app/features/query-library/components/QueryListItem.tsx
Normal file
198
public/app/features/query-library/components/QueryListItem.tsx
Normal 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" />
|
||||
|
||||
{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' }}
|
||||
/>
|
||||
{getDsType()}
|
||||
{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}`}
|
||||
/>
|
||||
{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;
|
||||
`,
|
||||
};
|
||||
};
|
119
public/app/features/query-library/components/QueryName.tsx
Normal file
119
public/app/features/query-library/components/QueryName.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
166
public/app/features/query-library/components/UsagesTab.tsx
Normal file
166
public/app/features/query-library/components/UsagesTab.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
166
public/app/features/query-library/components/VariablesTab.tsx
Normal file
166
public/app/features/query-library/components/VariablesTab.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
28
public/app/features/query-library/globalStyles.ts
Normal file
28
public/app/features/query-library/globalStyles.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
10
public/app/features/query-library/img/grafana_incident.svg
Normal file
10
public/app/features/query-library/img/grafana_incident.svg
Normal 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 |
1
public/app/features/query-library/img/grafana_ml.svg
Normal file
1
public/app/features/query-library/img/grafana_ml.svg
Normal 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 |
9
public/app/features/query-library/img/grafana_oncall.svg
Normal file
9
public/app/features/query-library/img/grafana_oncall.svg
Normal 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 |
20
public/app/features/query-library/routes.tsx
Normal file
20
public/app/features/query-library/routes.tsx
Normal 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 [];
|
||||
}
|
30
public/app/features/query-library/types.ts
Normal file
30
public/app/features/query-library/types.ts
Normal 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[];
|
||||
};
|
14
public/app/features/query-library/utils.ts
Normal file
14
public/app/features/query-library/utils.ts
Normal 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!');
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
116
public/app/features/query/components/SavedQueryPicker.tsx
Normal file
116
public/app/features/query/components/SavedQueryPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -145,6 +145,7 @@ export function getDefaultState(): State {
|
||||
name: 'gdev-testdata',
|
||||
},
|
||||
maxDataPoints: 100,
|
||||
savedQueryUid: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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(),
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user