From faad4b92adba175ff2bc2f467c460ce899da1bca Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 21 Apr 2023 15:25:50 -0500 Subject: [PATCH] GrafanaDS: Add support for annotation time regions (#65462) Co-authored-by: Ryan McKinley Co-authored-by: Adela Almasan --- .betterer.results | 5 +- .../DateTimePickers/TimeZonePicker.tsx | 8 +- public/app/core/utils/timeRegions.test.ts | 146 +++++- public/app/core/utils/timeRegions.ts | 50 +- .../components/AnnotationQueryEditor.tsx | 128 +++-- .../grafana/components/TimePickerInput.tsx | 189 +++++++ .../grafana/components/TimeRegionEditor.tsx | 191 +++++++ .../plugins/datasource/grafana/datasource.ts | 27 +- .../datasource/grafana/timeRegions.test.ts | 490 ++++++++++++++++++ .../plugins/datasource/grafana/timeRegions.ts | 48 ++ .../app/plugins/datasource/grafana/types.ts | 3 + .../panel/timeseries/TimeSeriesPanel.tsx | 29 +- 12 files changed, 1231 insertions(+), 83 deletions(-) create mode 100644 public/app/plugins/datasource/grafana/components/TimePickerInput.tsx create mode 100644 public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx create mode 100644 public/app/plugins/datasource/grafana/timeRegions.test.ts create mode 100644 public/app/plugins/datasource/grafana/timeRegions.ts diff --git a/.betterer.results b/.betterer.results index a4cb078f9fd..1bd25927cc8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4204,9 +4204,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], + [0, 0, 0, "Do not use any type assertions.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "11"], + [0, 0, 0, "Do not use any type assertions.", "12"] ], "public/app/plugins/datasource/graphite/components/FunctionEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx index 87ef42fd9e0..093a2ebde95 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker.tsx @@ -27,6 +27,8 @@ export interface Props { includeInternal?: boolean | InternalTimeZones[]; disabled?: boolean; inputId?: string; + menuShouldPortal?: boolean; + openMenuOnFocus?: boolean; } export const TimeZonePicker = (props: Props) => { @@ -39,6 +41,8 @@ export const TimeZonePicker = (props: Props) => { includeInternal = false, disabled = false, inputId, + menuShouldPortal = false, + openMenuOnFocus = true, } = props; const groupedTimeZones = useTimeZones(includeInternal); const selected = useSelectedTimeZone(groupedTimeZones, value); @@ -61,8 +65,8 @@ export const TimeZonePicker = (props: Props) => { value={selected} placeholder={t('time-picker.zone.select-search-input', 'Type to search (country, city, abbreviation)')} autoFocus={autoFocus} - menuShouldPortal={false} - openMenuOnFocus={true} + menuShouldPortal={menuShouldPortal} + openMenuOnFocus={openMenuOnFocus} width={width} filterOption={filterBySearchIndex} options={groupedTimeZones} diff --git a/public/app/core/utils/timeRegions.test.ts b/public/app/core/utils/timeRegions.test.ts index 643883a2707..9805e28824f 100644 --- a/public/app/core/utils/timeRegions.test.ts +++ b/public/app/core/utils/timeRegions.test.ts @@ -1,40 +1,160 @@ import { dateTime, TimeRange } from '@grafana/data'; +import { TimeRegionConfig } from 'app/core/utils/timeRegions'; -import { calculateTimesWithin, TimeRegionConfig } from './timeRegions'; +import { calculateTimesWithin } from './timeRegions'; +// note: calculateTimesWithin always returns time ranges in UTC describe('timeRegions', () => { describe('day of week', () => { - it('4 sundays in january 2021', () => { + it('returns regions with 4 Mondays in March 2023', () => { const cfg: TimeRegionConfig = { fromDayOfWeek: 1, - from: '12:00', }; + const tr: TimeRange = { - from: dateTime('2021-01-00', 'YYYY-MM-dd'), - to: dateTime('2021-02-00', 'YYYY-MM-dd'), + from: dateTime('2023-03-01'), + to: dateTime('2023-03-31'), raw: { to: '', from: '', }, }; + const regions = calculateTimesWithin(cfg, tr); expect(regions).toMatchInlineSnapshot(` [ { - "from": 1609779600000, - "to": 1609779600000, + "from": 1678060800000, + "to": 1678147199000, }, { - "from": 1610384400000, - "to": 1610384400000, + "from": 1678665600000, + "to": 1678751999000, }, { - "from": 1610989200000, - "to": 1610989200000, + "from": 1679270400000, + "to": 1679356799000, }, { - "from": 1611594000000, - "to": 1611594000000, + "from": 1679875200000, + "to": 1679961599000, + }, + ] + `); + }); + }); + describe('day and time of week', () => { + it('returns regions with 4 Mondays at 20:00 in March 2023', () => { + const cfg: TimeRegionConfig = { + fromDayOfWeek: 1, + from: '20:00', + }; + + const tr: TimeRange = { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-31'), + raw: { + to: '', + from: '', + }, + }; + + const regions = calculateTimesWithin(cfg, tr); + expect(regions).toMatchInlineSnapshot(` + [ + { + "from": 1678132800000, + "to": 1678132800000, + }, + { + "from": 1678737600000, + "to": 1678737600000, + }, + { + "from": 1679342400000, + "to": 1679342400000, + }, + { + "from": 1679947200000, + "to": 1679947200000, + }, + ] + `); + }); + }); + describe('day of week range', () => { + it('returns regions with days range', () => { + const cfg: TimeRegionConfig = { + fromDayOfWeek: 1, + toDayOfWeek: 3, + }; + + const tr: TimeRange = { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-31'), + raw: { + to: '', + from: '', + }, + }; + + const regions = calculateTimesWithin(cfg, tr); + expect(regions).toMatchInlineSnapshot(` + [ + { + "from": 1678060800000, + "to": 1678319999000, + }, + { + "from": 1678665600000, + "to": 1678924799000, + }, + { + "from": 1679270400000, + "to": 1679529599000, + }, + { + "from": 1679875200000, + "to": 1680134399000, + }, + ] + `); + }); + it('returns regions with days/times range', () => { + const cfg: TimeRegionConfig = { + fromDayOfWeek: 1, + from: '20:00', + toDayOfWeek: 2, + to: '10:00', + }; + + const tr: TimeRange = { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-31'), + raw: { + to: '', + from: '', + }, + }; + + const regions = calculateTimesWithin(cfg, tr); + expect(regions).toMatchInlineSnapshot(` + [ + { + "from": 1678132800000, + "to": 1678183200000, + }, + { + "from": 1678737600000, + "to": 1678788000000, + }, + { + "from": 1679342400000, + "to": 1679392800000, + }, + { + "from": 1679947200000, + "to": 1679997600000, }, ] `); diff --git a/public/app/core/utils/timeRegions.ts b/public/app/core/utils/timeRegions.ts index 329453cf014..363519021dd 100644 --- a/public/app/core/utils/timeRegions.ts +++ b/public/app/core/utils/timeRegions.ts @@ -6,6 +6,8 @@ export interface TimeRegionConfig { to?: string; toDayOfWeek?: number; // 1-7 + + timezone?: string; } interface ParsedTime { @@ -32,8 +34,8 @@ export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): } const hRange = { - from: parseTimeRange(timeRegion.from), - to: parseTimeRange(timeRegion.to), + from: parseTimeOfDay(timeRegion.from), + to: parseTimeOfDay(timeRegion.to), }; if (!timeRegion.fromDayOfWeek && timeRegion.toDayOfWeek) { @@ -78,10 +80,11 @@ export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): const regions: AbsoluteTimeRange[] = []; - const fromStart = dateTime(tRange.from); + const fromStart = dateTime(tRange.from).utc(); fromStart.set('hour', 0); fromStart.set('minute', 0); fromStart.set('second', 0); + fromStart.set('millisecond', 0); fromStart.add(hRange.from.h, 'hours'); fromStart.add(hRange.from.m, 'minutes'); fromStart.add(hRange.from.s, 'seconds'); @@ -95,7 +98,7 @@ export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): break; } - const fromEnd = dateTime(fromStart); + const fromEnd = dateTime(fromStart).utc(); if (fromEnd.hour) { if (hRange.from.h <= hRange.to.h) { @@ -134,35 +137,36 @@ export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): return regions; } -function parseTimeRange(str?: string): ParsedTime { +export function parseTimeOfDay(str?: string): ParsedTime { const result: ParsedTime = {}; if (!str?.length) { return result; } - const timeRegex = /^([\d]+):?(\d{2})?/; - const match = timeRegex.exec(str); - - if (!match) { + const match = str.split(':'); + if (!match?.length) { return result; } + result.h = Math.min(23, Math.max(0, Number(match[0]))); if (match.length > 1) { - result.h = Number(match[1]); - result.m = 0; - - if (match.length > 2 && match[2] !== undefined) { - result.m = Number(match[2]); - } - - if (result.h > 23) { - result.h = 23; - } - - if (result.m > 59) { - result.m = 59; + result.m = Math.min(60, Math.max(0, Number(match[1]))); + if (match.length > 2) { + result.s = Math.min(60, Math.max(0, Number(match[2]))); } } - return result; } + +export function formatTimeOfDayString(t?: ParsedTime): string { + if (!t || (t.h == null && t.m == null && t.s == null)) { + return ''; + } + + let str = String(t.h ?? 0).padStart(2, '0') + ':' + String(t.m ?? 0).padStart(2, '0'); + if (t.s != null) { + str += String(t.s ?? 0).padStart(2, '0'); + } + + return str; +} diff --git a/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx index 477d282fa61..0894f13ab2b 100644 --- a/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/AnnotationQueryEditor.tsx @@ -1,12 +1,16 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useMemo } from 'react'; -import { SelectableValue } from '@grafana/data'; -import { Field, FieldSet, Select, Switch } from '@grafana/ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Field, FieldSet, Select, Switch, useStyles2 } from '@grafana/ui'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; +import { TimeRegionConfig } from 'app/core/utils/timeRegions'; import { getAnnotationTags } from 'app/features/annotations/api'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery } from '../types'; +import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery, GrafanaQueryType } from '../types'; + +import { TimeRegionEditor } from './TimeRegionEditor'; const matchTooltipContent = 'Enabling this returns annotations that match any of the tags specified below'; @@ -14,7 +18,7 @@ const tagsTooltipContent = (
Specify a list of tags to match. To specify a key and value tag use `key:value` syntax.
); -const annotationTypes = [ +const annotationTypes: Array> = [ { label: 'Dashboard', value: GrafanaAnnotationType.Dashboard, @@ -27,6 +31,19 @@ const annotationTypes = [ }, ]; +const queryTypes: Array> = [ + { + label: 'Annotations & Alerts', + value: GrafanaQueryType.Annotations, + description: 'Show annotations or alerts managed by grafana', + }, + { + label: 'Time regions', + value: GrafanaQueryType.TimeRegions, + description: 'Configure a repeating time region', + }, +]; + const limitOptions = [10, 50, 100, 200, 300, 500, 1000, 2000].map((limit) => ({ label: String(limit), value: limit, @@ -39,8 +56,10 @@ interface Props { export default function AnnotationQueryEditor({ query, onChange }: Props) { const annotationQuery = query as GrafanaAnnotationQuery; - const { limit, matchAny, tags, type } = annotationQuery; - const styles = getStyles(); + const { limit, matchAny, tags, type, queryType } = annotationQuery; + let grafanaQueryType = queryType ?? GrafanaQueryType.Annotations; + const defaultTimezone = useMemo(() => getDashboardSrv().dashboard?.getTimezone(), []); + const styles = useStyles2(getStyles); const onFilterByChange = (newValue: SelectableValue) => onChange({ @@ -66,49 +85,86 @@ export default function AnnotationQueryEditor({ query, onChange }: Props) { tags, }); + const onQueryTypeChange = (newValue: SelectableValue) => { + const newQuery: GrafanaAnnotationQuery = { ...annotationQuery, queryType: newValue.value! }; + if (newQuery.queryType === GrafanaQueryType.TimeRegions) { + if (!newQuery.timeRegion) { + newQuery.timeRegion = { + timezone: defaultTimezone, + }; + } + } else { + delete newQuery.timeRegion; + } + + onChange(newQuery); + }; + const onTimeRegionChange = (timeRegion?: TimeRegionConfig) => { + onChange({ + ...annotationQuery, + timeRegion, + }); + }; + return (
- + - - {type === GrafanaAnnotationType.Tags && ( + {grafanaQueryType === GrafanaQueryType.Annotations && ( <> - - - - - + + + {type === GrafanaAnnotationType.Tags && ( + <> + + + + + + + + )} )} + {grafanaQueryType === GrafanaQueryType.TimeRegions && annotationQuery.timeRegion && ( + + )}
); } -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { - container: css` - max-width: 600px; - `, + container: css({ + maxWidth: theme.spacing(60), + marginBottom: theme.spacing(2), + }), }; }; diff --git a/public/app/plugins/datasource/grafana/components/TimePickerInput.tsx b/public/app/plugins/datasource/grafana/components/TimePickerInput.tsx new file mode 100644 index 00000000000..00d9fe07671 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/TimePickerInput.tsx @@ -0,0 +1,189 @@ +import { css, cx } from '@emotion/css'; +import { Moment } from 'moment'; +import TimePicker from 'rc-time-picker'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { FormInputSize, Icon, useStyles2 } from '@grafana/ui'; +import { inputSizes } from '@grafana/ui/src/components/Forms/commonStyles'; +import { focusCss } from '@grafana/ui/src/themes/mixins'; + +export interface Props { + onChange: (value: Moment) => void; + value?: Moment; + defaultValue?: Moment; + showHour?: boolean; + showSeconds?: boolean; + minuteStep?: number; + size?: FormInputSize; + disabled?: boolean; + disabledHours?: () => number[]; + disabledMinutes?: () => number[]; + disabledSeconds?: () => number[]; + placeholder?: string; + format?: string; + allowEmpty?: boolean; + width?: number; +} + +export const POPUP_CLASS_NAME = 'time-of-day-picker-panel'; + +// @TODO fix TimeOfDayPicker and switch? +export const TimePickerInput = ({ + minuteStep = 1, + showHour = true, + showSeconds = false, + onChange, + value, + size = 'auto', + disabled, + disabledHours, + disabledMinutes, + disabledSeconds, + placeholder, + format = 'HH:mm', + defaultValue = undefined, + allowEmpty = false, + width, +}: Props) => { + const styles = useStyles2(getStyles); + + const getWidth = () => { + if (width) { + return css` + width: ${width}px; + `; + } + + return inputSizes()[size]; + }; + + return ( + onChange(v)} + showHour={showHour} + showSecond={showSeconds} + format={format} + allowEmpty={allowEmpty} + className={cx(getWidth(), styles.input)} + popupClassName={cx(styles.picker, POPUP_CLASS_NAME)} + minuteStep={minuteStep} + inputIcon={} + disabled={disabled} + disabledHours={disabledHours} + disabledMinutes={disabledMinutes} + disabledSeconds={disabledSeconds} + placeholder={placeholder} + /> + ); +}; + +interface CaretProps { + wrapperStyle?: string; +} + +const Caret = ({ wrapperStyle = '' }: CaretProps) => { + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + const bgColor = theme.components.input.background; + const menuShadowColor = theme.v1.palette.black; + const optionBgHover = theme.colors.background.secondary; + const borderRadius = theme.shape.borderRadius(1); + const borderColor = theme.components.input.borderColor; + + return { + caretWrapper: css` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: inline-block; + text-align: right; + color: ${theme.colors.text.secondary}; + `, + picker: css` + .rc-time-picker-panel-select { + font-size: 14px; + background-color: ${bgColor}; + border-color: ${borderColor}; + li { + outline-width: 2px; + &.rc-time-picker-panel-select-option-selected { + background-color: inherit; + border: 1px solid ${theme.v1.palette.orange}; + border-radius: ${borderRadius}; + } + + &:hover { + background: ${optionBgHover}; + } + + &.rc-time-picker-panel-select-option-disabled { + color: ${theme.colors.action.disabledText}; + } + } + } + + .rc-time-picker-panel-inner { + box-shadow: 0px 4px 4px ${menuShadowColor}; + background-color: ${bgColor}; + border-color: ${borderColor}; + border-radius: ${borderRadius}; + margin-top: 3px; + + .rc-time-picker-panel-input-wrap { + margin-right: 2px; + + &, + .rc-time-picker-panel-input { + background-color: ${bgColor}; + padding-top: 2px; + } + } + + .rc-time-picker-panel-combobox { + display: flex; + } + } + `, + input: css` + .rc-time-picker-input { + background-color: ${bgColor}; + border-radius: ${borderRadius}; + border-color: ${borderColor}; + height: ${theme.spacing(4)}; + + &:focus { + ${focusCss(theme)} + } + + &:disabled { + background-color: ${theme.colors.action.disabledBackground}; + color: ${theme.colors.action.disabledText}; + border: 1px solid ${theme.colors.action.disabledBackground}; + &:focus { + box-shadow: none; + } + } + } + + .rc-time-picker-clear { + position: absolute; + right: 20px; + top: 50%; + cursor: pointer; + overflow: hidden; + transform: translateY(-50%); + color: ${theme.colors.text.secondary}; + } + `, + }; +}; diff --git a/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx b/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx new file mode 100644 index 00000000000..932609afecb --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx @@ -0,0 +1,191 @@ +import { css } from '@emotion/css'; +import moment, { Moment } from 'moment/moment'; +import React, { useState } from 'react'; + +import { getTimeZoneInfo, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Button, Field, FieldSet, HorizontalGroup, Select, TimeZonePicker, useStyles2 } from '@grafana/ui'; +import { TimeZoneOffset } from '@grafana/ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset'; +import { TimeZoneTitle } from '@grafana/ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneTitle'; +import { TimeRegionConfig } from 'app/core/utils/timeRegions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +import { TimePickerInput } from './TimePickerInput'; + +interface Props { + value: TimeRegionConfig; + onChange: (value?: TimeRegionConfig) => void; +} + +const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((v, idx) => { + return { + label: v, + value: idx + 1, + }; +}); +export const TimeRegionEditor = ({ value, onChange }: Props) => { + const styles = useStyles2(getStyles); + const timestamp = Date.now(); + const timezoneInfo = getTimeZoneInfo(value.timezone ?? 'utc', timestamp); + const isDashboardTimezone = getDashboardSrv().getCurrent()?.getTimezone() === value.timezone; + + const [isEditing, setEditing] = useState(false); + + const onToggleChangeTimezone = () => { + setEditing(!isEditing); + }; + + const getTime = (time: string | undefined): Moment | undefined => { + if (!time) { + return undefined; + } + + const date = moment(); + + if (time) { + const match = time.split(':'); + date.set('hour', parseInt(match[0], 10)); + date.set('minute', parseInt(match[1], 10)); + } + + return date; + }; + + const getToPlaceholder = () => { + let placeholder = 'Everyday'; + if (value.fromDayOfWeek && !value.toDayOfWeek) { + placeholder = days[value.fromDayOfWeek - 1].label; + } + + return placeholder; + }; + + const renderTimezonePicker = () => { + const timezone = ( + <> + + + + ); + + if (isDashboardTimezone) { + return <>Dashboard timezone ({timezone}); + } + + return timezone; + }; + + const onTimeChange = (v: Moment, field: string) => { + const time = v ? v.format('HH:mm') : undefined; + if (field === 'from') { + onChange({ ...value, from: time }); + } else { + onChange({ ...value, to: time }); + } + }; + + const onTimezoneChange = (v: string | undefined) => { + onChange({ ...value, timezone: v }); + }; + + const onFromDayOfWeekChange = (v: SelectableValue) => { + const fromDayOfWeek = v ? v.value : undefined; + const toDayOfWeek = v ? value.toDayOfWeek : undefined; // clear if everyday + onChange({ ...value, fromDayOfWeek, toDayOfWeek }); + }; + + const onToDayOfWeekChange = (v: SelectableValue) => { + onChange({ ...value, toDayOfWeek: v ? v.value : undefined }); + }; + + const renderTimezone = () => { + if (isEditing) { + return ( + onTimezoneChange(v)} + onBlur={() => setEditing(false)} + menuShouldPortal={true} + openMenuOnFocus={false} + width={100} + autoFocus + /> + ); + } + + return ( +
+
{renderTimezonePicker()}
+ +
+ ); + }; + + return ( +
+ + + onToDayOfWeekChange(v)} + width={20} + /> + )} + onTimeChange(v, 'to')} + allowEmpty={true} + placeholder="HH:mm" + width={100} + /> + + + {renderTimezone()} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + maxWidth: theme.spacing(60), + marginBottom: theme.spacing(2), + }), + timezoneContainer: css` + padding: 5px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 12px; + `, + timezone: css` + margin-right: 5px; + `, + }; +}; diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index d453d362986..b9e22bdfe78 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -30,6 +30,7 @@ import { migrateDatasourceNameToRef } from 'app/features/dashboard/state/Dashboa import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; import AnnotationQueryEditor from './components/AnnotationQueryEditor'; +import { doTimeRegionQuery } from './timeRegions'; import { GrafanaAnnotationQuery, GrafanaAnnotationType, GrafanaQuery, GrafanaQueryType } from './types'; let counter = 100; @@ -96,12 +97,25 @@ export class GrafanaDatasource extends DataSourceWithBackend { if (target.queryType === GrafanaQueryType.Snapshot) { results.push( of({ + // NOTE refId is intentionally missing because: + // 1) there is only one snapshot + // 2) the payload will reference original refIds data: (target.snapshot ?? []).map((v) => dataFrameFromJSON(v)), state: LoadingState.Done, }) ); continue; } + if (target.queryType === GrafanaQueryType.TimeRegions) { + const frame = doTimeRegionQuery('', target.timeRegion!, request.range, request.timezone); + results.push( + of({ + data: frame ? [frame] : [], + state: LoadingState.Done, + }) + ); + continue; + } if (target.queryType === GrafanaQueryType.LiveMeasurements) { let channel = templateSrv.replace(target.channel, request.scopedVars); const { filter } = target; @@ -177,7 +191,17 @@ export class GrafanaDatasource extends DataSourceWithBackend { } async getAnnotations(options: AnnotationQueryRequest): Promise { - const templateSrv = getTemplateSrv(); + const query = options.annotation.target as GrafanaQuery; + if (query?.queryType === GrafanaQueryType.TimeRegions) { + const frame = doTimeRegionQuery( + options.annotation.name, + query.timeRegion!, + options.range, + getDashboardSrv().getCurrent()?.timezone // Annotation queries don't include the timezone + ); + return Promise.resolve({ data: frame ? [frame] : [] }); + } + const annotation = options.annotation as unknown as AnnotationQuery; const target = annotation.target!; const params: any = { @@ -202,6 +226,7 @@ export class GrafanaDatasource extends DataSourceWithBackend { if (!Array.isArray(target.tags) || target.tags.length === 0) { return Promise.resolve({ data: [] }); } + const templateSrv = getTemplateSrv(); const delimiter = '__delimiter__'; const tags = []; for (const t of params.tags) { diff --git a/public/app/plugins/datasource/grafana/timeRegions.test.ts b/public/app/plugins/datasource/grafana/timeRegions.test.ts new file mode 100644 index 00000000000..f5502fec661 --- /dev/null +++ b/public/app/plugins/datasource/grafana/timeRegions.test.ts @@ -0,0 +1,490 @@ +import { dateTime, toDataFrameDTO } from '@grafana/data'; + +import { doTimeRegionQuery } from './timeRegions'; + +describe('grafana data source', () => { + it('supports time region query', () => { + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, toDayOfWeek: 2 }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-31'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!)).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678060800000, + 1678665600000, + 1679270400000, + 1679875200000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678233599000, + 1678838399000, + 1679443199000, + 1680047999000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + "test", + "test", + "test", + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, + } + `); + }); + + it('handles timezone conversion UTC-UTC', () => { + // region TZ = UTC + // dashboard TZ = UTC + // Mon Mar 06 2023 00:00:00 GMT+0000 -> Mon Mar 06 2023 23:59:59 GMT+0000 + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'utc' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678060800000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678147199000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion browser-UTC', () => { + // region TZ = browser (Pacific/Easter) + // dashboard TZ = UTC + // Mon Mar 06 2023 00:00:00 GMT-0600 -> Mon Mar 06 2023 23:59:59 GMT-0600 + // Mon Mar 06 2023 06:00:00 GMT+0000 -> Mon Mar 06 2023 05:59:59 GMT+0000 + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'browser' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678078800000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678165199000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion CST-UTC', () => { + // region TZ = America/Chicago (CST) + // dashboard TZ = UTC + // Mon Mar 06 2023 00:00:00 GMT-0600 -> Mon Mar 06 2023 23:59:59 GMT-0600 (CDT) + // Mon Mar 06 2023 06:00:00 GMT+0000 -> Tue Mar 07 2023 05:59:59 GMT+0000 + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'America/Chicago' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678082400000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678168799000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion Europe/Amsterdam-UTC', () => { + // region TZ = Europe/Amsterdam + // dashboard TZ = UTC + // Mon Mar 06 2023 00:00:00 GMT+0100 -> Mon Mar 06 2023 23:59:59 GMT+0100 (Europe/Amsterdam) + // Sun Mar 05 2023 23:00:00 GMT+0000 -> Mon Mar 06 2023 22:59:59 GMT+0000 + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'Europe/Amsterdam' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678057200000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678143599000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion Asia/Hovd-UTC', () => { + // region TZ = Asia/Hovd + // dashboard TZ = UTC + // Mon Mar 06 2023 00:00:00 GMT+0700 -> Mon Mar 06 2023 23:59:59 GMT+0700 (Asia/Hovd) + // Sun Mar 05 2023 17:00:00 GMT+0000 -> Mon Mar 06 2023 16:59:59 GMT+0000 + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'Asia/Hovd' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'utc' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678035600000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678121999000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion UTC-Asia/Dubai', () => { + // region TZ = UTC + // dashboard TZ = Asia/Dubai + // Mon Mar 06 2023 00:00:00 GMT+0000 -> Mon Mar 06 2023 23:59:59 GMT+0000 (UTC) + // Mon Mar 06 2023 04:00:00 GMT+0400 -> Mon Mar 06 2023 03:59:59 GMT+0400 (Asia/Dubai) + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, timezone: 'utc' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'Asia/Dubai' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678060800000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678147199000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion UTC-CST', () => { + // region TZ = UTC + // dashboard TZ = 'America/Chicago' + // Mon Mar 06 2023 08:00:00 GMT+0000 -> Mon Mar 06 2023 08:00:00 GMT+0000 (UTC) + // Mon Mar 06 2023 02:00:00 GMT-0600 -> Mon Mar 06 2023 02:00:00 GMT-0600 (CST) + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, from: '08:00', timezone: 'utc' }, + { + from: dateTime('2023-03-01'), + to: dateTime('2023-03-08'), + raw: { + to: '', + from: '', + }, + }, + 'America/Chicago' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1678089600000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1678089600000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); + + it('handles timezone conversion UTC-CDT', () => { + // region TZ = UTC + // dashboard TZ = 'America/Chicago' + // Mon Apr 03 2023 08:00:00 GMT+0000 -> Mon Apr 03 2023 08:00:00 GMT+0000 (UTC) + // Mon Apr 03 2023 03:00:00 GMT-0500 -> Mon Apr 03 2023 03:00:00 GMT-0500 (CDT) + + const frame = doTimeRegionQuery( + 'test', + { fromDayOfWeek: 1, from: '08:00', timezone: 'utc' }, + { + from: dateTime('2023-03-30'), + to: dateTime('2023-04-06'), + raw: { + to: '', + from: '', + }, + }, + 'America/Chicago' + ); + + expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` + [ + { + "config": {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": [ + 1680508800000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "timeEnd", + "type": "time", + "values": [ + 1680508800000, + ], + }, + { + "config": {}, + "labels": undefined, + "name": "text", + "type": "string", + "values": [ + "test", + ], + }, + ] + `); + }); +}); diff --git a/public/app/plugins/datasource/grafana/timeRegions.ts b/public/app/plugins/datasource/grafana/timeRegions.ts new file mode 100644 index 00000000000..8c7f000ba2a --- /dev/null +++ b/public/app/plugins/datasource/grafana/timeRegions.ts @@ -0,0 +1,48 @@ +import { TimeRange, DataFrame, FieldType, getTimeZoneInfo } from '@grafana/data'; +import { TimeRegionConfig, calculateTimesWithin } from 'app/core/utils/timeRegions'; + +export function doTimeRegionQuery( + name: string, + config: TimeRegionConfig, + range: TimeRange, + tz: string +): DataFrame | undefined { + if (!config) { + return undefined; + } + const regions = calculateTimesWithin(config, range); // UTC + if (!regions.length) { + return undefined; + } + + const times: number[] = []; + const timesEnd: number[] = []; + const texts: string[] = []; + + const regionTimezone = config.timezone ?? tz; + + for (const region of regions) { + let from = region.from; + let to = region.to; + + const info = getTimeZoneInfo(regionTimezone, from); + if (info) { + const offset = info.offsetInMins * 60 * 1000; + from += offset; + to += offset; + } + + times.push(from); + timesEnd.push(to); + texts.push(name); + } + + return { + fields: [ + { name: 'time', type: FieldType.time, values: times, config: {} }, + { name: 'timeEnd', type: FieldType.time, values: timesEnd, config: {} }, + { name: 'text', type: FieldType.string, values: texts, config: {} }, + ], + length: times.length, + }; +} diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index 46c05a22409..b26c07c1db8 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -1,6 +1,7 @@ import { DataFrameJSON } from '@grafana/data'; import { LiveDataFilter } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { TimeRegionConfig } from 'app/core/utils/timeRegions'; import { SearchQuery } from 'app/features/search/service'; //---------------------------------------------- @@ -11,6 +12,7 @@ export enum GrafanaQueryType { LiveMeasurements = 'measurements', Annotations = 'annotations', Snapshot = 'snapshot', + TimeRegions = 'timeRegions', // backend RandomWalk = 'randomWalk', @@ -27,6 +29,7 @@ export interface GrafanaQuery extends DataQuery { path?: string; // for list and read search?: SearchQuery; snapshot?: DataFrameJSON[]; + timeRegion?: TimeRegionConfig; file?: GrafanaQueryFile; } diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 85d6d67c45a..d75d81c34c6 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { Field, PanelProps } from '@grafana/data'; +import { DataFrame, Field, PanelProps } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; import { KeyboardPlugin, TimeSeries, TooltipPlugin, usePanelContext, ZoomPlugin } from '@grafana/ui'; @@ -37,6 +37,25 @@ export const TimeSeriesPanel = ({ return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); }; + const { annotations, exemplars } = useMemo(() => { + let annotations: DataFrame[] | null = null; + let exemplars: DataFrame[] | null = null; + + if (data?.annotations?.length) { + annotations = []; + exemplars = []; + for (let frame of data.annotations) { + if (frame.name === 'exemplar') { + exemplars.push(frame); + } else { + annotations.push(frame); + } + } + } + + return { annotations, exemplars }; + }, [data.annotations]); + const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data, timeRange]); const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); @@ -88,9 +107,7 @@ export const TimeSeriesPanel = ({ /> )} {/* Renders annotation markers*/} - {data.annotations && ( - - )} + {annotations && } {/* Enables annotations creation*/} {enableAnnotationCreation ? ( @@ -132,11 +149,11 @@ export const TimeSeriesPanel = ({ defaultItems={[]} /> )} - {data.annotations && ( + {exemplars && (