React UI: Support local timezone on /graph (#6692)

* React UI: Support local timezone on /graph

This partially implements
https://github.com/prometheus/prometheus/issues/500 in the sense that it
only addresses the /graph page, and only allows toggling between UTC and
local (browser) time, but no arbitrary timezone selection yet.

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

* Fixup: Also display TZ offset in tooltip

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

* Just show offset, not timezone name abbreviation

Signed-off-by: Julius Volz <julius.volz@gmail.com>
pull/6696/head
Julius Volz 2020-01-24 23:44:18 +01:00 committed by GitHub
parent fafb7940b1
commit d996ba20ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 120 additions and 31 deletions

View File

@ -19,6 +19,7 @@ export interface GraphProps {
result: Array<{ metric: Metric; values: [number, string][] }>;
};
stacked: boolean;
useLocalTime: boolean;
queryParams: QueryParams | null;
}
@ -44,7 +45,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
};
componentDidUpdate(prevProps: GraphProps) {
const { data, stacked } = this.props;
const { data, stacked, useLocalTime } = this.props;
if (prevProps.data !== data) {
this.selectedSeriesIndexes = [];
this.setState({ chartData: normalizeData(this.props) }, this.plot);
@ -57,6 +58,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
}
});
}
if (prevProps.useLocalTime !== useLocalTime) {
this.plot();
}
}
componentDidMount() {
@ -73,7 +78,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
}
this.destroyPlot();
this.$chart = $.plot($(this.chartRef.current), data, getOptions(this.props.stacked));
this.$chart = $.plot($(this.chartRef.current), data, getOptions(this.props.stacked, this.props.useLocalTime));
};
destroyPlot = () => {

View File

@ -10,6 +10,7 @@ import { parseRange, formatRange } from '../../utils';
interface GraphControlsProps {
range: number;
endTime: number | null;
useLocalTime: boolean;
resolution: number | null;
stacked: boolean;
@ -111,6 +112,7 @@ class GraphControls extends Component<GraphControlsProps> {
<TimeInput
time={this.props.endTime}
useLocalTime={this.props.useLocalTime}
range={this.props.range}
placeholder="End time"
onChangeTime={this.props.onChangeEndTime}

View File

@ -1,4 +1,5 @@
import { formatValue, getColors, parseValue, getOptions } from './GraphHelpers';
import moment from 'moment';
require('../../vendor/flot/jquery.flot'); // need for $.colors
describe('GraphHelpers', () => {
@ -98,8 +99,8 @@ describe('GraphHelpers', () => {
});
});
describe('Plot options', () => {
it('should configer options properly if stacked prop is true', () => {
expect(getOptions(true)).toMatchObject({
it('should configure options properly if stacked prop is true', () => {
expect(getOptions(true, false)).toMatchObject({
series: {
stack: true,
lines: { lineWidth: 1, steps: false, fill: true },
@ -107,8 +108,8 @@ describe('GraphHelpers', () => {
},
});
});
it('should configer options properly if stacked prop is false', () => {
expect(getOptions(false)).toMatchObject({
it('should configure options properly if stacked prop is false', () => {
expect(getOptions(false, false)).toMatchObject({
series: {
stack: false,
lines: { lineWidth: 2, steps: false, fill: false },
@ -116,13 +117,51 @@ describe('GraphHelpers', () => {
},
});
});
it('should configure options properly if useLocalTime prop is true', () => {
expect(getOptions(true, true)).toMatchObject({
xaxis: {
mode: 'time',
showTicks: true,
showMinorTicks: true,
timeBase: 'milliseconds',
timezone: 'browser',
},
});
});
it('should configure options properly if useLocalTime prop is false', () => {
expect(getOptions(false, false)).toMatchObject({
xaxis: {
mode: 'time',
showTicks: true,
showMinorTicks: true,
timeBase: 'milliseconds',
},
});
});
it('should return proper tooltip html from options', () => {
expect(
getOptions(true).tooltip.content('', 1572128592, 1572128592, {
getOptions(true, false).tooltip.content('', 1572128592, 1572128592, {
series: { labels: { foo: '1', bar: '2' }, color: '' },
} as any)
).toEqual(`
<div class="date">Mon, 19 Jan 1970 04:42:08 GMT</div>
<div class="date">1970-01-19 04:42:08 +00:00</div>
<div>
<span class="detail-swatch" style="background-color: " />
<span>value: <strong>1572128592</strong></span>
<div>
<div class="labels mt-1">
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
</div>
`);
});
it('should return proper tooltip html from options with local time', () => {
moment.tz.setDefault('America/New_York');
expect(
getOptions(true, true).tooltip.content('', 1572128592, 1572128592, {
series: { labels: { foo: '1', bar: '2' }, color: '' },
} as any)
).toEqual(`
<div class="date">1970-01-18 23:42:08 -05:00</div>
<div>
<span class="detail-swatch" style="background-color: " />
<span>value: <strong>1572128592</strong></span>
@ -133,7 +172,7 @@ describe('GraphHelpers', () => {
`);
});
it('should render Plot with proper options', () => {
expect(getOptions(true)).toEqual({
expect(getOptions(true, false)).toEqual({
grid: {
hoverable: true,
clickable: true,

View File

@ -3,6 +3,7 @@ import $ from 'jquery';
import { escapeHTML } from '../../utils';
import { Metric } from '../../types/types';
import { GraphProps, GraphSeries } from './Graph';
import moment from 'moment-timezone';
export const formatValue = (y: number | null): string => {
if (y === null) {
@ -71,7 +72,7 @@ export const toHoverColor = (index: number, stacked: boolean) => (series: GraphS
color: getHoverColor(series.color, i !== index ? 0.3 : 1, stacked),
});
export const getOptions = (stacked: boolean): jquery.flot.plotOptions => {
export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot.plotOptions => {
return {
grid: {
hoverable: true,
@ -87,6 +88,7 @@ export const getOptions = (stacked: boolean): jquery.flot.plotOptions => {
showTicks: true,
showMinorTicks: true,
timeBase: 'milliseconds',
timezone: useLocalTime ? 'browser' : undefined,
},
yaxis: {
tickFormatter: formatValue,
@ -100,8 +102,12 @@ export const getOptions = (stacked: boolean): jquery.flot.plotOptions => {
cssClass: 'graph-tooltip',
content: (_, xval, yval, { series }): string => {
const { labels, color } = series;
let dateTime = moment(xval);
if (!useLocalTime) {
dateTime = dateTime.utc();
}
return `
<div class="date">${new Date(xval).toUTCString()}</div>
<div class="date">${dateTime.format('YYYY-MM-DD HH:mm:ss Z')}</div>
<div>
<span class="detail-swatch" style="background-color: ${color}" />
<span>${labels.__name__ || 'value'}: <strong>${yval}</strong></span>

View File

@ -1,18 +1,17 @@
import React from 'react';
import React, { FC } from 'react';
import { Alert } from 'reactstrap';
import Graph from './Graph';
import { QueryParams } from '../../types/types';
import { isPresent } from '../../utils';
export const GraphTabContent = ({
data,
stacked,
lastQueryParams,
}: {
interface GraphTabContentProps {
data: any;
stacked: boolean;
useLocalTime: boolean;
lastQueryParams: QueryParams | null;
}) => {
}
export const GraphTabContent: FC<GraphTabContentProps> = ({ data, stacked, useLocalTime, lastQueryParams }) => {
if (!isPresent(data)) {
return <Alert color="light">No data queried yet</Alert>;
}
@ -24,5 +23,5 @@ export const GraphTabContent = ({
<Alert color="danger">Query result is of wrong type '{data.resultType}', should be 'matrix' (range vector).</Alert>
);
}
return <Graph data={data} stacked={stacked} queryParams={lastQueryParams} />;
return <Graph data={data} stacked={stacked} useLocalTime={useLocalTime} queryParams={lastQueryParams} />;
};

View File

@ -16,6 +16,7 @@ import { QueryParams } from '../../types/types';
interface PanelProps {
options: PanelOptions;
onOptionsChanged: (opts: PanelOptions) => void;
useLocalTime: boolean;
pastQueries: string[];
metricNames: string[];
removePanel: () => void;
@ -266,6 +267,7 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
<div className="table-controls">
<TimeInput
time={options.endTime}
useLocalTime={this.props.useLocalTime}
range={options.range}
placeholder="Evaluation time"
onChangeTime={this.handleChangeEndTime}
@ -281,6 +283,7 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
<GraphControls
range={options.range}
endTime={options.endTime}
useLocalTime={this.props.useLocalTime}
resolution={options.resolution}
stacked={options.stacked}
onChangeRange={this.handleChangeRange}
@ -291,6 +294,7 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
<GraphTabContent
data={this.state.data}
stacked={options.stacked}
useLocalTime={this.props.useLocalTime}
lastQueryParams={this.state.lastQueryParams}
/>
</>

View File

@ -6,16 +6,21 @@ import { Alert, Button } from 'reactstrap';
import Panel from './Panel';
describe('PanelList', () => {
it('renders a query history checkbox', () => {
const panelList = shallow(<PanelList />);
const checkbox = panelList.find(Checkbox);
expect(checkbox.prop('id')).toEqual('query-history-checkbox');
expect(checkbox.prop('wrapperStyles')).toEqual({
margin: '0 0 0 15px',
alignSelf: 'center',
it('renders query history and local time checkboxes', () => {
[
{ id: 'query-history-checkbox', label: 'Enable query history' },
{ id: 'use-local-time-checkbox', label: 'Use local time' },
].forEach((cb, idx) => {
const panelList = shallow(<PanelList />);
const checkbox = panelList.find(Checkbox).at(idx);
expect(checkbox.prop('id')).toEqual(cb.id);
expect(checkbox.prop('wrapperStyles')).toEqual({
margin: '0 0 0 15px',
alignSelf: 'center',
});
expect(checkbox.prop('defaultChecked')).toBe(false);
expect(checkbox.children().text()).toBe(cb.label);
});
expect(checkbox.prop('defaultChecked')).toBe(false);
expect(checkbox.children().text()).toBe('Enable query history');
});
it('renders an alert when no data is queried yet', () => {

View File

@ -17,6 +17,7 @@ interface PanelListState {
metricNames: string[];
fetchMetricsError: string | null;
timeDriftError: string | null;
useLocalTime: boolean;
}
class PanelList extends Component<RouteComponentProps & PathPrefixProps, PanelListState> {
@ -29,6 +30,7 @@ class PanelList extends Component<RouteComponentProps & PathPrefixProps, PanelLi
metricNames: [],
fetchMetricsError: null,
timeDriftError: null,
useLocalTime: this.useLocalTime(),
};
}
@ -95,6 +97,13 @@ class PanelList extends Component<RouteComponentProps & PathPrefixProps, PanelLi
});
};
useLocalTime = () => JSON.parse(localStorage.getItem('use-local-time') || 'false') as boolean;
toggleUseLocalTime = (e: ChangeEvent<HTMLInputElement>) => {
localStorage.setItem('use-local-time', `${e.target.checked}`);
this.setState({ useLocalTime: e.target.checked });
};
handleExecuteQuery = (query: string) => {
const isSimpleMetric = this.state.metricNames.indexOf(query) !== -1;
if (isSimpleMetric || !query.length) {
@ -159,6 +168,14 @@ class PanelList extends Component<RouteComponentProps & PathPrefixProps, PanelLi
>
Enable query history
</Checkbox>
<Checkbox
id="use-local-time-checkbox"
wrapperStyles={{ margin: '0 0 0 15px', alignSelf: 'center' }}
onChange={this.toggleUseLocalTime}
defaultChecked={this.useLocalTime()}
>
Use local time
</Checkbox>
</Row>
<Row>
<Col>
@ -184,6 +201,7 @@ class PanelList extends Component<RouteComponentProps & PathPrefixProps, PanelLi
key={id}
options={options}
onOptionsChanged={opts => this.handleOptionsChanged(id, opts)}
useLocalTime={this.state.useLocalTime}
removePanel={() => this.removePanel(id)}
metricNames={metricNames}
pastQueries={pastQueries}

View File

@ -25,6 +25,7 @@ dom.watch();
interface TimeInputProps {
time: number | null; // Timestamp in milliseconds.
useLocalTime: boolean;
range: number; // Range in seconds.
placeholder: string;
onChangeTime: (time: number | null) => void;
@ -54,6 +55,10 @@ class TimeInput extends Component<TimeInputProps> {
this.props.onChangeTime(null);
};
timezone = (): string => {
return this.props.useLocalTime ? moment.tz.guess() : 'UTC';
};
componentDidMount() {
this.$time = $(this.timeInputRef.current!);
@ -69,7 +74,7 @@ class TimeInput extends Component<TimeInputProps> {
sideBySide: true,
format: 'YYYY-MM-DD HH:mm:ss',
locale: 'en',
timeZone: 'UTC',
timeZone: this.timezone(),
defaultDate: this.props.time,
});
@ -84,8 +89,14 @@ class TimeInput extends Component<TimeInputProps> {
this.$time.datetimepicker('destroy');
}
componentDidUpdate() {
this.$time.datetimepicker('date', this.props.time ? moment(this.props.time) : null);
componentDidUpdate(prevProps: TimeInputProps) {
const { time, useLocalTime } = this.props;
if (prevProps.time !== time) {
this.$time.datetimepicker('date', time ? moment(time) : null);
}
if (prevProps.useLocalTime !== useLocalTime) {
this.$time.datetimepicker('options', { timeZone: this.timezone(), defaultDate: null });
}
}
render() {