mirror of
https://github.com/grafana/grafana.git
synced 2025-01-25 15:56:56 -06:00
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:
parent
991f77cee1
commit
a838d2b30a
@ -61,7 +61,7 @@ export class RefreshPicker extends PureComponent<Props> {
|
||||
<div className={cssClasses}>
|
||||
<div className="refresh-picker-buttons">
|
||||
<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" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
@ -6,7 +6,7 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-button--refresh {
|
||||
.navbar-button--border-right-0 {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
|
@ -43,6 +43,7 @@ export interface Props<T> {
|
||||
onOpenMenu?: () => void;
|
||||
onCloseMenu?: () => void;
|
||||
tabSelectsValue?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export class ButtonSelect<T> extends PureComponent<Props<T>> {
|
||||
@ -65,14 +66,16 @@ export class ButtonSelect<T> extends PureComponent<Props<T>> {
|
||||
onOpenMenu,
|
||||
onCloseMenu,
|
||||
tabSelectsValue,
|
||||
autoFocus = true,
|
||||
} = this.props;
|
||||
const combinedComponents = {
|
||||
...components,
|
||||
Control: ButtonComponent({ label, className, iconClass }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
backspaceRemovesValue={false}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
|
@ -263,14 +263,15 @@ export class BackendSrv implements BackendService {
|
||||
return this.get(`/api/folders/${uid}`);
|
||||
}
|
||||
|
||||
saveDashboard(dash: DashboardModel, options: any) {
|
||||
options = options || {};
|
||||
|
||||
saveDashboard(
|
||||
dash: DashboardModel,
|
||||
{ message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {}
|
||||
) {
|
||||
return this.post('/api/dashboards/db/', {
|
||||
dashboard: dash,
|
||||
folderId: options.folderId,
|
||||
overwrite: options.overwrite === true,
|
||||
message: options.message || '',
|
||||
folderId,
|
||||
overwrite,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -186,7 +186,6 @@ export class KeybindingSrv {
|
||||
if (dashboard.meta.focusPanelId) {
|
||||
appEvents.emit('panel-change-view', {
|
||||
fullscreen: true,
|
||||
edit: null,
|
||||
panelId: dashboard.meta.focusPanelId,
|
||||
toggle: true,
|
||||
});
|
||||
@ -199,7 +198,7 @@ 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.targets, datasource, this.datasourceSrv, this.timeSrv);
|
||||
const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
|
||||
if (url) {
|
||||
this.$timeout(() => this.$location.url(url));
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
|
||||
showingLogs: true,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
},
|
||||
originPanelId: undefined,
|
||||
};
|
||||
|
||||
describe('state functions', () => {
|
||||
|
@ -18,7 +18,7 @@ import { renderUrl } from 'app/core/utils/url';
|
||||
import store from 'app/core/store';
|
||||
import { getNextRefIdChar } from './query';
|
||||
// Types
|
||||
import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest } from '@grafana/ui';
|
||||
import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest, PanelModel } from '@grafana/ui';
|
||||
import {
|
||||
ExploreUrlState,
|
||||
HistoryItem,
|
||||
@ -29,6 +29,7 @@ import {
|
||||
} from 'app/types/explore';
|
||||
import { config } from '../config';
|
||||
import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
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 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 exploreTargets: DataQuery[] = panelTargets;
|
||||
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 });
|
||||
}
|
||||
return url;
|
||||
@ -198,6 +205,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
range: DEFAULT_RANGE,
|
||||
ui: DEFAULT_UI_STATE,
|
||||
mode: null,
|
||||
originPanelId: null,
|
||||
};
|
||||
|
||||
if (!parsed) {
|
||||
@ -234,7 +242,8 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
}
|
||||
: 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 {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { DashboardSrv } from '../../services/DashboardSrv';
|
||||
import { CloneOptions } from '../../state/DashboardModel';
|
||||
|
||||
const template = `
|
||||
<div class="modal-body">
|
||||
@ -95,7 +96,7 @@ export class SaveDashboardModalCtrl {
|
||||
return;
|
||||
}
|
||||
|
||||
const options: any = {
|
||||
const options: CloneOptions = {
|
||||
saveVariables: this.saveVariables,
|
||||
saveTimerange: this.saveTimerange,
|
||||
message: this.message,
|
||||
@ -105,11 +106,10 @@ export class SaveDashboardModalCtrl {
|
||||
const saveModel = dashboard.getSaveModelClone(options);
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options));
|
||||
}
|
||||
|
||||
postSave(options: any) {
|
||||
postSave(options?: { saveVariables?: boolean; saveTimerange?: boolean }) {
|
||||
if (options.saveVariables) {
|
||||
this.dashboardSrv.getCurrent().resetOriginalVariables();
|
||||
}
|
||||
|
@ -65,7 +65,7 @@ export class ChangeTracker {
|
||||
return false;
|
||||
});
|
||||
|
||||
if (originalCopyDelay) {
|
||||
if (originalCopyDelay && !dashboard.meta.fromExplore) {
|
||||
this.$timeout(() => {
|
||||
// wait for different services to patch the dashboard (missing properties)
|
||||
this.original = dashboard.getSaveModelClone();
|
||||
|
@ -7,6 +7,13 @@ import { DashboardMeta } from 'app/types';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { ILocationService } from 'angular';
|
||||
|
||||
interface DashboardSaveOptions {
|
||||
folderId?: number;
|
||||
overwrite?: boolean;
|
||||
message?: string;
|
||||
makeEditable?: boolean;
|
||||
}
|
||||
|
||||
export class DashboardSrv {
|
||||
dashboard: DashboardModel;
|
||||
|
||||
@ -37,54 +44,53 @@ export class DashboardSrv {
|
||||
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();
|
||||
|
||||
// handle toggle logic
|
||||
if (options.fullscreen === urlParams.fullscreen) {
|
||||
// I hate using these truthy converters (!!) but in this case
|
||||
// I think it's appropriate. edit can be null/false/undefined and
|
||||
// here i want all of those to compare the same
|
||||
if (!!options.edit === !!urlParams.edit) {
|
||||
delete urlParams.fullscreen;
|
||||
delete urlParams.edit;
|
||||
delete urlParams.panelId;
|
||||
delete urlParams.tab;
|
||||
this.$location.search(urlParams);
|
||||
return;
|
||||
// I hate using these truthy converters (!!) but in this case
|
||||
// I think it's appropriate. edit can be null/false/undefined and
|
||||
// here i want all of those to compare the same
|
||||
if (fullscreen === urlParams.fullscreen && edit === !!urlParams.edit) {
|
||||
const paramsToRemove = ['fullscreen', 'edit', 'panelId', 'tab'];
|
||||
for (const key of paramsToRemove) {
|
||||
delete urlParams[key];
|
||||
}
|
||||
|
||||
this.$location.search(urlParams);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.fullscreen) {
|
||||
urlParams.fullscreen = true;
|
||||
} else {
|
||||
delete urlParams.fullscreen;
|
||||
}
|
||||
const newUrlParams = {
|
||||
...urlParams,
|
||||
fullscreen: fullscreen || undefined,
|
||||
edit: edit || undefined,
|
||||
tab: edit ? urlParams.tab : undefined,
|
||||
panelId,
|
||||
};
|
||||
|
||||
if (options.edit) {
|
||||
urlParams.edit = true;
|
||||
} else {
|
||||
delete urlParams.edit;
|
||||
delete urlParams.tab;
|
||||
}
|
||||
Object.keys(newUrlParams).forEach(key => {
|
||||
if (newUrlParams[key] === undefined) {
|
||||
delete newUrlParams[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (options.panelId || options.panelId === 0) {
|
||||
urlParams.panelId = options.panelId;
|
||||
} else {
|
||||
delete urlParams.panelId;
|
||||
}
|
||||
|
||||
this.$location.search(urlParams);
|
||||
this.$location.search(newUrlParams);
|
||||
};
|
||||
|
||||
handleSaveDashboardError(
|
||||
clone: any,
|
||||
options: { overwrite?: any },
|
||||
options: DashboardSaveOptions,
|
||||
err: { data: { status: string; message: any }; isHandled: boolean }
|
||||
) {
|
||||
options = options || {};
|
||||
options.overwrite = true;
|
||||
|
||||
if (err.data && err.data.status === 'version-mismatch') {
|
||||
err.isHandled = true;
|
||||
|
||||
@ -129,16 +135,16 @@ export class DashboardSrv {
|
||||
this.showSaveAsModal();
|
||||
},
|
||||
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;
|
||||
|
||||
// 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('alert-success', ['Dashboard saved']);
|
||||
|
||||
@ -152,17 +158,19 @@ export class DashboardSrv {
|
||||
return this.dashboard;
|
||||
}
|
||||
|
||||
save(clone: any, options: { overwrite?: any; folderId?: any }) {
|
||||
options = options || {};
|
||||
save(clone: any, options?: DashboardSaveOptions) {
|
||||
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
|
||||
|
||||
return this.backendSrv
|
||||
.saveDashboard(clone, options)
|
||||
.then(this.postSave.bind(this, clone))
|
||||
.catch(this.handleSaveDashboardError.bind(this, clone, options));
|
||||
.then((data: any) => this.postSave(data))
|
||||
.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) {
|
||||
this.setCurrent(this.create(clone, this.dashboard.meta));
|
||||
}
|
||||
@ -171,7 +179,7 @@ export class DashboardSrv {
|
||||
return this.showDashboardProvisionedModal();
|
||||
}
|
||||
|
||||
if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
|
||||
if (!(this.dashboard.meta.canSave || makeEditable)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@ -183,7 +191,7 @@ export class DashboardSrv {
|
||||
return this.showSaveModal();
|
||||
}
|
||||
|
||||
return this.save(this.dashboard.getSaveModelClone(), options);
|
||||
return this.save(this.dashboard.getSaveModelClone(), { folderId, overwrite, message });
|
||||
}
|
||||
|
||||
saveJSONDashboard(json: string) {
|
||||
|
@ -4,6 +4,7 @@ import { initDashboard, InitDashboardArgs } from './initDashboard';
|
||||
import { DashboardRouteInfo } from 'app/types';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions';
|
||||
import { resetExploreAction } from 'app/features/explore/state/actionTypes';
|
||||
|
||||
jest.mock('app/core/services/backend_srv');
|
||||
|
||||
@ -16,6 +17,7 @@ interface ScenarioContext {
|
||||
unsavedChangesSrv: any;
|
||||
variableSrv: any;
|
||||
dashboardSrv: any;
|
||||
loaderSrv: any;
|
||||
keybindingSrv: any;
|
||||
backendSrv: any;
|
||||
setup: (fn: () => void) => void;
|
||||
@ -33,6 +35,33 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
|
||||
const variableSrv = { init: jest.fn() };
|
||||
const dashboardSrv = { setCurrent: 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 = {
|
||||
get: (name: string) => {
|
||||
@ -41,6 +70,8 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
|
||||
return timeSrv;
|
||||
case 'annotationsSrv':
|
||||
return annotationsSrv;
|
||||
case 'dashboardLoaderSrv':
|
||||
return loaderSrv;
|
||||
case 'unsavedChangesSrv':
|
||||
return unsavedChangesSrv;
|
||||
case 'dashboardSrv':
|
||||
@ -71,12 +102,19 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
|
||||
variableSrv,
|
||||
dashboardSrv,
|
||||
keybindingSrv,
|
||||
loaderSrv,
|
||||
actions: [],
|
||||
storeState: {
|
||||
location: {
|
||||
query: {},
|
||||
},
|
||||
user: {},
|
||||
explore: {
|
||||
left: {
|
||||
originPanelId: undefined,
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
setup: (fn: () => void) => {
|
||||
setupFn = fn;
|
||||
@ -121,7 +159,7 @@ describeInitScenario('Initializing new dashboard', ctx => {
|
||||
expect(ctx.actions[3].payload.title).toBe('New dashboard');
|
||||
});
|
||||
|
||||
it('Should Initializing services', () => {
|
||||
it('Should initialize services', () => {
|
||||
expect(ctx.timeSrv.init).toBeCalled();
|
||||
expect(ctx.annotationsSrv.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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -21,8 +21,10 @@ import {
|
||||
} from './actions';
|
||||
|
||||
// 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 { resetExploreAction } from 'app/features/explore/state/actionTypes';
|
||||
import { DataQuery } from '@grafana/ui';
|
||||
|
||||
export interface InitDashboardArgs {
|
||||
$injector: any;
|
||||
@ -171,6 +173,9 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
timeSrv.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
|
||||
// the rest of the dashboard can load
|
||||
try {
|
||||
@ -198,8 +203,13 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
if (dashboard.meta.fromExplore) {
|
||||
updateQueriesWhenComingFromExplore(dispatch, dashboard, left);
|
||||
}
|
||||
|
||||
// legacy srv state
|
||||
dashboardSrv.setCurrent(dashboard);
|
||||
|
||||
// yay we are done
|
||||
dispatch(dashboardInitCompleted(dashboard));
|
||||
};
|
||||
@ -231,3 +241,28 @@ function getNewDashboardModelData(urlFolderId?: string): any {
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
@ -90,6 +90,7 @@ interface ExploreProps {
|
||||
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
|
||||
toggleGraph: typeof toggleGraph;
|
||||
queryResponse: PanelData;
|
||||
originPanelId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -126,7 +127,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
width,
|
||||
this.exploreEvents,
|
||||
initialUI
|
||||
initialUI,
|
||||
originPanelId
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -351,7 +362,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
queryResponse,
|
||||
} = 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 initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
|
||||
const initialRange = urlRange ? getTimeRangeFromUrlMemoized(urlRange, timeZone).raw : DEFAULT_RANGE;
|
||||
@ -397,6 +409,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
showingTable,
|
||||
absoluteRange,
|
||||
queryResponse,
|
||||
originPanelId,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import classNames from 'classnames';
|
||||
|
||||
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 { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
||||
import { StoreState } from 'app/types/store';
|
||||
@ -16,8 +18,12 @@ import {
|
||||
splitOpen,
|
||||
changeRefreshInterval,
|
||||
changeMode,
|
||||
clearOrigin,
|
||||
} from './state/actions';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
|
||||
import kbn from '../../core/utils/kbn';
|
||||
import { ExploreTimeControls } from './ExploreTimeControls';
|
||||
|
||||
enum IconSide {
|
||||
@ -71,6 +77,8 @@ interface StateProps {
|
||||
selectedModeOption: SelectableValue<ExploreMode>;
|
||||
hasLiveOption: boolean;
|
||||
isLive: boolean;
|
||||
originPanelId: number;
|
||||
queries: DataQuery[];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
@ -81,6 +89,8 @@ interface DispatchProps {
|
||||
split: typeof splitOpen;
|
||||
changeRefreshInterval: typeof changeRefreshInterval;
|
||||
changeMode: typeof changeMode;
|
||||
clearOrigin: typeof clearOrigin;
|
||||
updateLocation: typeof updateLocation;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
@ -112,6 +122,31 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
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() {
|
||||
const {
|
||||
datasourceMissing,
|
||||
@ -130,8 +165,15 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
selectedModeOption,
|
||||
hasLiveOption,
|
||||
isLive,
|
||||
originPanelId,
|
||||
} = 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 (
|
||||
<div className={splitted ? 'explore-toolbar splitted' : 'explore-toolbar'}>
|
||||
<div className="explore-toolbar-item">
|
||||
@ -187,6 +229,24 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="explore-toolbar-content-item">
|
||||
{createResponsiveButton({
|
||||
@ -285,6 +345,8 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
|
||||
supportedModes,
|
||||
mode,
|
||||
isLive,
|
||||
originPanelId,
|
||||
queries,
|
||||
} = exploreItem;
|
||||
const selectedDatasource = datasourceInstance
|
||||
? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
|
||||
@ -307,17 +369,21 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
|
||||
selectedModeOption,
|
||||
hasLiveOption,
|
||||
isLive,
|
||||
originPanelId,
|
||||
queries,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps: DispatchProps = {
|
||||
changeDatasource,
|
||||
updateLocation,
|
||||
changeRefreshInterval,
|
||||
clearAll: clearQueries,
|
||||
runQueries,
|
||||
closeSplit: splitClose,
|
||||
split: splitOpen,
|
||||
changeMode: changeMode,
|
||||
clearOrigin,
|
||||
};
|
||||
|
||||
export const ExploreToolbar = hot(module)(
|
||||
|
@ -16,7 +16,7 @@ interface WrapperProps {
|
||||
|
||||
export class Wrapper extends Component<WrapperProps> {
|
||||
componentWillUnmount() {
|
||||
this.props.resetExploreAction();
|
||||
this.props.resetExploreAction({});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -4,7 +4,7 @@ import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelDa
|
||||
|
||||
import { LogLevel, TimeRange, LoadingState, AbsoluteTimeRange } from '@grafana/data';
|
||||
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
|
||||
*
|
||||
@ -61,6 +61,14 @@ export interface ClearQueriesPayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
|
||||
export interface ClearOriginPayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
|
||||
export interface ClearRefreshIntervalPayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
|
||||
export interface HighlightLogsExpressionPayload {
|
||||
exploreId: ExploreId;
|
||||
expressions: string[];
|
||||
@ -74,6 +82,7 @@ export interface InitializeExplorePayload {
|
||||
range: TimeRange;
|
||||
mode: ExploreMode;
|
||||
ui: ExploreUIState;
|
||||
originPanelId: number;
|
||||
}
|
||||
|
||||
export interface LoadDatasourceMissingPayload {
|
||||
@ -202,6 +211,10 @@ export interface SetPausedStatePayload {
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export interface ResetExplorePayload {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* Clear origin panel id.
|
||||
*/
|
||||
export const clearOriginAction = actionCreatorFactory<ClearOriginPayload>('explore/CLEAR_ORIGIN').create();
|
||||
|
||||
/**
|
||||
* Highlight expressions in the log results
|
||||
*/
|
||||
@ -351,7 +369,7 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
|
||||
/**
|
||||
* 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 testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>(
|
||||
'explore/TEST_DATASOURCE_PENDING'
|
||||
|
@ -69,6 +69,7 @@ import {
|
||||
historyUpdatedAction,
|
||||
queryEndedAction,
|
||||
queryStreamUpdatedAction,
|
||||
clearOriginAction,
|
||||
} from './actionTypes';
|
||||
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
|
||||
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.
|
||||
* If there are no datasources a missing datasource action is dispatched.
|
||||
@ -250,7 +257,8 @@ export function initializeExplore(
|
||||
mode: ExploreMode,
|
||||
containerWidth: number,
|
||||
eventBridge: Emitter,
|
||||
ui: ExploreUIState
|
||||
ui: ExploreUIState,
|
||||
originPanelId: number
|
||||
): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const timeZone = getTimeZone(getState().user);
|
||||
@ -265,6 +273,7 @@ export function initializeExplore(
|
||||
range,
|
||||
mode,
|
||||
ui,
|
||||
originPanelId,
|
||||
})
|
||||
);
|
||||
dispatch(updateTime({ exploreId }));
|
||||
@ -722,7 +731,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
const query = queries[index];
|
||||
@ -734,7 +743,19 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
|
||||
// need to refresh datasource
|
||||
if (update.datasource) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,10 @@ import {
|
||||
scanStopAction,
|
||||
queryStartAction,
|
||||
changeRangeAction,
|
||||
clearOriginAction,
|
||||
} from './actionTypes';
|
||||
|
||||
import {
|
||||
addQueryRowAction,
|
||||
changeQueryAction,
|
||||
changeSizeAction,
|
||||
@ -235,6 +239,15 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: clearOriginAction,
|
||||
mapper: (state): ExploreItemState => {
|
||||
return {
|
||||
...state,
|
||||
originPanelId: undefined,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: highlightLogsExpressionAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
@ -245,7 +258,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
.addMapper({
|
||||
filter: initializeExploreAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { containerWidth, eventBridge, queries, range, mode, ui } = action.payload;
|
||||
const { containerWidth, eventBridge, queries, range, mode, ui, originPanelId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
containerWidth,
|
||||
@ -256,6 +269,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
initialized: true,
|
||||
queryKeys: getQueryKeys(queries, state.datasourceInstance),
|
||||
...ui,
|
||||
originPanelId,
|
||||
update: makeInitialUpdateState(),
|
||||
};
|
||||
},
|
||||
@ -265,6 +279,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { datasourceInstance } = action.payload;
|
||||
const [supportedModes, mode] = getModesForDatasource(datasourceInstance, state.mode);
|
||||
|
||||
const originPanelId = state.urlState && state.urlState.originPanelId;
|
||||
|
||||
// Custom components
|
||||
const StartPage = datasourceInstance.components.ExploreStartPage;
|
||||
stopQueryState(state.queryState, 'Datasource changed');
|
||||
@ -283,6 +300,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
queryKeys: [],
|
||||
supportedModes,
|
||||
mode,
|
||||
originPanelId,
|
||||
};
|
||||
},
|
||||
})
|
||||
@ -704,7 +722,18 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
|
||||
}
|
||||
|
||||
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: {
|
||||
|
@ -255,11 +255,18 @@ class MetricsPanelCtrl extends PanelCtrl {
|
||||
text: 'Explore',
|
||||
icon: 'gicon gicon-explore',
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
@ -25,10 +25,10 @@ import {
|
||||
DataQueryResponseData,
|
||||
DataStreamState,
|
||||
} from '@grafana/ui';
|
||||
import { ExploreUrlState } from 'app/types/explore';
|
||||
import { safeStringifyValue } from 'app/core/utils/explore';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { ExploreUrlState } from 'app/types';
|
||||
|
||||
export interface PromDataQueryResponse {
|
||||
data: {
|
||||
@ -603,15 +603,16 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
|
||||
getExploreState(queries: PromQuery[]): Partial<ExploreUrlState> {
|
||||
let state: Partial<ExploreUrlState> = { datasource: this.name };
|
||||
if (queries && queries.length > 0) {
|
||||
const expandedQueries = queries.map(query => ({
|
||||
...query,
|
||||
expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr),
|
||||
context: 'explore',
|
||||
const expandedQueries = queries.map(query => {
|
||||
const expandedQuery = {
|
||||
...query,
|
||||
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,
|
||||
queries: expandedQueries,
|
||||
|
@ -22,6 +22,7 @@ export interface DashboardMeta {
|
||||
url?: string;
|
||||
folderId?: number;
|
||||
fullscreen?: boolean;
|
||||
fromExplore?: boolean;
|
||||
isEditing?: boolean;
|
||||
canMakeEditable?: boolean;
|
||||
submenuEnabled?: boolean;
|
||||
|
@ -266,6 +266,7 @@ export interface ExploreItemState {
|
||||
queryState: PanelQueryState;
|
||||
|
||||
queryResponse: PanelData;
|
||||
originPanelId?: number;
|
||||
}
|
||||
|
||||
export interface ExploreUpdateState {
|
||||
@ -289,6 +290,7 @@ export interface ExploreUrlState {
|
||||
mode: ExploreMode;
|
||||
range: RawTimeRange;
|
||||
ui: ExploreUIState;
|
||||
originPanelId?: number;
|
||||
}
|
||||
|
||||
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
|
||||
|
Loading…
Reference in New Issue
Block a user