Correlations: Add an editor in Explore (#73315)

* Start adding correlations editor mode

* Add selector for merge conflict resolution

* Enable saving

* Build out new corelation helper component

* flesh out save with label/description, change color

* Have breadcrumb exit correlation editor mode

* Add extension property to show/hide, use it for correlations

* Bring in feature toggle

* Remove unnecessary param

* Cleanup

* Parse logs json

* Work on correlation edit mode bar

* Tinker with a top element for the editor mode

* Handle various explore state changes with correlations editor mode

* WIP - add unsaved changes modal

* Have correlation bar always rendered, sometimes hidden

* Add various prompt modals

* Clear correlations data on mode bar unmount, only use not left pane changes to count as dirty

* Move special logic to explore

* Remove all shouldShow logic from plugin extensions

* remove grafana data changes

* WIP - clean up correlations state

* Interpolate data before sending to onclick

* Override outline button coloring to account for dark background

* More cleanup, more color tweaking

* Prettier formatting, change state to refer to editor

* Fix tests

* More state change tweaks

* ensure correlation save ability, change correlation editor state vars

* fix import

* Remove independent selector for editorMode, work close pane into editor exit flow

* Add change datasource post action

* Clean up based on PR feedback, handle closing left panel with helper better

* Remove breadcrumb additions, add section and better ID to cmd palette action

* Interpolate query results if it is ran with a helper with vars

* Pass the datasource query along with the correlate link to ensure the datasource unique ID requirement passes

* Use different onmount function to capture state of panes at time of close instead of time of mount

* Fix node graph’s datalink not working

* Actually commit the fix to saving

* Fix saving correlations with mixed datasource to use the first query correlation UID

* Add tracking

* Use query datasources in mixed scenario, move exit tracking to click handler

* Add correlations to a place where both can be used in the correlations editor

* Be more selective on when we await the datasource get

* Fix CSS to use objects

* Update betterer

* Add test around new decorator functionality

* Add tests for decorate with correlations

* Some reorganization and a few tweaks based on feedback

* Move dirty state change to state function and out of component

* Change the verbiage around a little

* Various suggestions from Gio

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* More small Gio-related tweaks

* Tie helper data to datasource - clear it out when the datasource changes

* Missed another Gio tweak

* Fix linter error

* Only clear helper data on left pane changes

* Add height offset for correlation editor bar so it doesn’t scroll off page

---------

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
pull/75886/head
Kristina 2 years ago committed by GitHub
parent 6150d1370c
commit 4b3d63dcdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 6
      packages/grafana-data/src/types/dataLink.ts
  3. 10
      packages/grafana-data/src/types/explore.ts
  4. 11
      packages/grafana-data/src/utils/dataLinks.ts
  5. 3
      public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx
  6. 4
      public/app/core/utils/explore.ts
  7. 8
      public/app/features/correlations/utils.ts
  8. 251
      public/app/features/explore/CorrelationEditorModeBar.tsx
  9. 76
      public/app/features/explore/CorrelationHelper.tsx
  10. 35
      public/app/features/explore/CorrelationUnsavedChangesModal.tsx
  11. 2
      public/app/features/explore/Explore.test.tsx
  12. 17
      public/app/features/explore/Explore.tsx
  13. 28
      public/app/features/explore/ExploreActions.tsx
  14. 50
      public/app/features/explore/ExplorePage.tsx
  15. 108
      public/app/features/explore/ExploreToolbar.tsx
  16. 1
      public/app/features/explore/QueryRows.test.tsx
  17. 1
      public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx
  18. 30
      public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
  19. 9
      public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx
  20. 16
      public/app/features/explore/extensions/getExploreExtensionConfigs.tsx
  21. 101
      public/app/features/explore/state/correlations.ts
  22. 31
      public/app/features/explore/state/explorePane.ts
  23. 27
      public/app/features/explore/state/main.ts
  24. 79
      public/app/features/explore/state/query.ts
  25. 8
      public/app/features/explore/state/selectors.ts
  26. 132
      public/app/features/explore/utils/decorators.test.ts
  27. 49
      public/app/features/explore/utils/decorators.ts
  28. 4
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx
  29. 32
      public/app/types/explore.ts

@ -3799,9 +3799,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/explore/ExplorePage.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/ExplorePaneContainer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],

