Merge pull request #136 from fabioformosa/feature/#9x_trigger_types

Feature/#9x trigger types
This commit is contained in:
Fabio Formosa
2026-05-14 08:26:03 +02:00
committed by GitHub
82 changed files with 6326 additions and 291 deletions

View File

@@ -14,10 +14,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
server-id: maven-central-release server-id: maven-central-release
server-username: MAVEN_USERNAME server-username: MAVEN_USERNAME
@@ -35,10 +35,10 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }}
MAVEN_GPG_PASSPHRASE: ${{ secrets.OSSRH_GPG_SECRET_KEY_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 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Publish to GitHub Packages Apache Maven - name: Publish to GitHub Packages Apache Maven
run: mvn deploy --file quartz-manager-parent/pom.xml -P "deploy-github,build-webjar" run: mvn deploy --file quartz-manager-parent/pom.xml -P "deploy-github,build-webjar"

View File

@@ -26,10 +26,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
cache: maven cache: maven
- name: Build and test with Maven - name: Build and test with Maven

View File

@@ -16,10 +16,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 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 uses: actions/setup-java@v4
with: with:
java-version: 17 java-version: 21
distribution: 'zulu' # Alternative distribution options are available. distribution: 'zulu' # Alternative distribution options are available.
- name: Cache SonarCloud packages - name: Cache SonarCloud packages
uses: actions/cache@v3 uses: actions/cache@v3

View File

@@ -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'
}
}
];

View File

@@ -5,8 +5,16 @@ module.exports = {
tsconfig: '<rootDir>/tsconfig.spec.json', tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$' stringifyContentPathRegex: '\\.(html|svg)$'
}), }),
moduleNameMapper: {
'^tslib$': '<rootDir>/node_modules/tslib/tslib.es6.mjs',
'^rxjs$': '<rootDir>/node_modules/rxjs/dist/cjs/index.js',
'^rxjs/operators$': '<rootDir>/node_modules/rxjs/dist/cjs/operators/index.js',
'^rxjs/(.*)$': '<rootDir>/node_modules/rxjs/dist/cjs/$1',
'^@fortawesome/fontawesome$': '<rootDir>/node_modules/@fortawesome/fontawesome/index.js',
'^@fortawesome/fontawesome-free-solid$': '<rootDir>/node_modules/@fortawesome/fontawesome-free-solid/index.js'
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!(@angular|@stomp/rx-stomp|@stomp/stompjs|.*\\.mjs$)/)' 'node_modules/(?!(@angular|@fortawesome|@stomp/rx-stomp|@stomp/stompjs|.*\\.mjs$)/)'
] ]
}; };

View File

@@ -8,8 +8,8 @@
"build": "ng build --configuration production", "build": "ng build --configuration production",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"lint": "ng lint", "lint": "ng lint",
"lint:sonar": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\"", "lint:sonar": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\"",
"lint:sonar:fix": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\" --fix" "lint:sonar:fix": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\" --fix"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@@ -1,3 +1,6 @@
@if (isOperationsConsoleRoute()) {
<router-outlet></router-outlet>
} @else {
<div class="app-shell flex flex-column justify-space-between h-100"> <div class="app-shell flex flex-column justify-space-between h-100">
<app-header class="flex-none"></app-header> <app-header class="flex-none"></app-header>
<div class="content flex h-100"> <div class="content flex h-100">
@@ -5,3 +8,4 @@
</div> </div>
<app-footer class="flex-none"></app-footer> <app-footer class="flex-none"></app-footer>
</div> </div>
}

View File

@@ -2,7 +2,7 @@
display: block; display: block;
color: rgba(0,0,0,.54); color: rgba(0,0,0,.54);
font-family: Roboto,"Helvetica Neue"; font-family: Roboto,"Helvetica Neue";
height: 100%; min-height: 100%;
} }
.content { .content {

View File

@@ -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 fontawesome from '@fortawesome/fontawesome';
import { import {
@@ -20,4 +21,11 @@ fontawesome.library.add(faCheckCircle, faExclamationCircle, faExclamationTriangl
}) })
export class AppComponent { export class AppComponent {
constructor(private router: Router) {
}
isOperationsConsoleRoute(): boolean {
const url = this.router.url || '/';
return url === '/' || url.startsWith('/manager');
}
} }

View File

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

View File

@@ -74,7 +74,7 @@ describe('SchedulerControlComponent', () => {
expect(playIconDe).toBeTruthy(); expect(playIconDe).toBeTruthy();
schedulerBtnDe.nativeElement.click(); schedulerBtnDe.nativeElement.click();
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/run'); const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/start');
startSchedulerReq.flush(null); startSchedulerReq.flush(null);
fixture.detectChanges(); fixture.detectChanges();
@@ -98,7 +98,7 @@ describe('SchedulerControlComponent', () => {
expect(pauseIconDe).toBeTruthy(); expect(pauseIconDe).toBeTruthy();
schedulerBtnDe.nativeElement.click(); schedulerBtnDe.nativeElement.click();
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/pause'); const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/standby');
startSchedulerReq.flush(null); startSchedulerReq.flush(null);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -36,7 +36,7 @@ export class SchedulerControlComponent implements OnInit {
}; };
stopScheduler = function () { stopScheduler = function () {
this.schedulerService.stopScheduler().subscribe((res) => { this.schedulerService.shutdownScheduler().subscribe((res) => {
this.scheduler.status = 'STOPPED' this.scheduler.status = 'STOPPED'
}, (res) => { }, (res) => {
console.log(JSON.stringify(res)) console.log(JSON.stringify(res))
@@ -44,7 +44,7 @@ export class SchedulerControlComponent implements OnInit {
}; };
pauseScheduler = function () { pauseScheduler = function () {
this.schedulerService.pauseScheduler().subscribe((res) => { this.schedulerService.standbyScheduler().subscribe((res) => {
this.scheduler.status = 'PAUSED' this.scheduler.status = 'PAUSED'
}, (res) => { }, (res) => {
console.log(JSON.stringify(res)) console.log(JSON.stringify(res))

View File

@@ -56,7 +56,7 @@ describe('SimpleTriggerConfig', () => {
it('should fetch no triggers at the init', () => { it('should fetch no triggers at the init', () => {
expect(component).toBeTruthy(); 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) { function setInputValue(componentDe: DebugElement, inputSelector: string, value: string) {
@@ -95,7 +95,7 @@ describe('SimpleTriggerConfig', () => {
component.openTriggerForm(); component.openTriggerForm();
fixture.detectChanges(); fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
getJobsReq.flush([testJobName]); getJobsReq.flush([testJobName]);
const componentDe: DebugElement = fixture.debugElement; const componentDe: DebugElement = fixture.debugElement;
@@ -150,7 +150,7 @@ describe('SimpleTriggerConfig', () => {
expect(submittedTriggerKey).toEqual(new TriggerKey(testTriggerName, null)); expect(submittedTriggerKey).toEqual(new TriggerKey(testTriggerName, null));
flush(); flush();
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`);
postSimpleTriggerReq.flush(mockTrigger); postSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toEqual(mockTrigger); expect(actualNewTrigger).toEqual(mockTrigger);
@@ -166,7 +166,7 @@ describe('SimpleTriggerConfig', () => {
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: testJobName, description: null}; mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: testJobName, description: null};
mockTrigger.mayFireAgain = true; mockTrigger.mayFireAgain = true;
mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; 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); getSimpleTriggerReq.flush(mockTrigger);
component.simpleTriggerReactiveForm.setValue({ component.simpleTriggerReactiveForm.setValue({
@@ -198,7 +198,7 @@ describe('SimpleTriggerConfig', () => {
submitButton.nativeElement.click(); 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); putSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toBeUndefined(); expect(actualNewTrigger).toBeUndefined();
@@ -214,7 +214,7 @@ describe('SimpleTriggerConfig', () => {
const mockTrigger = new Trigger(); const mockTrigger = new Trigger();
mockTrigger.triggerKeyDTO = mockTriggerKey; mockTrigger.triggerKeyDTO = mockTriggerKey;
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: 'TestJob', description: null}; mockTrigger.jobDetailDTO = <JobDetail>{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); getSimpleTriggerReq.flush(mockTrigger);
fixture.detectChanges(); fixture.detectChanges();
@@ -246,7 +246,7 @@ describe('SimpleTriggerConfig', () => {
mockTrigger.mayFireAgain = true; mockTrigger.mayFireAgain = true;
mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; 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); getSimpleTriggerReq.flush(mockTrigger);
expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName); expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName);
@@ -271,7 +271,7 @@ describe('SimpleTriggerConfig', () => {
it('should display the warning if there are no eligible jobs', () => { it('should display the warning if there are no eligible jobs', () => {
fixture.detectChanges(); fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
getJobsReq.flush([]); getJobsReq.flush([]);
fixture.detectChanges(); fixture.detectChanges();
@@ -285,7 +285,7 @@ describe('SimpleTriggerConfig', () => {
it('should not display the warning if there are eligible jobs', () => { it('should not display the warning if there are eligible jobs', () => {
fixture.detectChanges(); fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
getJobsReq.flush(['sampleJob']); getJobsReq.flush(['sampleJob']);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -207,6 +207,7 @@ export class SimpleTriggerConfigComponent implements OnInit {
const reactiveFormValue = this.simpleTriggerReactiveForm.getRawValue(); const reactiveFormValue = this.simpleTriggerReactiveForm.getRawValue();
const simpleTriggerCommand = new SimpleTriggerCommand(); const simpleTriggerCommand = new SimpleTriggerCommand();
simpleTriggerCommand.triggerName = reactiveFormValue.triggerName; simpleTriggerCommand.triggerName = reactiveFormValue.triggerName;
simpleTriggerCommand.triggerGroup = this.selectedTriggerKey?.group || 'DEFAULT';
simpleTriggerCommand.jobClass = reactiveFormValue.jobClass; simpleTriggerCommand.jobClass = reactiveFormValue.jobClass;
simpleTriggerCommand.repeatCount = reactiveFormValue.triggerRecurrence.repeatCount; simpleTriggerCommand.repeatCount = reactiveFormValue.triggerRecurrence.repeatCount;
simpleTriggerCommand.repeatInterval = reactiveFormValue.triggerRecurrence.repeatInterval; simpleTriggerCommand.repeatInterval = reactiveFormValue.triggerRecurrence.repeatInterval;

View File

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

View File

@@ -0,0 +1,7 @@
export class ScheduledJobCommand {
jobClass: string;
description: string;
durable: boolean;
requestsRecovery: boolean;
jobDataMap: {[key: string]: unknown};
}

View File

@@ -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[];
}

View File

@@ -5,6 +5,14 @@ export class Scheduler {
instanceId: string; instanceId: string;
status: string; status: string;
triggerKeys: TriggerKey[]; 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[]) { constructor(name: string, instanceId: string, status: string, triggerKeys: TriggerKey[]) {
this.name = name; this.name = name;

View File

@@ -1,9 +1,12 @@
export class SimpleTriggerCommand { export class SimpleTriggerCommand {
triggerName: string; triggerName: string;
triggerGroup: string;
jobClass: string; jobClass: string;
jobKey: {group: string; name: string};
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
repeatCount: number; repeatCount: number;
repeatInterval: number; repeatInterval: number;
misfireInstruction: string; misfireInstruction: string;
jobDataMap: {[key: string]: unknown};
} }

View File

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

View File

@@ -11,7 +11,22 @@ export class Trigger {
finalFireTime: Date; finalFireTime: Date;
misfireInstruction: number; misfireInstruction: number;
nextFireTime: Date; nextFireTime: Date;
previousFireTime: Date;
type: string;
state: string;
calendarName: string;
jobKeyDTO: JobKeyModel; jobKeyDTO: JobKeyModel;
jobDetailDTO: JobDetail = new JobDetail(); jobDetailDTO: JobDetail = new JobDetail();
mayFireAgain: boolean; mayFireAgain: boolean;
jobDataMap: {[key: string]: unknown};
cronExpression: string;
timeZone: string;
repeatInterval: number;
repeatCount: number;
repeatIntervalUnit: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
} }

View File

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

View File

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

View File

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

View File

@@ -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');
});
});

View File

@@ -2,6 +2,8 @@ import {Injectable} from '@angular/core';
import {ApiService} from './api.service'; import {ApiService} from './api.service';
import {CONTEXT_PATH, getBaseUrl} from './config.service'; import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {ScheduledJob} from '../model/scheduled-job.model';
import {ScheduledJobCommand} from '../model/scheduled-job.command';
@Injectable() @Injectable()
export default class JobService { export default class JobService {
@@ -12,7 +14,31 @@ export default class JobService {
} }
fetchJobs = (): Observable<string[]> => { fetchJobs = (): Observable<string[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/job-classes`)
}
fetchScheduledJobs = (): Observable<ScheduledJob[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs`) return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs`)
} }
getScheduledJob = (group: string, name: string): Observable<ScheduledJob> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`)
}
createJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
updateJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
triggerJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {})
}
deleteJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}`)
}
} }

View File

@@ -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);
});
});

View File

@@ -15,19 +15,19 @@ export class SchedulerService {
) { } ) { }
startScheduler = (): Observable<void> => { startScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/run`); return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/start`, {});
} }
stopScheduler = (): Observable<void> => { shutdownScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/stop`); return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/shutdown`, {});
} }
pauseScheduler = (): Observable<void> => { standbyScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/pause`); return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/standby`, {});
} }
resumeScheduler = (): Observable<void> => { resumeScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`); return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`, {});
} }
getStatus = () => { getStatus = () => {
@@ -38,16 +38,16 @@ export class SchedulerService {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler`); return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler`);
} }
getSimpleTriggerConfig = (triggerName: string): Observable<Trigger> => { getSimpleTriggerConfig = (triggerName: string, triggerGroup = 'DEFAULT'): Observable<Trigger> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerName}`); return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerGroup}/${triggerName}`);
} }
saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => { saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config) return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config)
} }
updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => { updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config) return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config)
} }

