chore: lots of fixes and enhancements

This commit is contained in:
Alex Spencer
2025-11-24 19:21:32 -08:00
parent eaa5762778
commit bf1a8ab7c8
18 changed files with 1192 additions and 693 deletions
@@ -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) => {
@@ -3,25 +3,28 @@ import { useBeforeUnload, useUnmount } from 'react-use';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Alert, Badge, Button, Stack, Text } from '@grafana/ui';
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';
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';
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?');
@@ -189,6 +192,9 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl
}}
/>
{/* Show tour for first-time users */}
{shouldShowTour && <CorrelationEditorTour onDismiss={dismissTour} />}
{saveMessage !== undefined && (
<CorrelationUnsavedChangesModal
onDiscard={() => {
@@ -246,13 +252,13 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl
<Stack gap={0} direction="column">
<Text variant="bodySmall">
<Trans i18nKey="explore.correlation-editor-mode-bar.instructions">
Step 1: In the left pane, run a query and click a table cell link or a &quot;🔗 Correlate with&quot;
button.
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 (Target Query Builder), build and test your correlation query.
Step 2: In the right pane (Correlation), build and test your correlation query.
</Trans>
</Text>
<Text variant="bodySmall">
@@ -0,0 +1,302 @@
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}>
<Stack direction="column" gap={2}>
<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">
{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>
</Stack>
<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) => ({
contentVisible: css({
opacity: 1,
[theme.transitions.handleMotion('no-preference')]: {
transform: 'translateY(0)',
transition: 'opacity 0.2s ease, transform 0.2s ease',
},
}),
contentTransitioning: css({
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',
},
}),
});
@@ -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',
}),
});
@@ -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>
);
};
@@ -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),
}),
});
@@ -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,
}),
});
@@ -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', 'Edit custom variable')
: t('explore.correlation-transformation-add-modal.title-add', '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>
);
};
@@ -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;
}
@@ -1,386 +0,0 @@
import { css } from '@emotion/css';
import { useEffect, useId, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useAsync } from 'react-use';
import { DataLinkTransformationConfig, ExploreCorrelationHelperData, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Alert,
Button,
Card,
Collapse,
DeleteButton,
Field,
Icon,
IconButton,
Input,
Select,
Stack,
Tooltip,
useStyles2,
} from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { getTransformationVars } from '../correlations/transformations';
import { generateDefaultLabel } from '../correlations/utils';
import { CorrelationTransformationAddModal } from './CorrelationTransformationAddModal';
import { changeCorrelationHelperData } from './state/explorePane';
import { changeCorrelationEditorDetails } from './state/main';
import { selectCorrelationDetails, selectPanes } from './state/selectors';
interface Props {
exploreId: string;
correlations: ExploreCorrelationHelperData;
}
interface FormValues {
type: string;
label: string;
description: string;
}
export const CorrelationHelper = ({ exploreId, correlations }: Props) => {
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: 'Link',
},
});
const [isLabelDescOpen, setIsLabelDescOpen] = useState(false);
const [isTransformOpen, setIsTransformOpen] = useState(false);
const [showTransformationAddModal, setShowTransformationAddModal] = useState(false);
const [transformations, setTransformations] = useState<DataLinkTransformationConfig[]>([]);
const [transformationIdxToEdit, setTransformationIdxToEdit] = useState<number | undefined>(undefined);
const correlationDetails = useSelector(selectCorrelationDetails);
const id = useId();
// 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 (
<>
{showTransformationAddModal && (
<CorrelationTransformationAddModal
onCancel={() => {
setTransformationIdxToEdit(undefined);
setShowTransformationAddModal(false);
}}
onSave={(transformation: DataLinkTransformationConfig) => {
if (transformationIdxToEdit !== undefined) {
const editTransformations = [...transformations];
editTransformations[transformationIdxToEdit] = transformation;
setTransformations(editTransformations);
setTransformationIdxToEdit(undefined);
} else {
setTransformations([...transformations, transformation]);
}
setShowTransformationAddModal(false);
}}
fieldList={correlations.origVars}
transformationToEdit={
transformationIdxToEdit !== undefined ? transformations[transformationIdxToEdit] : undefined
}
/>
)}
<Alert
className={styles.alertWrapper}
title={t('explore.correlation-helper.title-correlation-details', 'Correlation details')}
severity="info"
>
<div className={styles.alertContent}>
<div>
{/* <Trans
i18nKey="explore.correlation-helper.body-correlation-details"
values={{ resultField: correlations.resultField }}
> */}
<p>
When saved, the <code>{correlations.resultField}</code> field will have a clickable link that runs your target
query below.
</p>
{/* </Trans> */}
</div>
</div>
</Alert>
<Stack gap={1} direction="row" wrap="wrap" alignItems="center">
<p>General</p>
</Stack>
<Field label="Type" htmlFor={`${id}-type`}>
<Controller
name="type"
control={control}
render={({ field: { onChange, value, ...field } }) => {
const typeOptions: Array<SelectableValue<string>> = [
{ label: 'Link', value: 'Link' },
{ label: 'Explore Query', value: 'Explore Query' },
];
return (
<Select
{...field}
inputId={`${id}-type`}
options={typeOptions}
value={typeOptions.find((option) => option.value === value) || typeOptions[0]}
onChange={(option) => onChange(option?.value || 'Link')}
/>
);
}}
/>
</Field>
<Field label="Name" htmlFor={`${id}-label`}>
<Input
{...register('label')}
id={`${id}-label`}
onBlur={() => {
if (getValues('label') === '' && defaultLabel !== undefined) {
setValue('label', defaultLabel);
}
}}
/>
</Field>
<Field label="Description" htmlFor={`${id}-description`}>
<Input {...register('description')} id={`${id}-description`} />
</Field>
<Stack gap={1} direction="row" wrap="wrap" alignItems="center">
{/* <Trans i18nKey="explore.correlation-helper.transformations">Transformations</Trans> */}
Variables (optional)
<Tooltip
content={t(
'explore.correlation-helper.tooltip-transformations',
'A transformation extracts one or more variables out of a single field.'
)}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
<p>Use these variables in your target query. When a correlation link is clicked, each variable is filled in with its value from that row.</p>
<div className={styles.variableList}>
{Object.entries(correlations.vars).map(([name, value]) => (
<div key={name} className={styles.variableRow}>
<div className={styles.variableNameCell}>
<code className={styles.variableName}>${`{${name}}`}</code>
</div>
<div className={styles.variableValueCell}>
<Tooltip content={value} placement="auto-start">
<span className={styles.variableValue}>{value}</span>
</Tooltip>
</div>
</div>
))}
</div>
<Button
variant="secondary"
fill="outline"
onClick={() => {
setShowTransformationAddModal(true);
}}
className={styles.transformationAction}
>
{/* <Trans i18nKey="explore.correlation-helper.add-transformation">Add transformation</Trans> */}
Add custom variable
</Button>
{transformations.map((transformation, i) => {
const { type, field, expression, mapValue } = transformation;
const detailsString = [
(mapValue ?? '').length > 0 ? `Variable name: ${mapValue}` : undefined,
(expression ?? '').length > 0 ? (
<Trans i18nKey="explore.correlation-helper.expression" values={{ expression }}>
Expression: <code>{'{{expression}}'}</code>
</Trans>
) : undefined,
].filter((val) => val);
return (
<Card noMargin key={`trans-${i}`}>
<Card.Heading>
{field}: {type}
</Card.Heading>
{detailsString.length > 0 && (
<Card.Meta className={styles.transformationMeta}>{detailsString}</Card.Meta>
)}
<Card.SecondaryActions>
<IconButton
key="edit"
name="edit"
aria-label={t('explore.correlation-helper.aria-label-edit-transformation', 'Edit transformation')}
onClick={() => {
setTransformationIdxToEdit(i);
setShowTransformationAddModal(true);
}}
/>
<DeleteButton
aria-label={t(
'explore.correlation-helper.aria-label-delete-transformation',
'Delete transformation'
)}
onConfirm={() => setTransformations(transformations.filter((_, idx) => i !== idx))}
closeOnConfirm
/>
</Card.SecondaryActions>
</Card>
);
})}
<hr />
</>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
labelCollapseDetails: css({
marginLeft: theme.spacing(2),
...theme.typography['bodySmall'],
fontStyle: 'italic',
}),
transformationAction: css({
marginBottom: theme.spacing(2),
}),
transformationMeta: css({
alignItems: 'baseline',
}),
alertWrapper: css({
'& > div': {
minWidth: 0,
maxWidth: '100%',
overflow: 'hidden',
},
}),
alertContent: css({
minWidth: 0,
maxWidth: '100%',
overflow: 'hidden',
width: '100%',
}),
variableList: css({
marginTop: theme.spacing(1.5),
marginBottom: theme.spacing(2),
display: 'table',
width: '100%',
tableLayout: 'auto',
borderCollapse: 'collapse',
}),
variableRow: css({
display: 'table-row',
}),
variableNameCell: css({
display: 'table-cell',
width: '1%',
paddingBottom: theme.spacing(0.5),
paddingRight: theme.spacing(1),
verticalAlign: 'top',
whiteSpace: 'nowrap',
}),
variableName: css({
display: 'inline-block',
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',
}),
variableValueCell: css({
display: 'table-cell',
width: '100%',
maxWidth: 0,
paddingBottom: theme.spacing(0.5),
verticalAlign: 'top',
}),
variableValue: css({
display: 'inline-block',
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,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'top',
}),
};
};
@@ -1,260 +0,0 @@
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 } 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', 'Edit transformation')
: t('explore.correlation-transformation-add-modal.title-add', 'Add transformation')
}
onDismiss={onCancel}
className={css({ width: '700px' })}
>
<p>
<Trans i18nKey="explore.correlation-transformation-add-modal.body">
A transformation extracts variables out of a single field. These variables will be available along with your
field variables.
</Trans>
</p>
<Field label={t('explore.correlation-transformation-add-modal.label-field', 'Field')}>
<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>
{exampleValue && (
<>
<pre>
<Highlighter
textToHighlight={exampleValue}
searchWords={[isExpValid ? (getValues('expression') ?? '') : '']}
autoEscape={false}
/>
</pre>
<Field 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>
{formFieldsVis.expressionDetails.show && (
<Field
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>
)}
{formFieldsVis.mapValueDetails.show && (
<Field
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>
)}
{Object.entries(transformationVars).length > 0 && (
<>
<Trans i18nKey="explore.correlation-transformation-add-modal.added-variables">
This transformation 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>
</Modal>
);
};
+7 -3
View File
@@ -40,7 +40,7 @@ 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 { CustomContainer } from './CustomContainer';
import { ExploreToolbar } from './ExploreToolbar';
import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer';
@@ -626,7 +626,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
exploreId={exploreId}
onChangeTime={this.onChangeTime}
onContentOutlineToogle={this.onContentOutlineToogle}
isContentOutlineOpen={contentOutlineVisible}
isContentOutlineOpen={contentOutlineVisible && !showCorrelationHelper}
/>
<div
style={{
@@ -637,7 +637,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}
+1 -1
View File
@@ -12,7 +12,7 @@ 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 { ExploreActions } from './ExploreActions';
import { ExploreDrawer } from './ExploreDrawer';
import { ExplorePaneContainer } from './ExplorePaneContainer';
@@ -249,7 +249,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
<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', 'Target query')} />
<Badge color="orange" icon="crosshair" text={t('explore.toolbar.target-query-builder', 'Correlation')} />
</Tooltip>
</div>
)}
+62 -14
View File
@@ -7010,38 +7010,82 @@
},
"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 \"🔗 Correlate with\" 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-transformation": "Add transformation",
"add-custom-variable": "Add custom variable",
"aria-label-delete-transformation": "Delete transformation",
"aria-label-edit-transformation": "Edit transformation",
"body-correlation-details": "When saved, the <1>{{resultField}}</1> field will have a clickable link that runs your target query below. Use the below variables in your query. When clicked, they're replaced with values from that row.",
"expression": "Expression: <1>{{expression}}</1>",
"label-description": "Description",
"label-description-header": "Correlation Label / Description",
"label-label": "Label",
"title-correlation-details": "Correlation details",
"tooltip-transformations": "A transformation extracts one or more variables out of a single field.",
"transformations": "Transformations"
"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.",
"title-correlation-info": "Correlation Info",
"title-variables": "Variables (optional)"
},
"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 \"🔗 Correlate with [field name]\" 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",
"label-type": "Type",
"label-variable-name": "Variable name",
"label-variable-name-without-tooltip": "Variable name",
"title-add": "Add transformation",
"title-edit": "Edit transformation"
"title-add": "Add custom variable",
"title-edit": "Edit custom variable"
},
"correlation-unsaved-changes-modal": {
"cancel": "Cancel",
@@ -7487,6 +7531,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)",
@@ -7497,12 +7542,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",