diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 5da6e61..4871bd4 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Java 17 for publishing to Maven Central Repository + - name: Set up Java 21 for publishing to Maven Central Repository uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' server-id: maven-central-release server-username: MAVEN_USERNAME @@ -35,10 +35,10 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} - - name: Set up Java 17 for publishing to GitHub Packages + - name: Set up Java 21 for publishing to GitHub Packages uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Publish to GitHub Packages Apache Maven run: mvn deploy --file quartz-manager-parent/pom.xml -P "deploy-github,build-webjar" diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 911227e..f4baff0 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -26,10 +26,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: maven - name: Build and test with Maven diff --git a/.github/workflows/sonar-java.yml b/.github/workflows/sonar-java.yml index 3990173..925d2c7 100644 --- a/.github/workflows/sonar-java.yml +++ b/.github/workflows/sonar-java.yml @@ -16,10 +16,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'zulu' # Alternative distribution options are available. - name: Cache SonarCloud packages uses: actions/cache@v3 diff --git a/quartz-manager-frontend/eslint.sonar.config.mjs b/quartz-manager-frontend/eslint.sonar.config.mjs new file mode 100644 index 0000000..e4e69ed --- /dev/null +++ b/quartz-manager-frontend/eslint.sonar.config.mjs @@ -0,0 +1,30 @@ +import sonarjs from 'eslint-plugin-sonarjs'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: 'tsconfig.json', + sourceType: 'module' + } + }, + plugins: { + sonarjs + }, + rules: { + ...sonarjs.configs.recommended.rules, + 'sonarjs/deprecation': 'off', + 'sonarjs/no-commented-code': 'off', + 'sonarjs/no-dead-store': 'off', + 'sonarjs/no-incomplete-assertions': 'off', + 'sonarjs/no-primitive-wrappers': 'off', + 'sonarjs/no-unused-vars': 'off', + 'sonarjs/prefer-promise-shorthand': 'off', + 'sonarjs/todo-tag': 'off', + 'sonarjs/unused-import': 'off' + } + } +]; diff --git a/quartz-manager-frontend/jest.config.js b/quartz-manager-frontend/jest.config.js index 35ad8c8..1aa8b02 100644 --- a/quartz-manager-frontend/jest.config.js +++ b/quartz-manager-frontend/jest.config.js @@ -5,8 +5,16 @@ module.exports = { tsconfig: '/tsconfig.spec.json', stringifyContentPathRegex: '\\.(html|svg)$' }), + moduleNameMapper: { + '^tslib$': '/node_modules/tslib/tslib.es6.mjs', + '^rxjs$': '/node_modules/rxjs/dist/cjs/index.js', + '^rxjs/operators$': '/node_modules/rxjs/dist/cjs/operators/index.js', + '^rxjs/(.*)$': '/node_modules/rxjs/dist/cjs/$1', + '^@fortawesome/fontawesome$': '/node_modules/@fortawesome/fontawesome/index.js', + '^@fortawesome/fontawesome-free-solid$': '/node_modules/@fortawesome/fontawesome-free-solid/index.js' + }, setupFilesAfterEnv: ['/jest.setup.ts'], transformIgnorePatterns: [ - 'node_modules/(?!(@angular|@stomp/rx-stomp|@stomp/stompjs|.*\\.mjs$)/)' + 'node_modules/(?!(@angular|@fortawesome|@stomp/rx-stomp|@stomp/stompjs|.*\\.mjs$)/)' ] }; diff --git a/quartz-manager-frontend/package.json b/quartz-manager-frontend/package.json index f7629f6..e78abfd 100644 --- a/quartz-manager-frontend/package.json +++ b/quartz-manager-frontend/package.json @@ -8,8 +8,8 @@ "build": "ng build --configuration production", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "lint": "ng lint", - "lint:sonar": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\"", - "lint:sonar:fix": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\" --fix" + "lint:sonar": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\"", + "lint:sonar:fix": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\" --fix" }, "private": true, "dependencies": { diff --git a/quartz-manager-frontend/src/app/app.component.html b/quartz-manager-frontend/src/app/app.component.html index d28c563..ce9cc03 100644 --- a/quartz-manager-frontend/src/app/app.component.html +++ b/quartz-manager-frontend/src/app/app.component.html @@ -1,7 +1,11 @@ -
- -
- +@if (isOperationsConsoleRoute()) { + +} @else { +
+ +
+ +
+
- -
+} diff --git a/quartz-manager-frontend/src/app/app.component.scss b/quartz-manager-frontend/src/app/app.component.scss index 63672be..d941b7c 100644 --- a/quartz-manager-frontend/src/app/app.component.scss +++ b/quartz-manager-frontend/src/app/app.component.scss @@ -2,7 +2,7 @@ display: block; color: rgba(0,0,0,.54); font-family: Roboto,"Helvetica Neue"; - height: 100%; + min-height: 100%; } .content { diff --git a/quartz-manager-frontend/src/app/app.component.ts b/quartz-manager-frontend/src/app/app.component.ts index 3ab0889..9ede1cb 100644 --- a/quartz-manager-frontend/src/app/app.component.ts +++ b/quartz-manager-frontend/src/app/app.component.ts @@ -1,4 +1,5 @@ -import {Component} from '@angular/core'; +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; import fontawesome from '@fortawesome/fontawesome'; import { @@ -19,5 +20,12 @@ fontawesome.library.add(faCheckCircle, faExclamationCircle, faExclamationTriangl standalone: false }) -export class AppComponent { -} +export class AppComponent { + constructor(private router: Router) { + } + + isOperationsConsoleRoute(): boolean { + const url = this.router.url || '/'; + return url === '/' || url.startsWith('/manager'); + } +} diff --git a/quartz-manager-frontend/src/app/app.module.ts b/quartz-manager-frontend/src/app/app.module.ts index af88112..2277108 100644 --- a/quartz-manager-frontend/src/app/app.module.ts +++ b/quartz-manager-frontend/src/app/app.module.ts @@ -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, diff --git a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts index 2245bb0..2430bbf 100644 --- a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts +++ b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts @@ -74,7 +74,7 @@ describe('SchedulerControlComponent', () => { expect(playIconDe).toBeTruthy(); schedulerBtnDe.nativeElement.click(); - const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/run'); + const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/start'); startSchedulerReq.flush(null); fixture.detectChanges(); @@ -98,7 +98,7 @@ describe('SchedulerControlComponent', () => { expect(pauseIconDe).toBeTruthy(); schedulerBtnDe.nativeElement.click(); - const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/pause'); + const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/standby'); startSchedulerReq.flush(null); fixture.detectChanges(); diff --git a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.ts b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.ts index 533940a..ea01c70 100644 --- a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.ts +++ b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.ts @@ -35,16 +35,16 @@ export class SchedulerControlComponent implements OnInit { }); }; - stopScheduler = function () { - this.schedulerService.stopScheduler().subscribe((res) => { - this.scheduler.status = 'STOPPED' + stopScheduler = function () { + this.schedulerService.shutdownScheduler().subscribe((res) => { + this.scheduler.status = 'STOPPED' }, (res) => { console.log(JSON.stringify(res)) }); }; - pauseScheduler = function () { - this.schedulerService.pauseScheduler().subscribe((res) => { + pauseScheduler = function () { + this.schedulerService.standbyScheduler().subscribe((res) => { this.scheduler.status = 'PAUSED' }, (res) => { console.log(JSON.stringify(res)) diff --git a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts index bb33884..e8cce12 100644 --- a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts +++ b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts @@ -56,7 +56,7 @@ describe('SimpleTriggerConfig', () => { it('should fetch no triggers at the init', () => { expect(component).toBeTruthy(); - httpTestingController.expectNone(`${CONTEXT_PATH}/simple-triggers/my-simple-trigger`); + httpTestingController.expectNone(`${CONTEXT_PATH}/simple-triggers/DEFAULT/my-simple-trigger`); }); function setInputValue(componentDe: DebugElement, inputSelector: string, value: string) { @@ -95,7 +95,7 @@ describe('SimpleTriggerConfig', () => { component.openTriggerForm(); fixture.detectChanges(); - const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); + const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`); getJobsReq.flush([testJobName]); const componentDe: DebugElement = fixture.debugElement; @@ -150,7 +150,7 @@ describe('SimpleTriggerConfig', () => { expect(submittedTriggerKey).toEqual(new TriggerKey(testTriggerName, null)); flush(); - const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); + const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`); postSimpleTriggerReq.flush(mockTrigger); expect(actualNewTrigger).toEqual(mockTrigger); @@ -166,7 +166,7 @@ describe('SimpleTriggerConfig', () => { mockTrigger.jobDetailDTO = {jobClassName: testJobName, description: null}; mockTrigger.mayFireAgain = true; mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; - const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); + const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`); getSimpleTriggerReq.flush(mockTrigger); component.simpleTriggerReactiveForm.setValue({ @@ -198,7 +198,7 @@ describe('SimpleTriggerConfig', () => { submitButton.nativeElement.click(); - const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); + const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`); putSimpleTriggerReq.flush(mockTrigger); expect(actualNewTrigger).toBeUndefined(); @@ -214,7 +214,7 @@ describe('SimpleTriggerConfig', () => { const mockTrigger = new Trigger(); mockTrigger.triggerKeyDTO = mockTriggerKey; mockTrigger.jobDetailDTO = {jobClassName: 'TestJob', description: null}; - const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/my-simple-trigger`); + const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/my-simple-trigger`); getSimpleTriggerReq.flush(mockTrigger); fixture.detectChanges(); @@ -246,7 +246,7 @@ describe('SimpleTriggerConfig', () => { mockTrigger.mayFireAgain = true; mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; - const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); + const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`); getSimpleTriggerReq.flush(mockTrigger); expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName); @@ -271,7 +271,7 @@ describe('SimpleTriggerConfig', () => { it('should display the warning if there are no eligible jobs', () => { fixture.detectChanges(); - const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); + const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`); getJobsReq.flush([]); fixture.detectChanges(); @@ -285,7 +285,7 @@ describe('SimpleTriggerConfig', () => { it('should not display the warning if there are eligible jobs', () => { fixture.detectChanges(); - const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); + const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`); getJobsReq.flush(['sampleJob']); fixture.detectChanges(); diff --git a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts index 13fb3ba..ba66250 100644 --- a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts +++ b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts @@ -207,6 +207,7 @@ export class SimpleTriggerConfigComponent implements OnInit { const reactiveFormValue = this.simpleTriggerReactiveForm.getRawValue(); const simpleTriggerCommand = new SimpleTriggerCommand(); simpleTriggerCommand.triggerName = reactiveFormValue.triggerName; + simpleTriggerCommand.triggerGroup = this.selectedTriggerKey?.group || 'DEFAULT'; simpleTriggerCommand.jobClass = reactiveFormValue.jobClass; simpleTriggerCommand.repeatCount = reactiveFormValue.triggerRecurrence.repeatCount; simpleTriggerCommand.repeatInterval = reactiveFormValue.triggerRecurrence.repeatInterval; diff --git a/quartz-manager-frontend/src/app/model/calendar.model.ts b/quartz-manager-frontend/src/app/model/calendar.model.ts new file mode 100644 index 0000000..b315cfb --- /dev/null +++ b/quartz-manager-frontend/src/app/model/calendar.model.ts @@ -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; +} diff --git a/quartz-manager-frontend/src/app/model/scheduled-job.command.ts b/quartz-manager-frontend/src/app/model/scheduled-job.command.ts new file mode 100644 index 0000000..d7db326 --- /dev/null +++ b/quartz-manager-frontend/src/app/model/scheduled-job.command.ts @@ -0,0 +1,7 @@ +export class ScheduledJobCommand { + jobClass: string; + description: string; + durable: boolean; + requestsRecovery: boolean; + jobDataMap: {[key: string]: unknown}; +} diff --git a/quartz-manager-frontend/src/app/model/scheduled-job.model.ts b/quartz-manager-frontend/src/app/model/scheduled-job.model.ts new file mode 100644 index 0000000..ae9ee88 --- /dev/null +++ b/quartz-manager-frontend/src/app/model/scheduled-job.model.ts @@ -0,0 +1,12 @@ +import {JobKeyModel} from './jobKey.model'; +import {TriggerKey} from './triggerKey.model'; + +export class ScheduledJob { + jobKeyDTO: JobKeyModel; + jobClassName: string; + description: string; + durable: boolean; + requestsRecovery: boolean; + jobDataMap: {[key: string]: unknown}; + triggerKeys: TriggerKey[]; +} diff --git a/quartz-manager-frontend/src/app/model/scheduler.model.ts b/quartz-manager-frontend/src/app/model/scheduler.model.ts index e7df13a..780da6b 100644 --- a/quartz-manager-frontend/src/app/model/scheduler.model.ts +++ b/quartz-manager-frontend/src/app/model/scheduler.model.ts @@ -5,6 +5,14 @@ export class Scheduler { instanceId: string; status: string; triggerKeys: TriggerKey[]; + quartzVersion: string; + jobStoreClass: string; + jobStoreSupportsPersistence: boolean; + clustered: boolean; + threadPoolClass: string; + threadPoolSize: number; + runningSince: string; + numberOfJobsExecuted: number; constructor(name: string, instanceId: string, status: string, triggerKeys: TriggerKey[]) { this.name = name; diff --git a/quartz-manager-frontend/src/app/model/simple-trigger.command.ts b/quartz-manager-frontend/src/app/model/simple-trigger.command.ts index 8df39d4..be7fdfa 100644 --- a/quartz-manager-frontend/src/app/model/simple-trigger.command.ts +++ b/quartz-manager-frontend/src/app/model/simple-trigger.command.ts @@ -1,9 +1,12 @@ export class SimpleTriggerCommand { triggerName: string; + triggerGroup: string; jobClass: string; + jobKey: {group: string; name: string}; startDate: Date; endDate: Date; repeatCount: number; repeatInterval: number; misfireInstruction: string; + jobDataMap: {[key: string]: unknown}; } diff --git a/quartz-manager-frontend/src/app/model/trigger-command.model.ts b/quartz-manager-frontend/src/app/model/trigger-command.model.ts new file mode 100644 index 0000000..65d5c7c --- /dev/null +++ b/quartz-manager-frontend/src/app/model/trigger-command.model.ts @@ -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; +} diff --git a/quartz-manager-frontend/src/app/model/trigger.model.ts b/quartz-manager-frontend/src/app/model/trigger.model.ts index 8d4db4b..a1a3c12 100644 --- a/quartz-manager-frontend/src/app/model/trigger.model.ts +++ b/quartz-manager-frontend/src/app/model/trigger.model.ts @@ -11,7 +11,22 @@ export class Trigger { finalFireTime: Date; misfireInstruction: number; nextFireTime: Date; + previousFireTime: Date; + type: string; + state: string; + calendarName: string; jobKeyDTO: JobKeyModel; 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; } diff --git a/quartz-manager-frontend/src/app/services/calendar.service.spec.ts b/quartz-manager-frontend/src/app/services/calendar.service.spec.ts new file mode 100644 index 0000000..de4c6d2 --- /dev/null +++ b/quartz-manager-frontend/src/app/services/calendar.service.spec.ts @@ -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}); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/calendar.service.ts b/quartz-manager-frontend/src/app/services/calendar.service.ts new file mode 100644 index 0000000..6f0767e --- /dev/null +++ b/quartz-manager-frontend/src/app/services/calendar.service.ts @@ -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 => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars`); + } + + getCalendar = (name: string): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`); + } + + createCalendar = (name: string, calendar: QuartzCalendar): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar); + } + + updateCalendar = (name: string, calendar: QuartzCalendar): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar); + } + + deleteCalendar = (name: string): Observable => { + return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`); + } + + testIncludedTime = (name: string, time: Date): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}/included-time-test`, {time}); + } +} diff --git a/quartz-manager-frontend/src/app/services/index.ts b/quartz-manager-frontend/src/app/services/index.ts index 81435d0..df9feec 100644 --- a/quartz-manager-frontend/src/app/services/index.ts +++ b/quartz-manager-frontend/src/app/services/index.ts @@ -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' diff --git a/quartz-manager-frontend/src/app/services/job.service.spec.ts b/quartz-manager-frontend/src/app/services/job.service.spec.ts new file mode 100644 index 0000000..fc2dc4d --- /dev/null +++ b/quartz-manager-frontend/src/app/services/job.service.spec.ts @@ -0,0 +1,46 @@ +import JobService from './job.service'; +import {ScheduledJob} from '../model/scheduled-job.model'; +import {jest} from '@jest/globals'; + +describe('JobService', () => { + let apiService: any; + let jobService: JobService; + + beforeEach(() => { + apiService = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn() + }; + jobService = new JobService(apiService); + }); + + 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', 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', 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'); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/job.service.ts b/quartz-manager-frontend/src/app/services/job.service.ts index 037ed9a..21551a9 100644 --- a/quartz-manager-frontend/src/app/services/job.service.ts +++ b/quartz-manager-frontend/src/app/services/job.service.ts @@ -2,6 +2,8 @@ import {Injectable} from '@angular/core'; import {ApiService} from './api.service'; import {CONTEXT_PATH, getBaseUrl} from './config.service'; import {Observable} from 'rxjs'; +import {ScheduledJob} from '../model/scheduled-job.model'; +import {ScheduledJobCommand} from '../model/scheduled-job.command'; @Injectable() export default class JobService { @@ -12,7 +14,31 @@ export default class JobService { } fetchJobs = (): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/job-classes`) + } + + fetchScheduledJobs = (): Observable => { return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs`) } + getScheduledJob = (group: string, name: string): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`) + } + + createJob = (group: string, name: string, command: ScheduledJobCommand): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command) + } + + updateJob = (group: string, name: string, command: ScheduledJobCommand): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command) + } + + triggerJob = (job: ScheduledJob): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {}) + } + + deleteJob = (job: ScheduledJob): Observable => { + return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}`) + } + } diff --git a/quartz-manager-frontend/src/app/services/scheduler.service.spec.ts b/quartz-manager-frontend/src/app/services/scheduler.service.spec.ts new file mode 100644 index 0000000..56a21f8 --- /dev/null +++ b/quartz-manager-frontend/src/app/services/scheduler.service.spec.ts @@ -0,0 +1,43 @@ +import {SchedulerService} from './scheduler.service'; +import {SimpleTriggerCommand} from '../model/simple-trigger.command'; +import {jest} from '@jest/globals'; + +describe('SchedulerService', () => { + let apiService: any; + let schedulerService: SchedulerService; + + beforeEach(() => { + apiService = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn() + }; + schedulerService = new SchedulerService(apiService); + }); + + it('uses POST scheduler lifecycle endpoints', () => { + schedulerService.startScheduler(); + schedulerService.standbyScheduler(); + schedulerService.resumeScheduler(); + schedulerService.shutdownScheduler(); + + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/start', {}); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/standby', {}); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/resume', {}); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/shutdown', {}); + }); + + it('uses grouped simple trigger endpoints', () => { + const command = new SimpleTriggerCommand(); + command.triggerGroup = 'DEFAULT'; + command.triggerName = 'sampleTrigger'; + + schedulerService.getSimpleTriggerConfig(command.triggerName, command.triggerGroup); + schedulerService.saveSimpleTriggerConfig(command); + schedulerService.updateSimpleTriggerConfig(command); + + expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger'); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command); + expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/scheduler.service.ts b/quartz-manager-frontend/src/app/services/scheduler.service.ts index e675862..1212e4c 100644 --- a/quartz-manager-frontend/src/app/services/scheduler.service.ts +++ b/quartz-manager-frontend/src/app/services/scheduler.service.ts @@ -14,20 +14,20 @@ export class SchedulerService { private apiService: ApiService ) { } - startScheduler = (): Observable => { - return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/run`); - } - - stopScheduler = (): Observable => { - return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/stop`); - } - - pauseScheduler = (): Observable => { - return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/pause`); - } - - resumeScheduler = (): Observable => { - return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`); + startScheduler = (): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/start`, {}); + } + + shutdownScheduler = (): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/shutdown`, {}); + } + + standbyScheduler = (): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/standby`, {}); + } + + resumeScheduler = (): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`, {}); } getStatus = () => { @@ -38,17 +38,17 @@ export class SchedulerService { return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler`); } - getSimpleTriggerConfig = (triggerName: string): Observable => { - return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerName}`); - } - - saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => { - return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config) - } - - updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => { - return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config) - } + getSimpleTriggerConfig = (triggerName: string, triggerGroup = 'DEFAULT'): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerGroup}/${triggerName}`); + } + + saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config) + } + + updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config) + } } diff --git a/quartz-manager-frontend/src/app/services/trigger.service.spec.ts b/quartz-manager-frontend/src/app/services/trigger.service.spec.ts new file mode 100644 index 0000000..68d487b --- /dev/null +++ b/quartz-manager-frontend/src/app/services/trigger.service.spec.ts @@ -0,0 +1,42 @@ +import {TriggerService} from './trigger.service'; +import {TriggerKey} from '../model/triggerKey.model'; +import {jest} from '@jest/globals'; + +describe('TriggerService', () => { + let apiService: any; + let triggerService: TriggerService; + + beforeEach(() => { + apiService = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn() + }; + triggerService = new TriggerService(apiService); + }); + + it('uses grouped trigger lifecycle endpoints', () => { + const triggerKey = new TriggerKey('sampleTrigger', 'DEFAULT'); + + triggerService.getTrigger(triggerKey); + triggerService.pauseTrigger(triggerKey); + triggerService.resumeTrigger(triggerKey); + triggerService.unscheduleTrigger(triggerKey); + + expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger'); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger/pause', {}); + 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); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/trigger.service.ts b/quartz-manager-frontend/src/app/services/trigger.service.ts index 9aed968..21e385c 100644 --- a/quartz-manager-frontend/src/app/services/trigger.service.ts +++ b/quartz-manager-frontend/src/app/services/trigger.service.ts @@ -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 { @@ -16,5 +17,28 @@ export class TriggerService { return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers`); } + getTrigger = (triggerKey: TriggerKey): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`); + } + + saveTrigger = (group: string, name: string, config: TriggerCommand): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config); + } + + updateTrigger = (group: string, name: string, config: TriggerCommand): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config); + } + + pauseTrigger = (triggerKey: TriggerKey): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/pause`, {}); + } + + resumeTrigger = (triggerKey: TriggerKey): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/resume`, {}); + } + + unscheduleTrigger = (triggerKey: TriggerKey): Observable => { + return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`); + } } diff --git a/quartz-manager-frontend/src/app/views/login/login.component.html b/quartz-manager-frontend/src/app/views/login/login.component.html index 2fdc554..4f5362c 100644 --- a/quartz-manager-frontend/src/app/views/login/login.component.html +++ b/quartz-manager-frontend/src/app/views/login/login.component.html @@ -1,36 +1,63 @@ -
- - -

Quartz Manager

-
+
+ diff --git a/quartz-manager-frontend/src/app/views/login/login.component.scss b/quartz-manager-frontend/src/app/views/login/login.component.scss index 3e135f8..7f56018 100644 --- a/quartz-manager-frontend/src/app/views/login/login.component.scss +++ b/quartz-manager-frontend/src/app/views/login/login.component.scss @@ -1,62 +1,268 @@ :host { + --bg: oklch(98% 0.005 250); + --surface: oklch(100% 0 0); + --fg: oklch(22% 0.02 240); + --muted: oklch(50% 0.018 240); + --border: oklch(90% 0.008 240); + --accent: oklch(56% 0.19 302); + --success: oklch(58% 0.16 145); + --danger: oklch(58% 0.19 28); + --radius: 8px; + display: block; flex: 1; + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif; } -.content { +* { + box-sizing: border-box; +} + +.login-shell { + width: 100%; + min-height: min(680px, calc(100vh - 170px)); + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 430px); + gap: 20px; + align-items: stretch; + padding: 18px; + border: 1px solid var(--border); + border-radius: 14px; + background: + radial-gradient(circle at top left, oklch(56% 0.19 302 / 0.16), transparent 34%), + var(--bg); + animation: fadein 1s; + -o-animation: fadein 1s; + -moz-animation: fadein 1s; + -webkit-animation: fadein 1s; +} + +.login-hero { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 430px; + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: + linear-gradient(145deg, oklch(99% 0.003 250 / 0.92), oklch(95% 0.018 285 / 0.92)), + var(--surface); +} + +.login-hero::after { + content: ""; + position: absolute; + inset: auto -80px -95px auto; + width: 260px; + height: 260px; + border-radius: 999px; + background: oklch(56% 0.19 302 / 0.13); +} + +.brand { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 9px; + background: var(--accent); + color: white; + font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 13px; + font-weight: 800; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 21px; + line-height: 1.1; +} + +.brand p, +.form-header p, +.status-row, +.metric-grid span, +.loading-state span { + color: var(--muted); +} + +.hero-card { + position: relative; + z-index: 1; + display: grid; + gap: 18px; + max-width: 440px; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: oklch(100% 0 0 / 0.78); + box-shadow: 0 22px 60px oklch(22% 0.02 240 / 0.10); + backdrop-filter: blur(12px); +} + +.card-title, +.eyebrow, +.metric-grid strong { + font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; +} + +.card-title, +.eyebrow { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.status-row { + display: flex; + align-items: center; + gap: 9px; + line-height: 1.45; +} + +.pulse { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 6px oklch(58% 0.16 145 / 0.12); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.metric-grid div { + display: grid; + gap: 5px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.metric-grid strong { + font-size: 24px; + line-height: 1; +} + +.login-card { + align-self: center; + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 24px 70px oklch(22% 0.02 240 / 0.14); +} + +.login-card mat-card-content { + display: grid; + gap: 22px; + padding: 30px; +} + +.form-header { + display: grid; + gap: 8px; +} + +.form-header h2 { + font-size: 28px; + line-height: 1.05; +} + +.form-header p { + line-height: 1.45; +} + +form { + display: grid; + gap: 12px; +} + +mat-form-field, +.login-button { width: 100%; } -mat-card { - max-width: 350px; - text-align: center; - animation: fadein 1s; - -o-animation: fadein 1s; /* Opera */ - -moz-animation: fadein 1s; /* Firefox */ - -webkit-animation: fadein 1s; /* Safari and Chrome */ - -} - -mat-form-field { - display: block; +.login-button { + min-height: 44px; + border-radius: 7px; + font-weight: 700; } mat-spinner { width: 25px; height: 25px; - margin: 20px auto 0 auto; } -button { - display: block; - width: 100%; +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 128px; + font-size: 13px; +} + +.notification { + margin: 0; + padding: 12px 14px; + border-radius: var(--radius); + line-height: 1.4; } .error { - color: #D50000; + border: 1px solid oklch(58% 0.19 28 / 0.30); + background: oklch(98% 0.02 28); + color: var(--danger); } .success { - color: #8BC34A; + border: 1px solid oklch(58% 0.16 145 / 0.30); + background: oklch(98% 0.02 145); + color: var(--success); } - -@media screen and (max-width: 599px) { - - .content { - /* https://github.com/angular/flex-layout/issues/295 */ - display: block !important; +@media screen and (max-width: 760px) { + .login-shell { + grid-template-columns: 1fr; + min-height: auto; + padding: 12px; } - mat-card { - /* https://github.com/angular/flex-layout/issues/295 */ - display: block !important; - max-width: 999px; + .login-hero { + min-height: auto; + gap: 24px; + padding: 18px; } -} + .login-card mat-card-content { + padding: 24px 20px; + } -a { - text-decoration: none; - cursor: auto; - color: #FFFFFF; + .form-header h2 { + font-size: 25px; + } } diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.html b/quartz-manager-frontend/src/app/views/manager/manager.component.html index fd48af8..f6f791b 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.html +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.html @@ -1,48 +1,382 @@ -
-
- -
- -
-
-
- +
+
-
+
+ + + +
+

Live channel

+
WebSocketOPEN
+
+ + +
+
+
+
+

Quartz Operations Console

+
{{ scheduler?.name || 'quartz-manager-scheduler' }} / compact context
+
+ {{ scheduler?.status || 'LOADING' }} +
Instance ID{{ scheduler?.instanceId || '-' }}
+
Cluster{{ scheduler?.clustered ? 'YES' : 'NO' }}
+
WebSocketOPEN
+
+
+ + +
+
+ +
+
+
+
+

Scheduler Command Center

Supported lifecycle commands call the current backend
+
+
+
+ + + + + + +
+
Global lifecycle operations are centralized here. Group-level and destructive data operations stay visible as roadmap actions until backend endpoints exist.
+
+ +
+
+ +
TRIGGERS
{{ triggerKeys.length }}
Trigger keys returned by backend
+
JOBS
{{ jobs.length }}
Eligible job classes
+
EVENTS
{{ getExecutionLoadValue() }}
Logs received for selected trigger
+
STATUS
{{ scheduler?.status || '-' }}
Scheduler lifecycle state
+ +
+

Next Scheduled Fires

LIVE
+
+
+ + + + @for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) { + + + + + + + + + } @empty { + + } + +
TriggerGroupTypeStateJobNext fire
{{ triggerKey.name }}{{ getTriggerGroup(triggerKey) }}{{ getTriggerType(triggerKey) }}{{ getTriggerState(triggerKey) }}{{ getTriggerJobName(triggerKey) }}{{ getTriggerNextFireLabel(triggerKey) }}
No triggers returned by the backend. Use the wizard to create a SimpleTrigger.
+
+ +
+
+ +
+

Execution Load

Analytics roadmap preview
+
+
+ +
+
+
{{ logs.length }}
+
{{ getProgressPercentage() }}%
+ + +
+
+
+ +
+

Event Stream

STREAMING
+
+
TimeSeverityTypeSourceMessage
+ @for (log of logs; track log.time) { +
{{ log.time | date:'HH:mm:ss' }}{{ log.severity }}{{ log.type }}{{ log.source }}{{ log.message }}
+ } @empty { +
--WAITJOB_LOG{{ selectedTriggerKey?.name || '-' }}Waiting for log messages from the selected trigger.
+ } +
+
+
+
+ +
+
+

Jobs

The backend exposes scheduled Quartz jobs plus eligible job classes for SimpleTrigger creation. Durability, recovery, data map, and related trigger keys are read-only in this release.

+
+
+
+

Scheduled Jobs

{{ getScheduledJobRows().length }} / {{ scheduledJobs.length }} JOBS
+
+
+ + + + @for (job of getScheduledJobRows(); track job.jobKeyDTO.group + '.' + job.jobKeyDTO.name) { + + } @empty { + + } + +
Job keyClassDurableRecoveryTriggers
{{ job.jobKeyDTO.group }}.{{ job.jobKeyDTO.name }}{{ job.jobClassName }}{{ job.durable ? 'YES' : 'NO' }}{{ job.requestsRecovery ? 'YES' : 'NO' }}{{ job.triggerKeys?.length || 0 }}
No scheduled jobs returned by the backend. Create a SimpleTrigger from an eligible job class.
+
+ +
+
+
+ +
+
+

Triggers

The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.

+
+
+
+

Trigger Inventory

{{ getTriggerRows().length }} / {{ triggerKeys.length }} TOTALSTATE COUNTS ROADMAP
+
+
+ + + + @for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) { + + } @empty { + + } + +
TriggerGroupTypeStateJobNext fireMisfire
{{ triggerKey.name }}{{ getTriggerGroup(triggerKey) }}{{ getTriggerType(triggerKey) }}{{ getTriggerState(triggerKey) }}{{ getTriggerJobName(triggerKey) }}{{ getTriggerNextFireLabel(triggerKey) }}{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}
No triggers returned by the backend.
+
+ +
+
+
+ +
+
+

Calendars

Manage Quartz calendar exclusions and inspect which triggers are attached to each calendar.

+
+
+
+

Calendar Registry

{{ getCalendarRows().length }} / {{ calendars.length }} CALENDARS
+
+
@for (calendar of getCalendarRows(); track calendar.name) { } @empty { }
NameTypeDescriptionTriggers
{{ calendar.name }}{{ calendar.type }}{{ calendar.description || '-' }}{{ calendar.triggerKeys?.length || 0 }}
No calendars registered. Create one to exclude time windows from trigger firing.
+ +
+
+
+ +
+
+

Executions

Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.

+
+
+
+

Currently Executing Jobs

ROADMAP
+
+
Fire instanceJobTriggerRun timeNode
Roadmap{{ getSelectedJobName() }}{{ selectedTriggerKey?.name || '-' }}RoadmapRoadmap
+ +
+
+
+ +
+
+

Event Stream

The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.

+
+
+
+
+

Live Events

TRIGGER STREAM{{ logs.length }} EVENTS
+
+
TimeSeverityTypeSourceMessage
+ @for (log of logs; track log.time) { +
{{ log.time | date:'HH:mm:ss' }}{{ log.severity }}{{ log.type }}{{ log.source }}{{ log.message }}
+ } @empty { +
--WAITJOB_LOG{{ selectedTriggerKey?.name || '-' }}Select or fire a trigger to receive backend log messages.
+ } +
+
+ +
+
+ +
+
+

Scheduler / Settings

Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.

+
{{ scheduler?.status || 'LOADING' }}
+
+
+
+

Lifecycle Controls

Global actions affect the scheduler instance
+
Strong confirmation requiredShutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.
+
+
+

Scheduler Metadata

CURRENT API
+
{{ scheduler?.name || '-' }}
{{ scheduler?.instanceId || '-' }}
{{ scheduler?.status || '-' }}
{{ triggerKeys.length }}
{{ scheduler?.quartzVersion || '-' }}
{{ scheduler?.threadPoolSize || '-' }}
{{ scheduler?.jobStoreClass || '-' }}
{{ scheduler?.clustered ? 'YES' : 'NO' }}
+
+
+

Cluster Nodes

ROADMAP
+
{{ scheduler?.instanceId || 'local' }}
local scheduler instance
LOCAL
remote nodes
not exposed by backend
ROADMAP
+
+
+

Global State Overview

{{ triggerKeys.length }} TRIGGERSANALYTICS ROADMAP
+
AreaCurrent stateCountRepresentative keyRecommended action
Scheduler{{ scheduler?.status || '-' }}1{{ scheduler?.instanceId || '-' }}Use lifecycle controls above.
TriggersLISTED{{ triggerKeys.length }}{{ selectedTriggerKey?.name || '-' }}Open Triggers for details or reschedule SimpleTriggers.
Misfires / errorsROADMAPRoadmapRoadmapBackend analytics needed.
+
+
+
+
+
+ + @if (wizardOpen || jobWizardOpen || calendarWizardOpen || detailDrawerOpen) { + + } + + @if (roadmapNotice || operationNotice || operationError) { +
+
{{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}
+
{{ operationError || roadmapNotice || operationNotice }}
+ +
+ } + + + + + + +
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.scss b/quartz-manager-frontend/src/app/views/manager/manager.component.scss index a273cab..5774b9a 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.scss +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.scss @@ -1,10 +1,353 @@ :host { - display: flex; - flex-direction: column; - flex: 1; + --bg: oklch(98% 0.005 250); + --surface: oklch(100% 0 0); + --fg: oklch(22% 0.02 240); + --muted: oklch(50% 0.018 240); + --border: oklch(90% 0.008 240); + --accent: oklch(56% 0.19 302); + --success: oklch(58% 0.16 145); + --warning: oklch(72% 0.15 82); + --danger: oklch(58% 0.19 28); + --info: oklch(58% 0.18 255); + --radius: 8px; + display: block; + min-height: 100vh; + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif; + font-size: 14px; } -#manager-content-container { - height: calc(100% - 80px); - max-height: calc(100% - 80px); +* { box-sizing: border-box; } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } + +.qm-app { + display: grid; + grid-template-columns: 248px minmax(780px, 1fr); + min-height: 100vh; + background: var(--bg); +} + +.qm-app.object-mode { grid-template-columns: 248px minmax(780px, 1fr); } + +.rail { + border-right: 1px solid var(--border); + background: oklch(99% 0.003 250); + padding: 18px 14px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px 14px; + border-bottom: 1px solid var(--border); +} + +.brand-mark { + width: 30px; + height: 30px; + border-radius: 7px; + display: grid; + place-items: center; + color: white; + background: var(--accent); + font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 12px; + font-weight: 700; +} + +.brand-title { font-weight: 700; font-size: 14px; line-height: 1.15; } +.brand-subtitle, .caption, .help { color: var(--muted); font-size: 12px; } +.brand-subtitle, .caption, .mono, .kv span:last-child, .chip, .card-title, .field strong, .code-block, .fire-list { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; } +.caption { font-size: 11px; } + +.nav { display: flex; flex-direction: column; gap: 3px; } +.nav button { + border: 0; + background: transparent; + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + padding: 9px 10px; + border-radius: 7px; + text-align: left; +} +.nav button.active { + background: oklch(56% 0.19 302 / 0.10); + color: var(--fg); + box-shadow: inset 3px 0 0 var(--accent); +} +.nav svg { width: 17px; height: 17px; stroke-width: 1.8; } + +.rail-card { + margin-top: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 12px; +} +.rail-card h3, .filter-panel h3 { + margin: 0 0 7px; + font-size: 12px; + text-transform: uppercase; + color: var(--muted); + font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-weight: 700; +} +.connection { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 12px; } + +.main { min-width: 0; display: flex; flex-direction: column; } +.topbar { + position: sticky; + top: 0; + z-index: 3; + display: grid; + grid-template-columns: 1fr auto; + gap: 14px; + align-items: center; + min-height: 60px; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: oklch(99% 0.002 250 / 0.92); + backdrop-filter: blur(14px); +} + +.scheduler-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; } +.scheduler-title { min-width: 210px; } +h1 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: 0; } +h2 { margin: 0; } +.kv { display: grid; gap: 2px; min-width: 118px; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; } +.kv span:first-child { color: var(--muted); font-size: 11px; } +.kv span:last-child { font-size: 12px; white-space: nowrap; } +.kv-button span:last-child { color: var(--warning); } + +.actions, .toolbar, .command-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.compact-actions { gap: 7px; } +.btn { + border: 1px solid var(--border); + border-radius: 7px; + padding: 8px 11px; + min-height: 36px; + background: var(--surface); + color: var(--fg); + display: inline-flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} +.btn.primary { background: var(--accent); border-color: var(--accent); color: white; } +.btn.compact { min-height: 32px; padding: 6px 10px; font-size: 12px; } +.btn.danger { color: var(--danger); border-color: oklch(58% 0.19 28 / 0.35); background: oklch(58% 0.19 28 / 0.06); } +.btn:disabled { opacity: 0.55; cursor: not-allowed; } + +.toast-overlay { + position: fixed; + top: 18px; + right: 18px; + z-index: 90; + width: min(460px, calc(100vw - 36px)); + padding: 16px 46px 16px 16px; + border: 1px solid oklch(72% 0.15 82 / 0.55); + border-left: 5px solid var(--warning); + background: oklch(99% 0.02 82); + color: var(--fg); + border-radius: 12px; + box-shadow: 0 22px 60px oklch(22% 0.02 240 / 0.20); +} +.toast-overlay.success { border-color: oklch(58% 0.16 145 / 0.36); border-left-color: var(--success); background: oklch(98% 0.02 145); } +.toast-overlay.error { border-color: oklch(58% 0.19 28 / 0.40); border-left-color: var(--danger); background: oklch(98% 0.02 28); } +.toast-kicker { font: 800 12px 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; letter-spacing: 0.05em; text-transform: uppercase; } +.toast-message { margin-top: 6px; line-height: 1.45; } +.toast-close { position: absolute; top: 10px; right: 10px; border: 0; background: transparent; color: var(--muted); } + +.content { padding: 18px 20px 22px; display: grid; gap: 16px; } +.page { display: none; } +.page.active { display: grid; gap: 16px; } +.page-kicker { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; margin-bottom: 2px; } +.page-kicker h2 { font-size: 19px; } +.page-kicker p { margin: 4px 0 0; max-width: 760px; color: var(--muted); font-size: 13px; } + +.dashboard-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; overflow: hidden; } +.card-header { min-height: 48px; padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.card-title { font-size: 12px; text-transform: uppercase; color: var(--muted); font-weight: 700; } +.card-body { padding: 14px; } +.span-3 { grid-column: span 3; } +.span-4 { grid-column: span 4; } +.span-5 { grid-column: span 5; } +.span-7 { grid-column: span 7; } +.span-8 { grid-column: span 8; } +.span-12 { grid-column: span 12; } + +.scheduler-command-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); gap: 14px; align-items: stretch; } +.command-panel { display: grid; gap: 12px; } +.metadata-grid, .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.summary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + +.metric { display: grid; gap: 7px; min-height: 112px; } +.metric-value { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 27px; font-weight: 720; font-variant-numeric: tabular-nums; } +.compact-metric { font-size: 22px; } +.metric-label { color: var(--muted); font-size: 12px; } +.metric-line { height: 5px; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: auto; } +.metric-line > span { display: block; height: 100%; background: var(--success); width: var(--w); } + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + border-radius: 999px; + border: 1px solid var(--border); + padding: 0 8px; + font-size: 11px; + font-weight: 650; + white-space: nowrap; + background: var(--surface); + color: var(--muted); +} +.chip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.chip.running, .chip.normal, .chip.success { color: var(--success); background: oklch(58% 0.16 145 / 0.08); border-color: oklch(58% 0.16 145 / 0.25); } +.chip.paused, .chip.warn { color: var(--warning); background: oklch(72% 0.15 82 / 0.12); border-color: oklch(72% 0.15 82 / 0.30); } +.chip.error, .chip.danger { color: var(--danger); background: oklch(58% 0.19 28 / 0.08); border-color: oklch(58% 0.19 28 / 0.25); } +.chip.blocked { color: var(--info); background: oklch(58% 0.18 255 / 0.08); border-color: oklch(58% 0.18 255 / 0.25); } +.chip.accent { color: var(--accent); background: oklch(56% 0.19 302 / 0.08); border-color: oklch(56% 0.19 302 / 0.25); } + +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; } +th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +th { color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; background: oklch(98% 0.004 250); } +.selectable:hover { background: oklch(56% 0.19 302 / 0.035); } +tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 var(--accent); } + +.split { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 420px; } +.object-mode .split { grid-template-columns: minmax(0, 1fr); } +.detail { background: oklch(99% 0.003 250); padding: 14px; display: flex; flex-direction: column; gap: 14px; } +.detail h2 { font-size: 17px; } +.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.drawer-close { border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted); padding: 6px 10px; font-size: 12px; } +.drawer-backdrop { position: fixed; inset: 0; z-index: 70; border: 0; background: oklch(22% 0.02 240 / 0.32); backdrop-filter: blur(2px); } +.drawer { + position: fixed; + top: 0; + right: 0; + z-index: 80; + width: min(460px, 100vw); + height: 100vh; + max-height: 100vh; + overflow: auto; + border-left: 1px solid var(--border); + box-shadow: -24px 0 70px oklch(22% 0.02 240 / 0.22); + transform: translateX(104%); + transition: transform 180ms ease; +} +.drawer.drawer-open { transform: translateX(0); } +.detail-drawer { width: min(430px, 100vw); } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; } +.tab { padding: 8px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; } +.tab.active { color: var(--fg); border-color: var(--accent); } +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.field { display: grid; gap: 4px; padding: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); min-width: 0; text-align: left; color: inherit; } +.field-button { cursor: pointer; } +.field label { color: var(--muted); font-size: 11px; } +.field strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.progress-card { display: grid; gap: 10px; } +.progress-line { height: 8px; border-radius: 999px; background: var(--border); overflow: hidden; } +.progress-line span { display: block; height: 100%; background: var(--success); } +.preview { display: grid; gap: 9px; padding: 13px; border-radius: var(--radius); background: oklch(56% 0.19 302 / 0.07); border: 1px solid oklch(56% 0.19 302 / 0.18); } +.preview h4 { margin: 0; font-size: 13px; } +.fire-list { display: grid; gap: 5px; font-size: 12px; } +.warning-box, .danger-zone { border: 1px solid oklch(58% 0.19 28 / 0.30); background: oklch(58% 0.19 28 / 0.07); border-radius: 7px; padding: 10px; display: grid; gap: 5px; } +.warning-box strong, .danger-zone strong { color: var(--danger); font-size: 12px; } +.danger-zone { border-radius: var(--radius); padding: 12px; } +.code-block { margin: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: oklch(97% 0.006 250); font-size: 12px; overflow: auto; white-space: pre-wrap; } + +.stream { display: grid; grid-template-columns: 1fr; gap: 0; max-height: 310px; overflow: auto; } +.tall-stream { max-height: 560px; } +.stream-row { display: grid; grid-template-columns: 92px 78px 112px 140px 1fr; gap: 10px; align-items: center; padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; } +.stream-row:first-child { background: oklch(98% 0.004 250); color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; position: sticky; top: 0; z-index: 1; } +.muted-row { color: var(--muted); } +.search { min-width: 220px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); height: 32px; padding: 0 12px; color: var(--muted); font-size: 12px; } + +.mini-chart { height: 154px; display: grid; grid-template-columns: repeat(18, 1fr); align-items: end; gap: 5px; border-bottom: 1px solid var(--border); padding-top: 18px; } +.bar { background: color-mix(in oklch, var(--success), white 38%); border-radius: 4px 4px 0 0; height: var(--h); min-height: 12px; } +.bar.warn { background: color-mix(in oklch, var(--warning), white 35%); } +.bar.error { background: color-mix(in oklch, var(--danger), white 35%); } +.top-space { margin-top: 14px; } + +.two-column { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; } +.filter-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 12px; display: grid; gap: 12px; align-content: start; } +.control { display: grid; gap: 6px; } +.control label { font-size: 12px; color: var(--muted); } +.input, .select, .textarea { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 6px; background: oklch(99% 0.002 250); min-height: 38px; padding: 8px 10px; color: var(--fg); outline: none; } +.textarea { min-height: 70px; resize: vertical; } +.compact-select { width: auto; min-width: 150px; min-height: 32px; padding-block: 5px; } +.check-row { display: flex; gap: 8px; align-items: center; color: var(--fg); } +.data-map-editor { display: grid; gap: 8px; } +.data-map-row { display: grid; grid-template-columns: minmax(90px, 1fr) 104px minmax(110px, 1fr) auto; gap: 8px; align-items: start; } + +.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; } +.calendar-cell { min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 6px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); } +.calendar-cell.excluded { color: var(--danger); background: oklch(58% 0.19 28 / 0.06); border-color: oklch(58% 0.19 28 / 0.25); } +.roadmap-copy { margin: 0 0 14px; color: var(--muted); } +.compact-roadmap { align-items: start; } +.node-list { display: grid; gap: 8px; } +.node-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); } + +.wizard { background: oklch(99% 0.002 250); display: flex; flex-direction: column; width: min(620px, 100vw); height: 100dvh; overflow: hidden; } +.wizard-header { min-height: 76px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.wizard-header h2 { font-size: 17px; } +.stepper { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; padding: 14px 18px; border-bottom: 1px solid var(--border); } +.step { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } +.step span:first-child { height: 4px; border-radius: 999px; background: var(--border); } +.step.done span:first-child, .step.active span:first-child { background: var(--accent); } +.step.active { color: var(--fg); font-weight: 650; } +.wizard-form { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; } +.wizard-scroll { flex: 1 1 auto; min-height: 0; padding: 16px 18px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 14px; } +.wizard-scroll > * { flex: 0 0 auto; } +.form-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; } +.form-card h3 { margin: 0; padding: 12px 13px; border-bottom: 1px solid var(--border); font-size: 12px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; color: var(--muted); text-transform: uppercase; } +.form-section { padding: 13px; display: grid; gap: 12px; } +.input-row { display: grid; grid-template-columns: 1fr 118px; gap: 8px; } +.radio-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.type-option { border: 1px solid var(--border); border-radius: 7px; padding: 10px; display: grid; gap: 4px; background: oklch(99% 0.002 250); text-align: left; color: inherit; min-width: 0; } +.type-option.active { border-color: oklch(56% 0.19 302 / 0.55); box-shadow: inset 0 0 0 1px oklch(56% 0.19 302 / 0.22); background: oklch(56% 0.19 302 / 0.06); } +.type-option strong { font-size: 12px; } +.wizard-footer { margin-top: auto; display: flex; justify-content: space-between; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); background: var(--surface); } + +@media (max-width: 1280px) { + .qm-app { grid-template-columns: 78px minmax(680px, 1fr); } + .qm-app.object-mode { grid-template-columns: 78px minmax(680px, 1fr); } + .brand-title, .brand-subtitle, .nav span, .rail-card { display: none; } + .rail { align-items: center; } + .nav button { justify-content: center; } + .brand { padding-inline: 0; border-bottom: 0; } +} + +@media (max-width: 960px) { + .qm-app, .qm-app.object-mode { grid-template-columns: 1fr; } + .rail { position: sticky; top: 0; z-index: 5; border-right: 0; border-bottom: 1px solid var(--border); flex-direction: row; align-items: center; overflow-x: auto; padding: 10px; } + .brand-title, .brand-subtitle, .nav span { display: block; } + .brand { border-bottom: 0; padding: 0; min-width: 190px; } + .nav { flex-direction: row; } + .topbar, .page-kicker, .scheduler-command-grid, .two-column, .split, .object-mode .split { grid-template-columns: 1fr; } + .drawer { width: min(420px, 100vw); } + .span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 12; } + .metadata-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 640px) { + .content { padding: 12px; } + .dashboard-grid { grid-template-columns: 1fr; } + .span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 1; } + .metadata-grid, .summary-grid, .field-grid, .radio-grid, .input-row, .data-map-row { grid-template-columns: 1fr; } + .stepper { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stream-row { grid-template-columns: 1fr; gap: 4px; } + .toast-overlay { top: 10px; right: 10px; width: calc(100vw - 20px); } + .page-kicker { align-items: stretch; } } diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.ts b/quartz-manager-frontend/src/app/views/manager/manager.component.ts index 8ad2ab3..1544e7e 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.ts +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.ts @@ -1,8 +1,88 @@ -import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; -import { SimpleTrigger } from '../../model/simple-trigger.model'; -import { TriggerKey } from '../../model/triggerKey.model'; -import { SimpleTriggerConfigComponent } from '../../components/simple-trigger-config'; -import { TriggerListComponent } from '../../components'; +import {Component, NgZone, OnDestroy, OnInit} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {map} from 'rxjs/operators'; + +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 {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'; +import TriggerFiredBundle from '../../model/trigger-fired-bundle.model'; + +type ConsolePage = 'dashboard' | 'jobs' | 'triggers' | 'calendars' | 'executions' | 'events' | 'scheduler'; +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; + severity: string; + type: string; + source: string; + message: string; +} + +interface TriggerDraft { + triggerName: string; + group: string; + triggerType: TriggerType; + jobTargetType: JobTargetType; + storedJobKey: string; + jobClass: string; + startDate: string; + endDate: string; + repeatIntervalAmount: number; + 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; + jobClass: string; + description: string; + durable: boolean; + requestsRecovery: boolean; + jobDataMapEntries: JobDataMapEntry[]; +} + +interface JobDataMapEntry { + key: string; + type: JobDataMapType; + value: string; +} @Component({ selector: 'manager', @@ -10,63 +90,1420 @@ import { TriggerListComponent } from '../../components'; styleUrls: ['./manager.component.scss'], standalone: false }) -export class ManagerComponent implements OnInit, AfterViewInit { - @ViewChild(SimpleTriggerConfigComponent) - private triggerConfigComponent!: SimpleTriggerConfigComponent; +export class ManagerComponent implements OnInit, OnDestroy { - @ViewChild(TriggerListComponent) - private triggerListComponent: TriggerListComponent; - - newTriggerFormOpened = false; + readonly roadmapMessage = 'This feature is not supported by the current backend yet. ' + + 'It is tracked in the Quartz Manager roadmap and will come with a future release.'; + activePage: ConsolePage = 'dashboard'; + scheduler: Scheduler; + schedulerLoading = false; + triggerKeys: TriggerKey[] = []; + triggerDetailsByName: {[triggerName: string]: Trigger} = {}; selectedTriggerKey: TriggerKey; + selectedTrigger: Trigger; + selectedJobClass: string; + selectedScheduledJob: ScheduledJob; + jobs: string[] = []; + scheduledJobs: ScheduledJob[] = []; + logs: ConsoleLogRecord[] = []; + progress: TriggerFiredBundle; + roadmapNotice: string; + operationNotice: string; + operationError: string; + triggerLoading = false; + wizardMode: WizardMode = 'create'; + wizardOpen = false; + jobWizardOpen = false; + detailDrawerOpen = false; + wizardSubmitting = false; + jobWizardSubmitting = false; + 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; - monitoredTriggerKey: TriggerKey; + private readonly roadmapPages = new Set(['executions']); + private readonly subscriptions: Subscription[] = []; + private logsSubscription: Subscription; + private progressSubscription: Subscription; + private noticeTimer: ReturnType; - private pendingNewTriggerRequest = false; + constructor( + private schedulerService: SchedulerService, + private triggerService: TriggerService, + private calendarService: CalendarService, + private jobService: JobService, + private logsRxWebsocketService: LogsRxWebsocketService, + private progressRxWebsocketService: ProgressRxWebsocketService, + private ngZone: NgZone + ) {} - constructor() {} + ngOnInit() { + this.refreshScheduler(); + this.fetchTriggers(); + this.fetchJobs(); + this.fetchScheduledJobs(); + this.fetchCalendars(); + } - ngOnInit() {} - - ngAfterViewInit() { - if (this.pendingNewTriggerRequest) { - queueMicrotask(() => this.openNewTriggerForm()); + ngOnDestroy() { + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + this.unsubscribeFromTriggerTopics(); + if (this.noticeTimer) { + clearTimeout(this.noticeTimer); } } - onNewTriggerRequested() { - this.selectedTriggerKey = null; - this.monitoredTriggerKey = null; - if (this.triggerConfigComponent) { - this.openNewTriggerForm(); + selectPage(page: ConsolePage) { + this.activePage = page; + this.closeDrawers(); + if (this.roadmapPages.has(page)) { + this.showRoadmapNotice(`${this.getPageTitle(page)} is on the Quartz Manager roadmap`); + } + } + + jumpToScheduler() { + this.selectPage('scheduler'); + } + + handleConsoleClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const roadmapElement = target.closest('[data-roadmap]') as HTMLElement; + if (!roadmapElement) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.showRoadmapNotice(roadmapElement.getAttribute('data-roadmap')); + } + + showRoadmapNotice(feature?: string) { + this.operationError = null; + this.operationNotice = null; + this.roadmapNotice = feature ? `${feature}. ${this.roadmapMessage}` : this.roadmapMessage; + if (this.noticeTimer) { + clearTimeout(this.noticeTimer); + } + this.noticeTimer = setTimeout(() => this.roadmapNotice = null, 8000); + } + + dismissNotice() { + this.roadmapNotice = null; + this.operationNotice = null; + this.operationError = null; + this.wizardError = null; + } + + openDetailDrawer() { + this.detailDrawerOpen = true; + this.wizardOpen = false; + } + + closeDetailDrawer() { + this.detailDrawerOpen = false; + } + + closeWizardDrawer() { + this.wizardOpen = false; + } + + closeJobWizardDrawer() { + this.jobWizardOpen = false; + } + + closeCalendarWizardDrawer() { + this.calendarWizardOpen = false; + } + + closeDrawers() { + this.detailDrawerOpen = false; + this.wizardOpen = false; + this.jobWizardOpen = false; + this.calendarWizardOpen = false; + } + + refreshScheduler() { + this.schedulerLoading = true; + const subscription = this.schedulerService.getScheduler().subscribe({ + next: scheduler => { + this.scheduler = scheduler; + this.schedulerLoading = false; + }, + error: () => { + this.schedulerLoading = false; + this.operationError = 'Unable to load scheduler metadata.'; + } + }); + this.subscriptions.push(subscription); + } + + startScheduler() { + const subscription = this.schedulerService.startScheduler().subscribe({ + next: () => this.setSchedulerStatus('RUNNING', 'Scheduler started.'), + error: () => this.operationError = 'Unable to start the scheduler.' + }); + this.subscriptions.push(subscription); + } + + standbyScheduler() { + const subscription = this.schedulerService.standbyScheduler().subscribe({ + next: () => this.setSchedulerStatus('PAUSED', 'Scheduler moved to standby.'), + error: () => this.operationError = 'Unable to move the scheduler to standby.' + }); + this.subscriptions.push(subscription); + } + + resumeScheduler() { + const subscription = this.schedulerService.resumeScheduler().subscribe({ + next: () => this.setSchedulerStatus('RUNNING', 'Scheduler resumed.'), + error: () => this.operationError = 'Unable to resume the scheduler.' + }); + this.subscriptions.push(subscription); + } + + shutdownScheduler() { + if (!window.confirm('Shutdown the scheduler instance?')) { + return; + } + const subscription = this.schedulerService.shutdownScheduler().subscribe({ + next: () => this.setSchedulerStatus('STOPPED', 'Scheduler shut down.'), + error: () => this.operationError = 'Unable to shut down the scheduler.' + }); + this.subscriptions.push(subscription); + } + + toggleStandby() { + if (this.scheduler?.status === 'PAUSED') { + this.resumeScheduler(); + return; + } + this.standbyScheduler(); + } + + fetchTriggers() { + const subscription = this.triggerService.fetchTriggers().subscribe({ + next: triggerKeys => { + this.triggerKeys = triggerKeys || []; + this.fetchTriggerDetails(this.triggerKeys); + if (this.triggerKeys.length > 0) { + this.selectTrigger(this.selectedTriggerKey || this.triggerKeys[0], false); + } else { + this.resetWizard(); + } + }, + error: () => this.operationError = 'Unable to load triggers.' + }); + this.subscriptions.push(subscription); + } + + fetchJobs() { + const subscription = this.jobService.fetchJobs().subscribe({ + next: jobs => { + this.jobs = jobs || []; + this.selectedJobClass = this.jobs[0]; + if (!this.triggerDraft.jobClass && this.jobs.length > 0) { + this.triggerDraft.jobClass = this.jobs[0]; + } + }, + error: () => this.operationError = 'Unable to load eligible job classes.' + }); + this.subscriptions.push(subscription); + } + + fetchScheduledJobs() { + const subscription = this.jobService.fetchScheduledJobs().subscribe({ + next: scheduledJobs => { + this.scheduledJobs = scheduledJobs || []; + this.selectedScheduledJob = this.scheduledJobs[0]; + }, + error: () => this.operationError = 'Unable to load scheduled jobs.' + }); + 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.triggerService.getTrigger(triggerKey).subscribe({ + next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger, + error: () => { + this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = null; + } + }); + this.subscriptions.push(subscription); + }); + } + + selectTrigger(triggerKey: TriggerKey, openDrawer = true) { + if (!triggerKey?.name) { + return; + } + this.selectedTriggerKey = {...triggerKey}; + if (openDrawer) { + this.openDetailDrawer(); + } + this.triggerLoading = true; + this.selectedTrigger = this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] || null; + this.subscribeToTriggerTopics(this.selectedTriggerKey); + const subscription = this.triggerService.getTrigger(triggerKey).subscribe({ + next: trigger => { + this.selectedTrigger = trigger; + this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger; + this.triggerLoading = false; + }, + error: () => { + this.triggerLoading = false; + this.operationError = 'Unable to load trigger details.'; + } + }); + this.subscriptions.push(subscription); + } + + selectJob(jobClass: string) { + this.selectedJobClass = jobClass; + this.openDetailDrawer(); + } + + selectScheduledJob(job: ScheduledJob) { + this.selectedScheduledJob = job; + this.selectedJobClass = job?.jobClassName || this.selectedJobClass; + this.openDetailDrawer(); + } + + openCreateJobWizard() { + this.jobWizardMode = 'create'; + this.jobDraft = this.buildEmptyJobDraft(); + this.jobWizardError = null; + this.jobWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('jobs'); + this.jobWizardOpen = true; + } + + openEditJobWizard() { + if (!this.selectedScheduledJob?.jobKeyDTO) { + this.showRoadmapNotice('Editing requires a stored job returned by the backend'); + return; + } + this.jobWizardMode = 'edit'; + this.jobWizardError = null; + this.jobDraft = { + name: this.selectedScheduledJob.jobKeyDTO.name, + group: this.selectedScheduledJob.jobKeyDTO.group || 'DEFAULT', + jobClass: this.selectedScheduledJob.jobClassName, + description: this.selectedScheduledJob.description || '', + durable: this.selectedScheduledJob.durable, + requestsRecovery: this.selectedScheduledJob.requestsRecovery, + jobDataMapEntries: this.toJobDataMapEntries(this.selectedScheduledJob.jobDataMap) + }; + this.jobWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('jobs'); + this.jobWizardOpen = true; + } + + submitJobWizard() { + this.jobWizardError = null; + if (!this.canSubmitJob()) { + this.jobWizardError = 'Job name, group, and class are required.'; + return; + } + + let jobDataMap: {[key: string]: unknown}; + try { + jobDataMap = this.serializeJobDataMap(this.jobDraft.jobDataMapEntries); + } catch (err) { + this.jobWizardError = this.getErrorMessage(err, 'JobDataMap contains invalid values.'); + return; + } + + const command = new ScheduledJobCommand(); + command.jobClass = this.jobDraft.jobClass; + command.description = this.jobDraft.description; + command.durable = this.jobDraft.durable; + command.requestsRecovery = this.jobDraft.requestsRecovery; + command.jobDataMap = jobDataMap; + + this.jobWizardSubmitting = true; + const group = this.jobDraft.group || 'DEFAULT'; + const name = this.jobDraft.name.trim(); + const request = this.jobWizardMode === 'edit' + ? this.jobService.updateJob(group, name, command) + : this.jobService.createJob(group, name, command); + + const subscription = request.subscribe({ + next: job => { + this.jobWizardSubmitting = false; + this.upsertScheduledJob(job); + this.selectedScheduledJob = job; + this.selectedJobClass = job.jobClassName; + this.jobWizardOpen = false; + this.detailDrawerOpen = true; + this.operationNotice = this.jobWizardMode === 'edit' ? 'Stored job updated.' : 'Stored job created.'; + }, + error: () => { + this.jobWizardSubmitting = false; + this.jobWizardError = 'Unable to save the stored job.'; + } + }); + this.subscriptions.push(subscription); + } + + triggerSelectedJobNow() { + if (!this.selectedScheduledJob) { + this.showRoadmapNotice('Trigger-now requires a scheduled job key returned by the backend'); + return; + } + const subscription = this.jobService.triggerJob(this.selectedScheduledJob).subscribe({ + next: () => this.operationNotice = 'Job triggered.', + error: () => this.operationError = 'Unable to trigger the selected job.' + }); + this.subscriptions.push(subscription); + } + + deleteSelectedJob() { + if (!this.selectedScheduledJob) { + this.showRoadmapNotice('Delete requires a scheduled job key returned by the backend'); + return; + } + if (!window.confirm(`Delete job ${this.selectedScheduledJob.jobKeyDTO.group}.${this.selectedScheduledJob.jobKeyDTO.name}?`)) { + return; + } + const subscription = this.jobService.deleteJob(this.selectedScheduledJob).subscribe({ + next: () => { + this.operationNotice = 'Job deleted.'; + this.scheduledJobs = this.scheduledJobs.filter(job => !this.sameJob(job, this.selectedScheduledJob)); + this.selectedScheduledJob = this.scheduledJobs[0]; + }, + error: () => this.operationError = 'Unable to delete the selected job.' + }); + 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; + } + const subscription = this.triggerService.pauseTrigger(this.selectedTriggerKey).subscribe({ + next: () => this.setSelectedTriggerState('PAUSED', 'Trigger paused.'), + error: () => this.operationError = 'Unable to pause the selected trigger.' + }); + this.subscriptions.push(subscription); + } + + resumeSelectedTrigger() { + if (!this.selectedTriggerKey) { + return; + } + const subscription = this.triggerService.resumeTrigger(this.selectedTriggerKey).subscribe({ + next: () => this.setSelectedTriggerState('NORMAL', 'Trigger resumed.'), + error: () => this.operationError = 'Unable to resume the selected trigger.' + }); + this.subscriptions.push(subscription); + } + + unscheduleSelectedTrigger() { + if (!this.selectedTriggerKey) { + return; + } + if (!window.confirm(`Unschedule trigger ${this.getSelectedTriggerGroup()}.${this.selectedTriggerKey.name}?`)) { + return; + } + const triggerKey = {...this.selectedTriggerKey}; + const subscription = this.triggerService.unscheduleTrigger(triggerKey).subscribe({ + next: () => { + this.operationNotice = 'Trigger unscheduled.'; + this.triggerKeys = this.triggerKeys.filter(currentTriggerKey => !this.sameTriggerKey(currentTriggerKey, triggerKey)); + delete this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)]; + this.selectedTriggerKey = this.triggerKeys[0]; + this.selectedTrigger = this.selectedTriggerKey + ? this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)] + : null; + }, + error: () => this.operationError = 'Unable to unschedule the selected trigger.' + }); + this.subscriptions.push(subscription); + } + + openCreateTriggerWizard() { + this.resetWizard(); + this.wizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('triggers'); + this.wizardOpen = true; + } + + openRescheduleWizard(triggerKey?: TriggerKey) { + if (triggerKey) { + this.selectTrigger(triggerKey, false); + } + if (!this.selectedTrigger && !this.selectedTriggerKey) { + this.showRoadmapNotice('Reschedule requires a trigger loaded from the backend'); + return; + } + + const trigger = this.selectedTrigger || this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)]; + const repeatInterval = this.splitRepeatInterval(trigger?.repeatInterval || 60000); + this.wizardMode = 'edit'; + this.wizardOpen = true; + this.detailDrawerOpen = false; + 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(), + 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, 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('triggers'); + this.wizardOpen = true; + } + + resetWizard() { + this.wizardMode = 'create'; + this.wizardError = null; + this.triggerDraft = this.buildEmptyDraft(); + } + + submitTriggerWizard() { + this.wizardError = null; + if (!this.canSubmitTrigger()) { + this.wizardError = 'Trigger name, target job, type, and schedule fields are required.'; + return; + } + + 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.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) { + this.wizardError = this.getErrorMessage(err, 'JobDataMap contains invalid values.'); + return; + } + + this.wizardSubmitting = true; + const group = this.triggerDraft.group || 'DEFAULT'; + const name = this.triggerDraft.triggerName.trim(); + const request = this.wizardMode === 'edit' + ? 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; + this.upsertTriggerKey(trigger.triggerKeyDTO); + this.selectTrigger(trigger.triggerKeyDTO); + this.wizardOpen = false; + this.detailDrawerOpen = true; + 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 trigger.'; + } + }); + this.subscriptions.push(subscription); + } + + getSchedulerStatusClass(): string { + switch (this.scheduler?.status) { + case 'RUNNING': return 'running'; + case 'PAUSED': return 'paused'; + case 'STOPPED': return 'error'; + default: return ''; + } + } + + getTriggerDetail(triggerKey: TriggerKey): Trigger { + return triggerKey?.name ? this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] : null; + } + + getTriggerGroup(triggerKey: TriggerKey): string { + return triggerKey?.group || 'DEFAULT'; + } + + getTriggerType(triggerKey: TriggerKey): string { + return this.getTriggerDetail(triggerKey)?.type || 'Trigger'; + } + + getTriggerState(triggerKey: TriggerKey): string { + const trigger = this.getTriggerDetail(triggerKey); + if (!trigger) { + return 'UNKNOWN'; + } + if (trigger.state) { + return trigger.state; + } + if (!trigger.mayFireAgain) { + return 'COMPLETE'; + } + if (this.scheduler?.status === 'PAUSED') { + return 'PAUSED'; + } + return 'NORMAL'; + } + + getTriggerStateClass(triggerKey: TriggerKey): string { + const state = this.getTriggerState(triggerKey); + if (state === 'NORMAL') { + return 'normal'; + } + if (state === 'PAUSED') { + return 'paused'; + } + if (state === 'UNKNOWN') { + return 'warn'; + } + return ''; + } + + getTriggerJobName(triggerKey: TriggerKey): string { + const trigger = this.getTriggerDetail(triggerKey); + const jobGroup = trigger?.jobKeyDTO?.group ? `${trigger.jobKeyDTO.group}.` : ''; + return trigger?.jobKeyDTO?.name + ? `${jobGroup}${trigger.jobKeyDTO.name}` + : this.shortClassName(trigger?.jobDetailDTO?.jobClassName) || 'Roadmap'; + } + + getTriggerNextFireLabel(triggerKey: TriggerKey): string { + const trigger = this.getTriggerDetail(triggerKey); + return this.formatDateTime(trigger?.nextFireTime) || 'not available'; + } + + getTriggerPreviousFireLabel(triggerKey: TriggerKey): string { + const trigger = this.getTriggerDetail(triggerKey); + return this.formatDateTime(trigger?.['previousFireTime']) || 'not available'; + } + + getSelectedTriggerGroup(): string { + return this.getTriggerGroup(this.selectedTriggerKey); + } + + getSelectedTriggerState(): string { + return this.selectedTriggerKey ? this.getTriggerState(this.selectedTriggerKey) : 'NONE'; + } + + getSelectedTriggerStateClass(): string { + return this.selectedTriggerKey ? this.getTriggerStateClass(this.selectedTriggerKey) : ''; + } + + getSelectedJobName(): string { + return this.selectedTriggerKey ? this.getTriggerJobName(this.selectedTriggerKey) : '-'; + } + + getSelectedTriggerRepeatSummary(): string { + if (!this.selectedTrigger) { + return 'not loaded'; + } + const repeatInterval = this.selectedTrigger.repeatInterval; + if (!repeatInterval) { + return 'Run once'; + } + return `Every ${this.formatDuration(repeatInterval)}`; + } + + getProgressPercentage(): number { + return this.progress?.percentage >= 0 ? this.progress.percentage : 0; + } + + getProgressLabel(): string { + if (!this.progress || this.progress.percentage < 0) { + return 'Waiting for progress events'; + } + return `${this.progress.percentage}% / ${this.progress.timesTriggered || 0} fired`; + } + + getExecutionLoadValue(): string { + return this.logs.length > 0 ? `${this.logs.length}` : '0'; + } + + getJobClassRows(): string[] { + return this.jobs.length > 0 ? this.jobs : ['No eligible Quartz Manager job classes returned by the backend']; + } + + getSelectedJobShortName(): string { + return this.shortClassName(this.selectedScheduledJob?.jobClassName || this.selectedJobClass) || '-'; + } + + getSelectedJobKeyLabel(): string { + if (!this.selectedScheduledJob?.jobKeyDTO) { + return '-'; + } + return `${this.selectedScheduledJob.jobKeyDTO.group}.${this.selectedScheduledJob.jobKeyDTO.name}`; + } + + getScheduledJobRows(): ScheduledJob[] { + const search = this.jobSearch?.trim().toLowerCase(); + return (this.scheduledJobs || []).filter(job => { + const groupMatches = this.jobGroupFilter === 'ALL' || job.jobKeyDTO?.group === this.jobGroupFilter; + const searchable = `${job.jobKeyDTO?.group}.${job.jobKeyDTO?.name} ${job.jobClassName}`.toLowerCase(); + return groupMatches && (!search || searchable.includes(search)); + }); + } + + getTriggerRows(): TriggerKey[] { + const search = this.triggerSearch?.trim().toLowerCase(); + return (this.triggerKeys || []).filter(triggerKey => { + const group = this.getTriggerGroup(triggerKey); + const groupMatches = this.triggerGroupFilter === 'ALL' || group === this.triggerGroupFilter; + const searchable = `${group}.${triggerKey.name} ${this.getTriggerJobName(triggerKey)}`.toLowerCase(); + return groupMatches && (!search || searchable.includes(search)); + }); + } + + getJobGroups(): string[] { + return this.getUniqueGroups(this.scheduledJobs.map(job => job.jobKeyDTO?.group)); + } + + getTriggerGroups(): string[] { + return this.getUniqueGroups(this.triggerKeys.map(triggerKey => this.getTriggerGroup(triggerKey))); + } + + getStoredJobOptions(): {label: string; value: string}[] { + return this.scheduledJobs.map(job => ({ + label: `${job.jobKeyDTO.group}.${job.jobKeyDTO.name}`, + value: this.getJobOptionValue(job.jobKeyDTO.group, job.jobKeyDTO.name) + })); + } + + getSelectedJobDataMapPreview(): string { + return this.formatJson(this.selectedScheduledJob?.jobDataMap || {}); + } + + getSelectedTriggerDataMapPreview(): string { + return this.formatJson(this.selectedTrigger?.jobDataMap || {}); + } + + getJobDraftDataMapPreview(): string { + try { + return this.formatJson(this.serializeJobDataMap(this.jobDraft.jobDataMapEntries)); + } catch (err) { + return this.getErrorMessage(err, 'Invalid JobDataMap'); + } + } + + getTriggerDraftDataMapPreview(): string { + try { + return this.formatJson(this.serializeJobDataMap(this.triggerDraft.jobDataMapEntries)); + } catch (err) { + return this.getErrorMessage(err, 'Invalid JobDataMap'); + } + } + + getWizardTitle(): string { + return this.wizardMode === 'edit' ? 'Reschedule Trigger' : 'Create Trigger'; + } + + getWizardCta(): string { + 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() + && hasTarget + && 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: ''}); + } + + removeJobDataMapEntry(entries: JobDataMapEntry[], index: number) { + entries.splice(index, 1); + } + + 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) { + return [this.formatDateTime(start) || 'Next fire unavailable']; + } + + return Array.from({length: 5}).map((_, index) => { + const fireTime = new Date(start.getTime() + repeatInterval * index); + return `${index + 1}. ${this.formatDateTime(fireTime)}`; + }); + } + + 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 { - this.pendingNewTriggerRequest = true; + 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; + } + const parts = className.split('.'); + return parts[parts.length - 1]; + } + + formatDateTime(value: Date | string): string { + if (!value) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toLocaleString(); + } + + formatDuration(milliseconds: number): string { + if (!milliseconds) { + return '0 ms'; + } + if (milliseconds % 3600000 === 0) { + return `${milliseconds / 3600000} h`; + } + if (milliseconds % 60000 === 0) { + return `${milliseconds / 60000} min`; + } + if (milliseconds % 1000 === 0) { + return `${milliseconds / 1000} sec`; + } + return `${milliseconds} ms`; + } + + private setSchedulerStatus(status: string, notice: string) { + if (!this.scheduler) { + this.scheduler = new Scheduler(null, null, status, []); + } + this.scheduler.status = status; + this.operationNotice = notice; + this.roadmapNotice = null; + } + + private setSelectedTriggerState(state: string, notice: string) { + if (this.selectedTrigger) { + this.selectedTrigger.state = state; + } + if (this.selectedTriggerKey?.name && this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)]) { + this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)].state = state; + } + this.operationNotice = notice; + } + + private subscribeToTriggerTopics(triggerKey: TriggerKey) { + this.unsubscribeFromTriggerTopics(); + this.logs = []; + this.progress = null; + + this.logsSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`) + .pipe(map((msg: any) => JSON.parse(msg.body))) + .subscribe(logRecord => this.ngZone.run(() => this.addLogRecord(logRecord)), err => console.log(err)); + + this.progressSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`) + .pipe(map((msg: any) => JSON.parse(msg.body))) + .subscribe(progress => this.ngZone.run(() => this.progress = progress), err => console.log(err)); + } + + private unsubscribeFromTriggerTopics() { + if (this.logsSubscription) { + this.logsSubscription.unsubscribe(); + this.logsSubscription = null; + } + if (this.progressSubscription) { + this.progressSubscription.unsubscribe(); + this.progressSubscription = null; } } - private openNewTriggerForm() { - this.newTriggerFormOpened = true; - this.pendingNewTriggerRequest = false; - this.triggerConfigComponent.openNewTriggerForm(); + private addLogRecord(logRecord: any) { + const selectedSource = this.selectedTriggerKey?.group || this.selectedTriggerKey?.name || 'trigger'; + this.logs = [{ + time: logRecord.date, + severity: logRecord.type || 'INFO', + type: 'JOB_LOG', + source: logRecord.threadName || selectedSource, + message: logRecord.message || JSON.stringify(logRecord) + }, ...this.logs].slice(0, 50); } - onNewTriggerCreated(newTrigger: SimpleTrigger) { - this.triggerListComponent.onNewTrigger(newTrigger); - this.newTriggerFormOpened = false; + private upsertTriggerKey(triggerKey: TriggerKey) { + if (!this.triggerKeys.some(currentTriggerKey => this.sameTriggerKey(currentTriggerKey, triggerKey))) { + this.triggerKeys = [triggerKey, ...this.triggerKeys]; + } } - setSelectedTrigger(triggerKey: TriggerKey) { - this.selectedTriggerKey = triggerKey; - this.monitoredTriggerKey = triggerKey; - this.newTriggerFormOpened = false; + private upsertScheduledJob(job: ScheduledJob) { + const otherJobs = this.scheduledJobs.filter(currentJob => !this.sameJob(currentJob, job)); + this.scheduledJobs = [job, ...otherJobs]; } - monitorTrigger(triggerKey: TriggerKey) { - this.monitoredTriggerKey = triggerKey; + private upsertCalendar(calendar: QuartzCalendar) { + const otherCalendars = this.calendars.filter(currentCalendar => currentCalendar.name !== calendar.name); + this.calendars = [calendar, ...otherCalendars]; } - setNewTriggerFormOpened(opened: boolean) { - this.newTriggerFormOpened = opened; + private sameTriggerKey(first: TriggerKey, second: TriggerKey): boolean { + return first?.name === second?.name && this.getTriggerGroup(first) === this.getTriggerGroup(second); + } + + private getTriggerDetailKey(triggerKey: TriggerKey): string { + return `${this.getTriggerGroup(triggerKey)}.${triggerKey.name}`; + } + + private sameJob(first: ScheduledJob, second: ScheduledJob): boolean { + return first?.jobKeyDTO?.name === second?.jobKeyDTO?.name && first?.jobKeyDTO?.group === second?.jobKeyDTO?.group; + } + + private buildEmptyDraft(): TriggerDraft { + return { + triggerName: '', + group: 'DEFAULT', + triggerType: 'SIMPLE', + jobTargetType: this.scheduledJobs.length > 0 ? 'stored' : 'class', + storedJobKey: this.getDefaultStoredJobKey(), + jobClass: this.jobs[0] || '', + startDate: this.toDatetimeLocalValue(new Date()), + endDate: '', + repeatIntervalAmount: 1, + 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: '', + group: 'DEFAULT', + jobClass: this.jobs[0] || '', + description: '', + durable: true, + requestsRecovery: false, + jobDataMapEntries: [] + }; + } + + private getDefaultStoredJobKey(): string { + const job = this.scheduledJobs[0]; + return job?.jobKeyDTO ? this.getJobOptionValue(job.jobKeyDTO.group, job.jobKeyDTO.name) : ''; + } + + private getJobOptionValue(group: string, name: string): string { + return `${group || 'DEFAULT'}::${name}`; + } + + private parseJobOptionValue(value: string): {group: string; name: string} { + const [group, name] = (value || '').split('::'); + 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((first, second) => first.localeCompare(second)); + } + + private toJobDataMapEntries(jobDataMap: {[key: string]: unknown}): JobDataMapEntry[] { + return Object.entries(jobDataMap || {}).map(([key, value]) => ({ + key, + type: this.getJobDataMapType(value), + value: this.getJobDataMapEntryValue(value) + })); + } + + private getJobDataMapType(value: unknown): JobDataMapType { + if (value === null) { + return 'null'; + } + if (typeof value === 'number') { + return 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (typeof value === 'object') { + return 'json'; + } + return 'string'; + } + + private getJobDataMapEntryValue(value: unknown): string { + if (value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return `${value ?? ''}`; + } + + private serializeJobDataMap(entries: JobDataMapEntry[]): {[key: string]: unknown} { + return (entries || []).reduce((dataMap, entry) => { + const key = entry.key?.trim(); + if (!key) { + throw new Error('JobDataMap keys cannot be blank.'); + } + if (Object.prototype.hasOwnProperty.call(dataMap, key)) { + throw new Error(`JobDataMap key "${key}" is duplicated.`); + } + dataMap[key] = this.serializeJobDataMapValue(entry); + return dataMap; + }, {} as {[key: string]: unknown}); + } + + private serializeJobDataMapValue(entry: JobDataMapEntry): unknown { + switch (entry.type) { + case 'number': { + const value = Number(entry.value); + if (Number.isNaN(value)) { + throw new Error(`JobDataMap key "${entry.key}" must be a number.`); + } + return value; + } + case 'boolean': + if (entry.value !== 'true' && entry.value !== 'false') { + throw new Error(`JobDataMap key "${entry.key}" must be true or false.`); + } + return entry.value === 'true'; + case 'json': + try { + return JSON.parse(entry.value || 'null'); + } catch (err) { + throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON: ${this.getErrorMessage(err, 'Invalid JSON')}`); + } + case 'null': + return null; + default: + return entry.value || ''; + } + } + + private formatJson(value: unknown): string { + return JSON.stringify(value || {}, null, 2); + } + + private getErrorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; + } + + private getRepeatIntervalMs(): number { + const amount = Number(this.triggerDraft.repeatIntervalAmount || 0); + switch (this.triggerDraft.repeatIntervalUnit) { + case 'seconds': return amount * 1000; + case 'minutes': return amount * 60000; + case 'hours': return amount * 3600000; + case 'days': return amount * 86400000; + default: return amount; + } + } + + 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'}; + } + if (milliseconds && milliseconds % 3600000 === 0) { + return {amount: milliseconds / 3600000, unit: 'hours'}; + } + if (milliseconds && milliseconds % 60000 === 0) { + return {amount: milliseconds / 60000, unit: 'minutes'}; + } + if (milliseconds && milliseconds % 1000 === 0) { + return {amount: milliseconds / 1000, unit: 'seconds'}; + } + return {amount: milliseconds || 1, unit: 'milliseconds'}; + } + + private fromDatetimeLocalValue(value: string): Date { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + private toDatetimeLocalValue(value: Date | string): string { + if (!value) { + return ''; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return offsetDate.toISOString().slice(0, 16); + } + + 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'; + case 3: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT'; + case 4: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT'; + case 5: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT'; + default: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT'; + } + } + + 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'; + case 'executions': return 'Execution history and currently executing jobs'; + default: return page; + } } } diff --git a/quartz-manager-frontend/tsconfig.spec.json b/quartz-manager-frontend/tsconfig.spec.json index a9b5332..1ac1cba 100644 --- a/quartz-manager-frontend/tsconfig.spec.json +++ b/quartz-manager-frontend/tsconfig.spec.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "../out-tsc/spec", "baseUrl": "", + "importHelpers": false, "types": [ "jasmine", "node" diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java new file mode 100644 index 0000000..6e606c6 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java @@ -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 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); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java index ad47df2..1343abb 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java @@ -8,26 +8,40 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts; import it.fabioformosa.quartzmanager.api.common.config.QuartzManagerPaths; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; +import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; import it.fabioformosa.quartzmanager.api.services.JobService; +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.util.List; import java.util.stream.Collectors; -@RequestMapping(JobController.JOB_CONTROLLER_BASE_URL) +import jakarta.validation.Valid; + +@RequestMapping(QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH) @SecurityRequirement(name = OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA) @RestController public class JobController { public static final String JOB_CONTROLLER_BASE_URL = QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH + "/jobs"; + public static final String JOB_CLASSES_CONTROLLER_BASE_URL = QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH + "/job-classes"; private final JobService jobService; public JobController(JobService jobService) { this.jobService = jobService; } - @GetMapping + @GetMapping("/job-classes") @Operation(summary = "Get the list of job classes eligible for Quartz-Manager") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Return a list of qualified java classes", @@ -38,4 +52,48 @@ public class JobController { return jobService.getJobClasses().stream().map(Class::getName).collect(Collectors.toList()); } + @GetMapping("/jobs") + @Operation(summary = "Get the list of scheduled jobs") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Return a list of scheduled jobs", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = ScheduledJobDTO.class))}) + }) + public List listScheduledJobs() throws SchedulerException { + return jobService.fetchScheduledJobs(); + } + + @GetMapping("/jobs/{group}/{name}") + @Operation(summary = "Get a scheduled job") + public ScheduledJobDTO getScheduledJob(@PathVariable String group, @PathVariable String name) throws SchedulerException, JobNotFoundException { + return jobService.getScheduledJob(group, name); + } + + @PostMapping("/jobs/{group}/{name}") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a stored job") + public ScheduledJobDTO createJob(@PathVariable String group, @PathVariable String name, @Valid @RequestBody ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException { + return jobService.createJob(group, name, scheduledJobInputDTO); + } + + @PutMapping("/jobs/{group}/{name}") + @Operation(summary = "Update a stored job") + public ScheduledJobDTO updateJob(@PathVariable String group, @PathVariable String name, @Valid @RequestBody ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException, JobNotFoundException { + return jobService.updateJob(group, name, scheduledJobInputDTO); + } + + @PostMapping("/jobs/{group}/{name}/trigger") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Trigger a job now") + public void triggerJob(@PathVariable String group, @PathVariable String name) throws SchedulerException, JobNotFoundException { + jobService.triggerJob(group, name); + } + + @DeleteMapping("/jobs/{group}/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a job") + public void deleteJob(@PathVariable String group, @PathVariable String name) throws SchedulerException, JobNotFoundException { + jobService.deleteJob(group, name); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerController.java index bce342c..1bcfef1 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerController.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.quartz.SchedulerException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -51,21 +52,21 @@ public class SchedulerController { return schedulerService.getScheduler(); } - @GetMapping("/pause") - @Operation(summary = "Get paused the scheduler") + @PostMapping("/standby") + @Operation(summary = "Put the scheduler in standby mode") @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "Got paused successfully") + @ApiResponse(responseCode = "204", description = "Scheduler moved to standby successfully") }) @ResponseStatus(HttpStatus.NO_CONTENT) - public void pause() throws SchedulerException { - log.info("SCHEDULER - PAUSE COMMAND"); + public void standby() throws SchedulerException { + log.info("SCHEDULER - STANDBY COMMAND"); schedulerService.standby(); } - @GetMapping("/resume") - @Operation(summary = "Get resumed the scheduler") + @PostMapping("/resume") + @Operation(summary = "Resume the scheduler from standby mode") @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "Got resumed successfully") + @ApiResponse(responseCode = "204", description = "Scheduler resumed successfully") }) @ResponseStatus(HttpStatus.NO_CONTENT) public void resume() throws SchedulerException { @@ -73,25 +74,25 @@ public class SchedulerController { schedulerService.start(); } - @GetMapping("/run") + @PostMapping("/start") @Operation(summary = "Start the scheduler") @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "Got started successfully") + @ApiResponse(responseCode = "204", description = "Scheduler started successfully") }) @ResponseStatus(HttpStatus.NO_CONTENT) - public void run() throws SchedulerException { + public void start() throws SchedulerException { log.info("SCHEDULER - START COMMAND"); schedulerService.start(); } - @GetMapping("/stop") - @Operation(summary = "Stop the scheduler") + @PostMapping("/shutdown") + @Operation(summary = "Shutdown the scheduler terminally") @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "Got stopped successfully") + @ApiResponse(responseCode = "204", description = "Scheduler shut down successfully") }) @ResponseStatus(HttpStatus.NO_CONTENT) - public void stop() throws SchedulerException { - log.info("SCHEDULER - STOP COMMAND"); + public void shutdown() throws SchedulerException { + log.info("SCHEDULER - SHUTDOWN COMMAND"); schedulerService.shutdown(); } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerController.java index 1fd1b4c..0d45bbf 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerController.java @@ -35,7 +35,7 @@ public class SimpleTriggerController { this.simpleSchedulerService = simpleSchedulerService; } - @GetMapping("/{name}") + @GetMapping("/{group}/{name}") @Operation(summary = "Get a simple trigger by name") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Got the trigger by its name", @@ -44,11 +44,11 @@ public class SimpleTriggerController { @ApiResponse(responseCode = "404", description = "Trigger not found", content = @Content) }) - public SimpleTriggerDTO getSimpleTrigger(@PathVariable String name) throws SchedulerException, TriggerNotFoundException { - return simpleSchedulerService.getSimpleTriggerByName(name); + public SimpleTriggerDTO getSimpleTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException { + return simpleSchedulerService.getSimpleTrigger(group, name); } - @PostMapping("/{name}") + @PostMapping("/{group}/{name}") @ResponseStatus(HttpStatus.CREATED) @Operation(summary = "Schedule a new simple trigger") @ApiResponses(value = { @@ -58,10 +58,11 @@ public class SimpleTriggerController { @ApiResponse(responseCode = "400", description = "Invalid trigger configuration", content = @Content) }) - public SimpleTriggerDTO postSimpleTrigger(@PathVariable String name, @Valid @RequestBody SimpleTriggerInputDTO simpleTriggerInputDTO) throws SchedulerException, ClassNotFoundException { + public SimpleTriggerDTO postSimpleTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody SimpleTriggerInputDTO simpleTriggerInputDTO) throws SchedulerException, ClassNotFoundException { log.info("SIMPLE TRIGGER - CREATING a SimpleTrigger {} {}", name, simpleTriggerInputDTO); SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() .triggerName(name) + .triggerGroup(group) .simpleTriggerInputDTO(simpleTriggerInputDTO) .build(); SimpleTriggerDTO newTriggerDTO = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO); @@ -69,7 +70,7 @@ public class SimpleTriggerController { return newTriggerDTO; } - @PutMapping("/{name}") + @PutMapping("/{group}/{name}") @Operation(summary = "Reschedule a simple trigger") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Rescheduled a simple trigger", @@ -78,10 +79,11 @@ public class SimpleTriggerController { @ApiResponse(responseCode = "400", description = "Invalid trigger configuration", content = @Content) }) - public TriggerDTO rescheduleSimpleTrigger(@PathVariable String name, @Valid @RequestBody SimpleTriggerInputDTO simpleTriggerInputDTO) throws SchedulerException { + public TriggerDTO rescheduleSimpleTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody SimpleTriggerInputDTO simpleTriggerInputDTO) throws SchedulerException, TriggerNotFoundException { log.info("SIMPLE TRIGGER - RESCHEDULING the trigger {} {}", name, simpleTriggerInputDTO); SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() .triggerName(name) + .triggerGroup(group) .simpleTriggerInputDTO(simpleTriggerInputDTO) .build(); TriggerDTO triggerDTO = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java index 72726ee..b4b8d87 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java @@ -7,13 +7,25 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; 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; +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; @@ -44,4 +56,56 @@ public class TriggerController { return triggerService.fetchTriggers(); } + @GetMapping("/{group}/{name}") + @Operation(summary = "Get trigger details") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Got trigger details", + content = { @Content(mediaType = "application/json", + schema = @Schema(implementation = TriggerDTO.class)) }), + @ApiResponse(responseCode = "404", description = "Trigger not found", content = @Content) + }) + public TriggerDTO getTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException { + 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") + public void pauseTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException { + triggerService.pauseTrigger(group, name); + } + + @PostMapping("/{group}/{name}/resume") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Resume a trigger") + public void resumeTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException { + triggerService.resumeTrigger(group, name); + } + + @DeleteMapping("/{group}/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Unschedule a trigger") + public void unscheduleTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException { + triggerService.unscheduleTrigger(group, name); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java index d59acb5..2360a0a 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java @@ -1,8 +1,11 @@ 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; +import it.fabioformosa.quartzmanager.api.exceptions.UnsupportedTriggerTypeException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; @@ -28,4 +31,27 @@ public class ExceptionHandlingController { return ExceptionResponse.builder().errorCode(HttpStatus.NOT_FOUND.toString()).errorMessage(ex.getMessage()).build(); } + @ExceptionHandler(JobNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + @ResponseBody + public ExceptionResponse jobNotFound(JobNotFoundException ex){ + 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 unsupportedTriggerType(UnsupportedTriggerTypeException ex) { + ExceptionResponse response = ExceptionResponse.builder() + .errorCode(HttpStatus.CONFLICT.toString()) + .errorMessage(ex.getMessage()) + .build(); + return new ResponseEntity<>(response, HttpStatus.CONFLICT); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/converters/SchedulerToSchedulerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/converters/SchedulerToSchedulerDTO.java index 1a0ca26..01978ee 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/converters/SchedulerToSchedulerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/converters/SchedulerToSchedulerDTO.java @@ -6,9 +6,13 @@ import it.fabioformosa.quartzmanager.api.enums.SchedulerStatus; import lombok.SneakyThrows; import org.quartz.Scheduler; import org.quartz.SchedulerException; +import org.quartz.SchedulerMetaData; import org.quartz.impl.matchers.GroupMatcher; import org.springframework.stereotype.Component; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + @Component public class SchedulerToSchedulerDTO extends AbstractBaseConverterToDTO { @@ -20,6 +24,16 @@ public class SchedulerToSchedulerDTO extends AbstractBaseConverterToDTO extend target.setFinalFireTime(source.getFinalFireTime()); target.setMisfireInstruction(source.getMisfireInstruction()); target.setNextFireTime(source.getNextFireTime()); + target.setPreviousFireTime(source.getPreviousFireTime()); + target.setCalendarName(source.getCalendarName()); + target.setType(source.getClass().getSimpleName()); target.setPriority(source.getPriority()); target.setMayFireAgain(source.mayFireAgain()); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java new file mode 100644 index 0000000..bb05e82 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java @@ -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 excludedDaysOfWeek; + private Set excludedDaysOfMonth; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private List excludedDates; + + private List triggerKeys; + + public List getExcludedDatesOrEmpty() { + return excludedDates == null ? Collections.emptyList() : excludedDates; + } + + public Set getExcludedDaysOfWeekOrEmpty() { + return excludedDaysOfWeek == null ? Collections.emptySet() : excludedDaysOfWeek; + } + + public Set getExcludedDaysOfMonthOrEmpty() { + return excludedDaysOfMonth == null ? Collections.emptySet() : excludedDaysOfMonth; + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java new file mode 100644 index 0000000..6091caf --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java @@ -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; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java new file mode 100644 index 0000000..a3f682b --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java @@ -0,0 +1,10 @@ +package it.fabioformosa.quartzmanager.api.dto; + +public enum CalendarType { + ANNUAL, + CRON, + DAILY, + HOLIDAY, + MONTHLY, + WEEKLY +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobDTO.java new file mode 100644 index 0000000..039a7fc --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobDTO.java @@ -0,0 +1,23 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +public class ScheduledJobDTO { + private JobKeyDTO jobKeyDTO; + private String jobClassName; + private String description; + private boolean durable; + private boolean requestsRecovery; + private Map jobDataMap; + private List triggerKeys; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java new file mode 100644 index 0000000..c30be22 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java @@ -0,0 +1,29 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +public class ScheduledJobInputDTO { + @NotBlank + private String jobClass; + + private String description; + + @Builder.Default + private boolean durable = true; + + private boolean requestsRecovery; + + @Nullable + private Map jobDataMap; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SchedulerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SchedulerDTO.java index 07dfd21..71cc0a5 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SchedulerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SchedulerDTO.java @@ -18,4 +18,12 @@ public class SchedulerDTO { private String instanceId; private SchedulerStatus status; private Set triggerKeys; + private String quartzVersion; + private String jobStoreClass; + private boolean jobStoreSupportsPersistence; + private boolean clustered; + private String threadPoolClass; + private int threadPoolSize; + private String runningSince; + private int numberOfJobsExecuted; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerCommandDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerCommandDTO.java index ae8c622..0e4cf79 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerCommandDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerCommandDTO.java @@ -9,5 +9,6 @@ import lombok.*; @ToString public class SimpleTriggerCommandDTO { private String triggerName; + private String triggerGroup; private SimpleTriggerInputDTO simpleTriggerInputDTO; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java index 1fa500b..8330da5 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java @@ -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; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java index 265167e..52fb952 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java @@ -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 @@ -22,4 +25,7 @@ public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerR @Nullable private Map jobDataMap; + + @Nullable + private JobKeyDTO jobKey; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java index bf30318..a32b59f 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java @@ -5,7 +5,6 @@ import it.fabioformosa.quartzmanager.api.validators.ValidTriggerPeriod; import lombok.*; import lombok.experimental.SuperBuilder; -import jakarta.validation.constraints.NotBlank; import java.util.Date; @ValidTriggerPeriod @@ -16,7 +15,6 @@ import java.util.Date; @ToString @Data public class TriggerCommandDTO implements TriggerPeriodDTO { - @NotBlank private String jobClass; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java index 0e1d772..27a80f2 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java @@ -7,6 +7,7 @@ import lombok.experimental.SuperBuilder; import org.quartz.JobDataMap; import java.util.Date; +import java.util.Set; @AllArgsConstructor @NoArgsConstructor @@ -21,8 +22,22 @@ public class TriggerDTO { private Date finalFireTime; private int misfireInstruction; private Date nextFireTime; + private Date previousFireTime; + private String type; + private String state; + private String calendarName; private JobKeyDTO jobKeyDTO; 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 daysOfWeek; + private Boolean preserveHourOfDayAcrossDaylightSavings; + private Boolean skipDayIfHourDoesNotExist; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java new file mode 100644 index 0000000..d32a02f --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java @@ -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 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 daysOfWeek; + private Boolean preserveHourOfDayAcrossDaylightSavings; + private Boolean skipDayIfHourDoesNotExist; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java new file mode 100644 index 0000000..e03ef82 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java @@ -0,0 +1,8 @@ +package it.fabioformosa.quartzmanager.api.dto; + +public enum TriggerType { + SIMPLE, + CRON, + DAILY_TIME_INTERVAL, + CALENDAR_INTERVAL +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java new file mode 100644 index 0000000..779deff --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java @@ -0,0 +1,7 @@ +package it.fabioformosa.quartzmanager.api.exceptions; + +public class CalendarNotFoundException extends RuntimeException { + public CalendarNotFoundException(String name) { + super("Calendar " + name + " not found!"); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/JobNotFoundException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/JobNotFoundException.java new file mode 100644 index 0000000..9c6c7b3 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/JobNotFoundException.java @@ -0,0 +1,7 @@ +package it.fabioformosa.quartzmanager.api.exceptions; + +public class JobNotFoundException extends Exception { + public JobNotFoundException(String group, String name) { + super("Job " + group + "." + name + " not found!"); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/ResourceConflictException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/ResourceConflictException.java index c7ba754..02d728f 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/ResourceConflictException.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/ResourceConflictException.java @@ -12,4 +12,8 @@ public class ResourceConflictException extends RuntimeException { super("Conflict on resourceID " + resourceId + " " + message); } + public ResourceConflictException(String message) { + super(message); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/TriggerNotFoundException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/TriggerNotFoundException.java index d3e0113..d623e9f 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/TriggerNotFoundException.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/TriggerNotFoundException.java @@ -4,4 +4,8 @@ public class TriggerNotFoundException extends Exception { public TriggerNotFoundException(String name) { super("Trigger with name " + name + " not found!"); } + + public TriggerNotFoundException(String group, String name) { + super("Trigger " + group + "." + name + " not found!"); + } } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/UnsupportedTriggerTypeException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/UnsupportedTriggerTypeException.java new file mode 100644 index 0000000..611d169 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/UnsupportedTriggerTypeException.java @@ -0,0 +1,7 @@ +package it.fabioformosa.quartzmanager.api.exceptions; + +public class UnsupportedTriggerTypeException extends RuntimeException { + public UnsupportedTriggerTypeException(String group, String name) { + super("Trigger " + group + "." + name + " is not a SimpleTrigger"); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java new file mode 100644 index 0000000..5202dab --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java @@ -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 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 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 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 findTriggerKeys(String calendarName) throws SchedulerException { + List 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)); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java index 3433e4e..0ec1f16 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java @@ -1,11 +1,29 @@ package it.fabioformosa.quartzmanager.api.services; +import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; +import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; import it.fabioformosa.quartzmanager.api.jobs.AbstractQuartzManagerJob; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.quartz.JobDetail; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.impl.matchers.GroupMatcher; import org.reflections.Reflections; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.convert.ConversionService; import org.springframework.stereotype.Service; import jakarta.annotation.PostConstruct; @@ -20,8 +38,19 @@ public class JobService { private List> jobClasses = new ArrayList<>(); private List jobClassPackages = new ArrayList<>(); + private final Scheduler scheduler; + private final ConversionService conversionService; - public JobService(@Value("${quartz-manager.jobClassPackages}") String jobClassPackages) { + public JobService(String jobClassPackages) { + this(jobClassPackages, null, null); + } + + @Autowired + public JobService(@Value("${quartz-manager.jobClassPackages}") String jobClassPackages, + @Qualifier("quartzManagerScheduler") Scheduler scheduler, + ConversionService conversionService) { + this.scheduler = scheduler; + this.conversionService = conversionService; List splitPackages = Arrays.stream(Optional.of(jobClassPackages).map(str -> str.split(",")) .orElseThrow(() -> new RuntimeException("The prop quartz-manager.jobClassPackages cannot be blank!"))) .map(String::trim) @@ -47,4 +76,91 @@ public class JobService { return reflections.getSubTypesOf(AbstractQuartzManagerJob.class); } + public List fetchScheduledJobs() throws SchedulerException { + Set jobKeys = scheduler.getJobKeys(GroupMatcher.anyJobGroup()); + return jobKeys.stream().map(this::convertJob).toList(); + } + + public ScheduledJobDTO getScheduledJob(String group, String name) throws SchedulerException, JobNotFoundException { + JobKey jobKey = JobKey.jobKey(name, group); + if (!scheduler.checkExists(jobKey)) + throw new JobNotFoundException(group, name); + return convertJob(jobKey); + } + + public ScheduledJobDTO createJob(String group, String name, ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException { + JobKey jobKey = JobKey.jobKey(name, group); + if (scheduler.checkExists(jobKey)) + throw new ResourceConflictException("Job " + jobKey + " already exists"); + + JobDetail jobDetail = buildJobDetail(jobKey, scheduledJobInputDTO); + scheduler.addJob(jobDetail, false); + return convertJob(jobKey); + } + + public ScheduledJobDTO updateJob(String group, String name, ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException, JobNotFoundException { + JobKey jobKey = requireJob(group, name); + JobDetail jobDetail = buildJobDetail(jobKey, scheduledJobInputDTO); + scheduler.addJob(jobDetail, true); + return convertJob(jobKey); + } + + public void triggerJob(String group, String name) throws SchedulerException, JobNotFoundException { + JobKey jobKey = requireJob(group, name); + scheduler.triggerJob(jobKey); + } + + public void deleteJob(String group, String name) throws SchedulerException, JobNotFoundException { + JobKey jobKey = requireJob(group, name); + scheduler.deleteJob(jobKey); + } + + public Class getEligibleJobClass(String jobClassName) throws ClassNotFoundException { + return jobClasses.stream() + .filter(jobClass -> jobClass.getName().equals(jobClassName)) + .findFirst() + .orElseThrow(() -> new ClassNotFoundException("Job class " + jobClassName + " is not eligible")); + } + + private JobKey requireJob(String group, String name) throws SchedulerException, JobNotFoundException { + JobKey jobKey = JobKey.jobKey(name, group); + if (!scheduler.checkExists(jobKey)) + throw new JobNotFoundException(group, name); + return jobKey; + } + + private JobDetail buildJobDetail(JobKey jobKey, ScheduledJobInputDTO scheduledJobInputDTO) throws ClassNotFoundException { + Class jobClass = getEligibleJobClass(scheduledJobInputDTO.getJobClass()); + JobBuilder jobBuilder = JobBuilder.newJob(jobClass) + .withIdentity(jobKey) + .storeDurably(scheduledJobInputDTO.isDurable()) + .requestRecovery(scheduledJobInputDTO.isRequestsRecovery()); + if (scheduledJobInputDTO.getDescription() != null) + jobBuilder.withDescription(scheduledJobInputDTO.getDescription()); + if (scheduledJobInputDTO.getJobDataMap() != null) + jobBuilder.usingJobData(new JobDataMap(scheduledJobInputDTO.getJobDataMap())); + return jobBuilder.build(); + } + + private ScheduledJobDTO convertJob(JobKey jobKey) { + try { + JobDetail jobDetail = scheduler.getJobDetail(jobKey); + List triggerKeys = scheduler.getTriggersOfJob(jobKey).stream() + .map(Trigger::getKey) + .map(triggerKey -> conversionService.convert(triggerKey, TriggerKeyDTO.class)) + .toList(); + return ScheduledJobDTO.builder() + .jobKeyDTO(conversionService.convert(jobKey, JobKeyDTO.class)) + .jobClassName(jobDetail.getJobClass().getName()) + .description(jobDetail.getDescription()) + .durable(jobDetail.isDurable()) + .requestsRecovery(jobDetail.requestsRecovery()) + .jobDataMap(jobDetail.getJobDataMap()) + .triggerKeys(triggerKeys) + .build(); + } catch (SchedulerException ex) { + throw new IllegalStateException("Unable to read job " + jobKey, ex); + } + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java index d875966..77afbba 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java @@ -2,7 +2,9 @@ package it.fabioformosa.quartzmanager.api.services; import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerCommandDTO; import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerDTO; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.UnsupportedTriggerTypeException; import org.quartz.*; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.convert.ConversionService; @@ -11,32 +13,67 @@ import org.springframework.stereotype.Service; @Service public class SimpleTriggerService extends AbstractSchedulerService { - public SimpleTriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService) { + private final JobService jobService; + + public SimpleTriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService, JobService jobService) { super(scheduler, conversionService); + this.jobService = jobService; } public SimpleTriggerDTO getSimpleTriggerByName(String name) throws SchedulerException, TriggerNotFoundException { - Trigger trigger = getTriggerByName(name); - return conversionService.convert(trigger, SimpleTriggerDTO.class); + return getSimpleTrigger("DEFAULT", name); + } + + public SimpleTriggerDTO getSimpleTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { + Trigger trigger = scheduler.getTrigger(TriggerKey.triggerKey(name, group)); + if (trigger == null) + throw new TriggerNotFoundException(group, name); + if (!(trigger instanceof SimpleTrigger simpleTrigger)) + throw new UnsupportedTriggerTypeException(group, name); + SimpleTriggerDTO simpleTriggerDTO = conversionService.convert(simpleTrigger, SimpleTriggerDTO.class); + simpleTriggerDTO.setState(scheduler.getTriggerState(simpleTrigger.getKey()).name()); + return simpleTriggerDTO; } public SimpleTriggerDTO scheduleSimpleTrigger(SimpleTriggerCommandDTO simpleTriggerCommandDTO) throws SchedulerException, ClassNotFoundException { - Class jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class); - JobDetail jobDetail = JobBuilder.newJob() - .ofType(jobClass) - .storeDurably(false) - .build(); + TriggerKey triggerKey = TriggerKey.triggerKey(simpleTriggerCommandDTO.getTriggerName(), simpleTriggerCommandDTO.getTriggerGroup()); + if (scheduler.checkExists(triggerKey)) + throw new ResourceConflictException("Trigger " + triggerKey + " already exists"); SimpleTrigger newSimpleTrigger = conversionService.convert(simpleTriggerCommandDTO, SimpleTrigger.class); - scheduler.scheduleJob(jobDetail, newSimpleTrigger); + if (simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey() != null) { + JobKey jobKey = JobKey.jobKey( + simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getName(), + simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getGroup() + ); + if (!scheduler.checkExists(jobKey)) + throw new ResourceConflictException("Job " + jobKey + " does not exist"); + newSimpleTrigger = newSimpleTrigger.getTriggerBuilder().forJob(jobKey).build(); + scheduler.scheduleJob(newSimpleTrigger); + } + else { + Class jobClass = jobService.getEligibleJobClass(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()); + JobDetail jobDetail = JobBuilder.newJob() + .ofType(jobClass) + .storeDurably(false) + .build(); + scheduler.scheduleJob(jobDetail, newSimpleTrigger); + } return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); } - public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException { - SimpleTrigger newSimpleTrigger = conversionService.convert(triggerCommandDTO, SimpleTrigger.class); + public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey(triggerCommandDTO.getTriggerName(), triggerCommandDTO.getTriggerGroup()); + Trigger existingTrigger = scheduler.getTrigger(triggerKey); + if (existingTrigger == null) + throw new TriggerNotFoundException(triggerCommandDTO.getTriggerGroup(), triggerCommandDTO.getTriggerName()); + + SimpleTrigger newSimpleTrigger = conversionService.convert(triggerCommandDTO, SimpleTrigger.class); + newSimpleTrigger = newSimpleTrigger.getTriggerBuilder() + .forJob(existingTrigger.getJobKey()) + .build(); - TriggerKey triggerKey = TriggerKey.triggerKey(triggerCommandDTO.getTriggerName()); scheduler.rescheduleJob(triggerKey, newSimpleTrigger); return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java index 9a1a71a..45c3750 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java @@ -1,26 +1,58 @@ 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.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 static final String MISFIRE_DO_NOTHING = "DO_NOTHING"; + private static final String MISFIRE_IGNORE_MISFIRES = "IGNORE_MISFIRES"; - public TriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService) { + private final Scheduler scheduler; + private final ConversionService conversionService; + private final JobService jobService; + + public TriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService, JobService jobService) { this.scheduler = scheduler; this.conversionService = conversionService; + this.jobService = jobService; } public List fetchTriggers() throws SchedulerException { @@ -30,4 +62,251 @@ public class TriggerService { .toList(); } + public TriggerDTO getTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey(name, group); + Trigger trigger = scheduler.getTrigger(triggerKey); + if (trigger == null) + throw new TriggerNotFoundException(group, name); + 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(jobService.getEligibleJobClass(triggerInputDTO.getJobClass())) + .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); + } + + public void resumeTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = requireTrigger(group, name); + scheduler.resumeTrigger(triggerKey); + } + + public void unscheduleTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = requireTrigger(group, name); + scheduler.unscheduleJob(triggerKey); + } + + private TriggerKey requireTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey(name, group); + if (!scheduler.checkExists(triggerKey)) + throw new TriggerNotFoundException(group, name); + return triggerKey; + } + + private Trigger buildTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws ParseException { + TriggerBuilder 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 MISFIRE_DO_NOTHING -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case MISFIRE_IGNORE_MISFIRES -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires(); + default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed(); + }; + } + + private DailyTimeIntervalScheduleBuilder applyDailyMisfireInstruction(DailyTimeIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) { + return switch (normalizeMisfireInstruction(misfireInstruction)) { + case MISFIRE_DO_NOTHING -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case MISFIRE_IGNORE_MISFIRES -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires(); + default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed(); + }; + } + + private CalendarIntervalScheduleBuilder applyCalendarIntervalMisfireInstruction(CalendarIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) { + return switch (normalizeMisfireInstruction(misfireInstruction)) { + case MISFIRE_DO_NOTHING -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case MISFIRE_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()); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java new file mode 100644 index 0000000..c3be0e1 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java @@ -0,0 +1,8 @@ +package it.fabioformosa.quartzmanager.api.validators; + +import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; + +public interface JobTargetDTO { + String getJobClass(); + JobKeyDTO getJobKey(); +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java new file mode 100644 index 0000000..9aa5bd5 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java @@ -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[] payload() default {}; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java new file mode 100644 index 0000000..85efa97 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java @@ -0,0 +1,15 @@ +package it.fabioformosa.quartzmanager.api.validators; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidJobTargetValidator implements ConstraintValidator { + @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; + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java new file mode 100644 index 0000000..ba4f3c2 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java @@ -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 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))); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java index 9d7780d..5c03377 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java @@ -2,6 +2,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.JobKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; import it.fabioformosa.quartzmanager.api.jobs.SampleJob; import it.fabioformosa.quartzmanager.api.services.JobService; import org.junit.jupiter.api.Test; @@ -16,11 +19,13 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +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 = SimpleTriggerController.class, properties = { +@WebMvcTest(controllers = JobController.class, properties = { "quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs" }) class JobControllerTest { @@ -36,11 +41,88 @@ class JobControllerTest { Mockito.when(jobService.getJobClasses()).thenReturn(List.of(SampleJob.class)); List expectedJobs = List.of(SampleJob.class.getName()); - mockMvc.perform(get(JobController.JOB_CONTROLLER_BASE_URL) + mockMvc.perform(get(JobController.JOB_CLASSES_CONTROLLER_BASE_URL) .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedJobs))); Mockito.verify(jobService, Mockito.times(1)).getJobClasses(); } + @Test + void whenGetScheduledJobsIsCalled_thenScheduledJobsAreReturned() throws Exception { + ScheduledJobDTO scheduledJobDTO = ScheduledJobDTO.builder() + .jobKeyDTO(JobKeyDTO.builder().name("sampleJob").group("DEFAULT").build()) + .jobClassName(SampleJob.class.getName()) + .build(); + Mockito.when(jobService.fetchScheduledJobs()).thenReturn(List.of(scheduledJobDTO)); + + mockMvc.perform(get(JobController.JOB_CONTROLLER_BASE_URL) + .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(List.of(scheduledJobDTO)))); + + Mockito.verify(jobService).fetchScheduledJobs(); + } + + @Test + void whenTriggerJobIsCalled_thenNoContentIsReturned() throws Exception { + mockMvc.perform(post(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob/trigger") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(jobService).triggerJob("DEFAULT", "sampleJob"); + } + + @Test + void whenCreateJobIsCalled_thenCreatedJobIsReturned() throws Exception { + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder() + .jobClass(SampleJob.class.getName()) + .durable(true) + .build(); + ScheduledJobDTO scheduledJobDTO = ScheduledJobDTO.builder() + .jobKeyDTO(JobKeyDTO.builder().name("sampleJob").group("DEFAULT").build()) + .jobClassName(SampleJob.class.getName()) + .durable(true) + .build(); + Mockito.when(jobService.createJob("DEFAULT", "sampleJob", inputDTO)).thenReturn(scheduledJobDTO); + + mockMvc.perform(post(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(inputDTO))) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(scheduledJobDTO))); + + Mockito.verify(jobService).createJob("DEFAULT", "sampleJob", inputDTO); + } + + @Test + void whenUpdateJobIsCalled_thenUpdatedJobIsReturned() throws Exception { + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder() + .jobClass(SampleJob.class.getName()) + .durable(true) + .build(); + ScheduledJobDTO scheduledJobDTO = ScheduledJobDTO.builder() + .jobKeyDTO(JobKeyDTO.builder().name("sampleJob").group("DEFAULT").build()) + .jobClassName(SampleJob.class.getName()) + .durable(true) + .build(); + Mockito.when(jobService.updateJob("DEFAULT", "sampleJob", inputDTO)).thenReturn(scheduledJobDTO); + + mockMvc.perform(put(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(inputDTO))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(scheduledJobDTO))); + + Mockito.verify(jobService).updateJob("DEFAULT", "sampleJob", inputDTO); + } + + @Test + void whenDeleteJobIsCalled_thenNoContentIsReturned() throws Exception { + mockMvc.perform(delete(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(jobService).deleteJob("DEFAULT", "sampleJob"); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerControllerTest.java index fc59a54..1ac0a88 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SchedulerControllerTest.java @@ -16,9 +16,10 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @ContextConfiguration(classes = {QuartManagerApplicationTests.class}) -@WebMvcTest(controllers = SimpleTriggerController.class, properties = { +@WebMvcTest(controllers = SchedulerController.class, properties = { "quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs" }) class SchedulerControllerTest { @@ -47,8 +48,8 @@ class SchedulerControllerTest { } @Test - void givenAScheduler_whenTheGetPausedIsCalled_then2xxReturned() throws Exception { - mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/pause") + void givenAScheduler_whenStandbyIsCalled_then2xxReturned() throws Exception { + mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/standby") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.content().string("")); @@ -57,8 +58,8 @@ class SchedulerControllerTest { } @Test - void givenAScheduler_whenTheGetResumedIsCalled_then2xxReturned() throws Exception { - mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/resume") + void givenAScheduler_whenResumeIsCalled_then2xxReturned() throws Exception { + mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/resume") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.content().string("")); @@ -67,8 +68,8 @@ class SchedulerControllerTest { } @Test - void givenAScheduler_whenTheGetRunIsCalled_then2xxReturned() throws Exception { - mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/run") + void givenAScheduler_whenStartIsCalled_then2xxReturned() throws Exception { + mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/start") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.content().string("")); @@ -77,8 +78,8 @@ class SchedulerControllerTest { } @Test - void givenAScheduler_whenTheGetStoppedIsCalled_then2xxReturned() throws Exception { - mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/stop") + void givenAScheduler_whenShutdownIsCalled_then2xxReturned() throws Exception { + mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/shutdown") .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.content().string("")); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerTest.java index f4c396f..bd43c25 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerTest.java @@ -46,9 +46,9 @@ class SimpleTriggerControllerTest { @Test void whenGetIsCalled_thenASimpleTriggerIsReturned() throws Exception { SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger"); - Mockito.when(simpleTriggerService.getSimpleTriggerByName("mytrigger")).thenReturn(expectedSimpleTriggerDTO); + Mockito.when(simpleTriggerService.getSimpleTrigger("DEFAULT", "mytrigger")).thenReturn(expectedSimpleTriggerDTO); - mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") + mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger") .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedSimpleTriggerDTO))); } @@ -59,7 +59,7 @@ class SimpleTriggerControllerTest { SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); mockMvc.perform( - post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") + post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(simpleTriggerInputDTO)) ) @@ -90,11 +90,12 @@ class SimpleTriggerControllerTest { SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO); SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() .triggerName("mytrigger") + .triggerGroup("DEFAULT") .simpleTriggerInputDTO(simpleTriggerInputDTO) .build(); Mockito.when(simpleTriggerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO)).thenReturn(expectedSimpleTriggerDTO); - mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") + mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(simpleTriggerInputDTO))) .andExpect(MockMvcResultMatchers.status().isOk()) diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerValidationTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerValidationTest.java index 9cb795e..b33f3a7 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerValidationTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/SimpleTriggerControllerValidationTest.java @@ -46,9 +46,9 @@ class SimpleTriggerControllerValidationTest { @Test void givenANotExistingTrigger_whenGetIsCalled_then404IsReturned() throws Exception { - Mockito.when(simpleTriggerService.getSimpleTriggerByName("not_existing_trigger_name")).thenThrow(new TriggerNotFoundException("not_existing_trigger_name")); + Mockito.when(simpleTriggerService.getSimpleTrigger("DEFAULT", "not_existing_trigger_name")).thenThrow(new TriggerNotFoundException("DEFAULT", "not_existing_trigger_name")); - mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/not_existing_trigger_name") + mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/not_existing_trigger_name") .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isNotFound()); } @@ -59,7 +59,7 @@ class SimpleTriggerControllerValidationTest { SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-minimal-trigger"); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); mockMvc.perform( - post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/my-minimal-trigger") + post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/my-minimal-trigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(simpleTriggerInputDTO)) ) @@ -83,7 +83,7 @@ class SimpleTriggerControllerValidationTest { SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-puntual-trigger"); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); mockMvc.perform( - post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/my-puntual-trigger") + post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/my-puntual-trigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(simpleTriggerInputDTO)) ) @@ -95,7 +95,7 @@ class SimpleTriggerControllerValidationTest { @ParameterizedTest @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) void givenAnInvalidSimpleTriggerCommandDTO_whenPostedANewTrigger_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerComandDTO) throws Exception { - mockMvc.perform(post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") + mockMvc.perform(post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(invalidSimpleTriggerComandDTO))) .andExpect(MockMvcResultMatchers.status().is4xxClientError()); @@ -104,7 +104,7 @@ class SimpleTriggerControllerValidationTest { @ParameterizedTest @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) void givenAnInvalidSimpleTriggerCommandDTO_whenATriggerIsRescheduled_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerCommandTO) throws Exception { - mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") + mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger") .contentType(MediaType.APPLICATION_JSON) .content(TestUtils.toJson(invalidSimpleTriggerCommandTO))) .andExpect(MockMvcResultMatchers.status().is4xxClientError()); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java index 41322cc..4661650 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java @@ -1,14 +1,29 @@ 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; 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.bean.override.mockito.MockitoBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +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 = { @@ -27,4 +42,95 @@ class TriggerControllerTest { Mockito.reset(triggerService); } + @Test + void whenListTriggersIsCalled_thenTriggersAreReturned() throws Exception { + List triggerKeys = List.of(TriggerKeyDTO.builder().name("sampleTrigger").group("DEFAULT").build()); + Mockito.when(triggerService.fetchTriggers()).thenReturn(triggerKeys); + + mockMvc.perform(get(TriggerController.TRIGGER_CONTROLLER_BASE_URL).contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerKeys))); + } + + @Test + void whenGetTriggerIsCalled_thenTriggerIsReturned() throws Exception { + TriggerDTO triggerDTO = TriggerDTO.builder() + .triggerKeyDTO(TriggerKeyDTO.builder().name("sampleTrigger").group("DEFAULT").build()) + .state("NORMAL") + .type("SimpleTrigger") + .build(); + Mockito.when(triggerService.getTrigger("DEFAULT", "sampleTrigger")).thenReturn(triggerDTO); + + mockMvc.perform(get(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger").contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .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)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(triggerService).pauseTrigger("DEFAULT", "sampleTrigger"); + } + + @Test + void whenResumeTriggerIsCalled_thenNoContentIsReturned() throws Exception { + mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger/resume").contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(triggerService).resumeTrigger("DEFAULT", "sampleTrigger"); + } + + @Test + void whenUnscheduleTriggerIsCalled_thenNoContentIsReturned() throws Exception { + mockMvc.perform(delete(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger").contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(triggerService).unscheduleTrigger("DEFAULT", "sampleTrigger"); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/CalendarServiceTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/CalendarServiceTest.java new file mode 100644 index 0000000..ee18bc4 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/CalendarServiceTest.java @@ -0,0 +1,220 @@ +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.exceptions.CalendarNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.quartz.Calendar; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.quartz.impl.calendar.AnnualCalendar; +import org.quartz.impl.calendar.CronCalendar; +import org.quartz.impl.calendar.HolidayCalendar; +import org.quartz.impl.calendar.MonthlyCalendar; +import org.quartz.impl.calendar.WeeklyCalendar; + +import java.text.ParseException; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.TimeZone; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +class CalendarServiceTest { + + @Mock + private Scheduler scheduler; + + private CalendarService calendarService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + calendarService = new CalendarService(scheduler); + } + + @Test + void givenCalendarNames_whenCalendarsAreFetched_thenReturnsCalendarDtos() throws SchedulerException { + Mockito.when(scheduler.getCalendarNames()).thenReturn(List.of("weekday")); + WeeklyCalendar weeklyCalendar = new WeeklyCalendar(); + weeklyCalendar.setDayExcluded(java.util.Calendar.MONDAY, true); + Mockito.when(scheduler.getCalendar("weekday")).thenReturn(weeklyCalendar); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + + List calendars = calendarService.fetchCalendars(); + + Assertions.assertThat(calendars).hasSize(1); + Assertions.assertThat(calendars.get(0).getName()).isEqualTo("weekday"); + Assertions.assertThat(calendars.get(0).getType()).isEqualTo(CalendarType.WEEKLY); + Assertions.assertThat(calendars.get(0).getExcludedDaysOfWeek()).contains(java.util.Calendar.MONDAY); + } + + @Test + void givenMissingCalendar_whenCalendarIsFetched_thenThrowsNotFound() throws SchedulerException { + Mockito.when(scheduler.getCalendar("missing")).thenReturn(null); + + Assertions.assertThatThrownBy(() -> calendarService.getCalendar("missing")) + .isInstanceOf(CalendarNotFoundException.class); + } + + @Test + void givenExistingCalendar_whenCalendarIsAdded_thenThrowsConflict() throws SchedulerException { + Mockito.when(scheduler.getCalendar("existing")).thenReturn(new HolidayCalendar()); + + CalendarDTO calendarDTO = CalendarDTO.builder().type(CalendarType.HOLIDAY).build(); + + Assertions.assertThatThrownBy(() -> calendarService.addCalendar("existing", calendarDTO)) + .isInstanceOf(ResourceConflictException.class); + } + + @Test + void givenHolidayCalendar_whenCalendarIsAdded_thenStoresAndReturnsExcludedDates() throws SchedulerException, ParseException { + Date excludedDate = new Date(86_400_000L); + CalendarDTO calendarDTO = CalendarDTO.builder() + .type(CalendarType.HOLIDAY) + .description("holidays") + .excludedDates(List.of(excludedDate)) + .build(); + Mockito.when(scheduler.getCalendar("holidays")).thenReturn(null); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + ArgumentCaptor calendarCaptor = ArgumentCaptor.forClass(Calendar.class); + + CalendarDTO result = calendarService.addCalendar("holidays", calendarDTO); + + Mockito.verify(scheduler).addCalendar(eq("holidays"), calendarCaptor.capture(), eq(false), eq(false)); + Assertions.assertThat(calendarCaptor.getValue()).isInstanceOf(HolidayCalendar.class); + Assertions.assertThat(result.getType()).isEqualTo(CalendarType.HOLIDAY); + Assertions.assertThat(result.getDescription()).isEqualTo("holidays"); + Assertions.assertThat(result.getExcludedDates()).hasSize(1); + } + + @Test + void givenCronCalendar_whenCalendarIsAdded_thenStoresTimezone() throws SchedulerException, ParseException { + CalendarDTO calendarDTO = CalendarDTO.builder() + .type(CalendarType.CRON) + .cronExpression("0 0 12 * * ?") + .timeZone("UTC") + .build(); + Mockito.when(scheduler.getCalendar("cron")).thenReturn(null); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + ArgumentCaptor calendarCaptor = ArgumentCaptor.forClass(Calendar.class); + + CalendarDTO result = calendarService.addCalendar("cron", calendarDTO); + + Mockito.verify(scheduler).addCalendar(eq("cron"), calendarCaptor.capture(), eq(false), eq(false)); + Assertions.assertThat(calendarCaptor.getValue()).isInstanceOf(CronCalendar.class); + Assertions.assertThat(result.getCronExpression()).isEqualTo("0 0 12 * * ?"); + Assertions.assertThat(result.getTimeZone()).isEqualTo("UTC"); + } + + @Test + void givenDailyCalendar_whenCalendarIsAdded_thenStoresRange() throws SchedulerException, ParseException { + CalendarDTO calendarDTO = CalendarDTO.builder() + .type(CalendarType.DAILY) + .rangeStartingTime("08:00:00") + .rangeEndingTime("18:30:00") + .invertTimeRange(true) + .build(); + Mockito.when(scheduler.getCalendar("daily")).thenReturn(null); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + + CalendarDTO result = calendarService.addCalendar("daily", calendarDTO); + + Assertions.assertThat(result.getType()).isEqualTo(CalendarType.DAILY); + Assertions.assertThat(result.getRangeStartingTime()).isEqualTo("08:00:00"); + Assertions.assertThat(result.getRangeEndingTime()).isEqualTo("18:30:00"); + Assertions.assertThat(result.getInvertTimeRange()).isTrue(); + } + + @Test + void givenAnnualMonthlyAndWeeklyCalendars_whenFetched_thenCalendarSpecificFieldsAreMapped() throws SchedulerException { + Date excludedDate = new Date(86_400_000L); + AnnualCalendar annualCalendar = new AnnualCalendar(); + java.util.Calendar excludedDay = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")); + excludedDay.setTime(excludedDate); + annualCalendar.setDayExcluded(excludedDay, true); + + MonthlyCalendar monthlyCalendar = new MonthlyCalendar(); + monthlyCalendar.setDayExcluded(10, true); + + WeeklyCalendar weeklyCalendar = new WeeklyCalendar(); + weeklyCalendar.setDayExcluded(java.util.Calendar.FRIDAY, true); + + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + Mockito.when(scheduler.getCalendar("annual")).thenReturn(annualCalendar); + Mockito.when(scheduler.getCalendar("monthly")).thenReturn(monthlyCalendar); + Mockito.when(scheduler.getCalendar("weekly")).thenReturn(weeklyCalendar); + + Assertions.assertThat(calendarService.getCalendar("annual").getExcludedDates()).hasSize(1); + Assertions.assertThat(calendarService.getCalendar("monthly").getExcludedDaysOfMonth()).containsExactly(10); + Assertions.assertThat(calendarService.getCalendar("weekly").getExcludedDaysOfWeek()).contains(java.util.Calendar.FRIDAY); + } + + @Test + void givenCalendarUsedByTrigger_whenCalendarIsFetched_thenTriggerKeysAreIncluded() throws SchedulerException { + HolidayCalendar holidayCalendar = new HolidayCalendar(); + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .modifiedByCalendar("holidays") + .build(); + Mockito.when(scheduler.getCalendar("holidays")).thenReturn(holidayCalendar); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of(triggerKey)); + Mockito.when(scheduler.getTrigger(triggerKey)).thenReturn(trigger); + + CalendarDTO result = calendarService.getCalendar("holidays"); + + Assertions.assertThat(result.getTriggerKeys()).hasSize(1); + Assertions.assertThat(result.getTriggerKeys().get(0).getName()).isEqualTo("trigger"); + Assertions.assertThat(result.getTriggerKeys().get(0).getGroup()).isEqualTo("group"); + } + + @Test + void givenCalendar_whenIncludedTimeIsTested_thenReturnsIncludedAndNextIncludedTime() throws SchedulerException { + HolidayCalendar holidayCalendar = new HolidayCalendar(); + Date excludedDate = new Date(86_400_000L); + holidayCalendar.addExcludedDate(excludedDate); + Mockito.when(scheduler.getCalendar("holidays")).thenReturn(holidayCalendar); + + CalendarIncludedTimeDTO result = calendarService.testIncludedTime("holidays", CalendarIncludedTimeDTO.builder().time(excludedDate).build()); + + Assertions.assertThat(result.getIncluded()).isFalse(); + Assertions.assertThat(result.getNextIncludedTime()).isAfter(excludedDate); + } + + @Test + void givenMissingCalendar_whenDeleted_thenThrowsNotFound() throws SchedulerException { + Mockito.when(scheduler.deleteCalendar("missing")).thenReturn(false); + + Assertions.assertThatThrownBy(() -> calendarService.deleteCalendar("missing")) + .isInstanceOf(CalendarNotFoundException.class); + } + + @Test + void givenExistingCalendar_whenUpdated_thenReplacesCalendar() throws SchedulerException, ParseException { + Mockito.when(scheduler.getCalendar("monthly")).thenReturn(new MonthlyCalendar()); + Mockito.when(scheduler.getTriggerKeys(any())).thenReturn(Set.of()); + CalendarDTO calendarDTO = CalendarDTO.builder() + .type(CalendarType.MONTHLY) + .excludedDaysOfMonth(Set.of(3, 9)) + .build(); + + CalendarDTO result = calendarService.updateCalendar("monthly", calendarDTO); + + Mockito.verify(scheduler).addCalendar(eq("monthly"), any(Calendar.class), eq(true), eq(true)); + Assertions.assertThat(result.getExcludedDaysOfMonth()).containsExactly(3, 9); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/JobServiceTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/JobServiceTest.java index 10cf424..3378fde 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/JobServiceTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/JobServiceTest.java @@ -1,13 +1,55 @@ package it.fabioformosa.quartzmanager.api.services; +import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; +import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; +import it.fabioformosa.quartzmanager.api.jobs.SampleJob; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.springframework.core.convert.ConversionService; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; class JobServiceTest { + @Mock + private Scheduler scheduler; + + @Mock + private ConversionService conversionService; + + private JobService schedulerBackedJobService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + schedulerBackedJobService = new JobService("", scheduler, conversionService); + schedulerBackedJobService.getJobClasses().add(SampleJob.class); + } + @Test void givenTwoJobClassesInTwoPackages_whenTheJobServiceIsCalled_shouldReturnTwoJobClasses(){ JobService jobService = new JobService("it.fabioformosa.quartzmanager.api.jobs, it.fabioformosa.samplepackage"); @@ -42,4 +84,123 @@ class JobServiceTest { Assertions.assertThat(jobService.getJobClasses()).isEmpty(); } + @Test + void givenScheduledJobs_whenFetched_thenReturnsConvertedJobDtos() throws SchedulerException { + JobKey jobKey = JobKey.jobKey("job", "group"); + JobDetail jobDetail = org.quartz.JobBuilder.newJob(SampleJob.class) + .withIdentity(jobKey) + .withDescription("sample") + .storeDurably(true) + .requestRecovery(true) + .usingJobData("key", "value") + .build(); + Trigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger", "triggers").forJob(jobKey).build(); + Mockito.when(scheduler.getJobKeys(any())).thenReturn(Set.of(jobKey)); + Mockito.when(scheduler.getJobDetail(jobKey)).thenReturn(jobDetail); + Mockito.doReturn(List.of(trigger)).when(scheduler).getTriggersOfJob(jobKey); + mockKeyConversions(jobKey, trigger.getKey()); + + List scheduledJobs = schedulerBackedJobService.fetchScheduledJobs(); + + Assertions.assertThat(scheduledJobs).hasSize(1); + Assertions.assertThat(scheduledJobs.get(0).getJobClassName()).isEqualTo(SampleJob.class.getName()); + Assertions.assertThat(scheduledJobs.get(0).getDescription()).isEqualTo("sample"); + Assertions.assertThat(scheduledJobs.get(0).isDurable()).isTrue(); + Assertions.assertThat(scheduledJobs.get(0).isRequestsRecovery()).isTrue(); + Assertions.assertThat((Map) scheduledJobs.get(0).getJobDataMap()).containsEntry("key", "value"); + Assertions.assertThat(scheduledJobs.get(0).getTriggerKeys()).hasSize(1); + } + + @Test + void givenMissingScheduledJob_whenFetched_thenThrowsNotFound() throws SchedulerException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(false); + + Assertions.assertThatThrownBy(() -> schedulerBackedJobService.getScheduledJob("group", "job")) + .isInstanceOf(JobNotFoundException.class); + } + + @Test + void givenExistingJob_whenCreated_thenThrowsConflict() throws SchedulerException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(true); + + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder().jobClass(SampleJob.class.getName()).build(); + + Assertions.assertThatThrownBy(() -> schedulerBackedJobService.createJob("group", "job", inputDTO)) + .isInstanceOf(ResourceConflictException.class); + } + + @Test + void givenNewJob_whenCreated_thenAddsDurableJobAndReturnsDto() throws SchedulerException, ClassNotFoundException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(false); + Mockito.when(scheduler.getJobDetail(jobKey)).thenAnswer(invocation -> org.quartz.JobBuilder.newJob(SampleJob.class).withIdentity(jobKey).storeDurably(true).build()); + Mockito.when(scheduler.getTriggersOfJob(jobKey)).thenReturn(List.of()); + mockKeyConversions(jobKey, null); + ArgumentCaptor jobDetailCaptor = ArgumentCaptor.forClass(JobDetail.class); + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder() + .jobClass(SampleJob.class.getName()) + .description("sample") + .durable(true) + .requestsRecovery(true) + .jobDataMap(Map.of("key", "value")) + .build(); + + schedulerBackedJobService.createJob("group", "job", inputDTO); + + Mockito.verify(scheduler).addJob(jobDetailCaptor.capture(), eq(false)); + JobDetail createdJob = jobDetailCaptor.getValue(); + Assertions.assertThat(createdJob.getKey()).isEqualTo(jobKey); + Assertions.assertThat(createdJob.getJobClass()).isEqualTo(SampleJob.class); + Assertions.assertThat(createdJob.getDescription()).isEqualTo("sample"); + Assertions.assertThat(createdJob.isDurable()).isTrue(); + Assertions.assertThat(createdJob.requestsRecovery()).isTrue(); + Assertions.assertThat(createdJob.getJobDataMap().getString("key")).isEqualTo("value"); + } + + @Test + void givenExistingJob_whenUpdated_thenReplacesJob() throws SchedulerException, ClassNotFoundException, JobNotFoundException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(true); + Mockito.when(scheduler.getJobDetail(jobKey)).thenAnswer(invocation -> org.quartz.JobBuilder.newJob(SampleJob.class).withIdentity(jobKey).storeDurably(true).build()); + Mockito.when(scheduler.getTriggersOfJob(jobKey)).thenReturn(List.of()); + mockKeyConversions(jobKey, null); + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder().jobClass(SampleJob.class.getName()).build(); + + schedulerBackedJobService.updateJob("group", "job", inputDTO); + + Mockito.verify(scheduler).addJob(any(JobDetail.class), eq(true)); + } + + @Test + void givenExistingJob_whenTriggeredAndDeleted_thenDelegatesToScheduler() throws SchedulerException, JobNotFoundException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(true); + + schedulerBackedJobService.triggerJob("group", "job"); + schedulerBackedJobService.deleteJob("group", "job"); + + Mockito.verify(scheduler).triggerJob(jobKey); + Mockito.verify(scheduler).deleteJob(jobKey); + } + + @Test + void givenMissingJob_whenDeleted_thenThrowsNotFound() throws SchedulerException { + JobKey jobKey = JobKey.jobKey("job", "group"); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(false); + + Assertions.assertThatThrownBy(() -> schedulerBackedJobService.deleteJob("group", "job")) + .isInstanceOf(JobNotFoundException.class); + } + + private void mockKeyConversions(JobKey jobKey, TriggerKey triggerKey) { + Mockito.when(conversionService.convert(jobKey, JobKeyDTO.class)) + .thenReturn(JobKeyDTO.builder().name(jobKey.getName()).group(jobKey.getGroup()).build()); + if (triggerKey != null) { + Mockito.when(conversionService.convert(triggerKey, TriggerKeyDTO.class)) + .thenReturn(TriggerKeyDTO.builder().name(triggerKey.getName()).group(triggerKey.getGroup()).build()); + } + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java index e396c97..2c309e6 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java @@ -3,6 +3,7 @@ package it.fabioformosa.quartzmanager.api.services; import it.fabioformosa.quartzmanager.api.common.utils.DateUtils; import it.fabioformosa.quartzmanager.api.dto.*; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; +import it.fabioformosa.quartzmanager.api.jobs.SampleJob; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,9 +30,13 @@ class SimpleTriggerServiceTest { @Mock private ConversionService conversionService; + @Mock + private JobService jobService; + @BeforeEach - void setUp() { + void setUp() throws ClassNotFoundException { openMocks(this); + Mockito.doReturn(SampleJob.class).when(jobService).getEligibleJobClass(SampleJob.class.getName()); } @Test @@ -47,7 +52,8 @@ class SimpleTriggerServiceTest { void givenAnExistingTrigger_whenGetSimplerTriggerByNameIsCalled_thenTheDtoIsReturned() throws SchedulerException, TriggerNotFoundException { String existing_trigger = "existing_trigger"; Mockito.when(scheduler.getTrigger(any(TriggerKey.class))) - .thenReturn(TriggerBuilder.newTrigger().withIdentity(existing_trigger).build()); + .thenReturn(TriggerBuilder.newTrigger().withIdentity(existing_trigger).withSchedule(SimpleScheduleBuilder.simpleSchedule()).build()); + Mockito.when(scheduler.getTriggerState(any(TriggerKey.class))).thenReturn(Trigger.TriggerState.NORMAL); Mockito.when(conversionService.convert(any(SimpleTrigger.class), eq(SimpleTriggerDTO.class))) .thenReturn(SimpleTriggerDTO.builder() .triggerKeyDTO(TriggerKeyDTO.builder().name(existing_trigger).build()) @@ -61,7 +67,7 @@ class SimpleTriggerServiceTest { @Test void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsScheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException { SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder() - .jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") + .jobClass(SampleJob.class.getName()) .startDate(new Date()) .repeatInterval(5000L).repeatCount(5) .endDate(DateUtils.addHoursToNow(1)) @@ -71,7 +77,7 @@ class SimpleTriggerServiceTest { SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() .startTime(triggerInputDTO.getStartDate()) - .repeatInterval(1000) + .repeatInterval(1000L) .repeatCount(10) .mayFireAgain(true) .finalFireTime(triggerInputDTO.getEndDate()) @@ -81,10 +87,14 @@ class SimpleTriggerServiceTest { .build(); Mockito.when(scheduler.scheduleJob(any(), any())).thenReturn(new Date()); + Mockito.when(scheduler.checkExists(any(TriggerKey.class))).thenReturn(false); + Mockito.when(conversionService.convert(any(SimpleTriggerCommandDTO.class), eq(SimpleTrigger.class))) + .thenReturn(TriggerBuilder.newTrigger().withIdentity(simpleTriggerName, "DEFAULT").withSchedule(SimpleScheduleBuilder.simpleSchedule()).build()); Mockito.when(conversionService.convert(any(), eq(SimpleTriggerDTO.class))).thenReturn(expectedTriggerDTO); SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() .triggerName(simpleTriggerName) + .triggerGroup("DEFAULT") .simpleTriggerInputDTO(triggerInputDTO) .build(); SimpleTriggerDTO simpleTrigger = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO); @@ -93,7 +103,7 @@ class SimpleTriggerServiceTest { } @Test - void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException { + void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, TriggerNotFoundException { SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder() .jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") .startDate(new Date()) @@ -105,7 +115,7 @@ class SimpleTriggerServiceTest { SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() .startTime(triggerInputDTO.getStartDate()) - .repeatInterval(1000) + .repeatInterval(1000L) .repeatCount(10) .mayFireAgain(true) .finalFireTime(triggerInputDTO.getEndDate()) @@ -115,10 +125,15 @@ class SimpleTriggerServiceTest { .build(); Mockito.when(scheduler.rescheduleJob(any(), any())).thenReturn(new Date()); + Mockito.when(scheduler.getTrigger(any(TriggerKey.class))) + .thenReturn(TriggerBuilder.newTrigger().withIdentity(simpleTriggerName, "DEFAULT").forJob(JobKey.jobKey("MyJob", "DEFAULT")).withSchedule(SimpleScheduleBuilder.simpleSchedule()).build()); + Mockito.when(conversionService.convert(any(SimpleTriggerCommandDTO.class), eq(SimpleTrigger.class))) + .thenReturn(TriggerBuilder.newTrigger().withIdentity(simpleTriggerName, "DEFAULT").withSchedule(SimpleScheduleBuilder.simpleSchedule()).build()); Mockito.when(conversionService.convert(any(), eq(SimpleTriggerDTO.class))).thenReturn(expectedTriggerDTO); SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() .triggerName(simpleTriggerName) + .triggerGroup("DEFAULT") .simpleTriggerInputDTO(triggerInputDTO) .build(); SimpleTriggerDTO simpleTrigger = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/TriggerServiceTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/TriggerServiceTest.java index dd0c365..47e3a53 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/TriggerServiceTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/TriggerServiceTest.java @@ -1,20 +1,40 @@ 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 it.fabioformosa.quartzmanager.api.jobs.SampleJob; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.quartz.CalendarIntervalTrigger; +import org.quartz.CronTrigger; +import org.quartz.DailyTimeIntervalTrigger; +import org.quartz.DateBuilder; +import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; import org.quartz.TriggerKey; import org.springframework.core.convert.ConversionService; +import java.text.ParseException; +import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.TimeZone; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -30,9 +50,13 @@ class TriggerServiceTest { @Mock private ConversionService conversionService; + @Mock + private JobService jobService; + @BeforeEach - void setUp(){ + void setUp() throws ClassNotFoundException { MockitoAnnotations.openMocks(this); + Mockito.doReturn(SampleJob.class).when(jobService).getEligibleJobClass(SampleJob.class.getName()); } @Test @@ -47,4 +71,234 @@ class TriggerServiceTest { Assertions.assertThat(triggerKeyDTOs.get(0).getName()).isEqualTo(triggerTestName); } + @Test + void givenMissingTrigger_whenFetched_thenThrowsNotFound() throws SchedulerException { + Mockito.when(scheduler.getTrigger(TriggerKey.triggerKey("trigger", "group"))).thenReturn(null); + + Assertions.assertThatThrownBy(() -> triggerService.getTrigger("group", "trigger")) + .isInstanceOf(TriggerNotFoundException.class); + } + + @Test + void givenExistingTrigger_whenFetched_thenReturnsStateAndSimpleDetails() throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + SimpleTrigger trigger = org.quartz.TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .withSchedule(org.quartz.SimpleScheduleBuilder.simpleSchedule().withIntervalInMilliseconds(5000).withRepeatCount(3)) + .build(); + TriggerDTO convertedDTO = new TriggerDTO(); + Mockito.when(scheduler.getTrigger(triggerKey)).thenReturn(trigger); + Mockito.when(scheduler.getTriggerState(triggerKey)).thenReturn(Trigger.TriggerState.NORMAL); + Mockito.when(conversionService.convert(trigger, TriggerDTO.class)).thenReturn(convertedDTO); + + TriggerDTO result = triggerService.getTrigger("group", "trigger"); + + Assertions.assertThat(result.getState()).isEqualTo("NORMAL"); + Assertions.assertThat(result.getRepeatInterval()).isEqualTo(5000L); + Assertions.assertThat(result.getRepeatCount()).isEqualTo(3); + } + + @Test + void givenExistingTriggerKey_whenScheduled_thenThrowsConflict() throws SchedulerException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + Mockito.when(scheduler.checkExists(triggerKey)).thenReturn(true); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder().triggerType(TriggerType.SIMPLE).jobClass(SampleJob.class.getName()).build(); + + Assertions.assertThatThrownBy(() -> triggerService.scheduleTrigger("group", "trigger", inputDTO)) + .isInstanceOf(ResourceConflictException.class); + } + + @Test + void givenMissingTargetJob_whenTriggerIsScheduled_thenThrowsConflict() throws SchedulerException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + JobKey jobKey = JobKey.jobKey("job", "jobs"); + Mockito.when(scheduler.checkExists(triggerKey)).thenReturn(false); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(false); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.SIMPLE) + .jobKey(JobKeyDTO.builder().name("job").group("jobs").build()) + .build(); + + Assertions.assertThatThrownBy(() -> triggerService.scheduleTrigger("group", "trigger", inputDTO)) + .isInstanceOf(ResourceConflictException.class); + } + + @Test + void givenSimpleTriggerInputWithJobClass_whenScheduled_thenBuildsSimpleTriggerAndJobDetail() throws SchedulerException, ClassNotFoundException, ParseException { + Mockito.when(scheduler.checkExists(TriggerKey.triggerKey("trigger", "group"))).thenReturn(false); + Mockito.when(conversionService.convert(any(Trigger.class), eq(TriggerDTO.class))).thenReturn(new TriggerDTO()); + ArgumentCaptor jobDetailCaptor = ArgumentCaptor.forClass(org.quartz.JobDetail.class); + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.SIMPLE) + .jobClass(SampleJob.class.getName()) + .description("sample") + .priority(7) + .repeatInterval(2000L) + .repeatCount(5) + .misfireInstruction(MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW.name()) + .jobDataMap(Map.of("key", "value")) + .build(); + + TriggerDTO result = triggerService.scheduleTrigger("group", "trigger", inputDTO); + + Mockito.verify(scheduler).scheduleJob(jobDetailCaptor.capture(), triggerCaptor.capture()); + Assertions.assertThat(jobDetailCaptor.getValue().getJobClass()).isEqualTo(SampleJob.class); + Assertions.assertThat(triggerCaptor.getValue()).isInstanceOf(SimpleTrigger.class); + SimpleTrigger trigger = (SimpleTrigger) triggerCaptor.getValue(); + Assertions.assertThat(trigger.getPriority()).isEqualTo(7); + Assertions.assertThat(trigger.getDescription()).isEqualTo("sample"); + Assertions.assertThat(trigger.getRepeatInterval()).isEqualTo(2000L); + Assertions.assertThat(trigger.getRepeatCount()).isEqualTo(5); + Assertions.assertThat(result.getRepeatInterval()).isEqualTo(2000L); + Assertions.assertThat(result.getRepeatCount()).isEqualTo(5); + } + + @Test + void givenCronTriggerInput_whenScheduledForExistingJob_thenBuildsCronTrigger() throws SchedulerException, ClassNotFoundException, ParseException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + JobKey jobKey = JobKey.jobKey("job", "jobs"); + Mockito.when(scheduler.checkExists(triggerKey)).thenReturn(false); + Mockito.when(scheduler.checkExists(jobKey)).thenReturn(true); + Mockito.when(conversionService.convert(any(Trigger.class), eq(TriggerDTO.class))).thenReturn(new TriggerDTO()); + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.CRON) + .jobKey(JobKeyDTO.builder().name("job").group("jobs").build()) + .cronExpression("0 0 12 * * ?") + .timeZone("UTC") + .misfireInstruction("DO_NOTHING") + .build(); + + TriggerDTO result = triggerService.scheduleTrigger("group", "trigger", inputDTO); + + Mockito.verify(scheduler).scheduleJob(triggerCaptor.capture()); + Assertions.assertThat(triggerCaptor.getValue()).isInstanceOf(CronTrigger.class); + CronTrigger trigger = (CronTrigger) triggerCaptor.getValue(); + Assertions.assertThat(trigger.getJobKey()).isEqualTo(jobKey); + Assertions.assertThat(trigger.getCronExpression()).isEqualTo("0 0 12 * * ?"); + Assertions.assertThat(trigger.getTimeZone()).isEqualTo(TimeZone.getTimeZone("UTC")); + Assertions.assertThat(result.getCronExpression()).isEqualTo("0 0 12 * * ?"); + } + + @Test + void givenDailyTriggerInput_whenScheduled_thenBuildsDailyTrigger() throws SchedulerException, ClassNotFoundException, ParseException { + Mockito.when(scheduler.checkExists(TriggerKey.triggerKey("daily", "group"))).thenReturn(false); + Mockito.when(conversionService.convert(any(Trigger.class), eq(TriggerDTO.class))).thenReturn(new TriggerDTO()); + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.DAILY_TIME_INTERVAL) + .jobClass(SampleJob.class.getName()) + .repeatInterval(2L) + .repeatIntervalUnit(DateBuilder.IntervalUnit.HOUR.name()) + .startTimeOfDay("09:15") + .endTimeOfDay("17:45:30") + .daysOfWeek(Set.of(java.util.Calendar.MONDAY, java.util.Calendar.WEDNESDAY)) + .misfireInstruction("IGNORE_MISFIRES") + .build(); + + TriggerDTO result = triggerService.scheduleTrigger("group", "daily", inputDTO); + + Mockito.verify(scheduler).scheduleJob(any(org.quartz.JobDetail.class), triggerCaptor.capture()); + Assertions.assertThat(triggerCaptor.getValue()).isInstanceOf(DailyTimeIntervalTrigger.class); + DailyTimeIntervalTrigger trigger = (DailyTimeIntervalTrigger) triggerCaptor.getValue(); + Assertions.assertThat(trigger.getRepeatInterval()).isEqualTo(2); + Assertions.assertThat(trigger.getRepeatIntervalUnit()).isEqualTo(DateBuilder.IntervalUnit.HOUR); + Assertions.assertThat(result.getStartTimeOfDay()).isEqualTo("09:15:00"); + Assertions.assertThat(result.getEndTimeOfDay()).isEqualTo("17:45:30"); + Assertions.assertThat(result.getDaysOfWeek()).contains(java.util.Calendar.MONDAY, java.util.Calendar.WEDNESDAY); + } + + @Test + void givenCalendarIntervalTriggerInput_whenScheduled_thenBuildsCalendarIntervalTrigger() throws SchedulerException, ClassNotFoundException, ParseException { + Date startDate = new Date(System.currentTimeMillis() + 1000); + Date endDate = new Date(startDate.getTime() + 10000); + Mockito.when(scheduler.checkExists(TriggerKey.triggerKey("calendar", "group"))).thenReturn(false); + Mockito.when(conversionService.convert(any(Trigger.class), eq(TriggerDTO.class))).thenReturn(new TriggerDTO()); + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.CALENDAR_INTERVAL) + .jobClass(SampleJob.class.getName()) + .startDate(startDate) + .endDate(endDate) + .calendarName("holidays") + .repeatInterval(3L) + .repeatIntervalUnit(DateBuilder.IntervalUnit.WEEK.name()) + .timeZone("UTC") + .preserveHourOfDayAcrossDaylightSavings(true) + .skipDayIfHourDoesNotExist(true) + .misfireInstruction("FIRE_AND_PROCEED") + .build(); + + TriggerDTO result = triggerService.scheduleTrigger("group", "calendar", inputDTO); + + Mockito.verify(scheduler).scheduleJob(any(org.quartz.JobDetail.class), triggerCaptor.capture()); + Assertions.assertThat(triggerCaptor.getValue()).isInstanceOf(CalendarIntervalTrigger.class); + CalendarIntervalTrigger trigger = (CalendarIntervalTrigger) triggerCaptor.getValue(); + Assertions.assertThat(trigger.getCalendarName()).isEqualTo("holidays"); + Assertions.assertThat(trigger.getRepeatInterval()).isEqualTo(3); + Assertions.assertThat(trigger.getRepeatIntervalUnit()).isEqualTo(DateBuilder.IntervalUnit.WEEK); + Assertions.assertThat(trigger.isPreserveHourOfDayAcrossDaylightSavings()).isTrue(); + Assertions.assertThat(trigger.isSkipDayIfHourDoesNotExist()).isTrue(); + Assertions.assertThat(result.getTimeZone()).isEqualTo("UTC"); + } + + @Test + void givenExistingTrigger_whenRescheduled_thenKeepsExistingJob() throws SchedulerException, ParseException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + JobKey jobKey = JobKey.jobKey("job", "jobs"); + Trigger existingTrigger = org.quartz.TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey).build(); + Mockito.when(scheduler.getTrigger(triggerKey)).thenReturn(existingTrigger); + Mockito.when(conversionService.convert(any(Trigger.class), eq(TriggerDTO.class))).thenReturn(new TriggerDTO()); + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + + TriggerInputDTO inputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.SIMPLE) + .repeatInterval(1000L) + .repeatCount(1) + .build(); + + triggerService.rescheduleTrigger("group", "trigger", inputDTO); + + Mockito.verify(scheduler).rescheduleJob(eq(triggerKey), triggerCaptor.capture()); + Assertions.assertThat(triggerCaptor.getValue().getJobKey()).isEqualTo(jobKey); + } + + @Test + void givenMissingTrigger_whenRescheduled_thenThrowsNotFound() throws SchedulerException { + Mockito.when(scheduler.getTrigger(TriggerKey.triggerKey("trigger", "group"))).thenReturn(null); + + Assertions.assertThatThrownBy(() -> triggerService.rescheduleTrigger("group", "trigger", TriggerInputDTO.builder().triggerType(TriggerType.SIMPLE).build())) + .isInstanceOf(TriggerNotFoundException.class); + } + + @Test + void givenExistingTrigger_whenPausedResumedAndUnscheduled_thenDelegatesToScheduler() throws SchedulerException, TriggerNotFoundException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + Mockito.when(scheduler.checkExists(triggerKey)).thenReturn(true); + + triggerService.pauseTrigger("group", "trigger"); + triggerService.resumeTrigger("group", "trigger"); + triggerService.unscheduleTrigger("group", "trigger"); + + Mockito.verify(scheduler).pauseTrigger(triggerKey); + Mockito.verify(scheduler).resumeTrigger(triggerKey); + Mockito.verify(scheduler).unscheduleJob(triggerKey); + } + + @Test + void givenMissingTrigger_whenPaused_thenThrowsNotFound() throws SchedulerException { + TriggerKey triggerKey = TriggerKey.triggerKey("trigger", "group"); + Mockito.when(scheduler.checkExists(triggerKey)).thenReturn(false); + + Assertions.assertThatThrownBy(() -> triggerService.pauseTrigger("group", "trigger")) + .isInstanceOf(TriggerNotFoundException.class); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/resources/application.yml b/quartz-manager-parent/quartz-manager-starter-api/src/test/resources/application.yml index 7378be2..9e37bd5 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/resources/application.yml +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/resources/application.yml @@ -2,7 +2,7 @@ quartz: enabled: true quartz-manager: - jobClassPackages: it.fabioformosa.quartzmanager.api.jobs + jobClassPackages: it.fabioformosa.quartzmanager.api.jobs, it.fabioformosa.samplepackage logging: level: diff --git a/quartz-manager-parent/quartz-operations-console.html b/quartz-manager-parent/quartz-operations-console.html new file mode 100644 index 0000000..18b1f75 --- /dev/null +++ b/quartz-manager-parent/quartz-operations-console.html @@ -0,0 +1,1120 @@ + + + + + + + Quartz Operations Console Prototype + + + +
+ +
+
+
+
+