View File

@@ -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);
});
});

View File

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

View File

@@ -1,36 +1,63 @@
<div <section class="login-shell">
class="content flex flex-row justify-center" <div class="login-hero" aria-hidden="true">
style="padding-bottom: 160px"> <div class="brand">
<mat-card elevation="5" class="flex-1"> <span class="brand-mark">QM</span>
<mat-card-subtitle> <div>
<h2>Quartz Manager</h2> <h1>Quartz Manager</h1>
</mat-card-subtitle> <p>Scheduler operations console</p>
</div>
</div>
<mat-card-title> <div class="hero-card">
<h2>{{ title }}</h2> <span class="card-title">Operational View</span>
</mat-card-title> <div class="status-row">
<span class="pulse"></span>
<span>Jobs, triggers, logs and live execution state</span>
</div>
<div class="metric-grid">
<div>
<strong>01</strong>
<span>Secure entry</span>
</div>
<div>
<strong>24/7</strong>
<span>Runtime visibility</span>
</div>
</div>
</div>
</div>
<mat-card class="login-card">
<mat-card-content> <mat-card-content>
<div class="form-header">
<span class="eyebrow">Welcome back</span>
<h2>{{ title }}</h2>
<p>Sign in to manage scheduler activity and inspect runtime signals.</p>
</div>
@if (notification) { @if (notification) {
<p [class]="notification.msgType">{{ notification.msgBody }}</p> <p class="notification {{ notification.msgType }}">{{ notification.msgBody }}</p>
} @if (!submitted) { } @if (!submitted) {
<form [formGroup]="form" (ngSubmit)="onSubmit()" #loginForm="ngForm"> <form [formGroup]="form" (ngSubmit)="onSubmit()" #loginForm="ngForm">
<mat-form-field> <mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input <input
matInput matInput
formControlName="username" formControlName="username"
required required
placeholder="user" /> autocomplete="username" />
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field appearance="outline">
<mat-label>Password</mat-label>
<input <input
matInput matInput
formControlName="password" formControlName="password"
required required
type="password" type="password"
placeholder="password" /> autocomplete="current-password" />
</mat-form-field> </mat-form-field>
<button <button
class="login-button"
type="submit" type="submit"
[disabled]="!loginForm.form.valid" [disabled]="!loginForm.form.valid"
mat-raised-button mat-raised-button
@@ -39,8 +66,11 @@
</button> </button>
</form> </form>
} @if (submitted) { } @if (submitted) {
<div class="loading-state">
<mat-spinner mode="indeterminate"></mat-spinner> <mat-spinner mode="indeterminate"></mat-spinner>
<span>Checking credentials...</span>
</div>
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </section>

View File

@@ -1,62 +1,268 @@
:host { :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; 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%; width: 100%;
} }
mat-card { .login-button {
max-width: 350px; min-height: 44px;
text-align: center; border-radius: 7px;
animation: fadein 1s; font-weight: 700;
-o-animation: fadein 1s; /* Opera */
-moz-animation: fadein 1s; /* Firefox */
-webkit-animation: fadein 1s; /* Safari and Chrome */
}
mat-form-field {
display: block;
} }
mat-spinner { mat-spinner {
width: 25px; width: 25px;
height: 25px; height: 25px;
margin: 20px auto 0 auto;
} }
button { .loading-state {
display: block; display: flex;
width: 100%; 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 { .error {
color: #D50000; border: 1px solid oklch(58% 0.19 28 / 0.30);
background: oklch(98% 0.02 28);
color: var(--danger);
} }
.success { .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: 760px) {
@media screen and (max-width: 599px) { .login-shell {
grid-template-columns: 1fr;
.content { min-height: auto;
/* https://github.com/angular/flex-layout/issues/295 */ padding: 12px;
display: block !important;
} }
mat-card { .login-hero {
/* https://github.com/angular/flex-layout/issues/295 */ min-height: auto;
display: block !important; gap: 24px;
max-width: 999px; padding: 18px;
} }
.login-card mat-card-content {
padding: 24px 20px;
} }
a { .form-header h2 {
text-decoration: none; font-size: 25px;
cursor: auto; }
color: #FFFFFF;
} }

View File

@@ -1,48 +1,382 @@
<div id="managerViewContainer" class="flex flex-column flex-1 gap-6 h-100"> <div class="qm-app" [class.object-mode]="activePage !== 'dashboard'" (click)="handleConsoleClick($event)">
<div id="schedulerBarContainer"> <aside class="rail" aria-label="Primary navigation">
<qrzmng-scheduler-control></qrzmng-scheduler-control> <div class="brand">
</div> <div class="brand-mark">QM</div>
<div>
<div id="manager-content-container" class="flex flex-row flex-1 gap-6"> <div class="brand-title">Quartz Manager</div>
<div class="flex-1" style="max-width: 250px"> <div class="brand-subtitle">Operations Console</div>
<div class="flex h-100">
<qrzmng-trigger-list
(onNewTriggerClicked)="onNewTriggerRequested()"
[openedNewTriggerForm]="newTriggerFormOpened"
(onSelectedTrigger)="setSelectedTrigger($event)"
class="h-100 w-100"></qrzmng-trigger-list>
</div> </div>
</div> </div>
<div class="flex-1" style="max-width: 350px"> <nav class="nav">
<div class="flex h-100"> <button type="button" [class.active]="activePage === 'dashboard'" [attr.aria-current]="activePage === 'dashboard' ? 'page' : null" (click)="selectPage('dashboard')">
<div class="flex flex-column h-100 w-100"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 13h8V3H3v10Zm10 8h8V3h-8v18ZM3 21h8v-6H3v6Z"/></svg><span>Dashboard</span>
<qrzmng-simple-trigger-config </button>
class="h-100 w-100" <button type="button" [class.active]="activePage === 'jobs'" [attr.aria-current]="activePage === 'jobs' ? 'page' : null" (click)="selectPage('jobs')">
[triggerKey]="selectedTriggerKey" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 8h10M7 12h10M7 16h6"/><rect x="4" y="4" width="16" height="16" rx="2"/></svg><span>Jobs</span>
(triggerFormOpenChange)="setNewTriggerFormOpened($event)" </button>
(onTriggerSubmitting)="monitorTrigger($event)" <button type="button" [class.active]="activePage === 'triggers'" [attr.aria-current]="activePage === 'triggers' ? 'page' : null" (click)="selectPage('triggers')">
(onNewTrigger)="onNewTriggerCreated($event)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="9"/></svg><span>Triggers</span>
</qrzmng-simple-trigger-config> </button>
<button type="button" [class.active]="activePage === 'calendars'" [attr.aria-current]="activePage === 'calendars' ? 'page' : null" (click)="selectPage('calendars')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 3v4M17 3v4M4 9h16M5 5h14v15H5z"/></svg><span>Calendars</span>
</button>
<button type="button" [class.active]="activePage === 'executions'" [attr.aria-current]="activePage === 'executions' ? 'page' : null" (click)="selectPage('executions')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 17h16M7 17V7m5 10V4m5 13v-6"/></svg><span>Executions</span>
</button>
<button type="button" [class.active]="activePage === 'events'" [attr.aria-current]="activePage === 'events' ? 'page' : null" (click)="selectPage('events')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg><span>Event Stream</span>
</button>
<button type="button" [class.active]="activePage === 'scheduler'" [attr.aria-current]="activePage === 'scheduler' ? 'page' : null" (click)="selectPage('scheduler')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="m19.4 15 .6 2-1.7 3-2.1-.5a8.3 8.3 0 0 1-2 1.1L13.5 23h-3l-.7-2.4a8.3 8.3 0 0 1-2-1.1l-2.1.5-1.7-3 .6-2a8.9 8.9 0 0 1 0-2.1l-.6-2 1.7-3 2.1.5a8.3 8.3 0 0 1 2-1.1l.7-2.4h3l.7 2.4a8.3 8.3 0 0 1 2 1.1l2.1-.5 1.7 3-.6 2a8.9 8.9 0 0 1 0 2.1Z"/></svg><span>Scheduler</span>
</button>
</nav>
<div class="rail-card">
<h3>Live channel</h3>
<div class="connection"><span>WebSocket</span><span class="chip success">OPEN</span></div>
</div> </div>
</aside>
<main class="main">
<header class="topbar">
<div class="scheduler-meta">
<div class="scheduler-title">
<h1>Quartz Operations Console</h1>
<div class="caption">{{ scheduler?.name || 'quartz-manager-scheduler' }} / compact context</div>
</div>
<span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span>
<div class="kv"><span>Instance ID</span><span>{{ scheduler?.instanceId || '-' }}</span></div>
<div class="kv"><span>Cluster</span><span>{{ scheduler?.clustered ? 'YES' : 'NO' }}</span></div>
<div class="kv"><span>WebSocket</span><span>OPEN</span></div>
</div>
<div class="actions compact-actions" aria-label="Compact scheduler status actions">
<button type="button" class="btn compact" (click)="toggleStandby()">{{ scheduler?.status === 'PAUSED' ? 'Resume' : 'Standby' }}</button>
<button type="button" class="btn compact" (click)="jumpToScheduler()">Scheduler</button>
</div>
</header>
<section class="content">
<div class="page" [class.active]="activePage === 'dashboard'">
<div class="dashboard-grid">
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Scheduler Command Center</h2><span class="caption">Supported lifecycle commands call the current backend</span></div>
<div class="card-body scheduler-command-grid">
<div class="command-panel">
<div class="command-row" aria-label="Dashboard scheduler actions">
<button type="button" class="btn primary" (click)="startScheduler()">Start</button>
<button type="button" class="btn" (click)="standbyScheduler()">Standby</button>
<button type="button" class="btn" (click)="resumeScheduler()">Resume</button>
<button type="button" class="btn" data-roadmap="Pause all trigger groups is not available in the current backend">Pause All</button>
<button type="button" class="btn danger" data-roadmap="Clear scheduler is not available in the current backend">Clear</button>
<button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button>
</div>
<div class="help">Global lifecycle operations are centralized here. Group-level and destructive data operations stay visible as roadmap actions until backend endpoints exist.</div>
</div>
<div class="metadata-grid">
<div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div>
<div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div>
<div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div>
<div class="field"><label>Triggers</label><strong>{{ triggerKeys.length }}</strong></div>
<div class="field"><label>Eligible jobs</label><strong>{{ jobs.length }}</strong></div>
<div class="field"><label>Quartz metadata</label><strong>{{ scheduler?.quartzVersion || '-' }}</strong></div>
</div>
</div>
</section>
<article class="card span-3"><div class="card-body metric"><span class="chip running">TRIGGERS</span><div class="metric-value">{{ triggerKeys.length }}</div><div class="metric-label">Trigger keys returned by backend</div><div class="metric-line"><span style="--w: 64%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip blocked">JOBS</span><div class="metric-value">{{ jobs.length }}</div><div class="metric-label">Eligible job classes</div><div class="metric-line"><span style="--w: 48%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip warn">EVENTS</span><div class="metric-value">{{ getExecutionLoadValue() }}</div><div class="metric-label">Logs received for selected trigger</div><div class="metric-line"><span style="--w: 32%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip accent">STATUS</span><div class="metric-value compact-metric">{{ scheduler?.status || '-' }}</div><div class="metric-label">Scheduler lifecycle state</div><div class="metric-line"><span style="--w: 67%"></span></div></div></article>
<section class="card span-7">
<div class="card-header"><h2 class="card-title">Next Scheduled Fires</h2><div class="toolbar"><span class="chip normal">LIVE</span><button type="button" class="btn" (click)="selectPage('triggers')">Open Triggers</button></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:22%">Trigger</th><th style="width:15%">Group</th><th style="width:18%">Type</th><th style="width:13%">State</th><th style="width:16%">Job</th><th style="width:16%">Next fire</th></tr></thead>
<tbody>
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)">
<td class="mono">{{ triggerKey.name }}</td>
<td class="mono">{{ getTriggerGroup(triggerKey) }}</td>
<td>{{ getTriggerType(triggerKey) }}</td>
<td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td>
<td class="mono">{{ getTriggerJobName(triggerKey) }}</td>
<td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td>
</tr>
} @empty {
<tr><td colspan="6">No triggers returned by the backend. Use the wizard to create a SimpleTrigger.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'dashboard'" aria-label="Trigger detail drawer">
@if (selectedTriggerKey) {
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey.name }}</h2><div class="caption">{{ getSelectedTriggerGroup() }} / linked to {{ getSelectedJobName() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" data-roadmap="Per-trigger execution history is on the roadmap">Executions</button><button type="button" class="tab" (click)="selectPage('events')">Logs</button></div>
<div class="field-grid">
<div class="field"><label>Previous fire</label><strong>{{ selectedTrigger?.timesTriggered ? 'tracked by progress events' : 'not exposed' }}</strong></div>
<div class="field"><label>Next fire</label><strong>{{ formatDateTime(selectedTrigger?.nextFireTime) || '-' }}</strong></div>
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
<div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
<div class="field"><label>Misfire</label><strong>{{ selectedTrigger?.misfireInstruction || '-' }}</strong></div>
<div class="field"><label>Repeat</label><strong>{{ getSelectedTriggerRepeatSummary() }}</strong></div>
</div>
<div class="progress-card"><div class="caption">Current run progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div>
<div class="actions"><button type="button" class="btn" (click)="pauseSelectedTrigger()">Pause</button><button type="button" class="btn" (click)="resumeSelectedTrigger()">Resume</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn danger" (click)="unscheduleSelectedTrigger()">Unschedule</button></div>
} @else {
<div class="drawer-title"><div><span class="chip warn">EMPTY</span><h2>No trigger selected</h2><div class="caption">Create a SimpleTrigger or refresh trigger keys.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
}
</aside>
</div>
</section>
<section class="card span-5">
<div class="card-header"><h2 class="card-title">Execution Load</h2><span class="caption">Analytics roadmap preview</span></div>
<div class="card-body">
<div class="mini-chart" data-roadmap="Execution analytics are not exposed by the current backend" aria-label="Execution load chart">
<span class="bar" style="--h:34%"></span><span class="bar" style="--h:42%"></span><span class="bar" style="--h:28%"></span><span class="bar" style="--h:62%"></span><span class="bar warn" style="--h:52%"></span><span class="bar" style="--h:38%"></span><span class="bar" style="--h:55%"></span><span class="bar" style="--h:72%"></span><span class="bar error" style="--h:44%"></span><span class="bar" style="--h:67%"></span><span class="bar" style="--h:46%"></span><span class="bar" style="--h:58%"></span><span class="bar warn" style="--h:81%"></span><span class="bar" style="--h:64%"></span><span class="bar" style="--h:35%"></span><span class="bar" style="--h:50%"></span><span class="bar" style="--h:70%"></span><span class="bar" style="--h:40%"></span>
</div>
<div class="field-grid top-space">
<div class="field"><label>Logs received</label><strong>{{ logs.length }}</strong></div>
<div class="field"><label>Current progress</label><strong>{{ getProgressPercentage() }}%</strong></div>
<button type="button" class="field field-button" data-roadmap="Misfire analytics are on the roadmap"><label>Misfires</label><strong>Roadmap</strong></button>
<button type="button" class="field field-button" data-roadmap="Recovering jobs endpoint is on the roadmap"><label>Recovering jobs</label><strong>Roadmap</strong></button>
</div>
</div>
</section>
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Event Stream</h2><div class="toolbar"><input class="search" value="Filter: selected trigger logs" data-roadmap="Event stream filtering is on the roadmap"><span class="chip normal">STREAMING</span><button type="button" class="btn" data-roadmap="Pausing the merged event stream is on the roadmap">Pause</button><button type="button" class="btn" data-roadmap="Event export is on the roadmap">Export</button></div></div>
<div class="stream">
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
@for (log of logs; track log.time) {
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
} @empty {
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Waiting for log messages from the selected trigger.</span></div>
}
</div>
</section>
</div> </div>
</div> </div>
<div class="flex-1"> <div class="page" [class.active]="activePage === 'jobs'">
<div class="h-100 min-h-100 flex flex-column gap-6"> <div class="page-kicker">
<div class="flex flex-column" > <div><h2>Jobs</h2><p>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.</p></div>
<progress-panel class="flex-1" <div class="toolbar"><input class="search" name="jobSearch" placeholder="Filter jobs, groups, classes" [(ngModel)]="jobSearch"><select class="select compact-select" name="jobGroupFilter" [(ngModel)]="jobGroupFilter"><option value="ALL">All groups</option>@for (group of getJobGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateJobWizard()">New Job</button></div>
[triggerKey]=monitoredTriggerKey
>
</progress-panel>
</div> </div>
<div class="flex flex-column flex-1" style="max-height: calc(100% - 136px); min-height: calc(100% - 210px);"> <section class="card">
<logs-panel class="flex flex-1 h-100 max-h-100" <div class="card-header"><h2 class="card-title">Scheduled Jobs</h2><div class="toolbar"><span class="chip normal">{{ getScheduledJobRows().length }} / {{ scheduledJobs.length }} JOBS</span><button type="button" class="btn" data-roadmap="Pause job group is on the roadmap">Pause Group</button><button type="button" class="btn" data-roadmap="Job export is on the roadmap">Export</button></div></div>
[triggerKey]=monitoredTriggerKey <div class="split">
> <div class="table-wrap">
</logs-panel> <table>
<thead><tr><th style="width:22%">Job key</th><th style="width:48%">Class</th><th style="width:10%">Durable</th><th style="width:10%">Recovery</th><th style="width:10%">Triggers</th></tr></thead>
<tbody>
@for (job of getScheduledJobRows(); track job.jobKeyDTO.group + '.' + job.jobKeyDTO.name) {
<tr class="selectable" [class.selected]="selectedScheduledJob?.jobKeyDTO?.name === job.jobKeyDTO.name && selectedScheduledJob?.jobKeyDTO?.group === job.jobKeyDTO.group" (click)="selectScheduledJob(job)"><td class="mono">{{ job.jobKeyDTO.group }}.{{ job.jobKeyDTO.name }}</td><td class="mono">{{ job.jobClassName }}</td><td><span class="chip" [ngClass]="job.durable ? 'normal' : 'warn'">{{ job.durable ? 'YES' : 'NO' }}</span></td><td><span class="chip" [ngClass]="job.requestsRecovery ? 'normal' : 'warn'">{{ job.requestsRecovery ? 'YES' : 'NO' }}</span></td><td class="mono">{{ job.triggerKeys?.length || 0 }}</td></tr>
} @empty {
<tr><td colspan="5">No scheduled jobs returned by the backend. Create a SimpleTrigger from an eligible job class.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'jobs'" aria-label="Job detail drawer">
<div class="drawer-title"><div><span class="chip normal">SCHEDULED</span><h2>{{ getSelectedJobShortName() }}</h2><div class="caption">{{ getSelectedJobKeyLabel() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab">Triggers</button><button type="button" class="tab">Data Map</button><button type="button" class="tab" data-roadmap="Job execution history is on the roadmap">Executions</button></div>
<div class="field-grid">
<div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div>
<div class="field"><label>Group</label><strong>{{ selectedScheduledJob?.jobKeyDTO?.group || '-' }}</strong></div>
<div class="field"><label>Durable</label><strong>{{ selectedScheduledJob?.durable ? 'YES' : 'NO' }}</strong></div>
<div class="field"><label>Requests recovery</label><strong>{{ selectedScheduledJob?.requestsRecovery ? 'YES' : 'NO' }}</strong></div>
</div>
<pre class="code-block">JobDataMap
{{ getSelectedJobDataMapPreview() }}</pre>
<pre class="code-block">Triggers
@for (triggerKey of selectedScheduledJob?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre>
<div class="actions"><button type="button" class="btn primary" (click)="triggerSelectedJobNow()">Trigger Now</button><button type="button" class="btn" (click)="openEditJobWizard()">Edit Job</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobTargetType = 'stored'; triggerDraft.storedJobKey = selectedScheduledJob ? selectedScheduledJob.jobKeyDTO.group + '::' + selectedScheduledJob.jobKeyDTO.name : triggerDraft.storedJobKey">Create SimpleTrigger</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Interrupt remains roadmap-gated. Delete uses the scheduled job endpoint.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Job interruption is on the roadmap">Interrupt</button><button type="button" class="btn danger" (click)="deleteSelectedJob()">Delete Job</button></div></div>
</aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'triggers'">
<div class="page-kicker">
<div><h2>Triggers</h2><p>The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.</p></div>
<div class="toolbar"><input class="search" name="triggerSearch" placeholder="Filter triggers, jobs, groups" [(ngModel)]="triggerSearch"><select class="select compact-select" name="triggerGroupFilter" [(ngModel)]="triggerGroupFilter"><option value="ALL">All groups</option>@for (group of getTriggerGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateTriggerWizard()">Create Trigger</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Trigger Inventory</h2><div class="toolbar"><span class="chip normal">{{ getTriggerRows().length }} / {{ triggerKeys.length }} TOTAL</span><span class="chip warn" data-roadmap="Trigger state counts are on the roadmap">STATE COUNTS ROADMAP</span></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:18%">Trigger</th><th style="width:12%">Group</th><th style="width:15%">Type</th><th style="width:12%">State</th><th style="width:18%">Job</th><th style="width:15%">Next fire</th><th style="width:10%">Misfire</th></tr></thead>
<tbody>
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)"><td class="mono">{{ triggerKey.name }}</td><td class="mono">{{ getTriggerGroup(triggerKey) }}</td><td>{{ getTriggerType(triggerKey) }}</td><td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td><td class="mono">{{ getTriggerJobName(triggerKey) }}</td><td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td><td class="mono">{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}</td></tr>
} @empty {
<tr><td colspan="7">No triggers returned by the backend.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'triggers'" aria-label="Trigger detail drawer">
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey?.name || 'No trigger' }}</h2><div class="caption">SimpleTrigger / {{ getSelectedTriggerGroup() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab">Schedule</button><button type="button" class="tab" data-roadmap="Quartz calendars are on the roadmap">Calendar</button><button type="button" class="tab" data-roadmap="Trigger execution history is on the roadmap">Executions</button></div>
<div class="field-grid">
<div class="field"><label>Linked job</label><strong>{{ getSelectedJobName() }}</strong></div>
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
<div class="field"><label>Final fire</label><strong>{{ formatDateTime(selectedTrigger?.finalFireTime) || 'none' }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Timezone metadata is on the roadmap"><label>Timezone</label><strong>Roadmap</strong></button>
<div class="field"><label>Repeat interval</label><strong>{{ selectedTrigger?.repeatInterval ? formatDuration(selectedTrigger.repeatInterval) : '-' }}</strong></div>
<div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
</div>
<section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section>
<pre class="code-block">Trigger JobDataMap
{{ getSelectedTriggerDataMapPreview() }}</pre>
<div class="actions"><button type="button" class="btn" (click)="pauseSelectedTrigger()">Pause</button><button type="button" class="btn" (click)="resumeSelectedTrigger()">Resume</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn" data-roadmap="Trigger duplication is on the roadmap">Duplicate</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Unschedule uses the trigger lifecycle endpoint. Reset-error remains roadmap-gated.</span><div class="actions"><button type="button" class="btn danger" (click)="unscheduleSelectedTrigger()">Unschedule</button><button type="button" class="btn danger" data-roadmap="Reset error trigger is on the roadmap">Reset Error</button></div></div>
</aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'calendars'">
<div class="page-kicker">
<div><h2>Calendars</h2><p>Manage Quartz calendar exclusions and inspect which triggers are attached to each calendar.</p></div>
<div class="toolbar"><input class="search" name="calendarSearch" placeholder="Filter calendars" [(ngModel)]="calendarSearch"><button type="button" class="btn primary" (click)="openCreateCalendarWizard()">New Calendar</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Calendar Registry</h2><span class="chip normal">{{ getCalendarRows().length }} / {{ calendars.length }} CALENDARS</span></div>
<div class="split">
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Type</th><th>Description</th><th>Triggers</th></tr></thead><tbody>@for (calendar of getCalendarRows(); track calendar.name) { <tr class="selectable" [class.selected]="selectedCalendar?.name === calendar.name" (click)="selectCalendar(calendar)"><td class="mono">{{ calendar.name }}</td><td><span class="chip accent">{{ calendar.type }}</span></td><td>{{ calendar.description || '-' }}</td><td class="mono">{{ calendar.triggerKeys?.length || 0 }}</td></tr> } @empty { <tr><td colspan="4">No calendars registered. Create one to exclude time windows from trigger firing.</td></tr> }</tbody></table></div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'calendars'" aria-label="Calendar detail drawer"><div class="drawer-title"><div><span class="chip accent">{{ selectedCalendar?.type || 'NONE' }}</span><h2>{{ selectedCalendar?.name || 'No calendar' }}</h2><div class="caption">{{ selectedCalendar?.description || 'Select a calendar to inspect rules.' }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="field-grid"><div class="field"><label>Type</label><strong>{{ selectedCalendar?.type || '-' }}</strong></div><div class="field"><label>Attached triggers</label><strong>{{ selectedCalendar?.triggerKeys?.length || 0 }}</strong></div><div class="field"><label>Cron</label><strong>{{ selectedCalendar?.cronExpression || '-' }}</strong></div><div class="field"><label>Time zone</label><strong>{{ selectedCalendar?.timeZone || '-' }}</strong></div></div><pre class="code-block">Triggers
@for (triggerKey of selectedCalendar?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre><div class="control"><label>Included time test</label><input class="input mono" type="datetime-local" name="calendarIncludedTime" [(ngModel)]="calendarDraft.includedTime"></div><div class="help">{{ calendarIncludedTimeResult || 'Test whether this calendar includes a specific timestamp.' }}</div><div class="actions"><button type="button" class="btn" (click)="testSelectedCalendarTime()">Test Time</button><button type="button" class="btn" (click)="openEditCalendarWizard()">Edit</button><button type="button" class="btn danger" (click)="deleteSelectedCalendar()">Delete</button></div></aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'executions'">
<div class="page-kicker">
<div><h2>Executions</h2><p>Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.</p></div>
<div class="toolbar"><input class="search" value="Filter running jobs" data-roadmap="Execution filtering is on the roadmap"><button type="button" class="btn" data-roadmap="Execution refresh endpoint is on the roadmap">Refresh</button></div>
</div>
<section class="card roadmap-card" data-roadmap="Currently executing jobs endpoint is on the roadmap">
<div class="card-header"><h2 class="card-title">Currently Executing Jobs</h2><div class="toolbar"><span class="chip warn">ROADMAP</span></div></div>
<div class="split">
<div class="table-wrap"><table><thead><tr><th>Fire instance</th><th>Job</th><th>Trigger</th><th>Run time</th><th>Node</th></tr></thead><tbody><tr class="selectable" (click)="openDetailDrawer()"><td class="mono">Roadmap</td><td class="mono">{{ getSelectedJobName() }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td class="mono">Roadmap</td><td class="mono">Roadmap</td></tr></tbody></table></div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'executions'" aria-label="Execution detail drawer"><div class="drawer-title"><div><span class="chip warn">ROADMAP</span><h2>Execution Inspector</h2><div class="caption">Live progress remains available through the selected trigger websocket.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="progress-card"><div class="caption">Selected trigger progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div><div class="warning-box"><strong>Interrupt confirmation</strong><span>Interrupt operations need backend support and explicit operator confirmation.</span></div><div class="actions"><button type="button" class="btn danger" data-roadmap="Interrupt by fire instance is on the roadmap">Interrupt Fire Instance</button><button type="button" class="btn danger" data-roadmap="Interrupt by job key is on the roadmap">Interrupt Job Key</button></div></aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'events'">
<div class="page-kicker">
<div><h2>Event Stream</h2><p>The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.</p></div>
<div class="toolbar"><input class="search" value="Search messages, job keys, fire ids" data-roadmap="Event searching is on the roadmap"><button type="button" class="btn" data-roadmap="Pause global stream is on the roadmap">Pause Stream</button><button type="button" class="btn" data-roadmap="Export CSV is on the roadmap">Export CSV</button></div>
</div>
<div class="two-column">
<section class="card">
<div class="card-header"><h2 class="card-title">Live Events</h2><div class="toolbar"><span class="chip normal">TRIGGER STREAM</span><span class="chip accent">{{ logs.length }} EVENTS</span></div></div>
<div class="stream tall-stream">
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
@for (log of logs; track log.time) {
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
} @empty {
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Select or fire a trigger to receive backend log messages.</span></div>
}
</div>
</section>
<aside class="filter-panel">
<h3>Filters</h3>
<div class="control"><label>Severity</label><select class="select" data-roadmap="Severity filtering is on the roadmap"><option>INFO, WARN, ERROR</option></select></div>
<div class="control"><label>Event type</label><select class="select" data-roadmap="Event type filtering is on the roadmap"><option>All event types</option></select></div>
<div class="control"><label>Job / trigger / group</label><input class="input" [value]="selectedTriggerKey?.name || ''" data-roadmap="Event text filtering is on the roadmap"></div>
<section class="preview"><h4>Supported now</h4><div>Per-trigger logs and progress through existing websocket topics.</div></section>
</aside>
</div> </div>
</div> </div>
<div class="page" [class.active]="activePage === 'scheduler'">
<div class="page-kicker">
<div><h2>Scheduler / Settings</h2><p>Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.</p></div>
<div class="toolbar"><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span><button type="button" class="btn" (click)="refreshScheduler()">Refresh Metadata</button></div>
</div>
<div class="dashboard-grid">
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Lifecycle Controls</h2><span class="caption">Global actions affect the scheduler instance</span></div>
<div class="card-body command-panel"><div class="command-row"><button type="button" class="btn primary" (click)="startScheduler()">Start</button><button type="button" class="btn" data-roadmap="Delayed start is on the roadmap">Delayed Start 60s</button><button type="button" class="btn" (click)="standbyScheduler()">Standby</button><button type="button" class="btn" (click)="resumeScheduler()">Resume</button><button type="button" class="btn" data-roadmap="Pause all trigger groups is on the roadmap">Pause All</button><button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button><button type="button" class="btn danger" data-roadmap="Clear scheduler is on the roadmap">Clear Scheduler</button></div><div class="warning-box"><strong>Strong confirmation required</strong><span>Shutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.</span></div></div>
</section>
<section class="card span-8">
<div class="card-header"><h2 class="card-title">Scheduler Metadata</h2><span class="chip accent">CURRENT API</span></div>
<div class="card-body summary-grid"><div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div><div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div><div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div><div class="field"><label>Trigger keys</label><strong>{{ triggerKeys.length }}</strong></div><div class="field"><label>Quartz version</label><strong>{{ scheduler?.quartzVersion || '-' }}</strong></div><div class="field"><label>Thread pool</label><strong>{{ scheduler?.threadPoolSize || '-' }}</strong></div><div class="field"><label>Job store</label><strong>{{ scheduler?.jobStoreClass || '-' }}</strong></div><div class="field"><label>Clustered</label><strong>{{ scheduler?.clustered ? 'YES' : 'NO' }}</strong></div></div>
</section>
<section class="card span-4">
<div class="card-header"><h2 class="card-title">Cluster Nodes</h2><span class="chip warn">ROADMAP</span></div>
<div class="card-body node-list" data-roadmap="Cluster node visibility is on the roadmap"><div class="node-row"><div><strong class="mono">{{ scheduler?.instanceId || 'local' }}</strong><div class="caption">local scheduler instance</div></div><span class="chip running">LOCAL</span></div><div class="node-row"><div><strong class="mono">remote nodes</strong><div class="caption">not exposed by backend</div></div><span class="chip warn">ROADMAP</span></div></div>
</section>
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Global State Overview</h2><div class="toolbar"><span class="chip normal">{{ triggerKeys.length }} TRIGGERS</span><span class="chip warn">ANALYTICS ROADMAP</span></div></div>
<div class="table-wrap"><table><thead><tr><th>Area</th><th>Current state</th><th>Count</th><th>Representative key</th><th>Recommended action</th></tr></thead><tbody><tr><td>Scheduler</td><td><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || '-' }}</span></td><td class="mono">1</td><td class="mono">{{ scheduler?.instanceId || '-' }}</td><td>Use lifecycle controls above.</td></tr><tr><td>Triggers</td><td><span class="chip normal">LISTED</span></td><td class="mono">{{ triggerKeys.length }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td>Open Triggers for details or reschedule SimpleTriggers.</td></tr><tr data-roadmap="Misfire and error trigger analytics are on the roadmap"><td>Misfires / errors</td><td><span class="chip warn">ROADMAP</span></td><td class="mono">Roadmap</td><td class="mono">Roadmap</td><td>Backend analytics needed.</td></tr></tbody></table></div>
</section>
</div> </div>
</div> </div>
</section>
</main>
@if (wizardOpen || jobWizardOpen || calendarWizardOpen || detailDrawerOpen) {
<button type="button" class="drawer-backdrop" aria-label="Close drawer" (click)="closeDrawers()"></button>
}
@if (roadmapNotice || operationNotice || operationError) {
<section class="toast-overlay" [class.error]="operationError" [class.success]="operationNotice && !operationError">
<div class="toast-kicker">{{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}</div>
<div class="toast-message">{{ operationError || roadmapNotice || operationNotice }}</div>
<button type="button" class="toast-close" (click)="dismissNotice()">Dismiss</button>
</section>
}
<aside class="wizard drawer" [class.drawer-open]="wizardOpen" aria-label="Trigger creation wizard">
<div class="wizard-header"><div><h2>{{ getWizardTitle() }}</h2><div class="caption">Simple, Cron, Daily Time Interval, and Calendar Interval triggers are supported.</div></div><button type="button" class="drawer-close" (click)="closeWizardDrawer()">Close</button></div>
<div class="stepper"><div class="step done"><span></span><span>Identity</span></div><div class="step active"><span></span><span>Type</span></div><div class="step done"><span></span><span>Schedule</span></div><div class="step done"><span></span><span>Advanced</span></div><div class="step active"><span></span><span>Preview</span></div></div>
<form class="wizard-form" (ngSubmit)="submitTriggerWizard()">
<div class="wizard-scroll">
@if (wizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ wizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" list="trigger-groups" required></div><datalist id="trigger-groups">@for (group of getTriggerGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Quartz groups are implicit namespaces. Type a new group to create it with this trigger.</div></div><div class="control"><label>Target type</label><select class="select" name="jobTargetType" [(ngModel)]="triggerDraft.jobTargetType"><option value="stored">Existing stored job</option><option value="class">New job from class</option></select></div>@if (triggerDraft.jobTargetType === 'stored') { <div class="control"><label>Stored job</label><select class="select" name="storedJobKey" [(ngModel)]="triggerDraft.storedJobKey" required>@for (job of getStoredJobOptions(); track job.value) { <option [value]="job.value">{{ job.label }}</option> }</select><div class="help">The trigger will call TriggerBuilder.forJob with this stored job key.</div></div> } @else { <div class="control"><label>Job class</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">The backend will create an ephemeral job for this trigger.</div></div> }</div></section>
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'SIMPLE'" (click)="selectTriggerType('SIMPLE')"><strong>Simple</strong><span class="help">Repeat every fixed interval.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CRON'" (click)="selectTriggerType('CRON')"><strong>Cron</strong><span class="help">Cron expression schedule.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'DAILY_TIME_INTERVAL'" (click)="selectTriggerType('DAILY_TIME_INTERVAL')"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CALENDAR_INTERVAL'" (click)="selectTriggerType('CALENDAR_INTERVAL')"><strong>Calendar Interval</strong><span class="help">Every N calendar units.</span></button></div></div></section>
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div>@if (triggerDraft.triggerType === 'CRON') { <div class="control"><label>Cron expression</label><input class="input mono" name="cronExpression" [(ngModel)]="triggerDraft.cronExpression" required><div class="help">Quartz cron format, for example 0 0/5 * * * ?</div></div><div class="control"><label>Timezone</label><input class="input mono" name="cronTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> } @else { <div class="control"><label>Interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds" [disabled]="triggerDraft.triggerType !== 'SIMPLE'">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option><option value="weeks" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">weeks</option><option value="months" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">months</option><option value="years" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">years</option></select></div></div> } @if (triggerDraft.triggerType === 'SIMPLE') { <div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div> } @if (triggerDraft.triggerType === 'DAILY_TIME_INTERVAL') { <div class="control"><label>Daily window</label><div class="input-row"><input class="input mono" name="startTimeOfDay" [(ngModel)]="triggerDraft.startTimeOfDay"><input class="input mono" name="endTimeOfDay" [(ngModel)]="triggerDraft.endTimeOfDay"></div></div><div class="control"><label>Days of week</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="isDayOfWeekSelected(day)" (click)="toggleDayOfWeek(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (triggerDraft.triggerType === 'CALENDAR_INTERVAL') { <label class="check-row"><input type="checkbox" name="preserveHour" [(ngModel)]="triggerDraft.preserveHourOfDayAcrossDaylightSavings"> Preserve hour across daylight saving</label><label class="check-row"><input type="checkbox" name="skipMissingHour" [(ngModel)]="triggerDraft.skipDayIfHourDoesNotExist"> Skip day if hour does not exist</label><div class="control"><label>Timezone</label><input class="input mono" name="calendarIntervalTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> }</div></section>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Calendar</label><select class="select" name="calendarName" [(ngModel)]="triggerDraft.calendarName"><option value="">No calendar</option>@for (calendarName of getCalendarOptions(); track calendarName) { <option [value]="calendarName">{{ calendarName }}</option> }</select></div><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required>@if (triggerDraft.triggerType === 'SIMPLE') { <option value="MISFIRE_INSTRUCTION_FIRE_NOW">FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option> } @else { <option value="FIRE_AND_PROCEED">FIRE_AND_PROCEED</option><option value="DO_NOTHING">DO_NOTHING</option><option value="IGNORE_MISFIRES">IGNORE_MISFIRES</option> }</select></div><div class="control"><label>JobDataMap override</label><div class="data-map-editor">@for (entry of triggerDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="triggerDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="triggerDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="triggerDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(triggerDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(triggerDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getTriggerDraftDataMapPreview() }}</pre></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> as a <strong>{{ triggerDraft.triggerType }}</strong> trigger, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="resetWizard()">Reset</button><button type="submit" class="btn primary" [disabled]="wizardSubmitting || !canSubmitTrigger()">{{ wizardSubmitting ? 'Saving...' : getWizardCta() }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="jobWizardOpen" aria-label="Stored job editor">
<div class="wizard-header"><div><h2>{{ jobWizardMode === 'edit' ? 'Edit Stored Job' : 'New Stored Job' }}</h2><div class="caption">Stored jobs are durable Quartz JobDetails that triggers can launch by job key.</div></div><button type="button" class="drawer-close" (click)="closeJobWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitJobWizard()">
<div class="wizard-scroll">
@if (jobWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ jobWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Job key</label><div class="input-row"><input class="input" name="jobName" [(ngModel)]="jobDraft.name" [readonly]="jobWizardMode === 'edit'" required><input class="input mono" name="jobGroup" [(ngModel)]="jobDraft.group" [readonly]="jobWizardMode === 'edit'" list="job-groups" required></div><datalist id="job-groups">@for (group of getJobGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Groups are implicit. Typing a new group stores the job under that namespace.</div></div><div class="control"><label>Job class</label><select class="select" name="storedJobClass" [(ngModel)]="jobDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select></div><div class="control"><label>Description</label><input class="input" name="jobDescription" [(ngModel)]="jobDraft.description"></div></div></section>
<section class="form-card"><h3>Options</h3><div class="form-section"><label class="check-row"><input type="checkbox" name="jobDurable" [(ngModel)]="jobDraft.durable"> Store durably</label><label class="check-row"><input type="checkbox" name="jobRecovery" [(ngModel)]="jobDraft.requestsRecovery"> Requests recovery</label><div class="help">Durable jobs remain in the scheduler without active triggers and can be selected later by SimpleTriggers.</div></div></section>
<section class="form-card"><h3>JobDataMap</h3><div class="form-section"><div class="data-map-editor">@for (entry of jobDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="jobDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="jobDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="jobDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(jobDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(jobDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getJobDraftDataMapPreview() }}</pre></div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeJobWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="jobWizardSubmitting || !canSubmitJob()">{{ jobWizardSubmitting ? 'Saving...' : jobWizardMode === 'edit' ? 'Save Job' : 'Create Job' }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="calendarWizardOpen" aria-label="Calendar editor">
<div class="wizard-header"><div><h2>{{ calendarWizardMode === 'edit' ? 'Edit Calendar' : 'New Calendar' }}</h2><div class="caption">Quartz calendars exclude times from trigger schedules.</div></div><button type="button" class="drawer-close" (click)="closeCalendarWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitCalendarWizard()">
<div class="wizard-scroll">
@if (calendarWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ calendarWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Calendar name</label><input class="input mono" name="calendarNameInput" [(ngModel)]="calendarDraft.name" [readonly]="calendarWizardMode === 'edit'" required></div><div class="control"><label>Type</label><select class="select" name="calendarType" [(ngModel)]="calendarDraft.type"><option value="WEEKLY">Weekly</option><option value="MONTHLY">Monthly</option><option value="ANNUAL">Annual</option><option value="HOLIDAY">Holiday</option><option value="DAILY">Daily</option><option value="CRON">Cron</option></select></div><div class="control"><label>Description</label><input class="input" name="calendarDescription" [(ngModel)]="calendarDraft.description"></div></div></section>
<section class="form-card"><h3>Rules</h3><div class="form-section">@if (getCalendarRuleMode() === 'weekdays') { <div class="control"><label>Excluded days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfWeek.includes(day)" (click)="toggleCalendarWeekday(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (getCalendarRuleMode() === 'monthdays') { <div class="control"><label>Excluded month days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfMonth.includes(day)" (click)="toggleCalendarMonthday(day)">{{ day }}</button> }</div></div> } @if (getCalendarRuleMode() === 'dates') { <div class="control"><label>Excluded dates</label><div class="data-map-editor">@for (date of calendarDraft.excludedDates; track $index) { <div class="data-map-row"><input class="input mono" type="datetime-local" name="calendarDate{{$index}}" [(ngModel)]="calendarDraft.excludedDates[$index]"><button type="button" class="btn danger compact" (click)="removeCalendarDate($index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addCalendarDate()">Add Date</button></div> } @if (getCalendarRuleMode() === 'timeRange') { <div class="control"><label>Excluded time range</label><div class="input-row"><input class="input mono" name="rangeStartingTime" [(ngModel)]="calendarDraft.rangeStartingTime"><input class="input mono" name="rangeEndingTime" [(ngModel)]="calendarDraft.rangeEndingTime"></div></div><label class="check-row"><input type="checkbox" name="invertTimeRange" [(ngModel)]="calendarDraft.invertTimeRange"> Invert time range</label> } @if (getCalendarRuleMode() === 'cron') { <div class="control"><label>Cron exclusion expression</label><input class="input mono" name="calendarCron" [(ngModel)]="calendarDraft.cronExpression" required></div><div class="control"><label>Timezone</label><input class="input mono" name="calendarTimeZone" [(ngModel)]="calendarDraft.timeZone"></div> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeCalendarWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="calendarWizardSubmitting || !canSubmitCalendar()">{{ calendarWizardSubmitting ? 'Saving...' : calendarWizardMode === 'edit' ? 'Save Calendar' : 'Create Calendar' }}</button></div>
</form>
</aside>
</div> </div>

View File

@@ -1,10 +1,353 @@
:host { :host {
display: flex; --bg: oklch(98% 0.005 250);
flex-direction: column; --surface: oklch(100% 0 0);
flex: 1; --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 { * { box-sizing: border-box; }
height: calc(100% - 80px); button, input, select, textarea { font: inherit; }
max-height: calc(100% - 80px); 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; }
} }

View File

@@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"outDir": "../out-tsc/spec", "outDir": "../out-tsc/spec",
"baseUrl": "", "baseUrl": "",
"importHelpers": false,
"types": [ "types": [
"jasmine", "jasmine",
"node" "node"

View File

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

View File

@@ -8,26 +8,40 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts; import it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts;
import it.fabioformosa.quartzmanager.api.common.config.QuartzManagerPaths; 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 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.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.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; 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) @SecurityRequirement(name = OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA)
@RestController @RestController
public class JobController { public class JobController {
public static final String JOB_CONTROLLER_BASE_URL = QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH + "/jobs"; 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; private final JobService jobService;
public JobController(JobService jobService) { public JobController(JobService jobService) {
this.jobService = jobService; this.jobService = jobService;
} }
@GetMapping @GetMapping("/job-classes")
@Operation(summary = "Get the list of job classes eligible for Quartz-Manager") @Operation(summary = "Get the list of job classes eligible for Quartz-Manager")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Return a list of qualified java classes", @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()); 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<ScheduledJobDTO> 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);
}
} }

View File

@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
import org.quartz.SchedulerException; import org.quartz.SchedulerException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -51,21 +52,21 @@ public class SchedulerController {
return schedulerService.getScheduler(); return schedulerService.getScheduler();
} }
@GetMapping("/pause") @PostMapping("/standby")
@Operation(summary = "Get paused the scheduler") @Operation(summary = "Put the scheduler in standby mode")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Got paused successfully") @ApiResponse(responseCode = "204", description = "Scheduler moved to standby successfully")
}) })
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void pause() throws SchedulerException { public void standby() throws SchedulerException {
log.info("SCHEDULER - PAUSE COMMAND"); log.info("SCHEDULER - STANDBY COMMAND");
schedulerService.standby(); schedulerService.standby();
} }
@GetMapping("/resume") @PostMapping("/resume")
@Operation(summary = "Get resumed the scheduler") @Operation(summary = "Resume the scheduler from standby mode")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Got resumed successfully") @ApiResponse(responseCode = "204", description = "Scheduler resumed successfully")
}) })
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void resume() throws SchedulerException { public void resume() throws SchedulerException {
@@ -73,25 +74,25 @@ public class SchedulerController {
schedulerService.start(); schedulerService.start();
} }
@GetMapping("/run") @PostMapping("/start")
@Operation(summary = "Start the scheduler") @Operation(summary = "Start the scheduler")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Got started successfully") @ApiResponse(responseCode = "204", description = "Scheduler started successfully")
}) })
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void run() throws SchedulerException { public void start() throws SchedulerException {
log.info("SCHEDULER - START COMMAND"); log.info("SCHEDULER - START COMMAND");
schedulerService.start(); schedulerService.start();
} }
@GetMapping("/stop") @PostMapping("/shutdown")
@Operation(summary = "Stop the scheduler") @Operation(summary = "Shutdown the scheduler terminally")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Got stopped successfully") @ApiResponse(responseCode = "204", description = "Scheduler shut down successfully")
}) })
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void stop() throws SchedulerException { public void shutdown() throws SchedulerException {
log.info("SCHEDULER - STOP COMMAND"); log.info("SCHEDULER - SHUTDOWN COMMAND");
schedulerService.shutdown(); schedulerService.shutdown();
} }

View File

@@ -35,7 +35,7 @@ public class SimpleTriggerController {
this.simpleSchedulerService = simpleSchedulerService; this.simpleSchedulerService = simpleSchedulerService;
} }
@GetMapping("/{name}") @GetMapping("/{group}/{name}")
@Operation(summary = "Get a simple trigger by name") @Operation(summary = "Get a simple trigger by name")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Got the trigger by its name", @ApiResponse(responseCode = "200", description = "Got the trigger by its name",
@@ -44,11 +44,11 @@ public class SimpleTriggerController {
@ApiResponse(responseCode = "404", description = "Trigger not found", @ApiResponse(responseCode = "404", description = "Trigger not found",
content = @Content) content = @Content)
}) })
public SimpleTriggerDTO getSimpleTrigger(@PathVariable String name) throws SchedulerException, TriggerNotFoundException { public SimpleTriggerDTO getSimpleTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException {
return simpleSchedulerService.getSimpleTriggerByName(name); return simpleSchedulerService.getSimpleTrigger(group, name);
} }
@PostMapping("/{name}") @PostMapping("/{group}/{name}")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Schedule a new simple trigger") @Operation(summary = "Schedule a new simple trigger")
@ApiResponses(value = { @ApiResponses(value = {
@@ -58,10 +58,11 @@ public class SimpleTriggerController {
@ApiResponse(responseCode = "400", description = "Invalid trigger configuration", @ApiResponse(responseCode = "400", description = "Invalid trigger configuration",
content = @Content) 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); log.info("SIMPLE TRIGGER - CREATING a SimpleTrigger {} {}", name, simpleTriggerInputDTO);
SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder()
.triggerName(name) .triggerName(name)
.triggerGroup(group)
.simpleTriggerInputDTO(simpleTriggerInputDTO) .simpleTriggerInputDTO(simpleTriggerInputDTO)
.build(); .build();
SimpleTriggerDTO newTriggerDTO = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO); SimpleTriggerDTO newTriggerDTO = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO);
@@ -69,7 +70,7 @@ public class SimpleTriggerController {
return newTriggerDTO; return newTriggerDTO;
} }
@PutMapping("/{name}") @PutMapping("/{group}/{name}")
@Operation(summary = "Reschedule a simple trigger") @Operation(summary = "Reschedule a simple trigger")
@ApiResponses(value = { @ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Rescheduled a simple trigger", @ApiResponse(responseCode = "200", description = "Rescheduled a simple trigger",
@@ -78,10 +79,11 @@ public class SimpleTriggerController {
@ApiResponse(responseCode = "400", description = "Invalid trigger configuration", @ApiResponse(responseCode = "400", description = "Invalid trigger configuration",
content = @Content) 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); log.info("SIMPLE TRIGGER - RESCHEDULING the trigger {} {}", name, simpleTriggerInputDTO);
SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder()
.triggerName(name) .triggerName(name)
.triggerGroup(group)
.simpleTriggerInputDTO(simpleTriggerInputDTO) .simpleTriggerInputDTO(simpleTriggerInputDTO)
.build(); .build();
TriggerDTO triggerDTO = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO); TriggerDTO triggerDTO = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO);

View File

@@ -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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; 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 it.fabioformosa.quartzmanager.api.services.TriggerService;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.quartz.SchedulerException; 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.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.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.text.ParseException;
import java.util.List; 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.OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA;
@@ -44,4 +56,56 @@ public class TriggerController {
return triggerService.fetchTriggers(); 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);
}
} }

View File

@@ -1,8 +1,11 @@
package it.fabioformosa.quartzmanager.api.controllers.advices; package it.fabioformosa.quartzmanager.api.controllers.advices;
import it.fabioformosa.quartzmanager.api.exceptions.ExceptionResponse; 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.ResourceConflictException;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.UnsupportedTriggerTypeException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice; 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(); 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<ExceptionResponse> unsupportedTriggerType(UnsupportedTriggerTypeException ex) {
ExceptionResponse response = ExceptionResponse.builder()
.errorCode(HttpStatus.CONFLICT.toString())
.errorMessage(ex.getMessage())
.build();
return new ResponseEntity<>(response, HttpStatus.CONFLICT);
}
} }

