mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
6150d1370c
commit
4b3d63dcdc
@ -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);
|
||||
};
|
||||
|
251
public/app/features/explore/CorrelationEditorModeBar.tsx
Normal file
251
public/app/features/explore/CorrelationEditorModeBar.tsx
Normal file
@ -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`,
|
||||
}),
|
||||
};
|
||||
};
|
76
public/app/features/explore/CorrelationHelper.tsx
Normal file
76
public/app/features/explore/CorrelationHelper.tsx
Normal file
@ -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}"`);
|
||||
|
101
public/app/features/explore/state/correlations.ts
Normal file
101
public/app/features/explore/state/correlations.ts
Normal file
@ -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…
Reference in New Issue
Block a user