@ -1,4 +1,4 @@
import { ExplorePanelsState } from './explore';
import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore';
import { InterpolateFunction } from './panel';
import { DataQuery } from './query';
import { TimeRange } from './time';
@ -19,6 +19,7 @@ export interface DataLinkClickEvent<T = any> {
export enum DataLinkConfigOrigin {
Datasource = 'Datasource',
Correlations = 'Correlations',
ExploreCorrelationsEditor = 'CorrelationsEditor',
}
/**
@ -77,6 +78,9 @@ export interface InternalDataLink<T extends DataQuery = any> {
datasourceUid: string;
datasourceName: string; // used as a title if `DataLink.title` is empty
panelsState?: ExplorePanelsState;
meta?: {
correlationData?: ExploreCorrelationHelperData;
};
transformations?: DataLinkTransformationConfig[];
range?: TimeRange;
}

@ -31,6 +31,15 @@ export interface ExplorePanelsState extends Partial<Record<PreferredVisualisatio
logs?: ExploreLogsPanelState;
}
/**
* Keep a list of vars the correlations editor / helper in explore will use
*/
/** @internal */
export interface ExploreCorrelationHelperData {
resultField: string;
vars: Record<string, string>;
}
export interface ExploreTracePanelState {
spanId?: string;
}
@ -46,6 +55,7 @@ export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
queries?: T[];
range?: TimeRange;
panelsState?: ExplorePanelsState;
correlationHelperData?: ExploreCorrelationHelperData;
}
/**

@ -45,6 +45,11 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
const interpolatedQuery = interpolateObject(link.internal?.query, scopedVars, replaceVariables);
const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables);
const interpolatedCorrelationData = interpolateObject(
link.internal?.meta?.correlationData,
scopedVars,
replaceVariables
);
const title = link.title ? link.title : internalLink.datasourceName;
return {
@ -57,11 +62,15 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
// Explore data links can be displayed not only in DataLinkButton but it can be used by the consumer in
// other way, for example MenuItem. We want to provide the URL (for opening in the new tab as well as
// the onClick to open the split view).
event.preventDefault();
if (event.preventDefault) {
event.preventDefault();
}
onClickFn({
datasourceUid: internalLink.datasourceUid,
queries: [interpolatedQuery],
panelsState: interpolatedPanelsState,
correlationHelperData: interpolatedCorrelationData,
range,
});
}

@ -13,6 +13,7 @@ interface Props {
maxSize?: number;
primary?: 'first' | 'second';
onDragFinished?: (size?: number) => void;
parentStyle?: React.CSSProperties;
paneStyle?: React.CSSProperties;
secondaryPaneStyle?: React.CSSProperties;
}
@ -58,6 +59,7 @@ export class SplitPaneWrapper extends PureComponent<React.PropsWithChildren<Prop
maxSize,
minSize,
primary,
parentStyle,
paneStyle,
secondaryPaneStyle,
splitVisible = true,
@ -95,6 +97,7 @@ export class SplitPaneWrapper extends PureComponent<React.PropsWithChildren<Prop
resizerClassName={splitOrientation === 'horizontal' ? styles.resizerH : styles.resizerV}
onDragStarted={() => this.onDragStarted()}
onDragFinished={(size) => this.onDragFinished(size)}
style={parentStyle}
paneStyle={paneStyle}
pane2Style={secondaryPaneStyle}
>

@ -99,7 +99,8 @@ export function buildQueryTransaction(
queryOptions: QueryOptions,
range: TimeRange,
scanning: boolean,
timeZone?: TimeZone
timeZone?: TimeZone,
scopedVars?: ScopedVars
): QueryTransaction {
const key = queries.reduce((combinedKey, query) => {
combinedKey += query.key;
@ -131,6 +132,7 @@ export function buildQueryTransaction(
scopedVars: {
__interval: { text: interval, value: interval },
__interval_ms: { text: intervalMs, value: intervalMs },
...scopedVars,
},
maxDataPoints: queryOptions.maxDataPoints,
liveStreaming: queryOptions.liveStreaming,

@ -5,6 +5,7 @@ import { getBackendSrv } from '@grafana/runtime';
import { formatValueName } from '../explore/PrometheusListView/ItemLabels';
import { CreateCorrelationParams, CreateCorrelationResponse } from './types';
import {
CorrelationData,
CorrelationsData,
@ -82,3 +83,10 @@ export const getCorrelationsBySourceUIDs = async (sourceUIDs: string[]): Promise
.then(getData)
.then(toEnrichedCorrelationsData);
};
export const createCorrelation = async (
sourceUID: string,
correlation: CreateCorrelationParams
): Promise<CreateCorrelationResponse> => {
return getBackendSrv().post<CreateCorrelationResponse>(`/api/datasources/uid/${sourceUID}/correlations`, correlation);
};

@ -0,0 +1,251 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { Prompt } from 'react-router-dom';
import { useBeforeUnload, useUnmount } from 'react-use';
import { GrafanaTheme2, colorManipulator } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState, useDispatch, useSelector } from 'app/types';
import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal';
import { saveCurrentCorrelation } from './state/correlations';
import { changeDatasource } from './state/datasource';
import { changeCorrelationHelperData } from './state/explorePane';
import { changeCorrelationEditorDetails, splitClose } from './state/main';
import { runQueries } from './state/query';
import { selectCorrelationDetails } from './state/selectors';
export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const correlationDetails = useSelector(selectCorrelationDetails);
const [showSavePrompt, setShowSavePrompt] = useState(false);
// handle refreshing and closing the tab
useBeforeUnload(correlationDetails?.dirty || false, 'Save correlation?');
// handle exiting (staying within explore)
useEffect(() => {
if (correlationDetails?.isExiting && correlationDetails?.dirty) {
setShowSavePrompt(true);
} else if (correlationDetails?.isExiting && !correlationDetails?.dirty) {
dispatch(
changeCorrelationEditorDetails({
editorMode: false,
dirty: false,
isExiting: false,
})
);
}
}, [correlationDetails?.dirty, correlationDetails?.isExiting, dispatch]);
// clear data when unmounted
useUnmount(() => {
dispatch(
changeCorrelationEditorDetails({
editorMode: false,
isExiting: false,
dirty: false,
label: undefined,
description: undefined,
canSave: false,
})
);
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
dispatch(runQueries({ exploreId: pane[0] }));
});
});
const closePaneAndReset = (exploreId: string) => {
setShowSavePrompt(false);
dispatch(splitClose(exploreId));
reportInteraction('grafana_explore_split_view_closed');
dispatch(
changeCorrelationEditorDetails({
editorMode: true,
isExiting: false,
dirty: false,
label: undefined,
description: undefined,
canSave: false,
})
);
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
dispatch(runQueries({ exploreId: pane[0] }));
});
};
const changeDatasourceAndReset = (exploreId: string, datasourceUid: string) => {
setShowSavePrompt(false);
dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true }));
dispatch(
changeCorrelationEditorDetails({
editorMode: true,
isExiting: false,
dirty: false,
label: undefined,
description: undefined,
canSave: false,
})
);
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
});
};
const saveCorrelation = (skipPostConfirmAction: boolean) => {
dispatch(saveCurrentCorrelation(correlationDetails?.label, correlationDetails?.description));
if (!skipPostConfirmAction && correlationDetails?.postConfirmAction !== undefined) {
const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction;
if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) {
closePaneAndReset(exploreId);
} else if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE &&
changeDatasourceUid !== undefined
) {
changeDatasourceAndReset(exploreId, changeDatasourceUid);
}
} else {
dispatch(changeCorrelationEditorDetails({ editorMode: false, dirty: false, isExiting: false }));
}
};
return (
<>
{/* Handle navigating outside of Explore */}
<Prompt
message={(location) => {
if (
location.pathname !== '/explore' &&
(correlationDetails?.editorMode || false) &&
(correlationDetails?.dirty || false)
) {
return 'You have unsaved correlation data. Continue?';
} else {
return true;
}
}}
/>
{showSavePrompt && (
<CorrelationUnsavedChangesModal
onDiscard={() => {
if (correlationDetails?.postConfirmAction !== undefined) {
const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction;
if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) {
closePaneAndReset(exploreId);
} else if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE &&
changeDatasourceUid !== undefined
) {
changeDatasourceAndReset(exploreId, changeDatasourceUid);
}
} else {
// exit correlations mode
// if we are discarding the in progress correlation, reset everything
// this modal only shows if the editorMode is false, so we just need to update the dirty state
dispatch(
changeCorrelationEditorDetails({
editorMode: false,
dirty: false,
isExiting: false,
})
);
}
}}
onCancel={() => {
// if we are cancelling the exit, set the editor mode back to true and hide the prompt
dispatch(changeCorrelationEditorDetails({ isExiting: false }));
setShowSavePrompt(false);
}}
onSave={() => {
saveCorrelation(false);
}}
/>
)}
<div className={styles.correlationEditorTop}>
<HorizontalGroup spacing="md" justify="flex-end">
<Tooltip content="Correlations editor in Explore is an experimental feature.">
<Icon className={styles.iconColor} name="info-circle" size="xl" />
</Tooltip>
<Button
variant="secondary"
disabled={!correlationDetails?.canSave}
fill="outline"
className={correlationDetails?.canSave ? styles.buttonColor : styles.disabledButtonColor}
onClick={() => {
saveCorrelation(true);
}}
>
Save
</Button>
<Button
variant="secondary"
fill="outline"
className={styles.buttonColor}
icon="times"
onClick={() => {
dispatch(changeCorrelationEditorDetails({ isExiting: true }));
reportInteraction('grafana_explore_correlation_editor_exit_pressed');
}}
>
Exit correlation editor
</Button>
</HorizontalGroup>
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => {
const contrastColor = theme.colors.getContrastText(theme.colors.primary.main);
const lighterBackgroundColor = colorManipulator.lighten(theme.colors.primary.main, 0.1);
const darkerBackgroundColor = colorManipulator.darken(theme.colors.primary.main, 0.2);
const disabledColor = colorManipulator.darken(contrastColor, 0.2);
return {
correlationEditorTop: css({
backgroundColor: theme.colors.primary.main,
marginTop: '3px',
padding: theme.spacing(1),
}),
iconColor: css({
color: contrastColor,
}),
buttonColor: css({
color: contrastColor,
borderColor: contrastColor,
'&:hover': {
color: contrastColor,
borderColor: contrastColor,
backgroundColor: lighterBackgroundColor,
},
}),
// important needed to override disabled state styling
disabledButtonColor: css({
color: `${disabledColor} !important`,
backgroundColor: `${darkerBackgroundColor} !important`,
}),
};
};

