#135 added scheduled job management

This commit is contained in:
Fabio Formosa
2026-05-12 22:13:03 +02:00
parent 7d481247bc
commit 82e684f0a7
15 changed files with 544 additions and 36 deletions

View File

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

View File

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

View File

@@ -18,4 +18,5 @@ export class Trigger {
jobKeyDTO: JobKeyModel;
jobDetailDTO: JobDetail = new JobDetail();
mayFireAgain: boolean;
jobDataMap: {[key: string]: unknown};
}

View File

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

View File

@@ -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<ScheduledJob> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`)
}
createJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
updateJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
triggerJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {})
}

View File

@@ -96,7 +96,7 @@
<table>
<thead><tr><th style="width:22%">Trigger</th><th style="width:15%">Group</th><th style="width:18%">Type</th><th style="width:13%">State</th><th style="width:16%">Job</th><th style="width:16%">Next fire</th></tr></thead>
<tbody>
@for (triggerKey of triggerKeys; track triggerKey.name) {
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)">
<td class="mono">{{ triggerKey.name }}</td>
<td class="mono">{{ getTriggerGroup(triggerKey) }}</td>
@@ -164,10 +164,10 @@
<div class="page" [class.active]="activePage === 'jobs'">
<div class="page-kicker">
<div><h2>Jobs</h2><p>The backend exposes scheduled Quartz jobs plus eligible job classes for SimpleTrigger creation. Durability, recovery, data map, and related trigger keys are read-only in this release.</p></div>
<div class="toolbar"><input class="search" 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" name="jobSearch" placeholder="Filter jobs, groups, classes" [(ngModel)]="jobSearch"><select class="select compact-select" name="jobGroupFilter" [(ngModel)]="jobGroupFilter"><option value="ALL">All groups</option>@for (group of getJobGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateJobWizard()">New Job</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Scheduled Jobs</h2><div class="toolbar"><span class="chip normal">{{ 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="card-header"><h2 class="card-title">Scheduled Jobs</h2><div class="toolbar"><span class="chip normal">{{ getScheduledJobRows().length }} / {{ scheduledJobs.length }} JOBS</span><button type="button" class="btn" data-roadmap="Pause job group is on the roadmap">Pause Group</button><button type="button" class="btn" data-roadmap="Job export is on the roadmap">Export</button></div></div>
<div class="split">
<div class="table-wrap">
<table>
@@ -183,17 +183,19 @@
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'jobs'" aria-label="Job detail drawer">
<div class="drawer-title"><div><span class="chip normal">SCHEDULED</span><h2>{{ getSelectedJobShortName() }}</h2><div class="caption">{{ getSelectedJobKeyLabel() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" 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">Triggers</button><button type="button" class="tab">Data Map</button><button type="button" class="tab" data-roadmap="Job execution history is on the roadmap">Executions</button></div>
<div class="field-grid">
<div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div>
<div class="field"><label>Group</label><strong>{{ selectedScheduledJob?.jobKeyDTO?.group || '-' }}</strong></div>
<div class="field"><label>Durable</label><strong>{{ selectedScheduledJob?.durable ? 'YES' : 'NO' }}</strong></div>
<div class="field"><label>Requests recovery</label><strong>{{ selectedScheduledJob?.requestsRecovery ? 'YES' : 'NO' }}</strong></div>
</div>
<pre class="code-block">Backend contract
GET /quartz-manager/jobs
Returns scheduled Quartz jobs.</pre>
<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>
<pre class="code-block">JobDataMap
{{ getSelectedJobDataMapPreview() }}</pre>
<pre class="code-block">Triggers
@for (triggerKey of selectedScheduledJob?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre>
<div class="actions"><button type="button" class="btn primary" (click)="triggerSelectedJobNow()">Trigger Now</button><button type="button" class="btn" (click)="openEditJobWizard()">Edit Job</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobTargetType = 'stored'; triggerDraft.storedJobKey = selectedScheduledJob ? selectedScheduledJob.jobKeyDTO.group + '::' + selectedScheduledJob.jobKeyDTO.name : triggerDraft.storedJobKey">Create SimpleTrigger</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Interrupt remains roadmap-gated. Delete uses the scheduled job endpoint.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Job interruption is on the roadmap">Interrupt</button><button type="button" class="btn danger" (click)="deleteSelectedJob()">Delete Job</button></div></div>
</aside>
</div>
@@ -203,16 +205,16 @@ Returns scheduled Quartz jobs.</pre>
<div class="page" [class.active]="activePage === 'triggers'">
<div class="page-kicker">
<div><h2>Triggers</h2><p>The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.</p></div>
<div class="toolbar"><input class="search" value="Filter triggers, jobs, groups" data-roadmap="Trigger filtering is on the roadmap"><button type="button" class="btn primary" (click)="openCreateTriggerWizard()">Create Trigger</button></div>
<div class="toolbar"><input class="search" name="triggerSearch" placeholder="Filter triggers, jobs, groups" [(ngModel)]="triggerSearch"><select class="select compact-select" name="triggerGroupFilter" [(ngModel)]="triggerGroupFilter"><option value="ALL">All groups</option>@for (group of getTriggerGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateTriggerWizard()">Create Trigger</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Trigger Inventory</h2><div class="toolbar"><span class="chip normal">{{ triggerKeys.length }} TOTAL</span><span class="chip warn" data-roadmap="Trigger state counts are on the roadmap">STATE COUNTS ROADMAP</span></div></div>
<div class="card-header"><h2 class="card-title">Trigger Inventory</h2><div class="toolbar"><span class="chip normal">{{ getTriggerRows().length }} / {{ triggerKeys.length }} TOTAL</span><span class="chip warn" data-roadmap="Trigger state counts are on the roadmap">STATE COUNTS ROADMAP</span></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:18%">Trigger</th><th style="width:12%">Group</th><th style="width:15%">Type</th><th style="width:12%">State</th><th style="width:18%">Job</th><th style="width:15%">Next fire</th><th style="width:10%">Misfire</th></tr></thead>
<tbody>
@for (triggerKey of triggerKeys; track triggerKey.name) {
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)"><td class="mono">{{ triggerKey.name }}</td><td class="mono">{{ getTriggerGroup(triggerKey) }}</td><td>{{ getTriggerType(triggerKey) }}</td><td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td><td class="mono">{{ getTriggerJobName(triggerKey) }}</td><td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td><td class="mono">{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}</td></tr>
} @empty {
<tr><td colspan="7">No triggers returned by the backend.</td></tr>
@@ -232,6 +234,8 @@ Returns scheduled Quartz jobs.</pre>
<div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
</div>
<section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section>
<pre class="code-block">Trigger JobDataMap
{{ getSelectedTriggerDataMapPreview() }}</pre>
<div class="actions"><button type="button" class="btn" (click)="pauseSelectedTrigger()">Pause</button><button type="button" class="btn" (click)="resumeSelectedTrigger()">Resume</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn" data-roadmap="Trigger duplication is on the roadmap">Duplicate</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Unschedule uses the trigger lifecycle endpoint. Reset-error remains roadmap-gated.</span><div class="actions"><button type="button" class="btn danger" (click)="unscheduleSelectedTrigger()">Unschedule</button><button type="button" class="btn danger" data-roadmap="Reset error trigger is on the roadmap">Reset Error</button></div></div>
</aside>
@@ -324,7 +328,7 @@ Returns scheduled Quartz jobs.</pre>
</section>
</main>
@if (wizardOpen || detailDrawerOpen) {
@if (wizardOpen || jobWizardOpen || detailDrawerOpen) {
<button type="button" class="drawer-backdrop" aria-label="Close drawer" (click)="closeDrawers()"></button>
}
@@ -342,14 +346,27 @@ Returns scheduled Quartz jobs.</pre>
<form class="wizard-form" (ngSubmit)="submitTriggerWizard()">
<div class="wizard-scroll">
@if (wizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ wizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" readonly data-roadmap="Trigger group editing is on the roadmap"></div><div class="help">The current backend schedules SimpleTriggers by name. Group editing is tracked in the roadmap.</div></div><div class="control"><label>Target job</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">Loaded from GET /quartz-manager/jobs.</div></div></div></section>
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" list="trigger-groups" required></div><datalist id="trigger-groups">@for (group of getTriggerGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Quartz groups are implicit namespaces. Type a new group to create it with this trigger.</div></div><div class="control"><label>Target type</label><select class="select" name="jobTargetType" [(ngModel)]="triggerDraft.jobTargetType"><option value="stored">Existing stored job</option><option value="class">New job from class</option></select></div>@if (triggerDraft.jobTargetType === 'stored') { <div class="control"><label>Stored job</label><select class="select" name="storedJobKey" [(ngModel)]="triggerDraft.storedJobKey" required>@for (job of getStoredJobOptions(); track job.value) { <option [value]="job.value">{{ job.label }}</option> }</select><div class="help">The trigger will call TriggerBuilder.forJob with this stored job key.</div></div> } @else { <div class="control"><label>Job class</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">The backend will create an ephemeral job for this trigger.</div></div> }</div></section>
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><div class="type-option active"><strong>Simple</strong><span class="help">Repeat every fixed interval. Supported now.</span></div><button type="button" class="type-option" data-roadmap="CronTrigger support is on the roadmap"><strong>Cron</strong><span class="help">Calendar expression builder.</span></button><button type="button" class="type-option" data-roadmap="DailyTimeIntervalTrigger support is on the roadmap"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" data-roadmap="CalendarIntervalTrigger support is on the roadmap"><strong>Calendar Interval</strong><span class="help">Every N days, weeks, months.</span></button></div></div></section>
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>Repeat interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option></select></div><div class="help">The UI edits operational units and persists the current backend repeatInterval in milliseconds.</div></div><div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div></div></section>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required><option value="MISFIRE_INSTRUCTION_FIRE_NOW">MISFIRE_INSTRUCTION_FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option></select></div><div class="control"><label>Job data map override</label><textarea class="textarea mono" readonly data-roadmap="JobDataMap editing is on the roadmap">{}</textarea></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> every <strong>{{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}</strong>, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
<div class="warning-box"><strong>Backend support boundary</strong><span>Only SimpleTrigger create/reschedule is submitted. Trigger groups, calendars, job data maps, and other trigger families will show roadmap reminders.</span></div>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required><option value="MISFIRE_INSTRUCTION_FIRE_NOW">MISFIRE_INSTRUCTION_FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option></select></div><div class="control"><label>JobDataMap override</label><div class="data-map-editor">@for (entry of triggerDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="triggerDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="triggerDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="triggerDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(triggerDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(triggerDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getTriggerDraftDataMapPreview() }}</pre></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> every <strong>{{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}</strong>, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
<div class="warning-box"><strong>Backend support boundary</strong><span>SimpleTrigger create/reschedule is submitted. Groups are implicit Quartz namespaces, not standalone records.</span></div>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="resetWizard()">Reset</button><button type="submit" class="btn primary" [disabled]="wizardSubmitting || !canSubmitTrigger()">{{ wizardSubmitting ? 'Saving...' : getWizardCta() }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="jobWizardOpen" aria-label="Stored job editor">
<div class="wizard-header"><div><h2>{{ jobWizardMode === 'edit' ? 'Edit Stored Job' : 'New Stored Job' }}</h2><div class="caption">Stored jobs are durable Quartz JobDetails that triggers can launch by job key.</div></div><button type="button" class="drawer-close" (click)="closeJobWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitJobWizard()">
<div class="wizard-scroll">
@if (jobWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ jobWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Job key</label><div class="input-row"><input class="input" name="jobName" [(ngModel)]="jobDraft.name" [readonly]="jobWizardMode === 'edit'" required><input class="input mono" name="jobGroup" [(ngModel)]="jobDraft.group" [readonly]="jobWizardMode === 'edit'" list="job-groups" required></div><datalist id="job-groups">@for (group of getJobGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Groups are implicit. Typing a new group stores the job under that namespace.</div></div><div class="control"><label>Job class</label><select class="select" name="storedJobClass" [(ngModel)]="jobDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select></div><div class="control"><label>Description</label><input class="input" name="jobDescription" [(ngModel)]="jobDraft.description"></div></div></section>
<section class="form-card"><h3>Options</h3><div class="form-section"><label class="check-row"><input type="checkbox" name="jobDurable" [(ngModel)]="jobDraft.durable"> Store durably</label><label class="check-row"><input type="checkbox" name="jobRecovery" [(ngModel)]="jobDraft.requestsRecovery"> Requests recovery</label><div class="help">Durable jobs remain in the scheduler without active triggers and can be selected later by SimpleTriggers.</div></div></section>
<section class="form-card"><h3>JobDataMap</h3><div class="form-section"><div class="data-map-editor">@for (entry of jobDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="jobDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="jobDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="jobDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(jobDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(jobDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getJobDraftDataMapPreview() }}</pre></div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeJobWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="jobWizardSubmitting || !canSubmitJob()">{{ jobWizardSubmitting ? 'Saving...' : jobWizardMode === 'edit' ? 'Save Job' : 'Create Job' }}</button></div>
</form>
</aside>
</div>

View File

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

View File

@@ -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<ConsolePage>(['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) {

View File

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

View File

@@ -0,0 +1,29 @@
package it.fabioformosa.quartzmanager.api.dto;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class ScheduledJobInputDTO {
@NotBlank
private String jobClass;
private String description;
@Builder.Default
private boolean durable = true;
private boolean requestsRecovery;
@Nullable
private Map<String, ?> jobDataMap;
}

View File

@@ -22,4 +22,7 @@ public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerR
@Nullable
private Map<String, ?> jobDataMap;
@Nullable
private JobKeyDTO jobKey;
}

View File

@@ -5,7 +5,6 @@ import it.fabioformosa.quartzmanager.api.validators.ValidTriggerPeriod;
import lombok.*;
import lombok.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'")

View File

@@ -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<? extends Job> 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);

View File

@@ -37,14 +37,25 @@ public class SimpleTriggerService extends AbstractSchedulerService {
if (scheduler.checkExists(triggerKey))
throw new ResourceConflictException("Trigger " + triggerKey + " already exists");
Class<? extends Job> 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<? extends Job> 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);
}

View File

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