Commit e66fc3d4 by Hugo Häggmark Committed by GitHub

ReactPanels: Adds Explore menu item (#20236)

* Fix: Adds Explore menuitem to React Panels
Fixes #19865

* Refactor: Adds CMD|CTRL+click to Explore menu item
parent a499586f
import { Observable } from 'rxjs';
import { ComponentType } from 'react';
import { PluginMeta, GrafanaPlugin } from './plugin';
import { GrafanaPlugin, PluginMeta } from './plugin';
import { PanelData } from './panel';
import { LogRowModel } from './logs';
import { AnnotationEvent, TimeSeries, TableData, LoadingState, KeyValue } from './data';
import { AnnotationEvent, KeyValue, LoadingState, TableData, TimeSeries } from './data';
import { DataFrame, DataFrameDTO } from './dataFrame';
import { TimeRange, RawTimeRange } from './time';
import { RawTimeRange, TimeRange } from './time';
import { ScopedVars } from './ScopedVars';
export interface DataSourcePluginOptionsEditorProps<JSONData = DataSourceJsonData, SecureJSONData = {}> {
......@@ -253,6 +253,8 @@ export abstract class DataSourceApi<
* in the annotation editor `annotations` capability also needs to be enabled in plugin.json.
*/
annotationQuery?(options: AnnotationQueryRequest<TQuery>): Promise<AnnotationEvent[]>;
interpolateVariablesInQueries?(queries: TQuery[]): TQuery[];
}
export interface QueryEditorProps<
......
import { ComponentClass, ComponentType } from 'react';
import { DataQueryRequest, DataQueryError } from './datasource';
import { PluginMeta, GrafanaPlugin } from './plugin';
import { DataQueryError, DataQueryRequest } from './datasource';
import { GrafanaPlugin, PluginMeta } from './plugin';
import { ScopedVars } from './ScopedVars';
import { LoadingState } from './data';
import { DataFrame } from './dataFrame';
import { TimeRange, TimeZone, AbsoluteTimeRange } from './time';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
......@@ -125,7 +125,7 @@ export interface PanelMenuItem {
type?: 'submenu' | 'divider';
text?: string;
iconClassName?: string;
onClick?: () => void;
onClick?: (event: React.MouseEvent<any>) => void;
shortcut?: string;
subMenu?: PanelMenuItem[];
}
......
......@@ -5,13 +5,13 @@ import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import locationUtil from 'app/core/utils/location_util';
import { store } from 'app/store/store';
import { CoreEvents, AppEventEmitter } from 'app/types';
import { AppEventEmitter, CoreEvents } from 'app/types';
import Mousetrap from 'mousetrap';
import { PanelEvents } from '@grafana/data';
import 'mousetrap-global-bind';
import { ContextSrv } from './context_srv';
import { ILocationService, ITimeoutService, IRootScopeService } from 'angular';
import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { getLocationSrv } from '@grafana/runtime';
......@@ -224,7 +224,13 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource);
const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
const url = await getExploreUrl({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: this.datasourceSrv,
timeSrv: this.timeSrv,
});
const urlWithoutBase = locationUtil.stripBaseFromUrl(url);
if (urlWithoutBase) {
......
......@@ -3,34 +3,34 @@ import _ from 'lodash';
import { Unsubscribable } from 'rxjs';
// Services & Utils
import {
dateMath,
toUtc,
TimeRange,
RawTimeRange,
TimeZone,
TimeFragment,
LogRowModel,
LogsModel,
LogsDedupStrategy,
IntervalValues,
DefaultTimeZone,
DataQuery,
DataSourceApi,
DataQueryError,
DataQueryRequest,
PanelModel,
DataSourceApi,
dateMath,
DefaultTimeZone,
HistoryItem,
IntervalValues,
LogRowModel,
LogsDedupStrategy,
LogsModel,
PanelModel,
RawTimeRange,
TimeFragment,
TimeRange,
TimeZone,
toUtc,
} from '@grafana/data';
import { renderUrl } from 'app/core/utils/url';
import store from 'app/core/store';
import kbn from 'app/core/utils/kbn';
import { getNextRefIdChar } from './query';
// Types
import { RefreshPicker } from '@grafana/ui';
import { ExploreUrlState, QueryTransaction, QueryOptions, ExploreMode } from 'app/types/explore';
import { ExploreMode, ExploreUrlState, QueryOptions, QueryTransaction } from 'app/types/explore';
import { config } from '../config';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DataSourceSrv } from '@grafana/runtime';
export const DEFAULT_RANGE = {
from: 'now-1h',
......@@ -57,13 +57,15 @@ export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DAT
* @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
* @param timeSrv Time service to get the current dashboard range from
*/
export async function getExploreUrl(
panel: PanelModel,
panelTargets: DataQuery[],
panelDatasource: any,
datasourceSrv: any,
timeSrv: TimeSrv
) {
export interface GetExploreUrlArguments {
panel: PanelModel;
panelTargets: DataQuery[];
panelDatasource: DataSourceApi;
datasourceSrv: DataSourceSrv;
timeSrv: TimeSrv;
}
export async function getExploreUrl(args: GetExploreUrlArguments) {
const { panel, panelTargets, panelDatasource, datasourceSrv, timeSrv } = args;
let exploreDatasource = panelDatasource;
let exploreTargets: DataQuery[] = panelTargets;
let url: string;
......
import { updateLocation } from 'app/core/actions';
import { store } from 'app/store/store';
import config from 'app/core/config';
import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data';
import { removePanel, duplicatePanel, copyPanel, editPanelJson, sharePanel } from 'app/features/dashboard/utils/panel';
import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { getLocationSrv } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data';
import { contextSrv } from '../../../core/services/context_srv';
import { navigateToExplore } from '../../explore/state/actions';
import { getExploreUrl } from '../../../core/utils/explore';
import { getTimeSrv } from '../services/TimeSrv';
export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
const onViewPanel = () => {
const onViewPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
store.dispatch(
updateLocation({
query: {
......@@ -22,7 +27,8 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
);
};
const onEditPanel = () => {
const onEditPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
store.dispatch(
updateLocation({
query: {
......@@ -35,11 +41,13 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
);
};
const onSharePanel = () => {
const onSharePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
sharePanel(dashboard, panel);
};
const onInspectPanel = () => {
const onInspectPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
getLocationSrv().update({
partial: true,
query: {
......@@ -48,22 +56,36 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
});
};
const onDuplicatePanel = () => {
const onMore = (event: React.MouseEvent<any>) => {
event.preventDefault();
};
const onDuplicatePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
duplicatePanel(dashboard, panel);
};
const onCopyPanel = () => {
const onCopyPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
copyPanel(panel);
};
const onEditPanelJson = () => {
const onEditPanelJson = (event: React.MouseEvent<any>) => {
event.preventDefault();
editPanelJson(dashboard, panel);
};
const onRemovePanel = () => {
const onRemovePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
removePanel(dashboard, panel, true);
};
const onNavigateToExplore = (event: React.MouseEvent<any>) => {
event.preventDefault();
const openInNewWindow = event.ctrlKey || event.metaKey ? (url: string) => window.open(url) : undefined;
store.dispatch(navigateToExplore(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow }));
};
const menu: PanelMenuItem[] = [];
menu.push({
......@@ -89,6 +111,14 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
shortcut: 'p s',
});
if (contextSrv.hasAccessToExplore() && panel.datasource) {
menu.push({
text: 'Explore',
iconClassName: 'gicon gicon-explore',
shortcut: 'x',
onClick: onNavigateToExplore,
});
}
if (config.featureToggles.inspect) {
menu.push({
text: 'Inspect',
......@@ -123,6 +153,7 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
text: 'More...',
iconClassName: 'fa fa-fw fa-cube',
subMenu: subMenu,
onClick: onMore,
});
if (dashboard.meta.canEdit) {
......
import { refreshExplore, loadDatasource } from './actions';
import { ExploreId, ExploreUrlState, ExploreUpdateState, ExploreMode } from 'app/types';
import { loadDatasource, navigateToExplore, refreshExplore } from './actions';
import { ExploreId, ExploreMode, ExploreUpdateState, ExploreUrlState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester';
import {
initializeExploreAction,
InitializeExplorePayload,
updateUIStateAction,
setQueriesAction,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
setQueriesAction,
updateUIStateAction,
} from './actionTypes';
import { Emitter } from 'app/core/core';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { makeInitialUpdateState } from './reducers';
import { DataQuery, DefaultTimeZone, RawTimeRange, LogsDedupStrategy, toUtc } from '@grafana/data';
import { DataQuery, DefaultTimeZone, LogsDedupStrategy, RawTimeRange, toUtc } from '@grafana/data';
import { PanelModel } from 'app/features/dashboard/state';
import { updateLocation } from '../../../core/actions';
import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv';
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: () => ({
......@@ -218,3 +221,129 @@ describe('loading datasource', () => {
});
});
});
const getNavigateToExploreContext = async (openInNewWindow: (url: string) => void = undefined) => {
const url = 'http://www.someurl.com';
const panel: Partial<PanelModel> = {
datasource: 'mocked datasource',
targets: [{ refId: 'A' }],
};
const datasource = new MockDataSourceApi(panel.datasource);
const get = jest.fn().mockResolvedValue(datasource);
const getDataSourceSrv = jest.fn().mockReturnValue({ get });
const getTimeSrv = jest.fn();
const getExploreUrl = jest.fn().mockResolvedValue(url);
const dispatchedActions = await thunkTester({})
.givenThunk(navigateToExplore)
.whenThunkIsDispatched(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow });
return {
url,
panel,
datasource,
get,
getDataSourceSrv,
getTimeSrv,
getExploreUrl,
dispatchedActions,
};
};
describe('navigateToExplore', () => {
describe('when navigateToExplore thunk is dispatched', () => {
describe('and openInNewWindow is undefined', () => {
const openInNewWindow: (url: string) => void = undefined;
it('then it should dispatch correct actions', async () => {
const { dispatchedActions, url } = await getNavigateToExploreContext(openInNewWindow);
expect(dispatchedActions).toEqual([updateLocation({ path: url, query: {} })]);
});
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
});
describe('and openInNewWindow is defined', () => {
const openInNewWindow: (url: string) => void = jest.fn();
it('then it should dispatch no actions', async () => {
const { dispatchedActions } = await getNavigateToExploreContext(openInNewWindow);
expect(dispatchedActions).toEqual([]);
});
it('then getDataSourceSrv should have been once', async () => {
const { getDataSourceSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getDataSourceSrv).toHaveBeenCalledTimes(1);
});
it('then getDataSourceSrv.get should have been called with correct arguments', async () => {
const { get, panel } = await getNavigateToExploreContext(openInNewWindow);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(panel.datasource);
});
it('then getTimeSrv should have been called once', async () => {
const { getTimeSrv } = await getNavigateToExploreContext(openInNewWindow);
expect(getTimeSrv).toHaveBeenCalledTimes(1);
});
it('then getExploreUrl should have been called with correct arguments', async () => {
const { getExploreUrl, panel, datasource, getDataSourceSrv, getTimeSrv } = await getNavigateToExploreContext(
openInNewWindow
);
expect(getExploreUrl).toHaveBeenCalledTimes(1);
expect(getExploreUrl).toHaveBeenCalledWith({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv: getDataSourceSrv(),
timeSrv: getTimeSrv(),
});
});
it('then openInNewWindow should have been called with correct arguments', async () => {
const openInNewWindowFunc = jest.fn();
const { url } = await getNavigateToExploreContext(openInNewWindowFunc);
expect(openInNewWindowFunc).toHaveBeenCalledTimes(1);
expect(openInNewWindowFunc).toHaveBeenCalledWith(url);
});
});
});
});
......@@ -6,81 +6,84 @@ import store from 'app/core/store';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { Emitter } from 'app/core/core';
import {
buildQueryTransaction,
clearQueryKeys,
ensureQueries,
generateEmptyQuery,
parseUrlState,
generateNewKeyAndAddRefIdIfMissing,
GetExploreUrlArguments,
getTimeRange,
getTimeRangeFromUrl,
generateNewKeyAndAddRefIdIfMissing,
lastUsedDatasourceKeyForOrgId,
hasNonEmptyQuery,
buildQueryTransaction,
clearQueryKeys,
lastUsedDatasourceKeyForOrgId,
parseUrlState,
serializeStateToUrlParam,
stopQueryState,
updateHistory,
} from 'app/core/utils/explore';
// Types
import { ThunkResult, ExploreUrlState, ExploreItemState } from 'app/types';
import { ExploreItemState, ExploreUrlState, ThunkResult } from 'app/types';
import { RefreshPicker } from '@grafana/ui';
import {
DataSourceApi,
AbsoluteTimeRange,
DataQuery,
DataSourceApi,
DataSourceSelectItem,
QueryFixAction,
dateTimeForTimeZone,
isDateTime,
LoadingState,
LogsDedupStrategy,
PanelData,
QueryFixAction,
RawTimeRange,
LogsDedupStrategy,
AbsoluteTimeRange,
LoadingState,
TimeRange,
isDateTime,
dateTimeForTimeZone,
} from '@grafana/data';
import { ExploreId, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore';
import { ExploreId, ExploreMode, ExploreUIState, QueryOptions } from 'app/types/explore';
import {
updateDatasourceInstanceAction,
addQueryRowAction,
changeModeAction,
changeQueryAction,
changeRangeAction,
changeRefreshIntervalAction,
ChangeRefreshIntervalPayload,
changeSizeAction,
ChangeSizePayload,
clearOriginAction,
clearQueriesAction,
historyUpdatedAction,
initializeExploreAction,
loadDatasourceMissingAction,
loadDatasourcePendingAction,
queriesImportedAction,
LoadDatasourceReadyPayload,
loadDatasourceReadyAction,
LoadDatasourceReadyPayload,
loadExploreDatasources,
modifyQueriesAction,
queriesImportedAction,
queryStoreSubscriptionAction,
queryStreamUpdatedAction,
scanStartAction,
scanStopAction,
setQueriesAction,
setUrlReplacedAction,
splitCloseAction,
splitOpenAction,
addQueryRowAction,
syncTimesAction,
toggleGraphAction,
toggleTableAction,
ToggleGraphPayload,
toggleTableAction,
ToggleTablePayload,
updateDatasourceInstanceAction,
updateUIStateAction,
loadExploreDatasources,
changeModeAction,
scanStopAction,
setUrlReplacedAction,
changeRangeAction,
historyUpdatedAction,
queryStreamUpdatedAction,
queryStoreSubscriptionAction,
clearOriginAction,
syncTimesAction,
} from './actionTypes';
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
import { ActionCreator, ActionOf } from 'app/core/redux/actionCreatorFactory';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { updateLocation } from '../../../core/actions';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { runRequest, preProcessPanelData } from '../../dashboard/state/runRequest';
import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { preProcessPanelData, runRequest } from '../../dashboard/state/runRequest';
import { PanelModel } from 'app/features/dashboard/state';
import { DataSourceSrv } from '@grafana/runtime';
/**
* Updates UI state and save it to the URL
......@@ -764,3 +767,36 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
}
};
}
export interface NavigateToExploreDependencies {
getDataSourceSrv: () => DataSourceSrv;
getTimeSrv: () => TimeSrv;
getExploreUrl: (args: GetExploreUrlArguments) => Promise<string>;
openInNewWindow?: (url: string) => void;
}
export const navigateToExplore = (
panel: PanelModel,
dependencies: NavigateToExploreDependencies
): ThunkResult<void> => {
return async dispatch => {
const { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow } = dependencies;
const datasourceSrv = getDataSourceSrv();
const datasource = await datasourceSrv.get(panel.datasource);
const path = await getExploreUrl({
panel,
panelTargets: panel.targets,
panelDatasource: datasource,
datasourceSrv,
timeSrv: getTimeSrv(),
});
if (openInNewWindow) {
openInNewWindow(path);
return;
}
const query = {}; // strips any angular query param
dispatch(updateLocation({ path, query }));
};
};
......@@ -7,16 +7,16 @@ import { getExploreUrl } from 'app/core/utils/explore';
import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
import { ContextSrv } from 'app/core/services/context_srv';
import {
toLegacyResponseData,
toDataFrameDTO,
TimeRange,
LoadingState,
DataFrame,
LegacyResponseData,
DataQueryResponse,
DataSourceApi,
LegacyResponseData,
LoadingState,
PanelData,
DataQueryResponse,
PanelEvents,
TimeRange,
toDataFrameDTO,
toLegacyResponseData,
} from '@grafana/data';
import { Unsubscribable } from 'rxjs';
import { PanelModel } from 'app/features/dashboard/state';
......@@ -256,7 +256,13 @@ class MetricsPanelCtrl extends PanelCtrl {
text: 'Explore',
icon: 'gicon gicon-explore',
shortcut: 'x',
href: await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv),
href: await getExploreUrl({
panel: this.panel,
panelTargets: this.panel.targets,
panelDatasource: this.datasource,
datasourceSrv: this.datasourceSrv,
timeSrv: this.timeSrv,
}),
});
}
return items;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment