mirror of
https://github.com/fabioformosa/quartz-manager.git
synced 2026-05-14 22:00:30 +09:00
Merge pull request #136 from fabioformosa/feature/#9x_trigger_types
Feature/#9x trigger types
This commit is contained in:
8
.github/workflows/maven-release.yml
vendored
8
.github/workflows/maven-release.yml
vendored
@@ -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"
|
||||||
|
|||||||
4
.github/workflows/maven.yml
vendored
4
.github/workflows/maven.yml
vendored
@@ -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
|
||||||
|
|||||||
4
.github/workflows/sonar-java.yml
vendored
4
.github/workflows/sonar-java.yml
vendored
@@ -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
|
||||||
|
|||||||
30
quartz-manager-frontend/eslint.sonar.config.mjs
Normal file
30
quartz-manager-frontend/eslint.sonar.config.mjs
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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$)/)'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<div class="app-shell flex flex-column justify-space-between h-100">
|
@if (isOperationsConsoleRoute()) {
|
||||||
<app-header class="flex-none"></app-header>
|
<router-outlet></router-outlet>
|
||||||
<div class="content flex h-100">
|
} @else {
|
||||||
<router-outlet></router-outlet>
|
<div class="app-shell flex flex-column justify-space-between h-100">
|
||||||
|
<app-header class="flex-none"></app-header>
|
||||||
|
<div class="content flex h-100">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
|
<app-footer class="flex-none"></app-footer>
|
||||||
</div>
|
</div>
|
||||||
<app-footer class="flex-none"></app-footer>
|
}
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -19,5 +20,12 @@ fontawesome.library.add(faCheckCircle, faExclamationCircle, faExclamationTriangl
|
|||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
|
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
}
|
constructor(private router: Router) {
|
||||||
|
}
|
||||||
|
|
||||||
|
isOperationsConsoleRoute(): boolean {
|
||||||
|
const url = this.router.url || '/';
|
||||||
|
return url === '/' || url.startsWith('/manager');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ import {
|
|||||||
SchedulerService,
|
SchedulerService,
|
||||||
ConfigService,
|
ConfigService,
|
||||||
getHtmlBaseUrl,
|
getHtmlBaseUrl,
|
||||||
LogsRxWebsocketService,
|
LogsRxWebsocketService,
|
||||||
ProgressRxWebsocketService,
|
ProgressRxWebsocketService,
|
||||||
TriggerService
|
TriggerService,
|
||||||
} from './services';
|
CalendarService
|
||||||
|
} 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';
|
||||||
import JobService from './services/job.service';
|
import JobService from './services/job.service';
|
||||||
@@ -135,6 +136,7 @@ export function jwtOptionsFactory(apiService: ApiService) {
|
|||||||
SchedulerService,
|
SchedulerService,
|
||||||
JobService,
|
JobService,
|
||||||
TriggerService,
|
TriggerService,
|
||||||
|
CalendarService,
|
||||||
ProgressRxWebsocketService,
|
ProgressRxWebsocketService,
|
||||||
LogsRxWebsocketService,
|
LogsRxWebsocketService,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -35,16 +35,16 @@ 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))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
24
quartz-manager-frontend/src/app/model/calendar.model.ts
Normal file
24
quartz-manager-frontend/src/app/model/calendar.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class ScheduledJobCommand {
|
||||||
|
jobClass: string;
|
||||||
|
description: string;
|
||||||
|
durable: boolean;
|
||||||
|
requestsRecovery: boolean;
|
||||||
|
jobDataMap: {[key: string]: unknown};
|
||||||
|
}
|
||||||
12
quartz-manager-frontend/src/app/model/scheduled-job.model.ts
Normal file
12
quartz-manager-frontend/src/app/model/scheduled-job.model.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
quartz-manager-frontend/src/app/services/calendar.service.ts
Normal file
34
quartz-manager-frontend/src/app/services/calendar.service.ts
Normal 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});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ export * from './auth.service';
|
|||||||
export * from './scheduler.service';
|
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 './job.service'
|
export * from './calendar.service'
|
||||||
|
export * from './job.service'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
46
quartz-manager-frontend/src/app/services/job.service.spec.ts
Normal file
46
quartz-manager-frontend/src/app/services/job.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,20 +14,20 @@ export class SchedulerService {
|
|||||||
private apiService: ApiService
|
private apiService: ApiService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
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,17 +38,17 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
<mat-spinner mode="indeterminate"></mat-spinner>
|
<div class="loading-state">
|
||||||
|
<mat-spinner mode="indeterminate"></mat-spinner>
|
||||||
|
<span>Checking credentials...</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</section>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</div>
|
<button type="button" [class.active]="activePage === 'calendars'" [attr.aria-current]="activePage === 'calendars' ? 'page' : null" (click)="selectPage('calendars')">
|
||||||
</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 3v4M17 3v4M4 9h16M5 5h14v15H5z"/></svg><span>Calendars</span>
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" [class.active]="activePage === 'executions'" [attr.aria-current]="activePage === 'executions' ? 'page' : null" (click)="selectPage('executions')">
|
||||||
<div class="flex-1">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 17h16M7 17V7m5 10V4m5 13v-6"/></svg><span>Executions</span>
|
||||||
<div class="h-100 min-h-100 flex flex-column gap-6">
|
</button>
|
||||||
<div class="flex flex-column" >
|
<button type="button" [class.active]="activePage === 'events'" [attr.aria-current]="activePage === 'events' ? 'page' : null" (click)="selectPage('events')">
|
||||||
<progress-panel class="flex-1"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg><span>Event Stream</span>
|
||||||
[triggerKey]=monitoredTriggerKey
|
</button>
|
||||||
>
|
<button type="button" [class.active]="activePage === 'scheduler'" [attr.aria-current]="activePage === 'scheduler' ? 'page' : null" (click)="selectPage('scheduler')">
|
||||||
</progress-panel>
|
<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>
|
||||||
</div>
|
</button>
|
||||||
<div class="flex flex-column flex-1" style="max-height: calc(100% - 136px); min-height: calc(100% - 210px);">
|
</nav>
|
||||||
<logs-panel class="flex flex-1 h-100 max-h-100"
|
|
||||||
[triggerKey]=monitoredTriggerKey
|
<div class="rail-card">
|
||||||
>
|
<h3>Live channel</h3>
|
||||||
</logs-panel>
|
<div class="connection"><span>WebSocket</span><span class="chip success">OPEN</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
|
||||||
</div>
|
<main class="main">
|
||||||
</div>
|
<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 class="page" [class.active]="activePage === 'jobs'">
|
||||||
|
<div class="page-kicker">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<section class="card">
|
||||||
|
<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>
|
||||||
|
<div class="split">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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 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>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../out-tsc/spec",
|
"outDir": "../out-tsc/spec",
|
||||||
"baseUrl": "",
|
"baseUrl": "",
|
||||||
|
"importHelpers": false,
|
||||||
"types": [
|
"types": [
|
||||||
"jasmine",
|
"jasmine",
|
||||||
"node"
|
"node"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package it.fabioformosa.quartzmanager.api.dto;
|
||||||
|
|
||||||
|
public enum CalendarType {
|
||||||
|
ANNUAL,
|
||||||
|
CRON,
|
||||||
|
DAILY,
|
||||||
|
HOLIDAY,
|
||||||
|
MONTHLY,
|
||||||
|
WEEKLY
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package it.fabioformosa.quartzmanager.api.dto;
|
||||||
|
|
||||||
|
public enum TriggerType {
|
||||||
|
SIMPLE,
|
||||||
|
CRON,
|
||||||
|
DAILY_TIME_INTERVAL,
|
||||||
|
CALENDAR_INTERVAL
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package it.fabioformosa.quartzmanager.api.exceptions;
|
||||||
|
|
||||||
|
public class CalendarNotFoundException extends RuntimeException {
|
||||||
|
public CalendarNotFoundException(String name) {
|
||||||
|
super("Calendar " + name + " not found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
JobDetail jobDetail = JobBuilder.newJob()
|
if (scheduler.checkExists(triggerKey))
|
||||||
.ofType(jobClass)
|
throw new ResourceConflictException("Trigger " + triggerKey + " already exists");
|
||||||
.storeDurably(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
SimpleTrigger newSimpleTrigger = conversionService.convert(simpleTriggerCommandDTO, SimpleTrigger.class);
|
SimpleTrigger newSimpleTrigger = conversionService.convert(simpleTriggerCommandDTO, SimpleTrigger.class);
|
||||||
scheduler.scheduleJob(jobDetail, newSimpleTrigger);
|
if (simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey() != null) {
|
||||||
|
JobKey jobKey = JobKey.jobKey(
|
||||||
|
simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getName(),
|
||||||
|
simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getGroup()
|
||||||
|
);
|
||||||
|
if (!scheduler.checkExists(jobKey))
|
||||||
|
throw new ResourceConflictException("Job " + jobKey + " does not exist");
|
||||||
|
newSimpleTrigger = newSimpleTrigger.getTriggerBuilder().forJob(jobKey).build();
|
||||||
|
scheduler.scheduleJob(newSimpleTrigger);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Class<? extends Job> jobClass = jobService.getEligibleJobClass(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass());
|
||||||
|
JobDetail jobDetail = JobBuilder.newJob()
|
||||||
|
.ofType(jobClass)
|
||||||
|
.storeDurably(false)
|
||||||
|
.build();
|
||||||
|
scheduler.scheduleJob(jobDetail, newSimpleTrigger);
|
||||||
|
}
|
||||||
|
|
||||||
return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class);
|
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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package it.fabioformosa.quartzmanager.api.validators;
|
||||||
|
|
||||||
|
import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO;
|
||||||
|
|
||||||
|
public interface JobTargetDTO {
|
||||||
|
String getJobClass();
|
||||||
|
JobKeyDTO getJobKey();
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(""));
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
1120
quartz-manager-parent/quartz-operations-console.html
Normal file
1120
quartz-manager-parent/quartz-operations-console.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user