@ -0,0 +1,76 @@
import React, { useState, useEffect, useId } from 'react';
import { useForm } from 'react-hook-form';
import { ExploreCorrelationHelperData } from '@grafana/data';
import { Collapse, Alert, Field, Input } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types';
import { changeCorrelationEditorDetails } from './state/main';
import { selectCorrelationDetails } from './state/selectors';
interface Props {
correlations: ExploreCorrelationHelperData;
}
interface FormValues {
label: string;
description: string;
}
export const CorrelationHelper = ({ correlations }: Props) => {
const dispatch = useDispatch();
const { register, watch } = useForm<FormValues>();
const [isOpen, setIsOpen] = useState(false);
const correlationDetails = useSelector(selectCorrelationDetails);
const id = useId();
useEffect(() => {
const subscription = watch((value) => {
let dirty = false;
if (!correlationDetails?.dirty && (value.label !== '' || value.description !== '')) {
dirty = true;
} else if (correlationDetails?.dirty && value.label.trim() === '' && value.description.trim() === '') {
dirty = false;
}
dispatch(changeCorrelationEditorDetails({ label: value.label, description: value.description, dirty: dirty }));
});
return () => subscription.unsubscribe();
}, [correlationDetails?.dirty, dispatch, watch]);
// only fire once on mount to allow save button to enable / disable when unmounted
useEffect(() => {
dispatch(changeCorrelationEditorDetails({ canSave: true }));
return () => {
dispatch(changeCorrelationEditorDetails({ canSave: false }));
};
}, [dispatch]);
return (
<Alert title="Correlation details" severity="info">
The correlation link will appear by the <code>{correlations.resultField}</code> field. You can use the following
variables to set up your correlations:
<pre>
{Object.entries(correlations.vars).map((entry) => {
return `\$\{${entry[0]}\} = ${entry[1]}\n`;
})}
</pre>
<Collapse
collapsible
isOpen={isOpen}
onToggle={() => {
setIsOpen(!isOpen);
}}
label="Label/Description"
>
<Field label="Label" htmlFor={`${id}-label`}>
<Input {...register('label')} id={`${id}-label`} />
</Field>
<Field label="Description" htmlFor={`${id}-description`}>
<Input {...register('description')} id={`${id}-description`} />
</Field>
</Collapse>
</Alert>
);
};

@ -0,0 +1,35 @@
import { css } from '@emotion/css';
import React from 'react';
import { Button, Modal } from '@grafana/ui';
interface UnsavedChangesModalProps {
onDiscard: () => void;
onCancel: () => void;
onSave: () => void;
}
export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel }: UnsavedChangesModalProps) => {
return (
<Modal
isOpen={true}
title="Unsaved changes to correlation"
onDismiss={onCancel}
icon="exclamation-triangle"
className={css({ width: '500px' })}
>
<h5>Do you want to save changes to this Correlation?</h5>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button variant="destructive" onClick={onDiscard}>
Discard correlation
</Button>
<Button variant="primary" onClick={onSave}>
Save correlation
</Button>
</Modal.ButtonRow>
</Modal>
);
};

@ -95,6 +95,8 @@ const dummyProps: Props = {
showLogsSample: false,
logsSample: { enabled: false },
setSupplementaryQueryEnabled: jest.fn(),
correlationEditorDetails: undefined,
correlationEditorHelperData: undefined,
};
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {

@ -35,6 +35,7 @@ import { StoreState } from 'app/types';
import { getTimeZone } from '../profile/state/selectors';
import { CorrelationHelper } from './CorrelationHelper';
import { CustomContainer } from './CustomContainer';
import ExploreQueryInspector from './ExploreQueryInspector';
import { ExploreToolbar } from './ExploreToolbar';
@ -477,6 +478,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
showFlameGraph,
timeZone,
showLogsSample,
correlationEditorDetails,
correlationEditorHelperData,
} = this.props;
const { openDrawer } = this.state;
const styles = getStyles(theme);
@ -497,6 +500,13 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
queryResponse.customFrames,
].every((e) => e.length === 0);
let correlationsBox = undefined;
const isCorrelationsEditorMode = correlationEditorDetails?.editorMode;
const showCorrelationHelper = Boolean(isCorrelationsEditorMode || correlationEditorDetails?.dirty);
if (showCorrelationHelper && correlationEditorHelperData !== undefined) {
correlationsBox = <CorrelationHelper correlations={correlationEditorHelperData} />;
}
return (
<>
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} topOfViewRef={this.topOfViewRef} />
@ -508,9 +518,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
{datasourceInstance ? (
<div className={styles.exploreContainer}>
<PanelContainer className={styles.queryContainer}>
{correlationsBox}
<QueryRows exploreId={exploreId} />
<SecondaryActions
addQueryRowButtonDisabled={isLive}
// do not allow people to add queries with potentially different datasources in correlations editor mode
addQueryRowButtonDisabled={isLive || (isCorrelationsEditorMode && datasourceInstance.meta.mixed)}
// We cannot show multiple traces at the same time right now so we do not show add query button.
//TODO:unification
addQueryRowButtonHidden={false}
@ -605,6 +617,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showFlameGraph,
showRawPrometheus,
supplementaryQueries,
correlationEditorHelperData,
} = item;
const loading = selectIsWaitingForData(exploreId)(state);
@ -635,6 +648,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
loading,
logsSample,
showLogsSample,
correlationEditorHelperData,
correlationEditorDetails: explore.correlationEditorDetails,
};
}

