mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
QueryHistory: Improve handling of mixed datasource entries (#62214)
* QueryHistory: Improve handling of mixed datasource entries * remove todo * remove todo * fix comment submit test * disable running queries if at least one doesn't have a datasource * remove unnecessary code * add tests for diabled buttons state
This commit is contained in:
parent
f985f02584
commit
c66ab3a9e4
@ -234,12 +234,9 @@ export function createQueryHeading(query: RichHistoryQuery, sortOrder: SortOrder
|
||||
return heading;
|
||||
}
|
||||
|
||||
export function createQueryText(query: DataQuery, queryDsInstance: DataSourceApi | undefined) {
|
||||
/* query DatasourceInstance is necessary because we use its getQueryDisplayText method
|
||||
* to format query text
|
||||
*/
|
||||
if (queryDsInstance?.getQueryDisplayText) {
|
||||
return queryDsInstance.getQueryDisplayText(query);
|
||||
export function createQueryText(query: DataQuery, dsApi?: DataSourceApi) {
|
||||
if (dsApi?.getQueryDisplayText) {
|
||||
return dsApi.getQueryDisplayText(query);
|
||||
}
|
||||
|
||||
return getQueryDisplayText(query);
|
||||
@ -270,7 +267,6 @@ export function createDatasourcesList() {
|
||||
return {
|
||||
name: dsSettings.name,
|
||||
uid: dsSettings.uid,
|
||||
imgUrl: dsSettings.meta.info.logos.small,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { render, screen, fireEvent, getByText } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, getByText, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourceApi } from '@grafana/data';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
|
||||
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { MixedDatasource } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
import { ExploreId, RichHistoryQuery } from 'app/types/explore';
|
||||
|
||||
@ -14,10 +13,54 @@ import { RichHistoryCard, Props } from './RichHistoryCard';
|
||||
const starRichHistoryMock = jest.fn();
|
||||
const deleteRichHistoryMock = jest.fn();
|
||||
|
||||
const mockDS = mockDataSource({
|
||||
name: 'CloudManager',
|
||||
type: DataSourceType.Alertmanager,
|
||||
});
|
||||
class MockDatasourceApi<T extends DataQuery> implements DataSourceApi<T> {
|
||||
name: string;
|
||||
id: number;
|
||||
type: string;
|
||||
uid: string;
|
||||
meta: DataSourcePluginMeta<{}>;
|
||||
|
||||
constructor(name: string, id: number, type: string, uid: string, others?: Partial<DataSourceApi>) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.uid = uid;
|
||||
this.meta = {
|
||||
info: {
|
||||
logos: {
|
||||
small: `${type}.png`,
|
||||
},
|
||||
},
|
||||
} as DataSourcePluginMeta;
|
||||
|
||||
Object.assign(this, others);
|
||||
}
|
||||
|
||||
query(): ReturnType<DataSourceApi['query']> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
testDatasource(): ReturnType<DataSourceApi['testDatasource']> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getRef(): DataSourceRef {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
const dsStore: Record<string, DataSourceApi> = {
|
||||
alertmanager: new MockDatasourceApi('Alertmanager', 3, 'alertmanager', 'alertmanager'),
|
||||
loki: new MockDatasourceApi('Loki', 2, 'loki', 'loki'),
|
||||
prometheus: new MockDatasourceApi<MockQuery>('Prometheus', 1, 'prometheus', 'prometheus', {
|
||||
getQueryDisplayText: (query: MockQuery) => query.queryText || 'Unknwon query',
|
||||
}),
|
||||
mixed: new MixedDatasource({
|
||||
id: 4,
|
||||
name: 'Mixed',
|
||||
type: 'mixed',
|
||||
uid: 'mixed',
|
||||
meta: { info: { logos: { small: 'mixed.png' } }, mixed: true },
|
||||
} as DataSourceInstanceSettings),
|
||||
};
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
@ -27,19 +70,33 @@ jest.mock('@grafana/runtime', () => ({
|
||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
||||
return {
|
||||
getDataSourceSrv: () => ({
|
||||
get: () => Promise.resolve(mockDS),
|
||||
getList: () => [mockDS],
|
||||
getInstanceSettings: () => mockDS,
|
||||
get: (ref: DataSourceRef | string) => {
|
||||
const uid = typeof ref === 'string' ? ref : ref.uid;
|
||||
if (!uid) {
|
||||
return Promise.reject();
|
||||
}
|
||||
if (dsStore[uid]) {
|
||||
return Promise.resolve(dsStore[uid]);
|
||||
}
|
||||
return Promise.reject();
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const copyStringToClipboard = jest.fn();
|
||||
jest.mock('app/core/utils/explore', () => ({
|
||||
...jest.requireActual('app/core/utils/explore'),
|
||||
copyStringToClipboard: (str: string) => copyStringToClipboard(str),
|
||||
}));
|
||||
|
||||
jest.mock('app/core/app_events', () => ({
|
||||
publish: jest.fn(),
|
||||
}));
|
||||
|
||||
interface MockQuery extends DataQuery {
|
||||
query: string;
|
||||
queryText?: string;
|
||||
}
|
||||
|
||||
const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
||||
@ -47,8 +104,8 @@ const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
||||
query: {
|
||||
id: '1',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'Test datasource uid',
|
||||
datasourceName: 'Test datasource',
|
||||
datasourceUid: 'loki',
|
||||
datasourceName: 'Loki',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [
|
||||
@ -57,15 +114,13 @@ const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
||||
{ query: 'query3', refId: 'C' },
|
||||
],
|
||||
},
|
||||
dsImg: '/app/img',
|
||||
isRemoved: false,
|
||||
changeDatasource: jest.fn(),
|
||||
starHistoryItem: starRichHistoryMock,
|
||||
deleteHistoryItem: deleteRichHistoryMock,
|
||||
commentHistoryItem: jest.fn(),
|
||||
setQueries: jest.fn(),
|
||||
exploreId: ExploreId.left,
|
||||
datasourceInstance: { name: 'Datasource' } as DataSourceApi,
|
||||
datasourceInstance: dsStore.loki,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@ -107,12 +162,226 @@ describe('RichHistoryCard', () => {
|
||||
expect(datasourceIcon).toBeInTheDocument();
|
||||
expect(datasourceName).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Data source does not exist anymore" if removed data source', async () => {
|
||||
setup({ isRemoved: true });
|
||||
setup({
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'non-existent DS',
|
||||
datasourceName: 'Test datasource',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [
|
||||
{ query: 'query1', refId: 'A' },
|
||||
{ query: 'query2', refId: 'B' },
|
||||
{ query: 'query3', refId: 'C' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const datasourceName = await screen.findByLabelText('Data source name');
|
||||
expect(datasourceName).toHaveTextContent('Data source does not exist anymore');
|
||||
});
|
||||
|
||||
describe('copy queries to clipboard', () => {
|
||||
it('should copy query model to clipboard when copying a query from a non existent datasource', async () => {
|
||||
setup({
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'non-existent DS',
|
||||
datasourceName: 'Test datasource',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [{ query: 'query1', refId: 'A' }],
|
||||
},
|
||||
});
|
||||
const copyQueriesButton = await screen.findByRole('button', { name: 'Copy query to clipboard' });
|
||||
expect(copyQueriesButton).toBeInTheDocument();
|
||||
fireEvent.click(copyQueriesButton);
|
||||
await waitFor(() => {
|
||||
expect(copyStringToClipboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(copyStringToClipboard).toHaveBeenCalledWith(JSON.stringify({ query: 'query1' }));
|
||||
});
|
||||
|
||||
it('should copy query model to clipboard when copying a query from a datasource that does not implement getQueryDisplayText', async () => {
|
||||
setup({
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'loki',
|
||||
datasourceName: 'Test datasource',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [{ query: 'query1', refId: 'A' }],
|
||||
},
|
||||
});
|
||||
const copyQueriesButton = await screen.findByRole('button', { name: 'Copy query to clipboard' });
|
||||
expect(copyQueriesButton).toBeInTheDocument();
|
||||
fireEvent.click(copyQueriesButton);
|
||||
await waitFor(() => {
|
||||
expect(copyStringToClipboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(copyStringToClipboard).toHaveBeenCalledWith(JSON.stringify({ query: 'query1' }));
|
||||
});
|
||||
|
||||
it('should copy query text to clipboard when copying a query from a datasource that implements getQueryDisplayText', async () => {
|
||||
setup({
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'prometheus',
|
||||
datasourceName: 'Test datasource',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [{ query: 'query1', refId: 'A', queryText: 'query1' }],
|
||||
},
|
||||
});
|
||||
const copyQueriesButton = await screen.findByRole('button', { name: 'Copy query to clipboard' });
|
||||
expect(copyQueriesButton).toBeInTheDocument();
|
||||
fireEvent.click(copyQueriesButton);
|
||||
await waitFor(() => {
|
||||
expect(copyStringToClipboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(copyStringToClipboard).toHaveBeenCalledWith('query1');
|
||||
});
|
||||
|
||||
it('should use each datasource getQueryDisplayText when copying queries', async () => {
|
||||
setup({
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'mixed',
|
||||
datasourceName: 'Mixed',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries: [
|
||||
{ query: 'query1', refId: 'A', queryText: 'query1', datasource: { uid: 'prometheus' } },
|
||||
{ query: 'query2', refId: 'B', datasource: { uid: 'loki' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const copyQueriesButton = await screen.findByRole('button', { name: 'Copy query to clipboard' });
|
||||
expect(copyQueriesButton).toBeInTheDocument();
|
||||
fireEvent.click(copyQueriesButton);
|
||||
await waitFor(() => {
|
||||
expect(copyStringToClipboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(copyStringToClipboard).toHaveBeenCalledWith(`query1\n${JSON.stringify({ query: 'query2' })}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run queries', () => {
|
||||
it('should be disabled if at least one query datasource is missing when using mixed', async () => {
|
||||
const setQueries = jest.fn();
|
||||
const changeDatasource = jest.fn();
|
||||
const queries: MockQuery[] = [
|
||||
{ query: 'query1', refId: 'A', datasource: { uid: 'nonexistent-ds' } },
|
||||
{ query: 'query2', refId: 'B', datasource: { uid: 'loki' } },
|
||||
];
|
||||
setup({
|
||||
setQueries,
|
||||
changeDatasource,
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'mixed',
|
||||
datasourceName: 'Mixed',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries,
|
||||
},
|
||||
});
|
||||
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
|
||||
|
||||
expect(runQueryButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled if at datasource is missing', async () => {
|
||||
const setQueries = jest.fn();
|
||||
const changeDatasource = jest.fn();
|
||||
const queries: MockQuery[] = [
|
||||
{ query: 'query1', refId: 'A' },
|
||||
{ query: 'query2', refId: 'B' },
|
||||
];
|
||||
setup({
|
||||
setQueries,
|
||||
changeDatasource,
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'nonexistent-ds',
|
||||
datasourceName: 'nonexistent-ds',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries,
|
||||
},
|
||||
});
|
||||
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
|
||||
|
||||
expect(runQueryButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should only set new queries when running queries from the same datasource', async () => {
|
||||
const setQueries = jest.fn();
|
||||
const changeDatasource = jest.fn();
|
||||
const queries: MockQuery[] = [
|
||||
{ query: 'query1', refId: 'A' },
|
||||
{ query: 'query2', refId: 'B' },
|
||||
];
|
||||
setup({
|
||||
setQueries,
|
||||
changeDatasource,
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'loki',
|
||||
datasourceName: 'Loki',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries,
|
||||
},
|
||||
});
|
||||
|
||||
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
|
||||
fireEvent.click(runQueryButton);
|
||||
|
||||
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
|
||||
expect(changeDatasource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should change datasource to mixed and set new queries when running queries from mixed datasource', async () => {
|
||||
const setQueries = jest.fn();
|
||||
const changeDatasource = jest.fn();
|
||||
const queries: MockQuery[] = [
|
||||
{ query: 'query1', refId: 'A', datasource: { type: 'loki', uid: 'loki' } },
|
||||
{ query: 'query2', refId: 'B', datasource: { type: 'prometheus', uid: 'prometheus' } },
|
||||
];
|
||||
setup({
|
||||
setQueries,
|
||||
changeDatasource,
|
||||
query: {
|
||||
id: '2',
|
||||
createdAt: 1,
|
||||
datasourceUid: 'mixed',
|
||||
datasourceName: 'Mixed',
|
||||
starred: false,
|
||||
comment: '',
|
||||
queries,
|
||||
},
|
||||
});
|
||||
|
||||
const runQueryButton = await screen.findByRole('button', { name: /run query/i });
|
||||
fireEvent.click(runQueryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries);
|
||||
expect(changeDatasource).toHaveBeenCalledWith(expect.any(String), 'mixed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commenting', () => {
|
||||
it('should render comment, if comment present', async () => {
|
||||
setup({ query: starredQueryWithComment });
|
||||
|
@ -1,26 +1,26 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, DataSourceApi } from '@grafana/data';
|
||||
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { TextArea, Button, IconButton, useStyles2, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { copyStringToClipboard } from 'app/core/utils/explore';
|
||||
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
||||
import { changeDatasource } from 'app/features/explore/state/datasource';
|
||||
import { starHistoryItem, commentHistoryItem, deleteHistoryItem } from 'app/features/explore/state/history';
|
||||
import { setQueries } from 'app/features/explore/state/query';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
import { RichHistoryQuery, ExploreId } from 'app/types/explore';
|
||||
|
||||
import { ShowConfirmModalEvent } from '../../../types/events';
|
||||
import { changeDatasource } from '../state/datasource';
|
||||
import { starHistoryItem, commentHistoryItem, deleteHistoryItem } from '../state/history';
|
||||
import { setQueries } from '../state/query';
|
||||
|
||||
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
|
||||
const explore = state.explore;
|
||||
const { datasourceInstance } = explore[exploreId]!;
|
||||
@ -42,8 +42,6 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
interface OwnProps<T extends DataQuery = DataQuery> {
|
||||
query: RichHistoryQuery<T>;
|
||||
dsImg: string;
|
||||
isRemoved: boolean;
|
||||
}
|
||||
|
||||
export type Props<T extends DataQuery = DataQuery> = ConnectedProps<typeof connector> & OwnProps<T>;
|
||||
@ -58,6 +56,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
return {
|
||||
queryCard: css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
@ -84,12 +83,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
margin-right: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
datasourceContainer: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
`,
|
||||
queryActionButtons: css`
|
||||
max-width: ${rightColumnContentWidth};
|
||||
display: flex;
|
||||
@ -103,15 +96,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
width: calc(100% - ${rightColumnWidth});
|
||||
`,
|
||||
queryRow: css`
|
||||
border-top: 1px solid ${theme.colors.border.weak};
|
||||
word-break: break-all;
|
||||
padding: 4px 2px;
|
||||
:first-child {
|
||||
border-top: none;
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
`,
|
||||
updateCommentContainer: css`
|
||||
width: calc(100% + ${rightColumnWidth});
|
||||
margin-top: ${theme.spacing(1)};
|
||||
@ -143,14 +127,21 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
loader: css`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export function RichHistoryCard(props: Props) {
|
||||
const {
|
||||
query,
|
||||
dsImg,
|
||||
isRemoved,
|
||||
commentHistoryItem,
|
||||
starHistoryItem,
|
||||
deleteHistoryItem,
|
||||
@ -161,16 +152,33 @@ export function RichHistoryCard(props: Props) {
|
||||
} = props;
|
||||
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
|
||||
const [comment, setComment] = useState<string | undefined>(query.comment);
|
||||
const [queryDsInstance, setQueryDsInstance] = useState<DataSourceApi | undefined>(undefined);
|
||||
const { value, loading } = useAsync(async () => {
|
||||
let dsInstance: DataSourceApi | undefined;
|
||||
try {
|
||||
dsInstance = await getDataSourceSrv().get(query.datasourceUid);
|
||||
} catch (e) {}
|
||||
|
||||
useEffect(() => {
|
||||
const getQueryDsInstance = async () => {
|
||||
const ds = await getDataSourceSrv().get(query.datasourceUid);
|
||||
setQueryDsInstance(ds);
|
||||
return {
|
||||
dsInstance,
|
||||
queries: await Promise.all(
|
||||
query.queries.map(async (query) => {
|
||||
let datasource;
|
||||
if (dsInstance?.meta.mixed) {
|
||||
try {
|
||||
datasource = await getDataSourceSrv().get(query.datasource);
|
||||
} catch (e) {}
|
||||
} else {
|
||||
datasource = dsInstance;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
datasource,
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
getQueryDsInstance();
|
||||
}, [query.datasourceUid]);
|
||||
}, [query.datasourceUid, query.queries]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@ -178,25 +186,34 @@ export function RichHistoryCard(props: Props) {
|
||||
const queriesToRun = query.queries;
|
||||
const differentDataSource = query.datasourceUid !== datasourceInstance?.uid;
|
||||
if (differentDataSource) {
|
||||
await changeDatasource(exploreId, query.datasourceUid, { importQueries: true });
|
||||
setQueries(exploreId, queriesToRun);
|
||||
} else {
|
||||
setQueries(exploreId, queriesToRun);
|
||||
await changeDatasource(exploreId, query.datasourceUid);
|
||||
}
|
||||
setQueries(exploreId, queriesToRun);
|
||||
|
||||
reportInteraction('grafana_explore_query_history_run', {
|
||||
queryHistoryEnabled: config.queryHistoryEnabled,
|
||||
differentDataSource,
|
||||
});
|
||||
};
|
||||
|
||||
const onCopyQuery = () => {
|
||||
const onCopyQuery = async () => {
|
||||
const datasources = [...query.queries.map((q) => q.datasource?.type || 'unknown')];
|
||||
reportInteraction('grafana_explore_query_history_copy_query', {
|
||||
datasources,
|
||||
mixed: Boolean(queryDsInstance?.meta.mixed),
|
||||
mixed: Boolean(value?.dsInstance?.meta.mixed),
|
||||
});
|
||||
const queriesToCopy = query.queries.map((q) => createQueryText(q, queryDsInstance)).join('\n');
|
||||
copyStringToClipboard(queriesToCopy);
|
||||
|
||||
if (loading || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queriesText = value.queries
|
||||
.map((q) => {
|
||||
return createQueryText(q.query, q.datasource);
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
copyStringToClipboard(queriesText);
|
||||
dispatch(notifyApp(createSuccessNotification('Query copied to clipboard')));
|
||||
};
|
||||
|
||||
@ -273,9 +290,7 @@ export function RichHistoryCard(props: Props) {
|
||||
className={styles.textArea}
|
||||
/>
|
||||
<div className={styles.commentButtonRow}>
|
||||
<Button onClick={onUpdateComment} aria-label="Submit button">
|
||||
Save comment
|
||||
</Button>
|
||||
<Button onClick={onUpdateComment}>Save comment</Button>
|
||||
<Button variant="secondary" onClick={onCancelUpdateComment}>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -291,7 +306,7 @@ export function RichHistoryCard(props: Props) {
|
||||
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
|
||||
/>
|
||||
<IconButton name="copy" onClick={onCopyQuery} title="Copy query to clipboard" />
|
||||
{!isRemoved && (
|
||||
{value?.dsInstance && (
|
||||
<IconButton name="share-alt" onClick={onCreateShortLink} title="Copy shortened link to clipboard" />
|
||||
)}
|
||||
<IconButton name="trash-alt" title={'Delete query'} onClick={onDeleteQuery} />
|
||||
@ -307,23 +322,14 @@ export function RichHistoryCard(props: Props) {
|
||||
return (
|
||||
<div className={styles.queryCard}>
|
||||
<div className={styles.cardRow}>
|
||||
<div className={styles.datasourceContainer}>
|
||||
<img src={dsImg} aria-label="Data source icon" />
|
||||
<div aria-label="Data source name">
|
||||
{isRemoved ? 'Data source does not exist anymore' : query.datasourceName}
|
||||
</div>
|
||||
</div>
|
||||
<DatasourceInfo dsApi={value?.dsInstance} size="sm" />
|
||||
|
||||
{queryActionButtons}
|
||||
</div>
|
||||
<div className={cx(styles.cardRow)}>
|
||||
<div className={styles.queryContainer}>
|
||||
{query.queries.map((q, i) => {
|
||||
const queryText = createQueryText(q, queryDsInstance);
|
||||
return (
|
||||
<div aria-label="Query text" key={`${q}-${i}`} className={styles.queryRow}>
|
||||
{queryText}
|
||||
</div>
|
||||
);
|
||||
{value?.queries.map((q, i) => {
|
||||
return <Query query={q} key={`${q}-${i}`} showDsInfo={value?.dsInstance?.meta.mixed} />;
|
||||
})}
|
||||
{!activeUpdateComment && query.comment && (
|
||||
<div aria-label="Query comment" className={styles.comment}>
|
||||
@ -334,12 +340,89 @@ export function RichHistoryCard(props: Props) {
|
||||
</div>
|
||||
{!activeUpdateComment && (
|
||||
<div className={styles.runButton}>
|
||||
<Button variant="secondary" onClick={onRunQuery} disabled={isRemoved}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onRunQuery}
|
||||
disabled={!value?.dsInstance || value.queries.some((query) => !query.datasource)}
|
||||
>
|
||||
{datasourceInstance?.uid === query.datasourceUid ? 'Run query' : 'Switch data source and run query'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading && <LoadingPlaceholder text="loading..." className={styles.loader} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getQueryStyles = (theme: GrafanaTheme2) => ({
|
||||
queryRow: css`
|
||||
border-top: 1px solid ${theme.colors.border.weak};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 4px 0px;
|
||||
gap: 4px;
|
||||
:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
`,
|
||||
dsInfoContainer: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`,
|
||||
queryText: css`
|
||||
word-break: break-all;
|
||||
`,
|
||||
});
|
||||
|
||||
interface QueryProps {
|
||||
query: {
|
||||
query: DataQuery;
|
||||
datasource?: DataSourceApi;
|
||||
};
|
||||
/** Show datasource info (icon+name) alongside the query text */
|
||||
showDsInfo?: boolean;
|
||||
}
|
||||
|
||||
const Query = ({ query, showDsInfo = false }: QueryProps) => {
|
||||
const styles = useStyles2(getQueryStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.queryRow}>
|
||||
{showDsInfo && (
|
||||
<div className={styles.dsInfoContainer}>
|
||||
<DatasourceInfo dsApi={query.datasource} size="md" />
|
||||
{': '}
|
||||
</div>
|
||||
)}
|
||||
<span aria-label="Query text" className={styles.queryText}>
|
||||
{createQueryText(query.query, query.datasource)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getDsInfoStyles = (size: 'sm' | 'md') => (theme: GrafanaTheme2) =>
|
||||
css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${theme.typography[size === 'sm' ? 'bodySmall' : 'body'].fontSize};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function DatasourceInfo({ dsApi, size }: { dsApi?: DataSourceApi; size: 'sm' | 'md' }) {
|
||||
const getStyles = useCallback((theme: GrafanaTheme2) => getDsInfoStyles(size)(theme), [size]);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles}>
|
||||
<img
|
||||
src={dsApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'}
|
||||
alt={dsApi?.type || 'Data source does not exist anymore'}
|
||||
aria-label="Data source icon"
|
||||
/>
|
||||
<div aria-label="Data source name">{dsApi?.name || 'Data source does not exist anymore'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -240,16 +240,7 @@ export function RichHistoryQueriesTab(props: Props) {
|
||||
</span>
|
||||
</div>
|
||||
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
|
||||
const idx = listOfDatasources.findIndex((d) => d.uid === q.datasourceUid);
|
||||
return (
|
||||
<RichHistoryCard
|
||||
query={q}
|
||||
key={q.id}
|
||||
exploreId={exploreId}
|
||||
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
|
||||
isRemoved={idx === -1}
|
||||
/>
|
||||
);
|
||||
return <RichHistoryCard query={q} key={q.id} exploreId={exploreId} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
@ -153,16 +153,7 @@ export function RichHistoryStarredTab(props: Props) {
|
||||
{loading && <span>Loading results...</span>}
|
||||
{!loading &&
|
||||
queries.map((q) => {
|
||||
const idx = listOfDatasources.findIndex((d) => d.uid === q.datasourceUid);
|
||||
return (
|
||||
<RichHistoryCard
|
||||
query={q}
|
||||
key={q.id}
|
||||
exploreId={exploreId}
|
||||
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
|
||||
isRemoved={idx === -1}
|
||||
/>
|
||||
);
|
||||
return <RichHistoryCard query={q} key={q.id} exploreId={exploreId} />;
|
||||
})}
|
||||
{queries.length && queries.length !== totalQueries ? (
|
||||
<div>
|
||||
|
@ -71,7 +71,7 @@ export const commentQueryHistory = async (
|
||||
const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, comment);
|
||||
await invokeAction(queryIndex, 'Submit button', exploreId);
|
||||
await invokeAction(queryIndex, 'Save comment', exploreId);
|
||||
};
|
||||
|
||||
export const deleteQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => {
|
||||
|
@ -41,7 +41,7 @@ export function changeDatasource(
|
||||
exploreId: ExploreId,
|
||||
datasourceUid: string,
|
||||
options?: { importQueries: boolean }
|
||||
): ThunkResult<void> {
|
||||
): ThunkResult<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const orgId = getState().user.orgId;
|
||||
const { history, instance } = await loadAndInitDatasource(orgId, { uid: datasourceUid });
|
||||
|
@ -305,7 +305,7 @@ export const importQueries = (
|
||||
sourceDataSource: DataSourceApi | undefined | null,
|
||||
targetDataSource: DataSourceApi,
|
||||
singleQueryChangeRef?: string // when changing one query DS to another in a mixed environment, we do not want to change all queries, just the one being changed
|
||||
): ThunkResult<void> => {
|
||||
): ThunkResult<Promise<void>> => {
|
||||
return async (dispatch) => {
|
||||
if (!sourceDataSource) {
|
||||
// explore not initialized
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { cloneDeep, groupBy, omit } from 'lodash';
|
||||
import { cloneDeep, groupBy } from 'lodash';
|
||||
import { forkJoin, from, Observable, of, OperatorFunction } from 'rxjs';
|
||||
import { catchError, map, mergeAll, mergeMap, reduce, toArray } from 'rxjs/operators';
|
||||
|
||||
@ -98,13 +98,6 @@ export class MixedDatasource extends DataSourceApi<DataQuery> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
getQueryDisplayText(query: DataQuery) {
|
||||
const strippedQuery = omit(query, ['key', 'refId', 'datasource']);
|
||||
const strippedQueryJSON = JSON.stringify(strippedQuery);
|
||||
const prefix = query.datasource?.type ? `${query.datasource?.type}: ` : '';
|
||||
return `${prefix}${strippedQueryJSON}`;
|
||||
}
|
||||
|
||||
private isQueryable(query: BatchedQueries): boolean {
|
||||
return query && Array.isArray(query.targets) && query.targets.length > 0;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user