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:
Joey
2023-07-13 10:48:31 +01:00
committed by GitHub
parent 324b33146a
commit 76a83dd540
9 changed files with 157 additions and 120 deletions
@@ -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}>
@@ -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');
@@ -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')) {