Compare commits

...

12 Commits

Author SHA1 Message Date
Alex Spencer
4b9416a360 chore: fix tests for realsies 2025-11-26 13:53:53 -08:00
Alex Spencer
14d91eec01 chore: i18n 2025-11-26 13:35:41 -08:00
Alex Spencer
3d6deb4656 chore: config tests for featureToggle 2025-11-26 13:28:15 -08:00
Alex Spencer
7f30ecb494 fix: format go 2025-11-26 13:20:40 -08:00
Alex Spencer
7c11ea8ed5 fix: remove unnecessary file duplication 2025-11-26 13:18:35 -08:00
Alex Spencer
bc3a3ba74e remake toggles post merge conflict 2025-11-26 13:09:45 -08:00
Alex Spencer
f251bb1070 Merge branch 'main' of https://github.com/grafana/grafana into alexspencer/correlations-explore-editor-updates 2025-11-26 13:09:36 -08:00
Alex Spencer
aca15a9571 feat: incorporate feature flag 2025-11-26 13:05:57 -08:00
Alex Spencer
38c826fd49 refactor: update CorrelationEditorTour layout and styles
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-11-25 10:55:45 -08:00
Alex Spencer
bf1a8ab7c8 chore: lots of fixes and enhancements
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-11-24 19:21:32 -08:00
natellium
eaa5762778 poc layout
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-11-12 19:28:36 +01:00
Alex Spencer
b35a8a6c36 init: initial updates 2025-11-10 11:32:48 -08:00
23 changed files with 1607 additions and 58 deletions

View File

@@ -1189,4 +1189,9 @@ export interface FeatureToggles {
* @default false
*/
rudderstackUpgrade?: boolean;
/**
* Enables the new correlations editor in Explore
* @default false
*/
correlationsExploreEditor?: boolean;
}

View File

@@ -1963,6 +1963,14 @@ var (
RequiresRestart: false,
HideFromDocs: false,
},
{
Name: "correlationsExploreEditor",
Description: "Enables the new correlations editor in Explore",
Stage: FeatureStageExperimental,
Owner: grafanaDataProSquad,
FrontendOnly: true,
Expression: "false",
},
}
)

View File

@@ -266,3 +266,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true
lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true
rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true
correlationsExploreEditor,experimental,@grafana/datapro,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
266 ttlPluginInstanceManager experimental @grafana/plugins-platform-backend false false true
267 lokiQueryLimitsContext experimental @grafana/observability-logs false false true
268 rudderstackUpgrade experimental @grafana/grafana-frontend-platform false false true
269 correlationsExploreEditor experimental @grafana/datapro false false true

View File

