#132 #133 refactored some existing endpoint and added new ones to manage triggers

This commit is contained in:
Fabio Formosa
2026-05-12 21:41:31 +02:00
parent e24c5bc62a
commit 7d481247bc
40 changed files with 866 additions and 122 deletions

View File

@@ -0,0 +1,30 @@
import sonarjs from 'eslint-plugin-sonarjs';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module'
}
},
plugins: {
sonarjs
},
rules: {
...sonarjs.configs.recommended.rules,
'sonarjs/deprecation': 'off',
'sonarjs/no-commented-code': 'off',
'sonarjs/no-dead-store': 'off',
'sonarjs/no-incomplete-assertions': 'off',
'sonarjs/no-primitive-wrappers': 'off',
'sonarjs/no-unused-vars': 'off',
'sonarjs/prefer-promise-shorthand': 'off',
'sonarjs/todo-tag': 'off',
'sonarjs/unused-import': 'off'
}
}
];

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import {JobKeyModel} from './jobKey.model';
import {TriggerKey} from './triggerKey.model';
export class ScheduledJob {
jobKeyDTO: JobKeyModel;
jobClassName: string;
description: string;
durable: boolean;
requestsRecovery: boolean;
jobDataMap: {[key: string]: unknown};
triggerKeys: TriggerKey[];
}

View File

@@ -5,6 +5,14 @@ export class Scheduler {
instanceId: string; instanceId: string;
status: string; status: string;
triggerKeys: TriggerKey[]; triggerKeys: TriggerKey[];
quartzVersion: string;
jobStoreClass: string;
jobStoreSupportsPersistence: boolean;
clustered: boolean;
threadPoolClass: string;
threadPoolSize: number;
runningSince: string;
numberOfJobsExecuted: number;
constructor(name: string, instanceId: string, status: string, triggerKeys: TriggerKey[]) { constructor(name: string, instanceId: string, status: string, triggerKeys: TriggerKey[]) {
this.name = name; this.name = name;

View File

@@ -1,5 +1,6 @@
export class SimpleTriggerCommand { export class SimpleTriggerCommand {
triggerName: string; triggerName: string;
triggerGroup: string;
jobClass: string; jobClass: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;

View File

@@ -11,6 +11,10 @@ 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;

View File

@@ -0,0 +1,32 @@
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(),
delete: jest.fn()
};
jobService = new JobService(apiService);
});
it('uses job class and scheduled job endpoints', () => {
const job = new ScheduledJob();
job.jobKeyDTO = {group: 'DEFAULT', name: 'sampleJob'};
jobService.fetchJobs();
jobService.fetchScheduledJobs();
jobService.triggerJob(job);
jobService.deleteJob(job);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/job-classes');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob/trigger', {});
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob');
});
});

View File

@@ -2,6 +2,7 @@ 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';
@Injectable() @Injectable()
export default class JobService { export default class JobService {
@@ -12,7 +13,19 @@ 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`)
} }
triggerJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {})
}
deleteJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}`)
}
} }

View File

@@ -0,0 +1,43 @@
import {SchedulerService} from './scheduler.service';
import {SimpleTriggerCommand} from '../model/simple-trigger.command';
import {jest} from '@jest/globals';
describe('SchedulerService', () => {
let apiService: any;
let schedulerService: SchedulerService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn()
};
schedulerService = new SchedulerService(apiService);
});
it('uses POST scheduler lifecycle endpoints', () => {
schedulerService.startScheduler();
schedulerService.standbyScheduler();
schedulerService.resumeScheduler();
schedulerService.shutdownScheduler();
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/start', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/standby', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/resume', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/shutdown', {});
});
it('uses grouped simple trigger endpoints', () => {
const command = new SimpleTriggerCommand();
command.triggerGroup = 'DEFAULT';
command.triggerName = 'sampleTrigger';
schedulerService.getSimpleTriggerConfig(command.triggerName, command.triggerGroup);
schedulerService.saveSimpleTriggerConfig(command);
schedulerService.updateSimpleTriggerConfig(command);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command);
});
});

View File

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

View File

@@ -0,0 +1,31 @@
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(),
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');
});
});

View File

@@ -16,5 +16,20 @@ 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}`);
}
pauseTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/pause`, {});
}
resumeTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/resume`, {});
}
unscheduleTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`);
}
} }

View File