View File

@@ -6,9 +6,13 @@ import it.fabioformosa.quartzmanager.api.enums.SchedulerStatus;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.quartz.Scheduler; import org.quartz.Scheduler;
import org.quartz.SchedulerException; import org.quartz.SchedulerException;
import org.quartz.SchedulerMetaData;
import org.quartz.impl.matchers.GroupMatcher; import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component @Component
public class SchedulerToSchedulerDTO extends AbstractBaseConverterToDTO<Scheduler, SchedulerDTO> { public class SchedulerToSchedulerDTO extends AbstractBaseConverterToDTO<Scheduler, SchedulerDTO> {
@@ -20,6 +24,16 @@ public class SchedulerToSchedulerDTO extends AbstractBaseConverterToDTO<Schedule
if(!source.isShutdown()) if(!source.isShutdown())
target.setTriggerKeys(source.getTriggerKeys(GroupMatcher.anyTriggerGroup())); target.setTriggerKeys(source.getTriggerKeys(GroupMatcher.anyTriggerGroup()));
target.setStatus(buildTheSchedulerStatus(source)); target.setStatus(buildTheSchedulerStatus(source));
SchedulerMetaData metaData = source.getMetaData();
target.setQuartzVersion(metaData.getVersion());
target.setJobStoreClass(metaData.getJobStoreClass().getName());
target.setJobStoreSupportsPersistence(metaData.isJobStoreSupportsPersistence());
target.setClustered(metaData.isJobStoreClustered());
target.setThreadPoolClass(metaData.getThreadPoolClass().getName());
target.setThreadPoolSize(metaData.getThreadPoolSize());
target.setNumberOfJobsExecuted(metaData.getNumberOfJobsExecuted());
if (metaData.getRunningSince() != null)
target.setRunningSince(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(metaData.getRunningSince().toInstant().atOffset(ZoneOffset.UTC)));
} }
private SchedulerStatus buildTheSchedulerStatus(Scheduler scheduler) throws SchedulerException { private SchedulerStatus buildTheSchedulerStatus(Scheduler scheduler) throws SchedulerException {

View File

@@ -35,7 +35,7 @@ public class SimpleTriggerCommandDTOToSimpleTrigger implements Converter<SimpleT
return triggerTriggerBuilder.withSchedule( return triggerTriggerBuilder.withSchedule(
scheduleBuilder scheduleBuilder
) )
.withIdentity(triggerCommandDTO.getTriggerName()).build(); .withIdentity(triggerCommandDTO.getTriggerName(), triggerCommandDTO.getTriggerGroup()).build();
} }
private static void setTheMisfireInstruction(SimpleTriggerCommandDTO triggerCommandDTO, SimpleScheduleBuilder scheduleBuilder) { private static void setTheMisfireInstruction(SimpleTriggerCommandDTO triggerCommandDTO, SimpleScheduleBuilder scheduleBuilder) {

View File

@@ -25,6 +25,9 @@ public class TriggerToTriggerDTO<S extends Trigger, T extends TriggerDTO> extend
target.setFinalFireTime(source.getFinalFireTime()); target.setFinalFireTime(source.getFinalFireTime());
target.setMisfireInstruction(source.getMisfireInstruction()); target.setMisfireInstruction(source.getMisfireInstruction());
target.setNextFireTime(source.getNextFireTime()); target.setNextFireTime(source.getNextFireTime());
target.setPreviousFireTime(source.getPreviousFireTime());
target.setCalendarName(source.getCalendarName());
target.setType(source.getClass().getSimpleName());
target.setPriority(source.getPriority()); target.setPriority(source.getPriority());
target.setMayFireAgain(source.mayFireAgain()); target.setMayFireAgain(source.mayFireAgain());

View File

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

View File

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

View File

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

View File

@@ -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<String, ?> jobDataMap;
private List<TriggerKeyDTO> triggerKeys;
}

View File

@@ -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<String, ?> jobDataMap;
}

