Skip to content

Commit b975f6f

Browse files
committed
feat(ui): add starting at/on offset to cron schedule editor
Add conditional "starting at" (hours) and "starting on day" (days) inputs to the crontab editor, allowing users to specify an offset when the interval is greater than 1. Generates comma-separated cron values instead of */N notation. Also adds hideExpression prop and fixes lookback window calculation for offset-based expressions.
1 parent fb96c0b commit b975f6f

5 files changed

Lines changed: 237 additions & 29 deletions

File tree

testgen/ui/components/frontend/js/components/crontab_input.js

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* @property {CronSample?} sample
2222
* @property {InitialValue?} value
2323
* @property {('x_hours'|'x_days'|'certain_days'|'custom'))[]?} modes
24+
* @property {boolean?} hideExpression
2425
* @property {((expr: string) => void)?} onChange
2526
*/
2627
import { getRandomId, getValue, loadStylesheet } from '../utils.js';
@@ -92,6 +93,7 @@ const CrontabInput = (/** @type Options */ props) => {
9293
onClose: () => opened.val = false,
9394
sample: props.sample,
9495
modes: props.modes,
96+
hideExpression: props.hideExpression,
9597
},
9698
expression,
9799
),
@@ -110,11 +112,13 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
110112
const xHoursState = {
111113
hours: van.state(1),
112114
minute: van.state(0),
115+
startHour: van.state(0),
113116
};
114117
const xDaysState = {
115118
days: van.state(1),
116119
hour: van.state(1),
117120
minute: van.state(0),
121+
startDay: van.state(1),
118122
};
119123
const certainDaysState = {
120124
sunday: van.state(false),
@@ -135,12 +139,30 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
135139
if (mode.val === 'x_hours') {
136140
const hours = xHoursState.hours.val;
137141
const minute = xHoursState.minute.val;
138-
options.onChange(`${minute ?? 0} ${(hours && hours !== 1) ? '*/' + hours : '*'} * * *`);
142+
const startHour = xHoursState.startHour.val;
143+
let hourField;
144+
if (!hours || hours <= 1) {
145+
hourField = '*';
146+
} else if (startHour > 0) {
147+
hourField = generateSteppedValues(startHour, hours, 23);
148+
} else {
149+
hourField = '*/' + hours;
150+
}
151+
options.onChange(`${minute ?? 0} ${hourField} * * *`);
139152
} else if (mode.val === 'x_days') {
140153
const days = xDaysState.days.val;
141154
const hour = xDaysState.hour.val;
142155
const minute = xDaysState.minute.val;
143-
options.onChange(`${minute ?? 0} ${hour ?? 0} ${(days && days !== 1) ? '*/' + days : '*'} * *`);
156+
const startDay = xDaysState.startDay.val;
157+
let dayField;
158+
if (!days || days <= 1) {
159+
dayField = '*';
160+
} else if (startDay > 1) {
161+
dayField = generateSteppedValues(startDay, days, 31);
162+
} else {
163+
dayField = '*/' + days;
164+
}
165+
options.onChange(`${minute ?? 0} ${hour ?? 0} ${dayField} * *`);
144166
} else if (mode.val === 'certain_days') {
145167
const days = [];
146168
const dayMap = [
@@ -225,16 +247,19 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
225247
span({}, 'Every'),
226248
() => Select({
227249
label: "",
228-
options: Array.from({length: 24}, (_, i) => i).map(i => ({label: i.toString(), value: i})),
250+
options: Array.from({length: 24}, (_, i) => i + 1).map(i => ({label: i.toString(), value: i})),
229251
triggerStyle: 'inline',
230252
portalClass: 'tg-crontab--select-portal',
231253
value: xHoursState.hours,
232-
onChange: (value) => xHoursState.hours.val = value,
254+
onChange: (value) => {
255+
xHoursState.hours.val = value;
256+
if (value <= 1) xHoursState.startHour.val = 0;
257+
},
233258
}),
234259
span({}, 'hours'),
235260
),
236261
div(
237-
{class: 'flex-row fx-gap-2'},
262+
{class: () => `flex-row fx-gap-2 ${xHoursState.hours.val > 1 ? 'mb-2' : ''}`},
238263
span({}, 'on'),
239264
span({}, 'minute'),
240265
() => Select({
@@ -246,6 +271,18 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
246271
onChange: (value) => xHoursState.minute.val = value,
247272
}),
248273
),
274+
div(
275+
{class: () => `flex-row fx-gap-2 ${xHoursState.hours.val > 1 ? '' : 'hidden'}`},
276+
span({}, 'starting at hour'),
277+
() => Select({
278+
label: "",
279+
options: Array.from({length: 24}, (_, i) => i).map(i => ({label: i.toString(), value: i})),
280+
triggerStyle: 'inline',
281+
portalClass: 'tg-crontab--select-portal',
282+
value: xHoursState.startHour,
283+
onChange: (value) => xHoursState.startHour.val = value,
284+
}),
285+
),
249286
),
250287
div(
251288
{ class: () => `${mode.val === 'x_days' ? '' : 'hidden'}`},
@@ -258,12 +295,15 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
258295
triggerStyle: 'inline',
259296
portalClass: 'tg-crontab--select-portal',
260297
value: xDaysState.days,
261-
onChange: (value) => xDaysState.days.val = value,
298+
onChange: (value) => {
299+
xDaysState.days.val = value;
300+
if (value <= 1) xDaysState.startDay.val = 1;
301+
},
262302
}),
263303
span({}, 'days'),
264304
),
265305
div(
266-
{class: 'flex-row fx-gap-2'},
306+
{class: () => `flex-row fx-gap-2 ${xDaysState.days.val > 1 ? 'mb-2' : ''}`},
267307
span({}, 'at'),
268308
() => Select({
269309
label: "",
@@ -282,6 +322,18 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
282322
onChange: (value) => xDaysState.minute.val = value,
283323
}),
284324
),
325+
div(
326+
{class: () => `flex-row fx-gap-2 ${xDaysState.days.val > 1 ? '' : 'hidden'}`},
327+
span({}, 'starting on day'),
328+
() => Select({
329+
label: "",
330+
options: Array.from({length: 31}, (_, i) => i + 1).map(i => ({label: i.toString(), value: i})),
331+
triggerStyle: 'inline',
332+
portalClass: 'tg-crontab--select-portal',
333+
value: xDaysState.startDay,
334+
onChange: (value) => xDaysState.startDay.val = value,
335+
}),
336+
),
285337
),
286338
div(
287339
{ class: () => `${mode.val === 'certain_days' ? '' : 'hidden'}`},
@@ -370,7 +422,7 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
370422
div(
371423
{class: 'flex-column fx-gap-1 mt-3 text-secondary'},
372424
() => span(
373-
{ class: mode.val === 'custom' ? 'hidden': '' },
425+
{ class: mode.val === 'custom' || getValue(options.hideExpression) ? 'hidden': '' },
374426
`Cron Expression: ${expr.val ?? ''}`,
375427
),
376428
() => div(
@@ -409,6 +461,25 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
409461
);
410462
};
411463

464+
function generateSteppedValues(start, step, max) {
465+
const values = [];
466+
for (let i = start; i <= max; i += step) {
467+
values.push(i);
468+
}
469+
return values.join(',');
470+
}
471+
472+
function parseSteppedList(field) {
473+
const values = field.split(',').map(Number);
474+
if (values.length < 2 || values.some(isNaN)) return null;
475+
const step = values[1] - values[0];
476+
if (step <= 0) return null;
477+
for (let i = 2; i < values.length; i++) {
478+
if (values[i] - values[i - 1] !== step) return null;
479+
}
480+
return { start: values[0], step };
481+
}
482+
412483
/**
413484
* Populates the state variables for the initial mode based on the cron expression
414485
* @param {string} expr
@@ -420,21 +491,35 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => {
420491
function populateInitialModeState(expr, mode, xHoursState, xDaysState, certainDaysState) {
421492
const parts = (expr || '').trim().split(/\s+/);
422493
if (mode === 'x_hours' && parts.length === 5) {
423-
// e.g. "M */H * * *" or "M * * * *"
424494
xHoursState.minute.val = Number(parts[0]) || 0;
425495
if (parts[1].startsWith('*/')) {
426496
xHoursState.hours.val = Number(parts[1].slice(2)) || 1;
497+
xHoursState.startHour.val = 0;
498+
} else if (parts[1].includes(',')) {
499+
const parsed = parseSteppedList(parts[1]);
500+
if (parsed) {
501+
xHoursState.hours.val = parsed.step;
502+
xHoursState.startHour.val = parsed.start;
503+
}
427504
} else {
428505
xHoursState.hours.val = 1;
506+
xHoursState.startHour.val = 0;
429507
}
430508
} else if (mode === 'x_days' && parts.length === 5) {
431-
// e.g. "M H */D * *" or "M H * * *"
432509
xDaysState.minute.val = Number(parts[0]) || 0;
433510
xDaysState.hour.val = Number(parts[1]) || 0;
434511
if (parts[2].startsWith('*/')) {
435512
xDaysState.days.val = Number(parts[2].slice(2)) || 1;
513+
xDaysState.startDay.val = 1;
514+
} else if (parts[2].includes(',')) {
515+
const parsed = parseSteppedList(parts[2]);
516+
if (parsed) {
517+
xDaysState.days.val = parsed.step;
518+
xDaysState.startDay.val = parsed.start;
519+
}
436520
} else {
437521
xDaysState.days.val = 1;
522+
xDaysState.startDay.val = 1;
438523
}
439524
} else if (mode === 'certain_days' && parts.length === 5) {
440525
// e.g. "M H * * DAY[,DAY...]"
@@ -465,14 +550,22 @@ function populateInitialModeState(expr, mode, xHoursState, xDaysState, certainDa
465550
function determineMode(expression) {
466551
// Normalize whitespace
467552
const expr = (expression || '').trim().replace(/\s+/g, ' ');
468-
// x_hours: "M */H * * *" or "M * * * *"
553+
// x_hours: "M */H * * *" or "M * * * *" or "M H1,H2,... * * *"
469554
if (/^\d{1,2} \*\/\d+ \* \* \*$/.test(expr) || /^\d{1,2} \* \* \* \*$/.test(expr)) {
470555
return 'x_hours';
471556
}
472-
// x_days: "M H */D * *" or "M H * * *"
557+
if (/^\d{1,2} \d+(,\d+)+ \* \* \*$/.test(expr)) {
558+
const hourField = expr.split(' ')[1];
559+
if (parseSteppedList(hourField)) return 'x_hours';
560+
}
561+
// x_days: "M H */D * *" or "M H * * *" or "M H D1,D2,... * *"
473562
if (/^\d{1,2} \d{1,2} \*\/\d+ \* \*$/.test(expr) || /^\d{1,2} \d{1,2} \* \* \*$/.test(expr)) {
474563
return 'x_days';
475564
}
565+
if (/^\d{1,2} \d{1,2} \d+(,\d+)+ \* \*$/.test(expr)) {
566+
const dayField = expr.split(' ')[2];
567+
if (parseSteppedList(dayField)) return 'x_days';
568+
}
476569
// certain_days: "M H * * DAY[,DAY...]" (DAY = SUN,MON,...)
477570
if (/^\d{1,2} \d{1,2} \* \* ((SUN|MON|TUE|WED|THU|FRI|SAT)(-(SUN|MON|TUE|WED|THU|FRI|SAT))?(,)?)+$/.test(expr)) {
478571
return 'certain_days';
@@ -533,4 +626,4 @@ stylesheet.replace(`
533626
}
534627
`);
535628

536-
export { CrontabInput };
629+
export { CrontabInput, parseSteppedList };

testgen/ui/components/frontend/js/components/monitor_settings_form.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { RadioGroup } from './radio_group.js';
3939
import { Caption } from './caption.js';
4040
import { Select } from './select.js';
4141
import { Checkbox } from './checkbox.js';
42-
import { CrontabInput } from './crontab_input.js';
42+
import { CrontabInput, parseSteppedList } from './crontab_input.js';
4343
import { Icon } from './icon.js';
4444
import { Link } from './link.js';
4545
import { withTooltip } from './tooltip.js';
@@ -241,6 +241,7 @@ const ScheduleForm = (
241241
sample: options.cronSample,
242242
value: cronEditorValue,
243243
modes: ['x_hours', 'x_days'],
244+
hideExpression: true,
244245
onChange: (value) => cronExpression.val = value,
245246
}),
246247
),
@@ -372,6 +373,11 @@ function determineDuration(expression) {
372373
if (match) {
373374
return Number(match[1]) * 60 * 60; // H hours
374375
}
376+
// "M H1,H2,... * * *" (stepped hours with starting offset)
377+
if (/^\d{1,2} \d+(,\d+)+ \* \* \*$/.test(expr)) {
378+
const parsed = parseSteppedList(expr.split(' ')[1]);
379+
if (parsed) return parsed.step * 60 * 60;
380+
}
375381
// "M H * * *"
376382
if (/^\d{1,2} \d{1,2} \* \* \*$/.test(expr)) {
377383
return 24 * 60 * 60; // 1 day
@@ -381,6 +387,11 @@ function determineDuration(expression) {
381387
if (match) {
382388
return Number(match[1]) * 24 * 60 * 60; // D days
383389
}
390+
// "M H D1,D2,... * *" (stepped days with starting offset)
391+
if (/^\d{1,2} \d{1,2} \d+(,\d+)+ \* \*$/.test(expr)) {
392+
const parsed = parseSteppedList(expr.split(' ')[2]);
393+
if (parsed) return parsed.step * 24 * 60 * 60;
394+
}
384395
return null;
385396
}
386397

0 commit comments

Comments
 (0)