From d996ba20ec9c7f1808823a047ed9d5ce96be3d8f Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Fri, 24 Jan 2020 23:44:18 +0100 Subject: [PATCH] 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 * Fixup: Also display TZ offset in tooltip Signed-off-by: Julius Volz * Just show offset, not timezone name abbreviation Signed-off-by: Julius Volz --- web/ui/react-app/src/pages/graph/Graph.tsx | 9 +++- .../src/pages/graph/GraphControls.tsx | 2 + .../src/pages/graph/GraphHelpers.test.ts | 53 ++++++++++++++++--- .../react-app/src/pages/graph/GraphHelpers.ts | 10 +++- .../src/pages/graph/GraphTabContent.tsx | 15 +++--- web/ui/react-app/src/pages/graph/Panel.tsx | 4 ++ .../src/pages/graph/PanelList.test.tsx | 23 ++++---- .../react-app/src/pages/graph/PanelList.tsx | 18 +++++++ .../react-app/src/pages/graph/TimeInput.tsx | 17 ++++-- 9 files changed, 120 insertions(+), 31 deletions(-) diff --git a/web/ui/react-app/src/pages/graph/Graph.tsx b/web/ui/react-app/src/pages/graph/Graph.tsx index e282818922..09b15c8252 100644 --- a/web/ui/react-app/src/pages/graph/Graph.tsx +++ b/web/ui/react-app/src/pages/graph/Graph.tsx @@ -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 { }; 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 { } }); } + + if (prevProps.useLocalTime !== useLocalTime) { + this.plot(); + } } componentDidMount() { @@ -73,7 +78,7 @@ class Graph extends PureComponent { } 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 = () => { diff --git a/web/ui/react-app/src/pages/graph/GraphControls.tsx b/web/ui/react-app/src/pages/graph/GraphControls.tsx index 38358c2d02..dd75fb149b 100644 --- a/web/ui/react-app/src/pages/graph/GraphControls.tsx +++ b/web/ui/react-app/src/pages/graph/GraphControls.tsx @@ -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 { { @@ -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(` -
Mon, 19 Jan 1970 04:42:08 GMT
+
1970-01-19 04:42:08 +00:00
+
+ + value: 1572128592 +
+
+
foo: 1
bar: 2
+
+ `); + }); + 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(` +
1970-01-18 23:42:08 -05:00
value: 1572128592 @@ -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, diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.ts index 9e85c020a9..4bd2f24426 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.ts @@ -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 ` -
${new Date(xval).toUTCString()}
+
${dateTime.format('YYYY-MM-DD HH:mm:ss Z')}
${labels.__name__ || 'value'}: ${yval} diff --git a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx index ff101ee7e4..2350cb117e 100644 --- a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx +++ b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx @@ -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 = ({ data, stacked, useLocalTime, lastQueryParams }) => { if (!isPresent(data)) { return No data queried yet; } @@ -24,5 +23,5 @@ export const GraphTabContent = ({ Query result is of wrong type '{data.resultType}', should be 'matrix' (range vector). ); } - return ; + return ; }; diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 17874ea956..5bd38cf34e 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -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 {
{ { diff --git a/web/ui/react-app/src/pages/graph/PanelList.test.tsx b/web/ui/react-app/src/pages/graph/PanelList.test.tsx index f5a2fad27d..528b57a1d1 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -6,16 +6,21 @@ import { Alert, Button } from 'reactstrap'; import Panel from './Panel'; describe('PanelList', () => { - it('renders a query history checkbox', () => { - const panelList = shallow(); - 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(); + 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', () => { diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 706f135679..d7d51f6354 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -17,6 +17,7 @@ interface PanelListState { metricNames: string[]; fetchMetricsError: string | null; timeDriftError: string | null; + useLocalTime: boolean; } class PanelList extends Component { @@ -29,6 +30,7 @@ class PanelList extends Component JSON.parse(localStorage.getItem('use-local-time') || 'false') as boolean; + + toggleUseLocalTime = (e: ChangeEvent) => { + 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 Enable query history + + Use local time + @@ -184,6 +201,7 @@ class PanelList extends Component this.handleOptionsChanged(id, opts)} + useLocalTime={this.state.useLocalTime} removePanel={() => this.removePanel(id)} metricNames={metricNames} pastQueries={pastQueries} diff --git a/web/ui/react-app/src/pages/graph/TimeInput.tsx b/web/ui/react-app/src/pages/graph/TimeInput.tsx index d28a1c91b6..6f502abe70 100644 --- a/web/ui/react-app/src/pages/graph/TimeInput.tsx +++ b/web/ui/react-app/src/pages/graph/TimeInput.tsx @@ -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 { 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 { 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 { 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() {