#90 #91 #92 #93 supported all trigger types

This commit is contained in:
Fabio Formosa
2026-05-12 22:53:11 +02:00
parent 82e684f0a7
commit 93990a5994
32 changed files with 1576 additions and 79 deletions

View File

@@ -51,10 +51,11 @@ import {
SchedulerService,
ConfigService,
getHtmlBaseUrl,
LogsRxWebsocketService,
ProgressRxWebsocketService,
TriggerService
} from './services';
LogsRxWebsocketService,
ProgressRxWebsocketService,
TriggerService,
CalendarService
} from './services';
import { ForbiddenComponent } from './views/forbidden/forbidden.component';
import { APP_BASE_HREF } from '@angular/common';
import JobService from './services/job.service';
@@ -135,6 +136,7 @@ export function jwtOptionsFactory(apiService: ApiService) {
SchedulerService,
JobService,
TriggerService,
CalendarService,
ProgressRxWebsocketService,
LogsRxWebsocketService,
AuthService,

View File

@@ -0,0 +1,24 @@
import {TriggerKey} from './triggerKey.model';
export type CalendarType = 'ANNUAL' | 'CRON' | 'DAILY' | 'HOLIDAY' | 'MONTHLY' | 'WEEKLY';
export class QuartzCalendar {
name: string;
type: CalendarType = 'WEEKLY';
description: string;
cronExpression: string;
timeZone: string;
rangeStartingTime: string;
rangeEndingTime: string;
invertTimeRange: boolean;
excludedDaysOfWeek: number[];
excludedDaysOfMonth: number[];
excludedDates: Date[];
triggerKeys: TriggerKey[];
}
export class CalendarIncludedTimeTest {
time: Date;
included: boolean;
nextIncludedTime: Date;
}

View File

@@ -0,0 +1,26 @@
import {JobKeyModel} from './jobKey.model';
export type TriggerType = 'SIMPLE' | 'CRON' | 'DAILY_TIME_INTERVAL' | 'CALENDAR_INTERVAL';
export class TriggerCommand {
triggerType: TriggerType = 'SIMPLE';
jobClass: string;
jobKey: JobKeyModel;
startDate: Date;
endDate: Date;
description: string;
priority: number;
calendarName: string;
misfireInstruction: string;
jobDataMap: {[key: string]: unknown};
repeatCount: number;
repeatInterval: number;
repeatIntervalUnit: string;
cronExpression: string;
timeZone: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
}

View File

@@ -19,4 +19,14 @@ export class Trigger {
jobDetailDTO: JobDetail = new JobDetail();
mayFireAgain: boolean;
jobDataMap: {[key: string]: unknown};
cronExpression: string;
timeZone: string;
repeatInterval: number;
repeatCount: number;
repeatIntervalUnit: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
}

View File

@@ -0,0 +1,36 @@
import {jest} from '@jest/globals';
import {CalendarService} from './calendar.service';
describe('CalendarService', () => {
let apiService: any;
let calendarService: CalendarService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
};
calendarService = new CalendarService(apiService);
});
it('uses calendar registry endpoints', () => {
const calendar: any = {name: 'weekends', type: 'WEEKLY'};
const time = new Date('2026-05-12T12:00:00.000Z');
calendarService.fetchCalendars();
calendarService.getCalendar('weekends');
calendarService.createCalendar('weekends', calendar);
calendarService.updateCalendar('weekends', calendar);
calendarService.deleteCalendar('weekends');
calendarService.testIncludedTime('weekends', time);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars/weekends');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar);
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/calendars/weekends');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends/included-time-test', {time});
});
});

View File

@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {ApiService} from './api.service';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {CalendarIncludedTimeTest, QuartzCalendar} from '../model/calendar.model';
@Injectable()
export class CalendarService {
constructor(private apiService: ApiService) {}
fetchCalendars = (): Observable<QuartzCalendar[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars`);
}
getCalendar = (name: string): Observable<QuartzCalendar> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`);
}
createCalendar = (name: string, calendar: QuartzCalendar): Observable<QuartzCalendar> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar);
}
updateCalendar = (name: string, calendar: QuartzCalendar): Observable<QuartzCalendar> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar);
}
deleteCalendar = (name: string): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`);
}
testIncludedTime = (name: string, time: Date): Observable<CalendarIncludedTimeTest> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}/included-time-test`, {time});
}
}

View File

@@ -5,7 +5,8 @@ export * from './auth.service';
export * from './scheduler.service';
export * from './progress.rx-websocket.service';
export * from './logs.rx-websocket.service';
export * from './trigger.service'
export * from './job.service'
export * from './trigger.service'
export * from './calendar.service'
export * from './job.service'

View File

@@ -18,21 +18,28 @@ describe('JobService', () => {
it('uses job class and scheduled job endpoints', () => {
const job = new ScheduledJob();
const command = {
jobClass: 'SampleJob',
description: '',
durable: true,
requestsRecovery: false,
jobDataMap: {}
};
job.jobKeyDTO = {group: 'DEFAULT', name: 'sampleJob'};
jobService.fetchJobs();
jobService.fetchScheduledJobs();
jobService.getScheduledJob('DEFAULT', 'sampleJob');
jobService.createJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}});
jobService.updateJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}});
jobService.createJob('DEFAULT', 'sampleJob', command);
jobService.updateJob('DEFAULT', 'sampleJob', command);
jobService.triggerJob(job);
jobService.deleteJob(job);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/job-classes');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}});
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command);
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob/trigger', {});
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob');
});

View File

@@ -10,6 +10,7 @@ describe('TriggerService', () => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
};
triggerService = new TriggerService(apiService);
@@ -28,4 +29,14 @@ describe('TriggerService', () => {
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger/resume', {});
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger');
});
it('uses generic trigger create and update endpoints', () => {
const command: any = {triggerType: 'CRON', cronExpression: '0 0/5 * * * ?'};
triggerService.saveTrigger('OPS', 'cronTrigger', command);
triggerService.updateTrigger('OPS', 'cronTrigger', command);
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command);
});
});

View File

@@ -4,6 +4,7 @@ import {Observable} from 'rxjs';
import {Trigger} from '../model/trigger.model';
import {TriggerKey} from '../model/triggerKey.model';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {TriggerCommand} from '../model/trigger-command.model';
@Injectable()
export class TriggerService {
@@ -20,6 +21,14 @@ export class TriggerService {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`);
}
saveTrigger = (group: string, name: string, config: TriggerCommand): Observable<Trigger> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config);
}
updateTrigger = (group: string, name: string, config: TriggerCommand): Observable<Trigger> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config);
}
pauseTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/pause`, {});
}

View File