@ -1,9 +1,12 @@
import { useRegisterActions, useKBar, Action, Priority } from 'kbar';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'app/types';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { AccessControlAction, useDispatch, useSelector } from 'app/types';
import { splitOpen, splitClose } from './state/main';
import { splitOpen, splitClose, changeCorrelationEditorDetails } from './state/main';
import { runQueries } from './state/query';
import { isSplit, selectPanes } from './state/selectors';
@ -15,6 +18,8 @@ export const ExploreActions = () => {
const panes = useSelector(selectPanes);
const splitted = useSelector(isSplit);
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
useEffect(() => {
const keys = Object.keys(panes);
const exploreSection = {
@ -65,6 +70,23 @@ export const ExploreActions = () => {
});
}
} else {
// command palette doesn't know what pane we're in, only show option if not split and no datasource is mixed
const hasMixed = Object.values(panes).some((pane) => {
return pane?.datasourceInstance?.uid === MIXED_DATASOURCE_NAME;
});
if (config.featureToggles.correlations && canWriteCorrelations && !hasMixed) {
actionsArr.push({
id: 'explore/correlations-editor',
name: 'Correlations editor',
perform: () => {
dispatch(changeCorrelationEditorDetails({ editorMode: true }));
dispatch(runQueries({ exploreId: keys[0] }));
},
section: exploreSection,
});
}
actionsArr.push({
id: 'explore/run-query',
name: 'Run query',
@ -85,7 +107,7 @@ export const ExploreActions = () => {
});
}
setActions(actionsArr);
}, [panes, splitted, query, dispatch]);
}, [panes, splitted, query, dispatch, canWriteCorrelations]);
useRegisterActions(!query ? [] : actions, [actions, query]);

@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { useEffect } from 'react';
import { ErrorBoundaryAlert } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ErrorBoundaryAlert, useStyles2, useTheme2 } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useNavModel } from 'app/core/hooks/useNavModel';
@ -9,6 +11,7 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useSelector } from 'app/types';
import { ExploreQueryParams } from 'app/types/explore';
import { CorrelationEditorModeBar } from './CorrelationEditorModeBar';
import { ExploreActions } from './ExploreActions';
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { useExplorePageTitle } from './hooks/useExplorePageTitle';
@ -16,21 +19,13 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useSplitSizeUpdater } from './hooks/useSplitSizeUpdater';
import { useStateSync } from './hooks/useStateSync';
import { useTimeSrvFix } from './hooks/useTimeSrvFix';
import { isSplit, selectPanesEntries } from './state/selectors';
import { isSplit, selectCorrelationDetails, selectPanesEntries } from './state/selectors';
const MIN_PANE_WIDTH = 200;
const styles = {
pageScrollbarWrapper: css`
width: 100%;
flex-grow: 1;
min-height: 0;
height: 100%;
position: relative;
`,
};
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
useTimeSrvFix();
useStateSync(props.queryParams);
// We want to set the title according to the URL and not to the state because the URL itself may lag
@ -45,6 +40,8 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
const panes = useSelector(selectPanesEntries);
const hasSplit = useSelector(isSplit);
const correlationDetails = useSelector(selectCorrelationDetails);
const showCorrelationEditorBar = config.featureToggles.correlations && (correlationDetails?.editorMode || false);
useEffect(() => {
//This is needed for breadcrumbs and topnav.
@ -55,9 +52,13 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
useKeyboardShortcuts();
return (
<div className={styles.pageScrollbarWrapper}>
<div
className={cx(styles.pageScrollbarWrapper, {
[styles.correlationsEditorIndicator]: showCorrelationEditorBar,
})}
>
<ExploreActions />
{showCorrelationEditorBar && <CorrelationEditorModeBar panes={panes} />}
<SplitPaneWrapper
splitOrientation="vertical"
paneSize={widthCalc}
@ -65,6 +66,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
maxSize={MIN_PANE_WIDTH * -1}
primary="second"
splitVisible={hasSplit}
parentStyle={showCorrelationEditorBar ? { height: `calc(100% - ${theme.spacing(6)}` } : {}} // button = 4, padding = 1 x 2
paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column' }}
onDragFinished={(size) => size && updateSplitSize(size)}
>
@ -79,3 +81,21 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
pageScrollbarWrapper: css({
width: '100%',
flexGrow: 1,
minHeight: 0,
height: '100%',
position: 'relative',
}),
correlationsEditorIndicator: css({
borderLeft: `4px solid ${theme.colors.primary.main}`,
borderRight: `4px solid ${theme.colors.primary.main}`,
borderBottom: `4px solid ${theme.colors.primary.main}`,
overflow: 'scroll',
}),
};
};

@ -18,6 +18,7 @@ import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { t, Trans } from 'app/core/internationalization';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { CORRELATION_EDITOR_POST_CONFIRM_ACTION } from 'app/types/explore';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { contextSrv } from '../../core/core';
@ -29,9 +30,16 @@ import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton';
import { ToolbarExtensionPoint } from './extensions/ToolbarExtensionPoint';
import { changeDatasource } from './state/datasource';
import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main';
import { changeCorrelationHelperData } from './state/explorePane';
import {
splitClose,
splitOpen,
maximizePaneAction,
evenPaneResizeAction,
changeCorrelationEditorDetails,
} from './state/main';
import { cancelQueries, runQueries, selectIsWaitingForData } from './state/query';
import { isSplit, selectPanesEntries } from './state/selectors';
import { isLeftPaneSelector, isSplit, selectCorrelationDetails, selectPanesEntries } from './state/selectors';
import { syncTimes, changeRefreshInterval } from './state/time';
import { LiveTailControls } from './useLiveTailControls';
@ -71,10 +79,13 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
);
const panes = useSelector(selectPanesEntries);
const correlationDetails = useSelector(selectCorrelationDetails);
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const shouldRotateSplitIcon = useMemo(
() => (exploreId === panes[0][0] && isLargerPane) || (exploreId === panes[1]?.[0] && !isLargerPane),
[isLargerPane, exploreId, panes]
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
[isLeftPane, isLargerPane]
);
const refreshPickerLabel = loading
@ -87,7 +98,37 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
};
const onChangeDatasource = async (dsSettings: DataSourceInstanceSettings) => {
dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true }));
if (!isCorrelationsEditorMode) {
dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true }));
} else {
if (correlationDetails?.dirty) {
// prompt will handle datasource change if needed
dispatch(
changeCorrelationEditorDetails({
isExiting: true,
postConfirmAction: {
exploreId: exploreId,
action: CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE,
changeDatasourceUid: dsSettings.uid,
},
})
);
} else {
// if the left pane is changing, clear helper data for right pane
if (isLeftPane) {
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
});
}
dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true }));
}
}
};
const onRunQuery = (loading = false) => {
@ -106,8 +147,35 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
};
const onCloseSplitView = () => {
dispatch(splitClose(exploreId));
reportInteraction('grafana_explore_split_view_closed');
if (isCorrelationsEditorMode) {
if (correlationDetails?.dirty) {
// if dirty, prompt
dispatch(
changeCorrelationEditorDetails({
isExiting: true,
postConfirmAction: {
exploreId: exploreId,
action: CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE,
},
})
);
} else {
// otherwise, clear helper data and close
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
});
dispatch(splitClose(exploreId));
reportInteraction('grafana_explore_split_view_closed');
}
} else {
dispatch(splitClose(exploreId));
reportInteraction('grafana_explore_split_view_closed');
}
};
const onClickResize = () => {
@ -129,29 +197,29 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
dispatch(changeRefreshInterval({ exploreId, refreshInterval }));
};
const navBarActions = [
<DashNavButton
key="share"
tooltip={t('explore.toolbar.copy-shortened-link', 'Copy shortened link')}
icon="share-alt"
onClick={onCopyShortLink}
aria-label={t('explore.toolbar.copy-shortened-link', 'Copy shortened link')}
/>,
<div style={{ flex: 1 }} key="spacer0" />,
];
return (
<div ref={topOfViewRef}>
{refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
<div ref={topOfViewRef}>
<AppChromeUpdate
actions={[
<DashNavButton
key="share"
tooltip={t('explore.toolbar.copy-shortened-link', 'Copy shortened link')}
icon="share-alt"
onClick={onCopyShortLink}
aria-label={t('explore.toolbar.copy-shortened-link', 'Copy shortened link')}
/>,
<div style={{ flex: 1 }} key="spacer" />,
]}
/>
<AppChromeUpdate actions={navBarActions} />
</div>
<PageToolbar
aria-label={t('explore.toolbar.aria-label', 'Explore toolbar')}
leftItems={[
<DataSourcePicker
key={`${exploreId}-ds-picker`}
mixed
mixed={!isCorrelationsEditorMode}
onChange={onChangeDatasource}
current={datasourceInstance?.getRef()}
hideTextValue={showSmallDataSourcePicker}

@ -59,6 +59,7 @@ function setup(queries: DataQuery[]) {
correlations: [],
},
},
correlationEditorDetails: { editorMode: false, dirty: false, isExiting: false },
syncedTimes: false,
richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false,

@ -147,6 +147,7 @@ describe('ToolbarExtensionPoint', () => {
}),
timeZone: 'browser',
timeRange: { from: 'now-1h', to: 'now' },
shouldShowAddCorrelation: false,
});
});