@@ -923,6 +923,20 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "correlationsExploreEditor",
"resourceVersion": "1764191352059",
"creationTimestamp": "2025-11-26T21:09:12Z"
},
"spec": {
"description": "Enables the new correlations editor in Explore",
"stage": "experimental",
"codeowner": "@grafana/datapro",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "crashDetection",

View File

@@ -40,11 +40,20 @@ export const CONTENT_OUTLINE_LOCAL_STORAGE_KEYS = {
expanded: 'grafana.explore.contentOutline.expanded',
};
export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) {
export function ContentOutline({
scroller,
panelId,
defaultCollapsed = false,
}: {
scroller: HTMLElement | undefined;
panelId: string;
defaultCollapsed?: boolean;
}) {
const [contentOutlineExpanded, toggleContentOutlineExpanded] = useToggle(
store.getBool(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.expanded, true)
);
const styles = useStyles2(getStyles, contentOutlineExpanded);
const isExpanded = contentOutlineExpanded && !defaultCollapsed;
const styles = useStyles2(getStyles, isExpanded);
const scrollerRef = useRef(scroller || null);
const { y: verticalScroll } = useScroll(scrollerRef);
const { outlineItems } = useContentOutlineContext() ?? { outlineItems: [] };
@@ -102,6 +111,9 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
};
const toggle = () => {
if (defaultCollapsed && !contentOutlineExpanded) {
return;
}
store.set(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.expanded, !contentOutlineExpanded);
toggleContentOutlineExpanded();
reportInteraction('explore_toolbar_contentoutline_clicked', {
@@ -160,16 +172,16 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
<ContentOutlineItemButton
icon={'arrow-from-right'}
tooltip={
contentOutlineExpanded
isExpanded
? t('explore.content-outline.tooltip-collapse-outline', 'Collapse outline')
: t('explore.content-outline.tooltip-expand-outline', 'Expand outline')
}
tooltipPlacement={contentOutlineExpanded ? 'right' : 'bottom'}
tooltipPlacement={isExpanded ? 'right' : 'bottom'}
onClick={toggle}
className={cx(styles.toggleContentOutlineButton, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsShouldIndent,
[styles.justifyCenter]: !isExpanded && !outlineItemsShouldIndent,
})}
aria-expanded={contentOutlineExpanded}
aria-expanded={isExpanded}
/>
{outlineItems.map((item) => {
@@ -177,16 +189,16 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
<Fragment key={item.id}>
<ContentOutlineItemButton
key={item.id}
title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
title={isExpanded ? item.title : undefined}
contentOutlineExpanded={isExpanded}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
[styles.justifyCenter]: !isExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !isExpanded,
})}
indentStyle={cx({
[styles.indentRoot]: !isCollapsible(item) && outlineItemsShouldIndent,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id],
isChildActive(item, activeSectionChildId) && !isExpanded && sectionsExpanded[item.id],
})}
icon={item.icon}
onClick={() => handleItemClicked(item)}
@@ -204,7 +216,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
sectionsExpanded[item.id] &&
item.children.map((child, i) => (
<div key={child.id} className={styles.itemWrapper}>
{contentOutlineExpanded && (
{isExpanded && (
<div
className={cx(styles.itemConnector, {
[styles.firstItemConnector]: i === 0,
@@ -214,13 +226,12 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
)}
<ContentOutlineItemButton
key={child.id}
title={contentOutlineExpanded ? child.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
icon={contentOutlineExpanded ? undefined : item.icon}
title={isExpanded ? child.title : undefined}
contentOutlineExpanded={isExpanded}
icon={isExpanded ? undefined : item.icon}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
[styles.justifyCenter]: !isExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !isExpanded,
})}
indentStyle={styles.indentChild}
onClick={(e) => {

View File

@@ -0,0 +1,300 @@
import { useEffect, useState } from 'react';
import { useBeforeUnload, useUnmount } from 'react-use';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Alert, Badge, Button, Icon, Stack, Text } from '@grafana/ui';
import { Prompt } from 'app/core/components/FormPrompt/Prompt';
import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState } from 'app/types/explore';
import { useDispatch, useSelector } from 'app/types/store';
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, selectIsHelperShowing } from '../state/selectors';
import { CorrelationEditorTour, useCorrelationEditorTour } from './CorrelationEditorTour';
import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal';
import { showModalMessage } from './correlationEditLogic';
export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => {
const dispatch = useDispatch();
const correlationDetails = useSelector(selectCorrelationDetails);
const isHelperShowing = useSelector(selectIsHelperShowing);
const [saveMessage, setSaveMessage] = useState<string | undefined>(undefined); // undefined means do not show
const { shouldShowTour, dismissTour } = useCorrelationEditorTour();
// handle refreshing and closing the tab
useBeforeUnload(correlationDetails?.correlationDirty || false, 'Save correlation?');
useBeforeUnload(
(!correlationDetails?.correlationDirty && correlationDetails?.queryEditorDirty) || false,
'The query editor was changed. Save correlation before continuing?'
);
// decide if we are displaying prompt, perform action if not
useEffect(() => {
if (correlationDetails?.isExiting) {
const { correlationDirty, queryEditorDirty } = correlationDetails;
let isActionLeft = undefined;
let action = undefined;
if (correlationDetails.postConfirmAction) {
isActionLeft = correlationDetails.postConfirmAction.isActionLeft;
action = correlationDetails.postConfirmAction.action;
} else {
// closing the editor only
action = CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR;
isActionLeft = false;
}
const modalMessage = showModalMessage(action, isActionLeft, correlationDirty, queryEditorDirty);
if (modalMessage !== undefined) {
setSaveMessage(modalMessage);
} else {
// if no prompt, perform action
if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE &&
correlationDetails.postConfirmAction
) {
const { exploreId, changeDatasourceUid } = correlationDetails?.postConfirmAction;
if (exploreId && changeDatasourceUid) {
dispatch(
changeDatasource({ exploreId, datasource: changeDatasourceUid, options: { importQueries: true } })
);
dispatch(
changeCorrelationEditorDetails({
isExiting: false,
})
);
}
} else if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE &&
correlationDetails.postConfirmAction
) {
const { exploreId } = correlationDetails?.postConfirmAction;
if (exploreId !== undefined) {
dispatch(splitClose(exploreId));
dispatch(
changeCorrelationEditorDetails({
isExiting: false,
})
);
}
} else if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR) {
dispatch(
changeCorrelationEditorDetails({
editorMode: false,
})
);
}
}
}
}, [correlationDetails, dispatch, isHelperShowing]);
// clear data when unmounted
useUnmount(() => {
dispatch(
changeCorrelationEditorDetails({
editorMode: false,
isExiting: false,
correlationDirty: false,
label: undefined,
description: undefined,
canSave: false,
})
);
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
dispatch(runQueries({ exploreId: pane[0] }));
});
});
const resetEditor = () => {
dispatch(
changeCorrelationEditorDetails({
editorMode: true,
isExiting: false,
correlationDirty: false,
label: undefined,
description: undefined,
canSave: false,
})
);
panes.forEach((pane) => {
dispatch(
changeCorrelationHelperData({
exploreId: pane[0],
correlationEditorHelperData: undefined,
})
);
dispatch(runQueries({ exploreId: pane[0] }));
});
};
const closePane = (exploreId: string) => {
setSaveMessage(undefined);
dispatch(splitClose(exploreId));
reportInteraction('grafana_explore_split_view_closed');
};
const changeDatasourcePostAction = (exploreId: string, datasourceUid: string) => {
setSaveMessage(undefined);
dispatch(changeDatasource({ exploreId, datasource: datasourceUid, options: { importQueries: true } }));
};
const saveCorrelationPostAction = (skipPostConfirmAction: boolean) => {
dispatch(
saveCurrentCorrelation(
correlationDetails?.label,
correlationDetails?.description,
correlationDetails?.transformations
)
);
if (!skipPostConfirmAction && correlationDetails?.postConfirmAction !== undefined) {
const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction;
if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) {
closePane(exploreId);
resetEditor();
} else if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE &&
changeDatasourceUid !== undefined
) {
changeDatasource({ exploreId, datasource: changeDatasourceUid });
resetEditor();
}
} else {
dispatch(changeCorrelationEditorDetails({ editorMode: false, correlationDirty: false, isExiting: false }));
}
};
return (
<>
{/* Handle navigating outside Explore */}
<Prompt
message={(location) => {
if (
location.pathname !== '/explore' &&
correlationDetails?.editorMode &&
correlationDetails?.correlationDirty
) {
return 'You have unsaved correlation data. Continue?';
} else {
return true;
}
}}
/>
{/* Show tour for first-time users */}
{shouldShowTour && <CorrelationEditorTour onDismiss={dismissTour} />}
{saveMessage !== undefined && (
<CorrelationUnsavedChangesModal
onDiscard={() => {
if (correlationDetails?.postConfirmAction !== undefined) {
const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction;
if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) {
closePane(exploreId);
} else if (
action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE &&
changeDatasourceUid !== undefined
) {
changeDatasourcePostAction(exploreId, changeDatasourceUid);
}
dispatch(changeCorrelationEditorDetails({ isExiting: false }));
} 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,
correlationDirty: 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 }));
setSaveMessage(undefined);
}}
onSave={() => {
saveCorrelationPostAction(false);
}}
message={saveMessage}
/>
)}
<Alert title="" severity="info" bottomSpacing={0} topSpacing={0}>
<Stack width="100%" alignItems="start" justifyContent="space-between">
<Stack gap={1} direction="column">
<Stack gap={2} alignItems="center" direction="row">
<Text variant="h4">
<Trans i18nKey="explore.correlation-editor-mode-bar.edit-mode-title">Correlation Editor Mode</Trans>
</Text>
<Badge
color="orange"
text={t('explore.correlation-editor-mode-bar.experimental', 'Experimental')}
tooltip={t(
'explore.correlation-editor-mode-bar.content-correlations-editor-explore-experimental-feature',
'Correlations editor in Explore is an experimental feature.'
)}
/>
</Stack>
<Stack gap={0} direction="column">
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-editor-mode-bar.instructions">
Step 1: Run a query and click a table cell link or a <Icon name="link" size="sm" />{' '}
<strong>Correlate with</strong> button.
</Trans>
</Text>
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-editor-mode-bar.instructions-2">
Step 2: In the right pane (Correlation), build and test your correlation query.
</Trans>
</Text>
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-editor-mode-bar.instructions-3">
Step 3: Click Save to create the correlation.
</Trans>
</Text>
</Stack>
</Stack>
<Stack gap={1} alignItems="center">
<Button
size="sm"
disabled={!correlationDetails?.canSave}
variant="secondary"
onClick={() => {
saveCorrelationPostAction(true);
}}
>
<Trans i18nKey="explore.correlation-editor-mode-bar.save">Save</Trans>
</Button>
<Button
size="sm"
icon="times"
variant="secondary"
onClick={() => {
dispatch(changeCorrelationEditorDetails({ isExiting: true }));
reportInteraction('grafana_explore_correlation_editor_exit_pressed');
}}
>
<Trans i18nKey="explore.correlation-editor-mode-bar.exit-correlation-editor">
Exit correlation editor
</Trans>
</Button>
</Stack>
</Stack>
</Alert>
</>
);
};

View File

