diff --git a/.betterer.results b/.betterer.results index 8b826767eed..9a048fec628 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index c45a3ed7e7b..d577b5e1a97 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -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 { export enum DataLinkConfigOrigin { Datasource = 'Datasource', Correlations = 'Correlations', + ExploreCorrelationsEditor = 'CorrelationsEditor', } /** @@ -77,6 +78,9 @@ export interface InternalDataLink { datasourceUid: string; datasourceName: string; // used as a title if `DataLink.title` is empty panelsState?: ExplorePanelsState; + meta?: { + correlationData?: ExploreCorrelationHelperData; + }; transformations?: DataLinkTransformationConfig[]; range?: TimeRange; } diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index eecf02813e6..dda48b62802 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -31,6 +31,15 @@ export interface ExplorePanelsState extends Partial; +} + export interface ExploreTracePanelState { spanId?: string; } @@ -46,6 +55,7 @@ export interface SplitOpenOptions { queries?: T[]; range?: TimeRange; panelsState?: ExplorePanelsState; + correlationHelperData?: ExploreCorrelationHelperData; } /** diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 130c8682055..d47d03d71bb 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -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, }); } diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 4f5fdcc8df5..4389de57187 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -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 this.onDragStarted()} onDragFinished={(size) => this.onDragFinished(size)} + style={parentStyle} paneStyle={paneStyle} pane2Style={secondaryPaneStyle} > diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 566003ad8d3..6d4851de77f 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -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, diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index a59c570b553..cba91591e92 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -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 => { + return getBackendSrv().post(`/api/datasources/uid/${sourceUID}/correlations`, correlation); +}; diff --git a/public/app/features/explore/CorrelationEditorModeBar.tsx b/public/app/features/explore/CorrelationEditorModeBar.tsx new file mode 100644 index 00000000000..e4f4a761b9e --- /dev/null +++ b/public/app/features/explore/CorrelationEditorModeBar.tsx @@ -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 */} + { + if ( + location.pathname !== '/explore' && + (correlationDetails?.editorMode || false) && + (correlationDetails?.dirty || false) + ) { + return 'You have unsaved correlation data. Continue?'; + } else { + return true; + } + }} + /> + + {showSavePrompt && ( + { + 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); + }} + /> + )} +
+ + + + + + + +
+ + ); +}; + +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`, + }), + }; +}; diff --git a/public/app/features/explore/CorrelationHelper.tsx b/public/app/features/explore/CorrelationHelper.tsx new file mode 100644 index 00000000000..fecf3d03af5 --- /dev/null +++ b/public/app/features/explore/CorrelationHelper.tsx @@ -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(); + 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 ( + + The correlation link will appear by the {correlations.resultField} field. You can use the following + variables to set up your correlations: +
+        {Object.entries(correlations.vars).map((entry) => {
+          return `\$\{${entry[0]}\} = ${entry[1]}\n`;
+        })}
+      
+ { + setIsOpen(!isOpen); + }} + label="Label/Description" + > + + + + + + + +
+ ); +}; diff --git a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx new file mode 100644 index 00000000000..19100150fd9 --- /dev/null +++ b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx @@ -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 ( + +
Do you want to save changes to this Correlation?
+ + + + + +
+ ); +}; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 1b6b3b9cf4b..4cb63ae2049 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -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', () => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 92a774e054c..0e5592355e8 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { 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 { 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 = ; + } + return ( <> @@ -508,9 +518,11 @@ export class Explore extends React.PureComponent { {datasourceInstance ? (
+ {correlationsBox} { 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]); diff --git a/public/app/features/explore/ExplorePage.tsx b/public/app/features/explore/ExplorePage.tsx index 8612491ef9b..8eca40aaf36 100644 --- a/public/app/features/explore/ExplorePage.tsx +++ b/public/app/features/explore/ExplorePage.tsx @@ -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 ( -
+
- + {showCorrelationEditorBar && } size && updateSplitSize(size)} > @@ -79,3 +81,21 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
); } + +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', + }), + }; +}; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 6db62e49b5e..ecce29a88a8 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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 = [ + , +
, + ]; + return (
{refreshInterval && }
- , -
, - ]} - /> +
{ }), timeZone: 'browser', timeRange: { from: 'now-1h', to: 'now' }, + shouldShowAddCorrelation: false, }); }); diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 568ab9a66db..59a713c7ac3 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -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[] { diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx index 2726bf57ad2..74d843248ef 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx @@ -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), + }, ]); }); }); diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index 8be3ec9d955..636d2fe1ae6 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -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({ + 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}"`); diff --git a/public/app/features/explore/state/correlations.ts b/public/app/features/explore/state/correlations.ts new file mode 100644 index 00000000000..767f9becccb --- /dev/null +++ b/public/app/features/explore/state/correlations.ts @@ -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((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> { + 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> { + 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); + }); + } + }; +} diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index a88032ab9aa..1ce86f1e245 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -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( + '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, diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 46b8e90571e..3ed26f8e816 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -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( + 'explore/changeCorrelationEditorDetails' +); + export interface NavigateToExploreDependencies { timeRange: TimeRange; getExploreUrl: (args: GetExploreUrlArguments) => Promise; @@ -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 { diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index cb47fc5c9b8..4ada0940c71 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -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( 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( } const exploreItemState = getState().explore.panes[exploreId]!; + const { datasourceInstance, containerWidth, @@ -512,7 +520,14 @@ export const runQueries = createAsyncThunk( 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; let newQuerySubscription: SubscriptionLike; @@ -531,7 +546,16 @@ export const runQueries = createAsyncThunk( 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( 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( 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((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 diff --git a/public/app/features/explore/state/selectors.ts b/public/app/features/explore/state/selectors.ts index 938a1ad31c2..b6043f62df0 100644 --- a/public/app/features/explore/state/selectors.ts +++ b/public/app/features/explore/state/selectors.ts @@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { ExploreItemState, StoreState } from 'app/types'; export const selectPanes = (state: Pick) => state.explore.panes; +export const selectExploreRoot = (state: Pick) => state.explore; export const selectPanesEntries = createSelector< [(state: Pick) => Record], @@ -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); diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 20296b97df8..2060d9d3ae3 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -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): ExplorePanelDa return { ...defaults, ...args }; }; +const datasource = { + name: 'testDs', + type: 'postgres', + uid: 'ds1', + getRef: () => { + return { type: 'postgres', uid: 'ds1' }; + }, +} as DataSourceApi; + +const datasourceInstance = { + name: datasource.name, + id: 1, + uid: datasource.uid, + type: datasource.type, + jsonData: {}, +} as DataSourceInstanceSettings; + 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); + }); +}); diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 99ac1dbcacb..4f626dd4966 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -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 = {}; + 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 { 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 })), diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index 7f12cb2746b..4ac51127e97 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -39,7 +39,9 @@ export const PromQueryBuilderOptions = React.memo(({ 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) => { diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 1314f38779d..cf1d21524ab 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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 {} + /** * 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[]; }