From 59280d5242767688fe6875d1f783d577931afcd6 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 20 Feb 2025 14:50:32 -0600 Subject: [PATCH] Time regions: Add option for cron syntax to support complex schedules (#99548) Co-authored-by: Kristina Durivage --- package.json | 1 + .../grafana-data/src/datetime/durationutil.ts | 4 + packages/grafana-data/src/index.ts | 1 + public/app/core/utils/timeRegions.test.ts | 401 ++++++++++++++---- public/app/core/utils/timeRegions.ts | 283 ++++++------ .../grafana/components/TimeRegionEditor.tsx | 153 +++++-- .../plugins/datasource/grafana/datasource.ts | 9 +- .../datasource/grafana/timeRegions.test.ts | 49 +-- .../plugins/datasource/grafana/timeRegions.ts | 56 +-- .../graph/specs/time_region_manager.test.ts | 372 ---------------- public/locales/en-US/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + yarn.lock | 8 + 13 files changed, 626 insertions(+), 723 deletions(-) delete mode 100644 public/app/plugins/panel/graph/specs/time_region_manager.test.ts diff --git a/package.json b/package.json index 5a830bf9c88..cc2ba8bfcf8 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,7 @@ "combokeys": "^3.0.0", "comlink": "4.4.2", "common-tags": "1.8.2", + "croner": "^9.0.0", "d3": "7.9.0", "d3-force": "3.0.0", "d3-scale-chromatic": "3.1.0", diff --git a/packages/grafana-data/src/datetime/durationutil.ts b/packages/grafana-data/src/datetime/durationutil.ts index 547303134cd..a87d2aca97b 100644 --- a/packages/grafana-data/src/datetime/durationutil.ts +++ b/packages/grafana-data/src/datetime/durationutil.ts @@ -26,6 +26,10 @@ export function intervalToAbbreviatedDurationString(interval: Interval, includeS } const duration = intervalToDuration(interval); + return reverseParseDuration(duration, includeSeconds); +} + +export function reverseParseDuration(duration: Duration, includeSeconds: boolean): string { return Object.entries(duration).reduce((str, [unit, value]) => { if (value && value !== 0 && !(unit === 'seconds' && !includeSeconds && str)) { const padding = str !== '' ? ' ' : ''; diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 66cfac27a52..ec84e8f0d7b 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -403,6 +403,7 @@ export { type DateTimeOptionsWhenParsing, dateTimeParse } from './datetime/parse export { intervalToAbbreviatedDurationString, parseDuration, + reverseParseDuration, addDurationToDate, durationToMilliseconds, isValidDate, diff --git a/public/app/core/utils/timeRegions.test.ts b/public/app/core/utils/timeRegions.test.ts index 9805e28824f..a6aadb97db2 100644 --- a/public/app/core/utils/timeRegions.test.ts +++ b/public/app/core/utils/timeRegions.test.ts @@ -1,19 +1,84 @@ -import { dateTime, TimeRange } from '@grafana/data'; -import { TimeRegionConfig } from 'app/core/utils/timeRegions'; +import { Duration } from 'date-fns'; + +import { AbsoluteTimeRange, dateTimeForTimeZone, reverseParseDuration, TimeRange } from '@grafana/data'; +import { convertToCron, TimeRegionConfig } from 'app/core/utils/timeRegions'; import { calculateTimesWithin } from './timeRegions'; -// note: calculateTimesWithin always returns time ranges in UTC +// random from the interwebs +function durationFromSeconds(seconds: number): Duration { + const secondsInYear = 31536000; + const secondsInMonth = 2628000; + const secondsInDay = 86400; + const secondsInHour = 3600; + const secondsInMinute = 60; + + let years = Math.floor(seconds / secondsInYear); + let remainingSeconds = seconds % secondsInYear; + + let months = Math.floor(remainingSeconds / secondsInMonth); + remainingSeconds %= secondsInMonth; + + let days = Math.floor(remainingSeconds / secondsInDay); + remainingSeconds %= secondsInDay; + + let hours = Math.floor(remainingSeconds / secondsInHour); + remainingSeconds %= secondsInHour; + + let minutes = Math.floor(remainingSeconds / secondsInMinute); + let finalSeconds = remainingSeconds % secondsInMinute; + + return { + years, + months, + days, + hours, + minutes, + seconds: finalSeconds, + }; +} + +function tsToDayOfWeek(ts: number, tz?: string) { + return new Date(ts).toLocaleString('en', { + timeZone: tz, + weekday: 'short', + }); +} + +function tsToDateTimeString(ts: number, tz?: string) { + return new Date(ts).toLocaleString('sv', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: tz, + timeZoneName: 'short', + }); +} + +function formatAbsoluteRange(range: AbsoluteTimeRange, tz?: string) { + return { + fr: `${tsToDayOfWeek(range.from, tz)} | ${tsToDateTimeString(range.from, tz)}`.replaceAll('−', '-'), + to: `${tsToDayOfWeek(range.to, tz)} | ${tsToDateTimeString(range.to, tz)}`.replaceAll('−', '-'), + }; +} + describe('timeRegions', () => { describe('day of week', () => { it('returns regions with 4 Mondays in March 2023', () => { + const dashboardTz = 'America/Chicago'; + const regionsTz = dashboardTz; + const cfg: TimeRegionConfig = { + timezone: regionsTz, fromDayOfWeek: 1, }; const tr: TimeRange = { - from: dateTime('2023-03-01'), - to: dateTime('2023-03-31'), + from: dateTimeForTimeZone(dashboardTz, '2023-03-01'), + to: dateTimeForTimeZone(dashboardTz, '2023-03-31'), raw: { to: '', from: '', @@ -21,38 +86,41 @@ describe('timeRegions', () => { }; const regions = calculateTimesWithin(cfg, tr); - expect(regions).toMatchInlineSnapshot(` - [ - { - "from": 1678060800000, - "to": 1678147199000, - }, - { - "from": 1678665600000, - "to": 1678751999000, - }, - { - "from": 1679270400000, - "to": 1679356799000, - }, - { - "from": 1679875200000, - "to": 1679961599000, - }, - ] - `); + const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz)); + expect(formatted).toEqual([ + { + fr: 'Mon | 2023-03-06 00:00:00 GMT-6', + to: 'Tue | 2023-03-07 00:00:00 GMT-6', + }, + { + fr: 'Mon | 2023-03-13 00:00:00 GMT-5', + to: 'Tue | 2023-03-14 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-20 00:00:00 GMT-5', + to: 'Tue | 2023-03-21 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-27 00:00:00 GMT-5', + to: 'Tue | 2023-03-28 00:00:00 GMT-5', + }, + ]); }); }); describe('day and time of week', () => { it('returns regions with 4 Mondays at 20:00 in March 2023', () => { + const dashboardTz = 'America/Chicago'; + const regionsTz = dashboardTz; + const cfg: TimeRegionConfig = { + timezone: regionsTz, fromDayOfWeek: 1, from: '20:00', }; const tr: TimeRange = { - from: dateTime('2023-03-01'), - to: dateTime('2023-03-31'), + from: dateTimeForTimeZone(dashboardTz, '2023-03-01'), + to: dateTimeForTimeZone(dashboardTz, '2023-03-31'), raw: { to: '', from: '', @@ -60,38 +128,41 @@ describe('timeRegions', () => { }; const regions = calculateTimesWithin(cfg, tr); - expect(regions).toMatchInlineSnapshot(` - [ - { - "from": 1678132800000, - "to": 1678132800000, - }, - { - "from": 1678737600000, - "to": 1678737600000, - }, - { - "from": 1679342400000, - "to": 1679342400000, - }, - { - "from": 1679947200000, - "to": 1679947200000, - }, - ] - `); + const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz)); + expect(formatted).toEqual([ + { + fr: 'Mon | 2023-03-06 20:00:00 GMT-6', + to: 'Mon | 2023-03-06 20:00:00 GMT-6', + }, + { + fr: 'Mon | 2023-03-13 20:00:00 GMT-5', + to: 'Mon | 2023-03-13 20:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-20 20:00:00 GMT-5', + to: 'Mon | 2023-03-20 20:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-27 20:00:00 GMT-5', + to: 'Mon | 2023-03-27 20:00:00 GMT-5', + }, + ]); }); }); describe('day of week range', () => { it('returns regions with days range', () => { + const dashboardTz = 'America/Chicago'; + const regionsTz = dashboardTz; + const cfg: TimeRegionConfig = { + timezone: regionsTz, fromDayOfWeek: 1, toDayOfWeek: 3, }; const tr: TimeRange = { - from: dateTime('2023-03-01'), - to: dateTime('2023-03-31'), + from: dateTimeForTimeZone(dashboardTz, '2023-03-01'), + to: dateTimeForTimeZone(dashboardTz, '2023-03-31'), raw: { to: '', from: '', @@ -99,29 +170,82 @@ describe('timeRegions', () => { }; const regions = calculateTimesWithin(cfg, tr); - expect(regions).toMatchInlineSnapshot(` - [ - { - "from": 1678060800000, - "to": 1678319999000, - }, - { - "from": 1678665600000, - "to": 1678924799000, - }, - { - "from": 1679270400000, - "to": 1679529599000, - }, - { - "from": 1679875200000, - "to": 1680134399000, - }, - ] - `); + const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz)); + expect(formatted).toEqual([ + { + fr: 'Mon | 2023-02-27 00:00:00 GMT-6', + to: 'Thu | 2023-03-02 00:00:00 GMT-6', + }, + { + fr: 'Mon | 2023-03-06 00:00:00 GMT-6', + to: 'Thu | 2023-03-09 00:00:00 GMT-6', + }, + { + fr: 'Mon | 2023-03-13 00:00:00 GMT-5', + to: 'Thu | 2023-03-16 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-20 00:00:00 GMT-5', + to: 'Thu | 2023-03-23 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-27 00:00:00 GMT-5', + to: 'Thu | 2023-03-30 00:00:00 GMT-5', + }, + ]); }); - it('returns regions with days/times range', () => { + + it('returns regions with days range (browser time zone)', () => { + const dashboardTz = process.env.TZ; + const regionsTz = dashboardTz; + const cfg: TimeRegionConfig = { + timezone: regionsTz, + fromDayOfWeek: 1, + toDayOfWeek: 3, + }; + + const tr: TimeRange = { + from: dateTimeForTimeZone(dashboardTz, '2023-03-01'), + to: dateTimeForTimeZone(dashboardTz, '2023-03-31'), + raw: { + to: '', + from: '', + }, + }; + + const regions = calculateTimesWithin(cfg, tr); + const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz)); + expect(formatted).toEqual([ + { + fr: 'Mon | 2023-02-27 00:00:00 GMT-5', + to: 'Thu | 2023-03-02 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-06 00:00:00 GMT-5', + to: 'Thu | 2023-03-09 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-13 00:00:00 GMT-5', + to: 'Thu | 2023-03-16 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-20 00:00:00 GMT-5', + to: 'Thu | 2023-03-23 00:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-27 00:00:00 GMT-5', + to: 'Thu | 2023-03-30 00:00:00 GMT-5', + }, + ]); + }); + + it('returns regions with days/times range', () => { + const dashboardTz = 'America/Chicago'; + const regionsTz = dashboardTz; + + const cfg: TimeRegionConfig = { + timezone: regionsTz, fromDayOfWeek: 1, from: '20:00', toDayOfWeek: 2, @@ -129,8 +253,8 @@ describe('timeRegions', () => { }; const tr: TimeRange = { - from: dateTime('2023-03-01'), - to: dateTime('2023-03-31'), + from: dateTimeForTimeZone(dashboardTz, '2023-03-01'), + to: dateTimeForTimeZone(dashboardTz, '2023-03-31'), raw: { to: '', from: '', @@ -138,26 +262,119 @@ describe('timeRegions', () => { }; const regions = calculateTimesWithin(cfg, tr); - expect(regions).toMatchInlineSnapshot(` - [ - { - "from": 1678132800000, - "to": 1678183200000, - }, - { - "from": 1678737600000, - "to": 1678788000000, - }, - { - "from": 1679342400000, - "to": 1679392800000, - }, - { - "from": 1679947200000, - "to": 1679997600000, - }, - ] - `); + const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz)); + expect(formatted).toEqual([ + { + fr: 'Mon | 2023-03-06 20:00:00 GMT-6', + to: 'Tue | 2023-03-07 10:00:00 GMT-6', + }, + { + fr: 'Mon | 2023-03-13 20:00:00 GMT-5', + to: 'Tue | 2023-03-14 10:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-20 20:00:00 GMT-5', + to: 'Tue | 2023-03-21 10:00:00 GMT-5', + }, + { + fr: 'Mon | 2023-03-27 20:00:00 GMT-5', + to: 'Tue | 2023-03-28 10:00:00 GMT-5', + }, + ]); }); }); + + type TestDef = [ + name: string, + fromDayOfWeek: number | null, + from: string | null, + toDayOfWeek: number | null, + to: string | null, + cronExpr: string, + duration: string, + ]; + + let _ = null; + + describe('various scenarios (regions)', () => { + /* eslint-disable */ + // prettier-ignore + let tests: TestDef[] = [ + ['from every day (time before) to every day (time after)', _, '10:27', _, '14:27', '27 10 * * *', '4h'], + ['from every day (time after) to every day (time before)', _, '22:27', _, '02:27', '27 22 * * *', '4h'], + ['from every day (time) to every day (no time)', _, '10:27', _, _, '27 10 * * *', ''], + ['from fri (no time)', 5, _, _, _, '0 0 * * 5', '1d'], + ['from fri (no time) to tues (no time)', 5, _, 2, _, '0 0 * * 5', '5d'], + ['from fri (no time) to tues (time)', 5, _, 2, '02:27', '0 0 * * 5', '4d 2h 27m'], + ['from fri (time) to tues (no time)', 5, '10:27', 2, _, '27 10 * * 5', '4d'], + ['from fri (time) to tues (time)', 5, '10:27', 2, '14:27', '27 10 * * 5', '4d 4h'], + + // same day + ['from fri (time before) to fri (time after)', 5, '10:27', 5, '14:27', '27 10 * * 5', '4h'], + // "toDay" should assume Fri + ['from fri (time before) to every day (time after)', 5, '10:27', _, '14:27', '27 10 * * 5', '4h'], + // wrap-around case + ['from fri (time after) to fri (time before)', 5, '14:27', 5, '10:27', '27 14 * * 5', '6d 20h'], + + ]; + /* eslint-enable */ + + tests.forEach(([name, fromDayOfWeek, from, toDayOfWeek, to, cronExpr, duration]) => { + it(name, () => { + const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to); + + expect(cron).not.toBeUndefined(); + expect(cron?.cronExpr).toEqual(cronExpr); + expect(reverseParseDuration(durationFromSeconds(cron?.duration ?? 0), false)).toEqual(duration); + }); + }); + }); + + describe('various scenarios (points)', () => { + /* eslint-disable */ + // prettier-ignore + let tests: TestDef[] = [ + ['from every day (time)', _, '10:03', _, _, '3 10 * * *', ''], + ['from every day (time) to every day (time)', _, '10:03', _, '10:03', '3 10 * * *', ''], + ['from tues (time)', 2, '10:03', _, _, '3 10 * * 2', ''], + ['from tues (time) to tues (time)', 2, '10:03', _, '10:03', '3 10 * * 2', ''], + ]; + /* eslint-enable */ + + tests.forEach(([name, fromDayOfWeek, from, toDayOfWeek, to, cronExpr, duration]) => { + it(name, () => { + const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to); + + expect(cron).not.toBeUndefined(); + expect(cron?.cronExpr).toEqual(cronExpr); + expect(reverseParseDuration(durationFromSeconds(cron?.duration ?? 0), false)).toEqual(duration); + }); + }); + }); + + describe('convert simple time region config to cron string and duration', () => { + it.each` + from | fromDOW | to | toDOW | timezone | expectedCron | expectedDuration + ${'03:03'} | ${1} | ${'03:03'} | ${2} | ${'browser'} | ${'3 3 * * 1'} | ${'1d'} + ${'03:03'} | ${7} | ${'03:03'} | ${1} | ${'browser'} | ${'3 3 * * 7'} | ${'1d'} + ${'09:03'} | ${7} | ${'03:03'} | ${1} | ${'browser'} | ${'3 9 * * 7'} | ${'18h'} + ${'03:03'} | ${7} | ${'04:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${'1h'} + ${'03:03'} | ${7} | ${'02:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${'6d 23h'} + ${'03:03'} | ${7} | ${'3:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${''} + `( + "time region config with from time '$from' and DOW '$fromDOW', to: '$to' and DOW '$toDOW' should generate a cron string of '$expectedCron' and '$expectedDuration'", + ({ from, fromDOW, to, toDOW, timezone, expectedCron, expectedDuration }) => { + const timeConfig: TimeRegionConfig = { from, fromDayOfWeek: fromDOW, to, toDayOfWeek: toDOW, timezone }; + const convertedCron = convertToCron( + timeConfig.fromDayOfWeek, + timeConfig.from, + timeConfig.toDayOfWeek, + timeConfig.to + )!; + expect(convertedCron).not.toBeUndefined(); + expect(convertedCron.cronExpr).toEqual(expectedCron); + expect(reverseParseDuration(durationFromSeconds(convertedCron.duration), false)).toEqual(expectedDuration); + } + ); + }); }); diff --git a/public/app/core/utils/timeRegions.ts b/public/app/core/utils/timeRegions.ts index 363519021dd..0ca75cdc321 100644 --- a/public/app/core/utils/timeRegions.ts +++ b/public/app/core/utils/timeRegions.ts @@ -1,6 +1,11 @@ -import { AbsoluteTimeRange, dateTime, TimeRange } from '@grafana/data'; +import { Cron } from 'croner'; +import { AbsoluteTimeRange, TimeRange, durationToMilliseconds, parseDuration } from '@grafana/data'; + +export type TimeRegionMode = null | 'cron'; export interface TimeRegionConfig { + mode?: TimeRegionMode; + from?: string; fromDayOfWeek?: number; // 1-7 @@ -8,165 +13,155 @@ export interface TimeRegionConfig { toDayOfWeek?: number; // 1-7 timezone?: string; + + cronExpr?: string; // 0 9 * * 1-5 + duration?: string; // 8h } -interface ParsedTime { - dayOfWeek?: number; // 1-7 - h?: number; // 0-23 - m?: number; // 0-59 - s?: number; // 0-59 +const secsInDay = 24 * 3600; + +function getDurationSecs( + fromDay: number, + fromHour: number, + fromMin: number, + toDay: number, + toHour: number, + toMin: number +) { + let days = toDay - fromDay; + + // account for rollover + if (days < 0) { + days += 7; + } + + let fromSecs = fromHour * 3600 + fromMin * 60; + let toSecs = toHour * 3600 + toMin * 60; + + let durSecs = 0; + + // account for toTime < fromTime on same day + if (days === 0 && toSecs < fromSecs) { + durSecs = 7 * secsInDay - (fromSecs - toSecs); + } else { + let daysSecs = days * secsInDay; + durSecs = daysSecs - fromSecs + toSecs; + } + + return durSecs; +} + +export function convertToCron( + fromDay?: number | null, + from?: string | null, + toDay?: number | null, + to?: string | null +) { + // valid defs must have a "from" + if (fromDay == null && from == null) { + return undefined; + } + + const cronCfg = { + cronExpr: '', + duration: 0, + }; + + const isEveryDay = fromDay == null && toDay == null; + // if the def contains only days of week, then they become end-day-inclusive + const toDayEnd = fromDay != null && from == null && to == null; + + from ??= '00:00'; + + // 1. create cron (only requires froms) + let [fromHour, fromMin] = from.split(':').map((v) => +v); + + cronCfg.cronExpr = `${fromMin} ${fromHour} * * ${fromDay ?? '*'}`; + + // 2. determine duration + fromDay ??= 1; + toDay ??= fromDay; + + // e.g. from Wed to Fri (implies inclusive Fri) + if (toDayEnd) { + to = '00:00'; + toDay += toDay === 7 ? -6 : 1; + } + + to ??= from; + + let [toHour, toMin] = to.split(':').map((v) => +v); + + let fromSecs = fromHour * 3600 + fromMin * 60; + let toSecs = toHour * 3600 + toMin * 60; + + // e.g. every day from 22:00 to 02:00 (implied next day) + // NOTE: the odd wrap-around case of toSecs < fromSecs in same day is handled inside getDurationSecs() + if (isEveryDay && toSecs < fromSecs) { + toDay += toDay === 7 ? -6 : 1; + } + + cronCfg.duration = getDurationSecs(fromDay, fromHour, fromMin, toDay, toHour, toMin); + + return cronCfg; } export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): AbsoluteTimeRange[] { - if (!(cfg.fromDayOfWeek || cfg.from) && !(cfg.toDayOfWeek || cfg.to)) { + const ranges: AbsoluteTimeRange[] = []; + + let cronExpr = ''; + let durationMs = 0; + + let { fromDayOfWeek, from, toDayOfWeek, to, duration = '' } = cfg; + + if (cfg.mode === 'cron') { + cronExpr = cfg.cronExpr ?? ''; + durationMs = durationToMilliseconds(parseDuration(duration)); + } else { + // remove empty strings + from = from === '' ? undefined : from; + to = to === '' ? undefined : to; + + const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to); + + if (cron != null) { + cronExpr = cron.cronExpr; + durationMs = cron.duration * 1e3; + } + } + + if (cronExpr === '') { return []; } - // So we can mutate - const timeRegion = { ...cfg }; + try { + let tz = cfg.timezone === 'browser' ? undefined : cfg.timezone === 'utc' ? 'Etc/UTC' : cfg.timezone; - if (timeRegion.from && !timeRegion.to) { - timeRegion.to = timeRegion.from; - } + let job = new Cron(cronExpr, { timezone: tz }); - if (!timeRegion.from && timeRegion.to) { - timeRegion.from = timeRegion.to; - } + // get previous run that may overlap with start of timerange + let fromDate: Date | null = new Date(tRange.from.valueOf() - durationMs); - const hRange = { - from: parseTimeOfDay(timeRegion.from), - to: parseTimeOfDay(timeRegion.to), - }; + let toMs = tRange.to.valueOf(); + let nextDate = job.nextRun(fromDate); - if (!timeRegion.fromDayOfWeek && timeRegion.toDayOfWeek) { - timeRegion.fromDayOfWeek = timeRegion.toDayOfWeek; - } + while (nextDate != null) { + let nextMs = +nextDate; - if (!timeRegion.toDayOfWeek && timeRegion.fromDayOfWeek) { - timeRegion.toDayOfWeek = timeRegion.fromDayOfWeek; - } - - if (timeRegion.fromDayOfWeek) { - hRange.from.dayOfWeek = Number(timeRegion.fromDayOfWeek); - } - - if (timeRegion.toDayOfWeek) { - hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek); - } - - if (hRange.from.dayOfWeek && hRange.from.h == null && hRange.from.m == null) { - hRange.from.h = 0; - hRange.from.m = 0; - hRange.from.s = 0; - } - - if (hRange.to.dayOfWeek && hRange.to.h == null && hRange.to.m == null) { - hRange.to.h = 23; - hRange.to.m = 59; - hRange.to.s = 59; - } - - if (!hRange.from || !hRange.to) { - return []; - } - - if (hRange.from.h == null) { - hRange.from.h = 0; - } - - if (hRange.to.h == null) { - hRange.to.h = 23; - } - - const regions: AbsoluteTimeRange[] = []; - - 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'); - - while (fromStart.unix() <= tRange.to.unix()) { - while (hRange.from.dayOfWeek && hRange.from.dayOfWeek !== fromStart.isoWeekday()) { - fromStart.add(24, 'hours'); - } - - if (fromStart.unix() > tRange.to.unix()) { - break; - } - - const fromEnd = dateTime(fromStart).utc(); - - if (fromEnd.hour) { - if (hRange.from.h <= hRange.to.h) { - fromEnd.add(hRange.to.h - hRange.from.h, 'hours'); - } else if (hRange.from.h > hRange.to.h) { - while (fromEnd.hour() !== hRange.to.h) { - fromEnd.add(1, 'hours'); - } + if (nextMs > toMs) { + break; } else { - fromEnd.add(24 - hRange.from.h, 'hours'); - - while (fromEnd.hour() !== hRange.to.h) { - fromEnd.add(1, 'hours'); - } + ranges.push({ + from: nextMs, + to: nextMs + durationMs, + }); + nextDate = job.nextRun(nextDate); } } - - fromEnd.set('minute', hRange.to.m ?? 0); - fromEnd.set('second', hRange.to.s ?? 0); - - while (hRange.to.dayOfWeek && hRange.to.dayOfWeek !== fromEnd.isoWeekday()) { - fromEnd.add(24, 'hours'); - } - - const outsideRange = - (fromStart.unix() < tRange.from.unix() && fromEnd.unix() < tRange.from.unix()) || - (fromStart.unix() > tRange.to.unix() && fromEnd.unix() > tRange.to.unix()); - - if (!outsideRange) { - regions.push({ from: fromStart.valueOf(), to: fromEnd.valueOf() }); - } - - fromStart.add(24, 'hours'); + } catch (e) { + // invalid expression + console.error(e); } - return regions; -} - -export function parseTimeOfDay(str?: string): ParsedTime { - const result: ParsedTime = {}; - if (!str?.length) { - return result; - } - - 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.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; + return ranges; } diff --git a/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx b/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx index 9ba25879675..79b52e5a571 100644 --- a/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/TimeRegionEditor.tsx @@ -1,12 +1,24 @@ import { css } from '@emotion/css'; import moment, { Moment } from 'moment/moment'; -import { useState } from 'react'; +import { ChangeEvent, useState } from 'react'; import { dateTimeAsMoment, getTimeZoneInfo, GrafanaTheme2, isDateTime, SelectableValue } from '@grafana/data'; -import { Button, Field, FieldSet, Select, Stack, TimeOfDayPicker, TimeZonePicker, useStyles2 } from '@grafana/ui'; +import { + Button, + Field, + FieldSet, + Input, + Select, + Stack, + Switch, + TimeOfDayPicker, + 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 { t } from 'app/core/internationalization'; +import { TimeRegionConfig, TimeRegionMode } from 'app/core/utils/timeRegions'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; interface Props { @@ -20,6 +32,7 @@ const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' value: idx + 1, }; }); + export const TimeRegionEditor = ({ value, onChange }: Props) => { const styles = useStyles2(getStyles); const timestamp = Date.now(); @@ -85,6 +98,18 @@ export const TimeRegionEditor = ({ value, onChange }: Props) => { onChange({ ...value, timezone: v }); }; + const onModeChange = (v: TimeRegionMode) => { + onChange({ ...value, mode: v }); + }; + + const onCronExprChange = (v: string) => { + onChange({ ...value, cronExpr: v }); + }; + + const onDurationChange = (v: string) => { + onChange({ ...value, duration: v }); + }; + const onFromDayOfWeekChange = (v: SelectableValue) => { const fromDayOfWeek = v ? v.value : undefined; const toDayOfWeek = v ? value.toDayOfWeek : undefined; // clear if everyday @@ -125,46 +150,92 @@ export const TimeRegionEditor = ({ value, onChange }: Props) => { return (
- - - onToDayOfWeekChange(v)} - width={20} + + {value.mode == null && ( + <> + + + onToDayOfWeekChange(v)} + width={20} + /> + )} + onTimeChange(v ? dateTimeAsMoment(v) : v, 'to')} + allowEmpty={true} + placeholder="HH:mm" + size="sm" + /> + + + + )} + {value.mode === 'cron' && ( + <> + + ) => onCronExprChange(e.target.value)} + value={value.cronExpr} + placeholder="0 9 * * 1-5" + width={40} /> - )} - onTimeChange(v ? dateTimeAsMoment(v) : v, 'to')} - allowEmpty={true} - placeholder="HH:mm" - size="sm" - /> - - + + + ) => onDurationChange(e.target.value)} + value={value.duration} + placeholder="8h" + width={40} + /> + + + )} {renderTimezone()}
); diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 51dc7afb94e..02216e02f73 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -108,7 +108,7 @@ export class GrafanaDatasource extends DataSourceWithBackend { continue; } if (target.queryType === GrafanaQueryType.TimeRegions) { - const frame = doTimeRegionQuery('', target.timeRegion!, request.range, request.timezone); + const frame = doTimeRegionQuery('', target.timeRegion!, request.range); results.push( of({ data: frame ? [frame] : [], @@ -195,12 +195,7 @@ export class GrafanaDatasource extends DataSourceWithBackend { async getAnnotations(options: AnnotationQueryRequest): Promise { 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 - ); + const frame = doTimeRegionQuery(options.annotation.name, query.timeRegion!, options.range); return Promise.resolve({ data: frame ? [frame] : [] }); } diff --git a/public/app/plugins/datasource/grafana/timeRegions.test.ts b/public/app/plugins/datasource/grafana/timeRegions.test.ts index f5502fec661..76c052bc195 100644 --- a/public/app/plugins/datasource/grafana/timeRegions.test.ts +++ b/public/app/plugins/datasource/grafana/timeRegions.test.ts @@ -6,7 +6,7 @@ describe('grafana data source', () => { it('supports time region query', () => { const frame = doTimeRegionQuery( 'test', - { fromDayOfWeek: 1, toDayOfWeek: 2 }, + { fromDayOfWeek: 1, toDayOfWeek: 2, timezone: 'utc' }, { from: dateTime('2023-03-01'), to: dateTime('2023-03-31'), @@ -14,8 +14,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!)).toMatchInlineSnapshot(` @@ -39,10 +38,10 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678233599000, - 1678838399000, - 1679443199000, - 1680047999000, + 1678233600000, + 1678838400000, + 1679443200000, + 1680048000000, ], }, { @@ -80,8 +79,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -101,7 +99,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678147199000, + 1678147200000, ], }, { @@ -133,8 +131,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -154,7 +151,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678165199000, + 1678165200000, ], }, { @@ -186,8 +183,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -207,7 +203,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678168799000, + 1678168800000, ], }, { @@ -239,8 +235,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -260,7 +255,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678143599000, + 1678143600000, ], }, { @@ -292,8 +287,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'utc' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -313,7 +307,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678121999000, + 1678122000000, ], }, { @@ -345,8 +339,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'Asia/Dubai' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -366,7 +359,7 @@ describe('grafana data source', () => { "name": "timeEnd", "type": "time", "values": [ - 1678147199000, + 1678147200000, ], }, { @@ -398,8 +391,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'America/Chicago' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` @@ -451,8 +443,7 @@ describe('grafana data source', () => { to: '', from: '', }, - }, - 'America/Chicago' + } ); expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(` diff --git a/public/app/plugins/datasource/grafana/timeRegions.ts b/public/app/plugins/datasource/grafana/timeRegions.ts index 8c7f000ba2a..6e3fb3b0da6 100644 --- a/public/app/plugins/datasource/grafana/timeRegions.ts +++ b/public/app/plugins/datasource/grafana/timeRegions.ts @@ -1,48 +1,28 @@ -import { TimeRange, DataFrame, FieldType, getTimeZoneInfo } from '@grafana/data'; +import { TimeRange, DataFrame, FieldType } 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; - } +export function doTimeRegionQuery(name: string, config: TimeRegionConfig, range: TimeRange): DataFrame | undefined { + const { mode, duration, cronExpr, from, fromDayOfWeek } = config; - const times: number[] = []; - const timesEnd: number[] = []; - const texts: string[] = []; + const isValidSimple = mode == null && (fromDayOfWeek != null || from != null); + const isValidAdvanced = mode === 'cron' && cronExpr != null && duration != null; - const regionTimezone = config.timezone ?? tz; + if (isValidSimple || isValidAdvanced) { + const ranges = calculateTimesWithin(config, range); - for (const region of regions) { - let from = region.from; - let to = region.to; + if (ranges.length > 0) { + const frame: DataFrame = { + fields: [ + { name: 'time', type: FieldType.time, values: ranges.map((r) => r.from), config: {} }, + { name: 'timeEnd', type: FieldType.time, values: ranges.map((r) => r.to), config: {} }, + { name: 'text', type: FieldType.string, values: Array(ranges.length).fill(name), config: {} }, + ], + length: ranges.length, + }; - const info = getTimeZoneInfo(regionTimezone, from); - if (info) { - const offset = info.offsetInMins * 60 * 1000; - from += offset; - to += offset; + return frame; } - - 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, - }; + return undefined; } diff --git a/public/app/plugins/panel/graph/specs/time_region_manager.test.ts b/public/app/plugins/panel/graph/specs/time_region_manager.test.ts deleted file mode 100644 index 960baaa4287..00000000000 --- a/public/app/plugins/panel/graph/specs/time_region_manager.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { dateTime } from '@grafana/data'; - -import { TimeRegionManager, colorModes } from '../time_region_manager'; - -describe('TimeRegionManager', () => { - function plotOptionsScenario(desc: string, func: any) { - describe(desc, () => { - const ctx: any = { - panel: { - timeRegions: [], - }, - options: { - grid: { markings: [] }, - }, - panelCtrl: { - range: {}, - dashboard: {}, - }, - }; - - ctx.setup = (regions: any, from: any, to: any) => { - ctx.panel.timeRegions = regions; - ctx.panelCtrl.range.from = from; - ctx.panelCtrl.range.to = to; - const manager = new TimeRegionManager(ctx.panelCtrl); - manager.addFlotOptions(ctx.options, ctx.panel); - }; - - ctx.printScenario = () => { - console.log( - `Time range: from=${ctx.panelCtrl.range.from.format()}, to=${ctx.panelCtrl.range.to.format()}`, - ctx.panelCtrl.range.from._isUTC - ); - ctx.options.grid.markings.forEach((m: any, i: number) => { - console.log( - `Marking (${i}): from=${dateTime(m.xaxis.from).format()}, to=${dateTime(m.xaxis.to).format()}, color=${ - m.color - }` - ); - }); - }; - - func(ctx); - }); - } - - describe('When colors missing in config', () => { - plotOptionsScenario('should not throw an error when fillColor is undefined', (ctx: any) => { - const regions = [ - { fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, lineColor: '#ffffff', colorMode: 'custom' }, - ]; - const from = dateTime('2018-01-01T00:00:00+01:00'); - const to = dateTime('2018-01-01T23:59:00+01:00'); - expect(() => ctx.setup(regions, from, to)).not.toThrow(); - }); - plotOptionsScenario('should not throw an error when lineColor is undefined', (ctx: any) => { - const regions = [ - { fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, fillColor: '#ffffff', line: true, colorMode: 'custom' }, - ]; - const from = dateTime('2018-01-01T00:00:00+01:00'); - const to = dateTime('2018-01-01T23:59:00+01:00'); - expect(() => ctx.setup(regions, from, to)).not.toThrow(); - }); - }); - - describe('When creating plot markings using local time', () => { - plotOptionsScenario('for day of week region', (ctx: any) => { - const regions = [{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, colorMode: 'red' }]; - const from = dateTime('2018-01-01T00:00:00+01:00'); - const to = dateTime('2018-01-01T23:59:00+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add fill', () => { - const markings = ctx.options.grid.markings; - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - }); - - it('should add line before', () => { - const markings = ctx.options.grid.markings; - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.line); - }); - - it('should add line after', () => { - const markings = ctx.options.grid.markings; - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.line); - }); - }); - - plotOptionsScenario('for time from region', (ctx: any) => { - const regions = [{ from: '05:00', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-01-01T00:00+01:00'); - const to = dateTime('2018-01-03T23:59+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at 05:00 each day', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-01T06:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-01T06:00:00+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-02T06:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-02T06:00:00+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-03T06:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-03T06:00:00+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for time to region', (ctx: any) => { - const regions = [{ to: '05:00', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-02-01T00:00+01:00'); - const to = dateTime('2018-02-03T23:59+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at 05:00 each day', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-02-01T06:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-02-01T06:00:00+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-02-02T06:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-02-02T06:00:00+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-02-03T06:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-02-03T06:00:00+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for time from/to region', (ctx: any) => { - const regions = [{ from: '00:00', to: '05:00', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-12-01T00:00+01:00'); - const to = dateTime('2018-12-03T23:59+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill between 00:00 and 05:00 each day', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-01T01:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-01T06:00:00+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-12-02T01:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-12-02T06:00:00+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-12-03T01:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-12-03T06:00:00+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for time from/to region crossing midnight', (ctx: any) => { - const regions = [{ from: '22:00', to: '00:30', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-12-01T12:00+01:00'); - const to = dateTime('2018-12-04T08:00+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill between 22:00 and 00:30 each day', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-01T23:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-02T01:30:00+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-12-02T23:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-12-03T01:30:00+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-12-03T23:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-12-04T01:30:00+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for day of week from/to region', (ctx: any) => { - const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }]; - const from = dateTime('2018-01-01T18:45:05+01:00'); - const to = dateTime('2018-01-22T08:27:00+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at each sunday', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for day of week from region', (ctx: any) => { - const regions = [{ fromDayOfWeek: 7, fill: true, colorMode: 'red' }]; - const from = dateTime('2018-01-01T18:45:05+01:00'); - const to = dateTime('2018-01-22T08:27:00+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at each sunday', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for day of week to region', (ctx: any) => { - const regions = [{ toDayOfWeek: 7, fill: true, colorMode: 'red' }]; - const from = dateTime('2018-01-01T18:45:05+01:00'); - const to = dateTime('2018-01-22T08:27:00+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at each sunday', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format()); - expect(markings[0].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format()); - expect(markings[1].color).toBe(colorModes.red.color.fill); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format()); - expect(markings[2].color).toBe(colorModes.red.color.fill); - }); - }); - - plotOptionsScenario('for day of week from/to time region', (ctx: any) => { - const regions = [{ fromDayOfWeek: 7, from: '23:00', toDayOfWeek: 1, to: '01:40', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-12-07T12:51:19+01:00'); - const to = dateTime('2018-12-10T13:51:29+01:00'); - ctx.setup(regions, from, to); - - it('should add 1 marking', () => { - expect(ctx.options.grid.markings.length).toBe(1); - }); - - it('should add one fill between sunday 23:00 and monday 01:40', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-10T00:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-10T02:40:00+01:00').format()); - }); - }); - - plotOptionsScenario('for day of week from/to time region', (ctx: any) => { - const regions = [{ fromDayOfWeek: 6, from: '03:00', toDayOfWeek: 7, to: '02:00', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-12-07T12:51:19+01:00'); - const to = dateTime('2018-12-10T13:51:29+01:00'); - ctx.setup(regions, from, to); - - it('should add 1 marking', () => { - expect(ctx.options.grid.markings.length).toBe(1); - }); - - it('should add one fill between saturday 03:00 and sunday 02:00', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-08T04:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-09T03:00:00+01:00').format()); - }); - }); - - plotOptionsScenario('for day of week from/to time region with daylight saving time', (ctx: any) => { - const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }]; - const from = dateTime('2018-03-17T06:00:00+01:00'); - const to = dateTime('2018-04-03T06:00:00+02:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at each sunday between 20:00 and 23:00', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-03-18T21:00:00+01:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-03-19T00:00:00+01:00').format()); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-03-25T22:00:00+02:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-03-26T01:00:00+02:00').format()); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-04-01T22:00:00+02:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-04-02T01:00:00+02:00').format()); - }); - }); - - plotOptionsScenario('for each day of week with winter time', (ctx: any) => { - const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }]; - const from = dateTime('2018-10-20T14:50:11+02:00'); - const to = dateTime('2018-11-07T12:56:23+01:00'); - ctx.setup(regions, from, to); - - it('should add 3 markings', () => { - expect(ctx.options.grid.markings.length).toBe(3); - }); - - it('should add one fill at each sunday', () => { - const markings = ctx.options.grid.markings; - - expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-10-21T02:00:00+02:00').format()); - expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-10-22T01:59:59+02:00').format()); - - expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-10-28T02:00:00+02:00').format()); - expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-10-29T00:59:59+01:00').format()); - - expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-11-04T01:00:00+01:00').format()); - expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-11-05T00:59:59+01:00').format()); - }); - }); - }); -}); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d7cba3f8990..9773ba5c185 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1337,6 +1337,12 @@ "time-zone-label": "Time zone", "week-start-label": "Week start" }, + "time-regions": { + "advanced-description-cron": "Cron syntax", + "advanced-description-rest": " to define a recurrence schedule and duration", + "advanced-description-use": "Use ", + "advanced-label": "Advanced" + }, "variables": { "title": "Variables" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 2dc0f928c44..b442e198f22 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1337,6 +1337,12 @@ "time-zone-label": "Ŧįmę žőʼnę", "week-start-label": "Ŵęęĸ şŧäřŧ" }, + "time-regions": { + "advanced-description-cron": "Cřőʼn şyʼnŧäχ", + "advanced-description-rest": " ŧő đęƒįʼnę ä řęčūřřęʼnčę şčĥęđūľę äʼnđ đūřäŧįőʼn", + "advanced-description-use": "Ůşę ", + "advanced-label": "Åđväʼnčęđ" + }, "variables": { "title": "Väřįäþľęş" }, diff --git a/yarn.lock b/yarn.lock index c54b1d4815a..105c54482f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13935,6 +13935,13 @@ __metadata: languageName: node linkType: hard +"croner@npm:^9.0.0": + version: 9.0.0 + resolution: "croner@npm:9.0.0" + checksum: 10/b3cea758eedfe92e35c4ebae46c9db615565348ad898b5938fd94ea77abc5ff68d86539db248a1666b007b0726da642c9046879e8fd598220afcc87c8135e656 + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -18265,6 +18272,7 @@ __metadata: copy-webpack-plugin: "npm:12.0.2" core-js: "npm:3.40.0" crashme: "npm:0.0.15" + croner: "npm:^9.0.0" css-loader: "npm:7.1.2" css-minimizer-webpack-plugin: "npm:7.0.0" cypress: "npm:13.10.0"