From 82e684f0a7822e29a2640ec47b0613eb25bc3dfc Mon Sep 17 00:00:00 2001 From: Fabio Formosa Date: Tue, 12 May 2026 22:13:03 +0200 Subject: [PATCH] #135 added scheduled job management --- .../src/app/model/scheduled-job.command.ts | 7 + .../src/app/model/simple-trigger.command.ts | 2 + .../src/app/model/trigger.model.ts | 1 + .../src/app/services/job.service.spec.ts | 7 + .../src/app/services/job.service.ts | 13 + .../app/views/manager/manager.component.html | 49 ++- .../app/views/manager/manager.component.scss | 18 +- .../app/views/manager/manager.component.ts | 325 +++++++++++++++++- .../api/controllers/JobController.java | 18 + .../api/dto/ScheduledJobInputDTO.java | 29 ++ .../api/dto/SimpleTriggerInputDTO.java | 3 + .../api/dto/TriggerCommandDTO.java | 2 - .../api/services/JobService.java | 35 ++ .../api/services/SimpleTriggerService.java | 25 +- .../api/controllers/JobControllerTest.java | 46 +++ 15 files changed, 544 insertions(+), 36 deletions(-) create mode 100644 quartz-manager-frontend/src/app/model/scheduled-job.command.ts create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java diff --git a/quartz-manager-frontend/src/app/model/scheduled-job.command.ts b/quartz-manager-frontend/src/app/model/scheduled-job.command.ts new file mode 100644 index 0000000..d7db326 --- /dev/null +++ b/quartz-manager-frontend/src/app/model/scheduled-job.command.ts @@ -0,0 +1,7 @@ +export class ScheduledJobCommand { + jobClass: string; + description: string; + durable: boolean; + requestsRecovery: boolean; + jobDataMap: {[key: string]: unknown}; +} diff --git a/quartz-manager-frontend/src/app/model/simple-trigger.command.ts b/quartz-manager-frontend/src/app/model/simple-trigger.command.ts index 5c91b62..be7fdfa 100644 --- a/quartz-manager-frontend/src/app/model/simple-trigger.command.ts +++ b/quartz-manager-frontend/src/app/model/simple-trigger.command.ts @@ -2,9 +2,11 @@ export class SimpleTriggerCommand { triggerName: string; triggerGroup: string; jobClass: string; + jobKey: {group: string; name: string}; startDate: Date; endDate: Date; repeatCount: number; repeatInterval: number; misfireInstruction: string; + jobDataMap: {[key: string]: unknown}; } diff --git a/quartz-manager-frontend/src/app/model/trigger.model.ts b/quartz-manager-frontend/src/app/model/trigger.model.ts index 0edecd0..88a2212 100644 --- a/quartz-manager-frontend/src/app/model/trigger.model.ts +++ b/quartz-manager-frontend/src/app/model/trigger.model.ts @@ -18,4 +18,5 @@ export class Trigger { jobKeyDTO: JobKeyModel; jobDetailDTO: JobDetail = new JobDetail(); mayFireAgain: boolean; + jobDataMap: {[key: string]: unknown}; } diff --git a/quartz-manager-frontend/src/app/services/job.service.spec.ts b/quartz-manager-frontend/src/app/services/job.service.spec.ts index 70aba92..3f274d7 100644 --- a/quartz-manager-frontend/src/app/services/job.service.spec.ts +++ b/quartz-manager-frontend/src/app/services/job.service.spec.ts @@ -10,6 +10,7 @@ describe('JobService', () => { apiService = { get: jest.fn(), post: jest.fn(), + put: jest.fn(), delete: jest.fn() }; jobService = new JobService(apiService); @@ -21,11 +22,17 @@ describe('JobService', () => { jobService.fetchJobs(); jobService.fetchScheduledJobs(); + jobService.getScheduledJob('DEFAULT', 'sampleJob'); + jobService.createJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); + jobService.updateJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); jobService.triggerJob(job); jobService.deleteJob(job); expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/job-classes'); expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs'); + expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob'); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); + expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob/trigger', {}); expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob'); }); diff --git a/quartz-manager-frontend/src/app/services/job.service.ts b/quartz-manager-frontend/src/app/services/job.service.ts index 7dcd5e7..21551a9 100644 --- a/quartz-manager-frontend/src/app/services/job.service.ts +++ b/quartz-manager-frontend/src/app/services/job.service.ts @@ -3,6 +3,7 @@ import {ApiService} from './api.service'; import {CONTEXT_PATH, getBaseUrl} from './config.service'; import {Observable} from 'rxjs'; import {ScheduledJob} from '../model/scheduled-job.model'; +import {ScheduledJobCommand} from '../model/scheduled-job.command'; @Injectable() export default class JobService { @@ -20,6 +21,18 @@ export default class JobService { return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs`) } + getScheduledJob = (group: string, name: string): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`) + } + + createJob = (group: string, name: string, command: ScheduledJobCommand): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command) + } + + updateJob = (group: string, name: string, command: ScheduledJobCommand): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command) + } + triggerJob = (job: ScheduledJob): Observable => { return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {}) } diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.html b/quartz-manager-frontend/src/app/views/manager/manager.component.html index 2494a5e..b079af5 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.html +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.html @@ -96,7 +96,7 @@ - @for (triggerKey of triggerKeys; track triggerKey.name) { + @for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) { @@ -164,10 +164,10 @@