@@ -47,7 +47,7 @@
</div> </div>
<span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span> <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>Instance ID</span><span>{{ scheduler?.instanceId || '-' }}</span></div>
<button type="button" class="kv kv-button" data-roadmap="Cluster metadata is not exposed by the current backend"><span>Cluster</span><span>Roadmap</span></button> <div class="kv"><span>Cluster</span><span>{{ scheduler?.clustered ? 'YES' : 'NO' }}</span></div>
<div class="kv"><span>WebSocket</span><span>OPEN</span></div> <div class="kv"><span>WebSocket</span><span>OPEN</span></div>
</div> </div>
<div class="actions compact-actions" aria-label="Compact scheduler status actions"> <div class="actions compact-actions" aria-label="Compact scheduler status actions">
@@ -79,7 +79,7 @@
<div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</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>Triggers</label><strong>{{ triggerKeys.length }}</strong></div>
<div class="field"><label>Eligible jobs</label><strong>{{ jobs.length }}</strong></div> <div class="field"><label>Eligible jobs</label><strong>{{ jobs.length }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Quartz version and job-store metadata are not exposed by the current backend"><label>Quartz metadata</label><strong>Roadmap</strong></button> <div class="field"><label>Quartz metadata</label><strong>{{ scheduler?.quartzVersion || '-' }}</strong></div>
</div> </div>
</div> </div>
</section> </section>
@@ -119,12 +119,12 @@
<div class="field"><label>Previous fire</label><strong>{{ selectedTrigger?.timesTriggered ? 'tracked by progress events' : 'not exposed' }}</strong></div> <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>Next fire</label><strong>{{ formatDateTime(selectedTrigger?.nextFireTime) || '-' }}</strong></div>
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div> <div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
<div class="field"><label>Calendar</label><strong>Roadmap</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>Misfire</label><strong>{{ selectedTrigger?.misfireInstruction || '-' }}</strong></div>
<div class="field"><label>Repeat</label><strong>{{ getSelectedTriggerRepeatSummary() }}</strong></div> <div class="field"><label>Repeat</label><strong>{{ getSelectedTriggerRepeatSummary() }}</strong></div>
</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="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" data-roadmap="Trigger pause/resume endpoints are not available yet">Pause</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn danger" data-roadmap="Unschedule trigger is on the roadmap">Unschedule</button></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 { } @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> <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>
} }
@@ -163,36 +163,38 @@
<div class="page" [class.active]="activePage === 'jobs'"> <div class="page" [class.active]="activePage === 'jobs'">
<div class="page-kicker"> <div class="page-kicker">
<div><h2>Jobs</h2><p>The current backend exposes eligible Quartz Manager job classes. Full job registry metadata, CRUD, durability, recovery, and group operations remain roadmap features.</p></div> <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" value="Filter jobs, groups, classes" data-roadmap="Job filtering is on the roadmap"><button type="button" class="btn primary" data-roadmap="Creating jobs from the UI is on the roadmap">New Job</button></div> <div class="toolbar"><input class="search" value="Filter jobs, groups, classes" data-roadmap="Job filtering is on the roadmap"><button type="button" class="btn primary" data-roadmap="Creating jobs from the UI is on the roadmap">New Job</button></div>
</div> </div>
<section class="card"> <section class="card">
<div class="card-header"><h2 class="card-title">Eligible Job Classes</h2><div class="toolbar"><span class="chip normal">{{ jobs.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="card-header"><h2 class="card-title">Scheduled Jobs</h2><div class="toolbar"><span class="chip normal">{{ 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="split">
<div class="table-wrap"> <div class="table-wrap">
<table> <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> <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> <tbody>
@for (jobClass of getJobClassRows(); track jobClass) { @for (job of getScheduledJobRows(); track job.jobKeyDTO.group + '.' + job.jobKeyDTO.name) {
<tr class="selectable" [class.selected]="selectedJobClass === jobClass" (click)="selectJob(jobClass)"><td class="mono">{{ shortClassName(jobClass) }}</td><td class="mono">{{ jobClass }}</td><td><span class="chip warn">Roadmap</span></td><td><span class="chip warn">Roadmap</span></td><td class="mono">Roadmap</td></tr> <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> </tbody>
</table> </table>
</div> </div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'jobs'" aria-label="Job detail drawer"> <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">ELIGIBLE</span><h2>{{ getSelectedJobShortName() }}</h2><div class="caption">{{ selectedJobClass || 'Select a job class' }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div> <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" data-roadmap="Job trigger relationships are on the roadmap">Triggers</button><button type="button" class="tab" data-roadmap="JobDataMap editing is on the roadmap">Data Map</button><button type="button" class="tab" data-roadmap="Job execution history is on the roadmap">Executions</button></div> <div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" data-roadmap="Job trigger relationships are on the roadmap">Triggers</button><button type="button" class="tab" data-roadmap="JobDataMap editing is on the roadmap">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-grid">
<div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div> <div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Job key/group metadata is on the roadmap"><label>Group</label><strong>Roadmap</strong></button> <div class="field"><label>Group</label><strong>{{ selectedScheduledJob?.jobKeyDTO?.group || '-' }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Durability metadata is on the roadmap"><label>Durable</label><strong>Roadmap</strong></button> <div class="field"><label>Durable</label><strong>{{ selectedScheduledJob?.durable ? 'YES' : 'NO' }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Recovery metadata is on the roadmap"><label>Requests recovery</label><strong>Roadmap</strong></button> <div class="field"><label>Requests recovery</label><strong>{{ selectedScheduledJob?.requestsRecovery ? 'YES' : 'NO' }}</strong></div>
</div> </div>
<pre class="code-block">Backend contract <pre class="code-block">Backend contract
GET /quartz-manager/jobs GET /quartz-manager/jobs
Returns eligible Java job class names.</pre> Returns scheduled Quartz jobs.</pre>
<div class="actions"><button type="button" class="btn primary" data-roadmap="Manual trigger-now by job key is on the roadmap">Trigger Now</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobClass = selectedJobClass">Create SimpleTrigger</button></div> <div class="actions"><button type="button" class="btn primary" (click)="triggerSelectedJobNow()">Trigger Now</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobClass = selectedScheduledJob?.jobClassName || selectedJobClass">Create SimpleTrigger</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Interrupt and delete need backend support and explicit confirmation.</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" data-roadmap="Job deletion is on the roadmap">Delete Job</button></div></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> </aside>
</div> </div>
</section> </section>
@@ -227,11 +229,11 @@ Returns eligible Java job class names.</pre>
<div class="field"><label>Final fire</label><strong>{{ formatDateTime(selectedTrigger?.finalFireTime) || 'none' }}</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> <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>Repeat interval</label><strong>{{ selectedTrigger?.repeatInterval ? formatDuration(selectedTrigger.repeatInterval) : '-' }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Calendar attachment is on the roadmap"><label>Calendar</label><strong>Roadmap</strong></button> <div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
</div> </div>
<section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section> <section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section>
<div class="actions"><button type="button" class="btn" data-roadmap="Trigger pause endpoint is on the roadmap">Pause</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="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 and reset-error require backend endpoints that are still on the roadmap.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Unschedule trigger is on the roadmap">Unschedule</button><button type="button" class="btn danger" data-roadmap="Reset error trigger is on the roadmap">Reset Error</button></div></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> </aside>
</div> </div>
</section> </section>
@@ -307,7 +309,7 @@ Returns eligible Java job class names.</pre>
</section> </section>
<section class="card span-8"> <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-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><button type="button" class="field field-button" data-roadmap="Quartz version metadata is on the roadmap"><label>Quartz version</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Thread pool metadata is on the roadmap"><label>Thread pool</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Job store metadata is on the roadmap"><label>Job store</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Cluster mode support is on the roadmap"><label>Clustered</label><strong>Roadmap</strong></button></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>
<section class="card span-4"> <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-header"><h2 class="card-title">Cluster Nodes</h2><span class="chip warn">ROADMAP</span></div>

View File

@@ -9,6 +9,7 @@ import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.s
import {Scheduler} from '../../model/scheduler.model'; import {Scheduler} from '../../model/scheduler.model';
import {SimpleTriggerCommand} from '../../model/simple-trigger.command'; import {SimpleTriggerCommand} from '../../model/simple-trigger.command';
import {SimpleTrigger} from '../../model/simple-trigger.model'; import {SimpleTrigger} from '../../model/simple-trigger.model';
import {ScheduledJob} from '../../model/scheduled-job.model';
import {TriggerKey} from '../../model/triggerKey.model'; import {TriggerKey} from '../../model/triggerKey.model';
import TriggerFiredBundle from '../../model/trigger-fired-bundle.model'; import TriggerFiredBundle from '../../model/trigger-fired-bundle.model';
@@ -54,7 +55,9 @@ export class ManagerComponent implements OnInit, OnDestroy {
selectedTriggerKey: TriggerKey; selectedTriggerKey: TriggerKey;
selectedTrigger: SimpleTrigger; selectedTrigger: SimpleTrigger;
selectedJobClass: string; selectedJobClass: string;
selectedScheduledJob: ScheduledJob;
jobs: string[] = []; jobs: string[] = [];
scheduledJobs: ScheduledJob[] = [];
logs: ConsoleLogRecord[] = []; logs: ConsoleLogRecord[] = [];
progress: TriggerFiredBundle; progress: TriggerFiredBundle;
roadmapNotice: string; roadmapNotice: string;
@@ -87,6 +90,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.refreshScheduler(); this.refreshScheduler();
this.fetchTriggers(); this.fetchTriggers();
this.fetchJobs(); this.fetchJobs();
this.fetchScheduledJobs();
} }
ngOnDestroy() { ngOnDestroy() {
@@ -179,7 +183,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
} }
standbyScheduler() { standbyScheduler() {
const subscription = this.schedulerService.pauseScheduler().subscribe({ const subscription = this.schedulerService.standbyScheduler().subscribe({
next: () => this.setSchedulerStatus('PAUSED', 'Scheduler moved to standby.'), next: () => this.setSchedulerStatus('PAUSED', 'Scheduler moved to standby.'),
error: () => this.operationError = 'Unable to move the scheduler to standby.' error: () => this.operationError = 'Unable to move the scheduler to standby.'
}); });
@@ -198,7 +202,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
if (!window.confirm('Shutdown the scheduler instance?')) { if (!window.confirm('Shutdown the scheduler instance?')) {
return; return;
} }
const subscription = this.schedulerService.stopScheduler().subscribe({ const subscription = this.schedulerService.shutdownScheduler().subscribe({
next: () => this.setSchedulerStatus('STOPPED', 'Scheduler shut down.'), next: () => this.setSchedulerStatus('STOPPED', 'Scheduler shut down.'),
error: () => this.operationError = 'Unable to shut down the scheduler.' error: () => this.operationError = 'Unable to shut down the scheduler.'
}); });
@@ -243,12 +247,23 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.subscriptions.push(subscription); this.subscriptions.push(subscription);
} }
fetchScheduledJobs() {
const subscription = this.jobService.fetchScheduledJobs().subscribe({
next: scheduledJobs => {
this.scheduledJobs = scheduledJobs || [];
this.selectedScheduledJob = this.scheduledJobs[0];
},
error: () => this.operationError = 'Unable to load scheduled jobs.'
});
this.subscriptions.push(subscription);
}
fetchTriggerDetails(triggerKeys: TriggerKey[]) { fetchTriggerDetails(triggerKeys: TriggerKey[]) {
triggerKeys.forEach(triggerKey => { triggerKeys.forEach(triggerKey => {
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name).subscribe({ const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({
next: trigger => this.triggerDetailsByName[triggerKey.name] = trigger as SimpleTrigger, next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger,
error: () => { error: () => {
this.triggerDetailsByName[triggerKey.name] = null; this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = null;
} }
}); });
this.subscriptions.push(subscription); this.subscriptions.push(subscription);
@@ -264,12 +279,12 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.openDetailDrawer(); this.openDetailDrawer();
} }
this.triggerLoading = true; this.triggerLoading = true;
this.selectedTrigger = this.triggerDetailsByName[triggerKey.name] || null; this.selectedTrigger = this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] || null;
this.subscribeToTriggerTopics(this.selectedTriggerKey); this.subscribeToTriggerTopics(this.selectedTriggerKey);
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name).subscribe({ const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({
next: trigger => { next: trigger => {
this.selectedTrigger = trigger as SimpleTrigger; this.selectedTrigger = trigger as SimpleTrigger;
this.triggerDetailsByName[triggerKey.name] = trigger as SimpleTrigger; this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger;
this.triggerLoading = false; this.triggerLoading = false;
}, },
error: () => { error: () => {
@@ -285,6 +300,88 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.openDetailDrawer(); this.openDetailDrawer();
} }
selectScheduledJob(job: ScheduledJob) {
this.selectedScheduledJob = job;
this.selectedJobClass = job?.jobClassName || this.selectedJobClass;
this.openDetailDrawer();
}
triggerSelectedJobNow() {
if (!this.selectedScheduledJob) {
this.showRoadmapNotice('Trigger-now requires a scheduled job key returned by the backend');
return;
}
const subscription = this.jobService.triggerJob(this.selectedScheduledJob).subscribe({
next: () => this.operationNotice = 'Job triggered.',
error: () => this.operationError = 'Unable to trigger the selected job.'
});
this.subscriptions.push(subscription);
}
deleteSelectedJob() {
if (!this.selectedScheduledJob) {
this.showRoadmapNotice('Delete requires a scheduled job key returned by the backend');
return;
}
if (!window.confirm(`Delete job ${this.selectedScheduledJob.jobKeyDTO.group}.${this.selectedScheduledJob.jobKeyDTO.name}?`)) {
return;
}
const subscription = this.jobService.deleteJob(this.selectedScheduledJob).subscribe({
next: () => {
this.operationNotice = 'Job deleted.';
this.scheduledJobs = this.scheduledJobs.filter(job => !this.sameJob(job, this.selectedScheduledJob));
this.selectedScheduledJob = this.scheduledJobs[0];
},
error: () => this.operationError = 'Unable to delete the selected job.'
});
this.subscriptions.push(subscription);
}
pauseSelectedTrigger() {
if (!this.selectedTriggerKey) {
return;
}
const subscription = this.triggerService.pauseTrigger(this.selectedTriggerKey).subscribe({
next: () => this.setSelectedTriggerState('PAUSED', 'Trigger paused.'),
error: () => this.operationError = 'Unable to pause the selected trigger.'
});
this.subscriptions.push(subscription);
}
resumeSelectedTrigger() {
if (!this.selectedTriggerKey) {
return;
}
const subscription = this.triggerService.resumeTrigger(this.selectedTriggerKey).subscribe({
next: () => this.setSelectedTriggerState('NORMAL', 'Trigger resumed.'),
error: () => this.operationError = 'Unable to resume the selected trigger.'
});
this.subscriptions.push(subscription);
}
unscheduleSelectedTrigger() {
if (!this.selectedTriggerKey) {
return;
}
if (!window.confirm(`Unschedule trigger ${this.getSelectedTriggerGroup()}.${this.selectedTriggerKey.name}?`)) {
return;
}
const triggerKey = {...this.selectedTriggerKey};
const subscription = this.triggerService.unscheduleTrigger(triggerKey).subscribe({
next: () => {
this.operationNotice = 'Trigger unscheduled.';
this.triggerKeys = this.triggerKeys.filter(currentTriggerKey => !this.sameTriggerKey(currentTriggerKey, triggerKey));
delete this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)];
this.selectedTriggerKey = this.triggerKeys[0];
this.selectedTrigger = this.selectedTriggerKey
? this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)]
: null;
},
error: () => this.operationError = 'Unable to unschedule the selected trigger.'
});
this.subscriptions.push(subscription);
}
openCreateTriggerWizard() { openCreateTriggerWizard() {
this.resetWizard(); this.resetWizard();
this.wizardOpen = true; this.wizardOpen = true;
@@ -302,7 +399,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
return; return;
} }
const trigger = this.selectedTrigger || this.triggerDetailsByName[this.selectedTriggerKey.name]; const trigger = this.selectedTrigger || this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)];
const repeatInterval = this.splitRepeatInterval(trigger?.repeatInterval || 60000); const repeatInterval = this.splitRepeatInterval(trigger?.repeatInterval || 60000);
this.wizardMode = 'edit'; this.wizardMode = 'edit';
this.wizardOpen = true; this.wizardOpen = true;
@@ -337,6 +434,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
const command = new SimpleTriggerCommand(); const command = new SimpleTriggerCommand();
command.triggerName = this.triggerDraft.triggerName.trim(); command.triggerName = this.triggerDraft.triggerName.trim();
command.triggerGroup = this.triggerDraft.group || 'DEFAULT';
command.jobClass = this.triggerDraft.jobClass; command.jobClass = this.triggerDraft.jobClass;
command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate); command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate);
command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate); command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate);
@@ -352,7 +450,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
const subscription = request.subscribe({ const subscription = request.subscribe({
next: trigger => { next: trigger => {
this.wizardSubmitting = false; this.wizardSubmitting = false;
this.triggerDetailsByName[trigger.triggerKeyDTO.name] = trigger as SimpleTrigger; this.triggerDetailsByName[this.getTriggerDetailKey(trigger.triggerKeyDTO)] = trigger as SimpleTrigger;
this.upsertTriggerKey(trigger.triggerKeyDTO); this.upsertTriggerKey(trigger.triggerKeyDTO);
this.selectTrigger(trigger.triggerKeyDTO); this.selectTrigger(trigger.triggerKeyDTO);
this.wizardOpen = false; this.wizardOpen = false;
@@ -380,7 +478,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
} }
getTriggerDetail(triggerKey: TriggerKey): SimpleTrigger { getTriggerDetail(triggerKey: TriggerKey): SimpleTrigger {
return triggerKey?.name ? this.triggerDetailsByName[triggerKey.name] : null; return triggerKey?.name ? this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] : null;
} }
getTriggerGroup(triggerKey: TriggerKey): string { getTriggerGroup(triggerKey: TriggerKey): string {
@@ -388,7 +486,7 @@ export class ManagerComponent implements OnInit, OnDestroy {
} }
getTriggerType(triggerKey: TriggerKey): string { getTriggerType(triggerKey: TriggerKey): string {
return this.getTriggerDetail(triggerKey) ? 'SimpleTrigger' : 'SimpleTrigger'; return this.getTriggerDetail(triggerKey)?.type || 'SimpleTrigger';
} }
getTriggerState(triggerKey: TriggerKey): string { getTriggerState(triggerKey: TriggerKey): string {
@@ -396,6 +494,9 @@ export class ManagerComponent implements OnInit, OnDestroy {
if (!trigger) { if (!trigger) {
return 'UNKNOWN'; return 'UNKNOWN';
} }
if (trigger.state) {
return trigger.state;
}
if (!trigger.mayFireAgain) { if (!trigger.mayFireAgain) {
return 'COMPLETE'; return 'COMPLETE';
} }
@@ -421,7 +522,10 @@ export class ManagerComponent implements OnInit, OnDestroy {
getTriggerJobName(triggerKey: TriggerKey): string { getTriggerJobName(triggerKey: TriggerKey): string {
const trigger = this.getTriggerDetail(triggerKey); const trigger = this.getTriggerDetail(triggerKey);
return trigger?.jobKeyDTO?.name || this.shortClassName(trigger?.jobDetailDTO?.jobClassName) || 'Roadmap'; const jobGroup = trigger?.jobKeyDTO?.group ? `${trigger.jobKeyDTO.group}.` : '';
return trigger?.jobKeyDTO?.name
? `${jobGroup}${trigger.jobKeyDTO.name}`
: this.shortClassName(trigger?.jobDetailDTO?.jobClassName) || 'Roadmap';
} }
getTriggerNextFireLabel(triggerKey: TriggerKey): string { getTriggerNextFireLabel(triggerKey: TriggerKey): string {
@@ -481,7 +585,18 @@ export class ManagerComponent implements OnInit, OnDestroy {
} }
getSelectedJobShortName(): string { getSelectedJobShortName(): string {
return this.shortClassName(this.selectedJobClass) || '-'; return this.shortClassName(this.selectedScheduledJob?.jobClassName || this.selectedJobClass) || '-';
}
getSelectedJobKeyLabel(): string {
if (!this.selectedScheduledJob?.jobKeyDTO) {
return '-';
}
return `${this.selectedScheduledJob.jobKeyDTO.group}.${this.selectedScheduledJob.jobKeyDTO.name}`;
}
getScheduledJobRows(): ScheduledJob[] {
return this.scheduledJobs || [];
} }
getWizardTitle(): string { getWizardTitle(): string {
@@ -561,6 +676,16 @@ export class ManagerComponent implements OnInit, OnDestroy {
this.roadmapNotice = null; this.roadmapNotice = null;
} }
private setSelectedTriggerState(state: string, notice: string) {
if (this.selectedTrigger) {
this.selectedTrigger.state = state;
}
if (this.selectedTriggerKey?.name && this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)]) {
this.triggerDetailsByName[this.getTriggerDetailKey(this.selectedTriggerKey)].state = state;
}
this.operationNotice = notice;
}
private subscribeToTriggerTopics(triggerKey: TriggerKey) { private subscribeToTriggerTopics(triggerKey: TriggerKey) {
this.unsubscribeFromTriggerTopics(); this.unsubscribeFromTriggerTopics();
this.logs = []; this.logs = [];
@@ -598,11 +723,23 @@ export class ManagerComponent implements OnInit, OnDestroy {
} }
private upsertTriggerKey(triggerKey: TriggerKey) { private upsertTriggerKey(triggerKey: TriggerKey) {
if (!this.triggerKeys.some(currentTriggerKey => currentTriggerKey.name === triggerKey.name)) { if (!this.triggerKeys.some(currentTriggerKey => this.sameTriggerKey(currentTriggerKey, triggerKey))) {
this.triggerKeys = [triggerKey, ...this.triggerKeys]; this.triggerKeys = [triggerKey, ...this.triggerKeys];
} }
} }
private sameTriggerKey(first: TriggerKey, second: TriggerKey): boolean {
return first?.name === second?.name && this.getTriggerGroup(first) === this.getTriggerGroup(second);
}
private getTriggerDetailKey(triggerKey: TriggerKey): string {
return `${this.getTriggerGroup(triggerKey)}.${triggerKey.name}`;
}
private sameJob(first: ScheduledJob, second: ScheduledJob): boolean {
return first?.jobKeyDTO?.name === second?.jobKeyDTO?.name && first?.jobKeyDTO?.group === second?.jobKeyDTO?.group;
}
private buildEmptyDraft(): TriggerDraft { private buildEmptyDraft(): TriggerDraft {
return { return {
triggerName: '', triggerName: '',

View File

@@ -8,26 +8,35 @@ 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.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.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) @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 +47,35 @@ 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}/trigger")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Trigger a job now")
public void triggerJob(@PathVariable String group, @PathVariable String name) throws SchedulerException, JobNotFoundException {
jobService.triggerJob(group, name);
}
@DeleteMapping("/jobs/{group}/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Delete a job")
public void deleteJob(@PathVariable String group, @PathVariable String name) throws SchedulerException, JobNotFoundException {
jobService.deleteJob(group, name);
}
} }

View File

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

View File

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

View File

@@ -7,11 +7,18 @@ 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.exceptions.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.services.TriggerService; import it.fabioformosa.quartzmanager.api.services.TriggerService;
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.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;
@@ -44,4 +51,37 @@ 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}/pause")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Pause a trigger")
public void pauseTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException {
triggerService.pauseTrigger(group, name);
}
@PostMapping("/{group}/{name}/resume")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Resume a trigger")
public void resumeTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException {
triggerService.resumeTrigger(group, name);
}
@DeleteMapping("/{group}/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Unschedule a trigger")
public void unscheduleTrigger(@PathVariable String group, @PathVariable String name) throws SchedulerException, TriggerNotFoundException {
triggerService.unscheduleTrigger(group, name);
}
} }

View File

@@ -1,8 +1,10 @@
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.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 +30,20 @@ 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(UnsupportedTriggerTypeException.class)
public ResponseEntity<ExceptionResponse> unsupportedTriggerType(UnsupportedTriggerTypeException ex) {
ExceptionResponse response = ExceptionResponse.builder()
.errorCode(HttpStatus.CONFLICT.toString())
.errorMessage(ex.getMessage())
.build();
return new ResponseEntity<>(response, HttpStatus.CONFLICT);
}
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
package it.fabioformosa.quartzmanager.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class ScheduledJobDTO {
private JobKeyDTO jobKeyDTO;
private String jobClassName;
private String description;
private boolean durable;
private boolean requestsRecovery;
private Map<String, ?> jobDataMap;
private List<TriggerKeyDTO> triggerKeys;
}

View File

@@ -18,4 +18,12 @@ public class SchedulerDTO {
private String instanceId; private String instanceId;
private SchedulerStatus status; private SchedulerStatus status;
private Set<TriggerKey> triggerKeys; private Set<TriggerKey> triggerKeys;
private String quartzVersion;
private String jobStoreClass;
private boolean jobStoreSupportsPersistence;
private boolean clustered;
private String threadPoolClass;
private int threadPoolSize;
private String runningSince;
private int numberOfJobsExecuted;
} }

View File

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

View File

@@ -21,6 +21,10 @@ 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package it.fabioformosa.quartzmanager.api.exceptions;
public class UnsupportedTriggerTypeException extends RuntimeException {
public UnsupportedTriggerTypeException(String group, String name) {
super("Trigger " + group + "." + name + " is not a SimpleTrigger");
}
}

View File

@@ -1,11 +1,24 @@
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.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException;
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.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 +33,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 +71,54 @@ 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 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);
}
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 ScheduledJobDTO convertJob(JobKey jobKey) {
try {
JobDetail jobDetail = scheduler.getJobDetail(jobKey);
List<TriggerKeyDTO> triggerKeys = scheduler.getTriggersOfJob(jobKey).stream()
.map(Trigger::getKey)
.map(triggerKey -> conversionService.convert(triggerKey, TriggerKeyDTO.class))
.toList();
return ScheduledJobDTO.builder()
.jobKeyDTO(conversionService.convert(jobKey, JobKeyDTO.class))
.jobClassName(jobDetail.getJobClass().getName())
.description(jobDetail.getDescription())
.durable(jobDetail.isDurable())
.requestsRecovery(jobDetail.requestsRecovery())
.jobDataMap(jobDetail.getJobDataMap())
.triggerKeys(triggerKeys)
.build();
} catch (SchedulerException ex) {
throw new IllegalStateException("Unable to read job " + jobKey, ex);
}
}
} }

View File

@@ -2,7 +2,9 @@ package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerCommandDTO; import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerCommandDTO;
import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerDTO; import it.fabioformosa.quartzmanager.api.dto.SimpleTriggerDTO;
import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import it.fabioformosa.quartzmanager.api.exceptions.UnsupportedTriggerTypeException;
import org.quartz.*; import org.quartz.*;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.ConversionService;
@@ -16,11 +18,25 @@ public class SimpleTriggerService extends AbstractSchedulerService {
} }
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 {
TriggerKey triggerKey = TriggerKey.triggerKey(simpleTriggerCommandDTO.getTriggerName(), simpleTriggerCommandDTO.getTriggerGroup());
if (scheduler.checkExists(triggerKey))
throw new ResourceConflictException("Trigger " + triggerKey + " already exists");
Class<? extends Job> jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class); Class<? extends Job> jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class);
JobDetail jobDetail = JobBuilder.newJob() JobDetail jobDetail = JobBuilder.newJob()
.ofType(jobClass) .ofType(jobClass)
@@ -33,10 +49,17 @@ public class SimpleTriggerService extends AbstractSchedulerService {
return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class);
} }
public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException { public SimpleTriggerDTO rescheduleSimpleTrigger(SimpleTriggerCommandDTO triggerCommandDTO) throws SchedulerException, TriggerNotFoundException {
SimpleTrigger newSimpleTrigger = conversionService.convert(triggerCommandDTO, SimpleTrigger.class); TriggerKey triggerKey = TriggerKey.triggerKey(triggerCommandDTO.getTriggerName(), triggerCommandDTO.getTriggerGroup());
Trigger existingTrigger = scheduler.getTrigger(triggerKey);
if (existingTrigger == null)
throw new TriggerNotFoundException(triggerCommandDTO.getTriggerGroup(), triggerCommandDTO.getTriggerName());
SimpleTrigger newSimpleTrigger = conversionService.convert(triggerCommandDTO, SimpleTrigger.class);
newSimpleTrigger = newSimpleTrigger.getTriggerBuilder()
.forJob(existingTrigger.getJobKey())
.build();
TriggerKey triggerKey = TriggerKey.triggerKey(triggerCommandDTO.getTriggerName());
scheduler.rescheduleJob(triggerKey, newSimpleTrigger); scheduler.rescheduleJob(triggerKey, newSimpleTrigger);
return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class);

View File

@@ -1,8 +1,11 @@
package it.fabioformosa.quartzmanager.api.services; package it.fabioformosa.quartzmanager.api.services;
import it.fabioformosa.quartzmanager.api.dto.TriggerDTO;
import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO;
import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException;
import org.quartz.Scheduler; import org.quartz.Scheduler;
import org.quartz.SchedulerException; import org.quartz.SchedulerException;
import org.quartz.Trigger;
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;
@@ -30,4 +33,36 @@ 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 = conversionService.convert(trigger, TriggerDTO.class);
triggerDTO.setState(scheduler.getTriggerState(triggerKey).name());
return triggerDTO;
}
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;
}
} }

