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="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>

View File

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

View File

@ -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}

View File

@ -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,
});
}

View File

@ -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));
}

View File

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

View File

@ -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 {

View File

@ -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();
}

View File

@ -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();

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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 }));
}

View File

@ -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,
};
}

View File

@ -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)(

View File

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

View File

@ -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'

View File

@ -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;
}

View File

@ -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: {

View File

@ -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 };

View File

@ -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,

View File

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

View File

@ -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> {