mirror of
https://github.com/grafana/grafana.git
synced 2026-07-30 08:18:10 -05:00
Traces: Add inline validation and greater precision to duration fields in span filters (#71404)
* Add inline validation to span filters * Update filter spans by duration precision * Update IntervalInput props * Update validation * Update span filters * Update props * Update test * Update defaults and duration aria labels
This commit is contained in:
@@ -1,53 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
import { InlineField, InlineFieldRow, Input } from '@grafana/ui';
|
||||
import { InlineField, Input } from '@grafana/ui';
|
||||
|
||||
import { validateInterval } from './validation';
|
||||
import { validateInterval, validateIntervalRegex } from './validation';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
isInvalidError: string;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
validationRegex?: RegExp;
|
||||
}
|
||||
|
||||
export function IntervalInput(props: Props) {
|
||||
interface FieldProps {
|
||||
labelWidth: number;
|
||||
disabled: boolean;
|
||||
invalid: boolean;
|
||||
error: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const IntervalInput = (props: Props) => {
|
||||
const validationRegex = props.validationRegex || validateIntervalRegex;
|
||||
const [intervalIsInvalid, setIntervalIsInvalid] = useState(() => {
|
||||
return props.value ? validateInterval(props.value) : false;
|
||||
return props.value ? validateInterval(props.value, validationRegex) : false;
|
||||
});
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setIntervalIsInvalid(validateInterval(props.value));
|
||||
setIntervalIsInvalid(validateInterval(props.value, validationRegex));
|
||||
},
|
||||
500,
|
||||
[props.value]
|
||||
);
|
||||
|
||||
const fieldProps: FieldProps = {
|
||||
labelWidth: 26,
|
||||
disabled: props.disabled ?? false,
|
||||
invalid: intervalIsInvalid,
|
||||
error: props.isInvalidError,
|
||||
};
|
||||
if (props.label) {
|
||||
fieldProps.label = props.label;
|
||||
fieldProps.tooltip = props.tooltip || '';
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
label={props.label}
|
||||
labelWidth={26}
|
||||
disabled={props.disabled ?? false}
|
||||
grow
|
||||
tooltip={props.tooltip}
|
||||
invalid={intervalIsInvalid}
|
||||
error={props.isInvalidError}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="0"
|
||||
width={40}
|
||||
onChange={(e) => {
|
||||
props.onChange(e.currentTarget.value);
|
||||
}}
|
||||
value={props.value}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineField {...fieldProps}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={props.placeholder || '0'}
|
||||
width={props.width || 40}
|
||||
onChange={(e) => {
|
||||
props.onChange(e.currentTarget.value);
|
||||
}}
|
||||
value={props.value}
|
||||
aria-label={props.ariaLabel || 'interval input'}
|
||||
/>
|
||||
</InlineField>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { validateInterval } from './validation';
|
||||
import { validateInterval, validateIntervalRegex } from './validation';
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should validate incorrect values correctly', () => {
|
||||
expect(validateInterval('-')).toBeTruthy();
|
||||
expect(validateInterval('1')).toBeTruthy();
|
||||
expect(validateInterval('test')).toBeTruthy();
|
||||
expect(validateInterval('1ds')).toBeTruthy();
|
||||
expect(validateInterval('10Ms')).toBeTruthy();
|
||||
expect(validateInterval('-9999999')).toBeTruthy();
|
||||
expect(validateInterval('-', validateIntervalRegex)).toBeTruthy();
|
||||
expect(validateInterval('1', validateIntervalRegex)).toBeTruthy();
|
||||
expect(validateInterval('test', validateIntervalRegex)).toBeTruthy();
|
||||
expect(validateInterval('1ds', validateIntervalRegex)).toBeTruthy();
|
||||
expect(validateInterval('10Ms', validateIntervalRegex)).toBeTruthy();
|
||||
expect(validateInterval('-9999999', validateIntervalRegex)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate correct values correctly', () => {
|
||||
expect(validateInterval('1y')).toBeFalsy();
|
||||
expect(validateInterval('1M')).toBeFalsy();
|
||||
expect(validateInterval('1w')).toBeFalsy();
|
||||
expect(validateInterval('1d')).toBeFalsy();
|
||||
expect(validateInterval('2h')).toBeFalsy();
|
||||
expect(validateInterval('4m')).toBeFalsy();
|
||||
expect(validateInterval('8s')).toBeFalsy();
|
||||
expect(validateInterval('80ms')).toBeFalsy();
|
||||
expect(validateInterval('-80ms')).toBeFalsy();
|
||||
expect(validateInterval('1y', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('1M', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('1w', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('1d', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('2h', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('4m', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('8s', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('80ms', validateIntervalRegex)).toBeFalsy();
|
||||
expect(validateInterval('-80ms', validateIntervalRegex)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not return error if no value provided', () => {
|
||||
expect(validateInterval('')).toBeFalsy();
|
||||
expect(validateInterval('', validateIntervalRegex)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const validateInterval = (val: string) => {
|
||||
const intervalRegex = /^(-?\d+(?:\.\d+)?)(ms|[Mwdhmsy])$/;
|
||||
const matches = val.match(intervalRegex);
|
||||
export const validateIntervalRegex = /^(-?\d+(?:\.\d+)?)(ms|[Mwdhmsy])$/;
|
||||
|
||||
export const validateInterval = (val: string, regex: RegExp) => {
|
||||
const matches = val.match(regex);
|
||||
return matches || !val ? false : true;
|
||||
};
|
||||
|
||||
@@ -125,24 +125,29 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) {
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('start')}
|
||||
tooltip={getTimeShiftTooltip('start')}
|
||||
value={traceToLogs.spanStartTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateTracesToLogs({ spanStartTimeShift: val });
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('end')}
|
||||
tooltip={getTimeShiftTooltip('end')}
|
||||
value={traceToLogs.spanEndTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateTracesToLogs({ spanEndTimeShift: val });
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
<InlineFieldRow>
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('start')}
|
||||
tooltip={getTimeShiftTooltip('start')}
|
||||
value={traceToLogs.spanStartTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateTracesToLogs({ spanStartTimeShift: val });
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
|
||||
<InlineFieldRow>
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('end')}
|
||||
tooltip={getTimeShiftTooltip('end')}
|
||||
value={traceToLogs.spanEndTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateTracesToLogs({ spanEndTimeShift: val });
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
|
||||
@@ -78,31 +78,35 @@ export function TraceToMetricsSettings({ options, onOptionsChange }: Props) {
|
||||
) : null}
|
||||
</InlineFieldRow>
|
||||
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('start')}
|
||||
tooltip={getTimeShiftTooltip('start')}
|
||||
value={options.jsonData.tracesToMetrics?.spanStartTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
|
||||
...options.jsonData.tracesToMetrics,
|
||||
spanStartTimeShift: val,
|
||||
});
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
<InlineFieldRow>
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('start')}
|
||||
tooltip={getTimeShiftTooltip('start')}
|
||||
value={options.jsonData.tracesToMetrics?.spanStartTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
|
||||
...options.jsonData.tracesToMetrics,
|
||||
spanStartTimeShift: val,
|
||||
});
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('end')}
|
||||
tooltip={getTimeShiftTooltip('end')}
|
||||
value={options.jsonData.tracesToMetrics?.spanEndTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
|
||||
...options.jsonData.tracesToMetrics,
|
||||
spanEndTimeShift: val,
|
||||
});
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
<InlineFieldRow>
|
||||
<IntervalInput
|
||||
label={getTimeShiftLabel('end')}
|
||||
tooltip={getTimeShiftTooltip('end')}
|
||||
value={options.jsonData.tracesToMetrics?.spanEndTimeShift || ''}
|
||||
onChange={(val) => {
|
||||
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
|
||||
...options.jsonData.tracesToMetrics,
|
||||
spanEndTimeShift: val,
|
||||
});
|
||||
}}
|
||||
isInvalidError={invalidTimeShiftError}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
|
||||
<InlineFieldRow>
|
||||
<InlineField tooltip="Tags that will be used in the metrics query" label="Tags" labelWidth={26}>
|
||||
|
||||
+4
-4
@@ -83,10 +83,10 @@ describe('SpanFilters', () => {
|
||||
const serviceValue = screen.getByLabelText('Select service name');
|
||||
const spanOperator = screen.getByLabelText('Select span name operator');
|
||||
const spanValue = screen.getByLabelText('Select span name');
|
||||
const fromOperator = screen.getByLabelText('Select from operator');
|
||||
const fromValue = screen.getByLabelText('Select from value');
|
||||
const toOperator = screen.getByLabelText('Select to operator');
|
||||
const toValue = screen.getByLabelText('Select to value');
|
||||
const fromOperator = screen.getByLabelText('Select min span operator');
|
||||
const fromValue = screen.getByLabelText('Select min span duration');
|
||||
const toOperator = screen.getByLabelText('Select max span operator');
|
||||
const toValue = screen.getByLabelText('Select max span duration');
|
||||
const tagKey = screen.getByLabelText('Select tag key');
|
||||
const tagOperator = screen.getByLabelText('Select tag operator');
|
||||
const tagValue = screen.getByLabelText('Select tag value');
|
||||
|
||||
+24
-23
@@ -19,17 +19,8 @@ import React, { useState, useEffect, memo, useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
|
||||
import { AccessoryButton } from '@grafana/experimental';
|
||||
import {
|
||||
Collapse,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
InlineField,
|
||||
InlineFieldRow,
|
||||
Input,
|
||||
Select,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Collapse, HorizontalGroup, Icon, InlineField, InlineFieldRow, Select, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { IntervalInput } from 'app/core/components/IntervalInput/IntervalInput';
|
||||
|
||||
import { defaultFilters, randomId, SearchProps, Tag } from '../../../useSearch';
|
||||
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../../constants/span';
|
||||
@@ -70,6 +61,8 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
|
||||
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
|
||||
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
|
||||
|
||||
const durationRegex = /^\d+(?:\.\d)?\d*(?:ns|us|µs|ms|s|m|h)$/;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setServiceNames(undefined);
|
||||
setSpanNames(undefined);
|
||||
@@ -343,33 +336,41 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Duration" labelWidth={16}>
|
||||
<HorizontalGroup spacing={'xs'}>
|
||||
<InlineField
|
||||
label="Duration"
|
||||
labelWidth={16}
|
||||
tooltip="Filter by duration. Accepted units are ns, us, ms, s, m, h"
|
||||
>
|
||||
<HorizontalGroup spacing="xs" align="flex-start">
|
||||
<Select
|
||||
aria-label="Select from operator"
|
||||
aria-label="Select min span operator"
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, fromOperator: v.value! })}
|
||||
options={[toOption('>'), toOption('>=')]}
|
||||
value={search.fromOperator}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Select from value"
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, from: v.currentTarget.value })}
|
||||
<IntervalInput
|
||||
ariaLabel="Select min span duration"
|
||||
onChange={(val) => setSpanFiltersSearch({ ...search, from: val })}
|
||||
isInvalidError="Invalid duration"
|
||||
placeholder="e.g. 100ms, 1.2s"
|
||||
value={search.from || ''}
|
||||
width={18}
|
||||
value={search.from || ''}
|
||||
validationRegex={durationRegex}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Select to operator"
|
||||
aria-label="Select max span operator"
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, toOperator: v.value! })}
|
||||
options={[toOption('<'), toOption('<=')]}
|
||||
value={search.toOperator}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Select to value"
|
||||
onChange={(v) => setSpanFiltersSearch({ ...search, to: v.currentTarget.value })}
|
||||
<IntervalInput
|
||||
ariaLabel="Select max span duration"
|
||||
onChange={(val) => setSpanFiltersSearch({ ...search, to: val })}
|
||||
isInvalidError="Invalid duration"
|
||||
placeholder="e.g. 100ms, 1.2s"
|
||||
value={search.to || ''}
|
||||
width={18}
|
||||
value={search.to || ''}
|
||||
validationRegex={durationRegex}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</InlineField>
|
||||
|
||||
@@ -155,6 +155,12 @@ describe('filterSpans', () => {
|
||||
|
||||
// Durations
|
||||
it('should return spans whose duration match a filter', () => {
|
||||
expect(filterSpansNewTraceViewHeader({ ...defaultFilters, from: '2ns' }, spans)).toEqual(
|
||||
new Set([spanID0, spanID2])
|
||||
);
|
||||
expect(filterSpansNewTraceViewHeader({ ...defaultFilters, from: '2us' }, spans)).toEqual(
|
||||
new Set([spanID0, spanID2])
|
||||
);
|
||||
expect(filterSpansNewTraceViewHeader({ ...defaultFilters, from: '2ms' }, spans)).toEqual(
|
||||
new Set([spanID0, spanID2])
|
||||
);
|
||||
|
||||
@@ -157,8 +157,12 @@ const getDurationMatches = (spans: TraceSpan[], searchProps: SearchProps) => {
|
||||
};
|
||||
|
||||
export const convertTimeFilter = (time: string) => {
|
||||
if (time.includes('μs')) {
|
||||
return parseFloat(time.split('μs')[0]);
|
||||
if (time.includes('ns')) {
|
||||
return parseFloat(time.split('ns')[0]) / 1000;
|
||||
} else if (time.includes('us')) {
|
||||
return parseFloat(time.split('us')[0]);
|
||||
} else if (time.includes('µs')) {
|
||||
return parseFloat(time.split('µs')[0]);
|
||||
} else if (time.includes('ms')) {
|
||||
return parseFloat(time.split('ms')[0]) * 1000;
|
||||
} else if (time.includes('s')) {
|
||||
|
||||
Reference in New Issue
Block a user