View File

@@ -18,4 +18,12 @@ public class SchedulerDTO {
private String instanceId; private String instanceId;
private SchedulerStatus status; private SchedulerStatus status;
private Set<TriggerKey> triggerKeys; private Set<TriggerKey> 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;
} }

View File

@@ -9,5 +9,6 @@ import lombok.*;
@ToString @ToString
public class SimpleTriggerCommandDTO { public class SimpleTriggerCommandDTO {
private String triggerName; private String triggerName;
private String triggerGroup;
private SimpleTriggerInputDTO simpleTriggerInputDTO; private SimpleTriggerInputDTO simpleTriggerInputDTO;
} }

View File

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

View File

@@ -1,6 +1,8 @@
package it.fabioformosa.quartzmanager.api.dto; package it.fabioformosa.quartzmanager.api.dto;
import it.fabioformosa.quartzmanager.api.validators.ValidTriggerRepetition; 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.*;
import lombok.experimental.SuperBuilder; import lombok.experimental.SuperBuilder;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
@@ -8,13 +10,14 @@ import jakarta.validation.constraints.Positive;
import java.util.Map; import java.util.Map;
@ValidTriggerRepetition @ValidTriggerRepetition
@ValidJobTarget
@SuperBuilder @SuperBuilder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@Data @Data
@ToString(callSuper = true) @ToString(callSuper = true)
public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO { public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO, JobTargetDTO {
private Integer repeatCount; private Integer repeatCount;
@Positive @Positive
@@ -22,4 +25,7 @@ public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerR
@Nullable @Nullable
private Map<String, ?> jobDataMap; private Map<String, ?> jobDataMap;
@Nullable
private JobKeyDTO jobKey;
} }

