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:
Leon Sorokin 2023-04-21 15:25:50 -05:00 committed by GitHub
parent a2b97547a6
commit faad4b92ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1231 additions and 83 deletions

View File

@ -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"]

View File

@ -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}

View File

@ -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,
},
]
`);

View File

@ -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;
}

View File

@ -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),
}),
};
};

View File

@ -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};
}
`,
};
};

View File

@ -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;
`,
};
};

View File

@ -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) {

View 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",
],
},
]
`);
});
});

View 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,
};
}

View File

@ -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;
}

View File

@ -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}
/>