View File

@@ -2,6 +2,8 @@ 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.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 +18,12 @@ 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;
@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 +39,44 @@ 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 whenDeleteJobIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(delete(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
Mockito.verify(jobService).deleteJob("DEFAULT", "sampleJob");
}
} }

View File

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

View File

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

View File

@@ -46,9 +46,9 @@ class SimpleTriggerControllerValidationTest {
@Test @Test
void givenANotExistingTrigger_whenGetIsCalled_then404IsReturned() throws Exception { void givenANotExistingTrigger_whenGetIsCalled_then404IsReturned() throws Exception {
Mockito.when(simpleTriggerService.getSimpleTriggerByName("not_existing_trigger_name")).thenThrow(new TriggerNotFoundException("not_existing_trigger_name")); Mockito.when(simpleTriggerService.getSimpleTrigger("DEFAULT", "not_existing_trigger_name")).thenThrow(new TriggerNotFoundException("DEFAULT", "not_existing_trigger_name"));
mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/not_existing_trigger_name") mockMvc.perform(get(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/not_existing_trigger_name")
.contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isNotFound()); .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isNotFound());
} }
@@ -59,7 +59,7 @@ class SimpleTriggerControllerValidationTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-minimal-trigger"); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-minimal-trigger");
Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO);
mockMvc.perform( mockMvc.perform(
post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/my-minimal-trigger") post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/my-minimal-trigger")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO)) .content(TestUtils.toJson(simpleTriggerInputDTO))
) )
@@ -83,7 +83,7 @@ class SimpleTriggerControllerValidationTest {
SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-puntual-trigger"); SimpleTriggerDTO expectedSimpleTriggerDTO = TriggerUtils.getSimpleTriggerInstance("my-puntual-trigger");
Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO); Mockito.when(simpleTriggerService.scheduleSimpleTrigger(any())).thenReturn(expectedSimpleTriggerDTO);
mockMvc.perform( mockMvc.perform(
post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/my-puntual-trigger") post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/my-puntual-trigger")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(simpleTriggerInputDTO)) .content(TestUtils.toJson(simpleTriggerInputDTO))
) )
@@ -95,7 +95,7 @@ class SimpleTriggerControllerValidationTest {
@ParameterizedTest @ParameterizedTest
@ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class)
void givenAnInvalidSimpleTriggerCommandDTO_whenPostedANewTrigger_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerComandDTO) throws Exception { void givenAnInvalidSimpleTriggerCommandDTO_whenPostedANewTrigger_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerComandDTO) throws Exception {
mockMvc.perform(post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") mockMvc.perform(post(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(invalidSimpleTriggerComandDTO))) .content(TestUtils.toJson(invalidSimpleTriggerComandDTO)))
.andExpect(MockMvcResultMatchers.status().is4xxClientError()); .andExpect(MockMvcResultMatchers.status().is4xxClientError());
@@ -104,7 +104,7 @@ class SimpleTriggerControllerValidationTest {
@ParameterizedTest @ParameterizedTest
@ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class) @ArgumentsSource(InvalidSimpleTriggerCommandDTOProvider.class)
void givenAnInvalidSimpleTriggerCommandDTO_whenATriggerIsRescheduled_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerCommandTO) throws Exception { void givenAnInvalidSimpleTriggerCommandDTO_whenATriggerIsRescheduled_thenAnErrorIsReturned(SimpleTriggerInputDTO invalidSimpleTriggerCommandTO) throws Exception {
mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/mytrigger") mockMvc.perform(put(SimpleTriggerController.SIMPLE_TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/mytrigger")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(TestUtils.toJson(invalidSimpleTriggerCommandTO))) .content(TestUtils.toJson(invalidSimpleTriggerCommandTO)))
.andExpect(MockMvcResultMatchers.status().is4xxClientError()); .andExpect(MockMvcResultMatchers.status().is4xxClientError());