View File

@@ -5,7 +5,6 @@ import it.fabioformosa.quartzmanager.api.validators.ValidTriggerPeriod;
import lombok.*; import lombok.*;
import lombok.experimental.SuperBuilder; import lombok.experimental.SuperBuilder;
import jakarta.validation.constraints.NotBlank;
import java.util.Date; import java.util.Date;
@ValidTriggerPeriod @ValidTriggerPeriod
@@ -16,7 +15,6 @@ import java.util.Date;
@ToString @ToString
@Data @Data
public class TriggerCommandDTO implements TriggerPeriodDTO { public class TriggerCommandDTO implements TriggerPeriodDTO {
@NotBlank
private String jobClass; private String jobClass;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")

View File

@@ -7,6 +7,7 @@ import lombok.experimental.SuperBuilder;
import org.quartz.JobDataMap; import org.quartz.JobDataMap;
import java.util.Date; import java.util.Date;
import java.util.Set;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@@ -21,8 +22,22 @@ public class TriggerDTO {
private Date finalFireTime; private Date finalFireTime;
private int misfireInstruction; private int misfireInstruction;
private Date nextFireTime; private Date nextFireTime;
private Date previousFireTime;
private String type;
private String state;
private String calendarName;
private JobKeyDTO jobKeyDTO; private JobKeyDTO jobKeyDTO;
private JobDetailDTO jobDetailDTO; private JobDetailDTO jobDetailDTO;
private boolean mayFireAgain; private boolean mayFireAgain;
private JobDataMap jobDataMap; private JobDataMap jobDataMap;
private String cronExpression;
private String timeZone;
private Long repeatInterval;
private Integer repeatCount;
private String repeatIntervalUnit;
private String startTimeOfDay;
private String endTimeOfDay;
private Set<Integer> daysOfWeek;
private Boolean preserveHourOfDayAcrossDaylightSavings;
private Boolean skipDayIfHourDoesNotExist;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,4 +12,8 @@ public class ResourceConflictException extends RuntimeException {
super("Conflict on resourceID " + resourceId + " " + message); super("Conflict on resourceID " + resourceId + " " + message);
} }
public ResourceConflictException(String message) {
super(message);
}
} }