@@ -245,17 +245,16 @@
<div class="page" [class.active]="activePage === 'calendars'">
<div class="page-kicker">
<div><h2>Calendars</h2><p>Quartz calendar registry, rule editing, trigger usage, and next included time testing are not exposed by the backend yet.</p></div>
<div class="toolbar"><input class="search" value="Filter calendars" data-roadmap="Calendar filtering is on the roadmap"><button type="button" class="btn primary" data-roadmap="Calendar creation is on the roadmap">New Calendar</button></div>
<div><h2>Calendars</h2><p>Manage Quartz calendar exclusions and inspect which triggers are attached to each calendar.</p></div>
<div class="toolbar"><input class="search" name="calendarSearch" placeholder="Filter calendars" [(ngModel)]="calendarSearch"><button type="button" class="btn primary" (click)="openCreateCalendarWizard()">New Calendar</button></div>
</div>
<section class="card roadmap-card" data-roadmap="Quartz calendar management is on the roadmap">
<div class="card-header"><h2 class="card-title">Calendar Registry</h2><span class="chip warn">ROADMAP</span></div>
<div class="card-body two-column compact-roadmap">
<div>
<p class="roadmap-copy">This UI is ready for WeeklyCalendar, HolidayCalendar, MonthlyCalendar, DailyCalendar, and CronCalendar once the API surface is added.</p>
<div class="calendar-grid" aria-label="Calendar exclusion preview"><div class="calendar-cell">Mon</div><div class="calendar-cell">Tue</div><div class="calendar-cell">Wed</div><div class="calendar-cell">Thu</div><div class="calendar-cell">Fri</div><div class="calendar-cell excluded">Sat</div><div class="calendar-cell excluded">Sun</div></div>
</div>
<aside class="filter-panel"><h3>Planned backend</h3><div class="help">List calendars, inspect calendar type/base calendar, attach calendars to triggers, test included time, and edit exclusion rules.</div><button type="button" class="btn" data-roadmap="Calendar trigger usage is on the roadmap">Show Triggers</button><button type="button" class="btn danger" data-roadmap="Calendar deletion is on the roadmap">Delete Calendar</button></aside>
<section class="card">
<div class="card-header"><h2 class="card-title">Calendar Registry</h2><span class="chip normal">{{ getCalendarRows().length }} / {{ calendars.length }} CALENDARS</span></div>
<div class="split">
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Type</th><th>Description</th><th>Triggers</th></tr></thead><tbody>@for (calendar of getCalendarRows(); track calendar.name) { <tr class="selectable" [class.selected]="selectedCalendar?.name === calendar.name" (click)="selectCalendar(calendar)"><td class="mono">{{ calendar.name }}</td><td><span class="chip accent">{{ calendar.type }}</span></td><td>{{ calendar.description || '-' }}</td><td class="mono">{{ calendar.triggerKeys?.length || 0 }}</td></tr> } @empty { <tr><td colspan="4">No calendars registered. Create one to exclude time windows from trigger firing.</td></tr> }</tbody></table></div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'calendars'" aria-label="Calendar detail drawer"><div class="drawer-title"><div><span class="chip accent">{{ selectedCalendar?.type || 'NONE' }}</span><h2>{{ selectedCalendar?.name || 'No calendar' }}</h2><div class="caption">{{ selectedCalendar?.description || 'Select a calendar to inspect rules.' }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="field-grid"><div class="field"><label>Type</label><strong>{{ selectedCalendar?.type || '-' }}</strong></div><div class="field"><label>Attached triggers</label><strong>{{ selectedCalendar?.triggerKeys?.length || 0 }}</strong></div><div class="field"><label>Cron</label><strong>{{ selectedCalendar?.cronExpression || '-' }}</strong></div><div class="field"><label>Time zone</label><strong>{{ selectedCalendar?.timeZone || '-' }}</strong></div></div><pre class="code-block">Triggers
@for (triggerKey of selectedCalendar?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre><div class="control"><label>Included time test</label><input class="input mono" type="datetime-local" name="calendarIncludedTime" [(ngModel)]="calendarDraft.includedTime"></div><div class="help">{{ calendarIncludedTimeResult || 'Test whether this calendar includes a specific timestamp.' }}</div><div class="actions"><button type="button" class="btn" (click)="testSelectedCalendarTime()">Test Time</button><button type="button" class="btn" (click)="openEditCalendarWizard()">Edit</button><button type="button" class="btn danger" (click)="deleteSelectedCalendar()">Delete</button></div></aside>
</div>
</section>
</div>
@@ -328,7 +327,7 @@
</section>
</main>
@if (wizardOpen || jobWizardOpen || detailDrawerOpen) {
@if (wizardOpen || jobWizardOpen || calendarWizardOpen || detailDrawerOpen) {
<button type="button" class="drawer-backdrop" aria-label="Close drawer" (click)="closeDrawers()"></button>
}
@@ -341,17 +340,16 @@
}
<aside class="wizard drawer" [class.drawer-open]="wizardOpen" aria-label="Trigger creation wizard">
<div class="wizard-header"><div><h2>{{ getWizardTitle() }}</h2><div class="caption">SimpleTrigger is supported now. Other trigger types are roadmap-gated.</div></div><button type="button" class="drawer-close" (click)="closeWizardDrawer()">Close</button></div>
<div class="wizard-header"><div><h2>{{ getWizardTitle() }}</h2><div class="caption">Simple, Cron, Daily Time Interval, and Calendar Interval triggers are supported.</div></div><button type="button" class="drawer-close" (click)="closeWizardDrawer()">Close</button></div>
<div class="stepper"><div class="step done"><span></span><span>Identity</span></div><div class="step active"><span></span><span>Type</span></div><div class="step done"><span></span><span>Schedule</span></div><div class="step done"><span></span><span>Advanced</span></div><div class="step active"><span></span><span>Preview</span></div></div>
<form class="wizard-form" (ngSubmit)="submitTriggerWizard()">
<div class="wizard-scroll">
@if (wizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ wizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" list="trigger-groups" required></div><datalist id="trigger-groups">@for (group of getTriggerGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Quartz groups are implicit namespaces. Type a new group to create it with this trigger.</div></div><div class="control"><label>Target type</label><select class="select" name="jobTargetType" [(ngModel)]="triggerDraft.jobTargetType"><option value="stored">Existing stored job</option><option value="class">New job from class</option></select></div>@if (triggerDraft.jobTargetType === 'stored') { <div class="control"><label>Stored job</label><select class="select" name="storedJobKey" [(ngModel)]="triggerDraft.storedJobKey" required>@for (job of getStoredJobOptions(); track job.value) { <option [value]="job.value">{{ job.label }}</option> }</select><div class="help">The trigger will call TriggerBuilder.forJob with this stored job key.</div></div> } @else { <div class="control"><label>Job class</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">The backend will create an ephemeral job for this trigger.</div></div> }</div></section>
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><div class="type-option active"><strong>Simple</strong><span class="help">Repeat every fixed interval. Supported now.</span></div><button type="button" class="type-option" data-roadmap="CronTrigger support is on the roadmap"><strong>Cron</strong><span class="help">Calendar expression builder.</span></button><button type="button" class="type-option" data-roadmap="DailyTimeIntervalTrigger support is on the roadmap"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" data-roadmap="CalendarIntervalTrigger support is on the roadmap"><strong>Calendar Interval</strong><span class="help">Every N days, weeks, months.</span></button></div></div></section>
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>Repeat interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option></select></div><div class="help">The UI edits operational units and persists the current backend repeatInterval in milliseconds.</div></div><div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div></div></section>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required><option value="MISFIRE_INSTRUCTION_FIRE_NOW">MISFIRE_INSTRUCTION_FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option></select></div><div class="control"><label>JobDataMap override</label><div class="data-map-editor">@for (entry of triggerDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="triggerDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="triggerDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="triggerDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(triggerDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(triggerDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getTriggerDraftDataMapPreview() }}</pre></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> every <strong>{{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}</strong>, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
<div class="warning-box"><strong>Backend support boundary</strong><span>SimpleTrigger create/reschedule is submitted. Groups are implicit Quartz namespaces, not standalone records.</span></div>
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'SIMPLE'" (click)="selectTriggerType('SIMPLE')"><strong>Simple</strong><span class="help">Repeat every fixed interval.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CRON'" (click)="selectTriggerType('CRON')"><strong>Cron</strong><span class="help">Cron expression schedule.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'DAILY_TIME_INTERVAL'" (click)="selectTriggerType('DAILY_TIME_INTERVAL')"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CALENDAR_INTERVAL'" (click)="selectTriggerType('CALENDAR_INTERVAL')"><strong>Calendar Interval</strong><span class="help">Every N calendar units.</span></button></div></div></section>
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div>@if (triggerDraft.triggerType === 'CRON') { <div class="control"><label>Cron expression</label><input class="input mono" name="cronExpression" [(ngModel)]="triggerDraft.cronExpression" required><div class="help">Quartz cron format, for example 0 0/5 * * * ?</div></div><div class="control"><label>Timezone</label><input class="input mono" name="cronTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> } @else { <div class="control"><label>Interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds" [disabled]="triggerDraft.triggerType !== 'SIMPLE'">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option><option value="weeks" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">weeks</option><option value="months" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">months</option><option value="years" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">years</option></select></div></div> } @if (triggerDraft.triggerType === 'SIMPLE') { <div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div> } @if (triggerDraft.triggerType === 'DAILY_TIME_INTERVAL') { <div class="control"><label>Daily window</label><div class="input-row"><input class="input mono" name="startTimeOfDay" [(ngModel)]="triggerDraft.startTimeOfDay"><input class="input mono" name="endTimeOfDay" [(ngModel)]="triggerDraft.endTimeOfDay"></div></div><div class="control"><label>Days of week</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="isDayOfWeekSelected(day)" (click)="toggleDayOfWeek(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (triggerDraft.triggerType === 'CALENDAR_INTERVAL') { <label class="check-row"><input type="checkbox" name="preserveHour" [(ngModel)]="triggerDraft.preserveHourOfDayAcrossDaylightSavings"> Preserve hour across daylight saving</label><label class="check-row"><input type="checkbox" name="skipMissingHour" [(ngModel)]="triggerDraft.skipDayIfHourDoesNotExist"> Skip day if hour does not exist</label><div class="control"><label>Timezone</label><input class="input mono" name="calendarIntervalTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> }</div></section>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Calendar</label><select class="select" name="calendarName" [(ngModel)]="triggerDraft.calendarName"><option value="">No calendar</option>@for (calendarName of getCalendarOptions(); track calendarName) { <option [value]="calendarName">{{ calendarName }}</option> }</select></div><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required>@if (triggerDraft.triggerType === 'SIMPLE') { <option value="MISFIRE_INSTRUCTION_FIRE_NOW">FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option> } @else { <option value="FIRE_AND_PROCEED">FIRE_AND_PROCEED</option><option value="DO_NOTHING">DO_NOTHING</option><option value="IGNORE_MISFIRES">IGNORE_MISFIRES</option> }</select></div><div class="control"><label>JobDataMap override</label><div class="data-map-editor">@for (entry of triggerDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="triggerDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="triggerDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="triggerDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(triggerDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(triggerDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getTriggerDraftDataMapPreview() }}</pre></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> as a <strong>{{ triggerDraft.triggerType }}</strong> trigger, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="resetWizard()">Reset</button><button type="submit" class="btn primary" [disabled]="wizardSubmitting || !canSubmitTrigger()">{{ wizardSubmitting ? 'Saving...' : getWizardCta() }}</button></div>
</form>
@@ -369,4 +367,16 @@
<div class="wizard-footer"><button type="button" class="btn" (click)="closeJobWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="jobWizardSubmitting || !canSubmitJob()">{{ jobWizardSubmitting ? 'Saving...' : jobWizardMode === 'edit' ? 'Save Job' : 'Create Job' }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="calendarWizardOpen" aria-label="Calendar editor">
<div class="wizard-header"><div><h2>{{ calendarWizardMode === 'edit' ? 'Edit Calendar' : 'New Calendar' }}</h2><div class="caption">Quartz calendars exclude times from trigger schedules.</div></div><button type="button" class="drawer-close" (click)="closeCalendarWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitCalendarWizard()">
<div class="wizard-scroll">
@if (calendarWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ calendarWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Calendar name</label><input class="input mono" name="calendarNameInput" [(ngModel)]="calendarDraft.name" [readonly]="calendarWizardMode === 'edit'" required></div><div class="control"><label>Type</label><select class="select" name="calendarType" [(ngModel)]="calendarDraft.type"><option value="WEEKLY">Weekly</option><option value="MONTHLY">Monthly</option><option value="ANNUAL">Annual</option><option value="HOLIDAY">Holiday</option><option value="DAILY">Daily</option><option value="CRON">Cron</option></select></div><div class="control"><label>Description</label><input class="input" name="calendarDescription" [(ngModel)]="calendarDraft.description"></div></div></section>
<section class="form-card"><h3>Rules</h3><div class="form-section">@if (getCalendarRuleMode() === 'weekdays') { <div class="control"><label>Excluded days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfWeek.includes(day)" (click)="toggleCalendarWeekday(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (getCalendarRuleMode() === 'monthdays') { <div class="control"><label>Excluded month days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfMonth.includes(day)" (click)="toggleCalendarMonthday(day)">{{ day }}</button> }</div></div> } @if (getCalendarRuleMode() === 'dates') { <div class="control"><label>Excluded dates</label><div class="data-map-editor">@for (date of calendarDraft.excludedDates; track $index) { <div class="data-map-row"><input class="input mono" type="datetime-local" name="calendarDate{{$index}}" [(ngModel)]="calendarDraft.excludedDates[$index]"><button type="button" class="btn danger compact" (click)="removeCalendarDate($index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addCalendarDate()">Add Date</button></div> } @if (getCalendarRuleMode() === 'timeRange') { <div class="control"><label>Excluded time range</label><div class="input-row"><input class="input mono" name="rangeStartingTime" [(ngModel)]="calendarDraft.rangeStartingTime"><input class="input mono" name="rangeEndingTime" [(ngModel)]="calendarDraft.rangeEndingTime"></div></div><label class="check-row"><input type="checkbox" name="invertTimeRange" [(ngModel)]="calendarDraft.invertTimeRange"> Invert time range</label> } @if (getCalendarRuleMode() === 'cron') { <div class="control"><label>Cron exclusion expression</label><input class="input mono" name="calendarCron" [(ngModel)]="calendarDraft.cronExpression" required></div><div class="control"><label>Timezone</label><input class="input mono" name="calendarTimeZone" [(ngModel)]="calendarDraft.timeZone"></div> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeCalendarWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="calendarWizardSubmitting || !canSubmitCalendar()">{{ calendarWizardSubmitting ? 'Saving...' : calendarWizardMode === 'edit' ? 'Save Calendar' : 'Create Calendar' }}</button></div>
</form>
</aside>
</div>

View File

@@ -2,13 +2,14 @@ import {Component, NgZone, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from 'rxjs';
import {map} from 'rxjs/operators';
import {SchedulerService, TriggerService} from '../../services';
import {CalendarService, SchedulerService, TriggerService} from '../../services';
import JobService from '../../services/job.service';
import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service';
import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service';
import {Scheduler} from '../../model/scheduler.model';
import {SimpleTriggerCommand} from '../../model/simple-trigger.command';
import {SimpleTrigger} from '../../model/simple-trigger.model';
import {Trigger} from '../../model/trigger.model';
import {TriggerCommand, TriggerType} from '../../model/trigger-command.model';
import {CalendarType, QuartzCalendar} from '../../model/calendar.model';
import {ScheduledJob} from '../../model/scheduled-job.model';
import {ScheduledJobCommand} from '../../model/scheduled-job.command';
import {TriggerKey} from '../../model/triggerKey.model';
@@ -18,6 +19,7 @@ type ConsolePage = 'dashboard' | 'jobs' | 'triggers' | 'calendars' | 'executions
type WizardMode = 'create' | 'edit';
type JobTargetType = 'stored' | 'class';
type JobDataMapType = 'string' | 'number' | 'boolean' | 'json' | 'null';
type CalendarRuleMode = 'dates' | 'weekdays' | 'monthdays' | 'timeRange' | 'cron';
interface ConsoleLogRecord {
time: Date;
@@ -30,6 +32,7 @@ interface ConsoleLogRecord {
interface TriggerDraft {
triggerName: string;
group: string;
triggerType: TriggerType;
jobTargetType: JobTargetType;
storedJobKey: string;
jobClass: string;
@@ -39,9 +42,32 @@ interface TriggerDraft {
repeatIntervalUnit: string;
repeatCount: number;
misfireInstruction: string;
cronExpression: string;
timeZone: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
calendarName: string;
jobDataMapEntries: JobDataMapEntry[];
}
interface CalendarDraft {
name: string;
type: CalendarType;
description: string;
cronExpression: string;
timeZone: string;
rangeStartingTime: string;
rangeEndingTime: string;
invertTimeRange: boolean;
excludedDaysOfWeek: number[];
excludedDaysOfMonth: number[];
excludedDates: string[];
includedTime: string;
}
interface JobDraft {
name: string;
group: string;
@@ -73,9 +99,9 @@ export class ManagerComponent implements OnInit, OnDestroy {
scheduler: Scheduler;
schedulerLoading = false;
triggerKeys: TriggerKey[] = [];
triggerDetailsByName: {[triggerName: string]: SimpleTrigger} = {};
triggerDetailsByName: {[triggerName: string]: Trigger} = {};
selectedTriggerKey: TriggerKey;
selectedTrigger: SimpleTrigger;
selectedTrigger: Trigger;
selectedJobClass: string;
selectedScheduledJob: ScheduledJob;
jobs: string[] = [];
@@ -95,14 +121,23 @@ export class ManagerComponent implements OnInit, OnDestroy {
wizardError: string;
jobWizardError: string;
triggerDraft: TriggerDraft = this.buildEmptyDraft();
calendarDraft: CalendarDraft = this.buildEmptyCalendarDraft();
jobDraft: JobDraft = this.buildEmptyJobDraft();
jobWizardMode: WizardMode = 'create';
jobGroupFilter = 'ALL';
triggerGroupFilter = 'ALL';
jobSearch = '';
triggerSearch = '';
calendarSearch = '';
calendars: QuartzCalendar[] = [];
selectedCalendar: QuartzCalendar;
calendarWizardOpen = false;
calendarWizardMode: WizardMode = 'create';
calendarWizardSubmitting = false;
calendarWizardError: string;
calendarIncludedTimeResult: string;
private readonly roadmapPages = new Set<ConsolePage>(['calendars', 'executions']);
private readonly roadmapPages = new Set<ConsolePage>(['executions']);
private readonly subscriptions: Subscription[] = [];
private logsSubscription: Subscription;
private progressSubscription: Subscription;
@@ -111,6 +146,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
constructor(
private schedulerService: SchedulerService,
private triggerService: TriggerService,
private calendarService: CalendarService,
private jobService: JobService,
private logsRxWebsocketService: LogsRxWebsocketService,
private progressRxWebsocketService: ProgressRxWebsocketService,
@@ -122,6 +158,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.fetchTriggers();
this.fetchJobs();
this.fetchScheduledJobs();
this.fetchCalendars();
}
ngOnDestroy() {
@@ -189,10 +226,15 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.jobWizardOpen = false;
}
closeCalendarWizardDrawer() {
this.calendarWizardOpen = false;
}
closeDrawers() {
this.detailDrawerOpen = false;
this.wizardOpen = false;
this.jobWizardOpen = false;
this.calendarWizardOpen = false;
}
refreshScheduler() {
@@ -294,10 +336,21 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.subscriptions.push(subscription);
}
fetchCalendars() {
const subscription = this.calendarService.fetchCalendars().subscribe({
next: calendars => {
this.calendars = calendars || [];
this.selectedCalendar = this.selectedCalendar || this.calendars[0];
},
error: () => this.operationError = 'Unable to load calendars.'
});
this.subscriptions.push(subscription);
}
fetchTriggerDetails(triggerKeys: TriggerKey[]) {
triggerKeys.forEach(triggerKey => {
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({
next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger,
const subscription = this.triggerService.getTrigger(triggerKey).subscribe({
next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger,
error: () => {
this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = null;
}
@@ -317,15 +370,15 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.triggerLoading = true;
this.selectedTrigger = this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] || null;
this.subscribeToTriggerTopics(this.selectedTriggerKey);
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({
const subscription = this.triggerService.getTrigger(triggerKey).subscribe({
next: trigger => {
this.selectedTrigger = trigger as SimpleTrigger;
this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger;
this.selectedTrigger = trigger;
this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger;
this.triggerLoading = false;
},
error: () => {
this.triggerLoading = false;
this.showRoadmapNotice('Only SimpleTrigger details are supported by the current backend');
this.operationError = 'Unable to load trigger details.';
}
});
this.subscriptions.push(subscription);
@@ -452,6 +505,95 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.subscriptions.push(subscription);
}
selectCalendar(calendar: QuartzCalendar) {
this.selectedCalendar = calendar;
this.openDetailDrawer();
}
openCreateCalendarWizard() {
this.calendarWizardMode = 'create';
this.calendarWizardError = null;
this.calendarDraft = this.buildEmptyCalendarDraft();
this.calendarWizardOpen = true;
this.detailDrawerOpen = false;
this.selectPage('calendars');
this.calendarWizardOpen = true;
}
openEditCalendarWizard() {
if (!this.selectedCalendar) {
return;
}
this.calendarWizardMode = 'edit';
this.calendarWizardError = null;
this.calendarDraft = this.fromCalendarToDraft(this.selectedCalendar);
this.calendarWizardOpen = true;
this.detailDrawerOpen = false;
this.selectPage('calendars');
this.calendarWizardOpen = true;
}
submitCalendarWizard() {
this.calendarWizardError = null;
if (!this.canSubmitCalendar()) {
this.calendarWizardError = 'Calendar name, type, and rule fields are required.';
return;
}
const calendar = this.fromCalendarDraftToCommand();
const name = this.calendarDraft.name.trim();
this.calendarWizardSubmitting = true;
const request = this.calendarWizardMode === 'edit'
? this.calendarService.updateCalendar(name, calendar)
: this.calendarService.createCalendar(name, calendar);
const subscription = request.subscribe({
next: savedCalendar => {
this.calendarWizardSubmitting = false;
this.upsertCalendar(savedCalendar);
this.selectedCalendar = savedCalendar;
this.calendarWizardOpen = false;
this.detailDrawerOpen = true;
this.operationNotice = this.calendarWizardMode === 'edit' ? 'Calendar updated.' : 'Calendar created.';
},
error: () => {
this.calendarWizardSubmitting = false;
this.calendarWizardError = 'Unable to save the calendar.';
}
});
this.subscriptions.push(subscription);
}
deleteSelectedCalendar() {
if (!this.selectedCalendar || !window.confirm(`Delete calendar ${this.selectedCalendar.name}?`)) {
return;
}
const calendarName = this.selectedCalendar.name;
const subscription = this.calendarService.deleteCalendar(calendarName).subscribe({
next: () => {
this.calendars = this.calendars.filter(calendar => calendar.name !== calendarName);
this.selectedCalendar = this.calendars[0];
this.operationNotice = 'Calendar deleted.';
},
error: () => this.operationError = 'Unable to delete the selected calendar.'
});
this.subscriptions.push(subscription);
}
testSelectedCalendarTime() {
if (!this.selectedCalendar) {
return;
}
const testTime = this.fromDatetimeLocalValue(this.calendarDraft.includedTime) || new Date();
const subscription = this.calendarService.testIncludedTime(this.selectedCalendar.name, testTime).subscribe({
next: result => this.calendarIncludedTimeResult = result.included
? 'Included at the tested time.'
: `Excluded. Next included time: ${this.formatDateTime(result.nextIncludedTime) || '-'}`,
error: () => this.operationError = 'Unable to test the selected calendar.'
});
this.subscriptions.push(subscription);
}
pauseSelectedTrigger() {
if (!this.selectedTriggerKey) {
return;
@@ -501,7 +643,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.resetWizard();
this.wizardOpen = true;
this.detailDrawerOpen = false;
this.selectPage('dashboard');
this.selectPage('triggers');
this.wizardOpen = true;
}
@@ -510,7 +652,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.selectTrigger(triggerKey, false);
}
if (!this.selectedTrigger && !this.selectedTriggerKey) {
this.showRoadmapNotice('Reschedule requires a SimpleTrigger loaded from the backend');
this.showRoadmapNotice('Reschedule requires a trigger loaded from the backend');
return;
}
@@ -522,18 +664,29 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.triggerDraft = {
triggerName: this.selectedTriggerKey.name,
group: this.selectedTriggerKey.group || 'DEFAULT',
triggerType: this.getTriggerTypeValue(trigger),
jobTargetType: trigger?.jobKeyDTO ? 'stored' : 'class',
storedJobKey: trigger?.jobKeyDTO ? this.getJobOptionValue(trigger.jobKeyDTO.group, trigger.jobKeyDTO.name) : this.getDefaultStoredJobKey(),
storedJobKey: trigger?.jobKeyDTO
? this.getJobOptionValue(trigger.jobKeyDTO.group, trigger.jobKeyDTO.name)
: this.getDefaultStoredJobKey(),
jobClass: trigger?.jobDetailDTO?.jobClassName || this.jobs[0] || '',
startDate: this.toDatetimeLocalValue(trigger?.startTime),
endDate: this.toDatetimeLocalValue(trigger?.endTime),
repeatIntervalAmount: repeatInterval.amount,
repeatIntervalUnit: repeatInterval.unit,
repeatCount: trigger?.repeatCount ?? -1,
misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction),
misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction, this.getTriggerTypeValue(trigger)),
cronExpression: trigger?.cronExpression || '0 0/5 * * * ?',
timeZone: trigger?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
startTimeOfDay: trigger?.startTimeOfDay || '08:00:00',
endTimeOfDay: trigger?.endTimeOfDay || '18:00:00',
daysOfWeek: trigger?.daysOfWeek || [2, 3, 4, 5, 6],
preserveHourOfDayAcrossDaylightSavings: !!trigger?.preserveHourOfDayAcrossDaylightSavings,
skipDayIfHourDoesNotExist: !!trigger?.skipDayIfHourDoesNotExist,
calendarName: trigger?.calendarName || '',
jobDataMapEntries: this.toJobDataMapEntries(trigger?.jobDataMap)
};
this.selectPage('dashboard');
this.selectPage('triggers');
this.wizardOpen = true;
}
@@ -546,20 +699,32 @@ export class ManagerComponent implements OnInit, OnDestroy {
submitTriggerWizard() {
this.wizardError = null;
if (!this.canSubmitTrigger()) {
this.wizardError = 'Trigger name, job class, misfire policy, and both repeat fields are required for the current backend.';
this.wizardError = 'Trigger name, target job, type, and schedule fields are required.';
return;
}
const command = new SimpleTriggerCommand();
command.triggerName = this.triggerDraft.triggerName.trim();
command.triggerGroup = this.triggerDraft.group || 'DEFAULT';
const command = new TriggerCommand();
command.triggerType = this.triggerDraft.triggerType;
command.jobClass = this.triggerDraft.jobTargetType === 'class' ? this.triggerDraft.jobClass : null;
command.jobKey = this.triggerDraft.jobTargetType === 'stored' ? this.parseJobOptionValue(this.triggerDraft.storedJobKey) : null;
command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate);
command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate);
command.repeatInterval = this.getRepeatIntervalMs();
command.repeatCount = this.triggerDraft.repeatCount;
command.repeatInterval = this.getTriggerCommandRepeatInterval();
command.repeatCount = this.triggerDraft.triggerType === 'SIMPLE' ? this.triggerDraft.repeatCount : null;
command.repeatIntervalUnit = this.getTriggerCommandRepeatIntervalUnit();
command.misfireInstruction = this.triggerDraft.misfireInstruction;
command.cronExpression = this.triggerDraft.triggerType === 'CRON' ? this.triggerDraft.cronExpression : null;
command.timeZone = this.triggerDraft.timeZone;
command.startTimeOfDay = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.startTimeOfDay : null;
command.endTimeOfDay = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.endTimeOfDay : null;
command.daysOfWeek = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.daysOfWeek : null;
command.preserveHourOfDayAcrossDaylightSavings = this.triggerDraft.triggerType === 'CALENDAR_INTERVAL'
? this.triggerDraft.preserveHourOfDayAcrossDaylightSavings
: null;
command.skipDayIfHourDoesNotExist = this.triggerDraft.triggerType === 'CALENDAR_INTERVAL'
? this.triggerDraft.skipDayIfHourDoesNotExist
: null;
command.calendarName = this.triggerDraft.calendarName || null;
try {
command.jobDataMap = this.serializeJobDataMap(this.triggerDraft.jobDataMapEntries);
} catch (err) {
@@ -568,26 +733,28 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
this.wizardSubmitting = true;
const group = this.triggerDraft.group || 'DEFAULT';
const name = this.triggerDraft.triggerName.trim();
const request = this.wizardMode === 'edit'
? this.schedulerService.updateSimpleTriggerConfig(command)
: this.schedulerService.saveSimpleTriggerConfig(command);
? this.triggerService.updateTrigger(group, name, command)
: this.triggerService.saveTrigger(group, name, command);
const subscription = request.subscribe({
next: trigger => {
this.wizardSubmitting = false;
this.triggerDetailsByName[this.getTriggerDetailKey(trigger.triggerKeyDTO)] = trigger as SimpleTrigger;
this.triggerDetailsByName[this.getTriggerDetailKey(trigger.triggerKeyDTO)] = trigger;
this.upsertTriggerKey(trigger.triggerKeyDTO);
this.selectTrigger(trigger.triggerKeyDTO);
this.wizardOpen = false;
this.detailDrawerOpen = true;
this.operationNotice = this.wizardMode === 'edit' ? 'SimpleTrigger rescheduled.' : 'SimpleTrigger created.';
this.operationNotice = this.wizardMode === 'edit' ? 'Trigger rescheduled.' : 'Trigger created.';
if (this.wizardMode === 'create') {
this.resetWizard();
}
},
error: () => {
this.wizardSubmitting = false;
this.wizardError = 'Unable to save the SimpleTrigger with the current backend.';
this.wizardError = 'Unable to save the trigger.';
}
});
this.subscriptions.push(subscription);
@@ -602,7 +769,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
}
getTriggerDetail(triggerKey: TriggerKey): SimpleTrigger {
getTriggerDetail(triggerKey: TriggerKey): Trigger {
return triggerKey?.name ? this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] : null;
}
@@ -611,7 +778,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
getTriggerType(triggerKey: TriggerKey): string {
return this.getTriggerDetail(triggerKey)?.type || 'SimpleTrigger';
return this.getTriggerDetail(triggerKey)?.type || 'Trigger';
}
getTriggerState(triggerKey: TriggerKey): string {
@@ -779,32 +946,83 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
getWizardTitle(): string {
return this.wizardMode === 'edit' ? 'Reschedule SimpleTrigger' : 'Create SimpleTrigger';
return this.wizardMode === 'edit' ? 'Reschedule Trigger' : 'Create Trigger';
}
getWizardCta(): string {
return this.wizardMode === 'edit' ? 'Save Reschedule' : 'Create SimpleTrigger';
return this.wizardMode === 'edit' ? 'Save Reschedule' : 'Create Trigger';
}
canSubmitTrigger(): boolean {
const hasTarget = this.triggerDraft.jobTargetType === 'stored'
? !!this.triggerDraft.storedJobKey
: !!this.triggerDraft.jobClass;
return !!(
this.triggerDraft.triggerName?.trim()
return !!(this.triggerDraft.triggerName?.trim()
&& hasTarget
&& this.triggerDraft.misfireInstruction
&& this.triggerDraft.repeatCount !== null
&& this.triggerDraft.repeatCount !== undefined
&& this.triggerDraft.repeatIntervalAmount
&& this.triggerDraft.repeatIntervalUnit
);
&& this.triggerDraft.triggerType
&& this.hasValidTriggerSchedule());
}
hasValidTriggerSchedule(): boolean {
switch (this.triggerDraft.triggerType) {
case 'CRON': return !!this.triggerDraft.cronExpression?.trim();
case 'DAILY_TIME_INTERVAL': return !!(this.triggerDraft.repeatIntervalAmount
&& this.triggerDraft.repeatIntervalUnit
&& this.triggerDraft.startTimeOfDay
&& this.triggerDraft.endTimeOfDay);
case 'CALENDAR_INTERVAL': return !!(this.triggerDraft.repeatIntervalAmount && this.triggerDraft.repeatIntervalUnit);
default: return this.triggerDraft.repeatCount !== null
&& this.triggerDraft.repeatCount !== undefined
&& !!this.triggerDraft.repeatIntervalAmount;
}
}
canSubmitJob(): boolean {
return !!(this.jobDraft.name?.trim() && this.jobDraft.group?.trim() && this.jobDraft.jobClass);
}
canSubmitCalendar(): boolean {
if (!this.calendarDraft.name?.trim() || !this.calendarDraft.type) {
return false;
}
switch (this.calendarDraft.type) {
case 'CRON': return !!this.calendarDraft.cronExpression?.trim();
case 'DAILY': return !!(this.calendarDraft.rangeStartingTime && this.calendarDraft.rangeEndingTime);
default: return true;
}
}
getCalendarRows(): QuartzCalendar[] {
const search = this.calendarSearch?.trim().toLowerCase();
return (this.calendars || []).filter(calendar => !search || `${calendar.name} ${calendar.type}`.toLowerCase().includes(search));
}
getCalendarRuleMode(): CalendarRuleMode {
switch (this.calendarDraft.type) {
case 'WEEKLY': return 'weekdays';
case 'MONTHLY': return 'monthdays';
case 'DAILY': return 'timeRange';
case 'CRON': return 'cron';
default: return 'dates';
}
}
toggleCalendarWeekday(day: number) {
this.calendarDraft.excludedDaysOfWeek = this.toggleNumberValue(this.calendarDraft.excludedDaysOfWeek, day);
}
toggleCalendarMonthday(day: number) {
this.calendarDraft.excludedDaysOfMonth = this.toggleNumberValue(this.calendarDraft.excludedDaysOfMonth, day);
}
addCalendarDate() {
this.calendarDraft.excludedDates = [...(this.calendarDraft.excludedDates || []), this.toDatetimeLocalValue(new Date())];
}
removeCalendarDate(index: number) {
this.calendarDraft.excludedDates.splice(index, 1);
}
addJobDataMapEntry(entries: JobDataMapEntry[]) {
entries.push({key: '', type: 'string', value: ''});
}
@@ -814,6 +1032,9 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
getFirePreview(): string[] {
if (this.triggerDraft.triggerType === 'CRON') {
return [`Cron expression: ${this.triggerDraft.cronExpression || '-'}`];
}
const start = this.fromDatetimeLocalValue(this.triggerDraft.startDate) || new Date();
const repeatInterval = this.getRepeatIntervalMs();
if (!repeatInterval || repeatInterval <= 0) {
@@ -826,6 +1047,43 @@ export class ManagerComponent implements OnInit, OnDestroy {
});
}
getTriggerTypeValue(trigger: Trigger): TriggerType {
const type = trigger?.type || '';
if (type.includes('Cron')) {
return 'CRON';
}
if (type.includes('DailyTimeInterval')) {
return 'DAILY_TIME_INTERVAL';
}
if (type.includes('CalendarInterval')) {
return 'CALENDAR_INTERVAL';
}
return 'SIMPLE';
}
selectTriggerType(triggerType: TriggerType) {
this.triggerDraft.triggerType = triggerType;
this.triggerDraft.misfireInstruction = this.getDefaultMisfireInstruction(triggerType);
}
toggleDayOfWeek(day: number) {
const days = new Set(this.triggerDraft.daysOfWeek || []);
if (days.has(day)) {
days.delete(day);
} else {
days.add(day);
}
this.triggerDraft.daysOfWeek = Array.from(days).sort((first, second) => first - second);
}
isDayOfWeekSelected(day: number): boolean {
return (this.triggerDraft.daysOfWeek || []).includes(day);
}
getCalendarOptions(): string[] {
return this.calendars.map(calendar => calendar.name);
}
shortClassName(className: string): string {
if (!className) {
return null;
@@ -927,6 +1185,11 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.scheduledJobs = [job, ...otherJobs];
}
private upsertCalendar(calendar: QuartzCalendar) {
const otherCalendars = this.calendars.filter(currentCalendar => currentCalendar.name !== calendar.name);
this.calendars = [calendar, ...otherCalendars];
}
private sameTriggerKey(first: TriggerKey, second: TriggerKey): boolean {
return first?.name === second?.name && this.getTriggerGroup(first) === this.getTriggerGroup(second);
}
@@ -943,6 +1206,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
return {
triggerName: '',
group: 'DEFAULT',
triggerType: 'SIMPLE',
jobTargetType: this.scheduledJobs.length > 0 ? 'stored' : 'class',
storedJobKey: this.getDefaultStoredJobKey(),
jobClass: this.jobs[0] || '',
@@ -952,10 +1216,35 @@ export class ManagerComponent implements OnInit, OnDestroy {
repeatIntervalUnit: 'minutes',
repeatCount: -1,
misfireInstruction: 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT',
cronExpression: '0 0/5 * * * ?',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
startTimeOfDay: '08:00:00',
endTimeOfDay: '18:00:00',
daysOfWeek: [2, 3, 4, 5, 6],
preserveHourOfDayAcrossDaylightSavings: false,
skipDayIfHourDoesNotExist: false,
calendarName: '',
jobDataMapEntries: []
};
}
private buildEmptyCalendarDraft(): CalendarDraft {
return {
name: '',
type: 'WEEKLY',
description: '',
cronExpression: '0 0 0 ? * SAT,SUN',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
rangeStartingTime: '22:00:00',
rangeEndingTime: '06:00:00',
invertTimeRange: false,
excludedDaysOfWeek: [1, 7],
excludedDaysOfMonth: [],
excludedDates: [],
includedTime: this.toDatetimeLocalValue(new Date())
};
}
private buildEmptyJobDraft(): JobDraft {
return {
name: '',
@@ -982,8 +1271,53 @@ export class ManagerComponent implements OnInit, OnDestroy {
return name ? {group: group || 'DEFAULT', name} : null;
}
private fromCalendarToDraft(calendar: QuartzCalendar): CalendarDraft {
return {
name: calendar.name,
type: calendar.type,
description: calendar.description || '',
cronExpression: calendar.cronExpression || '0 0 0 ? * SAT,SUN',
timeZone: calendar.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
rangeStartingTime: calendar.rangeStartingTime || '22:00:00',
rangeEndingTime: calendar.rangeEndingTime || '06:00:00',
invertTimeRange: !!calendar.invertTimeRange,
excludedDaysOfWeek: calendar.excludedDaysOfWeek || [],
excludedDaysOfMonth: calendar.excludedDaysOfMonth || [],
excludedDates: (calendar.excludedDates || []).map(date => this.toDatetimeLocalValue(date)),
includedTime: this.toDatetimeLocalValue(new Date())
};
}
private fromCalendarDraftToCommand(): QuartzCalendar {
const calendar = new QuartzCalendar();
calendar.name = this.calendarDraft.name.trim();
calendar.type = this.calendarDraft.type;
calendar.description = this.calendarDraft.description;
calendar.cronExpression = this.calendarDraft.type === 'CRON' ? this.calendarDraft.cronExpression : null;
calendar.timeZone = this.calendarDraft.timeZone;
calendar.rangeStartingTime = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.rangeStartingTime : null;
calendar.rangeEndingTime = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.rangeEndingTime : null;
calendar.invertTimeRange = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.invertTimeRange : null;
calendar.excludedDaysOfWeek = this.calendarDraft.type === 'WEEKLY' ? this.calendarDraft.excludedDaysOfWeek : null;
calendar.excludedDaysOfMonth = this.calendarDraft.type === 'MONTHLY' ? this.calendarDraft.excludedDaysOfMonth : null;
calendar.excludedDates = ['ANNUAL', 'HOLIDAY'].includes(this.calendarDraft.type)
? (this.calendarDraft.excludedDates || []).map(value => this.fromDatetimeLocalValue(value)).filter(Boolean)
: null;
return calendar;
}
private toggleNumberValue(values: number[], value: number): number[] {
const set = new Set(values || []);
if (set.has(value)) {
set.delete(value);
} else {
set.add(value);
}
return Array.from(set).sort((first, second) => first - second);
}
private getUniqueGroups(groups: string[]): string[] {
return Array.from(new Set((groups || []).filter(Boolean))).sort();
return Array.from(new Set((groups || []).filter(Boolean))).sort((first, second) => first.localeCompare(second));
}
private toJobDataMapEntries(jobDataMap: {[key: string]: unknown}): JobDataMapEntry[] {
@@ -1052,7 +1386,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
try {
return JSON.parse(entry.value || 'null');
} catch (err) {
throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON.`);
throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON: ${this.getErrorMessage(err, 'Invalid JSON')}`);
}
case 'null':
return null;
@@ -1080,6 +1414,30 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
}
private getTriggerCommandRepeatInterval(): number {
if (this.triggerDraft.triggerType === 'SIMPLE') {
return this.getRepeatIntervalMs();
}
return Number(this.triggerDraft.repeatIntervalAmount || 0);
}
private getTriggerCommandRepeatIntervalUnit(): string {
if (this.triggerDraft.triggerType === 'SIMPLE' || this.triggerDraft.triggerType === 'CRON') {
return null;
}
const unit = this.triggerDraft.repeatIntervalUnit || 'minutes';
switch (unit) {
case 'seconds': return 'SECOND';
case 'minutes': return 'MINUTE';
case 'hours': return 'HOUR';
case 'days': return 'DAY';
case 'weeks': return 'WEEK';
case 'months': return 'MONTH';
case 'years': return 'YEAR';
default: return unit.toUpperCase();
}
}
private splitRepeatInterval(milliseconds: number): {amount: number; unit: string} {
if (milliseconds && milliseconds % 86400000 === 0) {
return {amount: milliseconds / 86400000, unit: 'days'};
@@ -1116,7 +1474,15 @@ export class ManagerComponent implements OnInit, OnDestroy {
return offsetDate.toISOString().slice(0, 16);
}
private getMisfireInstructionName(misfireInstruction: number): string {
private getMisfireInstructionName(misfireInstruction: number, triggerType: TriggerType = 'SIMPLE'): string {
if (triggerType !== 'SIMPLE') {
switch (misfireInstruction) {
case 1: return 'IGNORE_MISFIRES';
case 2: return 'FIRE_AND_PROCEED';
case 3: return 'DO_NOTHING';
default: return 'FIRE_AND_PROCEED';
}
}
switch (misfireInstruction) {
case 1: return 'MISFIRE_INSTRUCTION_FIRE_NOW';
case 2: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT';
@@ -1127,6 +1493,12 @@ export class ManagerComponent implements OnInit, OnDestroy {
}
}
private getDefaultMisfireInstruction(triggerType: TriggerType): string {
return triggerType === 'SIMPLE'
? 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT'
: 'FIRE_AND_PROCEED';
}
private getPageTitle(page: ConsolePage): string {
switch (page) {
case 'calendars': return 'Quartz calendars';

View File

@@ -0,0 +1,77 @@
package it.fabioformosa.quartzmanager.api.controllers;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import it.fabioformosa.quartzmanager.api.dto.CalendarDTO;
import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO;
import it.fabioformosa.quartzmanager.api.services.CalendarService;
import jakarta.validation.Valid;
import org.quartz.SchedulerException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.text.ParseException;
import java.util.List;
import static it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA;
import static it.fabioformosa.quartzmanager.api.common.config.QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH;
@RequestMapping(CalendarController.CALENDAR_CONTROLLER_BASE_URL)
@SecurityRequirement(name = QUARTZ_MANAGER_SEC_OAS_SCHEMA)
@RestController
public class CalendarController {
protected static final String CALENDAR_CONTROLLER_BASE_URL = QUARTZ_MANAGER_BASE_CONTEXT_PATH + "/calendars";
private final CalendarService calendarService;
public CalendarController(CalendarService calendarService) {
this.calendarService = calendarService;
}
@GetMapping
@Operation(summary = "Get a list of calendars")
public List<CalendarDTO> listCalendars() throws SchedulerException {
return calendarService.fetchCalendars();
}
@GetMapping("/{name}")
@Operation(summary = "Get calendar details")
public CalendarDTO getCalendar(@PathVariable String name) throws SchedulerException {
return calendarService.getCalendar(name);
}
@PostMapping("/{name}")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create a calendar")
public CalendarDTO postCalendar(@PathVariable String name, @Valid @RequestBody CalendarDTO calendarDTO) throws SchedulerException, ParseException {
return calendarService.addCalendar(name, calendarDTO);
}
@PutMapping("/{name}")
@Operation(summary = "Update a calendar")
public CalendarDTO putCalendar(@PathVariable String name, @Valid @RequestBody CalendarDTO calendarDTO) throws SchedulerException, ParseException {
return calendarService.updateCalendar(name, calendarDTO);
}
@DeleteMapping("/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Delete a calendar")
public void deleteCalendar(@PathVariable String name) throws SchedulerException {
calendarService.deleteCalendar(name);
}
@PostMapping("/{name}/included-time-test")
@Operation(summary = "Test if a time is included by a calendar")
public CalendarIncludedTimeDTO testIncludedTime(@PathVariable String name, @Valid @RequestBody CalendarIncludedTimeDTO input) throws SchedulerException {
return calendarService.testIncludedTime(name, input);
}
}

View File

@@ -8,8 +8,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.services.TriggerService;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.quartz.SchedulerException;
import org.springframework.http.HttpStatus;
@@ -17,10 +19,13 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.text.ParseException;
import java.util.List;
import static it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA;
@@ -63,6 +68,25 @@ public class TriggerController {
return triggerService.getTrigger(group, name);
}
@PostMapping("/{group}/{name}")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Schedule a new trigger")
public TriggerDTO postTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody TriggerInputDTO triggerInputDTO) throws SchedulerException, ClassNotFoundException, ParseException {
log.info("TRIGGER - CREATING a trigger {} {}", name, triggerInputDTO);
TriggerDTO newTriggerDTO = triggerService.scheduleTrigger(group, name, triggerInputDTO);
log.info("TRIGGER - CREATED a trigger {}", newTriggerDTO);
return newTriggerDTO;
}
@PutMapping("/{group}/{name}")
@Operation(summary = "Reschedule a trigger")
public TriggerDTO rescheduleTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody TriggerInputDTO triggerInputDTO) throws SchedulerException, TriggerNotFoundException, ParseException {
log.info("TRIGGER - RESCHEDULING the trigger {} {}", name, triggerInputDTO);
TriggerDTO triggerDTO = triggerService.rescheduleTrigger(group, name, triggerInputDTO);
log.info("TRIGGER - RESCHEDULED the trigger {}", triggerDTO);
return triggerDTO;
}
@PostMapping("/{group}/{name}/pause")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Pause a trigger")

View File

@@ -1,6 +1,7 @@
package it.fabioformosa.quartzmanager.api.controllers.advices;
import it.fabioformosa.quartzmanager.api.exceptions.ExceptionResponse;
import it.fabioformosa.quartzmanager.api.exceptions.CalendarNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
@@ -37,6 +38,13 @@ public class ExceptionHandlingController {
return ExceptionResponse.builder().errorCode(HttpStatus.NOT_FOUND.toString()).errorMessage(ex.getMessage()).build();
}
@ExceptionHandler(CalendarNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseBody
public ExceptionResponse calendarNotFound(CalendarNotFoundException ex){
return ExceptionResponse.builder().errorCode(HttpStatus.NOT_FOUND.toString()).errorMessage(ex.getMessage()).build();
}
@ExceptionHandler(UnsupportedTriggerTypeException.class)
public ResponseEntity<ExceptionResponse> unsupportedTriggerType(UnsupportedTriggerTypeException ex) {
ExceptionResponse response = ExceptionResponse.builder()

View File

@@ -0,0 +1,50 @@
package it.fabioformosa.quartzmanager.api.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class CalendarDTO {
private String name;
@NotNull
private CalendarType type;
private String description;
private String cronExpression;
private String timeZone;
private String rangeStartingTime;
private String rangeEndingTime;
private Boolean invertTimeRange;
private Set<Integer> excludedDaysOfWeek;
private Set<Integer> excludedDaysOfMonth;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private List<Date> excludedDates;
private List<TriggerKeyDTO> triggerKeys;
public List<Date> getExcludedDatesOrEmpty() {
return excludedDates == null ? Collections.emptyList() : excludedDates;
}
public Set<Integer> getExcludedDaysOfWeekOrEmpty() {
return excludedDaysOfWeek == null ? Collections.emptySet() : excludedDaysOfWeek;
}
public Set<Integer> getExcludedDaysOfMonthOrEmpty() {
return excludedDaysOfMonth == null ? Collections.emptySet() : excludedDaysOfMonth;
}
}

View File

@@ -0,0 +1,25 @@
package it.fabioformosa.quartzmanager.api.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class CalendarIncludedTimeDTO {
@NotNull
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private Date time;
private Boolean included;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private Date nextIncludedTime;
}

View File

@@ -0,0 +1,10 @@
package it.fabioformosa.quartzmanager.api.dto;
public enum CalendarType {
ANNUAL,
CRON,
DAILY,
HOLIDAY,
MONTHLY,
WEEKLY
}

View File

@@ -9,8 +9,8 @@ import lombok.experimental.SuperBuilder;
@SuperBuilder
public class SimpleTriggerDTO extends TriggerDTO{
private int repeatCount;
private long repeatInterval;
private int timesTriggered;
private Integer repeatCount;
private Long repeatInterval;
private Integer timesTriggered;
}

View File

@@ -1,6 +1,8 @@
package it.fabioformosa.quartzmanager.api.dto;
import it.fabioformosa.quartzmanager.api.validators.ValidTriggerRepetition;
import it.fabioformosa.quartzmanager.api.validators.JobTargetDTO;
import it.fabioformosa.quartzmanager.api.validators.ValidJobTarget;
import lombok.*;
import lombok.experimental.SuperBuilder;
import jakarta.annotation.Nullable;
@@ -8,13 +10,14 @@ import jakarta.validation.constraints.Positive;
import java.util.Map;
@ValidTriggerRepetition
@ValidJobTarget
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Data
@ToString(callSuper = true)
public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO {
public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO, JobTargetDTO {
private Integer repeatCount;
@Positive

View File

@@ -7,6 +7,7 @@ import lombok.experimental.SuperBuilder;
import org.quartz.JobDataMap;
import java.util.Date;
import java.util.Set;
@AllArgsConstructor
@NoArgsConstructor
@@ -29,4 +30,14 @@ public class TriggerDTO {
private JobDetailDTO jobDetailDTO;
private boolean mayFireAgain;
private JobDataMap jobDataMap;
private String cronExpression;
private String timeZone;
private Long repeatInterval;
private Integer repeatCount;
private String repeatIntervalUnit;
private String startTimeOfDay;
private String endTimeOfDay;
private Set<Integer> daysOfWeek;
private Boolean preserveHourOfDayAcrossDaylightSavings;
private Boolean skipDayIfHourDoesNotExist;
}

View File

@@ -0,0 +1,60 @@
package it.fabioformosa.quartzmanager.api.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import it.fabioformosa.quartzmanager.api.validators.JobTargetDTO;
import it.fabioformosa.quartzmanager.api.validators.ValidJobTarget;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.Map;
import java.util.Set;
@Builder
@ValidJobTarget
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TriggerInputDTO implements JobTargetDTO {
@NotNull
private TriggerType triggerType;
@Nullable
private String jobClass;
@Nullable
private JobKeyDTO jobKey;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private Date startDate;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private Date endDate;
private String description;
private Integer priority;
private String calendarName;
private String misfireInstruction;
@Nullable
private Map<String, ?> jobDataMap;
private Integer repeatCount;
@Positive
private Long repeatInterval;
private String repeatIntervalUnit;
private String cronExpression;
private String timeZone;
private String startTimeOfDay;
private String endTimeOfDay;
private Set<Integer> daysOfWeek;
private Boolean preserveHourOfDayAcrossDaylightSavings;
private Boolean skipDayIfHourDoesNotExist;
}

View File

@@ -0,0 +1,8 @@
package it.fabioformosa.quartzmanager.api.dto;
public enum TriggerType {
SIMPLE,
CRON,
DAILY_TIME_INTERVAL,
CALENDAR_INTERVAL
}

View File

@@ -0,0 +1,7 @@
package it.fabioformosa.quartzmanager.api.exceptions;
public class CalendarNotFoundException extends RuntimeException {
public CalendarNotFoundException(String name) {
super("Calendar " + name + " not found!");
}
}

View File

@@ -0,0 +1,233 @@
package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.dto.CalendarDTO;
import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO;
import it.fabioformosa.quartzmanager.api.dto.CalendarType;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.exceptions.CalendarNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException;
import org.quartz.Calendar;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerKey;
import org.quartz.impl.calendar.AnnualCalendar;
import org.quartz.impl.calendar.CronCalendar;
import org.quartz.impl.calendar.DailyCalendar;
import org.quartz.impl.calendar.HolidayCalendar;
import org.quartz.impl.calendar.MonthlyCalendar;
import org.quartz.impl.calendar.WeeklyCalendar;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeSet;
@Service
public class CalendarService {
private final Scheduler scheduler;
public CalendarService(@Qualifier("quartzManagerScheduler") Scheduler scheduler) {
this.scheduler = scheduler;
}
public List<CalendarDTO> fetchCalendars() throws SchedulerException {
return scheduler.getCalendarNames().stream()
.map(this::getCalendarUnchecked)
.toList();
}
public CalendarDTO getCalendar(String name) throws SchedulerException {
Calendar calendar = scheduler.getCalendar(name);
if (calendar == null)
throw new CalendarNotFoundException(name);
return toDTO(name, calendar);
}
public CalendarDTO addCalendar(String name, CalendarDTO calendarDTO) throws SchedulerException, ParseException {
if (scheduler.getCalendar(name) != null)
throw new ResourceConflictException("Calendar " + name + " already exists");
Calendar calendar = buildCalendar(calendarDTO);
scheduler.addCalendar(name, calendar, false, false);
return toDTO(name, calendar);
}
public CalendarDTO updateCalendar(String name, CalendarDTO calendarDTO) throws SchedulerException, ParseException {
if (scheduler.getCalendar(name) == null)
throw new CalendarNotFoundException(name);
Calendar calendar = buildCalendar(calendarDTO);
scheduler.addCalendar(name, calendar, true, true);
return toDTO(name, calendar);
}
public void deleteCalendar(String name) throws SchedulerException {
if (!scheduler.deleteCalendar(name))
throw new CalendarNotFoundException(name);
}
public CalendarIncludedTimeDTO testIncludedTime(String name, CalendarIncludedTimeDTO input) throws SchedulerException {
Calendar calendar = scheduler.getCalendar(name);
if (calendar == null)
throw new CalendarNotFoundException(name);
long timestamp = input.getTime().getTime();
return CalendarIncludedTimeDTO.builder()
.time(input.getTime())
.included(calendar.isTimeIncluded(timestamp))
.nextIncludedTime(new Date(calendar.getNextIncludedTime(timestamp)))
.build();
}
private CalendarDTO getCalendarUnchecked(String name) {
try {
return getCalendar(name);
} catch (SchedulerException ex) {
throw new IllegalStateException(ex);
}
}
private Calendar buildCalendar(CalendarDTO calendarDTO) throws ParseException {
Calendar calendar = switch (calendarDTO.getType()) {
case ANNUAL -> buildAnnualCalendar(calendarDTO);
case CRON -> buildCronCalendar(calendarDTO);
case DAILY -> buildDailyCalendar(calendarDTO);
case HOLIDAY -> buildHolidayCalendar(calendarDTO);
case MONTHLY -> buildMonthlyCalendar(calendarDTO);
case WEEKLY -> buildWeeklyCalendar(calendarDTO);
};
calendar.setDescription(calendarDTO.getDescription());
return calendar;
}
private AnnualCalendar buildAnnualCalendar(CalendarDTO calendarDTO) {
AnnualCalendar calendar = new AnnualCalendar();
for (Date excludedDate : calendarDTO.getExcludedDatesOrEmpty()) {
java.util.Calendar excludedDay = java.util.Calendar.getInstance();
excludedDay.setTime(excludedDate);
calendar.setDayExcluded(excludedDay, true);
}
return calendar;
}
private CronCalendar buildCronCalendar(CalendarDTO calendarDTO) throws ParseException {
CronCalendar calendar = new CronCalendar(calendarDTO.getCronExpression());
if (calendarDTO.getTimeZone() != null && !calendarDTO.getTimeZone().isBlank())
calendar.setTimeZone(TimeZone.getTimeZone(calendarDTO.getTimeZone()));
return calendar;
}
private DailyCalendar buildDailyCalendar(CalendarDTO calendarDTO) {
DailyCalendar calendar = new DailyCalendar(calendarDTO.getRangeStartingTime(), calendarDTO.getRangeEndingTime());
calendar.setInvertTimeRange(Boolean.TRUE.equals(calendarDTO.getInvertTimeRange()));
return calendar;
}
private HolidayCalendar buildHolidayCalendar(CalendarDTO calendarDTO) {
HolidayCalendar calendar = new HolidayCalendar();
for (Date excludedDate : calendarDTO.getExcludedDatesOrEmpty())
calendar.addExcludedDate(excludedDate);
return calendar;
}
private MonthlyCalendar buildMonthlyCalendar(CalendarDTO calendarDTO) {
MonthlyCalendar calendar = new MonthlyCalendar();
for (Integer day : calendarDTO.getExcludedDaysOfMonthOrEmpty())
calendar.setDayExcluded(day, true);
return calendar;
}
private WeeklyCalendar buildWeeklyCalendar(CalendarDTO calendarDTO) {
WeeklyCalendar calendar = new WeeklyCalendar();
for (Integer day : calendarDTO.getExcludedDaysOfWeekOrEmpty())
calendar.setDayExcluded(day, true);
return calendar;
}
private CalendarDTO toDTO(String name, Calendar calendar) throws SchedulerException {
CalendarDTO calendarDTO = CalendarDTO.builder()
.name(name)
.description(calendar.getDescription())
.triggerKeys(findTriggerKeys(name))
.build();
if (calendar instanceof AnnualCalendar annualCalendar)
enrichAnnualCalendar(calendarDTO, annualCalendar);
else if (calendar instanceof CronCalendar cronCalendar)
enrichCronCalendar(calendarDTO, cronCalendar);
else if (calendar instanceof DailyCalendar dailyCalendar)
enrichDailyCalendar(calendarDTO, dailyCalendar);
else if (calendar instanceof HolidayCalendar holidayCalendar)
enrichHolidayCalendar(calendarDTO, holidayCalendar);
else if (calendar instanceof MonthlyCalendar monthlyCalendar)
enrichMonthlyCalendar(calendarDTO, monthlyCalendar);
else if (calendar instanceof WeeklyCalendar weeklyCalendar)
enrichWeeklyCalendar(calendarDTO, weeklyCalendar);
return calendarDTO;
}
private void enrichAnnualCalendar(CalendarDTO calendarDTO, AnnualCalendar calendar) {
calendarDTO.setType(CalendarType.ANNUAL);
calendarDTO.setExcludedDates(calendar.getDaysExcluded().stream().map(java.util.Calendar::getTime).toList());
}
private void enrichCronCalendar(CalendarDTO calendarDTO, CronCalendar calendar) {
calendarDTO.setType(CalendarType.CRON);
calendarDTO.setCronExpression(calendar.getCronExpression().getCronExpression());
calendarDTO.setTimeZone(calendar.getTimeZone().getID());
}
private void enrichDailyCalendar(CalendarDTO calendarDTO, DailyCalendar calendar) {
calendarDTO.setType(CalendarType.DAILY);
long now = System.currentTimeMillis();
calendarDTO.setRangeStartingTime(formatTime(calendar.getTimeRangeStartingTimeInMillis(now)));
calendarDTO.setRangeEndingTime(formatTime(calendar.getTimeRangeEndingTimeInMillis(now)));
calendarDTO.setInvertTimeRange(calendar.getInvertTimeRange());
}
private void enrichHolidayCalendar(CalendarDTO calendarDTO, HolidayCalendar calendar) {
calendarDTO.setType(CalendarType.HOLIDAY);
calendarDTO.setExcludedDates(new ArrayList<>(calendar.getExcludedDates()));
}
private void enrichMonthlyCalendar(CalendarDTO calendarDTO, MonthlyCalendar calendar) {
calendarDTO.setType(CalendarType.MONTHLY);
Set<Integer> excludedDays = new TreeSet<>();
for (int day = 1; day <= 31; day++) {
if (calendar.isDayExcluded(day))
excludedDays.add(day);
}
calendarDTO.setExcludedDaysOfMonth(excludedDays);
}
private void enrichWeeklyCalendar(CalendarDTO calendarDTO, WeeklyCalendar calendar) {
calendarDTO.setType(CalendarType.WEEKLY);
Set<Integer> excludedDays = new TreeSet<>();
for (int day = java.util.Calendar.SUNDAY; day <= java.util.Calendar.SATURDAY; day++) {
if (calendar.isDayExcluded(day))
excludedDays.add(day);
}
calendarDTO.setExcludedDaysOfWeek(excludedDays);
}
private List<TriggerKeyDTO> findTriggerKeys(String calendarName) throws SchedulerException {
List<TriggerKeyDTO> triggerKeys = new ArrayList<>();
for (TriggerKey triggerKey : scheduler.getTriggerKeys(GroupMatcher.anyTriggerGroup())) {
Trigger trigger = scheduler.getTrigger(triggerKey);
if (trigger != null && calendarName.equals(trigger.getCalendarName()))
triggerKeys.add(TriggerKeyDTO.builder().name(triggerKey.getName()).group(triggerKey.getGroup()).build());
}
return triggerKeys;
}
private String formatTime(long timeInMillis) {
java.util.Calendar calendar = java.util.Calendar.getInstance();
calendar.setTimeInMillis(timeInMillis);
return String.format("%02d:%02d:%02d", calendar.get(java.util.Calendar.HOUR_OF_DAY), calendar.get(java.util.Calendar.MINUTE), calendar.get(java.util.Calendar.SECOND));
}
}

View File

@@ -1,25 +1,51 @@
package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO;
import it.fabioformosa.quartzmanager.api.dto.MisfireInstruction;
import it.fabioformosa.quartzmanager.api.dto.TriggerDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerType;
import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.DailyTimeIntervalScheduleBuilder;
import org.quartz.DailyTimeIntervalTrigger;
import org.quartz.DateBuilder;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.ScheduleBuilder;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SimpleTrigger;
import org.quartz.TimeOfDay;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
@Service
public class TriggerService {
private Scheduler scheduler;
private ConversionService conversionService;
private static final int DEFAULT_PRIORITY = Trigger.DEFAULT_PRIORITY;
private final Scheduler scheduler;
private final ConversionService conversionService;
public TriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService) {
this.scheduler = scheduler;
@@ -38,11 +64,47 @@ public class TriggerService {
Trigger trigger = scheduler.getTrigger(triggerKey);
if (trigger == null)
throw new TriggerNotFoundException(group, name);
TriggerDTO triggerDTO = conversionService.convert(trigger, TriggerDTO.class);
TriggerDTO triggerDTO = convertTrigger(trigger);
triggerDTO.setState(scheduler.getTriggerState(triggerKey).name());
return triggerDTO;
}
public TriggerDTO scheduleTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws SchedulerException, ClassNotFoundException, ParseException {
TriggerKey triggerKey = TriggerKey.triggerKey(name, group);
if (scheduler.checkExists(triggerKey))
throw new ResourceConflictException("Trigger " + triggerKey + " already exists");
Trigger newTrigger = buildTrigger(group, name, triggerInputDTO);
JobKey jobKey = getJobKey(triggerInputDTO);
if (jobKey != null) {
if (!scheduler.checkExists(jobKey))
throw new ResourceConflictException("Job " + jobKey + " does not exist");
scheduler.scheduleJob(newTrigger.getTriggerBuilder().forJob(jobKey).build());
}
else {
JobDetail jobDetail = JobBuilder.newJob()
.ofType(Class.forName(triggerInputDTO.getJobClass()).asSubclass(Job.class))
.storeDurably(false)
.build();
scheduler.scheduleJob(jobDetail, newTrigger);
}
return convertTrigger(newTrigger);
}
public TriggerDTO rescheduleTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws SchedulerException, TriggerNotFoundException, ParseException {
TriggerKey triggerKey = TriggerKey.triggerKey(name, group);
Trigger existingTrigger = scheduler.getTrigger(triggerKey);
if (existingTrigger == null)
throw new TriggerNotFoundException(group, name);
Trigger newTrigger = buildTrigger(group, name, triggerInputDTO).getTriggerBuilder()
.forJob(existingTrigger.getJobKey())
.build();
scheduler.rescheduleJob(triggerKey, newTrigger);
return convertTrigger(newTrigger);
}
public void pauseTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException {
TriggerKey triggerKey = requireTrigger(group, name);
scheduler.pauseTrigger(triggerKey);
@@ -65,4 +127,183 @@ public class TriggerService {
return triggerKey;
}
private Trigger buildTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws ParseException {
TriggerBuilder<Trigger> triggerBuilder = TriggerBuilder.newTrigger()
.withIdentity(name, group)
.withPriority(triggerInputDTO.getPriority() == null ? DEFAULT_PRIORITY : triggerInputDTO.getPriority());
if (triggerInputDTO.getStartDate() != null)
triggerBuilder.startAt(triggerInputDTO.getStartDate());
if (triggerInputDTO.getEndDate() != null)
triggerBuilder.endAt(triggerInputDTO.getEndDate());
if (triggerInputDTO.getDescription() != null)
triggerBuilder.withDescription(triggerInputDTO.getDescription());
if (triggerInputDTO.getCalendarName() != null && !triggerInputDTO.getCalendarName().isBlank())
triggerBuilder.modifiedByCalendar(triggerInputDTO.getCalendarName());
if (triggerInputDTO.getJobDataMap() != null)
triggerBuilder.usingJobData(new JobDataMap(triggerInputDTO.getJobDataMap()));
return triggerBuilder.withSchedule(buildSchedule(triggerInputDTO)).build();
}
private ScheduleBuilder<?> buildSchedule(TriggerInputDTO triggerInputDTO) throws ParseException {
TriggerType triggerType = triggerInputDTO.getTriggerType() == null ? TriggerType.SIMPLE : triggerInputDTO.getTriggerType();
return switch (triggerType) {
case SIMPLE -> buildSimpleSchedule(triggerInputDTO);
case CRON -> buildCronSchedule(triggerInputDTO);
case DAILY_TIME_INTERVAL -> buildDailyTimeIntervalSchedule(triggerInputDTO);
case CALENDAR_INTERVAL -> buildCalendarIntervalSchedule(triggerInputDTO);
};
}
private SimpleScheduleBuilder buildSimpleSchedule(TriggerInputDTO triggerInputDTO) {
SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule();
if (triggerInputDTO.getRepeatInterval() != null)
scheduleBuilder.withIntervalInMilliseconds(triggerInputDTO.getRepeatInterval());
if (triggerInputDTO.getRepeatCount() != null)
scheduleBuilder.withRepeatCount(triggerInputDTO.getRepeatCount());
MisfireInstruction misfireInstruction = parseSimpleMisfireInstruction(triggerInputDTO.getMisfireInstruction());
switch (misfireInstruction) {
case MISFIRE_INSTRUCTION_FIRE_NOW -> scheduleBuilder.withMisfireHandlingInstructionFireNow();
case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithExistingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithRemainingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithRemainingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithExistingCount();
}
return scheduleBuilder;
}
private CronScheduleBuilder buildCronSchedule(TriggerInputDTO triggerInputDTO) throws ParseException {
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronScheduleNonvalidatedExpression(triggerInputDTO.getCronExpression());
if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank())
scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone()));
return applyCronMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private DailyTimeIntervalScheduleBuilder buildDailyTimeIntervalSchedule(TriggerInputDTO triggerInputDTO) {
DailyTimeIntervalScheduleBuilder scheduleBuilder = DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.MINUTE));
if (triggerInputDTO.getStartTimeOfDay() != null && !triggerInputDTO.getStartTimeOfDay().isBlank())
scheduleBuilder.startingDailyAt(parseTimeOfDay(triggerInputDTO.getStartTimeOfDay()));
if (triggerInputDTO.getEndTimeOfDay() != null && !triggerInputDTO.getEndTimeOfDay().isBlank())
scheduleBuilder.endingDailyAt(parseTimeOfDay(triggerInputDTO.getEndTimeOfDay()));
if (triggerInputDTO.getDaysOfWeek() != null && !triggerInputDTO.getDaysOfWeek().isEmpty())
scheduleBuilder.onDaysOfTheWeek(triggerInputDTO.getDaysOfWeek());
return applyDailyMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private CalendarIntervalScheduleBuilder buildCalendarIntervalSchedule(TriggerInputDTO triggerInputDTO) {
CalendarIntervalScheduleBuilder scheduleBuilder = CalendarIntervalScheduleBuilder.calendarIntervalSchedule()
.withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.DAY));
if (Boolean.TRUE.equals(triggerInputDTO.getPreserveHourOfDayAcrossDaylightSavings()))
scheduleBuilder.preserveHourOfDayAcrossDaylightSavings(true);
if (Boolean.TRUE.equals(triggerInputDTO.getSkipDayIfHourDoesNotExist()))
scheduleBuilder.skipDayIfHourDoesNotExist(true);
if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank())
scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone()));
return applyCalendarIntervalMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private JobKey getJobKey(TriggerInputDTO triggerInputDTO) {
JobKeyDTO jobKeyDTO = triggerInputDTO.getJobKey();
if (jobKeyDTO == null)
return null;
return JobKey.jobKey(jobKeyDTO.getName(), jobKeyDTO.getGroup());
}
private TriggerDTO convertTrigger(Trigger trigger) {
TriggerDTO triggerDTO = conversionService.convert(trigger, TriggerDTO.class);
if (triggerDTO == null)
triggerDTO = new TriggerDTO();
if (trigger instanceof SimpleTrigger simpleTrigger)
enrichSimpleTrigger(triggerDTO, simpleTrigger);
else if (trigger instanceof CronTrigger cronTrigger)
enrichCronTrigger(triggerDTO, cronTrigger);
else if (trigger instanceof DailyTimeIntervalTrigger dailyTimeIntervalTrigger)
enrichDailyTimeIntervalTrigger(triggerDTO, dailyTimeIntervalTrigger);
else if (trigger instanceof CalendarIntervalTrigger calendarIntervalTrigger)
enrichCalendarIntervalTrigger(triggerDTO, calendarIntervalTrigger);
return triggerDTO;
}
private void enrichSimpleTrigger(TriggerDTO triggerDTO, SimpleTrigger simpleTrigger) {
triggerDTO.setRepeatCount(simpleTrigger.getRepeatCount());
triggerDTO.setRepeatInterval(simpleTrigger.getRepeatInterval());
}
private void enrichCronTrigger(TriggerDTO triggerDTO, CronTrigger cronTrigger) {
triggerDTO.setCronExpression(cronTrigger.getCronExpression());
triggerDTO.setTimeZone(cronTrigger.getTimeZone().getID());
}
private void enrichDailyTimeIntervalTrigger(TriggerDTO triggerDTO, DailyTimeIntervalTrigger dailyTrigger) {
triggerDTO.setRepeatCount(dailyTrigger.getRepeatCount());
triggerDTO.setRepeatInterval((long) dailyTrigger.getRepeatInterval());
triggerDTO.setRepeatIntervalUnit(dailyTrigger.getRepeatIntervalUnit().name());
triggerDTO.setStartTimeOfDay(formatTimeOfDay(dailyTrigger.getStartTimeOfDay()));
triggerDTO.setEndTimeOfDay(formatTimeOfDay(dailyTrigger.getEndTimeOfDay()));
triggerDTO.setDaysOfWeek(dailyTrigger.getDaysOfWeek());
}
private void enrichCalendarIntervalTrigger(TriggerDTO triggerDTO, CalendarIntervalTrigger calendarTrigger) {
triggerDTO.setRepeatInterval((long) calendarTrigger.getRepeatInterval());
triggerDTO.setRepeatIntervalUnit(calendarTrigger.getRepeatIntervalUnit().name());
triggerDTO.setPreserveHourOfDayAcrossDaylightSavings(calendarTrigger.isPreserveHourOfDayAcrossDaylightSavings());
triggerDTO.setSkipDayIfHourDoesNotExist(calendarTrigger.isSkipDayIfHourDoesNotExist());
triggerDTO.setTimeZone(calendarTrigger.getTimeZone().getID());
}
private MisfireInstruction parseSimpleMisfireInstruction(String misfireInstruction) {
if (misfireInstruction == null || misfireInstruction.isBlank())
return MisfireInstruction.MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT;
return MisfireInstruction.valueOf(misfireInstruction);
}
private CronScheduleBuilder applyCronMisfireInstruction(CronScheduleBuilder scheduleBuilder, String misfireInstruction) {
return switch (normalizeMisfireInstruction(misfireInstruction)) {
case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing();
case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires();
default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed();
};
}
private DailyTimeIntervalScheduleBuilder applyDailyMisfireInstruction(DailyTimeIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) {
return switch (normalizeMisfireInstruction(misfireInstruction)) {
case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing();
case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires();
default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed();
};
}
private CalendarIntervalScheduleBuilder applyCalendarIntervalMisfireInstruction(CalendarIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) {
return switch (normalizeMisfireInstruction(misfireInstruction)) {
case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing();
case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires();
default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed();
};
}
private String normalizeMisfireInstruction(String misfireInstruction) {
return misfireInstruction == null || misfireInstruction.isBlank() ? "FIRE_AND_PROCEED" : misfireInstruction;
}
private DateBuilder.IntervalUnit parseIntervalUnit(String intervalUnit, DateBuilder.IntervalUnit defaultUnit) {
return intervalUnit == null || intervalUnit.isBlank() ? defaultUnit : DateBuilder.IntervalUnit.valueOf(intervalUnit);
}
private TimeOfDay parseTimeOfDay(String timeOfDay) {
String[] parts = timeOfDay.split(":");
int hour = Integer.parseInt(parts[0]);
int minute = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int second = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
return TimeOfDay.hourMinuteAndSecondOfDay(hour, minute, second);
}
private String formatTimeOfDay(TimeOfDay timeOfDay) {
if (timeOfDay == null)
return null;
return String.format("%02d:%02d:%02d", timeOfDay.getHour(), timeOfDay.getMinute(), timeOfDay.getSecond());
}
}

View File

@@ -0,0 +1,8 @@
package it.fabioformosa.quartzmanager.api.validators;
import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO;
public interface JobTargetDTO {
String getJobClass();
JobKeyDTO getJobKey();
}

View File

@@ -0,0 +1,18 @@
package it.fabioformosa.quartzmanager.api.validators;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Constraint(validatedBy = ValidJobTargetValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidJobTarget {
String message() default "Either jobClass or jobKey must be set";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,15 @@
package it.fabioformosa.quartzmanager.api.validators;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class ValidJobTargetValidator implements ConstraintValidator<ValidJobTarget, JobTargetDTO> {
@Override
public boolean isValid(JobTargetDTO jobTargetDTO, ConstraintValidatorContext constraintValidatorContext) {
if (jobTargetDTO == null)
return true;
boolean hasJobClass = jobTargetDTO.getJobClass() != null && !jobTargetDTO.getJobClass().isBlank();
boolean hasJobKey = jobTargetDTO.getJobKey() != null && jobTargetDTO.getJobKey().getName() != null && !jobTargetDTO.getJobKey().getName().isBlank();
return hasJobClass || hasJobKey;
}
}

View File

@@ -0,0 +1,111 @@
package it.fabioformosa.quartzmanager.api.controllers;
import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests;
import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils;
import it.fabioformosa.quartzmanager.api.dto.CalendarDTO;
import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO;
import it.fabioformosa.quartzmanager.api.dto.CalendarType;
import it.fabioformosa.quartzmanager.api.services.CalendarService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.util.Date;
import java.util.List;
import java.util.Set;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.mockito.ArgumentMatchers.any;
@ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = CalendarController.class, properties = {
"quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs"
})
class CalendarControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private CalendarService calendarService;
@AfterEach
void cleanUp(){
Mockito.reset(calendarService);
}
@Test
void whenListCalendarsIsCalled_thenCalendarsAreReturned() throws Exception {
List<CalendarDTO> calendars = List.of(CalendarDTO.builder().name("weekends").type(CalendarType.WEEKLY).excludedDaysOfWeek(Set.of(1, 7)).build());
Mockito.when(calendarService.fetchCalendars()).thenReturn(calendars);
mockMvc.perform(get(CalendarController.CALENDAR_CONTROLLER_BASE_URL).contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendars)));
}
@Test
void whenGetCalendarIsCalled_thenCalendarIsReturned() throws Exception {
CalendarDTO calendarDTO = CalendarDTO.builder().name("cron").type(CalendarType.CRON).cronExpression("0 0 0 ? * SAT,SUN").build();
Mockito.when(calendarService.getCalendar("cron")).thenReturn(calendarDTO);
mockMvc.perform(get(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/cron").contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO)));
}
@Test
void givenACalendarDTO_whenPosted_thenCalendarIsCreated() throws Exception {
CalendarDTO calendarDTO = CalendarDTO.builder().name("holidays").type(CalendarType.HOLIDAY).excludedDates(List.of(new Date())).build();
Mockito.when(calendarService.addCalendar(Mockito.eq("holidays"), any())).thenReturn(calendarDTO);
mockMvc.perform(post(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/holidays")
.contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(calendarDTO)))
.andExpect(MockMvcResultMatchers.status().isCreated())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO)));
}
@Test
void givenACalendarDTO_whenPut_thenCalendarIsUpdated() throws Exception {
CalendarDTO calendarDTO = CalendarDTO.builder().name("month-end").type(CalendarType.MONTHLY).excludedDaysOfMonth(Set.of(31)).build();
Mockito.when(calendarService.updateCalendar("month-end", calendarDTO)).thenReturn(calendarDTO);
mockMvc.perform(put(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/month-end")
.contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(calendarDTO)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO)));
}
@Test
void whenDeleteCalendarIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(delete(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/weekends").contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
Mockito.verify(calendarService).deleteCalendar("weekends");
}
@Test
void whenIncludedTimeIsTested_thenResultIsReturned() throws Exception {
CalendarIncludedTimeDTO input = CalendarIncludedTimeDTO.builder().time(new Date()).build();
CalendarIncludedTimeDTO result = CalendarIncludedTimeDTO.builder().time(input.getTime()).included(true).nextIncludedTime(input.getTime()).build();
Mockito.when(calendarService.testIncludedTime(Mockito.eq("weekends"), any())).thenReturn(result);
mockMvc.perform(post(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/weekends/included-time-test")
.contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(input)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(result)));
}
}

View File

@@ -3,7 +3,9 @@ package it.fabioformosa.quartzmanager.api.controllers;
import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests;
import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils;
import it.fabioformosa.quartzmanager.api.dto.TriggerDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerType;
import it.fabioformosa.quartzmanager.api.services.TriggerService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -21,6 +23,7 @@ import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = TriggerController.class, properties = {
@@ -63,6 +66,49 @@ class TriggerControllerTest {
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO)));
}
@Test
void givenATriggerInputDTO_whenPosted_thenTriggerIsCreated() throws Exception {
TriggerInputDTO triggerInputDTO = TriggerInputDTO.builder()
.triggerType(TriggerType.CRON)
.jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob")
.cronExpression("0 0/5 * * * ?")
.misfireInstruction("FIRE_AND_PROCEED")
.build();
TriggerDTO triggerDTO = TriggerDTO.builder()
.triggerKeyDTO(TriggerKeyDTO.builder().name("cronTrigger").group("DEFAULT").build())
.type("CronTriggerImpl")
.build();
Mockito.when(triggerService.scheduleTrigger("DEFAULT", "cronTrigger", triggerInputDTO)).thenReturn(triggerDTO);
mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/cronTrigger")
.contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(triggerInputDTO)))
.andExpect(MockMvcResultMatchers.status().isCreated())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO)));
}
@Test
void givenATriggerInputDTO_whenPut_thenTriggerIsRescheduled() throws Exception {
TriggerInputDTO triggerInputDTO = TriggerInputDTO.builder()
.triggerType(TriggerType.CALENDAR_INTERVAL)
.jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob")
.repeatInterval(2L)
.repeatIntervalUnit("DAY")
.misfireInstruction("DO_NOTHING")
.build();
TriggerDTO triggerDTO = TriggerDTO.builder()
.triggerKeyDTO(TriggerKeyDTO.builder().name("calendarTrigger").group("DEFAULT").build())
.type("CalendarIntervalTriggerImpl")
.build();
Mockito.when(triggerService.rescheduleTrigger("DEFAULT", "calendarTrigger", triggerInputDTO)).thenReturn(triggerDTO);
mockMvc.perform(put(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/calendarTrigger")
.contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(triggerInputDTO)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO)));
}
@Test
void whenPauseTriggerIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger/pause").contentType(MediaType.APPLICATION_JSON))

View File

@@ -72,7 +72,7 @@ class SimpleTriggerServiceTest {
SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder()
.startTime(triggerInputDTO.getStartDate())
.repeatInterval(1000)
.repeatInterval(1000L)
.repeatCount(10)
.mayFireAgain(true)
.finalFireTime(triggerInputDTO.getEndDate())
@@ -110,7 +110,7 @@ class SimpleTriggerServiceTest {
SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder()
.startTime(triggerInputDTO.getStartDate())
.repeatInterval(1000)
.repeatInterval(1000L)
.repeatCount(10)
.mayFireAgain(true)
.finalFireTime(triggerInputDTO.getEndDate())