mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GrafanaDS: Add support for annotation time regions (#65462)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
parent
a2b97547a6
commit
faad4b92ad
@ -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"]
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 = (
|
||||
<div>Specify a list of tags to match. To specify a key and value tag use `key:value` syntax.</div>
|
||||
);
|
||||
|
||||
const annotationTypes = [
|
||||
const annotationTypes: Array<SelectableValue<GrafanaAnnotationType>> = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
value: GrafanaAnnotationType.Dashboard,
|
||||
@ -27,6 +31,19 @@ const annotationTypes = [
|
||||
},
|
||||
];
|
||||
|
||||
const queryTypes: Array<SelectableValue<GrafanaQueryType>> = [
|
||||
{
|
||||
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<GrafanaAnnotationType>) =>
|
||||
onChange({
|
||||
@ -66,49 +85,86 @@ export default function AnnotationQueryEditor({ query, onChange }: Props) {
|
||||
tags,
|
||||
});
|
||||
|
||||
const onQueryTypeChange = (newValue: SelectableValue<GrafanaQueryType>) => {
|
||||
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 (
|
||||
<FieldSet className={styles.container}>
|
||||
<Field label="Filter by">
|
||||
<Field label="Query type">
|
||||
<Select
|
||||
inputId="grafana-annotations__filter-by"
|
||||
options={annotationTypes}
|
||||
value={type}
|
||||
onChange={onFilterByChange}
|
||||
inputId="grafana-annotations__query-type"
|
||||
options={queryTypes}
|
||||
value={grafanaQueryType}
|
||||
onChange={onQueryTypeChange}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max limit">
|
||||
<Select
|
||||
inputId="grafana-annotations__limit"
|
||||
width={16}
|
||||
options={limitOptions}
|
||||
value={limit}
|
||||
onChange={onMaxLimitChange}
|
||||
/>
|
||||
</Field>
|
||||
{type === GrafanaAnnotationType.Tags && (
|
||||
{grafanaQueryType === GrafanaQueryType.Annotations && (
|
||||
<>
|
||||
<Field label="Match any" description={matchTooltipContent}>
|
||||
<Switch id="grafana-annotations__match-any" value={matchAny} onChange={onMatchAnyChange} />
|
||||
</Field>
|
||||
<Field label="Tags" description={tagsTooltipContent}>
|
||||
<TagFilter
|
||||
allowCustomValue
|
||||
inputId="grafana-annotations__tags"
|
||||
onChange={onTagsChange}
|
||||
tagOptions={getAnnotationTags}
|
||||
tags={tags ?? []}
|
||||
<Field label="Filter by">
|
||||
<Select
|
||||
inputId="grafana-annotations__filter-by"
|
||||
options={annotationTypes}
|
||||
value={type}
|
||||
onChange={onFilterByChange}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max limit">
|
||||
<Select
|
||||
inputId="grafana-annotations__limit"
|
||||
width={16}
|
||||
options={limitOptions}
|
||||
value={limit}
|
||||
onChange={onMaxLimitChange}
|
||||
/>
|
||||
</Field>
|
||||
{type === GrafanaAnnotationType.Tags && (
|
||||
<>
|
||||
<Field label="Match any" description={matchTooltipContent}>
|
||||
<Switch id="grafana-annotations__match-any" value={matchAny} onChange={onMatchAnyChange} />
|
||||
</Field>
|
||||
<Field label="Tags" description={tagsTooltipContent}>
|
||||
<TagFilter
|
||||
allowCustomValue
|
||||
inputId="grafana-annotations__tags"
|
||||
onChange={onTagsChange}
|
||||
tagOptions={getAnnotationTags}
|
||||
tags={tags ?? []}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{grafanaQueryType === GrafanaQueryType.TimeRegions && annotationQuery.timeRegion && (
|
||||
<TimeRegionEditor value={annotationQuery.timeRegion} onChange={onTimeRegionChange} />
|
||||
)}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css`
|
||||
max-width: 600px;
|
||||
`,
|
||||
container: css({
|
||||
maxWidth: theme.spacing(60),
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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 (
|
||||
<TimePicker
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(v) => 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={<Caret wrapperStyle={styles.caretWrapper} />}
|
||||
disabled={disabled}
|
||||
disabledHours={disabledHours}
|
||||
disabledMinutes={disabledMinutes}
|
||||
disabledSeconds={disabledSeconds}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CaretProps {
|
||||
wrapperStyle?: string;
|
||||
}
|
||||
|
||||
const Caret = ({ wrapperStyle = '' }: CaretProps) => {
|
||||
return (
|
||||
<div className={wrapperStyle}>
|
||||
<Icon name="angle-down" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -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 = (
|
||||
<>
|
||||
<TimeZoneTitle title={timezoneInfo?.name} />
|
||||
<TimeZoneOffset timeZone={value.timezone} timestamp={timestamp} />
|
||||
</>
|
||||
);
|
||||
|
||||
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<number>) => {
|
||||
const fromDayOfWeek = v ? v.value : undefined;
|
||||
const toDayOfWeek = v ? value.toDayOfWeek : undefined; // clear if everyday
|
||||
onChange({ ...value, fromDayOfWeek, toDayOfWeek });
|
||||
};
|
||||
|
||||
const onToDayOfWeekChange = (v: SelectableValue<number>) => {
|
||||
onChange({ ...value, toDayOfWeek: v ? v.value : undefined });
|
||||
};
|
||||
|
||||
const renderTimezone = () => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<TimeZonePicker
|
||||
value={value.timezone}
|
||||
includeInternal={true}
|
||||
onChange={(v) => onTimezoneChange(v)}
|
||||
onBlur={() => setEditing(false)}
|
||||
menuShouldPortal={true}
|
||||
openMenuOnFocus={false}
|
||||
width={100}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.timezoneContainer}>
|
||||
<div className={styles.timezone}>{renderTimezonePicker()}</div>
|
||||
<Button variant="secondary" onClick={onToggleChangeTimezone} size="sm">
|
||||
Change timezone
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldSet className={styles.wrapper}>
|
||||
<Field label="From">
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Select
|
||||
options={days}
|
||||
isClearable
|
||||
placeholder="Everyday"
|
||||
value={value.fromDayOfWeek ?? null}
|
||||
onChange={(v) => onFromDayOfWeekChange(v)}
|
||||
width={20}
|
||||
/>
|
||||
<TimePickerInput
|
||||
value={getTime(value.from)}
|
||||
onChange={(v) => onTimeChange(v, 'from')}
|
||||
allowEmpty={true}
|
||||
placeholder="HH:mm"
|
||||
width={100}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</Field>
|
||||
<Field label="To">
|
||||
<HorizontalGroup spacing="xs">
|
||||
{(value.fromDayOfWeek || value.toDayOfWeek) && (
|
||||
<Select
|
||||
options={days}
|
||||
isClearable
|
||||
placeholder={getToPlaceholder()}
|
||||
value={value.toDayOfWeek ?? null}
|
||||
onChange={(v) => onToDayOfWeekChange(v)}
|
||||
width={20}
|
||||
/>
|
||||
)}
|
||||
<TimePickerInput
|
||||
value={getTime(value.to)}
|
||||
onChange={(v) => onTimeChange(v, 'to')}
|
||||
allowEmpty={true}
|
||||
placeholder="HH:mm"
|
||||
width={100}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</Field>
|
||||
<Field label="Timezone">{renderTimezone()}</Field>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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<GrafanaQuery> {
|
||||
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<GrafanaQuery> {
|
||||
}
|
||||
|
||||
async getAnnotations(options: AnnotationQueryRequest<GrafanaQuery>): Promise<DataQueryResponse> {
|
||||
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<GrafanaAnnotationQuery>;
|
||||
const target = annotation.target!;
|
||||
const params: any = {
|
||||
@ -202,6 +226,7 @@ export class GrafanaDatasource extends DataSourceWithBackend<GrafanaQuery> {
|
||||
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) {
|
||||
|
490
public/app/plugins/datasource/grafana/timeRegions.test.ts
Normal file
490
public/app/plugins/datasource/grafana/timeRegions.test.ts
Normal file
@ -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",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
48
public/app/plugins/datasource/grafana/timeRegions.ts
Normal file
48
public/app/plugins/datasource/grafana/timeRegions.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 && (
|
||||
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
|
||||
)}
|
||||
{annotations && <AnnotationsPlugin annotations={annotations} config={config} timeZone={timeZone} />}
|
||||
{/* Enables annotations creation*/}
|
||||
{enableAnnotationCreation ? (
|
||||
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={config}>
|
||||
@ -132,11 +149,11 @@ export const TimeSeriesPanel = ({
|
||||
defaultItems={[]}
|
||||
/>
|
||||
)}
|
||||
{data.annotations && (
|
||||
{exemplars && (
|
||||
<ExemplarsPlugin
|
||||
visibleSeries={getVisibleLabels(config, frames)}
|
||||
config={config}
|
||||
exemplars={data.annotations}
|
||||
exemplars={exemplars}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user