mirror of
https://github.com/grafana/grafana.git
synced 2024-11-21 16:38:03 -06:00
Explore: Add Mixed Datasource (#53429)
* Toggle on the mixed mode option * Ensure switching to mixed gives existing query prev datasource * WIP - Populate datasource when switching between mixed and not * WIP - handle change from mixed * Remove preimport filter, refine filter to work for queries * WIP debugging datasource transition * Ensure creating a new query gets target data source if switching with no matches between * Add mixed datasource to rich history display * Cleanup console logs, add relevant comments * Add feature toggle for mixed datasource * Fix Wrapper tests * Fix tests! * Fix test types and add feature tracking * Remove unnecessary default, remove explore/mixed workarounds for D2E * Move display text logic to mixed datasource file * Add in the default query parameters to a generated empty query * Condense some code * Apply suggestions from code review Co-authored-by: Giordano Ricci <me@giordanoricci.com> * Add more logic around mixed datasource being off for explore * Build out logic to handle different datasource scenarios * Add tests * Finalize last test * Fix mixed URL with mixed ds off, and relevant test * Fix datasource to explore workflow * Add datasource change function, call import queries if needed * add logic for changing single query ds Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
parent
e5fba788d6
commit
38c1f3d054
@ -1212,7 +1212,7 @@ lokiQueryBuilder = true
|
||||
# Experimental Explore to Dashboard workflow
|
||||
explore2Dashboard = true
|
||||
|
||||
# Experimental Command Palette
|
||||
# Command Palette
|
||||
commandPalette = true
|
||||
|
||||
# Use dynamic labels in CloudWatch datasource
|
||||
|
@ -73,3 +73,7 @@ The Share shortened link capability allows you to create smaller and simpler URL
|
||||
> **Note:** Available in Grafana 8.5.0 and later versions.
|
||||
|
||||
Enabled by default, allows users to create panels in dashboards from within Explore.
|
||||
|
||||
### exploreMixedDatasource
|
||||
|
||||
Disabled by default, allows users in Explore to have different datasources for different queries. If compatible, results will be combined.
|
||||
|
@ -43,6 +43,7 @@ export interface FeatureToggles {
|
||||
export?: boolean;
|
||||
azureMonitorResourcePickerForMetrics?: boolean;
|
||||
explore2Dashboard?: boolean;
|
||||
exploreMixedDatasource?: boolean;
|
||||
tracing?: boolean;
|
||||
commandPalette?: boolean;
|
||||
cloudWatchDynamicLabels?: boolean;
|
||||
|
@ -155,6 +155,12 @@ var (
|
||||
State: FeatureStateBeta,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "exploreMixedDatasource",
|
||||
Description: "Enable mixed datasource in Explore",
|
||||
State: FeatureStateAlpha,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "tracing",
|
||||
Description: "Adds trace ID to error notifications",
|
||||
|
@ -115,6 +115,10 @@ const (
|
||||
// Experimental Explore to Dashboard workflow
|
||||
FlagExplore2Dashboard = "explore2Dashboard"
|
||||
|
||||
// FlagExploreMixedDatasource
|
||||
// Enable mixed datasource in Explore
|
||||
FlagExploreMixedDatasource = "exploreMixedDatasource"
|
||||
|
||||
// FlagTracing
|
||||
// Adds trace ID to error notifications
|
||||
FlagTracing = "tracing"
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
DataQuery,
|
||||
DataQueryRequest,
|
||||
DataSourceApi,
|
||||
DataSourceRef,
|
||||
dateMath,
|
||||
DateTime,
|
||||
DefaultTimeZone,
|
||||
@ -24,7 +25,7 @@ import {
|
||||
toUtc,
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
import { DataSourceSrv } from '@grafana/runtime';
|
||||
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { RefreshPicker } from '@grafana/ui';
|
||||
import store from 'app/core/store';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
@ -69,9 +70,12 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise<strin
|
||||
*/
|
||||
let exploreTargets: DataQuery[] = panel.targets.map((t) => omit(t, 'legendFormat'));
|
||||
let url: string | undefined;
|
||||
|
||||
// Mixed datasources need to choose only one datasource
|
||||
if (exploreDatasource.meta?.id === 'mixed' && exploreTargets) {
|
||||
// if the mixed datasource is not enabled for explore, choose only one datasource
|
||||
if (
|
||||
config.featureToggles.exploreMixedDatasource === false &&
|
||||
exploreDatasource.meta?.id === 'mixed' &&
|
||||
exploreTargets
|
||||
) {
|
||||
// Find first explore datasource among targets
|
||||
for (const t of exploreTargets) {
|
||||
const datasource = await datasourceSrv.get(t.datasource || undefined);
|
||||
@ -99,7 +103,7 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise<strin
|
||||
...state,
|
||||
datasource: exploreDatasource.name,
|
||||
context: 'explore',
|
||||
queries: exploreTargets.map((t) => ({ ...t, datasource: exploreDatasource.getRef() })),
|
||||
queries: exploreTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@ -254,8 +258,34 @@ export function generateKey(index = 0): string {
|
||||
return `Q-${uuidv4()}-${index}`;
|
||||
}
|
||||
|
||||
export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
|
||||
return { refId: getNextRefIdChar(queries), key: generateKey(index) };
|
||||
export async function generateEmptyQuery(
|
||||
queries: DataQuery[],
|
||||
index = 0,
|
||||
dataSourceOverride?: DataSourceRef
|
||||
): Promise<DataQuery> {
|
||||
let datasourceInstance: DataSourceApi | undefined;
|
||||
let datasourceRef: DataSourceRef | null | undefined;
|
||||
let defaultQuery: Partial<DataQuery> | undefined;
|
||||
|
||||
// datasource override is if we have switched datasources with no carry-over - we want to create a new query with a datasource we define
|
||||
if (dataSourceOverride) {
|
||||
datasourceRef = dataSourceOverride;
|
||||
} else if (queries.length > 0 && queries[queries.length - 1].datasource) {
|
||||
// otherwise use last queries' datasource
|
||||
datasourceRef = queries[queries.length - 1].datasource;
|
||||
} else {
|
||||
// if neither exists, use the default datasource
|
||||
datasourceInstance = await getDataSourceSrv().get();
|
||||
defaultQuery = datasourceInstance.getDefaultQuery?.(CoreApp.Explore);
|
||||
datasourceRef = datasourceInstance.getRef();
|
||||
}
|
||||
|
||||
if (!datasourceInstance) {
|
||||
datasourceInstance = await getDataSourceSrv().get(datasourceRef);
|
||||
defaultQuery = datasourceInstance.getDefaultQuery?.(CoreApp.Explore);
|
||||
}
|
||||
|
||||
return { refId: getNextRefIdChar(queries), key: generateKey(index), datasource: datasourceRef, ...defaultQuery };
|
||||
}
|
||||
|
||||
export const generateNewKeyAndAddRefIdIfMissing = (target: DataQuery, queries: DataQuery[], index = 0): DataQuery => {
|
||||
@ -265,10 +295,24 @@ export const generateNewKeyAndAddRefIdIfMissing = (target: DataQuery, queries: D
|
||||
return { ...target, refId, key };
|
||||
};
|
||||
|
||||
export const queryDatasourceDetails = (queries: DataQuery[]) => {
|
||||
const allUIDs = queries.map((query) => query.datasource?.uid);
|
||||
return {
|
||||
allHaveDatasource: allUIDs.length === queries.length,
|
||||
noneHaveDatasource: allUIDs.length === 0,
|
||||
allDatasourceSame: allUIDs.every((val, i, arr) => val === arr[0]),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure at least one target exists and that targets have the necessary keys
|
||||
*
|
||||
* This will return an empty array if there are no datasources, as Explore is not usable in that state
|
||||
*/
|
||||
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
|
||||
export async function ensureQueries(
|
||||
queries?: DataQuery[],
|
||||
newQueryDataSourceOverride?: DataSourceRef
|
||||
): Promise<DataQuery[]> {
|
||||
if (queries && typeof queries === 'object' && queries.length > 0) {
|
||||
const allQueries = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
@ -287,7 +331,16 @@ export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
|
||||
}
|
||||
return allQueries;
|
||||
}
|
||||
return [{ ...generateEmptyQuery(queries ?? []) }];
|
||||
try {
|
||||
// if a datasource override get its ref, otherwise get the default datasource
|
||||
const emptyQueryRef = newQueryDataSourceOverride ?? (await getDataSourceSrv().get()).getRef();
|
||||
const emptyQuery = await generateEmptyQuery(queries ?? [], undefined, emptyQueryRef);
|
||||
return [emptyQuery];
|
||||
} catch {
|
||||
// if there are no datasources, return an empty array because we will not allow use of explore
|
||||
// this will occur on init of explore with no datasources defined
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -344,9 +397,9 @@ export function clearHistory(datasourceId: string) {
|
||||
store.delete(historyKey);
|
||||
}
|
||||
|
||||
export const getQueryKeys = (queries: DataQuery[], datasourceInstance?: DataSourceApi | null): string[] => {
|
||||
export const getQueryKeys = (queries: DataQuery[]): string[] => {
|
||||
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
|
||||
const primaryKey = datasourceInstance && datasourceInstance.name ? datasourceInstance.name : query.key;
|
||||
const primaryKey = query.datasource?.uid || query.key;
|
||||
return newQueryKeys.concat(`${primaryKey}-${index}`);
|
||||
}, []);
|
||||
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { RichHistoryQuery } from 'app/types/explore';
|
||||
|
||||
import { config } from '../config';
|
||||
import RichHistoryLocalStorage from '../history/RichHistoryLocalStorage';
|
||||
import RichHistoryRemoteStorage from '../history/RichHistoryRemoteStorage';
|
||||
import {
|
||||
@ -264,7 +265,7 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO
|
||||
*/
|
||||
export function createDatasourcesList() {
|
||||
return getDataSourceSrv()
|
||||
.getList()
|
||||
.getList({ mixed: config.featureToggles.exploreMixedDatasource === true })
|
||||
.map((dsSettings) => {
|
||||
return {
|
||||
name: dsSettings.name,
|
||||
|
@ -158,8 +158,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
||||
};
|
||||
|
||||
onClickAddQueryRowButton = () => {
|
||||
const { exploreId, queryKeys, datasourceInstance } = this.props;
|
||||
this.props.addQueryRow(exploreId, queryKeys.length, datasourceInstance);
|
||||
const { exploreId, queryKeys } = this.props;
|
||||
this.props.addQueryRow(exploreId, queryKeys.length);
|
||||
};
|
||||
|
||||
onMakeAbsoluteTime = () => {
|
||||
|
@ -3,26 +3,31 @@ import memoizeOne from 'memoize-one';
|
||||
import React from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { DataQuery, ExploreUrlState, EventBusExtended, EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
||||
import { ExploreUrlState, EventBusExtended, EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import store from 'app/core/store';
|
||||
import {
|
||||
DEFAULT_RANGE,
|
||||
ensureQueries,
|
||||
queryDatasourceDetails,
|
||||
getTimeRange,
|
||||
getTimeRangeFromUrl,
|
||||
lastUsedDatasourceKeyForOrgId,
|
||||
parseUrlState,
|
||||
} from 'app/core/utils/explore';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ExploreId } from 'app/types/explore';
|
||||
|
||||
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
||||
import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors';
|
||||
|
||||
import Explore from './Explore';
|
||||
import { initializeExplore, refreshExplore } from './state/explorePane';
|
||||
import { lastSavedUrl, cleanupPaneAction } from './state/main';
|
||||
import { lastSavedUrl, cleanupPaneAction, stateSave } from './state/main';
|
||||
import { importQueries } from './state/query';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
@ -64,16 +69,49 @@ class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
async componentDidMount() {
|
||||
const { initialized, exploreId, initialDatasource, initialQueries, initialRange, panelsState } = this.props;
|
||||
const width = this.el?.offsetWidth ?? 0;
|
||||
|
||||
// initialize the whole explore first time we mount and if browser history contains a change in datasource
|
||||
if (!initialized) {
|
||||
let queriesDatasourceOverride = undefined;
|
||||
let rootDatasourceOverride = undefined;
|
||||
// if this is starting with no queries and an initial datasource exists (but is not mixed), look up the ref to use it (initial datasource can be a UID or name here)
|
||||
if ((!initialQueries || initialQueries.length === 0) && initialDatasource) {
|
||||
const isDSMixed =
|
||||
initialDatasource === MIXED_DATASOURCE_NAME || initialDatasource.uid === MIXED_DATASOURCE_NAME;
|
||||
if (!isDSMixed) {
|
||||
const datasource = await getDatasourceSrv().get(initialDatasource);
|
||||
queriesDatasourceOverride = datasource.getRef();
|
||||
}
|
||||
}
|
||||
|
||||
let queries = await ensureQueries(initialQueries, queriesDatasourceOverride); // this will return an empty array if there are no datasources
|
||||
|
||||
const queriesDatasourceDetails = queryDatasourceDetails(queries);
|
||||
if (!queriesDatasourceDetails.noneHaveDatasource) {
|
||||
if (!queryDatasourceDetails(queries).allDatasourceSame) {
|
||||
if (config.featureToggles.exploreMixedDatasource) {
|
||||
rootDatasourceOverride = await getDatasourceSrv().get(MIXED_DATASOURCE_NAME);
|
||||
} else {
|
||||
// if we have mixed queries but the mixed datasource feature is not on, change the datasource to the first query that has one
|
||||
const changeDatasourceUid = queries.find((query) => query.datasource?.uid)!.datasource!.uid;
|
||||
if (changeDatasourceUid) {
|
||||
rootDatasourceOverride = changeDatasourceUid;
|
||||
const datasource = await getDatasourceSrv().get(changeDatasourceUid);
|
||||
const datasourceInit = await getDatasourceSrv().get(initialDatasource);
|
||||
await this.props.importQueries(exploreId, queries, datasourceInit, datasource);
|
||||
await this.props.stateSave({ replace: true });
|
||||
queries = this.props.initialQueries;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.props.initializeExplore(
|
||||
exploreId,
|
||||
initialDatasource,
|
||||
initialQueries,
|
||||
rootDatasourceOverride || queries[0]?.datasource || initialDatasource,
|
||||
queries,
|
||||
initialRange,
|
||||
width,
|
||||
this.exploreEvents,
|
||||
@ -116,7 +154,6 @@ class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureQueriesMemoized = memoizeOne(ensureQueries);
|
||||
const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
|
||||
|
||||
function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
@ -126,7 +163,6 @@ function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
|
||||
const { datasource, queries, range: urlRange, panelsState } = (urlState || {}) as ExploreUrlState;
|
||||
const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
|
||||
const initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
|
||||
const initialRange = urlRange
|
||||
? getTimeRangeFromUrlMemoized(urlRange, timeZone, fiscalYearStartMonth)
|
||||
: getTimeRange(timeZone, DEFAULT_RANGE, fiscalYearStartMonth);
|
||||
@ -134,7 +170,7 @@ function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
return {
|
||||
initialized: state.explore[props.exploreId]?.initialized,
|
||||
initialDatasource,
|
||||
initialQueries,
|
||||
initialQueries: queries,
|
||||
initialRange,
|
||||
panelsState,
|
||||
};
|
||||
@ -144,6 +180,8 @@ const mapDispatchToProps = {
|
||||
initializeExplore,
|
||||
refreshExplore,
|
||||
cleanupPaneAction,
|
||||
importQueries,
|
||||
stateSave,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
@ -138,6 +138,7 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
||||
!datasourceMissing && (
|
||||
<DataSourcePicker
|
||||
key={`${exploreId}-ds-picker`}
|
||||
mixed={config.featureToggles.exploreMixedDatasource === true}
|
||||
onChange={this.onChangeDatasource}
|
||||
current={this.props.datasourceRef}
|
||||
hideTextValue={showSmallDataSourcePicker}
|
||||
|
@ -2,14 +2,15 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { CoreApp, DataQuery } from '@grafana/data';
|
||||
import { CoreApp, DataQuery, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
import { ExploreId } from 'app/types/explore';
|
||||
|
||||
import { getDatasourceSrv } from '../plugins/datasource_srv';
|
||||
import { QueryEditorRows } from '../query/components/QueryEditorRows';
|
||||
|
||||
import { runQueries, changeQueriesAction } from './state/query';
|
||||
import { runQueries, changeQueriesAction, importQueries } from './state/query';
|
||||
import { getExploreItemSelector } from './state/selectors';
|
||||
|
||||
interface Props {
|
||||
@ -66,9 +67,16 @@ export const QueryRows = ({ exploreId }: Props) => {
|
||||
[onChange, queries]
|
||||
);
|
||||
|
||||
const onMixedDataSourceChange = async (ds: DataSourceInstanceSettings, query: DataQuery) => {
|
||||
const queryDatasource = await getDataSourceSrv().get(query.datasource);
|
||||
const targetDS = await getDataSourceSrv().get({ uid: ds.uid });
|
||||
dispatch(importQueries(exploreId, queries, queryDatasource, targetDS, query.refId));
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryEditorRows
|
||||
dsSettings={dsSettings}
|
||||
onDatasourceChange={(ds: DataSourceInstanceSettings, query: DataQuery) => onMixedDataSourceChange(ds, query)}
|
||||
queries={queries}
|
||||
onQueriesChange={onChange}
|
||||
onAddQuery={onAddQuery}
|
||||
|
@ -3,12 +3,13 @@ import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { serializeStateToUrlParam } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { locationService, config } from '@grafana/runtime';
|
||||
|
||||
import { changeDatasource } from './spec/helper/interactions';
|
||||
import { makeLogsQueryResponse, makeMetricsQueryResponse } from './spec/helper/query';
|
||||
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
|
||||
import { splitOpen } from './state/main';
|
||||
import * as queryState from './state/query';
|
||||
|
||||
jest.mock('app/core/core', () => {
|
||||
return {
|
||||
@ -37,258 +38,432 @@ describe('Wrapper', () => {
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('shows warning if there are no data sources', async () => {
|
||||
setupExplore({ datasources: [] });
|
||||
// Will throw if isn't found
|
||||
screen.getByText(/Explore requires at least one data source/i);
|
||||
});
|
||||
|
||||
it('inits url and renders editor but does not call query on empty url', async () => {
|
||||
const { datasources } = setupExplore();
|
||||
await waitForExplore();
|
||||
|
||||
// At this point url should be initialised to some defaults
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki',
|
||||
queries: [{ refId: 'A' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
});
|
||||
expect(datasources.loki.query).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('runs query when url contains query and renders results', async () => {
|
||||
const urlParams = {
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki',
|
||||
queries: [{ refId: 'A', expr: '{ label="value"}' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
|
||||
// Make sure we render the logs panel
|
||||
await screen.findByText(/^Logs$/);
|
||||
|
||||
// Make sure we render the log line
|
||||
await screen.findByText(/custom log line/i);
|
||||
|
||||
// And that the editor gets the expr from the url
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
// We did not change the url
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
...urlParams,
|
||||
describe('Handles datasource states', () => {
|
||||
it('shows warning if there are no data sources', async () => {
|
||||
setupExplore({ datasources: [] });
|
||||
await waitFor(() => screen.getByText(/Explore requires at least one data source/i));
|
||||
});
|
||||
|
||||
// We called the data source query method once
|
||||
expect(datasources.loki.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: '{ label="value"}' }],
|
||||
it('handles changing the datasource manually', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
await waitForExplore();
|
||||
await changeDatasource('elastic');
|
||||
|
||||
await screen.findByText('elastic Editor input:');
|
||||
expect(datasources.elastic.query).not.toBeCalled();
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'elastic-uid',
|
||||
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'elastic-uid' } }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles url change and runs the new query', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
// Wait for rendering the logs
|
||||
await screen.findByText(/custom log line/i);
|
||||
describe('Handles running/not running query', () => {
|
||||
it('inits url and renders editor but does not call query on empty url', async () => {
|
||||
const { datasources } = setupExplore();
|
||||
await waitForExplore();
|
||||
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse('different log'));
|
||||
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="different"}' }]),
|
||||
// At this point url should be initialised to some defaults
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki-uid',
|
||||
queries: [{ refId: 'A', datasource: { type: 'logs', uid: 'loki-uid' } }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
});
|
||||
expect(datasources.loki.query).not.toBeCalled();
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`loki Editor input: { label="different"}`);
|
||||
// Renders new response
|
||||
await screen.findByText(/different log/i);
|
||||
});
|
||||
it('runs query when url contains query and renders results', async () => {
|
||||
const urlParams = {
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki-uid',
|
||||
queries: [{ refId: 'A', expr: '{ label="value"}' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
|
||||
it('handles url change and runs the new query with different datasource', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
// Wait for rendering the logs
|
||||
await screen.findByText(/custom log line/i);
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
// Make sure we render the logs panel
|
||||
await screen.findByText(/^Logs$/);
|
||||
|
||||
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeMetricsQueryResponse());
|
||||
// Make sure we render the log line
|
||||
await screen.findByText(/custom log line/i);
|
||||
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'other query' }]),
|
||||
// And that the editor gets the expr from the url
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
// We did not change the url
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
...urlParams,
|
||||
});
|
||||
|
||||
// We called the data source query method once
|
||||
expect(datasources.loki.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: '{ label="value"}' }],
|
||||
});
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`elastic Editor input: other query`);
|
||||
// Renders graph
|
||||
await screen.findByText(/Graph/i);
|
||||
});
|
||||
it('handles url change and runs the new query', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
// Wait for rendering the logs
|
||||
await screen.findByText(/custom log line/i);
|
||||
|
||||
it('handles changing the datasource manually', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
await waitForExplore();
|
||||
await changeDatasource('elastic');
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse('different log'));
|
||||
|
||||
await screen.findByText('elastic Editor input:');
|
||||
expect(datasources.elastic.query).not.toBeCalled();
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'elastic',
|
||||
queries: [{ refId: 'A' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="different"}' }]),
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`loki Editor input: { label="different"}`);
|
||||
// Renders new response
|
||||
await screen.findByText(/different log/i);
|
||||
});
|
||||
|
||||
it('handles url change and runs the new query with different datasource', async () => {
|
||||
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
// Wait for rendering the logs
|
||||
await screen.findByText(/custom log line/i);
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeMetricsQueryResponse());
|
||||
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'other query' }]),
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`elastic Editor input: other query`);
|
||||
// Renders graph
|
||||
await screen.findByText(/Graph/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the split pane when split button is clicked', async () => {
|
||||
setupExplore();
|
||||
// Wait for rendering the editor
|
||||
const splitButton = await screen.findByText(/split/i);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
const editors = screen.getAllByText('loki Editor input:');
|
||||
expect(editors.length).toBe(2);
|
||||
describe('Handles open/close splits in UI and URL', () => {
|
||||
it('opens the split pane when split button is clicked', async () => {
|
||||
setupExplore();
|
||||
// Wait for rendering the editor
|
||||
const splitButton = await screen.findByText(/split/i);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
const editors = screen.getAllByText('loki Editor input:');
|
||||
expect(editors.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('inits with two panes if specified in url', async () => {
|
||||
const urlParams = {
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki-uid',
|
||||
queries: [{ refId: 'A', expr: '{ label="value"}' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
right: serializeStateToUrlParam({
|
||||
datasource: 'elastic-uid',
|
||||
queries: [{ refId: 'A', expr: 'error' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
};
|
||||
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
|
||||
// Make sure we render the logs panel
|
||||
await waitFor(() => {
|
||||
const logsPanels = screen.getAllByText(/^Logs$/);
|
||||
expect(logsPanels.length).toBe(2);
|
||||
});
|
||||
|
||||
// Make sure we render the log line
|
||||
const logsLines = await screen.findAllByText(/custom log line/i);
|
||||
expect(logsLines.length).toBe(2);
|
||||
|
||||
// And that the editor gets the expr from the url
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
|
||||
// We did not change the url
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
...urlParams,
|
||||
});
|
||||
|
||||
// We called the data source query method once
|
||||
expect(datasources.loki.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: '{ label="value"}' }],
|
||||
});
|
||||
|
||||
expect(datasources.elastic.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.elastic.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: 'error' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('can close a panel from a split', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
|
||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
|
||||
};
|
||||
setupExplore({ urlParams });
|
||||
const closeButtons = await screen.findAllByLabelText(/Close split pane/i);
|
||||
await userEvent.click(closeButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsPanels = screen.queryAllByLabelText(/Close split pane/i);
|
||||
expect(logsPanels.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles url change to split view', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]),
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
});
|
||||
|
||||
it('handles opening split with split open func', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources, store } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen
|
||||
// to work
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('inits with two panes if specified in url', async () => {
|
||||
const urlParams = {
|
||||
left: serializeStateToUrlParam({
|
||||
datasource: 'loki',
|
||||
queries: [{ refId: 'A', expr: '{ label="value"}' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
right: serializeStateToUrlParam({
|
||||
datasource: 'elastic',
|
||||
queries: [{ refId: 'A', expr: 'error' }],
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
}),
|
||||
};
|
||||
describe('Handles document title changes', () => {
|
||||
it('changes the document title of the explore page to include the datasource in use', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for the title
|
||||
// to include the datasource
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValueOnce(makeLogsQueryResponse());
|
||||
|
||||
// Make sure we render the logs panel
|
||||
await waitFor(() => {
|
||||
const logsPanels = screen.getAllByText(/^Logs$/);
|
||||
expect(logsPanels.length).toBe(2);
|
||||
await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana'));
|
||||
});
|
||||
|
||||
// Make sure we render the log line
|
||||
const logsLines = await screen.findAllByText(/custom log line/i);
|
||||
expect(logsLines.length).toBe(2);
|
||||
it('changes the document title to include the two datasources in use in split view mode', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources, store } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
|
||||
// And that the editor gets the expr from the url
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen
|
||||
// to work
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
|
||||
// We did not change the url
|
||||
expect(locationService.getSearchObject()).toEqual({
|
||||
orgId: '1',
|
||||
...urlParams,
|
||||
});
|
||||
|
||||
// We called the data source query method once
|
||||
expect(datasources.loki.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.loki.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: '{ label="value"}' }],
|
||||
});
|
||||
|
||||
expect(datasources.elastic.query).toBeCalledTimes(1);
|
||||
expect(jest.mocked(datasources.elastic.query).mock.calls[0][0]).toMatchObject({
|
||||
targets: [{ expr: 'error' }],
|
||||
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
|
||||
await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));
|
||||
});
|
||||
});
|
||||
|
||||
it('can close a panel from a split', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
|
||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
|
||||
};
|
||||
setupExplore({ urlParams });
|
||||
const closeButtons = await screen.findAllByLabelText(/Close split pane/i);
|
||||
await userEvent.click(closeButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsPanels = screen.queryAllByLabelText(/Close split pane/i);
|
||||
expect(logsPanels.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles url change to split view', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
|
||||
locationService.partial({
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]),
|
||||
describe('Handles different URL datasource redirects', () => {
|
||||
it('No params, no store value uses default data source', async () => {
|
||||
setupExplore();
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
});
|
||||
it('No datasource in root or query and no store value uses default data source', async () => {
|
||||
setupExplore({ urlParams: 'orgId=1&left={"queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}' });
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles opening split with split open func', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources, store } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
it('No datasource in root or query with store value uses store value data source', async () => {
|
||||
setupExplore({
|
||||
urlParams: 'orgId=1&left={"queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen
|
||||
// to work
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
it('UID datasource in root uses root data source', async () => {
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
|
||||
it('Name datasource in root uses root data source, converts to UID', async () => {
|
||||
setupExplore({
|
||||
urlParams: 'orgId=1&left={"datasource":"loki","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
// Editor renders the new query
|
||||
await screen.findByText(`elastic Editor input: error`);
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
});
|
||||
it('Datasource ref in query, none in root uses query datasource', async () => {
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('changes the document title of the explore page to include the datasource in use', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for the title
|
||||
// to include the datasource
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
it('Datasource ref in query with matching UID in root uses matching datasource', async () => {
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana'));
|
||||
});
|
||||
it('changes the document title to include the two datasources in use in split view mode', async () => {
|
||||
const urlParams = {
|
||||
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
|
||||
};
|
||||
const { datasources, store } = setupExplore({ urlParams });
|
||||
jest.mocked(datasources.loki.query).mockReturnValue(makeLogsQueryResponse());
|
||||
jest.mocked(datasources.elastic.query).mockReturnValue(makeLogsQueryResponse());
|
||||
it('Datasource ref in query with matching name in root uses matching datasource, converts root to UID', async () => {
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"loki","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
// This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen
|
||||
// to work
|
||||
await screen.findByText(`loki Editor input: { label="value"}`);
|
||||
it('Datasource ref in query with mismatching UID in root uses query datasource', async () => {
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
await waitForExplore();
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
|
||||
await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));
|
||||
it('Different datasources in query with mixed feature on changes root to Mixed', async () => {
|
||||
config.featureToggles.exploreMixedDatasource = true;
|
||||
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
const reducerMock = jest.spyOn(queryState, 'queryReducer');
|
||||
await waitForExplore(undefined, true);
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
expect(reducerMock).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ type: 'explore/queriesImported' })
|
||||
);
|
||||
// this mixed UID is weird just because of our fake datasource generator
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"--+Mixed+---uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
|
||||
config.featureToggles.exploreMixedDatasource = false;
|
||||
});
|
||||
|
||||
it('Different datasources in query with mixed feature off uses first query DS, converts rest', async () => {
|
||||
config.featureToggles.exploreMixedDatasource = false;
|
||||
setupExplore({
|
||||
urlParams:
|
||||
'orgId=1&left={"datasource":"elastic-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}},{"refId":"B","datasource":{"type":"logs","uid":"elastic-uid"}}],"range":{"from":"now-1h","to":"now"}}',
|
||||
prevUsedDatasource: { orgId: 1, datasource: 'elastic' },
|
||||
});
|
||||
|
||||
const reducerMock = jest.spyOn(queryState, 'queryReducer');
|
||||
await waitForExplore(undefined, true);
|
||||
const urlParams = decodeURIComponent(locationService.getSearch().toString());
|
||||
// because there are no import/export queries in our mock datasources, only the first one remains
|
||||
expect(reducerMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: 'explore/queriesImported',
|
||||
payload: expect.objectContaining({
|
||||
exploreId: 'left',
|
||||
queries: [
|
||||
expect.objectContaining({
|
||||
datasource: {
|
||||
type: 'logs',
|
||||
uid: 'loki-uid',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(urlParams).toBe(
|
||||
'orgId=1&left={"datasource":"loki-uid","queries":[{"refId":"A","datasource":{"type":"logs","uid":"loki-uid"}}],"range":{"from":"now-1h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes `from` and `to` parameters from url when first mounted', async () => {
|
||||
|
@ -6,11 +6,21 @@ import { Provider } from 'react-redux';
|
||||
import { Route, Router } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { DataSourceApi, DataSourceInstanceSettings, DataSourceRef, QueryEditorProps, ScopedVars } from '@grafana/data';
|
||||
import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime';
|
||||
import {
|
||||
DataSourceApi,
|
||||
DataSourceInstanceSettings,
|
||||
DataSourceRef,
|
||||
QueryEditorProps,
|
||||
ScopedVars,
|
||||
UrlQueryValue,
|
||||
} from '@grafana/data';
|
||||
import { locationSearchToObject, locationService, setDataSourceSrv, setEchoSrv, config } from '@grafana/runtime';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
|
||||
import { Echo } from 'app/core/services/echo/Echo';
|
||||
import store from 'app/core/store';
|
||||
import { lastUsedDatasourceKeyForOrgId } from 'app/core/utils/explore';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { RICH_HISTORY_KEY, RichHistoryLocalStorageDTO } from '../../../../core/history/RichHistoryLocalStorage';
|
||||
@ -27,12 +37,13 @@ type SetupOptions = {
|
||||
// default true
|
||||
clearLocalStorage?: boolean;
|
||||
datasources?: DatasourceSetup[];
|
||||
urlParams?: { left: string; right?: string };
|
||||
urlParams?: { left: string; right?: string } | string;
|
||||
searchParams?: string;
|
||||
prevUsedDatasource?: { orgId: number; datasource: string };
|
||||
};
|
||||
|
||||
export function setupExplore(options?: SetupOptions): {
|
||||
datasources: { [name: string]: DataSourceApi };
|
||||
datasources: { [uid: string]: DataSourceApi };
|
||||
store: ReturnType<typeof configureStore>;
|
||||
unmount: () => void;
|
||||
container: HTMLElement;
|
||||
@ -43,12 +54,20 @@ export function setupExplore(options?: SetupOptions): {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
if (options?.prevUsedDatasource) {
|
||||
store.set(lastUsedDatasourceKeyForOrgId(options?.prevUsedDatasource.orgId), options?.prevUsedDatasource.datasource);
|
||||
}
|
||||
|
||||
// Create this here so any mocks are recreated on setup and don't retain state
|
||||
const defaultDatasources: DatasourceSetup[] = [
|
||||
makeDatasourceSetup(),
|
||||
makeDatasourceSetup({ name: 'elastic', id: 2 }),
|
||||
];
|
||||
|
||||
if (config.featureToggles.exploreMixedDatasource) {
|
||||
defaultDatasources.push(makeDatasourceSetup({ name: MIXED_DATASOURCE_NAME, id: 999 }));
|
||||
}
|
||||
|
||||
const dsSettings = options?.datasources || defaultDatasources;
|
||||
|
||||
setDataSourceSrv({
|
||||
@ -58,28 +77,32 @@ export function setupExplore(options?: SetupOptions): {
|
||||
getInstanceSettings(ref: DataSourceRef) {
|
||||
return dsSettings.map((d) => d.settings).find((x) => x.name === ref || x.uid === ref || x.uid === ref.uid);
|
||||
},
|
||||
get(datasource?: string | DataSourceRef | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
|
||||
const datasourceStr = typeof datasource === 'string';
|
||||
return Promise.resolve(
|
||||
(datasource
|
||||
? dsSettings.find((d) =>
|
||||
datasourceStr ? d.api.name === datasource || d.api.uid === datasource : d.api.uid === datasource?.uid
|
||||
)
|
||||
: dsSettings[0])!.api
|
||||
);
|
||||
get(datasource?: string | DataSourceRef | null, scopedVars?: ScopedVars): Promise<DataSourceApi | undefined> {
|
||||
if (dsSettings.length === 0) {
|
||||
return Promise.resolve(undefined);
|
||||
} else {
|
||||
const datasourceStr = typeof datasource === 'string';
|
||||
return Promise.resolve(
|
||||
(datasource
|
||||
? dsSettings.find((d) =>
|
||||
datasourceStr ? d.api.name === datasource || d.api.uid === datasource : d.api.uid === datasource?.uid
|
||||
)
|
||||
: dsSettings[0])!.api
|
||||
);
|
||||
}
|
||||
},
|
||||
} as any);
|
||||
|
||||
setEchoSrv(new Echo());
|
||||
|
||||
const store = configureStore();
|
||||
store.getState().user = {
|
||||
const storeState = configureStore();
|
||||
storeState.getState().user = {
|
||||
...initialUserState,
|
||||
orgId: 1,
|
||||
timeZone: 'utc',
|
||||
};
|
||||
|
||||
store.getState().navIndex = {
|
||||
storeState.getState().navIndex = {
|
||||
explore: {
|
||||
id: 'explore',
|
||||
text: 'Explore',
|
||||
@ -92,13 +115,15 @@ export function setupExplore(options?: SetupOptions): {
|
||||
locationService.push({ pathname: '/explore', search: options?.searchParams });
|
||||
|
||||
if (options?.urlParams) {
|
||||
locationService.partial(options.urlParams);
|
||||
let urlParams: Record<string, string | UrlQueryValue> =
|
||||
typeof options.urlParams === 'string' ? locationSearchToObject(options.urlParams) : options.urlParams;
|
||||
locationService.partial(urlParams);
|
||||
}
|
||||
|
||||
const route = { component: Wrapper };
|
||||
|
||||
const { unmount, container } = render(
|
||||
<Provider store={store}>
|
||||
<Provider store={storeState}>
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
|
||||
@ -107,7 +132,7 @@ export function setupExplore(options?: SetupOptions): {
|
||||
</Provider>
|
||||
);
|
||||
|
||||
return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store, unmount, container };
|
||||
return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store: storeState, unmount, container };
|
||||
}
|
||||
|
||||
function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup {
|
||||
@ -122,7 +147,7 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
|
||||
return {
|
||||
settings: {
|
||||
id,
|
||||
uid: name,
|
||||
uid: `${name}-uid`,
|
||||
type: 'logs',
|
||||
name,
|
||||
meta,
|
||||
@ -148,16 +173,20 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
|
||||
},
|
||||
},
|
||||
name: name,
|
||||
uid: name,
|
||||
uid: `${name}-uid`,
|
||||
query: jest.fn(),
|
||||
getRef: jest.fn().mockReturnValue(name),
|
||||
getRef: jest.fn().mockReturnValue({ type: 'logs', uid: `${name}-uid` }),
|
||||
meta,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
export const waitForExplore = async (exploreId: ExploreId = ExploreId.left) => {
|
||||
return await withinExplore(exploreId).findByText(/Editor/i);
|
||||
export const waitForExplore = async (exploreId: ExploreId = ExploreId.left, multi = false) => {
|
||||
if (multi) {
|
||||
return await withinExplore(exploreId).findAllByText(/Editor/i);
|
||||
} else {
|
||||
return await withinExplore(exploreId).findByText(/Editor/i);
|
||||
}
|
||||
};
|
||||
|
||||
export const tearDown = () => {
|
||||
|
@ -253,7 +253,7 @@ describe('Explore: Query History', () => {
|
||||
expect(fetchMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringMatching('/api/query-history/migrate'),
|
||||
data: { queries: [expect.objectContaining({ datasourceUid: 'loki' })] },
|
||||
data: { queries: [expect.objectContaining({ datasourceUid: 'loki-uid' })] },
|
||||
})
|
||||
);
|
||||
fetchMock.mockReset();
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { AnyAction, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { DataSourceApi, HistoryItem } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { RefreshPicker } from '@grafana/ui';
|
||||
import { stopQueryState } from 'app/core/utils/explore';
|
||||
import { ExploreItemState, ThunkResult } from 'app/types';
|
||||
@ -44,6 +45,11 @@ export function changeDatasource(
|
||||
const { history, instance } = await loadAndInitDatasource(orgId, { uid: datasourceUid });
|
||||
const currentDataSourceInstance = getState().explore[exploreId]!.datasourceInstance;
|
||||
|
||||
reportInteraction('explore_change_ds', {
|
||||
from: (currentDataSourceInstance?.meta?.mixed ? 'mixed' : currentDataSourceInstance?.type) || 'unknown',
|
||||
to: instance.meta.mixed ? 'mixed' : instance.type,
|
||||
exploreId,
|
||||
});
|
||||
dispatch(
|
||||
updateDatasourceInstanceAction({
|
||||
exploreId,
|
||||
|
@ -72,6 +72,9 @@ function setup(state?: any) {
|
||||
testDatasource: jest.fn(),
|
||||
init: jest.fn(),
|
||||
name: 'default',
|
||||
getRef() {
|
||||
return { type: 'default', uid: 'default' };
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
|
@ -222,7 +222,7 @@ export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): Thunk
|
||||
// commit changes based on the diff of new url vs old url
|
||||
|
||||
if (update.datasource) {
|
||||
const initialQueries = ensureQueries(queries);
|
||||
const initialQueries = await ensureQueries(queries);
|
||||
await dispatch(
|
||||
initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, panelsState)
|
||||
);
|
||||
@ -304,7 +304,7 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
|
||||
range,
|
||||
queries,
|
||||
initialized: true,
|
||||
queryKeys: getQueryKeys(queries, datasourceInstance),
|
||||
queryKeys: getQueryKeys(queries),
|
||||
datasourceInstance,
|
||||
history,
|
||||
datasourceMissing: !datasourceInstance,
|
||||
|
@ -154,12 +154,31 @@ describe('running queries', () => {
|
||||
describe('importing queries', () => {
|
||||
describe('when importing queries between the same type of data source', () => {
|
||||
it('remove datasource property from all of the queries', async () => {
|
||||
const datasources: DataSourceApi[] = [
|
||||
{
|
||||
name: 'testDs',
|
||||
type: 'postgres',
|
||||
uid: 'ds1',
|
||||
getRef: () => {
|
||||
return { type: 'postgres', uid: 'ds1' };
|
||||
},
|
||||
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>,
|
||||
{
|
||||
name: 'testDs2',
|
||||
type: 'postgres',
|
||||
uid: 'ds2',
|
||||
getRef: () => {
|
||||
return { type: 'postgres', uid: 'ds2' };
|
||||
},
|
||||
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>,
|
||||
];
|
||||
|
||||
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
|
||||
...(defaultInitialState as any),
|
||||
explore: {
|
||||
[ExploreId.left]: {
|
||||
...defaultInitialState.explore[ExploreId.left],
|
||||
datasourceInstance: { name: 'testDs', type: 'postgres' },
|
||||
datasourceInstance: datasources[0],
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -168,18 +187,18 @@ describe('importing queries', () => {
|
||||
importQueries(
|
||||
ExploreId.left,
|
||||
[
|
||||
{ datasource: { type: 'postgresql' }, refId: 'refId_A' },
|
||||
{ datasource: { type: 'postgresql' }, refId: 'refId_B' },
|
||||
{ datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_A' },
|
||||
{ datasource: { type: 'postgresql', uid: 'ds1' }, refId: 'refId_B' },
|
||||
],
|
||||
{ name: 'Postgres1', type: 'postgres' } as DataSourceApi<DataQuery, DataSourceJsonData, {}>,
|
||||
{ name: 'Postgres2', type: 'postgres' } as DataSourceApi<DataQuery, DataSourceJsonData, {}>
|
||||
datasources[0],
|
||||
datasources[1]
|
||||
)
|
||||
);
|
||||
|
||||
expect(getState().explore[ExploreId.left].queries[0]).toHaveProperty('refId', 'refId_A');
|
||||
expect(getState().explore[ExploreId.left].queries[1]).toHaveProperty('refId', 'refId_B');
|
||||
expect(getState().explore[ExploreId.left].queries[0]).not.toHaveProperty('datasource');
|
||||
expect(getState().explore[ExploreId.left].queries[1]).not.toHaveProperty('datasource');
|
||||
expect(getState().explore[ExploreId.left].queries[0]).toHaveProperty('datasource.uid', 'ds2');
|
||||
expect(getState().explore[ExploreId.left].queries[1]).toHaveProperty('datasource.uid', 'ds2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { flatten, groupBy } from 'lodash';
|
||||
import { identity, Observable, of, SubscriptionLike, Unsubscribable } from 'rxjs';
|
||||
import { mergeMap, throttleTime } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
CoreApp,
|
||||
DataQuery,
|
||||
DataQueryErrorType,
|
||||
DataQueryResponse,
|
||||
@ -20,7 +20,7 @@ import {
|
||||
QueryFixAction,
|
||||
toLegacyResponseData,
|
||||
} from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
buildQueryTransaction,
|
||||
ensureQueries,
|
||||
@ -33,6 +33,7 @@ import {
|
||||
} from 'app/core/utils/explore';
|
||||
import { getShiftedTimeRange } from 'app/core/utils/timePicker';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { ExploreItemState, ExplorePanelData, ThunkDispatch, ThunkResult } from 'app/types';
|
||||
import { ExploreId, ExploreState, QueryOptions } from 'app/types/explore';
|
||||
|
||||
@ -214,17 +215,10 @@ export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCa
|
||||
/**
|
||||
* Adds a query row after the row with the given index.
|
||||
*/
|
||||
export function addQueryRow(
|
||||
exploreId: ExploreId,
|
||||
index: number,
|
||||
datasource: DataSourceApi | undefined | null
|
||||
): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const queries = getState().explore[exploreId]!.queries;
|
||||
const query = {
|
||||
...datasource?.getDefaultQuery?.(CoreApp.Explore),
|
||||
...generateEmptyQuery(queries, index),
|
||||
};
|
||||
const query = await generateEmptyQuery(queries, index);
|
||||
|
||||
dispatch(addQueryRowAction({ exploreId, index, query }));
|
||||
};
|
||||
@ -251,6 +245,32 @@ export function cancelQueries(exploreId: ExploreId): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
const addDatasourceToQueries = (datasource: DataSourceApi, queries: DataQuery[]) => {
|
||||
const dataSourceRef = datasource.getRef();
|
||||
return queries.map((query: DataQuery) => {
|
||||
return { ...query, datasource: dataSourceRef };
|
||||
});
|
||||
};
|
||||
|
||||
const getImportableQueries = async (
|
||||
targetDataSource: DataSourceApi,
|
||||
sourceDataSource: DataSourceApi,
|
||||
queries: DataQuery[]
|
||||
): Promise<DataQuery[]> => {
|
||||
let queriesOut: DataQuery[] = [];
|
||||
if (sourceDataSource.meta?.id === targetDataSource.meta?.id) {
|
||||
queriesOut = queries;
|
||||
} else if (hasQueryExportSupport(sourceDataSource) && hasQueryImportSupport(targetDataSource)) {
|
||||
const abstractQueries = await sourceDataSource.exportToAbstractQueries(queries);
|
||||
queriesOut = await targetDataSource.importFromAbstractQueries(abstractQueries);
|
||||
} else if (targetDataSource.importQueries) {
|
||||
// Datasource-specific importers
|
||||
queriesOut = await targetDataSource.importQueries(queries, sourceDataSource);
|
||||
}
|
||||
// add new datasource to queries before returning
|
||||
return addDatasourceToQueries(targetDataSource, queriesOut);
|
||||
};
|
||||
|
||||
/**
|
||||
* Import queries from previous datasource if possible eg Loki and Prometheus have similar query language so the
|
||||
* labels part can be reused to get similar data.
|
||||
@ -263,7 +283,8 @@ export const importQueries = (
|
||||
exploreId: ExploreId,
|
||||
queries: DataQuery[],
|
||||
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
|
||||
): ThunkResult<void> => {
|
||||
return async (dispatch) => {
|
||||
if (!sourceDataSource) {
|
||||
@ -273,22 +294,44 @@ export const importQueries = (
|
||||
}
|
||||
|
||||
let importedQueries = queries;
|
||||
// Check if queries can be imported from previously selected datasource
|
||||
if (sourceDataSource.meta?.id === targetDataSource.meta?.id) {
|
||||
// Keep same queries if same type of datasource, but delete datasource query property to prevent mismatch of new and old data source instance
|
||||
importedQueries = queries.map(({ datasource, ...query }) => query);
|
||||
} else if (hasQueryExportSupport(sourceDataSource) && hasQueryImportSupport(targetDataSource)) {
|
||||
const abstractQueries = await sourceDataSource.exportToAbstractQueries(queries);
|
||||
importedQueries = await targetDataSource.importFromAbstractQueries(abstractQueries);
|
||||
} else if (targetDataSource.importQueries) {
|
||||
// Datasource-specific importers
|
||||
importedQueries = await targetDataSource.importQueries(queries, sourceDataSource);
|
||||
// If going to mixed, keep queries with source datasource
|
||||
if (targetDataSource.name === MIXED_DATASOURCE_NAME) {
|
||||
importedQueries = queries.map((query) => {
|
||||
return { ...query, datasource: sourceDataSource.getRef() };
|
||||
});
|
||||
}
|
||||
// If going from mixed, see what queries you keep by their individual datasources
|
||||
else if (sourceDataSource.name === MIXED_DATASOURCE_NAME) {
|
||||
const groupedQueries = groupBy(queries, (query) => query.datasource?.uid);
|
||||
const groupedImportableQueries = await Promise.all(
|
||||
Object.keys(groupedQueries).map(async (key: string) => {
|
||||
const queryDatasource = await getDataSourceSrv().get({ uid: key });
|
||||
return await getImportableQueries(targetDataSource, queryDatasource, groupedQueries[key]);
|
||||
})
|
||||
);
|
||||
importedQueries = flatten(groupedImportableQueries.filter((arr) => arr.length > 0));
|
||||
} else {
|
||||
// Default is blank queries
|
||||
importedQueries = ensureQueries();
|
||||
let queriesStartArr = queries;
|
||||
if (singleQueryChangeRef !== undefined) {
|
||||
const changedQuery = queries.find((query) => query.refId === singleQueryChangeRef);
|
||||
if (changedQuery) {
|
||||
queriesStartArr = [changedQuery];
|
||||
}
|
||||
}
|
||||
importedQueries = await getImportableQueries(targetDataSource, sourceDataSource, queriesStartArr);
|
||||
}
|
||||
|
||||
const nextQueries = ensureQueries(importedQueries);
|
||||
// this will be the entire imported set, or the single imported query in an array
|
||||
let nextQueries = await ensureQueries(importedQueries, targetDataSource.getRef());
|
||||
|
||||
if (singleQueryChangeRef !== undefined) {
|
||||
// capture the single imported query, and copy the original set
|
||||
const changedQuery = { ...nextQueries[0] };
|
||||
nextQueries = [...queries];
|
||||
const updatedQueryIdx = queries.findIndex((query) => query.refId === singleQueryChangeRef);
|
||||
// replace the changed query
|
||||
nextQueries[updatedQueryIdx] = changedQuery;
|
||||
}
|
||||
|
||||
dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
|
||||
};
|
||||
@ -639,7 +682,7 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
|
||||
return {
|
||||
...state,
|
||||
queries: nextQueries,
|
||||
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
|
||||
queryKeys: getQueryKeys(nextQueries),
|
||||
};
|
||||
}
|
||||
|
||||
@ -685,7 +728,7 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
|
||||
return {
|
||||
...state,
|
||||
queries: nextQueries,
|
||||
queryKeys: getQueryKeys(nextQueries, state.datasourceInstance),
|
||||
queryKeys: getQueryKeys(nextQueries),
|
||||
};
|
||||
}
|
||||
|
||||
@ -694,16 +737,7 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
|
||||
return {
|
||||
...state,
|
||||
queries: queries.slice(),
|
||||
queryKeys: getQueryKeys(queries, state.datasourceInstance),
|
||||
};
|
||||
}
|
||||
|
||||
if (queriesImportedAction.match(action)) {
|
||||
const { queries } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
queries,
|
||||
queryKeys: getQueryKeys(queries, state.datasourceInstance),
|
||||
queryKeys: getQueryKeys(queries),
|
||||
};
|
||||
}
|
||||
|
||||
@ -760,7 +794,7 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
|
||||
return {
|
||||
...state,
|
||||
queries,
|
||||
queryKeys: getQueryKeys(queries, state.datasourceInstance),
|
||||
queryKeys: getQueryKeys(queries),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,8 @@ interface Props {
|
||||
app?: CoreApp;
|
||||
history?: Array<HistoryItem<DataQuery>>;
|
||||
eventBus?: EventBusExtended;
|
||||
|
||||
onDatasourceChange?: (dataSource: DataSourceInstanceSettings, query: DataQuery) => void;
|
||||
}
|
||||
|
||||
export class QueryEditorRows extends PureComponent<Props> {
|
||||
@ -55,6 +57,10 @@ export class QueryEditorRows extends PureComponent<Props> {
|
||||
onDataSourceChange(dataSource: DataSourceInstanceSettings, index: number) {
|
||||
const { queries, onQueriesChange } = this.props;
|
||||
|
||||
if (this.props.onDatasourceChange) {
|
||||
this.props.onDatasourceChange(dataSource, queries[index]);
|
||||
}
|
||||
|
||||
onQueriesChange(
|
||||
queries.map((item, itemIndex) => {
|
||||
if (itemIndex !== index) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { cloneDeep, groupBy } from 'lodash';
|
||||
import { cloneDeep, groupBy, omit } from 'lodash';
|
||||
import { forkJoin, from, Observable, of, OperatorFunction } from 'rxjs';
|
||||
import { catchError, map, mergeAll, mergeMap, reduce, toArray } from 'rxjs/operators';
|
||||
|
||||
@ -98,6 +98,13 @@ 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