Explore: Adds ability to save a panel's query from Explore (#17982)

* Explore: Adds ability to return to origin dashboard
This commit is contained in:
kay delaney 2019-09-05 13:44:37 +01:00 committed by GitHub
parent 991f77cee1
commit a838d2b30a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 404 additions and 87 deletions

View File

@ -61,7 +61,7 @@ export class RefreshPicker extends PureComponent<Props> {
<div className={cssClasses}> <div className={cssClasses}>
<div className="refresh-picker-buttons"> <div className="refresh-picker-buttons">
<Tooltip placement="top" content={tooltip}> <Tooltip placement="top" content={tooltip}>
<button className="btn btn--radius-right-0 navbar-button navbar-button--refresh" onClick={onRefresh}> <button className="btn btn--radius-right-0 navbar-button navbar-button--border-right-0" onClick={onRefresh}>
<i className="fa fa-refresh" /> <i className="fa fa-refresh" />
</button> </button>
</Tooltip> </Tooltip>

View File

@ -6,7 +6,7 @@
display: flex; display: flex;
} }
.navbar-button--refresh { .navbar-button--border-right-0 {
border-right: 0; border-right: 0;
} }

View File

@ -43,6 +43,7 @@ export interface Props<T> {
onOpenMenu?: () => void; onOpenMenu?: () => void;
onCloseMenu?: () => void; onCloseMenu?: () => void;
tabSelectsValue?: boolean; tabSelectsValue?: boolean;
autoFocus?: boolean;
} }
export class ButtonSelect<T> extends PureComponent<Props<T>> { export class ButtonSelect<T> extends PureComponent<Props<T>> {
@ -65,14 +66,16 @@ export class ButtonSelect<T> extends PureComponent<Props<T>> {
onOpenMenu, onOpenMenu,
onCloseMenu, onCloseMenu,
tabSelectsValue, tabSelectsValue,
autoFocus = true,
} = this.props; } = this.props;
const combinedComponents = { const combinedComponents = {
...components, ...components,
Control: ButtonComponent({ label, className, iconClass }), Control: ButtonComponent({ label, className, iconClass }),
}; };
return ( return (
<Select <Select
autoFocus autoFocus={autoFocus}
backspaceRemovesValue={false} backspaceRemovesValue={false}
isClearable={false} isClearable={false}
isSearchable={false} isSearchable={false}

View File

@ -263,14 +263,15 @@ export class BackendSrv implements BackendService {
return this.get(`/api/folders/${uid}`); return this.get(`/api/folders/${uid}`);
} }
saveDashboard(dash: DashboardModel, options: any) { saveDashboard(
options = options || {}; dash: DashboardModel,
{ message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {}
) {
return this.post('/api/dashboards/db/', { return this.post('/api/dashboards/db/', {
dashboard: dash, dashboard: dash,
folderId: options.folderId, folderId,
overwrite: options.overwrite === true, overwrite,
message: options.message || '', message,
}); });
} }

View File

@ -186,7 +186,6 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) { if (dashboard.meta.focusPanelId) {
appEvents.emit('panel-change-view', { appEvents.emit('panel-change-view', {
fullscreen: true, fullscreen: true,
edit: null,
panelId: dashboard.meta.focusPanelId, panelId: dashboard.meta.focusPanelId,
toggle: true, toggle: true,
}); });
@ -199,7 +198,7 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) { if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId); const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource); const datasource = await this.datasourceSrv.get(panel.datasource);
const url = await getExploreUrl(panel.targets, datasource, this.datasourceSrv, this.timeSrv); const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
if (url) { if (url) {
this.$timeout(() => this.$location.url(url)); this.$timeout(() => this.$location.url(url));
} }

View File

@ -30,6 +30,7 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
showingLogs: true, showingLogs: true,
dedupStrategy: LogsDedupStrategy.none, dedupStrategy: LogsDedupStrategy.none,
}, },
originPanelId: undefined,
}; };
describe('state functions', () => { describe('state functions', () => {

View File

@ -18,7 +18,7 @@ import { renderUrl } from 'app/core/utils/url';
import store from 'app/core/store'; import store from 'app/core/store';
import { getNextRefIdChar } from './query'; import { getNextRefIdChar } from './query';
// Types // Types
import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest } from '@grafana/ui'; import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest, PanelModel } from '@grafana/ui';
import { import {
ExploreUrlState, ExploreUrlState,
HistoryItem, HistoryItem,
@ -29,6 +29,7 @@ import {
} from 'app/types/explore'; } from 'app/types/explore';
import { config } from '../config'; import { config } from '../config';
import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState'; import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
export const DEFAULT_RANGE = { export const DEFAULT_RANGE = {
from: 'now-1h', from: 'now-1h',
@ -55,7 +56,13 @@ export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DAT
* @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed * @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 * @param timeSrv Time service to get the current dashboard range from
*/ */
export async function getExploreUrl(panelTargets: any[], panelDatasource: any, datasourceSrv: any, timeSrv: any) { export async function getExploreUrl(
panel: PanelModel,
panelTargets: DataQuery[],
panelDatasource: any,
datasourceSrv: any,
timeSrv: TimeSrv
) {
let exploreDatasource = panelDatasource; let exploreDatasource = panelDatasource;
let exploreTargets: DataQuery[] = panelTargets; let exploreTargets: DataQuery[] = panelTargets;
let url: string; let url: string;
@ -86,7 +93,7 @@ export async function getExploreUrl(panelTargets: any[], panelDatasource: any, d
}; };
} }
const exploreState = JSON.stringify(state); const exploreState = JSON.stringify({ ...state, originPanelId: panel.id });
url = renderUrl('/explore', { left: exploreState }); url = renderUrl('/explore', { left: exploreState });
} }
return url; return url;
@ -198,6 +205,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
range: DEFAULT_RANGE, range: DEFAULT_RANGE,
ui: DEFAULT_UI_STATE, ui: DEFAULT_UI_STATE,
mode: null, mode: null,
originPanelId: null,
}; };
if (!parsed) { if (!parsed) {
@ -234,7 +242,8 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
} }
: DEFAULT_UI_STATE; : DEFAULT_UI_STATE;
return { datasource, queries, range, ui, mode }; const originPanelId = parsedSegments.filter(segment => isSegment(segment, 'originPanelId'))[0];
return { datasource, queries, range, ui, mode, originPanelId };
} }
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {

View File

@ -1,5 +1,6 @@
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { DashboardSrv } from '../../services/DashboardSrv'; import { DashboardSrv } from '../../services/DashboardSrv';
import { CloneOptions } from '../../state/DashboardModel';
const template = ` const template = `
<div class="modal-body"> <div class="modal-body">
@ -95,7 +96,7 @@ export class SaveDashboardModalCtrl {
return; return;
} }
const options: any = { const options: CloneOptions = {
saveVariables: this.saveVariables, saveVariables: this.saveVariables,
saveTimerange: this.saveTimerange, saveTimerange: this.saveTimerange,
message: this.message, message: this.message,
@ -105,11 +106,10 @@ export class SaveDashboardModalCtrl {
const saveModel = dashboard.getSaveModelClone(options); const saveModel = dashboard.getSaveModelClone(options);
this.isSaving = true; this.isSaving = true;
return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options)); return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options));
} }
postSave(options: any) { postSave(options?: { saveVariables?: boolean; saveTimerange?: boolean }) {
if (options.saveVariables) { if (options.saveVariables) {
this.dashboardSrv.getCurrent().resetOriginalVariables(); this.dashboardSrv.getCurrent().resetOriginalVariables();
} }

View File

@ -65,7 +65,7 @@ export class ChangeTracker {
return false; return false;
}); });
if (originalCopyDelay) { if (originalCopyDelay && !dashboard.meta.fromExplore) {
this.$timeout(() => { this.$timeout(() => {
// wait for different services to patch the dashboard (missing properties) // wait for different services to patch the dashboard (missing properties)
this.original = dashboard.getSaveModelClone(); this.original = dashboard.getSaveModelClone();

View File

@ -7,6 +7,13 @@ import { DashboardMeta } from 'app/types';
import { BackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv } from 'app/core/services/backend_srv';
import { ILocationService } from 'angular'; import { ILocationService } from 'angular';
interface DashboardSaveOptions {
folderId?: number;
overwrite?: boolean;
message?: string;
makeEditable?: boolean;
}
export class DashboardSrv { export class DashboardSrv {
dashboard: DashboardModel; dashboard: DashboardModel;
@ -37,54 +44,53 @@ export class DashboardSrv {
removePanel(dashboard, dashboard.getPanelById(panelId), true); removePanel(dashboard, dashboard.getPanelById(panelId), true);
}; };
onPanelChangeView = (options: any) => { onPanelChangeView = ({
fullscreen = false,
edit = false,
panelId,
}: {
fullscreen?: boolean;
edit?: boolean;
panelId?: number;
}) => {
const urlParams = this.$location.search(); const urlParams = this.$location.search();
// handle toggle logic // handle toggle logic
if (options.fullscreen === urlParams.fullscreen) { // I hate using these truthy converters (!!) but in this case
// I hate using these truthy converters (!!) but in this case // I think it's appropriate. edit can be null/false/undefined and
// I think it's appropriate. edit can be null/false/undefined and // here i want all of those to compare the same
// here i want all of those to compare the same if (fullscreen === urlParams.fullscreen && edit === !!urlParams.edit) {
if (!!options.edit === !!urlParams.edit) { const paramsToRemove = ['fullscreen', 'edit', 'panelId', 'tab'];
delete urlParams.fullscreen; for (const key of paramsToRemove) {
delete urlParams.edit; delete urlParams[key];
delete urlParams.panelId;
delete urlParams.tab;
this.$location.search(urlParams);
return;
} }
this.$location.search(urlParams);
return;
} }
if (options.fullscreen) { const newUrlParams = {
urlParams.fullscreen = true; ...urlParams,
} else { fullscreen: fullscreen || undefined,
delete urlParams.fullscreen; edit: edit || undefined,
} tab: edit ? urlParams.tab : undefined,
panelId,
};
if (options.edit) { Object.keys(newUrlParams).forEach(key => {
urlParams.edit = true; if (newUrlParams[key] === undefined) {
} else { delete newUrlParams[key];
delete urlParams.edit; }
delete urlParams.tab; });
}
if (options.panelId || options.panelId === 0) { this.$location.search(newUrlParams);
urlParams.panelId = options.panelId;
} else {
delete urlParams.panelId;
}
this.$location.search(urlParams);
}; };
handleSaveDashboardError( handleSaveDashboardError(
clone: any, clone: any,
options: { overwrite?: any }, options: DashboardSaveOptions,
err: { data: { status: string; message: any }; isHandled: boolean } err: { data: { status: string; message: any }; isHandled: boolean }
) { ) {
options = options || {};
options.overwrite = true;
if (err.data && err.data.status === 'version-mismatch') { if (err.data && err.data.status === 'version-mismatch') {
err.isHandled = true; err.isHandled = true;
@ -129,16 +135,16 @@ export class DashboardSrv {
this.showSaveAsModal(); this.showSaveAsModal();
}, },
onConfirm: () => { onConfirm: () => {
this.save(clone, { overwrite: true }); this.save(clone, { ...options, overwrite: true });
}, },
}); });
} }
} }
postSave(clone: DashboardModel, data: { version: number; url: string }) { postSave(data: { version: number; url: string }) {
this.dashboard.version = data.version; this.dashboard.version = data.version;
// important that these happens before location redirect below // important that these happen before location redirect below
this.$rootScope.appEvent('dashboard-saved', this.dashboard); this.$rootScope.appEvent('dashboard-saved', this.dashboard);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']); this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
@ -152,17 +158,19 @@ export class DashboardSrv {
return this.dashboard; return this.dashboard;
} }
save(clone: any, options: { overwrite?: any; folderId?: any }) { save(clone: any, options?: DashboardSaveOptions) {
options = options || {};
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId; options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
return this.backendSrv return this.backendSrv
.saveDashboard(clone, options) .saveDashboard(clone, options)
.then(this.postSave.bind(this, clone)) .then((data: any) => this.postSave(data))
.catch(this.handleSaveDashboardError.bind(this, clone, options)); .catch(this.handleSaveDashboardError.bind(this, clone, { folderId: options.folderId }));
} }
saveDashboard(options?: { overwrite?: any; folderId?: any; makeEditable?: any }, clone?: DashboardModel) { saveDashboard(
clone?: DashboardModel,
{ makeEditable = false, folderId, overwrite = false, message }: DashboardSaveOptions = {}
) {
if (clone) { if (clone) {
this.setCurrent(this.create(clone, this.dashboard.meta)); this.setCurrent(this.create(clone, this.dashboard.meta));
} }
@ -171,7 +179,7 @@ export class DashboardSrv {
return this.showDashboardProvisionedModal(); return this.showDashboardProvisionedModal();
} }
if (!this.dashboard.meta.canSave && options.makeEditable !== true) { if (!(this.dashboard.meta.canSave || makeEditable)) {
return Promise.resolve(); return Promise.resolve();
} }
@ -183,7 +191,7 @@ export class DashboardSrv {
return this.showSaveModal(); return this.showSaveModal();
} }
return this.save(this.dashboard.getSaveModelClone(), options); return this.save(this.dashboard.getSaveModelClone(), { folderId, overwrite, message });
} }
saveJSONDashboard(json: string) { saveJSONDashboard(json: string) {

View File

@ -4,6 +4,7 @@ import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardRouteInfo } from 'app/types'; import { DashboardRouteInfo } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv } from 'app/core/services/backend_srv';
import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions'; import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions';
import { resetExploreAction } from 'app/features/explore/state/actionTypes';
jest.mock('app/core/services/backend_srv'); jest.mock('app/core/services/backend_srv');
@ -16,6 +17,7 @@ interface ScenarioContext {
unsavedChangesSrv: any; unsavedChangesSrv: any;
variableSrv: any; variableSrv: any;
dashboardSrv: any; dashboardSrv: any;
loaderSrv: any;
keybindingSrv: any; keybindingSrv: any;
backendSrv: any; backendSrv: any;
setup: (fn: () => void) => void; setup: (fn: () => void) => void;
@ -33,6 +35,33 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
const variableSrv = { init: jest.fn() }; const variableSrv = { init: jest.fn() };
const dashboardSrv = { setCurrent: jest.fn() }; const dashboardSrv = { setCurrent: jest.fn() };
const keybindingSrv = { setupDashboardBindings: jest.fn() }; const keybindingSrv = { setupDashboardBindings: jest.fn() };
const loaderSrv = {
loadDashboard: jest.fn(() => ({
meta: {
canStar: false,
canShare: false,
isNew: true,
folderId: 0,
},
dashboard: {
title: 'My cool dashboard',
panels: [
{
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 9 },
title: 'Panel Title',
id: 2,
targets: [
{
refId: 'A',
expr: 'old expr',
},
],
},
],
},
})),
};
const injectorMock = { const injectorMock = {
get: (name: string) => { get: (name: string) => {
@ -41,6 +70,8 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
return timeSrv; return timeSrv;
case 'annotationsSrv': case 'annotationsSrv':
return annotationsSrv; return annotationsSrv;
case 'dashboardLoaderSrv':
return loaderSrv;
case 'unsavedChangesSrv': case 'unsavedChangesSrv':
return unsavedChangesSrv; return unsavedChangesSrv;
case 'dashboardSrv': case 'dashboardSrv':
@ -71,12 +102,19 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
variableSrv, variableSrv,
dashboardSrv, dashboardSrv,
keybindingSrv, keybindingSrv,
loaderSrv,
actions: [], actions: [],
storeState: { storeState: {
location: { location: {
query: {}, query: {},
}, },
user: {}, user: {},
explore: {
left: {
originPanelId: undefined,
queries: [],
},
},
}, },
setup: (fn: () => void) => { setup: (fn: () => void) => {
setupFn = fn; setupFn = fn;
@ -121,7 +159,7 @@ describeInitScenario('Initializing new dashboard', ctx => {
expect(ctx.actions[3].payload.title).toBe('New dashboard'); expect(ctx.actions[3].payload.title).toBe('New dashboard');
}); });
it('Should Initializing services', () => { it('Should initialize services', () => {
expect(ctx.timeSrv.init).toBeCalled(); expect(ctx.timeSrv.init).toBeCalled();
expect(ctx.annotationsSrv.init).toBeCalled(); expect(ctx.annotationsSrv.init).toBeCalled();
expect(ctx.variableSrv.init).toBeCalled(); expect(ctx.variableSrv.init).toBeCalled();
@ -146,3 +184,68 @@ describeInitScenario('Initializing home dashboard', ctx => {
expect(ctx.actions[1].payload.path).toBe('/u/123/my-home'); expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
}); });
}); });
describeInitScenario('Initializing existing dashboard', ctx => {
const mockQueries = [
{
context: 'explore',
key: 'jdasldsa98dsa9',
refId: 'A',
expr: 'new expr',
},
{
context: 'explore',
key: 'fdsjkfds78fd',
refId: 'B',
},
];
const expectedQueries = mockQueries.map(query => ({ refId: query.refId, expr: query.expr }));
ctx.setup(() => {
ctx.storeState.user.orgId = 12;
ctx.storeState.explore.left.originPanelId = 2;
ctx.storeState.explore.left.queries = mockQueries;
});
it('Should send action dashboardInitFetching', () => {
expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
});
it('Should send action dashboardInitServices ', () => {
expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
});
it('Should update location with orgId query param', () => {
expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[2].payload.query.orgId).toBe(12);
});
it('Should send resetExploreAction when coming from explore', () => {
expect(ctx.actions[3].type).toBe(resetExploreAction.type);
expect(ctx.actions[3].payload.force).toBe(true);
expect(ctx.dashboardSrv.setCurrent).lastCalledWith(
expect.objectContaining({
panels: expect.arrayContaining([
expect.objectContaining({
targets: expectedQueries,
}),
]),
})
);
});
it('Should send action dashboardInitCompleted', () => {
expect(ctx.actions[4].type).toBe(dashboardInitCompleted.type);
expect(ctx.actions[4].payload.title).toBe('My cool dashboard');
});
it('Should initialize services', () => {
expect(ctx.timeSrv.init).toBeCalled();
expect(ctx.annotationsSrv.init).toBeCalled();
expect(ctx.variableSrv.init).toBeCalled();
expect(ctx.unsavedChangesSrv.init).toBeCalled();
expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
expect(ctx.dashboardSrv.setCurrent).toBeCalled();
});
});

View File

@ -21,8 +21,10 @@ import {
} from './actions'; } from './actions';
// Types // Types
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types'; import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO, ExploreItemState } from 'app/types';
import { DashboardModel } from './DashboardModel'; import { DashboardModel } from './DashboardModel';
import { resetExploreAction } from 'app/features/explore/state/actionTypes';
import { DataQuery } from '@grafana/ui';
export interface InitDashboardArgs { export interface InitDashboardArgs {
$injector: any; $injector: any;
@ -171,6 +173,9 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
timeSrv.init(dashboard); timeSrv.init(dashboard);
annotationsSrv.init(dashboard); annotationsSrv.init(dashboard);
const left = storeState.explore && storeState.explore.left;
dashboard.meta.fromExplore = !!(left && left.originPanelId);
// template values service needs to initialize completely before // template values service needs to initialize completely before
// the rest of the dashboard can load // the rest of the dashboard can load
try { try {
@ -198,8 +203,13 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
console.log(err); console.log(err);
} }
if (dashboard.meta.fromExplore) {
updateQueriesWhenComingFromExplore(dispatch, dashboard, left);
}
// legacy srv state // legacy srv state
dashboardSrv.setCurrent(dashboard); dashboardSrv.setCurrent(dashboard);
// yay we are done // yay we are done
dispatch(dashboardInitCompleted(dashboard)); dispatch(dashboardInitCompleted(dashboard));
}; };
@ -231,3 +241,28 @@ function getNewDashboardModelData(urlFolderId?: string): any {
return data; return data;
} }
function updateQueriesWhenComingFromExplore(
dispatch: ThunkDispatch,
dashboard: DashboardModel,
left: ExploreItemState
) {
// When returning to the origin panel from explore, if we're doing
// so with changes all the explore state is reset _except_ the queries
// and the origin panel ID.
const panelArrId = dashboard.panels.findIndex(panel => panel.id === left.originPanelId);
if (panelArrId > -1) {
dashboard.panels[panelArrId].targets = left.queries.map((query: DataQuery & { context?: string }) => {
delete query.context;
delete query.key;
return query;
});
}
dashboard.startRefresh();
// Force-reset explore so that on subsequent dashboard loads we aren't
// taking the modified queries from explore again.
dispatch(resetExploreAction({ force: true }));
}