View File

@@ -4,4 +4,8 @@ public class TriggerNotFoundException extends Exception {
public TriggerNotFoundException(String name) { public TriggerNotFoundException(String name) {
super("Trigger with name " + name + " not found!"); super("Trigger with name " + name + " not found!");
} }
public TriggerNotFoundException(String group, String name) {
super("Trigger " + group + "." + name + " not found!");
}
} }

View File

@@ -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");
}
}

View File

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

View File

@@ -1,11 +1,29 @@
package it.fabioformosa.quartzmanager.api.services; 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 it.fabioformosa.quartzmanager.api.jobs.AbstractQuartzManagerJob;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; 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.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.beans.factory.annotation.Value;
import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -20,8 +38,19 @@ public class JobService {
private List<Class<? extends AbstractQuartzManagerJob>> jobClasses = new ArrayList<>(); private List<Class<? extends AbstractQuartzManagerJob>> jobClasses = new ArrayList<>();
private List<String> jobClassPackages = new ArrayList<>(); private List<String> 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<String> splitPackages = Arrays.stream(Optional.of(jobClassPackages).map(str -> str.split(",")) List<String> splitPackages = Arrays.stream(Optional.of(jobClassPackages).map(str -> str.split(","))
.orElseThrow(() -> new RuntimeException("The prop quartz-manager.jobClassPackages cannot be blank!"))) .orElseThrow(() -> new RuntimeException("The prop quartz-manager.jobClassPackages cannot be blank!")))
.map(String::trim) .map(String::trim)
@@ -47,4 +76,91 @@ public class JobService {
return reflections.getSubTypesOf(AbstractQuartzManagerJob.class); return reflections.getSubTypesOf(AbstractQuartzManagerJob.class);
} }
public List<ScheduledJobDTO> fetchScheduledJobs() throws SchedulerException {
Set<JobKey> 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<? extends Job> 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<? extends Job> 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<TriggerKeyDTO> 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);
}
}
} }

View File