Quartz Operations Console

+
quartz-manager-scheduler / compact context
+
+ RUNNING +
Instance IDnode-a7f3
+
Cluster2 nodes
+
WebSocketOPEN
+
+
+ + +
+
+
+
+
+
+

Scheduler Command Center

Expanded controls live on Dashboard and Scheduler / Settings only
+
+
+
+ + + + + + +
+
Global scheduler operations are intentionally centralized here because they affect every job and trigger. Object pages keep only compact scheduler context.
+
+ +
+
+
NORMAL
42
Jobs scheduled across 7 groups
+
BLOCKED
3
Currently executing jobs
+
MISFIRE
5
Misfires in the last hour
+
THREADS
8 / 12
Thread pool active capacity
+
+

Next Scheduled Fires

LIVE
+
+
+ + + + + + + + + + +
TriggerGroupTypeStateJobNext fire
invoice-sync-5mbillingSimpleTriggerNORMALInvoiceSyncJob09:45:00 Europe/Rome
daily-ledger-closefinanceCronTriggerNORMALLedgerCloseJob18:00:00 Europe/Rome
tenant-cleanupmaintenanceDailyTimeIntervalPAUSEDTenantCleanupJobpaused
erp-retry-windowintegrationsCalendarIntervalERRORErpRetryJobrequires reset
cache-warmupplatformSimpleTriggerBLOCKEDCacheWarmupJob09:47:30 Europe/Rome
welcome-email-oncecrmSimpleTriggerCOMPLETEWelcomeEmailJobcomplete
+
+ +
+
+
+

