mirror of
https://github.com/fabioformosa/quartz-manager.git
synced 2026-05-14 22:00:30 +09:00
#135 added scheduled job management
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export class ScheduledJobCommand {
|
||||
jobClass: string;
|
||||
description: string;
|
||||
durable: boolean;
|
||||
requestsRecovery: boolean;
|
||||
jobDataMap: {[key: string]: unknown};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -18,4 +18,5 @@ export class Trigger {
|
||||
jobKeyDTO: JobKeyModel;
|
||||
jobDetailDTO: JobDetail = new JobDetail();
|
||||
mayFireAgain: boolean;
|
||||
jobDataMap: {[key: string]: unknown};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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`, {})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -22,4 +22,7 @@ public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerR
|
||||
|
||||
@Nullable
|
||||
private Map<String, ?> jobDataMap;
|
||||
|
||||
@Nullable
|
||||
private JobKeyDTO jobKey;
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user