fix(Cron Parser): handle aws, next executions and TZ

Handle AWS Cron syntax and distinguishe from standard syntax (fix #855)
Add show crontab next 5 execution times (taken from #1283)
Add Timezone handling: fix #261
This commit is contained in:
sharevb 2024-05-01 14:22:46 +02:00 committed by ShareVB
parent cb5b462e11
commit 48b4904cf1
9 changed files with 339 additions and 18 deletions

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { getCronType, getLastExecutionTimes, isCronValid } from './crontab-generator.service';
describe('crontab-generator', () => {
describe('isCronValid', () => {
it('should return true for all valid formats', () => {
expect(isCronValid('0 0 * * 1-5')).toBe(true);
expect(isCronValid('23 0-20/2 * * *')).toBe(true);
// AWS formats
expect(isCronValid('0 11-22 ? * MON-FRI *')).toBe(true);
expect(isCronValid('0 0 ? * 1 *')).toBe(true);
});
it('should return false for all invalid formats', () => {
expect(isCronValid('aert')).toBe(false);
expect(isCronValid('40 *')).toBe(false);
});
});
describe('getCronType', () => {
it('should return right type', () => {
expect(getCronType('0 0 * * 1-5')).toBe('standard');
expect(getCronType('23 0-20/2 * * *')).toBe('standard');
// AWS formats
expect(getCronType('0 11-22 ? * MON-FRI *')).toBe('aws');
expect(getCronType('0 0 ? * 1 *')).toBe('aws');
expect(getCronType('aert')).toBe(false);
expect(getCronType('40 *')).toBe(false);
});
});
describe('getLastExecutionTimes', () => {
it('should return next valid datetimes', () => {
expect(getLastExecutionTimes('0 0 * * 1-5')).toHaveLength(5);
expect(getLastExecutionTimes('23 0-20/2 * * *')).toHaveLength(5);
// AWS formats
expect(getLastExecutionTimes('0 11-22 ? * MON-FRI *')).toHaveLength(5);
expect(getLastExecutionTimes('0 0 ? * 1 *')).toHaveLength(5);
});
});
});

View file

@ -0,0 +1,44 @@
import { parseExpression } from 'cron-parser';
import EventCronParser from 'event-cron-parser';
export function getLastExecutionTimes(cronExpression: string, tz: string | undefined = undefined, count: number = 5) {
if (getCronType(cronExpression) === 'standard') {
const interval = parseExpression(cronExpression, { tz });
const times = [];
for (let i = 0; i < count; i++) {
times.push(interval.next().toJSON());
}
return times;
}
if (getCronType(cronExpression) === 'aws') {
const parsed = new EventCronParser(cronExpression);
const times = [];
for (let i = 0; i < count; i++) {
times.push(JSON.stringify(parsed.next()));
}
return times;
}
return [];
}
export function isCronValid(v: string) {
return !!getCronType(v);
}
export function getCronType(v: string) {
try {
parseExpression(v);
return 'standard';
}
catch (_) {
try {
const parsed = new EventCronParser(v);
parsed.validate();
return 'aws';
}
catch (_) {
}
}
return false;
}

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import cronstrue from 'cronstrue';
import { isValidCron } from 'cron-validator';
import ctz from 'countries-and-timezones';
import getTimezoneOffset from 'get-timezone-offset';
import { getCronType, getLastExecutionTimes, isCronValid } from './crontab-generator.service';
import { useStyleStore } from '@/stores/style.store';
function isCronValid(v: string) {
return isValidCron(v, { allowBlankDay: true, alias: true, seconds: true });
}
import { useQueryParamOrStorage } from '@/composable/queryParams';
const styleStore = useStyleStore();
@ -15,9 +14,22 @@ const cronstrueConfig = reactive({
dayOfWeekStartIndexZero: true,
use24HourTimeFormat: true,
throwExceptionOnParseError: true,
monthStartIndexZero: false,
tzOffset: (new Date()).getTimezoneOffset() / 60,
});
const helpers = [
// getTimezoneOffset(tz.name, now) / 60
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const allTimezones = Object.values(ctz.getAllTimezones()).map(tz => ({
value: tz.name,
label: `${tz.name === browserTimezone ? 'Browser TZ - ' : ''}${tz.name} (${tz.utcOffset === tz.dstOffset ? tz.utcOffsetStr : `${tz.utcOffsetStr}/${tz.dstOffsetStr}`})`,
}));
const currentTimezone = useQueryParamOrStorage({ name: 'tz', storageName: 'crongen:tz', defaultValue: browserTimezone });
watchEffect(() => {
cronstrueConfig.tzOffset = -getTimezoneOffset(currentTimezone.value, new Date()) / 60;
});
const standardHelpers = [
{
symbol: '*',
meaning: 'Any value',
@ -92,6 +104,77 @@ const helpers = [
},
];
const awsHelpers = [
{
symbol: '*',
meaning: 'Any value',
example: '* * * *',
equivalent: 'Every minute',
},
{
symbol: '-',
meaning: 'Range of values',
example: '1-10 * * *',
equivalent: 'Minutes 1 through 10',
},
{
symbol: ',',
meaning: 'List of values',
example: '1,10 * * *',
equivalent: 'At minutes 1 and 10',
},
{
symbol: '/',
meaning: 'Step values',
example: '*/10 * * *',
equivalent: 'Every 10 minutes',
},
{
symbol: '?',
meaning: 'One or another. In the Day-of-month field you could enter 7, and if you didn\'t care what day of the week the seventh was, you could enter ? in the Day-of-week field',
example: '9 * 7,9,11 5 ? 2021',
equivalent: 'At 9 minutes past the hour, every hour, on day 7, 9, and 11 of the month, only in May, only in 2021',
},
{
symbol: 'L',
meaning: 'The L wildcard in the Day-of-month or Day-of-week fields specifies the last day of the month or week.',
example: '9 * L 5 ? 2019,2020',
equivalent: 'At 9 minutes past the hour, every hour, on the last day of the month, only in May, only in 2019 and 2020',
},
{
symbol: 'W',
meaning: 'The W wildcard in the Day-of-month field specifies a weekday. In the Day-of-month field, 3W specifies the day closest to the third weekday of the month.',
example: '19 4 3W 9 ? 2019,2020',
equivalent: 'At 04:19 AM, on the weekday nearest day 3 of the month, only in September, only in 2019 and 2020',
},
{
symbol: '#',
meaning: 'The # wildcard in the Day-of-week field specifies the nieth weekday of the month. 3#5 specifies the fifth Wednesday of the month',
example: '9 8-20 ? 12 3#5 2019,2020',
equivalent: 'At 9 minutes past the hour, between 08:00 AM and 08:59 PM, on the fifth Wednesday of the month, only in December, only in 2019 and 2020',
},
];
const cronType = computed({
get() {
return getCronType(cron.value);
},
set(newCronType) {
if (newCronType === 'aws') {
cron.value = '0 0 ? * 1 *';
}
else {
cron.value = '40 * * * *';
}
},
});
const getHelpers = computed(() => {
if (cronType.value === 'aws') {
return awsHelpers;
}
return standardHelpers;
});
const cronString = computed(() => {
if (isCronValid(cron.value)) {
return cronstrue.toString(cron.value, cronstrueConfig);
@ -105,6 +188,20 @@ const cronValidationRules = [
message: 'This cron is invalid',
},
];
const executionTimesString = computed(() => {
if (isCronValid(cron.value)) {
try {
const lastExecutionTimes = getLastExecutionTimes(cron.value, currentTimezone.value);
const executionTimesString = lastExecutionTimes.join('\n');
return `Next 5 execution times:\n${executionTimesString}`;
}
catch (e: any) {
return e.toString();
}
}
return ' ';
});
</script>
<template>
@ -119,10 +216,27 @@ const cronValidationRules = [
/>
</div>
<n-radio-group v-model:value="cronType" name="radiogroup" mb-2 flex justify-center>
<n-space>
<n-radio
value="standard"
label="Unix standard"
/>
<n-radio
value="aws"
label="AWS"
/>
</n-space>
</n-radio-group>
<div class="cron-string">
{{ cronString }}
</div>
<div class="cron-execution-string">
{{ executionTimesString }}
</div>
<n-divider />
<div flex justify-center>
@ -136,11 +250,21 @@ const cronValidationRules = [
<n-form-item label="Days start at 0">
<n-switch v-model:value="cronstrueConfig.dayOfWeekStartIndexZero" />
</n-form-item>
<n-form-item label="Months start at 0">
<n-switch v-model:value="cronstrueConfig.monthStartIndexZero" />
</n-form-item>
<c-select
v-model:value="currentTimezone"
searchable
label="Timezone:"
:options="allTimezones"
/>
</n-form>
</div>
</c-card>
<c-card>
<pre>
<pre v-if="cronType === 'standard'">
-- Standard CRON Syntax --
[optional] seconds (0 - 59)
| minute (0 - 59)
| | hour (0 - 23)
@ -150,8 +274,19 @@ const cronValidationRules = [
| | | | | |
* * * * * * command</pre>
<pre v-if="cronType === 'aws'">
-- AWS CRON Syntax --
minute (0 - 59)
| hour (0 - 23)
| | day of month (1 - 31) OR ? OR L OR W
| | | month (1 - 12) OR jan,feb,mar,apr ...
| | | | day of week (0 - 6, sunday=0) OR sun,mon OR L ...
| | | | | year
| | | | | |
* * * * * *</pre>
<div v-if="styleStore.isSmallScreen">
<c-card v-for="{ symbol, meaning, example, equivalent } in helpers" :key="symbol" mb-3 important:border-none>
<c-card v-for="{ symbol, meaning, example, equivalent } in getHelpers" :key="symbol" mb-3 important:border-none>
<div>
Symbol: <strong>{{ symbol }}</strong>
</div>
@ -168,7 +303,7 @@ const cronValidationRules = [
</c-card>
</div>
<c-table v-else :data="helpers" />
<c-table v-else :data="getHelpers" />
</c-card>
</template>
@ -191,4 +326,12 @@ pre {
overflow: auto;
padding: 10px 0;
}
.cron-execution-string{
text-align: center;
font-size: 14px;
opacity: 0.8;
margin: 5px 0 15px;
white-space: pre-wrap;
}
</style>

View file

@ -0,0 +1,3 @@
declare module "get-timezone-offset" {
export default function(timeZoneName: string, date: Date);
}

View file

@ -20,6 +20,7 @@ export const tool = defineTool({
'day',
'minute',
'second',
'aws',
],
component: () => import('./crontab-generator.vue'),
icon: Alarm,