mirror of https://github.com/prometheus/prometheus
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
parent
fafb7940b1
commit
d996ba20ec
|
@ -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 = () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -6,16 +6,21 @@ import { Alert, Button } from 'reactstrap';
|
|||
import Panel from './Panel';
|
||||
|
||||
describe('PanelList', () => {
|
||||
it('renders a query history checkbox', () => {
|
||||
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);
|
||||
expect(checkbox.prop('id')).toEqual('query-history-checkbox');
|
||||
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('Enable query history');
|
||||
expect(checkbox.children().text()).toBe(cb.label);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an alert when no data is queried yet', () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in New Issue