@@ -2,7 +2,9 @@ package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerCommandDTO; import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerCommandDTO;
import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerDTO; 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.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.UnsupportedTriggerTypeException;
import org.quartz.*; import org.quartz.*;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.ConversionService;
@@ -11,32 +13,67 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SimpleTriggerService extends AbstractSchedulerService { 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); super(scheduler, conversionService);
this.jobService = jobService;
} }
public SimpleTriggerDTO getSimpleTriggerByName(String name) throws SchedulerException, TriggerNotFoundException { public SimpleTriggerDTO getSimpleTriggerByName(String name) throws SchedulerException, TriggerNotFoundException {
Trigger trigger = getTriggerByName(name); return getSimpleTrigger("DEFAULT", name);
return conversionService.convert(trigger, SimpleTriggerDTO.class); }
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 { public SimpleTriggerDTO scheduleSimpleTrigger(SimpleTriggerCommandDTO simpleTriggerCommandDTO) throws SchedulerException, ClassNotFoundException {
Class<? extends Job> jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class); 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);
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<? extends Job> jobClass = jobService.getEligibleJobClass(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass());
JobDetail jobDetail = JobBuilder.newJob() JobDetail jobDetail = JobBuilder.newJob()
.ofType(jobClass) .ofType(jobClass)
.storeDurably(false) .storeDurably(false)
.build(); .build();
SimpleTrigger newSimpleTrigger = conversionService.convert(simpleTriggerCommandDTO, SimpleTrigger.class);
scheduler.scheduleJob(jobDetail, newSimpleTrigger); scheduler.scheduleJob(jobDetail, newSimpleTrigger);
}
return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class);
} }
public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException { public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException, TriggerNotFoundException {
SimpleTrigger newSimpleTrigger = conversionService.convert(triggerCommandDTO, SimpleTrigger.class); 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); scheduler.rescheduleJob(triggerKey, newSimpleTrigger);
return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class);

View File

@@ -1,26 +1,58 @@
package it.fabioformosa.quartzmanager.api.services; 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.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.Scheduler;
import org.quartz.SchedulerException; 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.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher; import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.ConversionService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.TimeZone;
@Service @Service
public class TriggerService { public class TriggerService {
private Scheduler scheduler; private static final int DEFAULT_PRIORITY = Trigger.DEFAULT_PRIORITY;
private ConversionService conversionService; 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.scheduler = scheduler;
this.conversionService = conversionService; this.conversionService = conversionService;
this.jobService = jobService;
} }
public List<TriggerKeyDTO> fetchTriggers() throws SchedulerException { public List<TriggerKeyDTO> fetchTriggers() throws SchedulerException {
@@ -30,4 +62,251 @@ public class TriggerService {
.toList(); .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<Trigger> triggerBuilder = TriggerBuilder.newTrigger()
.withIdentity(name, group)
.withPriority(triggerInputDTO.getPriority() == null ? DEFAULT_PRIORITY : triggerInputDTO.getPriority());
if (triggerInputDTO.getStartDate() != null)
triggerBuilder.startAt(triggerInputDTO.getStartDate());
if (triggerInputDTO.getEndDate() != null)
triggerBuilder.endAt(triggerInputDTO.getEndDate());
if (triggerInputDTO.getDescription() != null)
triggerBuilder.withDescription(triggerInputDTO.getDescription());
if (triggerInputDTO.getCalendarName() != null && !triggerInputDTO.getCalendarName().isBlank())
triggerBuilder.modifiedByCalendar(triggerInputDTO.getCalendarName());
if (triggerInputDTO.getJobDataMap() != null)
triggerBuilder.usingJobData(new JobDataMap(triggerInputDTO.getJobDataMap()));
return triggerBuilder.withSchedule(buildSchedule(triggerInputDTO)).build();
}
private ScheduleBuilder<?> buildSchedule(TriggerInputDTO triggerInputDTO) throws ParseException {
TriggerType triggerType = triggerInputDTO.getTriggerType() == null ? TriggerType.SIMPLE : triggerInputDTO.getTriggerType();
return switch (triggerType) {
case SIMPLE -> buildSimpleSchedule(triggerInputDTO);
case CRON -> buildCronSchedule(triggerInputDTO);
case DAILY_TIME_INTERVAL -> buildDailyTimeIntervalSchedule(triggerInputDTO);
case CALENDAR_INTERVAL -> buildCalendarIntervalSchedule(triggerInputDTO);
};
}
private SimpleScheduleBuilder buildSimpleSchedule(TriggerInputDTO triggerInputDTO) {
SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule();
if (triggerInputDTO.getRepeatInterval() != null)
scheduleBuilder.withIntervalInMilliseconds(triggerInputDTO.getRepeatInterval());
if (triggerInputDTO.getRepeatCount() != null)
scheduleBuilder.withRepeatCount(triggerInputDTO.getRepeatCount());
MisfireInstruction misfireInstruction = parseSimpleMisfireInstruction(triggerInputDTO.getMisfireInstruction());
switch (misfireInstruction) {
case MISFIRE_INSTRUCTION_FIRE_NOW -> scheduleBuilder.withMisfireHandlingInstructionFireNow();
case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithExistingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithRemainingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithRemainingCount();
case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithExistingCount();
}
return scheduleBuilder;
}
private CronScheduleBuilder buildCronSchedule(TriggerInputDTO triggerInputDTO) throws ParseException {
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronScheduleNonvalidatedExpression(triggerInputDTO.getCronExpression());
if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank())
scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone()));
return applyCronMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private DailyTimeIntervalScheduleBuilder buildDailyTimeIntervalSchedule(TriggerInputDTO triggerInputDTO) {
DailyTimeIntervalScheduleBuilder scheduleBuilder = DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.MINUTE));
if (triggerInputDTO.getStartTimeOfDay() != null && !triggerInputDTO.getStartTimeOfDay().isBlank())
scheduleBuilder.startingDailyAt(parseTimeOfDay(triggerInputDTO.getStartTimeOfDay()));
if (triggerInputDTO.getEndTimeOfDay() != null && !triggerInputDTO.getEndTimeOfDay().isBlank())
scheduleBuilder.endingDailyAt(parseTimeOfDay(triggerInputDTO.getEndTimeOfDay()));
if (triggerInputDTO.getDaysOfWeek() != null && !triggerInputDTO.getDaysOfWeek().isEmpty())
scheduleBuilder.onDaysOfTheWeek(triggerInputDTO.getDaysOfWeek());
return applyDailyMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private CalendarIntervalScheduleBuilder buildCalendarIntervalSchedule(TriggerInputDTO triggerInputDTO) {
CalendarIntervalScheduleBuilder scheduleBuilder = CalendarIntervalScheduleBuilder.calendarIntervalSchedule()
.withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.DAY));
if (Boolean.TRUE.equals(triggerInputDTO.getPreserveHourOfDayAcrossDaylightSavings()))
scheduleBuilder.preserveHourOfDayAcrossDaylightSavings(true);
if (Boolean.TRUE.equals(triggerInputDTO.getSkipDayIfHourDoesNotExist()))
scheduleBuilder.skipDayIfHourDoesNotExist(true);
if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank())
scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone()));
return applyCalendarIntervalMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction());
}
private JobKey getJobKey(TriggerInputDTO triggerInputDTO) {
JobKeyDTO jobKeyDTO = triggerInputDTO.getJobKey();
if (jobKeyDTO == null)
return null;
return JobKey.jobKey(jobKeyDTO.getName(), jobKeyDTO.getGroup());
}
private TriggerDTO convertTrigger(Trigger trigger) {
TriggerDTO triggerDTO = conversionService.convert(trigger, TriggerDTO.class);
if (triggerDTO == null)
triggerDTO = new TriggerDTO();
if (trigger instanceof SimpleTrigger simpleTrigger)
enrichSimpleTrigger(triggerDTO, simpleTrigger);
else if (trigger instanceof CronTrigger cronTrigger)
enrichCronTrigger(triggerDTO, cronTrigger);
else if (trigger instanceof DailyTimeIntervalTrigger dailyTimeIntervalTrigger)
enrichDailyTimeIntervalTrigger(triggerDTO, dailyTimeIntervalTrigger);
else if (trigger instanceof CalendarIntervalTrigger calendarIntervalTrigger)
enrichCalendarIntervalTrigger(triggerDTO, calendarIntervalTrigger);
return triggerDTO;
}
private void enrichSimpleTrigger(TriggerDTO triggerDTO, SimpleTrigger simpleTrigger) {
triggerDTO.setRepeatCount(simpleTrigger.getRepeatCount());
triggerDTO.setRepeatInterval(simpleTrigger.getRepeatInterval());
}
private void enrichCronTrigger(TriggerDTO triggerDTO, CronTrigger cronTrigger) {
triggerDTO.setCronExpression(cronTrigger.getCronExpression());
triggerDTO.setTimeZone(cronTrigger.getTimeZone().getID());
}
private void enrichDailyTimeIntervalTrigger(TriggerDTO triggerDTO, DailyTimeIntervalTrigger dailyTrigger) {
triggerDTO.setRepeatCount(dailyTrigger.getRepeatCount());
triggerDTO.setRepeatInterval((long) dailyTrigger.getRepeatInterval());
triggerDTO.setRepeatIntervalUnit(dailyTrigger.getRepeatIntervalUnit().name());
triggerDTO.setStartTimeOfDay(formatTimeOfDay(dailyTrigger.getStartTimeOfDay()));
triggerDTO.setEndTimeOfDay(formatTimeOfDay(dailyTrigger.getEndTimeOfDay()));
triggerDTO.setDaysOfWeek(dailyTrigger.getDaysOfWeek());
}
private void enrichCalendarIntervalTrigger(TriggerDTO triggerDTO, CalendarIntervalTrigger calendarTrigger) {
triggerDTO.setRepeatInterval((long) calendarTrigger.getRepeatInterval());
triggerDTO.setRepeatIntervalUnit(calendarTrigger.getRepeatIntervalUnit().name());
triggerDTO.setPreserveHourOfDayAcrossDaylightSavings(calendarTrigger.isPreserveHourOfDayAcrossDaylightSavings());
triggerDTO.setSkipDayIfHourDoesNotExist(calendarTrigger.isSkipDayIfHourDoesNotExist());
triggerDTO.setTimeZone(calendarTrigger.getTimeZone().getID());
}
private MisfireInstruction parseSimpleMisfireInstruction(String misfireInstruction) {
if (misfireInstruction == null || misfireInstruction.isBlank())
return MisfireInstruction.MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT;
return MisfireInstruction.valueOf(misfireInstruction);
}
private CronScheduleBuilder applyCronMisfireInstruction(CronScheduleBuilder scheduleBuilder, String misfireInstruction) {
return switch (normalizeMisfireInstruction(misfireInstruction)) {
case 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());
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ package it.fabioformosa.quartzmanager.api.controllers;
import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests; import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests;
import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils; 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.jobs.SampleJob;
import it.fabioformosa.quartzmanager.api.services.JobService; import it.fabioformosa.quartzmanager.api.services.JobService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,11 +19,13 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.util.List; 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.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}) @ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = SimpleTriggerController.class, properties = { @WebMvcTest(controllers = JobController.class, properties = {
"quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs" "quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs"
}) })
class JobControllerTest { class JobControllerTest {
@@ -36,11 +41,88 @@ class JobControllerTest {
Mockito.when(jobService.getJobClasses()).thenReturn(List.of(SampleJob.class)); Mockito.when(jobService.getJobClasses()).thenReturn(List.of(SampleJob.class));
List<String> expectedJobs = List.of(SampleJob.class.getName()); List<String> 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()) .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedJobs))); .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedJobs)));
Mockito.verify(jobService, Mockito.times(1)).getJobClasses(); 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");
}
} }

View File