Execution Load

Last 30 minutes
+
+
+ +
+
+
18,942
+
2
+
maintenance
+
1
+
+
+
+
+

Event Stream

STREAMING
+
+
TimeSeverityTypeSourceMessage
+
09:44:18INFOJOB_PROGRESSbillingInvoiceSyncJob processed 144 of 200 invoices for trigger invoice-sync-5m.
+
09:43:56WARNMISFIREfinancedaily-ledger-close missed scheduled fire; policy SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31ERRORTRIGGER_ERRORintegrationserp-retry-window moved to ERROR after job threw ResourceAccessException.
+
09:41:05INFOSCHEDULERnode-a7f3Cluster check-in completed. 2 scheduler instances active.
+
+
+
+
+
+
+

Jobs

Jobs become first-class Quartz objects: class, group, durability, recovery, concurrency, data map, associated triggers, and execution history are visible in one operational surface.

+
+
+
+

Job Registry

42 JOBS
+
+
+ + + + + + + + + + +
Job keyGroupClassDurableRecoveryConcurrentTriggersNext run
InvoiceSyncJobbillingit.fabioformosa.jobs.InvoiceSyncJobYESYESDISALLOW309:45
LedgerCloseJobfinanceit.fabioformosa.jobs.LedgerCloseJobYESNOALLOW118:00
TenantCleanupJobmaintenanceit.fabioformosa.jobs.TenantCleanupJobYESYESDISALLOW2paused
ErpRetryJobintegrationsit.fabioformosa.jobs.ErpRetryJobYESYESALLOW4error
CacheWarmupJobplatformit.fabioformosa.jobs.CacheWarmupJobNONODISALLOW109:47
WelcomeEmailJobcrmit.fabioformosa.jobs.WelcomeEmailJobNONOALLOW0complete
+
+ +
+
+
+
+
+