Jobs

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

-
+
-

Scheduled Jobs

{{ scheduledJobs.length }} JOBS
+

Scheduled Jobs

{{ getScheduledJobRows().length }} / {{ scheduledJobs.length }} JOBS
TriggerGroupTypeStateJobNext fire
{{ triggerKey.name }} {{ getTriggerGroup(triggerKey) }}
@@ -183,17 +183,19 @@ @@ -203,16 +205,16 @@ Returns scheduled Quartz jobs.

Triggers

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

-
+
-

Trigger Inventory

{{ triggerKeys.length }} TOTALSTATE COUNTS ROADMAP
+

Trigger Inventory

{{ getTriggerRows().length }} / {{ triggerKeys.length }} TOTALSTATE COUNTS ROADMAP
- @for (triggerKey of triggerKeys; track triggerKey.name) { + @for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) { } @empty { @@ -232,6 +234,8 @@ Returns scheduled Quartz jobs.
{{ selectedTrigger?.calendarName || 'none' }}

Schedule summary

{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.
+
Trigger JobDataMap
+{{ getSelectedTriggerDataMapPreview() }}
Danger zoneUnschedule uses the trigger lifecycle endpoint. Reset-error remains roadmap-gated.
@@ -324,7 +328,7 @@ Returns scheduled Quartz jobs. - @if (wizardOpen || detailDrawerOpen) { + @if (wizardOpen || jobWizardOpen || detailDrawerOpen) { } @@ -342,14 +346,27 @@ Returns scheduled Quartz jobs.
@if (wizardError) {
Unable to save{{ wizardError }}
} -

Identity

The current backend schedules SimpleTriggers by name. Group editing is tracked in the roadmap.
Loaded from GET /quartz-manager/jobs.
+

Identity

@for (group of getTriggerGroups(); track group) { }
Quartz groups are implicit namespaces. Type a new group to create it with this trigger.
@if (triggerDraft.jobTargetType === 'stored') {
The trigger will call TriggerBuilder.forJob with this stored job key.
} @else {
The backend will create an ephemeral job for this trigger.
}

Trigger Type

SimpleRepeat every fixed interval. Supported now.

Schedule Editor

The UI edits operational units and persists the current backend repeatInterval in milliseconds.
Use -1 to repeat indefinitely.
-

Advanced

-

Plain-language summary

Run {{ shortClassName(triggerDraft.jobClass) || 'selected job' }} every {{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}, starting at {{ triggerDraft.startDate || 'backend default start time' }}.
@for (fireTime of getFirePreview(); track fireTime) { {{ fireTime }} }
-
Backend support boundaryOnly SimpleTrigger create/reschedule is submitted. Trigger groups, calendars, job data maps, and other trigger families will show roadmap reminders.
+

Advanced

@for (entry of triggerDraft.jobDataMapEntries; track $index) {
}
{{ getTriggerDraftDataMapPreview() }}
+

Plain-language summary

Run {{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }} every {{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}, starting at {{ triggerDraft.startDate || 'backend default start time' }}.
@for (fireTime of getFirePreview(); track fireTime) { {{ fireTime }} }
+
Backend support boundarySimpleTrigger create/reschedule is submitted. Groups are implicit Quartz namespaces, not standalone records.
+ + diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.scss b/quartz-manager-frontend/src/app/views/manager/manager.component.scss index 9a59851..5774b9a 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.scss +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.scss @@ -284,8 +284,12 @@ tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 .filter-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 12px; display: grid; gap: 12px; align-content: start; } .control { display: grid; gap: 6px; } .control label { font-size: 12px; color: var(--muted); } -.input, .select, .textarea { width: 100%; border: 1px solid var(--border); border-radius: 6px; background: oklch(99% 0.002 250); min-height: 38px; padding: 8px 10px; color: var(--fg); outline: none; } +.input, .select, .textarea { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 6px; background: oklch(99% 0.002 250); min-height: 38px; padding: 8px 10px; color: var(--fg); outline: none; } .textarea { min-height: 70px; resize: vertical; } +.compact-select { width: auto; min-width: 150px; min-height: 32px; padding-block: 5px; } +.check-row { display: flex; gap: 8px; align-items: center; color: var(--fg); } +.data-map-editor { display: grid; gap: 8px; } +.data-map-row { display: grid; grid-template-columns: minmax(90px, 1fr) 104px minmax(110px, 1fr) auto; gap: 8px; align-items: start; } .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; } .calendar-cell { min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 6px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); } @@ -295,7 +299,7 @@ tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 .node-list { display: grid; gap: 8px; } .node-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); } -.wizard { background: oklch(99% 0.002 250); display: flex; flex-direction: column; width: min(460px, 100vw); } +.wizard { background: oklch(99% 0.002 250); display: flex; flex-direction: column; width: min(620px, 100vw); height: 100dvh; overflow: hidden; } .wizard-header { min-height: 76px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .wizard-header h2 { font-size: 17px; } .stepper { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; padding: 14px 18px; border-bottom: 1px solid var(--border); } @@ -303,14 +307,15 @@ tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 .step span:first-child { height: 4px; border-radius: 999px; background: var(--border); } .step.done span:first-child, .step.active span:first-child { background: var(--accent); } .step.active { color: var(--fg); font-weight: 650; } -.wizard-form { display: flex; flex-direction: column; min-height: 0; flex: 1; } -.wizard-scroll { padding: 16px 18px; overflow: auto; display: grid; gap: 14px; } +.wizard-form { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; } +.wizard-scroll { flex: 1 1 auto; min-height: 0; padding: 16px 18px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 14px; } +.wizard-scroll > * { flex: 0 0 auto; } .form-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; } .form-card h3 { margin: 0; padding: 12px 13px; border-bottom: 1px solid var(--border); font-size: 12px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; color: var(--muted); text-transform: uppercase; } .form-section { padding: 13px; display: grid; gap: 12px; } .input-row { display: grid; grid-template-columns: 1fr 118px; gap: 8px; } .radio-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } -.type-option { border: 1px solid var(--border); border-radius: 7px; padding: 10px; display: grid; gap: 4px; background: oklch(99% 0.002 250); text-align: left; color: inherit; } +.type-option { border: 1px solid var(--border); border-radius: 7px; padding: 10px; display: grid; gap: 4px; background: oklch(99% 0.002 250); text-align: left; color: inherit; min-width: 0; } .type-option.active { border-color: oklch(56% 0.19 302 / 0.55); box-shadow: inset 0 0 0 1px oklch(56% 0.19 302 / 0.22); background: oklch(56% 0.19 302 / 0.06); } .type-option strong { font-size: 12px; } .wizard-footer { margin-top: auto; display: flex; justify-content: space-between; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); background: var(--surface); } @@ -340,7 +345,8 @@ tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 .content { padding: 12px; } .dashboard-grid { grid-template-columns: 1fr; } .span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 1; } - .metadata-grid, .summary-grid, .field-grid, .radio-grid, .input-row { grid-template-columns: 1fr; } + .metadata-grid, .summary-grid, .field-grid, .radio-grid, .input-row, .data-map-row { grid-template-columns: 1fr; } + .stepper { grid-template-columns: repeat(3, minmax(0, 1fr)); } .stream-row { grid-template-columns: 1fr; gap: 4px; } .toast-overlay { top: 10px; right: 10px; width: calc(100vw - 20px); } .page-kicker { align-items: stretch; } diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.ts b/quartz-manager-frontend/src/app/views/manager/manager.component.ts index 2827ec3..4c19a2a 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.ts +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.ts @@ -10,11 +10,14 @@ import {Scheduler} from '../../model/scheduler.model'; import {SimpleTriggerCommand} from '../../model/simple-trigger.command'; import {SimpleTrigger} from '../../model/simple-trigger.model'; import {ScheduledJob} from '../../model/scheduled-job.model'; +import {ScheduledJobCommand} from '../../model/scheduled-job.command'; import {TriggerKey} from '../../model/triggerKey.model'; import TriggerFiredBundle from '../../model/trigger-fired-bundle.model'; type ConsolePage = 'dashboard' | 'jobs' | 'triggers' | 'calendars' | 'executions' | 'events' | 'scheduler'; type WizardMode = 'create' | 'edit'; +type JobTargetType = 'stored' | 'class'; +type JobDataMapType = 'string' | 'number' | 'boolean' | 'json' | 'null'; interface ConsoleLogRecord { time: Date; @@ -27,6 +30,8 @@ interface ConsoleLogRecord { interface TriggerDraft { triggerName: string; group: string; + jobTargetType: JobTargetType; + storedJobKey: string; jobClass: string; startDate: string; endDate: string; @@ -34,6 +39,23 @@ interface TriggerDraft { repeatIntervalUnit: string; repeatCount: number; misfireInstruction: string; + jobDataMapEntries: JobDataMapEntry[]; +} + +interface JobDraft { + name: string; + group: string; + jobClass: string; + description: string; + durable: boolean; + requestsRecovery: boolean; + jobDataMapEntries: JobDataMapEntry[]; +} + +interface JobDataMapEntry { + key: string; + type: JobDataMapType; + value: string; } @Component({ @@ -66,10 +88,19 @@ export class ManagerComponent implements OnInit, OnDestroy { triggerLoading = false; wizardMode: WizardMode = 'create'; wizardOpen = false; + jobWizardOpen = false; detailDrawerOpen = false; wizardSubmitting = false; + jobWizardSubmitting = false; wizardError: string; + jobWizardError: string; triggerDraft: TriggerDraft = this.buildEmptyDraft(); + jobDraft: JobDraft = this.buildEmptyJobDraft(); + jobWizardMode: WizardMode = 'create'; + jobGroupFilter = 'ALL'; + triggerGroupFilter = 'ALL'; + jobSearch = ''; + triggerSearch = ''; private readonly roadmapPages = new Set(['calendars', 'executions']); private readonly subscriptions: Subscription[] = []; @@ -154,9 +185,14 @@ export class ManagerComponent implements OnInit, OnDestroy { this.wizardOpen = false; } + closeJobWizardDrawer() { + this.jobWizardOpen = false; + } + closeDrawers() { this.detailDrawerOpen = false; this.wizardOpen = false; + this.jobWizardOpen = false; } refreshScheduler() { @@ -306,6 +342,85 @@ export class ManagerComponent implements OnInit, OnDestroy { this.openDetailDrawer(); } + openCreateJobWizard() { + this.jobWizardMode = 'create'; + this.jobDraft = this.buildEmptyJobDraft(); + this.jobWizardError = null; + this.jobWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('jobs'); + this.jobWizardOpen = true; + } + + openEditJobWizard() { + if (!this.selectedScheduledJob?.jobKeyDTO) { + this.showRoadmapNotice('Editing requires a stored job returned by the backend'); + return; + } + this.jobWizardMode = 'edit'; + this.jobWizardError = null; + this.jobDraft = { + name: this.selectedScheduledJob.jobKeyDTO.name, + group: this.selectedScheduledJob.jobKeyDTO.group || 'DEFAULT', + jobClass: this.selectedScheduledJob.jobClassName, + description: this.selectedScheduledJob.description || '', + durable: this.selectedScheduledJob.durable, + requestsRecovery: this.selectedScheduledJob.requestsRecovery, + jobDataMapEntries: this.toJobDataMapEntries(this.selectedScheduledJob.jobDataMap) + }; + this.jobWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('jobs'); + this.jobWizardOpen = true; + } + + submitJobWizard() { + this.jobWizardError = null; + if (!this.canSubmitJob()) { + this.jobWizardError = 'Job name, group, and class are required.'; + return; + } + + let jobDataMap: {[key: string]: unknown}; + try { + jobDataMap = this.serializeJobDataMap(this.jobDraft.jobDataMapEntries); + } catch (err) { + this.jobWizardError = this.getErrorMessage(err, 'JobDataMap contains invalid values.'); + return; + } + + const command = new ScheduledJobCommand(); + command.jobClass = this.jobDraft.jobClass; + command.description = this.jobDraft.description; + command.durable = this.jobDraft.durable; + command.requestsRecovery = this.jobDraft.requestsRecovery; + command.jobDataMap = jobDataMap; + + this.jobWizardSubmitting = true; + const group = this.jobDraft.group || 'DEFAULT'; + const name = this.jobDraft.name.trim(); + const request = this.jobWizardMode === 'edit' + ? this.jobService.updateJob(group, name, command) + : this.jobService.createJob(group, name, command); + + const subscription = request.subscribe({ + next: job => { + this.jobWizardSubmitting = false; + this.upsertScheduledJob(job); + this.selectedScheduledJob = job; + this.selectedJobClass = job.jobClassName; + this.jobWizardOpen = false; + this.detailDrawerOpen = true; + this.operationNotice = this.jobWizardMode === 'edit' ? 'Stored job updated.' : 'Stored job created.'; + }, + error: () => { + this.jobWizardSubmitting = false; + this.jobWizardError = 'Unable to save the stored job.'; + } + }); + this.subscriptions.push(subscription); + } + triggerSelectedJobNow() { if (!this.selectedScheduledJob) { this.showRoadmapNotice('Trigger-now requires a scheduled job key returned by the backend'); @@ -407,13 +522,16 @@ export class ManagerComponent implements OnInit, OnDestroy { this.triggerDraft = { triggerName: this.selectedTriggerKey.name, group: this.selectedTriggerKey.group || 'DEFAULT', + jobTargetType: trigger?.jobKeyDTO ? 'stored' : 'class', + storedJobKey: trigger?.jobKeyDTO ? this.getJobOptionValue(trigger.jobKeyDTO.group, trigger.jobKeyDTO.name) : this.getDefaultStoredJobKey(), jobClass: trigger?.jobDetailDTO?.jobClassName || this.jobs[0] || '', startDate: this.toDatetimeLocalValue(trigger?.startTime), endDate: this.toDatetimeLocalValue(trigger?.endTime), repeatIntervalAmount: repeatInterval.amount, repeatIntervalUnit: repeatInterval.unit, repeatCount: trigger?.repeatCount ?? -1, - misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction) + misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction), + jobDataMapEntries: this.toJobDataMapEntries(trigger?.jobDataMap) }; this.selectPage('dashboard'); this.wizardOpen = true; @@ -435,12 +553,19 @@ export class ManagerComponent implements OnInit, OnDestroy { const command = new SimpleTriggerCommand(); command.triggerName = this.triggerDraft.triggerName.trim(); command.triggerGroup = this.triggerDraft.group || 'DEFAULT'; - command.jobClass = this.triggerDraft.jobClass; + command.jobClass = this.triggerDraft.jobTargetType === 'class' ? this.triggerDraft.jobClass : null; + command.jobKey = this.triggerDraft.jobTargetType === 'stored' ? this.parseJobOptionValue(this.triggerDraft.storedJobKey) : null; command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate); command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate); command.repeatInterval = this.getRepeatIntervalMs(); command.repeatCount = this.triggerDraft.repeatCount; command.misfireInstruction = this.triggerDraft.misfireInstruction; + try { + command.jobDataMap = this.serializeJobDataMap(this.triggerDraft.jobDataMapEntries); + } catch (err) { + this.wizardError = this.getErrorMessage(err, 'JobDataMap contains invalid values.'); + return; + } this.wizardSubmitting = true; const request = this.wizardMode === 'edit' @@ -596,7 +721,61 @@ export class ManagerComponent implements OnInit, OnDestroy { } getScheduledJobRows(): ScheduledJob[] { - return this.scheduledJobs || []; + const search = this.jobSearch?.trim().toLowerCase(); + return (this.scheduledJobs || []).filter(job => { + const groupMatches = this.jobGroupFilter === 'ALL' || job.jobKeyDTO?.group === this.jobGroupFilter; + const searchable = `${job.jobKeyDTO?.group}.${job.jobKeyDTO?.name} ${job.jobClassName}`.toLowerCase(); + return groupMatches && (!search || searchable.includes(search)); + }); + } + + getTriggerRows(): TriggerKey[] { + const search = this.triggerSearch?.trim().toLowerCase(); + return (this.triggerKeys || []).filter(triggerKey => { + const group = this.getTriggerGroup(triggerKey); + const groupMatches = this.triggerGroupFilter === 'ALL' || group === this.triggerGroupFilter; + const searchable = `${group}.${triggerKey.name} ${this.getTriggerJobName(triggerKey)}`.toLowerCase(); + return groupMatches && (!search || searchable.includes(search)); + }); + } + + getJobGroups(): string[] { + return this.getUniqueGroups(this.scheduledJobs.map(job => job.jobKeyDTO?.group)); + } + + getTriggerGroups(): string[] { + return this.getUniqueGroups(this.triggerKeys.map(triggerKey => this.getTriggerGroup(triggerKey))); + } + + getStoredJobOptions(): {label: string; value: string}[] { + return this.scheduledJobs.map(job => ({ + label: `${job.jobKeyDTO.group}.${job.jobKeyDTO.name}`, + value: this.getJobOptionValue(job.jobKeyDTO.group, job.jobKeyDTO.name) + })); + } + + getSelectedJobDataMapPreview(): string { + return this.formatJson(this.selectedScheduledJob?.jobDataMap || {}); + } + + getSelectedTriggerDataMapPreview(): string { + return this.formatJson(this.selectedTrigger?.jobDataMap || {}); + } + + getJobDraftDataMapPreview(): string { + try { + return this.formatJson(this.serializeJobDataMap(this.jobDraft.jobDataMapEntries)); + } catch (err) { + return this.getErrorMessage(err, 'Invalid JobDataMap'); + } + } + + getTriggerDraftDataMapPreview(): string { + try { + return this.formatJson(this.serializeJobDataMap(this.triggerDraft.jobDataMapEntries)); + } catch (err) { + return this.getErrorMessage(err, 'Invalid JobDataMap'); + } } getWizardTitle(): string { @@ -608,9 +787,12 @@ export class ManagerComponent implements OnInit, OnDestroy { } canSubmitTrigger(): boolean { + const hasTarget = this.triggerDraft.jobTargetType === 'stored' + ? !!this.triggerDraft.storedJobKey + : !!this.triggerDraft.jobClass; return !!( this.triggerDraft.triggerName?.trim() - && this.triggerDraft.jobClass + && hasTarget && this.triggerDraft.misfireInstruction && this.triggerDraft.repeatCount !== null && this.triggerDraft.repeatCount !== undefined @@ -619,6 +801,18 @@ export class ManagerComponent implements OnInit, OnDestroy { ); } + canSubmitJob(): boolean { + return !!(this.jobDraft.name?.trim() && this.jobDraft.group?.trim() && this.jobDraft.jobClass); + } + + addJobDataMapEntry(entries: JobDataMapEntry[]) { + entries.push({key: '', type: 'string', value: ''}); + } + + removeJobDataMapEntry(entries: JobDataMapEntry[], index: number) { + entries.splice(index, 1); + } + getFirePreview(): string[] { const start = this.fromDatetimeLocalValue(this.triggerDraft.startDate) || new Date(); const repeatInterval = this.getRepeatIntervalMs(); @@ -728,6 +922,11 @@ export class ManagerComponent implements OnInit, OnDestroy { } } + private upsertScheduledJob(job: ScheduledJob) { + const otherJobs = this.scheduledJobs.filter(currentJob => !this.sameJob(currentJob, job)); + this.scheduledJobs = [job, ...otherJobs]; + } + private sameTriggerKey(first: TriggerKey, second: TriggerKey): boolean { return first?.name === second?.name && this.getTriggerGroup(first) === this.getTriggerGroup(second); } @@ -744,16 +943,132 @@ export class ManagerComponent implements OnInit, OnDestroy { return { triggerName: '', group: 'DEFAULT', + jobTargetType: this.scheduledJobs.length > 0 ? 'stored' : 'class', + storedJobKey: this.getDefaultStoredJobKey(), jobClass: this.jobs[0] || '', startDate: this.toDatetimeLocalValue(new Date()), endDate: '', repeatIntervalAmount: 1, repeatIntervalUnit: 'minutes', repeatCount: -1, - misfireInstruction: 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT' + misfireInstruction: 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT', + jobDataMapEntries: [] }; } + private buildEmptyJobDraft(): JobDraft { + return { + name: '', + group: 'DEFAULT', + jobClass: this.jobs[0] || '', + description: '', + durable: true, + requestsRecovery: false, + jobDataMapEntries: [] + }; + } + + private getDefaultStoredJobKey(): string { + const job = this.scheduledJobs[0]; + return job?.jobKeyDTO ? this.getJobOptionValue(job.jobKeyDTO.group, job.jobKeyDTO.name) : ''; + } + + private getJobOptionValue(group: string, name: string): string { + return `${group || 'DEFAULT'}::${name}`; + } + + private parseJobOptionValue(value: string): {group: string; name: string} { + const [group, name] = (value || '').split('::'); + return name ? {group: group || 'DEFAULT', name} : null; + } + + private getUniqueGroups(groups: string[]): string[] { + return Array.from(new Set((groups || []).filter(Boolean))).sort(); + } + + private toJobDataMapEntries(jobDataMap: {[key: string]: unknown}): JobDataMapEntry[] { + return Object.entries(jobDataMap || {}).map(([key, value]) => ({ + key, + type: this.getJobDataMapType(value), + value: this.getJobDataMapEntryValue(value) + })); + } + + private getJobDataMapType(value: unknown): JobDataMapType { + if (value === null) { + return 'null'; + } + if (typeof value === 'number') { + return 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (typeof value === 'object') { + return 'json'; + } + return 'string'; + } + + private getJobDataMapEntryValue(value: unknown): string { + if (value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return `${value ?? ''}`; + } + + private serializeJobDataMap(entries: JobDataMapEntry[]): {[key: string]: unknown} { + return (entries || []).reduce((dataMap, entry) => { + const key = entry.key?.trim(); + if (!key) { + throw new Error('JobDataMap keys cannot be blank.'); + } + if (Object.prototype.hasOwnProperty.call(dataMap, key)) { + throw new Error(`JobDataMap key "${key}" is duplicated.`); + } + dataMap[key] = this.serializeJobDataMapValue(entry); + return dataMap; + }, {} as {[key: string]: unknown}); + } + + private serializeJobDataMapValue(entry: JobDataMapEntry): unknown { + switch (entry.type) { + case 'number': { + const value = Number(entry.value); + if (Number.isNaN(value)) { + throw new Error(`JobDataMap key "${entry.key}" must be a number.`); + } + return value; + } + case 'boolean': + if (entry.value !== 'true' && entry.value !== 'false') { + throw new Error(`JobDataMap key "${entry.key}" must be true or false.`); + } + return entry.value === 'true'; + case 'json': + try { + return JSON.parse(entry.value || 'null'); + } catch (err) { + throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON.`); + } + case 'null': + return null; + default: + return entry.value || ''; + } + } + + private formatJson(value: unknown): string { + return JSON.stringify(value || {}, null, 2); + } + + private getErrorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; + } + private getRepeatIntervalMs(): number { const amount = Number(this.triggerDraft.repeatIntervalAmount || 0); switch (this.triggerDraft.repeatIntervalUnit) { diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java index 9b198e0..1343abb 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/JobController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts; import it.fabioformosa.quartzmanager.api.common.config.QuartzManagerPaths; import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; import it.fabioformosa.quartzmanager.api.services.JobService; import org.quartz.SchedulerException; @@ -17,6 +18,8 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -24,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.stream.Collectors; +import jakarta.validation.Valid; + @RequestMapping(QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH) @SecurityRequirement(name = OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA) @RestController @@ -64,6 +69,19 @@ public class JobController { return jobService.getScheduledJob(group, name); } + @PostMapping("/jobs/{group}/{name}") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a stored job") + public ScheduledJobDTO createJob(@PathVariable String group, @PathVariable String name, @Valid @RequestBody ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException { + return jobService.createJob(group, name, scheduledJobInputDTO); + } + + @PutMapping("/jobs/{group}/{name}") + @Operation(summary = "Update a stored job") + public ScheduledJobDTO updateJob(@PathVariable String group, @PathVariable String name, @Valid @RequestBody ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException, JobNotFoundException { + return jobService.updateJob(group, name, scheduledJobInputDTO); + } + @PostMapping("/jobs/{group}/{name}/trigger") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Trigger a job now") diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java new file mode 100644 index 0000000..c30be22 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/ScheduledJobInputDTO.java @@ -0,0 +1,29 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +public class ScheduledJobInputDTO { + @NotBlank + private String jobClass; + + private String description; + + @Builder.Default + private boolean durable = true; + + private boolean requestsRecovery; + + @Nullable + private Map jobDataMap; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java index 265167e..2ffb228 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java @@ -22,4 +22,7 @@ public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerR @Nullable private Map jobDataMap; + + @Nullable + private JobKeyDTO jobKey; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java index bf30318..a32b59f 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerCommandDTO.java @@ -5,7 +5,6 @@ import it.fabioformosa.quartzmanager.api.validators.ValidTriggerPeriod; import lombok.*; import lombok.experimental.SuperBuilder; -import jakarta.validation.constraints.NotBlank; import java.util.Date; @ValidTriggerPeriod @@ -16,7 +15,6 @@ import java.util.Date; @ToString @Data public class TriggerCommandDTO implements TriggerPeriodDTO { - @NotBlank private String jobClass; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java index 3a554a2..bbc7b74 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/JobService.java @@ -2,13 +2,18 @@ package it.fabioformosa.quartzmanager.api.services; import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; import it.fabioformosa.quartzmanager.api.jobs.AbstractQuartzManagerJob; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.quartz.JobDetail; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; @@ -83,6 +88,23 @@ public class JobService { return convertJob(jobKey); } + public ScheduledJobDTO createJob(String group, String name, ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException { + JobKey jobKey = JobKey.jobKey(name, group); + if (scheduler.checkExists(jobKey)) + throw new ResourceConflictException("Job " + jobKey + " already exists"); + + JobDetail jobDetail = buildJobDetail(jobKey, scheduledJobInputDTO); + scheduler.addJob(jobDetail, false); + return convertJob(jobKey); + } + + public ScheduledJobDTO updateJob(String group, String name, ScheduledJobInputDTO scheduledJobInputDTO) throws SchedulerException, ClassNotFoundException, JobNotFoundException { + JobKey jobKey = requireJob(group, name); + JobDetail jobDetail = buildJobDetail(jobKey, scheduledJobInputDTO); + scheduler.addJob(jobDetail, true); + return convertJob(jobKey); + } + public void triggerJob(String group, String name) throws SchedulerException, JobNotFoundException { JobKey jobKey = requireJob(group, name); scheduler.triggerJob(jobKey); @@ -100,6 +122,19 @@ public class JobService { return jobKey; } + private JobDetail buildJobDetail(JobKey jobKey, ScheduledJobInputDTO scheduledJobInputDTO) throws ClassNotFoundException { + Class jobClass = Class.forName(scheduledJobInputDTO.getJobClass()).asSubclass(Job.class); + JobBuilder jobBuilder = JobBuilder.newJob(jobClass) + .withIdentity(jobKey) + .storeDurably(scheduledJobInputDTO.isDurable()) + .requestRecovery(scheduledJobInputDTO.isRequestsRecovery()); + if (scheduledJobInputDTO.getDescription() != null) + jobBuilder.withDescription(scheduledJobInputDTO.getDescription()); + if (scheduledJobInputDTO.getJobDataMap() != null) + jobBuilder.usingJobData(new JobDataMap(scheduledJobInputDTO.getJobDataMap())); + return jobBuilder.build(); + } + private ScheduledJobDTO convertJob(JobKey jobKey) { try { JobDetail jobDetail = scheduler.getJobDetail(jobKey); diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java index ed783c3..174db6a 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerService.java @@ -37,14 +37,25 @@ public class SimpleTriggerService extends AbstractSchedulerService { if (scheduler.checkExists(triggerKey)) throw new ResourceConflictException("Trigger " + triggerKey + " already exists"); - Class jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class); - JobDetail jobDetail = JobBuilder.newJob() - .ofType(jobClass) - .storeDurably(false) - .build(); - SimpleTrigger newSimpleTrigger = conversionService.convert(simpleTriggerCommandDTO, SimpleTrigger.class); - scheduler.scheduleJob(jobDetail, newSimpleTrigger); + if (simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey() != null) { + JobKey jobKey = JobKey.jobKey( + simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getName(), + simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobKey().getGroup() + ); + if (!scheduler.checkExists(jobKey)) + throw new ResourceConflictException("Job " + jobKey + " does not exist"); + newSimpleTrigger = newSimpleTrigger.getTriggerBuilder().forJob(jobKey).build(); + scheduler.scheduleJob(newSimpleTrigger); + } + else { + Class jobClass = Class.forName(simpleTriggerCommandDTO.getSimpleTriggerInputDTO().getJobClass()).asSubclass(Job.class); + JobDetail jobDetail = JobBuilder.newJob() + .ofType(jobClass) + .storeDurably(false) + .build(); + scheduler.scheduleJob(jobDetail, newSimpleTrigger); + } return conversionService.convert(newSimpleTrigger, SimpleTriggerDTO.class); } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java index 74d94cf..5c03377 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/JobControllerTest.java @@ -4,6 +4,7 @@ import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests; import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils; import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; import it.fabioformosa.quartzmanager.api.dto.ScheduledJobDTO; +import it.fabioformosa.quartzmanager.api.dto.ScheduledJobInputDTO; import it.fabioformosa.quartzmanager.api.jobs.SampleJob; import it.fabioformosa.quartzmanager.api.services.JobService; import org.junit.jupiter.api.Test; @@ -21,6 +22,7 @@ import java.util.List; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @ContextConfiguration(classes = {QuartManagerApplicationTests.class}) @WebMvcTest(controllers = JobController.class, properties = { @@ -70,6 +72,50 @@ class JobControllerTest { Mockito.verify(jobService).triggerJob("DEFAULT", "sampleJob"); } + @Test + void whenCreateJobIsCalled_thenCreatedJobIsReturned() throws Exception { + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder() + .jobClass(SampleJob.class.getName()) + .durable(true) + .build(); + ScheduledJobDTO scheduledJobDTO = ScheduledJobDTO.builder() + .jobKeyDTO(JobKeyDTO.builder().name("sampleJob").group("DEFAULT").build()) + .jobClassName(SampleJob.class.getName()) + .durable(true) + .build(); + Mockito.when(jobService.createJob("DEFAULT", "sampleJob", inputDTO)).thenReturn(scheduledJobDTO); + + mockMvc.perform(post(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(inputDTO))) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(scheduledJobDTO))); + + Mockito.verify(jobService).createJob("DEFAULT", "sampleJob", inputDTO); + } + + @Test + void whenUpdateJobIsCalled_thenUpdatedJobIsReturned() throws Exception { + ScheduledJobInputDTO inputDTO = ScheduledJobInputDTO.builder() + .jobClass(SampleJob.class.getName()) + .durable(true) + .build(); + ScheduledJobDTO scheduledJobDTO = ScheduledJobDTO.builder() + .jobKeyDTO(JobKeyDTO.builder().name("sampleJob").group("DEFAULT").build()) + .jobClassName(SampleJob.class.getName()) + .durable(true) + .build(); + Mockito.when(jobService.updateJob("DEFAULT", "sampleJob", inputDTO)).thenReturn(scheduledJobDTO); + + mockMvc.perform(put(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(inputDTO))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(scheduledJobDTO))); + + Mockito.verify(jobService).updateJob("DEFAULT", "sampleJob", inputDTO); + } + @Test void whenDeleteJobIsCalled_thenNoContentIsReturned() throws Exception { mockMvc.perform(delete(JobController.JOB_CONTROLLER_BASE_URL + "/DEFAULT/sampleJob")
TriggerGroupTypeStateJobNext fireMisfire
{{ triggerKey.name }}{{ getTriggerGroup(triggerKey) }}{{ getTriggerType(triggerKey) }}{{ getTriggerState(triggerKey) }}{{ getTriggerJobName(triggerKey) }}{{ getTriggerNextFireLabel(triggerKey) }}{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}
No triggers returned by the backend.