mirror of https://github.com/grafana/grafana
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
parent
6150d1370c
commit
4b3d63dcdc
@ -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> |
||||
); |
||||
}; |
@ -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); |
||||
}); |
||||
} |
||||
}; |
||||
} |
Loading…
Reference in new issue