Triggers

The trigger page expands beyond SimpleTrigger: every row shows type, state, linked job, fire times, calendar, priority, and misfire policy, with operational actions exposed close to the selected trigger.

+
+
+
+

Trigger Inventory

38 ACTIVE4 PAUSED1 ERROR
+
+
+ + + + + + + + + + +
TriggerGroupTypeStateJobNext firePrevious fireMisfire
invoice-sync-5mbillingSimpleTriggerNORMALInvoiceSyncJob09:4509:40FIRE_NOW
daily-ledger-closefinanceCronTriggerNORMALLedgerCloseJob18:00yesterdaySMART
tenant-cleanupmaintenanceDailyTimeIntervalPAUSEDTenantCleanupJobpaused08:30DO_NOTHING
erp-retry-windowintegrationsCalendarIntervalERRORErpRetryJobreset09:20FIRE_ONCE
weekly-reportanalyticsCronTriggerNORMALReportJobMon 07:00Mon 07:00SMART
cache-warmupplatformSimpleTriggerBLOCKEDCacheWarmupJob09:4709:42IGNORE
+
+ +
+
+
+
+
+

Calendars

Quartz calendars are exclusion rules, not date pickers. This page makes the calendar type, base calendar, trigger usage, excluded windows, and next included time testable before operators attach them to triggers.