@ -1,13 +1,13 @@
import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { getPluginLinkExtensions, config } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ExplorePanelData, useSelector } from 'app/types';
import { getExploreItemSelector } from '../state/selectors';
import { getExploreItemSelector, isLeftPaneSelector, selectCorrelationDetails } from '../state/selectors';
import { ConfirmNavigationModal } from './ConfirmNavigationModal';
import { ToolbarExtensionPointMenu } from './ToolbarExtensionPointMenu';
@ -81,11 +81,19 @@ export type PluginExtensionExploreContext = {
data: ExplorePanelData;
timeRange: RawTimeRange;
timeZone: TimeZone;
shouldShowAddCorrelation: boolean;
};
function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
const { exploreId, timeZone } = props;
const isCorrelationDetails = useSelector(selectCorrelationDetails);
const isCorrelationsEditorMode = isCorrelationDetails?.editorMode || false;
const { queries, queryResponse, range } = useSelector(getExploreItemSelector(exploreId))!;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const datasourceUids = queries.map((query) => query?.datasource?.uid).filter((uid) => uid !== undefined);
const numUniqueIds = [...new Set(datasourceUids)].length;
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
return useMemo(() => {
return {
@ -94,8 +102,24 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
data: queryResponse,
timeRange: range.raw,
timeZone: timeZone,
shouldShowAddCorrelation:
config.featureToggles.correlations === true &&
canWriteCorrelations &&
!isCorrelationsEditorMode &&
isLeftPane &&
numUniqueIds === 1,
};
}, [exploreId, queries, queryResponse, range, timeZone]);
}, [
exploreId,
queries,
queryResponse,
range.raw,
timeZone,
canWriteCorrelations,
isCorrelationsEditorMode,
isLeftPane,
numUniqueIds,
]);
}
function useExtensionLinks(context: PluginExtensionExploreContext): PluginExtensionLink[] {

@ -23,6 +23,15 @@ describe('getExploreExtensionConfigs', () => {
onClick: expect.any(Function),
category: 'Dashboards',
},
{
type: 'link',
title: 'Add correlation',
description: 'Create a correlation from this query',
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
icon: 'link',
configure: expect.any(Function),
onClick: expect.any(Function),
},
]);
});
});

