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;
|
return heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createQueryText(query: DataQuery, queryDsInstance: DataSourceApi | undefined) {
|
export function createQueryText(query: DataQuery, dsApi?: DataSourceApi) {
|
||||||
/* query DatasourceInstance is necessary because we use its getQueryDisplayText method
|
if (dsApi?.getQueryDisplayText) {
|
||||||
* to format query text
|
return dsApi.getQueryDisplayText(query);
|
||||||
*/
|
|
||||||
if (queryDsInstance?.getQueryDisplayText) {
|
|
||||||
return queryDsInstance.getQueryDisplayText(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return getQueryDisplayText(query);
|
return getQueryDisplayText(query);
|
||||||
@ -270,7 +267,6 @@ export function createDatasourcesList() {
|
|||||||
return {
|
return {
|
||||||
name: dsSettings.name,
|
name: dsSettings.name,
|
||||||
uid: dsSettings.uid,
|
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 React from 'react';
|
||||||
|
|
||||||
import { DataSourceApi } from '@grafana/data';
|
import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { mockDataSource } from 'app/features/alerting/unified/mocks';
|
import { MixedDatasource } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||||
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
|
|
||||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||||
import { ExploreId, RichHistoryQuery } from 'app/types/explore';
|
import { ExploreId, RichHistoryQuery } from 'app/types/explore';
|
||||||
|
|
||||||
@ -14,10 +13,54 @@ import { RichHistoryCard, Props } from './RichHistoryCard';
|
|||||||
const starRichHistoryMock = jest.fn();
|
const starRichHistoryMock = jest.fn();
|
||||||
const deleteRichHistoryMock = jest.fn();
|
const deleteRichHistoryMock = jest.fn();
|
||||||
|
|
||||||
const mockDS = mockDataSource({
|
class MockDatasourceApi<T extends DataQuery> implements DataSourceApi<T> {
|
||||||
name: 'CloudManager',
|
name: string;
|
||||||
type: DataSourceType.Alertmanager,
|
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.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
@ -27,19 +70,33 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
||||||
return {
|
return {
|
||||||
getDataSourceSrv: () => ({
|
getDataSourceSrv: () => ({
|
||||||
get: () => Promise.resolve(mockDS),
|
get: (ref: DataSourceRef | string) => {
|
||||||
getList: () => [mockDS],
|
const uid = typeof ref === 'string' ? ref : ref.uid;
|
||||||
getInstanceSettings: () => mockDS,
|
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', () => ({
|
jest.mock('app/core/app_events', () => ({
|
||||||
publish: jest.fn(),
|
publish: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface MockQuery extends DataQuery {
|
interface MockQuery extends DataQuery {
|
||||||
query: string;
|
query: string;
|
||||||
|
queryText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
||||||
@ -47,8 +104,8 @@ const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
|||||||
query: {
|
query: {
|
||||||
id: '1',
|
id: '1',
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
datasourceUid: 'Test datasource uid',
|
datasourceUid: 'loki',
|
||||||
datasourceName: 'Test datasource',
|
datasourceName: 'Loki',
|
||||||
starred: false,
|
starred: false,
|
||||||
comment: '',
|
comment: '',
|
||||||
queries: [
|
queries: [
|
||||||
@ -57,15 +114,13 @@ const setup = (propOverrides?: Partial<Props<MockQuery>>) => {
|
|||||||
{ query: 'query3', refId: 'C' },
|
{ query: 'query3', refId: 'C' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
dsImg: '/app/img',
|
|
||||||
isRemoved: false,
|
|
||||||
changeDatasource: jest.fn(),
|
changeDatasource: jest.fn(),
|
||||||
starHistoryItem: starRichHistoryMock,
|
starHistoryItem: starRichHistoryMock,
|
||||||
deleteHistoryItem: deleteRichHistoryMock,
|
deleteHistoryItem: deleteRichHistoryMock,
|
||||||
commentHistoryItem: jest.fn(),
|
commentHistoryItem: jest.fn(),
|
||||||
setQueries: jest.fn(),
|
setQueries: jest.fn(),
|
||||||
exploreId: ExploreId.left,
|
exploreId: ExploreId.left,
|
||||||
datasourceInstance: { name: 'Datasource' } as DataSourceApi,
|
datasourceInstance: dsStore.loki,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
@ -107,12 +162,226 @@ describe('RichHistoryCard', () => {
|
|||||||
expect(datasourceIcon).toBeInTheDocument();
|
expect(datasourceIcon).toBeInTheDocument();
|
||||||
expect(datasourceName).toBeInTheDocument();
|
expect(datasourceName).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render "Data source does not exist anymore" if removed data source', async () => {
|
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');
|
const datasourceName = await screen.findByLabelText('Data source name');
|
||||||
expect(datasourceName).toHaveTextContent('Data source does not exist anymore');
|
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', () => {
|
describe('commenting', () => {
|
||||||
it('should render comment, if comment present', async () => {
|
it('should render comment, if comment present', async () => {
|
||||||
setup({ query: starredQueryWithComment });
|
setup({ query: starredQueryWithComment });
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
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 { 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 { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||||
import { DataQuery } from '@grafana/schema';
|
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 { notifyApp } from 'app/core/actions';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
import { copyStringToClipboard } from 'app/core/utils/explore';
|
import { copyStringToClipboard } from 'app/core/utils/explore';
|
||||||
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
||||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
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 { dispatch } from 'app/store/store';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||||
import { RichHistoryQuery, ExploreId } from 'app/types/explore';
|
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 }) {
|
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
|
||||||
const explore = state.explore;
|
const explore = state.explore;
|
||||||
const { datasourceInstance } = explore[exploreId]!;
|
const { datasourceInstance } = explore[exploreId]!;
|
||||||
@ -42,8 +42,6 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
|
|||||||
|
|
||||||
interface OwnProps<T extends DataQuery = DataQuery> {
|
interface OwnProps<T extends DataQuery = DataQuery> {
|
||||||
query: RichHistoryQuery<T>;
|
query: RichHistoryQuery<T>;
|
||||||
dsImg: string;
|
|
||||||
isRemoved: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Props<T extends DataQuery = DataQuery> = ConnectedProps<typeof connector> & OwnProps<T>;
|
export type Props<T extends DataQuery = DataQuery> = ConnectedProps<typeof connector> & OwnProps<T>;
|
||||||
@ -58,6 +56,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
queryCard: css`
|
queryCard: css`
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid ${theme.colors.border.weak};
|
border: 1px solid ${theme.colors.border.weak};
|
||||||
@ -84,12 +83,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
margin-right: ${theme.spacing(1)};
|
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`
|
queryActionButtons: css`
|
||||||
max-width: ${rightColumnContentWidth};
|
max-width: ${rightColumnContentWidth};
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -103,15 +96,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
font-weight: ${theme.typography.fontWeightMedium};
|
font-weight: ${theme.typography.fontWeightMedium};
|
||||||
width: calc(100% - ${rightColumnWidth});
|
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`
|
updateCommentContainer: css`
|
||||||
width: calc(100% + ${rightColumnWidth});
|
width: calc(100% + ${rightColumnWidth});
|
||||||
margin-top: ${theme.spacing(1)};
|
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) {
|
export function RichHistoryCard(props: Props) {
|
||||||
const {
|
const {
|
||||||
query,
|
query,
|
||||||
dsImg,
|
|
||||||
isRemoved,
|
|
||||||
commentHistoryItem,
|
commentHistoryItem,
|
||||||
starHistoryItem,
|
starHistoryItem,
|
||||||
deleteHistoryItem,
|
deleteHistoryItem,
|
||||||
@ -161,16 +152,33 @@ export function RichHistoryCard(props: Props) {
|
|||||||
} = props;
|
} = props;
|
||||||
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
|
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
|
||||||
const [comment, setComment] = useState<string | undefined>(query.comment);
|
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(() => {
|
return {
|
||||||
const getQueryDsInstance = async () => {
|
dsInstance,
|
||||||
const ds = await getDataSourceSrv().get(query.datasourceUid);
|
queries: await Promise.all(
|
||||||
setQueryDsInstance(ds);
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
}, [query.datasourceUid, query.queries]);
|
||||||
getQueryDsInstance();
|
|
||||||
}, [query.datasourceUid]);
|
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
@ -178,25 +186,34 @@ export function RichHistoryCard(props: Props) {
|
|||||||
const queriesToRun = query.queries;
|
const queriesToRun = query.queries;
|
||||||
const differentDataSource = query.datasourceUid !== datasourceInstance?.uid;
|
const differentDataSource = query.datasourceUid !== datasourceInstance?.uid;
|
||||||
if (differentDataSource) {
|
if (differentDataSource) {
|
||||||
await changeDatasource(exploreId, query.datasourceUid, { importQueries: true });
|
await changeDatasource(exploreId, query.datasourceUid);
|
||||||
setQueries(exploreId, queriesToRun);
|
|
||||||
} else {
|
|
||||||
setQueries(exploreId, queriesToRun);
|
|
||||||
}
|
}
|
||||||
|
setQueries(exploreId, queriesToRun);
|
||||||
|
|
||||||
reportInteraction('grafana_explore_query_history_run', {
|
reportInteraction('grafana_explore_query_history_run', {
|
||||||
queryHistoryEnabled: config.queryHistoryEnabled,
|
queryHistoryEnabled: config.queryHistoryEnabled,
|
||||||
differentDataSource,
|
differentDataSource,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyQuery = () => {
|
const onCopyQuery = async () => {
|
||||||
const datasources = [...query.queries.map((q) => q.datasource?.type || 'unknown')];
|
const datasources = [...query.queries.map((q) => q.datasource?.type || 'unknown')];
|
||||||
reportInteraction('grafana_explore_query_history_copy_query', {
|
reportInteraction('grafana_explore_query_history_copy_query', {
|
||||||
datasources,
|
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')));
|
dispatch(notifyApp(createSuccessNotification('Query copied to clipboard')));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -273,9 +290,7 @@ export function RichHistoryCard(props: Props) {
|
|||||||
className={styles.textArea}
|
className={styles.textArea}
|
||||||
/>
|
/>
|
||||||
<div className={styles.commentButtonRow}>
|
<div className={styles.commentButtonRow}>
|
||||||
<Button onClick={onUpdateComment} aria-label="Submit button">
|
<Button onClick={onUpdateComment}>Save comment</Button>
|
||||||
Save comment
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={onCancelUpdateComment}>
|
<Button variant="secondary" onClick={onCancelUpdateComment}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@ -291,7 +306,7 @@ export function RichHistoryCard(props: Props) {
|
|||||||
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
|
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
|
||||||
/>
|
/>
|
||||||
<IconButton name="copy" onClick={onCopyQuery} title="Copy query to clipboard" />
|
<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="share-alt" onClick={onCreateShortLink} title="Copy shortened link to clipboard" />
|
||||||
)}
|
)}
|
||||||
<IconButton name="trash-alt" title={'Delete query'} onClick={onDeleteQuery} />
|
<IconButton name="trash-alt" title={'Delete query'} onClick={onDeleteQuery} />
|
||||||
@ -307,23 +322,14 @@ export function RichHistoryCard(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.queryCard}>
|
<div className={styles.queryCard}>
|
||||||
<div className={styles.cardRow}>
|
<div className={styles.cardRow}>
|
||||||
<div className={styles.datasourceContainer}>
|
<DatasourceInfo dsApi={value?.dsInstance} size="sm" />
|
||||||
<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>
|
|
||||||
{queryActionButtons}
|
{queryActionButtons}
|
||||||
</div>
|
</div>
|
||||||
<div className={cx(styles.cardRow)}>
|
<div className={cx(styles.cardRow)}>
|
||||||
<div className={styles.queryContainer}>
|
<div className={styles.queryContainer}>
|
||||||
{query.queries.map((q, i) => {
|
{value?.queries.map((q, i) => {
|
||||||
const queryText = createQueryText(q, queryDsInstance);
|
return <Query query={q} key={`${q}-${i}`} showDsInfo={value?.dsInstance?.meta.mixed} />;
|
||||||
return (
|
|
||||||
<div aria-label="Query text" key={`${q}-${i}`} className={styles.queryRow}>
|
|
||||||
{queryText}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{!activeUpdateComment && query.comment && (
|
{!activeUpdateComment && query.comment && (
|
||||||
<div aria-label="Query comment" className={styles.comment}>
|
<div aria-label="Query comment" className={styles.comment}>
|
||||||
@ -334,12 +340,89 @@ export function RichHistoryCard(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{!activeUpdateComment && (
|
{!activeUpdateComment && (
|
||||||
<div className={styles.runButton}>
|
<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'}
|
{datasourceInstance?.uid === query.datasourceUid ? 'Run query' : 'Switch data source and run query'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -240,16 +240,7 @@ export function RichHistoryQueriesTab(props: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
|
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
|
||||||
const idx = listOfDatasources.findIndex((d) => d.uid === q.datasourceUid);
|
return <RichHistoryCard query={q} key={q.id} exploreId={exploreId} />;
|
||||||
return (
|
|
||||||
<RichHistoryCard
|
|
||||||
query={q}
|
|
||||||
key={q.id}
|
|
||||||
exploreId={exploreId}
|
|
||||||
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
|
|
||||||
isRemoved={idx === -1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -153,16 +153,7 @@ export function RichHistoryStarredTab(props: Props) {
|
|||||||
{loading && <span>Loading results...</span>}
|
{loading && <span>Loading results...</span>}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
queries.map((q) => {
|
queries.map((q) => {
|
||||||
const idx = listOfDatasources.findIndex((d) => d.uid === q.datasourceUid);
|
return <RichHistoryCard query={q} key={q.id} exploreId={exploreId} />;
|
||||||
return (
|
|
||||||
<RichHistoryCard
|
|
||||||
query={q}
|
|
||||||
key={q.id}
|
|
||||||
exploreId={exploreId}
|
|
||||||
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
|
|
||||||
isRemoved={idx === -1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{queries.length && queries.length !== totalQueries ? (
|
{queries.length && queries.length !== totalQueries ? (
|
||||||
<div>
|
<div>
|
||||||
|
@ -71,7 +71,7 @@ export const commentQueryHistory = async (
|
|||||||
const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.');
|
const input = withinExplore(exploreId).getByPlaceholderText('An optional description of what the query does.');
|
||||||
await userEvent.clear(input);
|
await userEvent.clear(input);
|
||||||
await userEvent.type(input, comment);
|
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) => {
|
export const deleteQueryHistory = async (queryIndex: number, exploreId: ExploreId = ExploreId.left) => {
|
||||||
|
@ -41,7 +41,7 @@ export function changeDatasource(
|
|||||||
exploreId: ExploreId,
|
exploreId: ExploreId,
|
||||||
datasourceUid: string,
|
datasourceUid: string,
|
||||||
options?: { importQueries: boolean }
|
options?: { importQueries: boolean }
|
||||||
): ThunkResult<void> {
|
): ThunkResult<Promise<void>> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const orgId = getState().user.orgId;
|
const orgId = getState().user.orgId;
|
||||||
const { history, instance } = await loadAndInitDatasource(orgId, { uid: datasourceUid });
|
const { history, instance } = await loadAndInitDatasource(orgId, { uid: datasourceUid });
|
||||||
|
@ -305,7 +305,7 @@ export const importQueries = (
|
|||||||
sourceDataSource: DataSourceApi | undefined | null,
|
sourceDataSource: DataSourceApi | undefined | null,
|
||||||
targetDataSource: DataSourceApi,
|
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
|
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) => {
|
return async (dispatch) => {
|
||||||
if (!sourceDataSource) {
|
if (!sourceDataSource) {
|
||||||
// explore not initialized
|
// 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 { forkJoin, from, Observable, of, OperatorFunction } from 'rxjs';
|
||||||
import { catchError, map, mergeAll, mergeMap, reduce, toArray } from 'rxjs/operators';
|
import { catchError, map, mergeAll, mergeMap, reduce, toArray } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -98,13 +98,6 @@ export class MixedDatasource extends DataSourceApi<DataQuery> {
|
|||||||
return Promise.resolve({});
|
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 {
|
private isQueryable(query: BatchedQueries): boolean {
|
||||||
return query && Array.isArray(query.targets) && query.targets.length > 0;
|
return query && Array.isArray(query.targets) && query.targets.length > 0;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user