+
+
+
+

Calendar Registry

7 CALENDARS21 TRIGGERS USING CALENDARS
+
+
+ + + + + + + + + +
CalendarTypeBase calendarTriggersNext excludedDescription
business-daysWeeklyCalendarcompany-holidays142026-05-16 00:00Exclude Saturday and Sunday.
company-holidaysHolidayCalendarnone92026-06-02 00:00Italian public holidays.
month-end-freezeMonthlyCalendarbusiness-days32026-05-31 00:00Exclude month-end close window.
batch-windowDailyCalendarbusiness-days42026-05-11 20:00Allow 06:00 to 20:00 only.
cron-blackoutCronCalendarnone1Fri 23:00Exclude release windows.
+
+ +
+
+
+

Weekly Time Grid

A visual editor for DailyCalendar / WeeklyCalendar windows
+
+
+ MonTueWedThuFriSat + 06:00openopenopenopenopenclosed + 12:00openopenopenopenopenclosed + 20:00closedclosedclosedclosedrelease freezeclosed +
+
+
+
+
+
+

Executions

Currently executing jobs are treated as live operational objects with fire instance id, scheduled versus actual fire time, run time, refire count, recovery state, and node ownership.

+
+
+
+

Currently Executing Jobs

3 RUNNING1 RECOVERING
+
+
+ + + + + + + +
Fire instanceJobTriggerScheduledActualRun timeRefireNode
node-a7f3-119238InvoiceSyncJobinvoice-sync-5m09:40:0009:40:0304:210node-a7f3
node-b912-119239CacheWarmupJobcache-warmup09:42:3009:42:3101:530node-b912
node-a7f3-119240ErpRetryJoberp-retry-window09:43:0009:43:0800:462node-a7f3
+
+ +
+
+
+

