mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Correlations: Add transformation editor (#66217)
* There was an attempt * Change disabled state based on transformation type * Add validation to transformation type * Revert "Add validation to transformation type" This reverts commit 2188a3d9a93aec5eeafcdd40510391ba1a53671a. * Add validation to transformation type * Move transformations editor to a separate file * Make name more descriptive * Ensure type dropdown has always the same width * Add tooltips around transformation options * Slight style changes * Remove autofocus on append, integrate read only to transformationeditor, save values that disappear so they come back * Remove yaml changes * Have variable background color work with alternating colors on different themes * Make expression required for regular expressions * Remove unused empty form object * Fix bug about transformation’s values saved in memory * Better validation formatting for expression * Add labels and (for now) non working test, attempt to fix saved transformation delete/add bug * Fix datalink comment * Remove fancy CSS due to background change * Fix deleting saved transformation bug, finish tests * Consolidate transformation types * Double check aria labels * Change aria labels, fix tests * Add a transformation with the create correlation test --------- Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
@@ -52,15 +52,20 @@ export interface DataLink<T extends DataQuery = any> {
|
|||||||
origin?: DataLinkConfigOrigin;
|
origin?: DataLinkConfigOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/**
|
||||||
export enum SupportedTransformationTypes {
|
* We provide tooltips with information about these to guide the user, please
|
||||||
|
* check for validity when adding more transformation types.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export enum SupportedTransformationType {
|
||||||
Regex = 'regex',
|
Regex = 'regex',
|
||||||
Logfmt = 'logfmt',
|
Logfmt = 'logfmt',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface DataLinkTransformationConfig {
|
export interface DataLinkTransformationConfig {
|
||||||
type: SupportedTransformationTypes;
|
type: SupportedTransformationType;
|
||||||
field?: string;
|
field?: string;
|
||||||
expression?: string;
|
expression?: string;
|
||||||
mapValue?: string;
|
mapValue?: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UseFormReturn, FieldValues, FieldErrors } from 'react-hook-form';
|
import { UseFormReturn, FieldValues, FieldErrors, FieldArrayMethodProps } from 'react-hook-form';
|
||||||
export type { SubmitHandler as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form';
|
export type { SubmitHandler as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form';
|
||||||
|
|
||||||
export type FormAPI<T extends FieldValues> = Omit<UseFormReturn<T>, 'trigger' | 'handleSubmit'> & {
|
export type FormAPI<T extends FieldValues> = Omit<UseFormReturn<T>, 'trigger' | 'handleSubmit'> & {
|
||||||
@@ -9,7 +9,7 @@ type FieldArrayValue = Partial<FieldValues> | Array<Partial<FieldValues>>;
|
|||||||
|
|
||||||
export interface FieldArrayApi {
|
export interface FieldArrayApi {
|
||||||
fields: Array<Record<string, any>>;
|
fields: Array<Record<string, any>>;
|
||||||
append: (value: FieldArrayValue) => void;
|
append: (value: FieldArrayValue, options?: FieldArrayMethodProps) => void;
|
||||||
prepend: (value: FieldArrayValue) => void;
|
prepend: (value: FieldArrayValue) => void;
|
||||||
remove: (index?: number | number[]) => void;
|
remove: (index?: number | number[]) => void;
|
||||||
swap: (indexA: number, indexB: number) => void;
|
swap: (indexA: number, indexB: number) => void;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { render, waitFor, screen, fireEvent, within, Matcher, getByRole } from '
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { merge, uniqueId } from 'lodash';
|
import { merge, uniqueId } from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { openMenu } from 'react-select-event';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
|
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
|
||||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||||
|
|
||||||
import { DataSourcePluginMeta } from '@grafana/data';
|
import { DataSourcePluginMeta, SupportedTransformationType } from '@grafana/data';
|
||||||
import { BackendSrv, setDataSourceSrv, BackendSrvRequest, reportInteraction } from '@grafana/runtime';
|
import { BackendSrv, setDataSourceSrv, BackendSrvRequest, reportInteraction } from '@grafana/runtime';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
@@ -274,6 +275,14 @@ describe('CorrelationsPage', () => {
|
|||||||
|
|
||||||
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
|
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
|
||||||
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
|
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
|
||||||
|
|
||||||
|
// add transformation
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add transformation/i }));
|
||||||
|
const typeFilterSelect = screen.getAllByLabelText('Type');
|
||||||
|
openMenu(typeFilterSelect[0]);
|
||||||
|
await userEvent.click(screen.getByText('Regular expression'));
|
||||||
|
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
|
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
|
||||||
|
|
||||||
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_added');
|
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_added');
|
||||||
@@ -342,7 +351,14 @@ describe('CorrelationsPage', () => {
|
|||||||
targetUID: 'loki',
|
targetUID: 'loki',
|
||||||
uid: '1',
|
uid: '1',
|
||||||
label: 'Some label',
|
label: 'Some label',
|
||||||
config: { field: 'line', target: {}, type: 'query' },
|
config: {
|
||||||
|
field: 'line',
|
||||||
|
target: {},
|
||||||
|
type: 'query',
|
||||||
|
transformations: [
|
||||||
|
{ type: SupportedTransformationType.Regex, expression: 'url=http[s]?://(S*)', mapValue: 'path' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceUID: 'prometheus',
|
sourceUID: 'prometheus',
|
||||||
@@ -472,12 +488,64 @@ describe('CorrelationsPage', () => {
|
|||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole('cell', { name: /edited label$/i })).toBeInTheDocument();
|
expect(await screen.findByRole('cell', { name: /edited label$/i })).toBeInTheDocument();
|
||||||
|
|
||||||
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_edited');
|
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_edited');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('correctly edits transformations', async () => {
|
||||||
|
// wait for table to appear
|
||||||
|
await screen.findByRole('table');
|
||||||
|
|
||||||
|
const tableRows = queryRowsByCellValue('Source', 'loki');
|
||||||
|
|
||||||
|
const rowExpanderButton = within(tableRows[0]).getByRole('button', { name: /toggle row expanded/i });
|
||||||
|
await userEvent.click(rowExpanderButton);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
|
|
||||||
|
// select Logfmt, be sure expression field is disabled
|
||||||
|
let typeFilterSelect = screen.getAllByLabelText('Type');
|
||||||
|
openMenu(typeFilterSelect[0]);
|
||||||
|
await userEvent.click(screen.getByText('Logfmt'));
|
||||||
|
|
||||||
|
let expressionInput = screen.queryByLabelText(/expression/i);
|
||||||
|
expect(expressionInput).toBeInTheDocument();
|
||||||
|
expect(expressionInput).toBeDisabled();
|
||||||
|
|
||||||
|
// select Regex, be sure expression field is not disabled and contains the former expression
|
||||||
|
openMenu(typeFilterSelect[0]);
|
||||||
|
await userEvent.click(screen.getByText('Regular expression', { selector: 'span' }));
|
||||||
|
expressionInput = screen.queryByLabelText(/expression/i);
|
||||||
|
expect(expressionInput).toBeInTheDocument();
|
||||||
|
expect(expressionInput).toBeEnabled();
|
||||||
|
expect(expressionInput).toHaveAttribute('value', 'url=http[s]?://(S*)');
|
||||||
|
|
||||||
|
// select Logfmt, delete, then add a new one to be sure the value is blank
|
||||||
|
openMenu(typeFilterSelect[0]);
|
||||||
|
await userEvent.click(screen.getByText('Logfmt'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /remove transformation/i }));
|
||||||
|
expressionInput = screen.queryByLabelText(/expression/i);
|
||||||
|
expect(expressionInput).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add transformation/i }));
|
||||||
|
typeFilterSelect = screen.getAllByLabelText('Type');
|
||||||
|
openMenu(typeFilterSelect[0]);
|
||||||
|
await userEvent.click(screen.getByText('Regular expression'));
|
||||||
|
expressionInput = screen.queryByLabelText(/expression/i);
|
||||||
|
expect(expressionInput).toBeInTheDocument();
|
||||||
|
expect(expressionInput).toBeEnabled();
|
||||||
|
expect(expressionInput).not.toHaveValue('url=http[s]?://(S*)');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||||
|
expect(screen.getByText('Please define an expression')).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||||
|
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_edited');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Read only correlations', () => {
|
describe('Read only correlations', () => {
|
||||||
@@ -487,7 +555,12 @@ describe('CorrelationsPage', () => {
|
|||||||
targetUID: 'loki',
|
targetUID: 'loki',
|
||||||
uid: '1',
|
uid: '1',
|
||||||
label: 'Some label',
|
label: 'Some label',
|
||||||
config: { field: 'line', target: {}, type: 'query' },
|
config: {
|
||||||
|
field: 'line',
|
||||||
|
target: {},
|
||||||
|
type: 'query',
|
||||||
|
transformations: [{ type: SupportedTransformationType.Regex, expression: '(?:msg)=' }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -532,6 +605,16 @@ describe('CorrelationsPage', () => {
|
|||||||
expect(descriptionInput).toBeInTheDocument();
|
expect(descriptionInput).toBeInTheDocument();
|
||||||
expect(descriptionInput).toHaveAttribute('readonly');
|
expect(descriptionInput).toHaveAttribute('readonly');
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||||
|
|
||||||
|
// expect the transformation to exist but be read only
|
||||||
|
const expressionInput = screen.queryByLabelText(/expression/i);
|
||||||
|
expect(expressionInput).toBeInTheDocument();
|
||||||
|
expect(expressionInput).toHaveAttribute('readonly');
|
||||||
|
expect(screen.queryByRole('button', { name: 'add transformation' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'remove transformation' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
// we don't expect the save button to be rendered
|
// we don't expect the save button to be rendered
|
||||||
expect(screen.queryByRole('button', { name: 'save' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'save' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
|||||||
|
|
||||||
import { getVariableUsageInfo } from '../../explore/utils/links';
|
import { getVariableUsageInfo } from '../../explore/utils/links';
|
||||||
|
|
||||||
|
import { TransformationsEditor } from './TransformationsEditor';
|
||||||
import { useCorrelationsFormContext } from './correlationsFormContext';
|
import { useCorrelationsFormContext } from './correlationsFormContext';
|
||||||
import { getInputId } from './utils';
|
import { getInputId } from './utils';
|
||||||
|
|
||||||
@@ -33,12 +34,11 @@ export const ConfigureCorrelationSourceForm = () => {
|
|||||||
const variables = getVariableUsageInfo(currentTargetQuery, {}).variables.map(
|
const variables = getVariableUsageInfo(currentTargetQuery, {}).variables.map(
|
||||||
(variable) => variable.variableName + (variable.fieldPath ? `.${variable.fieldPath}` : '')
|
(variable) => variable.variableName + (variable.fieldPath ? `.${variable.fieldPath}` : '')
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldSet label="Configure source data source (3/3)">
|
<FieldSet label="Configure source data source (3/3)">
|
||||||
<p>
|
<p>
|
||||||
Links are displayed with results of the selected origin source data. They shown along with the value of the
|
Links are displayed with results of the selected origin source data. They show along with the value of the
|
||||||
provided <em>results field</em>.
|
provided <em>results field</em>.
|
||||||
</p>
|
</p>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -84,27 +84,26 @@ export const ConfigureCorrelationSourceForm = () => {
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Heading>Variables used in the target query</Card.Heading>
|
<Card.Heading>Variables used in the target query</Card.Heading>
|
||||||
<Card.Description>
|
<Card.Description>
|
||||||
<div>
|
You have used following variables in the target query:{' '}
|
||||||
You have used following variables in the target query:{' '}
|
{variables.map((name, i) => (
|
||||||
{variables.map((name, i) => (
|
<span className={styles.variable} key={i}>
|
||||||
<span className={styles.variable} key={i}>
|
{name}
|
||||||
{name}
|
{i < variables.length - 1 ? ', ' : ''}
|
||||||
{i < variables.length - 1 ? ', ' : ''}
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
<br />A data point needs to provide values to all variables as fields or as transformations output to make
|
||||||
</div>
|
the correlation button appear in the visualization.
|
||||||
<div>
|
<br />
|
||||||
A data point needs to provide values to all variables as fields or as transformations output to make the
|
Note: Not every variable needs to be explicitly defined below. A transformation such as{' '}
|
||||||
correlation button appear in the visualization.
|
<span className={styles.variable}>logfmt</span> will create variables for every key/value pair.
|
||||||
</div>
|
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
<TransformationsEditor readOnly={readOnly} />
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
315
public/app/features/correlations/Forms/TransformationsEditor.tsx
Normal file
315
public/app/features/correlations/Forms/TransformationsEditor.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { compact, fill } from 'lodash';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, SupportedTransformationType } from '@grafana/data';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Field,
|
||||||
|
FieldArray,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputControl,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
Tooltip,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
|
||||||
|
type Props = { readOnly: boolean };
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
heading: css`
|
||||||
|
font-size: ${theme.typography.h5.fontSize};
|
||||||
|
font-weight: ${theme.typography.fontWeightRegular};
|
||||||
|
`,
|
||||||
|
// set fixed position from the top instead of centring as the container
|
||||||
|
// may get bigger when the for is invalid
|
||||||
|
removeButton: css`
|
||||||
|
margin-top: 25px;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TransformationsEditor = (props: Props) => {
|
||||||
|
const { control, formState, register, setValue, watch, getValues } = useFormContext();
|
||||||
|
const { readOnly } = props;
|
||||||
|
const [keptVals, setKeptVals] = useState<Array<{ expression?: string; mapValue?: string }>>([]);
|
||||||
|
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const transformOptions = getTransformOptions();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input type="hidden" {...register('id')} />
|
||||||
|
<FieldArray name="config.transformations" control={control}>
|
||||||
|
{({ fields, append, remove }) => (
|
||||||
|
<>
|
||||||
|
<Stack direction="column" alignItems="flex-start">
|
||||||
|
<div className={styles.heading}>Transformations</div>
|
||||||
|
{fields.length === 0 && <div> No transformations defined.</div>}
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{fields.map((fieldVal, index) => {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" key={fieldVal.id} alignItems="top">
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<Stack gap={0.5}>
|
||||||
|
<Label htmlFor={`config.transformations.${fieldVal.id}-${index}.type`}>Type</Label>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<p>The type of transformation that will be applied to the source data.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="info-circle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
invalid={!!formState.errors?.config?.transformations?.[index]?.type}
|
||||||
|
error={formState.errors?.config?.transformations?.[index]?.type?.message}
|
||||||
|
validationMessageHorizontalOverflow={true}
|
||||||
|
>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => {
|
||||||
|
// input control field is not manipulated with remove, use value from control
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={fieldVal.type}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!readOnly) {
|
||||||
|
const currentValues = getValues().config.transformations[index];
|
||||||
|
let keptValsCopy = fill(Array(index + 1), {});
|
||||||
|
keptVals.forEach((keptVal, i) => (keptValsCopy[i] = keptVal));
|
||||||
|
keptValsCopy[index] = {
|
||||||
|
expression: currentValues.expression,
|
||||||
|
mapValue: currentValues.mapValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
setKeptVals(keptValsCopy);
|
||||||
|
|
||||||
|
const newValueDetails = getSupportedTransTypeDetails(value.value);
|
||||||
|
|
||||||
|
if (newValueDetails.showExpression) {
|
||||||
|
setValue(
|
||||||
|
`config.transformations.${index}.expression`,
|
||||||
|
keptVals[index]?.expression || ''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setValue(`config.transformations.${index}.expression`, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValueDetails.showMapValue) {
|
||||||
|
setValue(
|
||||||
|
`config.transformations.${index}.mapValue`,
|
||||||
|
keptVals[index]?.mapValue || ''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setValue(`config.transformations.${index}.mapValue`, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(value.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={transformOptions}
|
||||||
|
width={25}
|
||||||
|
inputId={`config.transformations.${fieldVal.id}-${index}.type`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
control={control}
|
||||||
|
name={`config.transformations.${index}.type`}
|
||||||
|
rules={{ required: { value: true, message: 'Please select a transformation type' } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<Stack gap={0.5}>
|
||||||
|
<Label htmlFor={`config.transformations.${fieldVal.id}.field`}>Field</Label>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Optional. The field to transform. If not specified, the transformation will be
|
||||||
|
applied to the results field.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="info-circle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register(`config.transformations.${index}.field`)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
defaultValue={fieldVal.field}
|
||||||
|
label="field"
|
||||||
|
id={`config.transformations.${fieldVal.id}.field`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<Stack gap={0.5}>
|
||||||
|
<Label htmlFor={`config.transformations.${fieldVal.id}.expression`}>
|
||||||
|
Expression
|
||||||
|
{getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`))
|
||||||
|
.requireExpression
|
||||||
|
? ' *'
|
||||||
|
: ''}
|
||||||
|
</Label>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Required for regular expression. The expression the transformation will use.
|
||||||
|
Logfmt does not use further specifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="info-circle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
invalid={!!formState.errors?.config?.transformations?.[index]?.expression}
|
||||||
|
error={formState.errors?.config?.transformations?.[index]?.expression?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register(`config.transformations.${index}.expression`, {
|
||||||
|
required: getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`))
|
||||||
|
.requireExpression
|
||||||
|
? 'Please define an expression'
|
||||||
|
: undefined,
|
||||||
|
})}
|
||||||
|
defaultValue={fieldVal.expression}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={
|
||||||
|
!getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`))
|
||||||
|
.showExpression
|
||||||
|
}
|
||||||
|
id={`config.transformations.${fieldVal.id}.expression`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<Stack gap={0.5}>
|
||||||
|
<Label htmlFor={`config.transformations.${fieldVal.id}.mapValue`}>Map value</Label>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Optional. Defines the name of the variable. This is currently only valid for
|
||||||
|
regular expressions with a single, unnamed capture group.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="info-circle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register(`config.transformations.${index}.mapValue`)}
|
||||||
|
defaultValue={fieldVal.mapValue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={
|
||||||
|
!getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)).showMapValue
|
||||||
|
}
|
||||||
|
id={`config.transformations.${fieldVal.id}.mapValue`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className={styles.removeButton}>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
tooltip="Remove transformation"
|
||||||
|
name={'trash-alt'}
|
||||||
|
onClick={() => {
|
||||||
|
remove(index);
|
||||||
|
const keptValsCopy: Array<{ expression?: string; mapValue?: string } | undefined> = [
|
||||||
|
...keptVals,
|
||||||
|
];
|
||||||
|
keptValsCopy[index] = undefined;
|
||||||
|
setKeptVals(compact(keptValsCopy));
|
||||||
|
}}
|
||||||
|
ariaLabel="Remove transformation"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => append({ type: undefined }, { shouldFocus: false })}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Add transformation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SupportedTransformationTypeDetails {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
showExpression: boolean;
|
||||||
|
showMapValue: boolean;
|
||||||
|
requireExpression?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedTransTypeDetails(transType: SupportedTransformationType): SupportedTransformationTypeDetails {
|
||||||
|
switch (transType) {
|
||||||
|
case SupportedTransformationType.Logfmt:
|
||||||
|
return {
|
||||||
|
label: 'Logfmt',
|
||||||
|
value: SupportedTransformationType.Logfmt,
|
||||||
|
description: 'Parse provided field with logfmt to get variables',
|
||||||
|
showExpression: false,
|
||||||
|
showMapValue: false,
|
||||||
|
};
|
||||||
|
case SupportedTransformationType.Regex:
|
||||||
|
return {
|
||||||
|
label: 'Regular expression',
|
||||||
|
value: SupportedTransformationType.Regex,
|
||||||
|
description:
|
||||||
|
'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value.',
|
||||||
|
showExpression: true,
|
||||||
|
showMapValue: true,
|
||||||
|
requireExpression: true,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { label: transType, value: transType, showExpression: false, showMapValue: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransformOptions = () => {
|
||||||
|
return Object.values(SupportedTransformationType).map((transformationType) => {
|
||||||
|
const transType = getSupportedTransTypeDetails(transformationType);
|
||||||
|
return {
|
||||||
|
label: transType.label,
|
||||||
|
value: transType.value,
|
||||||
|
description: transType.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SupportedTransformationType } from '@grafana/data';
|
||||||
|
|
||||||
import { CorrelationConfig } from '../types';
|
import { CorrelationConfig } from '../types';
|
||||||
|
|
||||||
export interface FormDTO {
|
export interface FormDTO {
|
||||||
@@ -9,3 +11,9 @@ export interface FormDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EditFormDTO = Omit<FormDTO, 'targetUID' | 'sourceUID'>;
|
export type EditFormDTO = Omit<FormDTO, 'targetUID' | 'sourceUID'>;
|
||||||
|
|
||||||
|
export type TransformationDTO = {
|
||||||
|
type: SupportedTransformationType;
|
||||||
|
expression?: string;
|
||||||
|
mapValue?: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logfmt from 'logfmt';
|
import logfmt from 'logfmt';
|
||||||
|
|
||||||
import { ScopedVars, DataLinkTransformationConfig, SupportedTransformationTypes } from '@grafana/data';
|
import { ScopedVars, DataLinkTransformationConfig, SupportedTransformationType } from '@grafana/data';
|
||||||
import { safeStringifyValue } from 'app/core/utils/explore';
|
import { safeStringifyValue } from 'app/core/utils/explore';
|
||||||
|
|
||||||
export const getTransformationVars = (
|
export const getTransformationVars = (
|
||||||
@@ -10,7 +10,7 @@ export const getTransformationVars = (
|
|||||||
): ScopedVars => {
|
): ScopedVars => {
|
||||||
let transformationScopedVars: ScopedVars = {};
|
let transformationScopedVars: ScopedVars = {};
|
||||||
let transformVal: { [key: string]: string | boolean | null | undefined } = {};
|
let transformVal: { [key: string]: string | boolean | null | undefined } = {};
|
||||||
if (transformation.type === SupportedTransformationTypes.Regex && transformation.expression) {
|
if (transformation.type === SupportedTransformationType.Regex && transformation.expression) {
|
||||||
const regexp = new RegExp(transformation.expression, 'gi');
|
const regexp = new RegExp(transformation.expression, 'gi');
|
||||||
const matches = fieldValue.matchAll(regexp);
|
const matches = fieldValue.matchAll(regexp);
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
@@ -20,7 +20,7 @@ export const getTransformationVars = (
|
|||||||
transformVal[transformation.mapValue || fieldName] = match[1] || match[0];
|
transformVal[transformation.mapValue || fieldName] = match[1] || match[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (transformation.type === SupportedTransformationTypes.Logfmt) {
|
} else if (transformation.type === SupportedTransformationType.Logfmt) {
|
||||||
transformVal = logfmt.parse(fieldValue);
|
transformVal = logfmt.parse(fieldValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
FieldType,
|
FieldType,
|
||||||
InterpolateFunction,
|
InterpolateFunction,
|
||||||
SupportedTransformationTypes,
|
SupportedTransformationType,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
toDataFrame,
|
toDataFrame,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
@@ -285,8 +285,8 @@ describe('explore links utils', () => {
|
|||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [
|
transformations: [
|
||||||
{ type: SupportedTransformationTypes.Logfmt },
|
{ type: SupportedTransformationType.Logfmt },
|
||||||
{ type: SupportedTransformationTypes.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' },
|
{ type: SupportedTransformationType.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -334,8 +334,8 @@ describe('explore links utils', () => {
|
|||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [
|
transformations: [
|
||||||
{ type: SupportedTransformationTypes.Regex, expression: 'fieldA=(asparagus|broccoli)' },
|
{ type: SupportedTransformationType.Regex, expression: 'fieldA=(asparagus|broccoli)' },
|
||||||
{ type: SupportedTransformationTypes.Regex, expression: 'fieldB=(apple|banana)' },
|
{ type: SupportedTransformationType.Regex, expression: 'fieldB=(apple|banana)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -375,7 +375,7 @@ describe('explore links utils', () => {
|
|||||||
query: { query: 'http_requests{app=${application} isOnline=${online}}' },
|
query: { query: 'http_requests{app=${application} isOnline=${online}}' },
|
||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [{ type: SupportedTransformationTypes.Logfmt }],
|
transformations: [{ type: SupportedTransformationType.Logfmt }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ describe('explore links utils', () => {
|
|||||||
query: { query: 'http_requests{app=${application}}' },
|
query: { query: 'http_requests{app=${application}}' },
|
||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [{ type: SupportedTransformationTypes.Logfmt, field: 'fieldNamedInTransformation' }],
|
transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,7 +468,7 @@ describe('explore links utils', () => {
|
|||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [
|
transformations: [
|
||||||
{
|
{
|
||||||
type: SupportedTransformationTypes.Regex,
|
type: SupportedTransformationType.Regex,
|
||||||
expression: '(?=.*(?<application>(grafana|loki)))(?=.*(?<environment>(dev|prod)))',
|
expression: '(?=.*(?<application>(grafana|loki)))(?=.*(?<environment>(dev|prod)))',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -546,7 +546,7 @@ describe('explore links utils', () => {
|
|||||||
query: { query: 'http_requests{app=${application} env=${diffVar}}' },
|
query: { query: 'http_requests{app=${application} env=${diffVar}}' },
|
||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [{ type: SupportedTransformationTypes.Logfmt }],
|
transformations: [{ type: SupportedTransformationType.Logfmt }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,7 +571,7 @@ describe('explore links utils', () => {
|
|||||||
query: { query: 'http_requests{app=test}' },
|
query: { query: 'http_requests{app=test}' },
|
||||||
datasourceUid: 'uid_1',
|
datasourceUid: 'uid_1',
|
||||||
datasourceName: 'test_ds',
|
datasourceName: 'test_ds',
|
||||||
transformations: [{ type: SupportedTransformationTypes.Logfmt }],
|
transformations: [{ type: SupportedTransformationType.Logfmt }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user