TimeSeries: Support multiple timezones in x axis (#52424)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Ryan McKinley 2022-07-22 20:18:27 -07:00 committed by GitHub
parent 17ea5f4f3e
commit 2fa10dc903
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 211 additions and 45 deletions

View File

@ -4532,7 +4532,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
[0, 0, 0, "Do not use any type assertions.", "26"],
[0, 0, 0, "Unexpected any. Specify a different type.", "27"],
[0, 0, 0, "Unexpected any. Specify a different type.", "28"]
[0, 0, 0, "Unexpected any. Specify a different type.", "28"],
[0, 0, 0, "Unexpected any. Specify a different type.", "29"]
],
"public/app/features/dashboard/state/TimeModel.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@ -173,6 +173,11 @@ OptionsWithLegend: {
legend: VizLegendOptions
} @cuetsy(kind="interface")
// TODO docs
OptionsWithTimezones: {
timezones?: [...string]
} @cuetsy(kind="interface")
// TODO docs
OptionsWithTextFormatting: {
text?: VizTextDisplayOptions

View File

@ -208,6 +208,14 @@ export interface OptionsWithLegend {
legend: VizLegendOptions;
}
export interface OptionsWithTimezones {
timezones?: string[];
}
export const defaultOptionsWithTimezones: Partial<OptionsWithTimezones> = {
timezones: [],
};
export interface OptionsWithTextFormatting {
text?: VizTextDisplayOptions;
}

View File

@ -44,7 +44,7 @@ export interface GraphNGProps extends Themeable2 {
width: number;
height: number;
timeRange: TimeRange;
timeZone: TimeZone;
timeZones: TimeZone[] | TimeZone;
legend: VizLegendOptions;
fields?: XYFieldMatchers; // default will assume timeseries data
renderers?: Renderers;
@ -216,17 +216,17 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
}
componentDidUpdate(prevProps: GraphNGProps) {
const { frames, structureRev, timeZone, propsToDiff } = this.props;
const { frames, structureRev, timeZones, propsToDiff } = this.props;
const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) {
if (frames !== prevProps.frames || propsChanged || timeZones !== prevProps.timeZones) {
let newState = this.prepState(this.props, false);
if (newState) {
const shouldReconfig =
this.state.config === undefined ||
timeZone !== prevProps.timeZone ||
timeZones !== prevProps.timeZones ||
structureRev !== prevProps.structureRev ||
!structureRev ||
propsChanged;

View File

@ -214,7 +214,7 @@ describe('GraphNG utils', () => {
const result = preparePlotConfigBuilder({
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
sync: () => DashboardCursorSync.Tooltip,

View File

@ -22,12 +22,12 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventBus, sync } = this.context as PanelContext;
const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props;
const { theme, timeZones, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
frame: alignedFrame,
theme,
timeZone,
timeZones: Array.isArray(timeZones) ? timeZones : [timeZones],
getTimeRange,
eventBus,
sync,

View File

@ -48,7 +48,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
}> = ({
frame,
theme,
timeZone,
timeZones,
getTimeRange,
eventBus,
sync,
@ -57,7 +57,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
tweakScale = (opts) => opts,
tweakAxis = (opts) => opts,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
const builder = new UPlotConfigBuilder(timeZones[0]);
let alignedFrame: DataFrame;
@ -97,16 +97,51 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
},
});
builder.addAxis({
scaleKey: xScaleKey,
isTime: true,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: xField.config.custom?.axisLabel,
timeZone,
theme,
grid: { show: xField.config.custom?.axisGridShow },
});
// filters first 2 ticks to make space for timezone labels
const filterTicks: uPlot.Axis.Filter | undefined =
timeZones.length > 1
? (u, splits) => {
return splits.map((v, i) => (i < 2 ? null : v));
}
: undefined;
for (let i = 0; i < timeZones.length; i++) {
const timeZone = timeZones[i];
builder.addAxis({
scaleKey: xScaleKey,
isTime: true,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: xField.config.custom?.axisLabel,
timeZone,
theme,
grid: { show: i === 0 && xField.config.custom?.axisGridShow },
filter: filterTicks,
});
}
// render timezone labels
if (timeZones.length > 1) {
builder.addHook('drawAxes', (u: uPlot) => {
u.ctx.save();
u.ctx.fillStyle = theme.colors.text.primary;
u.ctx.textAlign = 'left';
u.ctx.textBaseline = 'bottom';
let i = 0;
u.axes.forEach((a) => {
if (a.side === 2) {
//@ts-ignore
let cssBaseline: number = a._pos + a._size;
u.ctx.fillText(timeZones[i], u.bbox.left, cssBaseline * uPlot.pxRatio);
i++;
}
});
u.ctx.restore();
});
}
} else {
// Not time!
if (xField.config.unit) {

View File

@ -151,7 +151,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
ticks
),
splits,
values: values,
values,
space:
space ??
((self, axisIdx, scaleMin, scaleMax, plotDim) => {
@ -227,7 +227,7 @@ export function formatTime(
format = systemDateFormats.interval.month;
}
return splits.map((v) => dateTimeFormat(v, { format, timeZone }));
return splits.map((v) => (v == null ? '' : dateTimeFormat(v, { format, timeZone })));
}
export function getUPlotSideFromAxis(axis: AxisPlacement) {

View File

@ -87,8 +87,14 @@ export class UPlotConfigBuilder {
addAxis(props: AxisProps) {
props.placement = props.placement ?? AxisPlacement.Auto;
props.grid = props.grid ?? {};
if (this.axes[props.scaleKey]) {
this.axes[props.scaleKey].merge(props);
let scaleKey = props.scaleKey;
if (scaleKey === 'x') {
scaleKey += props.timeZone ?? '';
}
if (this.axes[scaleKey]) {
this.axes[scaleKey].merge(props);
return;
}
@ -106,7 +112,7 @@ export class UPlotConfigBuilder {
props.size = 0;
}
this.axes[props.scaleKey] = new UPlotAxisBuilder(props);
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
getAxisPlacement(scaleKey: string): AxisPlacement {
@ -285,7 +291,7 @@ export type Renderers = Array<{
type UPlotConfigPrepOpts<T extends Record<string, any> = {}> = {
frame: DataFrame;
theme: GrafanaTheme2;
timeZone: TimeZone;
timeZones: TimeZone[];
getTimeRange: () => TimeRange;
eventBus: EventBus;
allFrames: DataFrame[];

View File

@ -236,7 +236,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
frame: alignedFrame,
getTimeRange,
theme,
timeZone,
timeZones: [timeZone],
eventBus,
orientation,
barWidth,
@ -266,7 +266,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
preparePlotFrame={(f) => f[0]} // already processed in by the panel above!
renderLegend={renderLegend}
legend={options.legend}
timeZone={timeZone}
timeZones={timeZone}
timeRange={{ from: 1, to: 1 } as unknown as TimeRange} // HACK
structureRev={structureRev}
width={width}

View File

@ -116,7 +116,7 @@ describe('BarChart utils', () => {
orientation: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
allFrames: [frame],
@ -131,7 +131,7 @@ describe('BarChart utils', () => {
showValue: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
allFrames: [frame],
@ -146,7 +146,7 @@ describe('BarChart utils', () => {
stacking: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
allFrames: [frame],

View File

@ -233,7 +233,7 @@ export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
frames={[info.frame]}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
timeZones={timeZone}
width={width}
height={height}
legend={options.legend}

View File

@ -8,6 +8,7 @@ import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPl
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getTimezones } from '../timeseries/utils';
import { StateTimelineTooltip } from './StateTimelineTooltip';
import { TimelineChart } from './TimelineChart';
@ -42,6 +43,8 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
[frames, options.legend, theme]
);
const timezones = useMemo(() => getTimezones(options.timezones, timeZone), [options.timezones, timeZone]);
const renderCustomTooltip = useCallback(
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
const data = frames ?? [];
@ -104,7 +107,7 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
timeZones={timezones}
width={width}
height={height}
legendItems={legendItems}

View File

@ -61,6 +61,9 @@ export class TimelineChart extends React.Component<TimelineProps> {
allFrames: this.props.frames,
...this.props,
// Ensure timezones is passed as an array
timeZones: Array.isArray(this.props.timeZones) ? this.props.timeZones : [this.props.timeZones],
// When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1,
getValueColor: this.getValueColor,

View File

@ -32,6 +32,7 @@ Panel: thema.#Lineage & {
mode?: TimelineMode
ui.OptionsWithLegend
ui.OptionsWithTooltip
ui.OptionsWithTimezones
showValue: ui.VisibilityMode | *"auto"
rowHeight: number | *0.9
colWidth?: number

View File

@ -1,10 +1,16 @@
import { DashboardCursorSync } from '@grafana/data';
import { HideableFieldConfig, OptionsWithLegend, OptionsWithTooltip, VisibilityMode } from '@grafana/schema';
import {
HideableFieldConfig,
OptionsWithLegend,
OptionsWithTimezones,
OptionsWithTooltip,
VisibilityMode,
} from '@grafana/schema';
/**
* @alpha
*/
export interface TimelineOptions extends OptionsWithLegend, OptionsWithTooltip {
export interface TimelineOptions extends OptionsWithLegend, OptionsWithTooltip, OptionsWithTimezones {
mode: TimelineMode; // not in the saved model!
showValue: VisibilityMode;

View File

@ -56,7 +56,7 @@ export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityCh
export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
frame,
theme,
timeZone,
timeZones,
getTimeRange,
mode,
eventBus,
@ -68,7 +68,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
mergeValues,
getValueColor,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
const builder = new UPlotConfigBuilder(timeZones[0]);
const xScaleUnit = 'time';
const xScaleKey = 'x';
@ -185,7 +185,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
isTime: true,
splits: coreConfig.xSplits!,
placement: AxisPlacement.Bottom,
timeZone,
timeZone: timeZones[0],
theme,
grid: { show: true },
});

View File

@ -7,6 +7,7 @@ import { TimelineChart } from '../state-timeline/TimelineChart';
import { TimelineMode } from '../state-timeline/types';
import { prepareTimelineFields, prepareTimelineLegendItems } from '../state-timeline/utils';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getTimezones } from '../timeseries/utils';
import { StatusPanelOptions } from './types';
@ -36,6 +37,8 @@ export const StatusHistoryPanel: React.FC<TimelinePanelProps> = ({
[frames, options.legend, theme]
);
const timezones = useMemo(() => getTimezones(options.timezones, timeZone), [options.timezones, timeZone]);
if (!frames || warn) {
return (
<div className="panel-empty">
@ -62,7 +65,7 @@ export const StatusHistoryPanel: React.FC<TimelinePanelProps> = ({
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
timeZones={timezones}
width={width}
height={height}
legendItems={legendItems}

View File

@ -28,6 +28,7 @@ Panel: thema.#Lineage & {
PanelOptions: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
ui.OptionsWithTimezones
showValue: ui.VisibilityMode
rowHeight: number
colWidth?: number

View File

@ -1,9 +1,15 @@
import { HideableFieldConfig, VisibilityMode, OptionsWithTooltip, OptionsWithLegend } from '@grafana/schema';
import {
HideableFieldConfig,
VisibilityMode,
OptionsWithTooltip,
OptionsWithLegend,
OptionsWithTimezones,
} from '@grafana/schema';
/**
* @alpha
*/
export interface StatusPanelOptions extends OptionsWithTooltip, OptionsWithLegend {
export interface StatusPanelOptions extends OptionsWithTooltip, OptionsWithLegend, OptionsWithTimezones {
showValue: VisibilityMode;
rowHeight: number;
colWidth?: number;

View File

@ -14,7 +14,7 @@ import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
import { TimeSeriesOptions } from './types';
import { prepareGraphableFields } from './utils';
import { getTimezones, prepareGraphableFields } from './utils';
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
@ -37,6 +37,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
};
const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data, timeRange]);
const timezones = useMemo(() => getTimezones(options.timezones, timeZone), [options.timezones, timeZone]);
if (!frames) {
return (
@ -57,7 +58,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
timeZones={timezones}
width={width}
height={height}
legend={options.legend}

View File

@ -0,0 +1,70 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, InternalTimeZones, StandardEditorProps } from '@grafana/data';
import { OptionsWithTimezones } from '@grafana/schema';
import { IconButton, TimeZonePicker, useStyles2 } from '@grafana/ui';
type Props = StandardEditorProps<string[], unknown, OptionsWithTimezones>;
export const TimezonesEditor = ({ value, onChange }: Props) => {
const styles = useStyles2(getStyles);
if (!value || value.length < 1) {
value = [''];
}
const addTimezone = () => {
onChange([...value, InternalTimeZones.default]);
};
const removeTimezone = (idx: number) => {
const copy = value.slice();
copy.splice(idx, 1);
onChange(copy);
};
const setTimezone = (idx: number, tz?: string) => {
const copy = value.slice();
copy[idx] = tz ?? InternalTimeZones.default;
if (copy.length === 0 || (copy.length === 1 && copy[0] === '')) {
onChange(undefined);
} else {
onChange(copy);
}
};
return (
<div>
{value.map((tz, idx) => (
<div className={styles.wrapper} key={`${idx}.${tz}`}>
<span className={styles.first}>
<TimeZonePicker
onChange={(v) => setTimezone(idx, v)}
includeInternal={true}
value={tz ?? InternalTimeZones.default}
/>
</span>
{idx === value.length - 1 ? (
<IconButton ariaLabel="Add timezone" name="plus" onClick={addTimezone} />
) : (
<IconButton ariaLabel="Remove timezone" name="times" onClick={() => removeTimezone(idx)} />
)}
</div>
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
width: 100%;
display: flex;
flex-direction: rows;
align-items: center;
`,
first: css`
margin-right: 8px;
flex-grow: 2;
`,
});

View File

@ -3,6 +3,7 @@ import { GraphFieldConfig } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { TimeSeriesPanel } from './TimeSeriesPanel';
import { TimezonesEditor } from './TimezonesEditor';
import { defaultGraphConfig, getGraphFieldConfig } from './config';
import { graphPanelChangedHandler } from './migrations';
import { TimeSeriesSuggestionsSupplier } from './suggestions';
@ -14,6 +15,15 @@ export const plugin = new PanelPlugin<TimeSeriesOptions, GraphFieldConfig>(TimeS
.setPanelOptions((builder) => {
commonOptionsBuilder.addTooltipOptions(builder);
commonOptionsBuilder.addLegendOptions(builder);
builder.addCustomEditor({
id: 'timezones',
name: 'Timezone',
path: 'timezones',
category: ['Axis'],
editor: TimezonesEditor,
defaultValue: undefined,
});
})
.setSuggestionsSupplier(new TimeSeriesSuggestionsSupplier())
.setDataSupport({ annotations: true, alertStates: true });

View File

@ -1,3 +1,3 @@
import { OptionsWithLegend, OptionsWithTooltip } from '@grafana/schema';
import { OptionsWithLegend, OptionsWithTimezones, OptionsWithTooltip } from '@grafana/schema';
export interface TimeSeriesOptions extends OptionsWithLegend, OptionsWithTooltip {}
export interface TimeSeriesOptions extends OptionsWithLegend, OptionsWithTooltip, OptionsWithTimezones {}

View File

@ -116,3 +116,10 @@ export function prepareGraphableFields(
return null;
}
export function getTimezones(timezones: string[] | undefined, defaultTimezone: string): string[] {
if (!timezones || !timezones.length) {
return [defaultTimezone];
}
return timezones.map((v) => (v?.length ? v : defaultTimezone));
}