@@ -0,0 +1,310 @@
import { css } from '@emotion/css';
import { useState, useEffect } from 'react';
import { GrafanaTheme2, store } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Icon, Modal, Stack, Text, useStyles2 } from '@grafana/ui';
const TOUR_STORAGE_KEY = 'grafana.explore.correlationEditor.tourCompleted';
interface TourStep {
title: string;
content: JSX.Element;
}
const getTourSteps = (): TourStep[] => [
{
title: t('explore.correlation-tour.welcome-title', 'Welcome to the Correlation Editor'),
content: (
<Stack direction="column" gap={2}>
<Text>
<Trans i18nKey="explore.correlation-tour.welcome-body">
The Correlation Editor helps you create clickable links between different data sources in Grafana. This
makes it easy to jump from one view to another with context preserved.
</Trans>
</Text>
<Text>
<Trans i18nKey="explore.correlation-tour.welcome-example">
For example, you can click a service name in your logs and automatically open a dashboard showing metrics
for that service.
</Trans>
</Text>
</Stack>
),
},
{
title: t('explore.correlation-tour.step1-title', 'Step 1: Run a Query and Click a Link'),
content: (
<Stack direction="column" gap={2}>
<Text>
<Trans i18nKey="explore.correlation-tour.step1-body">
Run a query that returns data. You can then click a link in a table cell, or use the{' '}
<Icon name="link" size="sm" /> <strong>Correlate with [field name]</strong> button to start creating a
correlation.
</Trans>
</Text>
<Stack direction="row" gap={1} alignItems="center">
<Icon name="info-circle" />
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="explore.correlation-tour.step1-tip">
Tip: Look for these correlation links in table cells or log lines
</Trans>
</Text>
</Stack>
</Stack>
),
},
{
title: t('explore.correlation-tour.step2-title', 'Step 2: Build Your Target Query'),
content: (
<Stack direction="column" gap={2}>
<Text>
<Trans i18nKey="explore.correlation-tour.step2-body">
After clicking a correlation link, the <strong>right pane</strong> (target) opens with a query editor. Build
and test your query here.
</Trans>
</Text>
<Text>
<Trans i18nKey="explore.correlation-tour.step2-variables">
Available variables are shown in the &quot;Variables&quot; section below. You can also create custom
variables by extracting parts of fields using regular expressions or logfmt.
</Trans>
</Text>
</Stack>
),
},
{
title: t('explore.correlation-tour.step3-title', 'Step 3: Save Your Correlation'),
content: (
<Stack direction="column" gap={2}>
<Text>
<Trans i18nKey="explore.correlation-tour.step3-body">
Once your query works correctly, click the <strong>Save</strong> button . Give your correlation a name and
optionally add a description.
</Trans>
</Text>
<Text>
<Trans i18nKey="explore.correlation-tour.step3-result">
After saving, this correlation link will appear for all users in the same field across all queries from your
source data source!
</Trans>
</Text>
</Stack>
),
},
{
title: t('explore.correlation-tour.ready-title', "You're All Set!"),
content: (
<Stack direction="column" gap={2}>
<Text>
<Trans i18nKey="explore.correlation-tour.ready-body">
You now know the basics of creating correlations. Remember, you can exit the editor at any time by clicking
the <strong>Exit correlation editor</strong> button.
</Trans>
</Text>
<Stack direction="column" gap={1}>
<Text variant="h6">
<Trans i18nKey="explore.correlation-tour.ready-tips-title">Quick Tips:</Trans>
</Text>
<Stack direction="row" gap={1}>
<Icon name="check" />
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-tour.ready-tip1">
Test your correlation query thoroughly before saving
</Trans>
</Text>
</Stack>
<Stack direction="row" gap={1}>
<Icon name="check" />
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-tour.ready-tip2">
Use clear, descriptive names so other users understand the link
</Trans>
</Text>
</Stack>
<Stack direction="row" gap={1}>
<Icon name="check" />
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-tour.ready-tip3">
Custom variables let you extract specific parts of field values
</Trans>
</Text>
</Stack>
</Stack>
</Stack>
),
},
];
interface CorrelationEditorTourProps {
onDismiss: () => void;
}
export const CorrelationEditorTour = ({ onDismiss }: CorrelationEditorTourProps) => {
const [currentStep, setCurrentStep] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const styles = useStyles2(getStyles);
const tourSteps = getTourSteps();
const isLastStep = currentStep === tourSteps.length - 1;
const isFirstStep = currentStep === 0;
const handleNext = () => {
if (isLastStep) {
handleComplete();
} else {
setIsTransitioning(true);
setTimeout(() => {
setCurrentStep(currentStep + 1);
setIsTransitioning(false);
}, 150);
}
};
const handleBack = () => {
setIsTransitioning(true);
setTimeout(() => {
setCurrentStep(currentStep - 1);
setIsTransitioning(false);
}, 150);
};
const handleComplete = () => {
store.set(TOUR_STORAGE_KEY, true);
onDismiss();
};
const currentTourStep = tourSteps[currentStep];
return (
<Modal isOpen={true} title={currentTourStep.title} onDismiss={handleComplete}>
<div className={styles.modalContent}>
<div className={isTransitioning ? styles.contentTransitioning : styles.contentVisible}>
{currentTourStep.content}
</div>
<div className={styles.progressContainer}>
<Stack direction="column" gap={1.5}>
{/* Progress indicator */}
<Stack direction="row" gap={0.5} justifyContent="center" alignItems="center">
{tourSteps.map((_, index) => (
<div
key={index}
className={index === currentStep ? styles.progressDotActive : styles.progressDot}
aria-label={
index === currentStep
? t('explore.correlation-tour.current-step', 'Current step {{step}}', {
step: index + 1,
})
: t('explore.correlation-tour.step-number', 'Step {{step}}', { step: index + 1 })
}
/>
))}
</Stack>
{/* Step counter */}
<Text variant="bodySmall" color="secondary" textAlignment="center">
<Trans
i18nKey="explore.correlation-tour.step-counter"
values={{ current: currentStep + 1, total: tourSteps.length }}
>
Step {{ current: currentStep + 1 }} of {{ total: tourSteps.length }}
</Trans>
</Text>
</Stack>
</div>
</div>
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={handleComplete}>
<Trans i18nKey="explore.correlation-tour.skip">Skip tour</Trans>
</Button>
<Stack direction="row" gap={1}>
{!isFirstStep && (
<Button variant="secondary" onClick={handleBack}>
<Trans i18nKey="explore.correlation-tour.back">Back</Trans>
</Button>
)}
<Button variant="primary" onClick={handleNext}>
{isLastStep ? (
<Trans i18nKey="explore.correlation-tour.got-it">Got it!</Trans>
) : (
<Trans i18nKey="explore.correlation-tour.next">Next</Trans>
)}
</Button>
</Stack>
</Modal.ButtonRow>
</Modal>
);
};
/**
* Hook to check if the tour should be shown for first-time users
*/
export const useCorrelationEditorTour = () => {
const [shouldShowTour, setShouldShowTour] = useState(false);
useEffect(() => {
const hasCompletedTour = store.getBool(TOUR_STORAGE_KEY, false);
if (!hasCompletedTour) {
// Small delay to let the UI settle before showing the tour
const timer = setTimeout(() => {
setShouldShowTour(true);
}, 500);
return () => clearTimeout(timer);
}
return undefined;
}, []);
const dismissTour = () => {
setShouldShowTour(false);
};
return { shouldShowTour, dismissTour };
};
const getStyles = (theme: GrafanaTheme2) => ({
modalContent: css({
minHeight: '180px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
contentVisible: css({
minHeight: '180px',
opacity: 1,
[theme.transitions.handleMotion('no-preference')]: {
transform: 'translateY(0)',
transition: 'opacity 0.2s ease, transform 0.2s ease',
},
}),
contentTransitioning: css({
minHeight: '180px',
opacity: 0,
[theme.transitions.handleMotion('no-preference')]: {
transform: 'translateY(-8px)',
transition: 'opacity 0.15s ease, transform 0.15s ease',
},
}),
progressContainer: css({
marginTop: theme.spacing(2),
}),
progressDot: css({
width: '8px',
height: '8px',
borderRadius: theme.shape.radius.circle,
backgroundColor: theme.colors.border.medium,
[theme.transitions.handleMotion('no-preference')]: {
transition: 'all 0.2s ease',
},
}),
progressDotActive: css({
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
backgroundColor: theme.colors.primary.main,
[theme.transitions.handleMotion('no-preference')]: {
transition: 'all 0.2s ease',
},
}),
});

View File

@@ -0,0 +1,104 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, DeleteButton, IconButton, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { CorrelationFormCustomVariablesProps } from '../types';
import { FormSection } from './FormSection';
export const CorrelationFormCustomVariables = ({
correlations,
transformations,
handlers,
}: CorrelationFormCustomVariablesProps) => {
const styles = useStyles2(getStyles);
const transformationMap = useMemo(
() => new Map(transformations.map((t, idx) => [t.mapValue, idx])),
[transformations]
);
return (
<FormSection title={<Trans i18nKey="explore.correlation-helper.title-variables">Variables (optional)</Trans>}>
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-helper.body-variables">
Use these variables in your target query. When a correlation link is clicked, each variable is filled in with
its value from that row.
</Trans>
</Text>
<Stack direction="column" gap={1.5}>
{Object.entries(correlations.vars).map(([name, value]) => {
// Check if this is a custom variable (not in origVars)
const isCustomVariable = !(name in correlations.origVars);
const transformationIdx = transformationMap.get(name);
return (
<Stack key={name} direction="row" gap={1} alignItems="flex-start" justifyContent="space-between">
<Stack direction="row" gap={1} alignItems="center" grow={1}>
<code className={styles.variableName}>${`{${name}}`}</code>
<Tooltip content={value} placement="auto-start">
<span className={styles.variableValue}>{value}</span>
</Tooltip>
</Stack>
{isCustomVariable && transformationIdx !== undefined && (
<Stack direction="row" gap={0.5}>
<IconButton
name="edit"
size="sm"
aria-label={t('explore.correlation-helper.aria-label-edit-transformation', 'Edit transformation')}
onClick={() => handlers.onEdit(transformationIdx)}
/>
<DeleteButton
size="sm"
aria-label={t(
'explore.correlation-helper.aria-label-delete-transformation',
'Delete transformation'
)}
onConfirm={() => handlers.onDelete(transformationIdx)}
closeOnConfirm
/>
</Stack>
)}
</Stack>
);
})}
</Stack>
<Button variant="secondary" fill="outline" onClick={handlers.onAdd} className={styles.addButton}>
<Trans i18nKey="explore.correlation-helper.add-custom-variable">Add custom variable</Trans>
</Button>
</FormSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
variableName: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(0.5, 1),
borderRadius: theme.shape.radius.default,
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.primary.text,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
}),
variableValue: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(0.5, 1),
borderRadius: theme.shape.radius.default,
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '400px',
}),
addButton: css({
alignSelf: 'flex-start',
}),
});