View File

@@ -1,14 +1,26 @@
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.TriggerKeyDTO;
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;
@ContextConfiguration(classes = {QuartManagerApplicationTests.class}) @ContextConfiguration(classes = {QuartManagerApplicationTests.class})
@WebMvcTest(controllers = TriggerController.class, properties = { @WebMvcTest(controllers = TriggerController.class, properties = {
@@ -27,4 +39,52 @@ 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 whenPauseTriggerIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger/pause").contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
Mockito.verify(triggerService).pauseTrigger("DEFAULT", "sampleTrigger");
}
@Test
void whenResumeTriggerIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger/resume").contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
Mockito.verify(triggerService).resumeTrigger("DEFAULT", "sampleTrigger");
}
@Test
void whenUnscheduleTriggerIsCalled_thenNoContentIsReturned() throws Exception {
mockMvc.perform(delete(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger").contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
Mockito.verify(triggerService).unscheduleTrigger("DEFAULT", "sampleTrigger");
}
} }

View File

@@ -47,7 +47,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())
@@ -81,10 +82,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 +98,7 @@ class SimpleTriggerServiceTest {
} }
@Test @Test
void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException { void givenASimpleTriggerCommandDTO_whenASimpleTriggerIsRecheduled_thenATriggerDTOIsReturned() throws SchedulerException, ClassNotFoundException, 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())
@@ -115,10 +120,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);