Recent Execution History

+
+ + + + + + +
CompletedJobTriggerDurationResultNodeMessage
09:39:58ReportJobweekly-report00:11SUCCESSnode-b912Report generated for analytics group.
09:38:22ErpRetryJoberp-retry-window00:31FAILEDnode-a7f3ResourceAccessException from ERP endpoint.
+
+
+
+
+
+

Event Stream

The old logs and progress panels become one observable stream with live mode, pause, text search, severity filtering, event-type filtering, and export for incident review.

+
+
+
+
+

Live Events

STREAMING142 EVENTS / HOUR
+
+
TimeSeverityTypeSourceMessage
+
09:44:18INFOJOB_PROGRESSbillingInvoiceSyncJob processed 144 of 200 invoices for fireInstanceId node-a7f3-119238.
+
09:43:56WARNMISFIREfinancedaily-ledger-close missed fire at 09:30; SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31ERRORTRIGGER_ERRORintegrationserp-retry-window entered ERROR after ErpRetryJob threw ResourceAccessException.
+
09:41:05INFOSCHEDULERnode-a7f3Cluster check-in completed. 2 scheduler instances active.
+
09:40:03INFOJOB_STARTEDbillingInvoiceSyncJob started from trigger invoice-sync-5m.
+
09:39:58INFOJOB_COMPLETEDanalyticsReportJob completed in 11 seconds on node-b912.
+
+
+ +
+
+
+
+