@@ -16,9 +16,10 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ContextConfiguration(classes = {QuartManagerApplicationTests.class}) @ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = SimpleTriggerController.class, properties = { @WebMvcTest(controllers = SchedulerController.class, properties = {
"quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs" "quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs"
}) })
class SchedulerControllerTest { class SchedulerControllerTest {
@@ -47,8 +48,8 @@ class SchedulerControllerTest {
} }
@Test @Test
void givenAScheduler_whenTheGetPausedIsCalled_then2xxReturned() throws Exception { void givenAScheduler_whenStandbyIsCalled_then2xxReturned() throws Exception {
mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/pause") mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/standby")
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.status().isNoContent())
.andExpect(MockMvcResultMatchers.content().string("")); .andExpect(MockMvcResultMatchers.content().string(""));
@@ -57,8 +58,8 @@ class SchedulerControllerTest {
} }
@Test @Test
void givenAScheduler_whenTheGetResumedIsCalled_then2xxReturned() throws Exception { void givenAScheduler_whenResumeIsCalled_then2xxReturned() throws Exception {
mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/resume") mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/resume")
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.status().isNoContent())
.andExpect(MockMvcResultMatchers.content().string("")); .andExpect(MockMvcResultMatchers.content().string(""));
@@ -67,8 +68,8 @@ class SchedulerControllerTest {
} }
@Test @Test
void givenAScheduler_whenTheGetRunIsCalled_then2xxReturned() throws Exception { void givenAScheduler_whenStartIsCalled_then2xxReturned() throws Exception {
mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/run") mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/start")
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.status().isNoContent())
.andExpect(MockMvcResultMatchers.content().string("")); .andExpect(MockMvcResultMatchers.content().string(""));
@@ -77,8 +78,8 @@ class SchedulerControllerTest {
} }
@Test @Test
void givenAScheduler_whenTheGetStoppedIsCalled_then2xxReturned() throws Exception { void givenAScheduler_whenShutdownIsCalled_then2xxReturned() throws Exception {
mockMvc.perform(get(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/stop") mockMvc.perform(post(SchedulerController.SCHEDULER_CONTROLLER_BASE_URL + "/shutdown")
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent()) .andExpect(MockMvcResultMatchers.status().isNoContent())
.andExpect(MockMvcResultMatchers.content().string("")); .andExpect(MockMvcResultMatchers.content().string(""));

View File

@@ -46,9 +46,9 @@ class SimpleTriggerControllerTest {
@Test @Test
void whenGetIsCalled_thenASimpleTriggerIsReturned() throws Exception { void whenGetIsCalled_thenASimpleTriggerIsReturned() throws Exception {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger"); 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()) .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedSimpleTriggerDTO))); .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(expectedSimpleTriggerDTO)));
} }
@@ -59,7 +59,7 @@ class SimpleTriggerControllerTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO);
Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO);
mockMvc.perform( mockMvc.perform(
post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO)) .content(TestUtils.toJson(simpleTriggerInputDTO))
) )
@@ -90,11 +90,12 @@ class SimpleTriggerControllerTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("mytrigger", simpleTriggerInputDTO);
SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder()
.triggerName("mytrigger") .triggerName("mytrigger")
.triggerGroup("DEFAULT")
.simpleTriggerInputDTO(simpleTriggerInputDTO) .simpleTriggerInputDTO(simpleTriggerInputDTO)
.build(); .build();
Mockito.when(simpleTriggerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO)).thenReturn(expectedSimpleTriggerDTO); 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) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO))) .content(TestUtils.toJson(simpleTriggerInputDTO)))
.andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.status().isOk())

View File

@@ -46,9 +46,9 @@ class SimpleTriggerControllerValidationTest {
@Test @Test
void givenANotExistingTrigger_whenGetIsCalled_then404IsReturned() throws Exception { 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()); .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isNotFound());
} }
@@ -59,7 +59,7 @@ class SimpleTriggerControllerValidationTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-minimal-trigger"); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-minimal-trigger");
Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO);
mockMvc.perform( 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) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO)) .content(TestUtils.toJson(simpleTriggerInputDTO))
) )
@@ -83,7 +83,7 @@ class SimpleTriggerControllerValidationTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-puntual-trigger"); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-puntual-trigger");
Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO);
mockMvc.perform( 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) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO)) .content(TestUtils.toJson(simpleTriggerInputDTO))
) )
@@ -95,7 +95,7 @@ class SimpleTriggerControllerValidationTest {
@ParameterizedTest @ParameterizedTest
@ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class)
void givenAnInvalidSimpleTriggerCommandDTO_whenPostedANewTrigger_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerComandDTO) throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(invalidSimpleTriggerComandDTO))) .content(TestUtils.toJson(invalidSimpleTriggerComandDTO)))
.andExpect(MockMvcResultMatchers.status().is4xxClientError()); .andExpect(MockMvcResultMatchers.status().is4xxClientError());
@@ -104,7 +104,7 @@ class SimpleTriggerControllerValidationTest {
@ParameterizedTest @ParameterizedTest
@ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class)
void givenAnInvalidSimpleTriggerCommandDTO_whenATriggerIsRescheduled_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerCommandTO) throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(invalidSimpleTriggerCommandTO))) .content(TestUtils.toJson(invalidSimpleTriggerCommandTO)))
.andExpect(MockMvcResultMatchers.status().is4xxClientError()); .andExpect(MockMvcResultMatchers.status().is4xxClientError());

View File

@@ -1,14 +1,29 @@
package it.fabioformosa.quartzmanager.api.controllers; package it.fabioformosa.quartzmanager.api.controllers;
import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests; 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 it.fabioformosa.quartzmanager.api.services.TriggerService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; 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.bean.override.mockito.MockitoBean;
import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc; 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}) @ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = TriggerController.class, properties = { @WebMvcTest(controllers = TriggerController.class, properties = {
@@ -27,4 +42,95 @@ class TriggerControllerTest {
Mockito.reset(triggerService); Mockito.reset(triggerService);
} }
@Test
void whenListTriggersIsCalled_thenTriggersAreReturned() throws Exception {
List<TriggerKeyDTO> 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");
}
} }

View File

@@ -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<CalendarDTO> 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<Calendar> 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<Calendar> 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);
}
}

View File

@@ -1,13 +1,55 @@
package it.fabioformosa.quartzmanager.api.services; 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.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; 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 { 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 @Test
void givenTwoJobClassesInTwoPackages_whenTheJobServiceIsCalled_shouldReturnTwoJobClasses(){ void givenTwoJobClassesInTwoPackages_whenTheJobServiceIsCalled_shouldReturnTwoJobClasses(){
JobService jobService = new JobService("it.fabioformosa.quartzmanager.api.jobs, it.fabioformosa.samplepackage"); JobService jobService = new JobService("it.fabioformosa.quartzmanager.api.jobs, it.fabioformosa.samplepackage");
@@ -42,4 +84,123 @@ class JobServiceTest {
Assertions.assertThat(jobService.getJobClasses()).isEmpty(); 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<ScheduledJobDTO> 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<String, Object>) 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<JobDetail> 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());
}
}
} }

View File

@@ -3,6 +3,7 @@ package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.common.utils.DateUtils; import it.fabioformosa.quartzmanager.api.common.utils.DateUtils;
import it.fabioformosa.quartzmanager.api.dto.*; import it.fabioformosa.quartzmanager.api.dto.*;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.jobs.SampleJob;
import org.assertj.core.api.Assertions; import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -29,9 +30,13 @@ class SimpleTriggerServiceTest {
@Mock @Mock
private ConversionService conversionService; private ConversionService conversionService;
@Mock
private JobService jobService;
@BeforeEach @BeforeEach
void setUp() { void setUp() throws ClassNotFoundException {
openMocks(this); openMocks(this);
Mockito.doReturn(SampleJob.class).when(jobService).getEligibleJobClass(SampleJob.class.getName());
} }
@Test @Test
@@ -47,7 +52,8 @@ class SimpleTriggerServiceTest {
void givenAnExistingTrigger_whenGetSimplerTriggerByNameIsCalled_thenTheDtoIsReturned() throws SchedulerException, TriggerNotFoundException { void givenAnExistingTrigger_whenGetSimplerTriggerByNameIsCalled_thenTheDtoIsReturned() throws SchedulerException, TriggerNotFoundException {
String existing_trigger = "existing_trigger"; String existing_trigger = "existing_trigger";
Mockito.when(scheduler.getTrigger(any(TriggerKey.class))) 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))) Mockito.when(conversionService.convert(any(SimpleTrigger.class), eq(SimpleTriggerDTO.class)))
.thenReturn(SimpleTriggerDTO.builder() .thenReturn(SimpleTriggerDTO.builder()
.triggerKeyDTO(TriggerKeyDTO.builder().name(existing_trigger).build()) .triggerKeyDTO(TriggerKeyDTO.builder().name(existing_trigger).build())
@@ -61,7 +67,7 @@ class SimpleTriggerServiceTest {
@Test @Test
void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsScheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException { void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsScheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException {
SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder() SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder()
.jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") .jobClass(SampleJob.class.getName())
.startDate(new Date()) .startDate(new Date())
.repeatInterval(5000L).repeatCount(5) .repeatInterval(5000L).repeatCount(5)
.endDate(DateUtils.addHoursToNow(1)) .endDate(DateUtils.addHoursToNow(1))
@@ -71,7 +77,7 @@ class SimpleTriggerServiceTest {
SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder()
.startTime(triggerInputDTO.getStartDate()) .startTime(triggerInputDTO.getStartDate())
.repeatInterval(1000) .repeatInterval(1000L)
.repeatCount(10) .repeatCount(10)
.mayFireAgain(true) .mayFireAgain(true)
.finalFireTime(triggerInputDTO.getEndDate()) .finalFireTime(triggerInputDTO.getEndDate())
@@ -81,10 +87,14 @@ class SimpleTriggerServiceTest {
.build(); .build();
Mockito.when(scheduler.scheduleJob(any(), any())).thenReturn(new Date()); 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); Mockito.when(conversionService.convert(any(), eq(SimpleTriggerDTO.class))).thenReturn(expectedTriggerDTO);
SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder()
.triggerName(simpleTriggerName) .triggerName(simpleTriggerName)
.triggerGroup("DEFAULT")
.simpleTriggerInputDTO(triggerInputDTO) .simpleTriggerInputDTO(triggerInputDTO)
.build(); .build();
SimpleTriggerDTO simpleTrigger = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO); SimpleTriggerDTO simpleTrigger = simpleSchedulerService.scheduleSimpleTrigger(simpleTriggerCommandDTO);
@@ -93,7 +103,7 @@ class SimpleTriggerServiceTest {
} }
@Test @Test
void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException { void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, TriggerNotFoundException {
SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder() SimpleTriggerInputDTO triggerInputDTO = SimpleTriggerInputDTO.builder()
.jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") .jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob")
.startDate(new Date()) .startDate(new Date())
@@ -105,7 +115,7 @@ class SimpleTriggerServiceTest {
SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder()
.startTime(triggerInputDTO.getStartDate()) .startTime(triggerInputDTO.getStartDate())
.repeatInterval(1000) .repeatInterval(1000L)
.repeatCount(10) .repeatCount(10)
.mayFireAgain(true) .mayFireAgain(true)
.finalFireTime(triggerInputDTO.getEndDate()) .finalFireTime(triggerInputDTO.getEndDate())
@@ -115,10 +125,15 @@ class SimpleTriggerServiceTest {
.build(); .build();
Mockito.when(scheduler.rescheduleJob(any(), any())).thenReturn(new Date()); 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); Mockito.when(conversionService.convert(any(), eq(SimpleTriggerDTO.class))).thenReturn(expectedTriggerDTO);
SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder() SimpleTriggerCommandDTO simpleTriggerCommandDTO = SimpleTriggerCommandDTO.builder()
.triggerName(simpleTriggerName) .triggerName(simpleTriggerName)
.triggerGroup("DEFAULT")
.simpleTriggerInputDTO(triggerInputDTO) .simpleTriggerInputDTO(triggerInputDTO)
.build(); .build();
SimpleTriggerDTO simpleTrigger = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO); SimpleTriggerDTO simpleTrigger = simpleSchedulerService.rescheduleSimpleTrigger(simpleTriggerCommandDTO);

View File

@@ -1,20 +1,40 @@
package it.fabioformosa.quartzmanager.api.services; 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.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.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.MockitoAnnotations; 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.Scheduler;
import org.quartz.SchedulerException; import org.quartz.SchedulerException;
import org.quartz.SimpleTrigger;
import org.quartz.Trigger;
import org.quartz.TriggerKey; import org.quartz.TriggerKey;
import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.ConversionService;
import java.text.ParseException;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -30,9 +50,13 @@ class TriggerServiceTest {
@Mock @Mock
private ConversionService conversionService; private ConversionService conversionService;
@Mock
private JobService jobService;
@BeforeEach @BeforeEach
void setUp(){ void setUp() throws ClassNotFoundException {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
Mockito.doReturn(SampleJob.class).when(jobService).getEligibleJobClass(SampleJob.class.getName());
} }
@Test @Test
@@ -47,4 +71,234 @@ class TriggerServiceTest {
Assertions.assertThat(triggerKeyDTOs.get(0).getName()).isEqualTo(triggerTestName); 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<org.quartz.JobDetail> jobDetailCaptor = ArgumentCaptor.forClass(org.quartz.JobDetail.class);
ArgumentCaptor<Trigger> 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<Trigger> 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<Trigger> 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<Trigger> 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<Trigger> 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);
}
} }

View File

@@ -2,7 +2,7 @@ quartz:
enabled: true enabled: true
quartz-manager: quartz-manager:
jobClassPackages: it.fabioformosa.quartzmanager.api.jobs jobClassPackages: it.fabioformosa.quartzmanager.api.jobs, it.fabioformosa.samplepackage
logging: logging:
level: level:

File diff suppressed because it is too large Load Diff