diff --git a/src/common/formatters.ts b/src/common/formatters.ts index 5d29f77e7..89ed03e3c 100644 --- a/src/common/formatters.ts +++ b/src/common/formatters.ts @@ -93,6 +93,54 @@ export function millisecondsToHMS(valueMS: number): string { return parts.length ? parts.join(' ') : unknownValueString; } +/** Outputs a value in moment.Duration in (Y M D H M S) format (ex. 1y 1M 1d 2h 3m 30s) */ +export function durationToYMWDHMS(duration: moment.Duration): string { + if (duration.asSeconds() === 0) { + return ''; + } + + const parts = []; + + if (duration.years() !== 0) { + parts.push(`${Math.abs(duration.years())}y`); + } + + if (duration.months() !== 0) { + parts.push(`${Math.abs(duration.months())}M`); + } + + // ISO-8601 does not permit mixing between the PnYnMnD and PnW formats. + // Any week-based input is multiplied by 7 and treated as a number of days. + // However moment can parse the mixture resulting both a number of weeks and a number of days. + // For example both P8D and P1W1D result duration.weeks() == 1 and duration.days() == 8. + // Here we skip showing weeks and only take the total number of days. + if (duration.days() !== 0) { + parts.push(`${Math.abs(duration.days())}d`); + } + + if (duration.hours() !== 0) { + parts.push(`${Math.abs(duration.hours())}h`); + } + + if (duration.minutes() !== 0) { + parts.push(`${Math.abs(duration.minutes())}m`); + } + + if (duration.seconds() !== 0) { + parts.push(`${Math.abs(duration.seconds())}s`); + } + + const now = moment(); + const sign = now + .clone() + .add(duration) + .isBefore(now) + ? '-' + : '+'; + + return `(${sign}) ${parts.join(' ')}`; +} + /** Converts a protobuf Duration value to (H M S) format (ex. 2h 3m 30s)*/ export function protobufDurationToHMS(duration: Protobuf.IDuration) { return millisecondsToHMS(durationToMilliseconds(duration)); @@ -122,6 +170,31 @@ export function fixedRateToString({ value, unit }: Admin.IFixedRate): string { return `Every ${value} ${fixedRateUnitStrings[unit]}`; } +const hourlyAliases = ['@hourly', 'hourly', 'hours']; +const dailyAliases = ['@daily', 'daily', 'days']; +const weeklyAliases = ['@weekly', 'weekly', 'weeks']; +const monthlyAliases = ['@monthly', 'monthly', 'months']; +const yearlyAliases = ['@yearly', 'yearly', 'years', '@annually', 'annually']; + +export function getScheduleFrequencyStringFromAlias(schedule: string) { + if (hourlyAliases.includes(schedule)) { + return 'Every hour'; + } + if (dailyAliases.includes(schedule)) { + return 'Every day'; + } + if (weeklyAliases.includes(schedule)) { + return 'Every week'; + } + if (monthlyAliases.includes(schedule)) { + return 'Every month'; + } + if (yearlyAliases.includes(schedule)) { + return 'Every year'; + } + return ''; +} + export function getScheduleFrequencyString(schedule?: Admin.ISchedule) { if (schedule == null) { return ''; @@ -134,6 +207,23 @@ export function getScheduleFrequencyString(schedule?: Admin.ISchedule) { if (schedule.rate) { return fixedRateToString(schedule.rate); } + if (schedule.cronSchedule && schedule.cronSchedule.schedule) { + return ( + getScheduleFrequencyStringFromAlias( + schedule.cronSchedule.schedule + ) || cronstrue.toString(schedule.cronSchedule.schedule) + ); + } + return ''; +} + +export function getScheduleOffsetString(schedule?: Admin.ISchedule) { + if (schedule == null) { + return ''; + } + if (schedule.cronSchedule && schedule.cronSchedule.offset) { + return durationToYMWDHMS(moment.duration(schedule.cronSchedule.offset)); + } return ''; } diff --git a/src/common/test/formatters.spec.ts b/src/common/test/formatters.spec.ts index 5f209743a..c9be7bef0 100644 --- a/src/common/test/formatters.spec.ts +++ b/src/common/test/formatters.spec.ts @@ -1,4 +1,6 @@ import { millisecondsToDuration } from 'common/utils'; +import { Admin } from 'flyteidl'; +import * as moment from 'moment-timezone'; import { subSecondString, unknownValueString, @@ -8,10 +10,13 @@ import { dateDiffString, dateFromNow, dateWithFromNow, + durationToYMWDHMS, ensureUrlWithProtocol, formatDate, formatDateLocalTimezone, formatDateUTC, + getScheduleFrequencyString, + getScheduleOffsetString, leftPaddedNumber, millisecondsToHMS, protobufDurationToHMS @@ -160,6 +165,89 @@ describe('millisecondsToHMS', () => { ); }); +describe('durationToYMWDHMS', () => { + // input and expected result + const cases: [string, string][] = [ + ['P1Y1M1W1D', '(+) 1y 1M 8d'], + ['P1Y1M1W1DT1H1M1S', '(+) 1y 1M 8d 1h 1m 1s'], + ['P1Y1M1DT1H1M1S', '(+) 1y 1M 1d 1h 1m 1s'], + ['P1M1DT1H1M1S', '(+) 1M 1d 1h 1m 1s'], + ['P1DT1H1M1S', '(+) 1d 1h 1m 1s'], + ['PT1H1M1S', '(+) 1h 1m 1s'], + ['PT1M1S', '(+) 1m 1s'], + ['PT1S', '(+) 1s'], + ['PT1M-1S', '(+) 59s'], + ['-P1Y1M1W1D', '(-) 1y 1M 8d'], + ['-P1Y1M1W1DT1H1M1S', '(-) 1y 1M 8d 1h 1m 1s'], + ['-P1Y1M1DT1H1M1S', '(-) 1y 1M 1d 1h 1m 1s'], + ['-P1M1DT1H1M1S', '(-) 1M 1d 1h 1m 1s'], + ['-P1DT1H1M1S', '(-) 1d 1h 1m 1s'], + ['-PT1H1M1S', '(-) 1h 1m 1s'], + ['-PT1M1S', '(-) 1m 1s'], + ['-PT1S', '(-) 1s'], + ['PT-1M1S', '(-) 59s'], + ['', ''] + ]; + cases.forEach(([input, expected]) => + it(`should produce ${expected} with input ${input}`, () => { + expect(durationToYMWDHMS(moment.duration(input))).toEqual(expected); + }) + ); +}); + +describe('getScheduleFrequencyString', () => { + // input and expected result + const cases: [Admin.ISchedule, string][] = [ + [{ cronExpression: '* * * * *' }, 'Every minute'], + [ + { rate: { value: 1, unit: Admin.FixedRateUnit.MINUTE } }, + 'Every 1 minutes' + ], + [{ cronSchedule: { schedule: '* * * * *' } }, 'Every minute'], + [{ cronSchedule: { schedule: '@hourly' } }, 'Every hour'], + [{ cronSchedule: { schedule: 'hourly' } }, 'Every hour'], + [{ cronSchedule: { schedule: 'hours' } }, 'Every hour'], + [{ cronSchedule: { schedule: '@daily' } }, 'Every day'], + [{ cronSchedule: { schedule: 'daily' } }, 'Every day'], + [{ cronSchedule: { schedule: 'days' } }, 'Every day'], + [{ cronSchedule: { schedule: '@weekly' } }, 'Every week'], + [{ cronSchedule: { schedule: 'weekly' } }, 'Every week'], + [{ cronSchedule: { schedule: 'weeks' } }, 'Every week'], + [{ cronSchedule: { schedule: '@monthly' } }, 'Every month'], + [{ cronSchedule: { schedule: 'monthly' } }, 'Every month'], + [{ cronSchedule: { schedule: 'months' } }, 'Every month'], + [{ cronSchedule: { schedule: '@yearly' } }, 'Every year'], + [{ cronSchedule: { schedule: 'yearly' } }, 'Every year'], + [{ cronSchedule: { schedule: 'years' } }, 'Every year'], + [{ cronSchedule: { schedule: '@annually' } }, 'Every year'], + [{ cronSchedule: { schedule: 'annually' } }, 'Every year'], + [null!, ''], + [{ cronSchedule: { schedule: '' } }, ''] + ]; + + cases.forEach(([input, expected]) => + it(`should produce ${expected} with input ${input}`, () => { + expect(getScheduleFrequencyString(input)).toEqual(expected); + }) + ); +}); + +describe('getScheduleOffsetString', () => { + // input and expected result + const cases: [Admin.ISchedule, string][] = [ + [{ cronSchedule: { offset: 'P1D' } }, '(+) 1d'], + [{ cronSchedule: { offset: 'P-1D' } }, '(-) 1d'], + [null!, ''], + [{ cronSchedule: { offset: '' } }, ''] + ]; + + cases.forEach(([input, expected]) => + it(`should produce ${expected} with input ${input}`, () => { + expect(getScheduleOffsetString(input)).toEqual(expected); + }) + ); +}); + describe('ensureUrlWithProtocol', () => { // input and expected result const cases: [string, string][] = [ diff --git a/src/components/Entities/EntitySchedules.tsx b/src/components/Entities/EntitySchedules.tsx index 3b04b4852..a07943d00 100644 --- a/src/components/Entities/EntitySchedules.tsx +++ b/src/components/Entities/EntitySchedules.tsx @@ -1,6 +1,9 @@ import { Typography } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; -import { getScheduleFrequencyString } from 'common/formatters'; +import { + getScheduleFrequencyString, + getScheduleOffsetString +} from 'common/formatters'; import { WaitForData } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; import { useWorkflowSchedules } from 'components/hooks'; @@ -23,7 +26,11 @@ const RenderSchedules: React.FC<{ {launchPlans.map((launchPlan, idx) => { const { schedule } = launchPlan.spec.entityMetadata; const frequencyString = getScheduleFrequencyString(schedule); - return
  • {frequencyString}
  • ; + const offsetString = getScheduleOffsetString(schedule); + const scheduleString = offsetString + ? `${frequencyString} (offset by ${offsetString})` + : frequencyString; + return
  • {scheduleString}
  • ; })} ); diff --git a/src/components/Launch/constants.ts b/src/components/Launch/constants.ts index f558c3668..02e295f70 100644 --- a/src/components/Launch/constants.ts +++ b/src/components/Launch/constants.ts @@ -7,5 +7,6 @@ export const launchPlansTableColumnWidths = { export const schedulesTableColumnsWidths = { active: 80, frequency: 300, + offset: 300, name: 250 };