View File

@ -90,6 +90,7 @@ interface ExploreProps {
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
toggleGraph: typeof toggleGraph; toggleGraph: typeof toggleGraph;
queryResponse: PanelData; queryResponse: PanelData;
originPanelId: number;
} }
/** /**
@ -126,7 +127,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
} }
componentDidMount() { componentDidMount() {
const { initialized, exploreId, initialDatasource, initialQueries, initialRange, mode, initialUI } = this.props; const {
initialized,
exploreId,
initialDatasource,
initialQueries,
initialRange,
mode,
initialUI,
originPanelId,
} = this.props;
const width = this.el ? this.el.offsetWidth : 0; const width = this.el ? this.el.offsetWidth : 0;
// initialize the whole explore first time we mount and if browser history contains a change in datasource // initialize the whole explore first time we mount and if browser history contains a change in datasource
@ -139,7 +149,8 @@ export class Explore extends React.PureComponent<ExploreProps> {
mode, mode,
width, width,
this.exploreEvents, this.exploreEvents,
initialUI initialUI,
originPanelId
); );
} }
} }
@ -351,7 +362,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
queryResponse, queryResponse,
} = item; } = item;
const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState; const { datasource, queries, range: urlRange, mode: urlMode, ui, originPanelId } = (urlState ||
{}) as ExploreUrlState;
const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId)); const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
const initialQueries: DataQuery[] = ensureQueriesMemoized(queries); const initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
const initialRange = urlRange ? getTimeRangeFromUrlMemoized(urlRange, timeZone).raw : DEFAULT_RANGE; const initialRange = urlRange ? getTimeRangeFromUrlMemoized(urlRange, timeZone).raw : DEFAULT_RANGE;
@ -397,6 +409,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showingTable, showingTable,
absoluteRange, absoluteRange,
queryResponse, queryResponse,
originPanelId,
}; };
} }

View File

@ -1,10 +1,12 @@
import omitBy from 'lodash/omitBy';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import classNames from 'classnames';
import { ExploreId, ExploreMode } from 'app/types/explore'; import { ExploreId, ExploreMode } from 'app/types/explore';
import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton } from '@grafana/ui'; import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton, DataQuery, Tooltip, ButtonSelect } from '@grafana/ui';
import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data'; import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store'; import { StoreState } from 'app/types/store';
@ -16,8 +18,12 @@ import {
splitOpen, splitOpen,
changeRefreshInterval, changeRefreshInterval,
changeMode, changeMode,
clearOrigin,
} from './state/actions'; } from './state/actions';
import { updateLocation } from 'app/core/actions';
import { getTimeZone } from '../profile/state/selectors'; import { getTimeZone } from '../profile/state/selectors';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import kbn from '../../core/utils/kbn';
import { ExploreTimeControls } from './ExploreTimeControls'; import { ExploreTimeControls } from './ExploreTimeControls';
enum IconSide { enum IconSide {
@ -71,6 +77,8 @@ interface StateProps {
selectedModeOption: SelectableValue<ExploreMode>; selectedModeOption: SelectableValue<ExploreMode>;
hasLiveOption: boolean; hasLiveOption: boolean;
isLive: boolean; isLive: boolean;
originPanelId: number;
queries: DataQuery[];
} }
interface DispatchProps { interface DispatchProps {
@ -81,6 +89,8 @@ interface DispatchProps {
split: typeof splitOpen; split: typeof splitOpen;
changeRefreshInterval: typeof changeRefreshInterval; changeRefreshInterval: typeof changeRefreshInterval;
changeMode: typeof changeMode; changeMode: typeof changeMode;
clearOrigin: typeof clearOrigin;
updateLocation: typeof updateLocation;
} }
type Props = StateProps & DispatchProps & OwnProps; type Props = StateProps & DispatchProps & OwnProps;
@ -112,6 +122,31 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
changeMode(exploreId, mode); changeMode(exploreId, mode);
}; };
returnToPanel = async ({ withChanges = false } = {}) => {
const { originPanelId } = this.props;
const dashboardSrv = getDashboardSrv();
const dash = dashboardSrv.getCurrent();
const titleSlug = kbn.slugifyForUrl(dash.title);
if (!withChanges) {
this.props.clearOrigin();
}
const dashViewOptions = {
fullscreen: withChanges || dash.meta.fullscreen,
edit: withChanges || dash.meta.isEditing,
};
this.props.updateLocation({
path: `/d/${dash.uid}/:${titleSlug}`,
query: {
...omitBy(dashViewOptions, v => !v),
panelId: originPanelId,
},
});
};
render() { render() {
const { const {
datasourceMissing, datasourceMissing,
@ -130,8 +165,15 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
selectedModeOption, selectedModeOption,
hasLiveOption, hasLiveOption,
isLive, isLive,
originPanelId,
} = this.props; } = this.props;
const originDashboardIsEditable = Number.isInteger(originPanelId);
const panelReturnClasses = classNames('btn', 'navbar-button', {
'btn--radius-right-0': originDashboardIsEditable,
'navbar-button navbar-button--border-right-0': originDashboardIsEditable,
});
return ( return (
<div className={splitted ? 'explore-toolbar splitted' : 'explore-toolbar'}> <div className={splitted ? 'explore-toolbar splitted' : 'explore-toolbar'}>
<div className="explore-toolbar-item"> <div className="explore-toolbar-item">
@ -187,6 +229,24 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
</div> </div>
) : null} ) : null}
{Number.isInteger(originPanelId) && !splitted && (
<div className="explore-toolbar-content-item">
<Tooltip content={'Return to panel'} placement="bottom">
<button className={panelReturnClasses} onClick={() => this.returnToPanel()}>
<i className="fa fa-arrow-left" />
</button>
</Tooltip>
{originDashboardIsEditable && (
<ButtonSelect
className="navbar-button--attached btn--radius-left-0$"
options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => this.returnToPanel({ withChanges: true })}
maxMenuHeight={380}
/>
)}
</div>
)}
{exploreId === 'left' && !splitted ? ( {exploreId === 'left' && !splitted ? (
<div className="explore-toolbar-content-item"> <div className="explore-toolbar-content-item">
{createResponsiveButton({ {createResponsiveButton({
@ -285,6 +345,8 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
supportedModes, supportedModes,
mode, mode,
isLive, isLive,
originPanelId,
queries,
} = exploreItem; } = exploreItem;
const selectedDatasource = datasourceInstance const selectedDatasource = datasourceInstance
? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name) ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
@ -307,17 +369,21 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
selectedModeOption, selectedModeOption,
hasLiveOption, hasLiveOption,
isLive, isLive,
originPanelId,
queries,
}; };
}; };
const mapDispatchToProps: DispatchProps = { const mapDispatchToProps: DispatchProps = {
changeDatasource, changeDatasource,
updateLocation,
changeRefreshInterval, changeRefreshInterval,
clearAll: clearQueries, clearAll: clearQueries,
runQueries, runQueries,
closeSplit: splitClose, closeSplit: splitClose,
split: splitOpen, split: splitOpen,
changeMode: changeMode, changeMode: changeMode,
clearOrigin,
}; };
export const ExploreToolbar = hot(module)( export const ExploreToolbar = hot(module)(

View File

@ -16,7 +16,7 @@ interface WrapperProps {
export class Wrapper extends Component<WrapperProps> { export class Wrapper extends Component<WrapperProps> {
componentWillUnmount() { componentWillUnmount() {
this.props.resetExploreAction(); this.props.resetExploreAction({});
} }
render() { render() {

View File

@ -4,7 +4,7 @@ import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelDa
import { LogLevel, TimeRange, LoadingState, AbsoluteTimeRange } from '@grafana/data'; import { LogLevel, TimeRange, LoadingState, AbsoluteTimeRange } from '@grafana/data';
import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore'; import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore';
import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory'; import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
/** Higher order actions /** Higher order actions
* *
@ -61,6 +61,14 @@ export interface ClearQueriesPayload {
exploreId: ExploreId; exploreId: ExploreId;
} }
export interface ClearOriginPayload {
exploreId: ExploreId;
}
export interface ClearRefreshIntervalPayload {
exploreId: ExploreId;
}
export interface HighlightLogsExpressionPayload { export interface HighlightLogsExpressionPayload {
exploreId: ExploreId; exploreId: ExploreId;
expressions: string[]; expressions: string[];
@ -74,6 +82,7 @@ export interface InitializeExplorePayload {
range: TimeRange; range: TimeRange;
mode: ExploreMode; mode: ExploreMode;
ui: ExploreUIState; ui: ExploreUIState;
originPanelId: number;
} }
export interface LoadDatasourceMissingPayload { export interface LoadDatasourceMissingPayload {
@ -202,6 +211,10 @@ export interface SetPausedStatePayload {
isPaused: boolean; isPaused: boolean;
} }
export interface ResetExplorePayload {
force?: boolean;
}
/** /**
* Adds a query row after the row with the given index. * Adds a query row after the row with the given index.
*/ */
@ -236,6 +249,11 @@ export const changeRefreshIntervalAction = actionCreatorFactory<ChangeRefreshInt
*/ */
export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create(); export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create();
/**
* Clear origin panel id.
*/
export const clearOriginAction = actionCreatorFactory<ClearOriginPayload>('explore/CLEAR_ORIGIN').create();
/** /**
* Highlight expressions in the log results * Highlight expressions in the log results
*/ */
@ -351,7 +369,7 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
/** /**
* Resets state for explore. * Resets state for explore.
*/ */
export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create(); export const resetExploreAction = actionCreatorFactory<ResetExplorePayload>('explore/RESET_EXPLORE').create();
export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create(); export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
export const testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>( export const testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>(
'explore/TEST_DATASOURCE_PENDING' 'explore/TEST_DATASOURCE_PENDING'

View File

@ -69,6 +69,7 @@ import {
historyUpdatedAction, historyUpdatedAction,
queryEndedAction, queryEndedAction,
queryStreamUpdatedAction, queryStreamUpdatedAction,
clearOriginAction,
} from './actionTypes'; } from './actionTypes';
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeZone } from 'app/features/profile/state/selectors';
@ -208,6 +209,12 @@ export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
}; };
} }
export function clearOrigin(): ThunkResult<void> {
return dispatch => {
dispatch(clearOriginAction({ exploreId: ExploreId.left }));
};
}
/** /**
* Loads all explore data sources and sets the chosen datasource. * Loads all explore data sources and sets the chosen datasource.
* If there are no datasources a missing datasource action is dispatched. * If there are no datasources a missing datasource action is dispatched.
@ -250,7 +257,8 @@ export function initializeExplore(
mode: ExploreMode, mode: ExploreMode,
containerWidth: number, containerWidth: number,
eventBridge: Emitter, eventBridge: Emitter,
ui: ExploreUIState ui: ExploreUIState,
originPanelId: number
): ThunkResult<void> { ): ThunkResult<void> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const timeZone = getTimeZone(getState().user); const timeZone = getTimeZone(getState().user);
@ -265,6 +273,7 @@ export function initializeExplore(
range, range,
mode, mode,
ui, ui,
originPanelId,
}) })
); );
dispatch(updateTime({ exploreId })); dispatch(updateTime({ exploreId }));
@ -722,7 +731,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
} }
const { urlState, update, containerWidth, eventBridge } = itemState; const { urlState, update, containerWidth, eventBridge } = itemState;
const { datasource, queries, range: urlRange, mode, ui } = urlState; const { datasource, queries, range: urlRange, mode, ui, originPanelId } = urlState;
const refreshQueries: DataQuery[] = []; const refreshQueries: DataQuery[] = [];
for (let index = 0; index < queries.length; index++) { for (let index = 0; index < queries.length; index++) {
const query = queries[index]; const query = queries[index];
@ -734,7 +743,19 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
// need to refresh datasource // need to refresh datasource
if (update.datasource) { if (update.datasource) {
const initialQueries = ensureQueries(queries); const initialQueries = ensureQueries(queries);
dispatch(initializeExplore(exploreId, datasource, initialQueries, range, mode, containerWidth, eventBridge, ui)); dispatch(
initializeExplore(
exploreId,
datasource,
initialQueries,
range,
mode,
containerWidth,
eventBridge,
ui,
originPanelId
)
);
return; return;
} }

View File

@ -28,6 +28,10 @@ import {
scanStopAction, scanStopAction,
queryStartAction, queryStartAction,
changeRangeAction, changeRangeAction,
clearOriginAction,
} from './actionTypes';
import {
addQueryRowAction, addQueryRowAction,
changeQueryAction, changeQueryAction,
changeSizeAction, changeSizeAction,
@ -235,6 +239,15 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
}; };
}, },
}) })
.addMapper({
filter: clearOriginAction,
mapper: (state): ExploreItemState => {
return {
...state,
originPanelId: undefined,
};
},
})
.addMapper({ .addMapper({
filter: highlightLogsExpressionAction, filter: highlightLogsExpressionAction,
mapper: (state, action): ExploreItemState => { mapper: (state, action): ExploreItemState => {
@ -245,7 +258,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
.addMapper({ .addMapper({
filter: initializeExploreAction, filter: initializeExploreAction,
mapper: (state, action): ExploreItemState => { mapper: (state, action): ExploreItemState => {
const { containerWidth, eventBridge, queries, range, mode, ui } = action.payload; const { containerWidth, eventBridge, queries, range, mode, ui, originPanelId } = action.payload;
return { return {
...state, ...state,
containerWidth, containerWidth,
@ -256,6 +269,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
initialized: true, initialized: true,
queryKeys: getQueryKeys(queries, state.datasourceInstance), queryKeys: getQueryKeys(queries, state.datasourceInstance),
...ui, ...ui,
originPanelId,
update: makeInitialUpdateState(), update: makeInitialUpdateState(),
}; };
}, },
@ -265,6 +279,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
mapper: (state, action): ExploreItemState => { mapper: (state, action): ExploreItemState => {
const { datasourceInstance } = action.payload; const { datasourceInstance } = action.payload;
const [supportedModes, mode] = getModesForDatasource(datasourceInstance, state.mode); const [supportedModes, mode] = getModesForDatasource(datasourceInstance, state.mode);
const originPanelId = state.urlState && state.urlState.originPanelId;
// Custom components // Custom components
const StartPage = datasourceInstance.components.ExploreStartPage; const StartPage = datasourceInstance.components.ExploreStartPage;
stopQueryState(state.queryState, 'Datasource changed'); stopQueryState(state.queryState, 'Datasource changed');
@ -283,6 +300,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
queryKeys: [], queryKeys: [],
supportedModes, supportedModes,
mode, mode,
originPanelId,
}; };
}, },
}) })
@ -704,7 +722,18 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
} }
case ActionTypes.ResetExplore: { case ActionTypes.ResetExplore: {
return initialExploreState; if (action.payload.force || !Number.isInteger(state.left.originPanelId)) {
return initialExploreState;
}
return {
...initialExploreState,
left: {
...initialExploreItemState,
queries: state.left.queries,
originPanelId: state.left.originPanelId,
},
};
} }
case updateLocation.type: { case updateLocation.type: {

View File

@ -255,11 +255,18 @@ class MetricsPanelCtrl extends PanelCtrl {
text: 'Explore', text: 'Explore',
icon: 'gicon gicon-explore', icon: 'gicon gicon-explore',
shortcut: 'x', shortcut: 'x',
href: await getExploreUrl(this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv), href: await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv),
}); });
} }
return items; return items;
} }
async explore() {
const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
if (url) {
this.$timeout(() => this.$location.url(url));
}
}
} }
export { MetricsPanelCtrl }; export { MetricsPanelCtrl };

View File

@ -25,10 +25,10 @@ import {
DataQueryResponseData, DataQueryResponseData,
DataStreamState, DataStreamState,
} from '@grafana/ui'; } from '@grafana/ui';
import { ExploreUrlState } from 'app/types/explore';
import { safeStringifyValue } from 'app/core/utils/explore'; import { safeStringifyValue } from 'app/core/utils/explore';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ExploreUrlState } from 'app/types';
export interface PromDataQueryResponse { export interface PromDataQueryResponse {
data: { data: {
@ -603,15 +603,16 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
getExploreState(queries: PromQuery[]): Partial<ExploreUrlState> { getExploreState(queries: PromQuery[]): Partial<ExploreUrlState> {
let state: Partial<ExploreUrlState> = { datasource: this.name }; let state: Partial<ExploreUrlState> = { datasource: this.name };
if (queries && queries.length > 0) { if (queries && queries.length > 0) {
const expandedQueries = queries.map(query => ({ const expandedQueries = queries.map(query => {
...query, const expandedQuery = {
expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr), ...query,
context: 'explore', expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr),
context: 'explore',
};
return expandedQuery;
});
// null out values we don't support in Explore yet
legendFormat: null,
step: null,
}));
state = { state = {
...state, ...state,
queries: expandedQueries, queries: expandedQueries,

View File

@ -22,6 +22,7 @@ export interface DashboardMeta {
url?: string; url?: string;
folderId?: number; folderId?: number;
fullscreen?: boolean; fullscreen?: boolean;
fromExplore?: boolean;
isEditing?: boolean; isEditing?: boolean;
canMakeEditable?: boolean; canMakeEditable?: boolean;
submenuEnabled?: boolean; submenuEnabled?: boolean;

View File

@ -266,6 +266,7 @@ export interface ExploreItemState {
queryState: PanelQueryState; queryState: PanelQueryState;
queryResponse: PanelData; queryResponse: PanelData;
originPanelId?: number;
} }
export interface ExploreUpdateState { export interface ExploreUpdateState {
@ -289,6 +290,7 @@ export interface ExploreUrlState {
mode: ExploreMode; mode: ExploreMode;
range: RawTimeRange; range: RawTimeRange;
ui: ExploreUIState; ui: ExploreUIState;
originPanelId?: number;
} }
export interface HistoryItem<TQuery extends DataQuery = DataQuery> { export interface HistoryItem<TQuery extends DataQuery = DataQuery> {