@ -2,9 +2,12 @@ import React from 'react';
import { PluginExtensionPoints, type PluginExtensionLinkConfig } from '@grafana/data';
import { contextSrv } from 'app/core/core';
import { dispatch } from 'app/store/store';
import { AccessControlAction } from 'app/types';
import { createExtensionLinkConfig, logWarning } from '../../plugins/extensions/utils';
import { changeCorrelationEditorDetails } from '../state/main';
import { runQueries } from '../state/query';
import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm';
import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle';
@ -38,6 +41,19 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] {
});
},
}),
createExtensionLinkConfig<PluginExtensionExploreContext>({
title: 'Add correlation',
description: 'Create a correlation from this query',
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
icon: 'link',
configure: (context) => {
return context?.shouldShowAddCorrelation ? {} : undefined;
},
onClick: (_, { context }) => {
dispatch(changeCorrelationEditorDetails({ editorMode: true }));
dispatch(runQueries({ exploreId: context!.exploreId }));
},
}),
];
} catch (error) {
logWarning(`Could not configure extensions for Explore due to: "${error}"`);

@ -0,0 +1,101 @@
import { Observable } from 'rxjs';
import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { CreateCorrelationParams } from 'app/features/correlations/types';
import { CorrelationData } from 'app/features/correlations/useCorrelations';
import { getCorrelationsBySourceUIDs, createCorrelation } from 'app/features/correlations/utils';
import { store } from 'app/store/store';
import { ThunkResult } from 'app/types';
import { saveCorrelationsAction } from './explorePane';
import { splitClose } from './main';
import { runQueries } from './query';
/**
* Creates an observable that emits correlations once they are loaded
*/
export const getCorrelations = (exploreId: string) => {
return new Observable<CorrelationData[]>((subscriber) => {
const existingCorrelations = store.getState().explore.panes[exploreId]?.correlations;
if (existingCorrelations) {
subscriber.next(existingCorrelations);
subscriber.complete();
} else {
const unsubscribe = store.subscribe(() => {
const correlations = store.getState().explore.panes[exploreId]?.correlations;
if (correlations) {
unsubscribe();
subscriber.next(correlations);
subscriber.complete();
}
});
}
});
};
function reloadCorrelations(exploreId: string): ThunkResult<Promise<void>> {
return async (dispatch, getState) => {
const pane = getState().explore!.panes[exploreId]!;
if (pane.datasourceInstance?.uid !== undefined) {
// TODO: Tie correlations with query refID for mixed datasource
let datasourceUIDs = pane.datasourceInstance.meta.mixed
? pane.queries.map((query) => query.datasource?.uid).filter((x): x is string => x !== null)
: [pane.datasourceInstance.uid];
const correlations = await getCorrelationsBySourceUIDs(datasourceUIDs);
dispatch(saveCorrelationsAction({ exploreId, correlations: correlations.correlations || [] }));
}
};
}
export function saveCurrentCorrelation(label?: string, description?: string): ThunkResult<Promise<void>> {
return async (dispatch, getState) => {
const keys = Object.keys(getState().explore?.panes);
const sourcePane = getState().explore?.panes[keys[0]];
const targetPane = getState().explore?.panes[keys[1]];
if (!sourcePane || !targetPane) {
return;
}
const sourceDatasourceRef = sourcePane.datasourceInstance?.meta.mixed
? sourcePane.queries[0].datasource
: sourcePane.datasourceInstance?.getRef();
const targetDataSourceRef = targetPane.datasourceInstance?.meta.mixed
? targetPane.queries[0].datasource
: targetPane.datasourceInstance?.getRef();
const [sourceDatasource, targetDatasource] = await Promise.all([
getDataSourceSrv().get(sourceDatasourceRef),
getDataSourceSrv().get(targetDataSourceRef),
]);
if (sourceDatasource?.uid && targetDatasource?.uid && targetPane.correlationEditorHelperData?.resultField) {
const correlation: CreateCorrelationParams = {
sourceUID: sourceDatasource.uid,
targetUID: targetDatasource.uid,
label: label || `${sourceDatasource?.name} to ${targetDatasource.name}`,
description,
config: {
field: targetPane.correlationEditorHelperData.resultField,
target: targetPane.queries[0],
type: 'query',
},
};
await createCorrelation(sourceDatasource.uid, correlation)
.then(async () => {
dispatch(splitClose(keys[1]));
await dispatch(reloadCorrelations(keys[0]));
await dispatch(runQueries({ exploreId: keys[0] }));
reportInteraction('grafana_explore_correlation_editor_saved', {
sourceDatasourceType: sourceDatasource.type,
targetDataSourceType: targetDatasource.type,
});
})
.catch((err) => {
dispatch(notifyApp(createErrorNotification('Error creating correlation', err)));
console.error(err);
});
}
};
}

@ -8,6 +8,7 @@ import {
ExplorePanelsState,
PreferredVisualisationType,
RawTimeRange,
ExploreCorrelationHelperData,
} from '@grafana/data';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { getQueryKeys } from 'app/core/utils/explore';
@ -76,6 +77,17 @@ export function changePanelState(
};
}
/**
* Tracks the state of correlation helper data in the panel
*/
interface ChangeCorrelationHelperData {
exploreId: string;
correlationEditorHelperData?: ExploreCorrelationHelperData;
}
export const changeCorrelationHelperData = createAction<ChangeCorrelationHelperData>(
'explore/changeCorrelationHelperData'
);
/**
* Initialize Explore state with state from the URL and the React component.
* Call this only on components for with the Explore state has not been initialized.
@ -114,6 +126,7 @@ export interface InitializeExploreOptions {
queries: DataQuery[];
range: RawTimeRange;
panelsState?: ExplorePanelsState;
correlationHelperData?: ExploreCorrelationHelperData;
position?: number;
}
/**
@ -127,7 +140,7 @@ export interface InitializeExploreOptions {
export const initializeExplore = createAsyncThunk(
'explore/initializeExplore',
async (
{ exploreId, datasource, queries, range, panelsState }: InitializeExploreOptions,
{ exploreId, datasource, queries, range, panelsState, correlationHelperData }: InitializeExploreOptions,
{ dispatch, getState, fulfillWithValue }
) => {
let instance = undefined;
@ -152,6 +165,7 @@ export const initializeExplore = createAsyncThunk(
if (panelsState !== undefined) {
dispatch(changePanelsStateAction({ exploreId, panelsState }));
}
dispatch(updateTime({ exploreId }));
if (instance) {
@ -162,6 +176,16 @@ export const initializeExplore = createAsyncThunk(
dispatch(runQueries({ exploreId }));
}
// initialize new pane with helper data
if (correlationHelperData !== undefined && getState().explore.correlationEditorDetails?.editorMode) {
dispatch(
changeCorrelationHelperData({
exploreId,
correlationEditorHelperData: correlationHelperData,
})
);
}
return fulfillWithValue({ exploreId, state: getState().explore.panes[exploreId]! });
}
);
@ -207,6 +231,11 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
return { ...state, panelsState };
}
if (changeCorrelationHelperData.match(action)) {
const { correlationEditorHelperData } = action.payload;
return { ...state, correlationEditorHelperData };
}
if (saveCorrelationsAction.match(action)) {
return {
...state,

@ -5,7 +5,7 @@ import { SplitOpenOptions, TimeRange } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { generateExploreId, GetExploreUrlArguments } from 'app/core/utils/explore';
import { PanelModel } from 'app/features/dashboard/state';
import { ExploreItemState, ExploreState } from 'app/types/explore';
import { CorrelationEditorDetailsUpdate, ExploreItemState, ExploreState } from 'app/types/explore';
import { RichHistoryResults } from '../../../core/history/RichHistoryStorage';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
@ -83,6 +83,7 @@ export const splitOpen = createAsyncThunk(
queries: withUniqueRefIds(queries),
range: options?.range || originState?.range.raw || DEFAULT_RANGE,
panelsState: options?.panelsState || originState?.panelsState,
correlationHelperData: options?.correlationHelperData,
})
);
},
@ -104,6 +105,13 @@ const createNewSplitOpenPane = createAsyncThunk(
}
);
/**
* Moves explore into and out of correlations editor mode
*/
export const changeCorrelationEditorDetails = createAction<CorrelationEditorDetailsUpdate>(
'explore/changeCorrelationEditorDetails'
);
export interface NavigateToExploreDependencies {
timeRange: TimeRange;
getExploreUrl: (args: GetExploreUrlArguments) => Promise<string | undefined>;
@ -140,6 +148,7 @@ const initialExploreItemState = makeExplorePaneState();
export const initialExploreState: ExploreState = {
syncedTimes: false,
panes: {},
correlationEditorDetails: { editorMode: false, dirty: false, isExiting: false },
richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false,
largerExploreId: undefined,
@ -252,6 +261,22 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
};
}
if (changeCorrelationEditorDetails.match(action)) {
const { editorMode, label, description, canSave, dirty, isExiting, postConfirmAction } = action.payload;
return {
...state,
correlationEditorDetails: {
editorMode: Boolean(editorMode ?? state.correlationEditorDetails?.editorMode),
canSave: Boolean(canSave ?? state.correlationEditorDetails?.canSave),
label: label ?? state.correlationEditorDetails?.label,
description: description ?? state.correlationEditorDetails?.description,
dirty: Boolean(dirty ?? state.correlationEditorDetails?.dirty),
isExiting: Boolean(isExiting ?? state.correlationEditorDetails?.isExiting),
postConfirmAction,
},
};
}
const exploreId: string | undefined = action.payload?.exploreId;
if (typeof exploreId === 'string') {
return {

@ -34,11 +34,9 @@ import {
updateHistory,
} from 'app/core/utils/explore';
import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { CorrelationData } from 'app/features/correlations/useCorrelations';
import { getCorrelationsBySourceUIDs } from 'app/features/correlations/utils';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { store } from 'app/store/store';
import {
createAsyncThunk,
ExploreItemState,
@ -60,8 +58,10 @@ import {
supplementaryQueryTypes,
} from '../utils/supplementaryQueries';
import { getCorrelations } from './correlations';
import { saveCorrelationsAction } from './explorePane';
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history';
import { changeCorrelationEditorDetails } from './main';
import { updateTime } from './time';
import { createCacheKey, filterLogRowsByIndex, getDatasourceUIDs, getResultsFromCache } from './utils';
@ -320,6 +320,13 @@ export const changeQueries = createAsyncThunk<void, ChangeQueriesPayload>(
let queriesImported = false;
const oldQueries = getState().explore.panes[exploreId]!.queries;
const rootUID = getState().explore.panes[exploreId]!.datasourceInstance?.uid;
const correlationDetails = getState().explore.correlationEditorDetails;
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = Object.keys(getState().explore.panes)[0] === exploreId;
if (!isLeftPane && isCorrelationsEditorMode && !correlationDetails?.dirty) {
dispatch(changeCorrelationEditorDetails({ dirty: true }));
}
for (const newQuery of queries) {
for (const oldQuery of oldQueries) {
@ -500,6 +507,7 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
}
const exploreItemState = getState().explore.panes[exploreId]!;
const {
datasourceInstance,
containerWidth,
@ -512,7 +520,14 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
absoluteRange,
cache,
supplementaryQueries,
correlationEditorHelperData,
} = exploreItemState;
const isCorrelationEditorMode = getState().explore.correlationEditorDetails?.editorMode || false;
const isLeftPane = Object.keys(getState().explore.panes)[0] === exploreId;
const showCorrelationEditorLinks = isCorrelationEditorMode && isLeftPane;
const defaultCorrelationEditorDatasource = showCorrelationEditorLinks ? await getDataSourceSrv().get() : undefined;
const interpolateCorrelationHelperVars =
isCorrelationEditorMode && !isLeftPane && correlationEditorHelperData !== undefined;
let newQuerySource: Observable<ExplorePanelData>;
let newQuerySubscription: SubscriptionLike;
@ -531,7 +546,16 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
if (cachedValue) {
newQuerySource = combineLatest([of(cachedValue), correlations$]).pipe(
mergeMap(([data, correlations]) =>
decorateData(data, queryResponse, absoluteRange, refreshInterval, queries, correlations)
decorateData(
data,
queryResponse,
absoluteRange,
refreshInterval,
queries,
correlations,
showCorrelationEditorLinks,
defaultCorrelationEditorDatasource
)
)
);
@ -563,8 +587,23 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
liveStreaming: live,
};
let scopedVars: ScopedVars = {};
if (interpolateCorrelationHelperVars && correlationEditorHelperData !== undefined) {
Object.entries(correlationEditorHelperData?.vars).forEach((variable) => {
scopedVars[variable[0]] = { value: variable[1] };
});
}
const timeZone = getTimeZone(getState().user);
const transaction = buildQueryTransaction(exploreId, queries, queryOptions, range, scanning, timeZone);
const transaction = buildQueryTransaction(
exploreId,
queries,
queryOptions,
range,
scanning,
timeZone,
scopedVars
);
dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading }));
@ -577,7 +616,16 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
correlations$,
]).pipe(
mergeMap(([data, correlations]) =>
decorateData(data, queryResponse, absoluteRange, refreshInterval, queries, correlations)
decorateData(
data,
queryResponse,
absoluteRange,
refreshInterval,
queries,
correlations,
showCorrelationEditorLinks,
defaultCorrelationEditorDatasource
)
)
);
@ -1142,27 +1190,6 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
return state;
};
/**
* Creates an observable that emits correlations once they are loaded
*/
const getCorrelations = (exploreId: string) => {
return new Observable<CorrelationData[]>((subscriber) => {
const existingCorrelations = store.getState().explore.panes[exploreId]?.correlations;
if (existingCorrelations) {
subscriber.next(existingCorrelations);
subscriber.complete();
} else {
const unsubscribe = store.subscribe(() => {
const correlations = store.getState().explore.panes[exploreId]?.correlations;
if (correlations) {
unsubscribe();
subscriber.next(correlations);
subscriber.complete();
}
});
}
});
};
export const processQueryResponse = (
state: ExploreItemState,
action: PayloadAction<QueryEndedPayload>

@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { ExploreItemState, StoreState } from 'app/types';
export const selectPanes = (state: Pick<StoreState, 'explore'>) => state.explore.panes;
export const selectExploreRoot = (state: Pick<StoreState, 'explore'>) => state.explore;
export const selectPanesEntries = createSelector<
[(state: Pick<StoreState, 'explore'>) => Record<string, ExploreItemState | undefined>],
@ -11,4 +12,11 @@ export const selectPanesEntries = createSelector<
export const isSplit = createSelector(selectPanesEntries, (panes) => panes.length > 1);
export const isLeftPaneSelector = (exploreId: string) =>
createSelector(selectPanes, (panes) => {
return Object.keys(panes)[0] === exploreId;
});
export const getExploreItemSelector = (exploreId: string) => createSelector(selectPanes, (panes) => panes[exploreId]);
export const selectCorrelationDetails = createSelector(selectExploreRoot, (state) => state.correlationEditorDetails);

@ -1,10 +1,23 @@
import { flattenDeep } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { DataFrame, FieldType, LoadingState, PanelData, getDefaultTimeRange, toDataFrame } from '@grafana/data';
import {
DataFrame,
FieldType,
LoadingState,
PanelData,
getDefaultTimeRange,
toDataFrame,
DataSourceApi,
DataSourceInstanceSettings,
} from '@grafana/data';
import { DataSourceJsonData, DataQuery } from '@grafana/schema';
import TableModel from 'app/core/TableModel';
import { CorrelationData } from 'app/features/correlations/useCorrelations';
import { ExplorePanelData } from 'app/types';
import {
decorateWithCorrelations,
decorateWithFrameTypeMetadata,
decorateWithGraphResult,
decorateWithLogsResult,
@ -103,6 +116,23 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
return { ...defaults, ...args };
};
const datasource = {
name: 'testDs',
type: 'postgres',
uid: 'ds1',
getRef: () => {
return { type: 'postgres', uid: 'ds1' };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
const datasourceInstance = {
name: datasource.name,
id: 1,
uid: datasource.uid,
type: datasource.type,
jsonData: {},
} as DataSourceInstanceSettings<DataSourceJsonData>;
describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
it('should correctly classify the dataFrames', () => {
const { table, logs, timeSeries, emptyTable, flameGraph } = getTestContext();
@ -362,3 +392,103 @@ describe('decorateWithCustomFrames', () => {
expect(decorateWithFrameTypeMetadata(panelData).customFrames).toEqual([customFrame]);
});
});
describe('decorateWithCorrelations', () => {
it('returns no links if there are no correlations and no editor links', () => {
const { table, logs, timeSeries, emptyTable, flameGraph } = getTestContext();
const series = [table, logs, timeSeries, emptyTable, flameGraph];
const timeRange = getDefaultTimeRange();
const panelData: PanelData = {
series,
state: LoadingState.Done,
timeRange,
};
const postDecoratedPanel = decorateWithCorrelations({
showCorrelationEditorLinks: false,
queries: [],
correlations: [],
defaultTargetDatasource: undefined,
})(panelData);
expect(
flattenDeep(postDecoratedPanel.series.map((frame) => frame.fields.map((field) => field.config.links)))
).toEqual([]);
});
it('returns one field link per field if there are no correlations, but there are editor links', () => {
const { table } = getTestContext();
const series = [table];
const timeRange = getDefaultTimeRange();
const panelData: PanelData = {
series,
state: LoadingState.Done,
timeRange,
};
const postDecoratedPanel = decorateWithCorrelations({
showCorrelationEditorLinks: true,
queries: [],
correlations: [],
defaultTargetDatasource: datasource,
})(panelData);
const flattenedLinks = flattenDeep(
postDecoratedPanel.series.map((frame) => frame.fields.map((field) => field.config.links))
);
expect(flattenedLinks.length).toEqual(table.fields.length);
expect(flattenedLinks[0]).not.toBeUndefined();
});
it('returns one field link per field if there are correlations and editor links', () => {
const { table } = getTestContext();
const series = [table];
const timeRange = getDefaultTimeRange();
const panelData: PanelData = {
series,
state: LoadingState.Done,
timeRange,
};
const correlations = [{ source: datasourceInstance, target: datasourceInstance }] as CorrelationData[];
const postDecoratedPanel = decorateWithCorrelations({
showCorrelationEditorLinks: true,
queries: [],
correlations: correlations,
defaultTargetDatasource: datasource,
})(panelData);
const flattenedLinks = flattenDeep(
postDecoratedPanel.series.map((frame) => frame.fields.map((field) => field.config.links))
);
expect(flattenedLinks.length).toEqual(table.fields.length);
expect(flattenedLinks[0]).not.toBeUndefined();
});
it('returns one field link per correlation if there are correlations and we are not showing editor links', () => {
const { table } = getTestContext();
const series = [table];
const timeRange = getDefaultTimeRange();
const panelData: PanelData = {
series,
state: LoadingState.Done,
timeRange,
};
const correlations = [
{
uid: '0',
source: datasourceInstance,
target: datasourceInstance,
provisioned: true,
config: { field: panelData.series[0].fields[0].name },
},
] as CorrelationData[];
const postDecoratedPanel = decorateWithCorrelations({
showCorrelationEditorLinks: false,
queries: [{ refId: 'A', datasource: datasource.getRef() }],
correlations: correlations,
defaultTargetDatasource: undefined,
})(panelData);
expect(
flattenDeep(postDecoratedPanel.series.map((frame) => frame.fields.map((field) => field.config.links))).length
).toEqual(correlations.length);
});
});

@ -10,6 +10,9 @@ import {
PanelData,
standardTransformers,
preProcessPanelData,
DataLinkConfigOrigin,
getRawDisplayProcessor,
DataSourceApi,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
@ -93,14 +96,45 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
};
export const decorateWithCorrelations = ({
showCorrelationEditorLinks,
queries,
correlations,
defaultTargetDatasource,
}: {
showCorrelationEditorLinks: boolean;
queries: DataQuery[] | undefined;
correlations: CorrelationData[] | undefined;
defaultTargetDatasource?: DataSourceApi;
}) => {
return (data: PanelData): PanelData => {
if (queries?.length && correlations?.length) {
if (showCorrelationEditorLinks && defaultTargetDatasource) {
for (const frame of data.series) {
for (const field of frame.fields) {
field.config.links = []; // hide all previous links, we only want to show fake correlations in this view
field.display = field.display || getRawDisplayProcessor();
const availableVars: Record<string, string> = {};
frame.fields.map((field) => {
availableVars[`${field.name}`] = "${__data.fields.['" + `${field.name}` + `']}`;
});
field.config.links.push({
url: '',
origin: DataLinkConfigOrigin.ExploreCorrelationsEditor,
title: `Correlate with ${field.name}`,
internal: {
datasourceUid: defaultTargetDatasource.uid,
datasourceName: defaultTargetDatasource.name,
query: { datasource: { uid: defaultTargetDatasource.uid } },
meta: {
correlationData: { resultField: field.name, vars: availableVars },
},
},
});
}
}
} else if (queries?.length && correlations?.length) {
const queryRefIdToDataSourceUid = mapValues(groupBy(queries, 'refId'), '0.datasource.uid');
attachCorrelationsToDataFrames(data.series, correlations, queryRefIdToDataSourceUid);
}
@ -255,11 +289,20 @@ export function decorateData(
absoluteRange: AbsoluteTimeRange,
refreshInterval: string | undefined,
queries: DataQuery[] | undefined,
correlations: CorrelationData[] | undefined
correlations: CorrelationData[] | undefined,
showCorrelationEditorLinks: boolean,
defaultCorrelationTargetDatasource?: DataSourceApi
): Observable<ExplorePanelData> {
return of(data).pipe(
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithCorrelations({ queries, correlations })),
map(
decorateWithCorrelations({
defaultTargetDatasource: defaultCorrelationTargetDatasource,
showCorrelationEditorLinks,
queries,
correlations,
})
),
map(decorateWithFrameTypeMetadata),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })),

@ -39,7 +39,9 @@ export const PromQueryBuilderOptions = React.memo<Props>(({ query, app, onChange
onRunQuery();
};
const queryTypeOptions = getQueryTypeOptions(app === CoreApp.Explore || app === CoreApp.PanelEditor);
const queryTypeOptions = getQueryTypeOptions(
app === CoreApp.Explore || app === CoreApp.Correlations || app === CoreApp.PanelEditor
);
const onQueryTypeChange = getQueryTypeChangeHandler(query, onChange);
const onExemplarChange = (event: SyntheticEvent<HTMLInputElement>) => {

@ -16,12 +16,37 @@ import {
ExplorePanelsState,
SupplementaryQueryType,
UrlQueryMap,
ExploreCorrelationHelperData,
} from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { CorrelationData } from '../features/correlations/useCorrelations';
export type ExploreQueryParams = UrlQueryMap;
export enum CORRELATION_EDITOR_POST_CONFIRM_ACTION {
CLOSE_PANE,
CHANGE_DATASOURCE,
}
export interface CorrelationEditorDetails {
editorMode: boolean;
dirty: boolean;
isExiting: boolean;
postConfirmAction?: {
// perform an action after a confirmation modal instead of exiting editor mode
exploreId: string;
action: CORRELATION_EDITOR_POST_CONFIRM_ACTION;
changeDatasourceUid?: string;
};
canSave?: boolean;
label?: string;
description?: string;
}
// updates can have any properties
export interface CorrelationEditorDetailsUpdate extends Partial<CorrelationEditorDetails> {}
/**
* Global Explore state
*/
@ -49,6 +74,11 @@ export interface ExploreState {
*/
richHistoryLimitExceededWarningShown: boolean;
/**
* Details on a correlation being created from explore
*/
correlationEditorDetails?: CorrelationEditorDetails;
/**
* On a split manual resize, we calculate which pane is larger, or if they are roughly the same size. If undefined, it is not split or they are roughly the same size
*/
@ -192,6 +222,8 @@ export interface ExploreItemState {
panelsState: ExplorePanelsState;
correlationEditorHelperData?: ExploreCorrelationHelperData;
correlations?: CorrelationData[];
}

Loading…
Cancel
Save