Scheduler / Settings

The full scheduler command surface belongs here: global lifecycle actions, delayed start, shutdown, clear, cluster metadata, nodes, currently executing jobs, and safety warnings for destructive operations.

+
RUNNING
+
+
+
+

Lifecycle Controls

Global actions affect all jobs, triggers, calendars, and running executions
+
+
+
Strong confirmation requiredShutdown stops the scheduler instance. Clear removes all scheduling data from the scheduler. Both actions should require typed confirmation and role checks.
+
+
+
+

Scheduler Metadata

JDBC JOB STORE
+
+
quartz-manager-scheduler
+
node-a7f3
+
2.3.2
+
2026-05-11 08:14:03
+
SimpleThreadPool
+
12
+
JobStoreTX
+
true
+
+
+
+

Cluster Nodes

2 ACTIVE
+
+
node-a7f3
last check-in 2s ago / 8 active threads
LOCAL
+
node-b912
last check-in 4s ago / 4 active threads
REMOTE
+
node-c401
last check-in 19m ago / stale
STALE
+
+
+
+

Global State Overview

3 EXECUTING4 PAUSED GROUPS1 ERROR TRIGGER
+
+ + + + + + + + +
AreaCurrent stateCountRepresentative keyRecommended action
Executing jobsRUNNING3InvoiceSyncJobOpen Executions before interrupting anything.
Paused groupsPAUSED4maintenanceResume group only after maintenance window closes.
Error triggersERROR1erp-retry-windowResolve root cause, then reset from Triggers page.
MisfiresMISFIRE5 / hourdaily-ledger-closeReview thread pool saturation and misfire policy.
+
+
+
+
+
+
+ +
+ + +