View File

@@ -0,0 +1,92 @@
import { useId, useMemo } from 'react';
import { Controller } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Combobox, ComboboxOption, Field, Input } from '@grafana/ui';
import { CorrelationType, CorrelationFormInformationProps } from '../types';
import { FormSection } from './FormSection';
export const CorrelationFormInformation = ({
control,
register,
getValues,
setValue,
defaultLabel,
selectedType,
}: CorrelationFormInformationProps) => {
const id = useId();
const typeOptions: Array<ComboboxOption<CorrelationType>> = useMemo(
() => [
{
label: t('explore.correlation-form-information.type-options.label.explore-query', 'Explore Query'),
value: CorrelationType.ExploreQuery,
},
{
label: t('explore.correlation-form-information.type-options.label.link', 'Link'),
value: CorrelationType.Link,
},
],
[]
);
return (
<FormSection title={<Trans i18nKey="explore.correlation-helper.title-correlation-info">Correlation Info</Trans>}>
<Field noMargin label={t('explore.correlation-form-information.label-type', 'Type')} htmlFor={`${id}-type`}>
<Controller
name="type"
control={control}
render={({ field: { onChange, value } }) => {
return (
<Combobox
id={`${id}-type`}
options={typeOptions}
value={value || CorrelationType.ExploreQuery}
onChange={(option) => onChange(option?.value || CorrelationType.ExploreQuery)}
/>
);
}}
/>
</Field>
{selectedType === CorrelationType.Link && (
<Field
noMargin
label={t('explore.correlation-form-information.label-url', 'URL')}
description={t(
'explore.correlation-form-information.url-description',
'Specify the URL that will open when the link is clicked'
)}
htmlFor={`${id}-url`}
>
<Input
{...register('url')}
id={`${id}-url`}
placeholder={t('explore.correlation-form-information.url-placeholder', 'https://example.com')}
/>
</Field>
)}
<Field noMargin label={t('explore.correlation-form-information.label-name', 'Name')} htmlFor={`${id}-label`}>
<Input
{...register('label')}
id={`${id}-label`}
onBlur={() => {
if (getValues('label') === '' && defaultLabel !== undefined) {
setValue('label', defaultLabel);
}
}}
/>
</Field>
<Field
noMargin
label={t('explore.correlation-form-information.label-description', 'Description')}
htmlFor={`${id}-description`}
>
<Input {...register('description')} id={`${id}-description`} />
</Field>
</FormSection>
);
};

View File

