Support new duration format in graph range input (#7833)

* Support new duration format in graph range input

This is to make the duration parsing and formatting in the graph range
input field consistent with the new duration formatting introduced for
the configuration and PromQL
(https://github.com/prometheus/prometheus/pull/7713).

Ranges were previously handled in seconds - these are now handled in
milliseconds everywhere, as this makes things nicer / easier.

Signed-off-by: Julius Volz <julius.volz@gmail.com>

* Fixups

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2020-08-21 11:53:11 +02:00 committed by GitHub
parent 7ec647dbe9
commit a1601274ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 175 additions and 67 deletions

View file

@ -5,7 +5,7 @@ import { RuleStatus } from './AlertContents';
import { Rule } from '../../types/types';
import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { createExpressionLink, parsePrometheusFloat, formatRange } from '../../utils/index';
import { createExpressionLink, parsePrometheusFloat, formatDuration } from '../../utils/index';
interface CollapsibleAlertPanelProps {
rule: Rule;
@ -38,7 +38,7 @@ const CollapsibleAlertPanel: FC<CollapsibleAlertPanelProps> = ({ rule, showAnnot
</div>
{rule.duration > 0 && (
<div>
<div>for: {formatRange(rule.duration)}</div>
<div>for: {formatDuration(rule.duration * 1000)}</div>
</div>
)}
{rule.labels && Object.keys(rule.labels).length > 0 && (

View file

@ -7,7 +7,7 @@ import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-sol
import TimeInput from './TimeInput';
const defaultGraphControlProps = {
range: 60 * 60 * 24,
range: 60 * 60 * 24 * 1000,
endTime: 1572100217898,
resolution: 10,
stacked: false,
@ -81,7 +81,7 @@ describe('GraphControls', () => {
const timeInput = controls.find(TimeInput);
expect(timeInput).toHaveLength(1);
expect(timeInput.prop('time')).toEqual(1572100217898);
expect(timeInput.prop('range')).toEqual(86400);
expect(timeInput.prop('range')).toEqual(86400000);
expect(timeInput.prop('placeholder')).toEqual('End time');
});

View file

@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
import TimeInput from './TimeInput';
import { parseRange, formatRange } from '../../utils';
import { parseDuration, formatDuration } from '../../utils';
interface GraphControlsProps {
range: number;
@ -43,10 +43,10 @@ class GraphControls extends Component<GraphControlsProps> {
56 * 24 * 60 * 60,
365 * 24 * 60 * 60,
730 * 24 * 60 * 60,
];
].map(s => s * 1000);
onChangeRangeInput = (rangeText: string): void => {
const range = parseRange(rangeText);
const range = parseDuration(rangeText);
if (range === null) {
this.changeRangeInput(this.props.range);
} else {
@ -55,7 +55,7 @@ class GraphControls extends Component<GraphControlsProps> {
};
changeRangeInput = (range: number): void => {
this.rangeRef.current!.value = formatRange(range);
this.rangeRef.current!.value = formatDuration(range);
};
increaseRange = (): void => {
@ -98,7 +98,7 @@ class GraphControls extends Component<GraphControlsProps> {
</InputGroupAddon>
<Input
defaultValue={formatRange(this.props.range)}
defaultValue={formatDuration(this.props.range)}
innerRef={this.rangeRef}
onBlur={() => this.onChangeRangeInput(this.rangeRef.current!.value)}
/>

View file

@ -35,7 +35,7 @@ interface PanelState {
export interface PanelOptions {
expr: string;
type: PanelType;
range: number; // Range in seconds.
range: number; // Range in milliseconds.
endTime: number | null; // Timestamp in milliseconds.
resolution: number | null; // Resolution in seconds.
stacked: boolean;
@ -49,7 +49,7 @@ export enum PanelType {
export const PanelDefaultOptions: PanelOptions = {
type: PanelType.Table,
expr: '',
range: 3600,
range: 60 * 60 * 1000,
endTime: null,
resolution: null,
stacked: false,
@ -108,8 +108,8 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
this.setState({ loading: true });
const endTime = this.getEndTime().valueOf() / 1000; // TODO: shouldn't valueof only work when it's a moment?
const startTime = endTime - this.props.options.range;
const resolution = this.props.options.resolution || Math.max(Math.floor(this.props.options.range / 250), 1);
const startTime = endTime - this.props.options.range / 1000;
const resolution = this.props.options.resolution || Math.max(Math.floor(this.props.options.range / 250000), 1);
const params: URLSearchParams = new URLSearchParams({
query: expr,
});

View file

@ -39,7 +39,7 @@ class TimeInput extends Component<TimeInputProps> {
return this.props.time || moment().valueOf();
};
calcShiftRange = () => (this.props.range * 1000) / 2;
calcShiftRange = () => this.props.range / 2;
increaseTime = (): void => {
const time = this.getBaseTime() + this.calcShiftRange();

View file

@ -3,7 +3,7 @@ import { RouteComponentProps } from '@reach/router';
import { APIResponse } from '../../hooks/useFetch';
import { Alert, Table, Badge } from 'reactstrap';
import { Link } from '@reach/router';
import { formatRelative, createExpressionLink, humanizeDuration, formatRange } from '../../utils';
import { formatRelative, createExpressionLink, humanizeDuration, formatDuration } from '../../utils';
import { Rule } from '../../types/types';
import { now } from 'moment';
@ -92,7 +92,7 @@ export const RulesContent: FC<RouteComponentProps & RulesContentProps> = ({ resp
<GraphExpressionLink title="expr" text={r.query} expr={r.query} />
{r.duration > 0 && (
<div>
<strong>for:</strong> {formatRange(r.duration)}
<strong>for:</strong> {formatDuration(r.duration * 1000)}
</div>
)}
{r.labels && Object.keys(r.labels).length > 0 && (

View file

@ -43,34 +43,75 @@ export const metricToSeriesName = (labels: { [key: string]: string }) => {
return tsName;
};
const rangeUnits: { [unit: string]: number } = {
y: 60 * 60 * 24 * 365,
w: 60 * 60 * 24 * 7,
d: 60 * 60 * 24,
h: 60 * 60,
m: 60,
s: 1,
};
export function parseRange(rangeText: string): number | null {
const rangeRE = new RegExp('^([0-9]+)([ywdhms]+)$');
const matches = rangeText.match(rangeRE);
if (!matches || matches.length !== 3) {
export const parseDuration = (durationStr: string): number | null => {
if (durationStr === '') {
return null;
}
const value = parseInt(matches[1]);
const unit = matches[2];
return value * rangeUnits[unit];
}
export function formatRange(range: number): string {
for (const unit of Object.keys(rangeUnits)) {
if (range % rangeUnits[unit] === 0) {
return range / rangeUnits[unit] + unit;
}
if (durationStr === '0') {
// Allow 0 without a unit.
return 0;
}
return range + 's';
}
const durationRE = new RegExp('^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$');
const matches = durationStr.match(durationRE);
if (!matches) {
return null;
}
let dur = 0;
// Parse the match at pos `pos` in the regex and use `mult` to turn that
// into ms, then add that value to the total parsed duration.
const m = (pos: number, mult: number) => {
if (matches[pos] === undefined) {
return;
}
const n = parseInt(matches[pos]);
dur += n * mult;
};
m(2, 1000 * 60 * 60 * 24 * 365); // y
m(4, 1000 * 60 * 60 * 24 * 7); // w
m(6, 1000 * 60 * 60 * 24); // d
m(8, 1000 * 60 * 60); // h
m(10, 1000 * 60); // m
m(12, 1000); // s
m(14, 1); // ms
return dur;
};
export const formatDuration = (d: number): string => {
let ms = d;
let r = '';
if (ms === 0) {
return '0s';
}
const f = (unit: string, mult: number, exact: boolean) => {
if (exact && ms % mult !== 0) {
return;
}
const v = Math.floor(ms / mult);
if (v > 0) {
r += `${v}${unit}`;
ms -= v * mult;
}
};
// Only format years and weeks if the remainder is zero, as it is often
// easier to read 90d than 12w6d.
f('y', 1000 * 60 * 60 * 24 * 365, true);
f('w', 1000 * 60 * 60 * 24 * 7, true);
f('d', 1000 * 60 * 60 * 24, false);
f('h', 1000 * 60 * 60, false);
f('m', 1000 * 60, false);
f('s', 1000, false);
f('ms', 1, false);
return r;
};
export function parseTime(timeText: string): number {
return moment.utc(timeText).valueOf();
@ -161,7 +202,7 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
return { stacked: decodedValue === '1' };
case 'range_input':
const range = parseRange(decodedValue);
const range = parseDuration(decodedValue);
return isPresent(range) ? { range } : {};
case 'end_input':
@ -187,7 +228,7 @@ export const toQueryString = ({ key, options }: PanelMeta) => {
formatWithKey('expr', expr),
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
formatWithKey('stacked', stacked ? 1 : 0),
formatWithKey('range_input', formatRange(range)),
formatWithKey('range_input', formatDuration(range)),
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
isPresent(resolution) ? formatWithKey('step_input', resolution) : '',
];

View file

@ -5,8 +5,8 @@ import {
metricToSeriesName,
formatTime,
parseTime,
formatRange,
parseRange,
formatDuration,
parseDuration,
humanizeDuration,
formatRelative,
now,
@ -67,25 +67,92 @@ describe('Utils', () => {
});
});
describe('formatRange', () => {
it('returns a time string representing the time in seconds in one unit', () => {
expect(formatRange(60 * 60 * 24 * 365)).toEqual('1y');
expect(formatRange(60 * 60 * 24 * 7)).toEqual('1w');
expect(formatRange(2 * 60 * 60 * 24)).toEqual('2d');
expect(formatRange(60 * 60)).toEqual('1h');
expect(formatRange(7 * 60)).toEqual('7m');
expect(formatRange(63)).toEqual('63s');
});
});
describe('parseDuration and formatDuration', () => {
describe('should parse and format durations correctly', () => {
const tests: { input: string; output: number; expectedString?: string }[] = [
{
input: '0',
output: 0,
expectedString: '0s',
},
{
input: '0w',
output: 0,
expectedString: '0s',
},
{
input: '0s',
output: 0,
},
{
input: '324ms',
output: 324,
},
{
input: '3s',
output: 3 * 1000,
},
{
input: '5m',
output: 5 * 60 * 1000,
},
{
input: '1h',
output: 60 * 60 * 1000,
},
{
input: '4d',
output: 4 * 24 * 60 * 60 * 1000,
},
{
input: '4d1h',
output: 4 * 24 * 60 * 60 * 1000 + 1 * 60 * 60 * 1000,
},
{
input: '14d',
output: 14 * 24 * 60 * 60 * 1000,
expectedString: '2w',
},
{
input: '3w',
output: 3 * 7 * 24 * 60 * 60 * 1000,
},
{
input: '3w2d1h',
output: 3 * 7 * 24 * 60 * 60 * 1000 + 2 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000,
expectedString: '23d1h',
},
{
input: '1y2w3d4h5m6s7ms',
output:
1 * 365 * 24 * 60 * 60 * 1000 +
2 * 7 * 24 * 60 * 60 * 1000 +
3 * 24 * 60 * 60 * 1000 +
4 * 60 * 60 * 1000 +
5 * 60 * 1000 +
6 * 1000 +
7,
expectedString: '382d4h5m6s7ms',
},
];
describe('parseRange', () => {
it('returns a time string representing the time in seconds in one unit', () => {
expect(parseRange('1y')).toEqual(60 * 60 * 24 * 365);
expect(parseRange('1w')).toEqual(60 * 60 * 24 * 7);
expect(parseRange('2d')).toEqual(2 * 60 * 60 * 24);
expect(parseRange('1h')).toEqual(60 * 60);
expect(parseRange('7m')).toEqual(7 * 60);
expect(parseRange('63s')).toEqual(63);
tests.forEach(t => {
it(t.input, () => {
const d = parseDuration(t.input);
expect(d).toEqual(t.output);
expect(formatDuration(d!)).toEqual(t.expectedString || t.input);
});
});
});
describe('should fail to parse invalid durations', () => {
const tests = ['1', '1y1m1d', '-1w', '1.5d', 'd', ''];
tests.forEach(t => {
it(t, () => {
expect(parseDuration(t)).toBe(null);
});
});
});
});
@ -141,7 +208,7 @@ describe('Utils', () => {
options: {
endTime: 1572046620000,
expr: 'rate(node_cpu_seconds_total{mode="system"}[1m])',
range: 3600,
range: 60 * 60 * 1000,
resolution: null,
stacked: false,
type: PanelType.Graph,
@ -152,7 +219,7 @@ describe('Utils', () => {
options: {
endTime: null,
expr: 'node_filesystem_avail_bytes',
range: 3600,
range: 60 * 60 * 1000,
resolution: null,
stacked: false,
type: PanelType.Table,
@ -201,7 +268,7 @@ describe('Utils', () => {
describe('range_input', () => {
it('should return range parsed if its not null', () => {
expect(parseOption('range_input=2h')).toEqual({ range: 7200 });
expect(parseOption('range_input=2h')).toEqual({ range: 2 * 60 * 60 * 1000 });
});
it('should return empty object for invalid value', () => {
expect(parseOption('range_input=h')).toEqual({});
@ -226,7 +293,7 @@ describe('Utils', () => {
key: '0',
options: { expr: 'foo', type: PanelType.Graph, stacked: true, range: 0, endTime: null, resolution: 1 },
})
).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.range_input=0y&g0.step_input=1');
).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.range_input=0s&g0.step_input=1');
});
});