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 { Rule } from '../../types/types';
import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { createExpressionLink, parsePrometheusFloat, formatRange } from '../../utils/index'; import { createExpressionLink, parsePrometheusFloat, formatDuration } from '../../utils/index';
interface CollapsibleAlertPanelProps { interface CollapsibleAlertPanelProps {
rule: Rule; rule: Rule;
@ -38,7 +38,7 @@ const CollapsibleAlertPanel: FC<CollapsibleAlertPanelProps> = ({ rule, showAnnot
</div> </div>
{rule.duration > 0 && ( {rule.duration > 0 && (
<div> <div>
<div>for: {formatRange(rule.duration)}</div> <div>for: {formatDuration(rule.duration * 1000)}</div>
</div> </div>
)} )}
{rule.labels && Object.keys(rule.labels).length > 0 && ( {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'; import TimeInput from './TimeInput';
const defaultGraphControlProps = { const defaultGraphControlProps = {
range: 60 * 60 * 24, range: 60 * 60 * 24 * 1000,
endTime: 1572100217898, endTime: 1572100217898,
resolution: 10, resolution: 10,
stacked: false, stacked: false,
@ -81,7 +81,7 @@ describe('GraphControls', () => {
const timeInput = controls.find(TimeInput); const timeInput = controls.find(TimeInput);
expect(timeInput).toHaveLength(1); expect(timeInput).toHaveLength(1);
expect(timeInput.prop('time')).toEqual(1572100217898); 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'); 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 { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
import { parseRange, formatRange } from '../../utils'; import { parseDuration, formatDuration } from '../../utils';
interface GraphControlsProps { interface GraphControlsProps {
range: number; range: number;
@ -43,10 +43,10 @@ class GraphControls extends Component<GraphControlsProps> {
56 * 24 * 60 * 60, 56 * 24 * 60 * 60,
365 * 24 * 60 * 60, 365 * 24 * 60 * 60,
730 * 24 * 60 * 60, 730 * 24 * 60 * 60,
]; ].map(s => s * 1000);
onChangeRangeInput = (rangeText: string): void => { onChangeRangeInput = (rangeText: string): void => {
const range = parseRange(rangeText); const range = parseDuration(rangeText);
if (range === null) { if (range === null) {
this.changeRangeInput(this.props.range); this.changeRangeInput(this.props.range);
} else { } else {
@ -55,7 +55,7 @@ class GraphControls extends Component<GraphControlsProps> {
}; };
changeRangeInput = (range: number): void => { changeRangeInput = (range: number): void => {
this.rangeRef.current!.value = formatRange(range); this.rangeRef.current!.value = formatDuration(range);
}; };
increaseRange = (): void => { increaseRange = (): void => {
@ -98,7 +98,7 @@ class GraphControls extends Component<GraphControlsProps> {
</InputGroupAddon> </InputGroupAddon>
<Input <Input
defaultValue={formatRange(this.props.range)} defaultValue={formatDuration(this.props.range)}
innerRef={this.rangeRef} innerRef={this.rangeRef}
onBlur={() => this.onChangeRangeInput(this.rangeRef.current!.value)} onBlur={() => this.onChangeRangeInput(this.rangeRef.current!.value)}
/> />

View file

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

View file

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

View file

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

View file

@ -43,34 +43,75 @@ export const metricToSeriesName = (labels: { [key: string]: string }) => {
return tsName; return tsName;
}; };
const rangeUnits: { [unit: string]: number } = { export const parseDuration = (durationStr: string): number | null => {
y: 60 * 60 * 24 * 365, if (durationStr === '') {
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) {
return null; return null;
} }
const value = parseInt(matches[1]); if (durationStr === '0') {
const unit = matches[2]; // Allow 0 without a unit.
return value * rangeUnits[unit]; return 0;
} }
export function formatRange(range: number): string { 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)?$');
for (const unit of Object.keys(rangeUnits)) { const matches = durationStr.match(durationRE);
if (range % rangeUnits[unit] === 0) { if (!matches) {
return range / rangeUnits[unit] + unit; 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;
} }
return range + 's'; 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 { export function parseTime(timeText: string): number {
return moment.utc(timeText).valueOf(); return moment.utc(timeText).valueOf();
@ -161,7 +202,7 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
return { stacked: decodedValue === '1' }; return { stacked: decodedValue === '1' };
case 'range_input': case 'range_input':
const range = parseRange(decodedValue); const range = parseDuration(decodedValue);
return isPresent(range) ? { range } : {}; return isPresent(range) ? { range } : {};
case 'end_input': case 'end_input':
@ -187,7 +228,7 @@ export const toQueryString = ({ key, options }: PanelMeta) => {
formatWithKey('expr', expr), formatWithKey('expr', expr),
formatWithKey('tab', type === PanelType.Graph ? 0 : 1), formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
formatWithKey('stacked', stacked ? 1 : 0), formatWithKey('stacked', stacked ? 1 : 0),
formatWithKey('range_input', formatRange(range)), formatWithKey('range_input', formatDuration(range)),
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '', time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
isPresent(resolution) ? formatWithKey('step_input', resolution) : '', isPresent(resolution) ? formatWithKey('step_input', resolution) : '',
]; ];

View file

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