@@ -0,0 +1,220 @@
import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useAsync } from 'react-use';
import { DataLinkTransformationConfig, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Icon, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { getTransformationVars } from '../../../correlations/transformations';
import { generateDefaultLabel } from '../../../correlations/utils';
import { changeCorrelationHelperData } from '../../state/explorePane';
import { changeCorrelationEditorDetails } from '../../state/main';
import { selectCorrelationDetails, selectPanes } from '../../state/selectors';
import { CorrelationTransformationAddModal } from '../CorrelationTransformationAddModal';
import { CorrelationHelperProps, CorrelationType, FormValues, TransformationHandlers } from '../types';
import { CorrelationFormCustomVariables } from './CorrelationFormCustomVariables';
import { CorrelationFormInformation } from './CorrelationFormInformation';
export const CorrelationHelper = ({ exploreId, correlations }: CorrelationHelperProps) => {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const panes = useSelector(selectPanes);
const panesVals = Object.values(panes);
const { value: defaultLabel, loading: loadingLabel } = useAsync(
async () => await generateDefaultLabel(panesVals[0]!, panesVals[1]!),
[
panesVals[0]?.datasourceInstance,
panesVals[0]?.queries[0].datasource,
panesVals[1]?.datasourceInstance,
panesVals[1]?.queries[0].datasource,
]
);
const { control, register, watch, getValues, setValue } = useForm<FormValues>({
defaultValues: { type: CorrelationType.ExploreQuery },
});
const selectedType = useWatch({ control, name: 'type' });
const [showTransformationAddModal, setShowTransformationAddModal] = useState(false);
const [transformations, setTransformations] = useState<DataLinkTransformationConfig[]>([]);
const [transformationIdxToEdit, setTransformationIdxToEdit] = useState<number | undefined>(undefined);
const correlationDetails = useSelector(selectCorrelationDetails);
const transformationHandlers: TransformationHandlers = {
onEdit: (index: number) => {
setTransformationIdxToEdit(index);
setShowTransformationAddModal(true);
},
onDelete: (index: number) => {
setTransformations((prev) => prev.filter((_, idx) => idx !== index));
},
onAdd: () => {
setShowTransformationAddModal(true);
},
onModalCancel: () => {
setTransformationIdxToEdit(undefined);
setShowTransformationAddModal(false);
},
onModalSave: (transformation: DataLinkTransformationConfig) => {
if (transformationIdxToEdit !== undefined) {
const editTransformations = [...transformations];
editTransformations[transformationIdxToEdit] = transformation;
setTransformations(editTransformations);
setTransformationIdxToEdit(undefined);
} else {
setTransformations([...transformations, transformation]);
}
setShowTransformationAddModal(false);
},
};
// 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]);
useEffect(() => {
if (
!loadingLabel &&
defaultLabel !== undefined &&
!correlationDetails?.correlationDirty &&
getValues('label') !== ''
) {
setValue('label', defaultLabel);
}
}, [correlationDetails?.correlationDirty, defaultLabel, getValues, loadingLabel, setValue]);
useEffect(() => {
const subscription = watch((value) => {
let dirty = correlationDetails?.correlationDirty || false;
let description = value.description || '';
if (!dirty && (value.label !== defaultLabel || description !== '')) {
dirty = true;
} else if (dirty && value.label === defaultLabel && description.trim() === '') {
dirty = false;
}
dispatch(
changeCorrelationEditorDetails({ label: value.label, description: value.description, correlationDirty: dirty })
);
});
return () => subscription.unsubscribe();
}, [correlationDetails?.correlationDirty, defaultLabel, dispatch, watch]);
useEffect(() => {
const dirty =
!correlationDetails?.correlationDirty && transformations.length > 0 ? true : correlationDetails?.correlationDirty;
dispatch(changeCorrelationEditorDetails({ transformations: transformations, correlationDirty: dirty }));
let transVarRecords: Record<string, string> = {};
transformations.forEach((transformation) => {
const transformationVars = getTransformationVars(
{
type: transformation.type,
expression: transformation.expression,
mapValue: transformation.mapValue,
},
correlations.vars[transformation.field!],
transformation.field!
);
Object.keys(transformationVars).forEach((key) => {
transVarRecords[key] = transformationVars[key]?.value;
});
});
dispatch(
changeCorrelationHelperData({
exploreId: exploreId,
correlationEditorHelperData: {
resultField: correlations.resultField,
origVars: correlations.origVars,
vars: { ...correlations.origVars, ...transVarRecords },
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, transformations]);
return (
<>
<Stack direction="column" gap={1.5}>
<div className={styles.infoBox}>
<Stack direction="row" gap={1} alignItems="flex-start">
<Icon name="info-circle" size="sm" className={styles.infoIcon} />
<Text variant="body" color="secondary">
{selectedType === CorrelationType.Link ? (
<Trans
i18nKey="explore.correlation-helper.body-correlation-details-link"
values={{ resultField: correlations.resultField }}
>
When saved, the <code>{'{{resultField}}'}</code> field will have a clickable link that opens the
specified URL.
</Trans>
) : (
<Trans
i18nKey="explore.correlation-helper.body-correlation-details-query"
values={{ resultField: correlations.resultField }}
>
When saved, the <code>{'{{resultField}}'}</code> field will have a clickable link that runs your
target query below.
</Trans>
)}
</Text>
</Stack>
</div>
<CorrelationFormInformation
control={control}
register={register}
getValues={getValues}
setValue={setValue}
defaultLabel={defaultLabel}
selectedType={selectedType}
/>
{selectedType === CorrelationType.ExploreQuery && (
<CorrelationFormCustomVariables
correlations={correlations}
transformations={transformations}
handlers={transformationHandlers}
/>
)}
</Stack>
<div className={styles.divider} />
{showTransformationAddModal && selectedType === CorrelationType.ExploreQuery && (
<CorrelationTransformationAddModal
onCancel={transformationHandlers.onModalCancel}
onSave={transformationHandlers.onModalSave}
fieldList={correlations.origVars}
transformationToEdit={
transformationIdxToEdit !== undefined ? transformations[transformationIdxToEdit] : undefined
}
/>
)}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
infoBox: css({
padding: theme.spacing(1.5),
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.weak}`,
}),
infoIcon: css({
color: theme.colors.info.text,
marginTop: theme.spacing(0.25),
}),
divider: css({
height: '1px',
backgroundColor: theme.colors.border.weak,
margin: theme.spacing(2, 0),
}),
});

View File

@@ -0,0 +1,30 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, Text, useStyles2 } from '@grafana/ui';
import { FormSectionProps } from '../types';
export const FormSection = ({ title, children }: FormSectionProps) => {
const styles = useStyles2(getStyles);
return (
<Stack direction="column" gap={2}>
<Text variant="h5">{title}</Text>
<div className={styles.formFieldsWrapper}>
<Stack direction="column" gap={1.5}>
{children}
</Stack>
</div>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
formFieldsWrapper: css({
padding: theme.spacing(2),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});

View File

@@ -0,0 +1,279 @@
import { css } from '@emotion/css';
import { useId, useState, useMemo, useEffect } from 'react';
import Highlighter from 'react-highlight-words';
import { useForm, Controller } from 'react-hook-form';
import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, Icon, Input, Label, Modal, Select, Tooltip, Stack, Text } from '@grafana/ui';
import {
getSupportedTransTypeDetails,
getTransformOptions,
TransformationFieldDetails,
} from '../../correlations/Forms/types';
import { getTransformationVars } from '../../correlations/transformations';
interface CorrelationTransformationAddModalProps {
onCancel: () => void;
onSave: (transformation: DataLinkTransformationConfig) => void;
fieldList: Record<string, string>;
transformationToEdit?: DataLinkTransformationConfig;
}
interface ShowFormFields {
expressionDetails: TransformationFieldDetails;
mapValueDetails: TransformationFieldDetails;
}
const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText: string }) => (
<Stack gap={1} direction="row" wrap="wrap" alignItems="flex-start">
<Label>{label}</Label>
<Tooltip content={tooltipText}>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
);
export const CorrelationTransformationAddModal = ({
onSave,
onCancel,
fieldList,
transformationToEdit,
}: CorrelationTransformationAddModalProps) => {
const [exampleValue, setExampleValue] = useState<string | undefined>(undefined);
const [transformationVars, setTransformationVars] = useState<ScopedVars>({});
const [formFieldsVis, setFormFieldsVis] = useState<ShowFormFields>({
mapValueDetails: { show: false },
expressionDetails: { show: false },
});
const [isExpValid, setIsExpValid] = useState(false); // keep the highlighter from erroring on bad expressions
const [validToSave, setValidToSave] = useState(false);
const { getValues, control, register, watch } = useForm<DataLinkTransformationConfig>({
defaultValues: useMemo(() => {
if (transformationToEdit) {
const exampleVal = fieldList[transformationToEdit?.field!];
setExampleValue(exampleVal);
if (transformationToEdit?.expression) {
setIsExpValid(true);
}
const transformationTypeDetails = getSupportedTransTypeDetails(transformationToEdit?.type!);
setFormFieldsVis({
mapValueDetails: transformationTypeDetails.mapValueDetails,
expressionDetails: transformationTypeDetails.expressionDetails,
});
const transformationVars = getTransformationVars(
{
type: transformationToEdit?.type!,
expression: transformationToEdit?.expression,
mapValue: transformationToEdit?.mapValue,
},
exampleVal || '',
transformationToEdit?.field!
);
setTransformationVars({ ...transformationVars });
setValidToSave(true);
return {
type: transformationToEdit?.type,
field: transformationToEdit?.field,
mapValue: transformationToEdit?.mapValue,
expression: transformationToEdit?.expression,
};
} else {
return undefined;
}
}, [fieldList, transformationToEdit]),
});
const id = useId();
useEffect(() => {
const subscription = watch((formValues) => {
const expression = formValues.expression;
let isExpressionValid = false;
if (expression !== undefined) {
isExpressionValid = true;
try {
new RegExp(expression);
} catch (e) {
isExpressionValid = false;
}
} else {
isExpressionValid = !formFieldsVis.expressionDetails.show;
}
setIsExpValid(isExpressionValid);
let transKeys = [];
if (formValues.type) {
const transformationVars = getTransformationVars(
{
type: formValues.type,
expression: isExpressionValid ? expression : '',
mapValue: formValues.mapValue,
},
fieldList[formValues.field!] || '',
formValues.field!
);
transKeys = Object.keys(transformationVars);
setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {});
}
if (transKeys.length === 0 || !isExpressionValid) {
setValidToSave(false);
} else {
setValidToSave(true);
}
});
return () => subscription.unsubscribe();
}, [fieldList, formFieldsVis.expressionDetails.show, watch]);
return (
<Modal
isOpen={true}
title={
transformationToEdit
? t('explore.correlation-transformation-add-modal.title-edit-custom-variable', 'Edit custom variable')
: t('explore.correlation-transformation-add-modal.title-add-custom-variable', 'Add custom variable')
}
onDismiss={onCancel}
className={css({ width: '700px' })}
>
<Stack direction="column" gap={2}>
<Field
noMargin
label={t('explore.correlation-transformation-add-modal.label-field', 'Field')}
description={t(
'explore.correlation-transformation-add-modal.description-field',
'Select the field from which to extract a value for your variable'
)}
>
<Controller
control={control}
render={({ field: { onChange, ref, ...field } }) => (
<Select
{...field}
onChange={(value) => {
if (value.value) {
onChange(value.value);
setExampleValue(fieldList[value.value]);
}
}}
options={Object.entries(fieldList).map((entry) => {
return { label: entry[0], value: entry[0] };
})}
aria-label={t('explore.correlation-transformation-add-modal.aria-label-field', 'Field')}
/>
)}
name={`field` as const}
/>
</Field>
<Field noMargin label={t('explore.correlation-transformation-add-modal.label-type', 'Type')}>
<Controller
control={control}
render={({ field: { onChange, ref, ...field } }) => (
<Select
{...field}
onChange={(value) => {
onChange(value.value);
const transformationTypeDetails = getSupportedTransTypeDetails(value.value!);
setFormFieldsVis({
mapValueDetails: transformationTypeDetails.mapValueDetails,
expressionDetails: transformationTypeDetails.expressionDetails,
});
}}
options={getTransformOptions()}
aria-label={t('explore.correlation-transformation-add-modal.aria-label-type', 'Type')}
/>
)}
name={`type` as const}
/>
</Field>
{exampleValue && (
<>
{formFieldsVis.mapValueDetails.show && (
<Field
noMargin
label={
formFieldsVis.mapValueDetails.helpText ? (
<LabelWithTooltip
label={t('explore.correlation-transformation-add-modal.label-variable-name', 'Variable name')}
tooltipText={formFieldsVis.mapValueDetails.helpText}
/>
) : (
t(
'explore.correlation-transformation-add-modal.label-variable-name-without-tooltip',
'Variable name'
)
)
}
htmlFor={`${id}-mapValue`}
>
<Input {...register('mapValue')} id={`${id}-mapValue`} />
</Field>
)}
{formFieldsVis.expressionDetails.show && (
<Field
noMargin
label={
formFieldsVis.expressionDetails.helpText ? (
<LabelWithTooltip
label={t('explore.correlation-transformation-add-modal.label-expression', 'Expression')}
tooltipText={formFieldsVis.expressionDetails.helpText}
/>
) : (
t('explore.correlation-transformation-add-modal.label-expression-without-tooltip', 'Expression')
)
}
htmlFor={`${id}-expression`}
required={formFieldsVis.expressionDetails.required}
>
<Input {...register('expression')} id={`${id}-expression`} />
</Field>
)}
<Stack gap={1} direction="column">
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-transformation-add-modal.example-value">
Example value for your variable:
</Trans>
</Text>
<pre>
<Highlighter
textToHighlight={exampleValue}
searchWords={[isExpValid ? (getValues('expression') ?? '') : '']}
autoEscape={false}
/>
</pre>
</Stack>
{Object.entries(transformationVars).length > 0 && (
<>
<Trans i18nKey="explore.correlation-transformation-add-modal.added-variables">
This custom variable will add the following variables:
</Trans>
<pre>
{Object.entries(transformationVars).map((entry) => {
return `\$\{${entry[0]}\} = ${entry[1]?.value}\n`;
})}
</pre>
</>
)}
</>
)}
<Modal.ButtonRow>
<Button variant="secondary" onClick={onCancel} fill="outline">
<Trans i18nKey="explore.correlation-transformation-add-modal.cancel">Cancel</Trans>
</Button>
<Button variant="primary" onClick={() => onSave(getValues())} disabled={!validToSave}>
{transformationToEdit
? t('explore.correlation-transformation-add-modal.edit-transformation', 'Edit transformation')
: t(
'explore.correlation-transformation-add-modal.add-transformation',
'Add transformation to correlation'
)}
</Button>
</Modal.ButtonRow>
</Stack>
</Modal>
);
};

View File

@@ -10,16 +10,16 @@ import { Prompt } from 'app/core/components/FormPrompt/Prompt';
import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState } from 'app/types/explore';
import { useDispatch, useSelector } from 'app/types/store';
import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal';
import { showModalMessage } from './correlationEditLogic';
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, selectIsHelperShowing } from './state/selectors';
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, selectIsHelperShowing } from '../../state/selectors';
import { CorrelationUnsavedChangesModal } from '../CorrelationUnsavedChangesModal';
import { showModalMessage } from '../correlationEditLogic';
export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => {
export const CorrelationEditorModeBarLegacy = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const correlationDetails = useSelector(selectCorrelationDetails);

View File

@@ -21,13 +21,13 @@ import {
} from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { getTransformationVars } from '../correlations/transformations';
import { generateDefaultLabel } from '../correlations/utils';
import { getTransformationVars } from '../../../correlations/transformations';
import { generateDefaultLabel } from '../../../correlations/utils';
import { changeCorrelationHelperData } from '../../state/explorePane';
import { changeCorrelationEditorDetails } from '../../state/main';
import { selectCorrelationDetails, selectPanes } from '../../state/selectors';
import { CorrelationTransformationAddModal } from './CorrelationTransformationAddModal';
import { changeCorrelationHelperData } from './state/explorePane';
import { changeCorrelationEditorDetails } from './state/main';
import { selectCorrelationDetails, selectPanes } from './state/selectors';
import { CorrelationTransformationAddModalLegacy } from './CorrelationTransformationAddModalLegacy';
interface Props {
exploreId: string;
@@ -39,7 +39,7 @@ interface FormValues {
description: string;
}
export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
export const CorrelationHelperLegacy = ({ exploreId, correlations }: Props) => {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const panes = useSelector(selectPanes);
@@ -135,7 +135,7 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
return (
<>
{showTransformationAddModal && (
<CorrelationTransformationAddModal
<CorrelationTransformationAddModalLegacy
onCancel={() => {
setTransformationIdxToEdit(undefined);
setShowTransformationAddModal(false);
@@ -184,7 +184,7 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
</Stack>
}
>
<Field label={t('explore.correlation-helper.label-label', 'Label')} htmlFor={`${id}-label`}>
<Field noMargin label={t('explore.correlation-helper.label-label', 'Label')} htmlFor={`${id}-label`}>
<Input
{...register('label')}
id={`${id}-label`}
@@ -195,7 +195,11 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
}}
/>
</Field>
<Field label={t('explore.correlation-helper.label-description', 'Description')} htmlFor={`${id}-description`}>
<Field
noMargin
label={t('explore.correlation-helper.label-description', 'Description')}
htmlFor={`${id}-description`}
>
<Input {...register('description')} id={`${id}-description`} />
</Field>
</Collapse>

View File

@@ -11,8 +11,8 @@ import {
getSupportedTransTypeDetails,
getTransformOptions,
TransformationFieldDetails,
} from '../correlations/Forms/types';
import { getTransformationVars } from '../correlations/transformations';
} from '../../../correlations/Forms/types';
import { getTransformationVars } from '../../../correlations/transformations';
interface CorrelationTransformationAddModalProps {
onCancel: () => void;
@@ -35,7 +35,7 @@ const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText:
</Stack>
);
export const CorrelationTransformationAddModal = ({
export const CorrelationTransformationAddModalLegacy = ({
onSave,
onCancel,
fieldList,
@@ -144,7 +144,7 @@ export const CorrelationTransformationAddModal = ({
field variables.
</Trans>
</p>
<Field label={t('explore.correlation-transformation-add-modal.label-field', 'Field')}>
<Field noMargin label={t('explore.correlation-transformation-add-modal.label-field', 'Field')}>
<Controller
control={control}
render={({ field: { onChange, ref, ...field } }) => (
@@ -175,7 +175,7 @@ export const CorrelationTransformationAddModal = ({
autoEscape={false}
/>
</pre>
<Field label={t('explore.correlation-transformation-add-modal.label-type', 'Type')}>
<Field noMargin label={t('explore.correlation-transformation-add-modal.label-type', 'Type')}>
<Controller
control={control}
render={({ field: { onChange, ref, ...field } }) => (
@@ -198,6 +198,7 @@ export const CorrelationTransformationAddModal = ({
</Field>
{formFieldsVis.expressionDetails.show && (
<Field
noMargin
label={
formFieldsVis.expressionDetails.helpText ? (
<LabelWithTooltip
@@ -216,6 +217,7 @@ export const CorrelationTransformationAddModal = ({
)}
{formFieldsVis.mapValueDetails.show && (
<Field
noMargin
label={
formFieldsVis.mapValueDetails.helpText ? (
<LabelWithTooltip

View File

@@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import { Control, UseFormGetValues, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { DataLinkTransformationConfig, ExploreCorrelationHelperData } from '@grafana/data';
export enum CorrelationType {
ExploreQuery = 'Explore Query',
Link = 'Link',
}
export interface CorrelationHelperProps {
exploreId: string;
correlations: ExploreCorrelationHelperData;
}
export interface FormValues {
type: CorrelationType;
label: string;
description: string;
url?: string;
}
export interface TransformationHandlers {
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onAdd: () => void;
onModalCancel: () => void;
onModalSave: (transformation: DataLinkTransformationConfig) => void;
}
export interface CorrelationFormInformationProps {
control: Control<FormValues>;
register: UseFormRegister<FormValues>;
getValues: UseFormGetValues<FormValues>;
setValue: UseFormSetValue<FormValues>;
defaultLabel: string | undefined;
selectedType: CorrelationType;
}
export interface CorrelationFormCustomVariablesProps {
correlations: ExploreCorrelationHelperData;
transformations: DataLinkTransformationConfig[];
handlers: TransformationHandlers;
}
export interface FormSectionProps {
title: JSX.Element;
children: ReactNode;
}

View File

@@ -20,7 +20,7 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import {
AdHocFilterItem,
@@ -40,7 +40,8 @@ import { getTimeZone } from '../profile/state/selectors';
import { CONTENT_OUTLINE_LOCAL_STORAGE_KEYS, ContentOutline } from './ContentOutline/ContentOutline';
import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext';
import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem';
import { CorrelationHelper } from './CorrelationHelper';
import { CorrelationHelper } from './CorrelationEditor/CorrelationHelper/CorrelationHelper';
import { CorrelationHelperLegacy } from './CorrelationEditor/Legacy/CorrelationHelperLegacy';
import { CustomContainer } from './CustomContainer';
import { ExploreToolbar } from './ExploreToolbar';
import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer';
@@ -596,6 +597,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
queryLibraryRef,
} = this.props;
const { contentOutlineVisible } = this.state;
const correlationsExploreEditor = config.featureToggles.correlationsExploreEditor;
const styles = getStyles(theme);
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
const richHistoryRowButtonHidden = !supportedFeatures().queryHistoryAvailable;
@@ -616,7 +618,11 @@ export class Explore extends PureComponent<Props, ExploreState> {
const isCorrelationsEditorMode = correlationEditorDetails?.editorMode;
const showCorrelationHelper = Boolean(isCorrelationsEditorMode || correlationEditorDetails?.correlationDirty);
if (showCorrelationHelper && correlationEditorHelperData !== undefined) {
correlationsBox = <CorrelationHelper exploreId={exploreId} correlations={correlationEditorHelperData} />;
correlationsBox = correlationsExploreEditor ? (
<CorrelationHelper exploreId={exploreId} correlations={correlationEditorHelperData} />
) : (
<CorrelationHelperLegacy exploreId={exploreId} correlations={correlationEditorHelperData} />
);
}
return (
@@ -625,7 +631,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
exploreId={exploreId}
onChangeTime={this.onChangeTime}
onContentOutlineToogle={this.onContentOutlineToogle}
isContentOutlineOpen={contentOutlineVisible}
isContentOutlineOpen={contentOutlineVisible && !showCorrelationHelper}
/>
<div
style={{
@@ -636,7 +642,11 @@ export class Explore extends PureComponent<Props, ExploreState> {
>
<div className={styles.wrapper}>
{contentOutlineVisible && !compact && (
<ContentOutline scroller={this.scrollElement} panelId={`content-outline-container-${exploreId}`} />
<ContentOutline
scroller={this.scrollElement}
panelId={`content-outline-container-${exploreId}`}
defaultCollapsed={showCorrelationHelper}
/>
)}
<ScrollContainer
data-testid={selectors.pages.Explore.General.scrollView}

View File

@@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { ErrorBoundaryAlert, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
@@ -11,7 +12,8 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ExploreQueryParams } from 'app/types/explore';
import { useSelector } from 'app/types/store';
import { CorrelationEditorModeBar } from './CorrelationEditorModeBar';
import { CorrelationEditorModeBar } from './CorrelationEditor/CorrelationEditorModeBar';
import { CorrelationEditorModeBarLegacy } from './CorrelationEditor/Legacy/CorrelationEditorModeBarLegacy';
import { ExploreActions } from './ExploreActions';
import { ExploreDrawer } from './ExploreDrawer';
import { ExplorePaneContainer } from './ExplorePaneContainer';
@@ -31,7 +33,8 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
}
function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
const styles = useStyles2(getStyles);
const correlationsExploreEditor = config.featureToggles.correlationsExploreEditor;
const styles = useStyles2(getStyles, correlationsExploreEditor);
const theme = useTheme2();
useTimeSrvFix();
useStateSync(props.queryParams);
@@ -71,7 +74,12 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
<Trans i18nKey="nav.explore.title" />
</h1>
<ExploreActions />
{showCorrelationEditorBar && <CorrelationEditorModeBar panes={panes} />}
{showCorrelationEditorBar &&
(correlationsExploreEditor ? (
<CorrelationEditorModeBar panes={panes} />
) : (
<CorrelationEditorModeBarLegacy panes={panes} />
))}
<SplitPaneWrapper
splitOrientation="vertical"
paneSize={widthCalc}
@@ -108,7 +116,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
);
}
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2, correlationsExploreEditor?: boolean) => {
return {
pageScrollbarWrapper: css({
width: '100%',
@@ -119,10 +127,10 @@ const getStyles = (theme: GrafanaTheme2) => {
overflow: 'hidden',
}),
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',
borderLeft: correlationsExploreEditor ? 'none' : `4px solid ${theme.colors.primary.main}`,
borderRight: correlationsExploreEditor ? 'none' : `4px solid ${theme.colors.primary.main}`,
borderBottom: correlationsExploreEditor ? 'none' : `4px solid ${theme.colors.primary.main}`,
}),
};
};

View File

@@ -6,7 +6,7 @@ import { shallowEqual } from 'react-redux';
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import {
defaultIntervals,
PageToolbar,
@@ -16,6 +16,8 @@ import {
ButtonGroup,
useStyles2,
Button,
Badge,
Tooltip,
} from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { contextSrv } from 'app/core/services/context_srv';
@@ -57,6 +59,10 @@ const getStyles = (theme: GrafanaTheme2, splitted: Boolean) => ({
marginRight: theme.spacing(0.5),
width: splitted && theme.spacing(6),
}),
badgeWrapper: css({
marginTop: theme.spacing(2),
marginLeft: theme.spacing(2),
}),
});
interface Props {
@@ -219,10 +225,46 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
<ShortLinkButtonMenu key="share" />,
];
const correlationsExploreEditor = config.featureToggles.correlationsExploreEditor;
const showBuilderIndicator = isCorrelationsEditorMode && !isLeftPane;
const showSourceIndicator = isCorrelationsEditorMode && isLeftPane && splitted;
return (
<div>
{refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
<AppChromeUpdate actions={navBarActions} />
{correlationsExploreEditor && (
<>
{showSourceIndicator && (
<div className={styles.badgeWrapper}>
<Tooltip
content={t(
'explore.toolbar.source-correlation-query',
'Run a query and click a correlation link to start building your correlation'
)}
>
<Badge color="blue" icon="arrow-from-right" text={t('explore.toolbar.source-query', 'Source query')} />
</Tooltip>
</div>
)}
{showBuilderIndicator && (
<div className={styles.badgeWrapper}>
<Tooltip
content={t(
'explore.toolbar.build-correlation-query',
'Build and test your correlation target query here'
)}
>
<Badge
color="orange"
icon="crosshair"
text={t('explore.toolbar.target-query-builder', 'Correlation')}
/>
</Tooltip>
</div>
)}
</>
)}
<PageToolbar
aria-label={t('explore.toolbar.aria-label', 'Explore toolbar')}
leftItems={[

View File

@@ -7122,30 +7122,84 @@
},
"correlation-editor-mode-bar": {
"content-correlations-editor-explore-experimental-feature": "Correlations editor in Explore is an experimental feature.",
"edit-mode-title": "Correlation Editor Mode",
"exit-correlation-editor": "Exit correlation editor",
"experimental": "Experimental",
"instructions": "Step 1: Run a query and click a table cell link or a <1></1> <strong>Correlate with</strong> button.",
"instructions-2": "Step 2: In the right pane (Correlation), build and test your correlation query.",
"instructions-3": "Step 3: Click Save to create the correlation.",
"save": "Save"
},
"correlation-form-information": {
"label-description": "Description",
"label-name": "Name",
"label-type": "Type",
"label-url": "URL",
"type-options": {
"label": {
"explore-query": "Explore Query",
"link": "Link"
}
},
"url-description": "Specify the URL that will open when the link is clicked",
"url-placeholder": "https://example.com"
},
"correlation-helper": {
"add-custom-variable": "Add custom variable",
"add-transformation": "Add transformation",
"aria-label-delete-transformation": "Delete transformation",
"aria-label-edit-transformation": "Edit transformation",
"body-correlation-details": "The correlation link will appear by the <1>{{resultField}}</1> field. You can use the following variables to set up your correlations:",
"body-correlation-details-link": "When saved, the <1>{{resultField}}</1> field will have a clickable link that opens the specified URL.",
"body-correlation-details-query": "When saved, the <1>{{resultField}}</1> field will have a clickable link that runs your target query below.",
"body-variables": "Use these variables in your target query. When a correlation link is clicked, each variable is filled in with its value from that row.",
"expression": "Expression: <1>{{expression}}</1>",
"label-description": "Description",
"label-description-header": "Label / Description",
"label-label": "Label",
"title-correlation-details": "Correlation details",
"title-correlation-info": "Correlation Info",
"title-variables": "Variables (optional)",
"tooltip-transformations": "A transformation extracts one or more variables out of a single field.",
"transformations": "Transformations"
},
"correlation-tour": {
"back": "Back",
"current-step": "Current step {{step}}",
"got-it": "Got it!",
"next": "Next",
"ready-body": "You now know the basics of creating correlations. Remember, you can exit the editor at any time by clicking the <strong>Exit correlation editor</strong> button.",
"ready-tip1": "Test your correlation query thoroughly before saving",
"ready-tip2": "Use clear, descriptive names so other users understand the link",
"ready-tip3": "Custom variables let you extract specific parts of field values",
"ready-tips-title": "Quick Tips:",
"ready-title": "You're All Set!",
"skip": "Skip tour",
"step-counter": "Step {{current}} of {{total}}",
"step-number": "Step {{step}}",
"step1-body": "Run a query that returns data. You can then click a link in a table cell, or use the <2></2> <strong>Correlate with [field name]</strong> button to start creating a correlation.",
"step1-tip": "Tip: Look for these correlation links in table cells or log lines",
"step1-title": "Step 1: Run a Query and Click a Link",
"step2-body": "After clicking a correlation link, the <strong>right pane</strong> (target) opens with a query editor. Build and test your query here.",
"step2-title": "Step 2: Build Your Target Query",
"step2-variables": "Available variables are shown in the \"Variables\" section below. You can also create custom variables by extracting parts of fields using regular expressions or logfmt.",
"step3-body": "Once your query works correctly, click the <strong>Save</strong> button. Give your correlation a name and optionally add a description.",
"step3-result": "After saving, this correlation link will appear for all users in the same field across all queries from your source data source!",
"step3-title": "Step 3: Save Your Correlation",
"welcome-body": "The Correlation Editor helps you create clickable links between different data sources in Grafana. This makes it easy to jump from one view to another with context preserved.",
"welcome-example": "For example, you can click a service name in your logs and automatically open a dashboard showing metrics for that service.",
"welcome-title": "Welcome to the Correlation Editor"
},
"correlation-transformation-add-modal": {
"add-transformation": "Add transformation to correlation",
"added-variables": "This transformation will add the following variables:",
"added-variables": "This custom variable will add the following variables:",
"aria-label-field": "Field",
"aria-label-type": "Type",
"body": "A transformation extracts variables out of a single field. These variables will be available along with your field variables.",
"cancel": "Cancel",
"description-field": "Select the field from which to extract a value for your variable",
"edit-transformation": "Edit transformation",
"example-value": "Example value for your variable:",
"label-expression": "Expression",
"label-expression-without-tooltip": "Expression",
"label-field": "Field",
@@ -7153,7 +7207,9 @@
"label-variable-name": "Variable name",
"label-variable-name-without-tooltip": "Variable name",
"title-add": "Add transformation",
"title-edit": "Edit transformation"
"title-add-custom-variable": "Add custom variable",
"title-edit": "Edit transformation",
"title-edit-custom-variable": "Edit custom variable"
},
"correlation-unsaved-changes-modal": {
"cancel": "Cancel",
@@ -7600,6 +7656,7 @@
"add-to-extensions": "Add",
"add-to-queryless-extensions": "Go queryless",
"aria-label": "Explore toolbar",
"build-correlation-query": "Build and test your correlation target query here",
"copy-link": "Copy URL",
"copy-link-abs-time": "Copy absolute URL",
"copy-links-absolute-category": "Time-sync URL links (share with time range intact)",
@@ -7610,12 +7667,15 @@
"copy-shortened-link-menu": "Open copy link options",
"refresh-picker-cancel": "Cancel",
"refresh-picker-run": "Run query",
"source-correlation-query": "Run a query and click a correlation link to start building your correlation",
"source-query": "Source query",
"split-close": "Close",
"split-close-tooltip": "Close split pane",
"split-narrow": "Narrow pane",
"split-title": "Split",
"split-tooltip": "Split the pane",
"split-widen": "Widen pane"
"split-widen": "Widen pane",
"target-query-builder": "Correlation"
},
"trace-page-header": {
"aria-label-share-dropdown": "Open share trace options menu",