This article shows how to build a dynamic cron-job scheduler with a web UI that lets admins create / edit / enable / disable / run now / view history for scheduled jobs.
The backend uses Quartz.NET as the scheduler engine while job definitions and history are persisted in SQL Server. The Angular frontend provides a friendly editor for cron expressions and job parameters.
What you will get here (practical):
Architecture, flowchart and sequence diagrams (compact)
Database scripts (job definitions + history)
ASP.NET Core backend: Quartz setup, Job classes, SchedulerService, Controller endpoints
Angular module: components, service, routing, UI samples (list, editor, run now)
Best practices, validation and production notes
Overview
A dynamic scheduler consists of:
Scheduler engine (Quartz.NET) — executes jobs using cron expressions.
Job store (SQL Server) — stores job definitions and execution history.
API — create / update / delete / run jobs; list job status and history.
Angular UI — job list, editor with cron helper, history view, run-now button.
Flow: admin creates job → backend persists definition → scheduler service schedules job → Quartz triggers job → job executes and logs history → UI shows status.
Flowchart
Admin (UI) → Submit Job Definition → API persists in DB
↓ ↓
←--------- SchedulerService loads definitions ---------→
|
v
Quartz Scheduler
|
v
Job Triggered
|
v
Job Execution (worker)
|
v
Execution Result logged in DB
|
v
UI fetches history/status
Workflow (compact)
User opens Scheduler UI and creates a job (cron, parameters, enabled).
API saves job to ScheduledJobs table.
SchedulerService (backend) watches DB (or reloads on change) and schedules jobs in Quartz.
When cron matches, Quartz invokes job class with parameters.
Job logic runs (HTTP call, DB task, script, etc.) and writes JobHistory.
UI shows job status and history; admin can run job immediately.
ER Diagram (Job metadata + history)
+-------------------------+ 1 → * +------------------------+
| ScheduledJobs |---------------| JobHistory |
+-------------------------+ +------------------------+
| JobId (PK) | | HistoryId (PK) |
| Name | | JobId (FK) |
| Description | | TriggeredAt |
| CronExpression | | FinishedAt |
| JobType | | Status |
| Parameters (JSON) | | Output (NVARCHAR(MAX)) |
| IsEnabled (bit) | | Error (NVARCHAR(MAX)) |
| CreatedBy | +------------------------+
| CreatedOn |
| UpdatedOn |
+-------------------------+
Architecture Diagram (Visio-style)
[ Angular UI ] <---- HTTPS ----> [ ASP.NET Core API ]
| |
| v
| [ SchedulerService (Quartz.NET) ]
| |
| v
| [ Job Executors (IJob implementations) ]
| |
| v
| [ SQL Server: ScheduledJobs, JobHistory ]
Sequence Diagram (compact)
Admin -> UI: Create Job
UI -> API: POST /api/scheduler/jobs
API -> DB: INSERT ScheduledJobs
API -> SchedulerService: Notify reload
SchedulerService -> Quartz: Schedule job
Quartz -> JobImpl: Trigger execution
JobImpl -> DB: Insert JobHistory (start)
JobImpl -> Execute task (HTTP/DB/script)
JobImpl -> DB: Update JobHistory (finish)
UI -> API: GET /api/scheduler/jobs/1/history
API -> DB: Return history
Database scripts (SQL Server)
Save as scheduler_schema.sql.
CREATE TABLE ScheduledJobs (
JobId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
Name NVARCHAR(200) NOT NULL,
Description NVARCHAR(1000) NULL,
CronExpression NVARCHAR(100) NOT NULL,
JobType NVARCHAR(200) NOT NULL, -- e.g. "HttpPost", "SqlTask", "Custom"
Parameters NVARCHAR(MAX) NULL, -- JSON
IsEnabled BIT NOT NULL DEFAULT 1,
CreatedBy NVARCHAR(200) NULL,
CreatedOn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedOn DATETIME2 NULL
);
CREATE TABLE JobHistory (
HistoryId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
JobId UNIQUEIDENTIFIER NOT NULL,
TriggeredAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
FinishedAt DATETIME2 NULL,
Status NVARCHAR(50) NULL, -- Running/Success/Failed
Output NVARCHAR(MAX) NULL,
Error NVARCHAR(MAX) NULL,
CONSTRAINT FK_JobHistory_ScheduledJobs FOREIGN KEY (JobId) REFERENCES ScheduledJobs(JobId)
);
CREATE INDEX IX_ScheduledJobs_Cron ON ScheduledJobs(CronExpression);
CREATE INDEX IX_JobHistory_Job ON JobHistory(JobId);
Backend — ASP.NET Core (practical code)
NuGet packages
dotnet add package Quartz
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Newtonsoft.Json
We keep Quartz in-memory for runtime scheduling; job definitions persist in DB so scheduler recreates on restart. For clustered scenarios consider Quartz AdoJobStore.
1) EF Core models
Models/ScheduledJob.cs
public class ScheduledJob
{
public Guid JobId { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public string CronExpression { get; set; } = "";
public string JobType { get; set; } = ""; // "HttpPost", "Sql", "Custom"
public string? Parameters { get; set; } // JSON
public bool IsEnabled { get; set; } = true;
public string? CreatedBy { get; set; }
public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedOn { get; set; }
}
Models/JobHistory.cs
public class JobHistory
{
public Guid HistoryId { get; set; }
public Guid JobId { get; set; }
public DateTime TriggeredAt { get; set; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
public string? Status { get; set; }
public string? Output { get; set; }
public string? Error { get; set; }
}
Data/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> opts) : base(opts) {}
public DbSet<ScheduledJob> ScheduledJobs { get; set; } = null!;
public DbSet<JobHistory> JobHistory { get; set; } = null!;
}
2) Job implementation (example: HTTP POST job)
Jobs/HttpPostJob.cs
using Quartz;
using Newtonsoft.Json;
public class HttpPostJob : IJob
{
private readonly IServiceProvider _services;
public HttpPostJob(IServiceProvider services) { _services = services; }
public async Task Execute(IJobExecutionContext context)
{
var jobData = context.MergedJobDataMap;
var jobId = Guid.Parse(jobData.GetString("JobId")!);
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var history = new JobHistory { JobId = jobId, TriggeredAt = DateTime.UtcNow, Status = "Running" };
db.JobHistory.Add(history);
await db.SaveChangesAsync();
try
{
var parametersJson = jobData.GetString("Parameters");
dynamic? parameters = null;
if (!string.IsNullOrEmpty(parametersJson))
parameters = JsonConvert.DeserializeObject(parametersJson);
string url = parameters?.url;
string payload = parameters?.body ?? "";
using var http = new HttpClient();
var resp = await http.PostAsync(url, new StringContent(payload, Encoding.UTF8, "application/json"));
var outText = await resp.Content.ReadAsStringAsync();
history.FinishedAt = DateTime.UtcNow;
history.Status = resp.IsSuccessStatusCode ? "Success" : "Failed";
history.Output = outText;
if (!resp.IsSuccessStatusCode)
history.Error = $"Status {resp.StatusCode}";
await db.SaveChangesAsync();
}
catch (Exception ex)
{
history.FinishedAt = DateTime.UtcNow;
history.Status = "Failed";
history.Error = ex.ToString();
await db.SaveChangesAsync();
}
}
}
You can add more job implementations (SQL task, custom assembly call). JobType maps to job class.
3) SchedulerService — maps DB definitions to Quartz triggers
Services/SchedulerService.cs
using Quartz;
using Quartz.Impl.Matchers;
public class SchedulerService : IHostedService
{
private readonly ISchedulerFactory _factory;
private IScheduler? _scheduler;
private readonly IServiceProvider _services;
private readonly ILogger<SchedulerService> _logger;
private readonly ApplicationDbContext _db;
public SchedulerService(ISchedulerFactory factory, IServiceProvider services, ILogger<SchedulerService> logger, ApplicationDbContext db)
{
_factory = factory; _services = services; _logger = logger; _db = db;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_scheduler = await _factory.GetScheduler(cancellationToken);
await _scheduler.Start(cancellationToken);
await LoadJobsFromDb();
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_scheduler != null) await _scheduler.Shutdown(cancellationToken);
}
public async Task ReloadJob(Guid jobId)
{
if (_scheduler == null) return;
// remove existing triggers for job
var jobKey = new JobKey(jobId.ToString(), "dynamic-jobs");
await _scheduler.DeleteJob(jobKey);
var job = await _db.ScheduledJobs.FindAsync(jobId);
if (job != null && job.IsEnabled)
await ScheduleJob(job);
}
private async Task LoadJobsFromDb()
{
var jobs = _db.ScheduledJobs.Where(j => j.IsEnabled).ToList();
foreach (var j in jobs) await ScheduleJob(j);
}
private async Task ScheduleJob(ScheduledJob job)
{
if (_scheduler == null) return;
IJobDetail jobDetail;
switch (job.JobType)
{
case "HttpPost":
jobDetail = JobBuilder.Create<HttpPostJob>()
.WithIdentity(job.JobId.ToString(), "dynamic-jobs")
.UsingJobData("JobId", job.JobId.ToString())
.UsingJobData("Parameters", job.Parameters ?? "")
.Build();
break;
default:
_logger.LogWarning("Unknown job type {type}", job.JobType);
return;
}
var cron = CronScheduleBuilder.CronSchedule(job.CronExpression);
var trigger = TriggerBuilder.Create()
.WithIdentity(job.JobId.ToString(), "dynamic-triggers")
.WithSchedule(cron)
.StartNow()
.Build();
await _scheduler.ScheduleJob(jobDetail, trigger);
_logger.LogInformation("Scheduled job {name} ({id})", job.Name, job.JobId);
}
// Helper to run job now
public async Task TriggerNow(Guid jobId)
{
if (_scheduler == null) return;
var jobKey = new JobKey(jobId.ToString(), "dynamic-jobs");
await _scheduler.TriggerJob(jobKey);
}
}
Notes
4) API Controller (create / edit / list / run-now / history)
Controllers/SchedulerController.cs
[ApiController]
[Route("api/[controller]")]
public class SchedulerController : ControllerBase
{
private readonly ApplicationDbContext _db;
private readonly SchedulerService _scheduler;
public SchedulerController(ApplicationDbContext db, SchedulerService scheduler)
{
_db = db; _scheduler = scheduler;
}
[HttpGet("jobs")]
public IActionResult GetJobs() => Ok(_db.ScheduledJobs.ToList());
[HttpPost("jobs")]
public async Task<IActionResult> Create([FromBody] ScheduledJob job)
{
job.JobId = Guid.NewGuid();
job.CreatedOn = DateTime.UtcNow;
_db.ScheduledJobs.Add(job);
await _db.SaveChangesAsync();
await _scheduler.ReloadJob(job.JobId);
return Ok(job);
}
[HttpPut("jobs/{id}")]
public async Task<IActionResult> Update(Guid id, [FromBody] ScheduledJob model)
{
var job = await _db.ScheduledJobs.FindAsync(id);
if (job == null) return NotFound();
job.Name = model.Name; job.Description = model.Description; job.CronExpression = model.CronExpression;
job.Parameters = model.Parameters; job.JobType = model.JobType; job.IsEnabled = model.IsEnabled;
job.UpdatedOn = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _scheduler.ReloadJob(id);
return Ok(job);
}
[HttpPost("jobs/{id}/run")]
public async Task<IActionResult> RunNow(Guid id)
{
await _scheduler.TriggerNow(id);
return Ok();
}
[HttpGet("jobs/{id}/history")]
public IActionResult History(Guid id)
{
var h = _db.JobHistory.Where(x => x.JobId == id).OrderByDescending(x => x.TriggeredAt).Take(50);
return Ok(h);
}
}
5) Program.cs (register services and Quartz)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ApplicationDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Quartz
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
// Register jobs
builder.Services.AddScoped<HttpPostJob>();
// SchedulerService (singleton since HostedService uses it directly)
builder.Services.AddSingleton<SchedulerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<SchedulerService>());
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Note: We register SchedulerService as both singleton and hosted service for lifecycle management.
Angular — Full module (routing + components)
Create module scheduler.
1) Install packages
ng generate module scheduler --routing
ng generate component scheduler/job-list
ng generate component scheduler/job-editor
ng generate component scheduler/job-history
2) Scheduler service (API calls)
src/app/scheduler/scheduler.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ScheduledJob {
jobId?: string;
name: string;
description?: string;
cronExpression: string;
jobType: string;
parameters?: string; // JSON string
isEnabled?: boolean;
}
@Injectable({ providedIn: 'root' })
export class SchedulerApiService {
private base = '/api/scheduler';
constructor(private http: HttpClient) {}
getJobs(): Observable<ScheduledJob[]> { return this.http.get<ScheduledJob[]>(`${this.base}/jobs`); }
createJob(job: ScheduledJob) { return this.http.post(`${this.base}/jobs`, job); }
updateJob(id: string, job: ScheduledJob) { return this.http.put(`${this.base}/jobs/${id}`, job); }
runNow(id: string) { return this.http.post(`${this.base}/jobs/${id}/run`, {}); }
getHistory(id: string) { return this.http.get<any[]>(`${this.base}/jobs/${id}/history`); }
}
3) Job list component (simple UI)
job-list.component.html
<div class="container">
<h4>Scheduled Jobs</h4>
<button routerLink="/scheduler/edit" class="btn btn-primary">New Job</button>
<table class="table">
<thead><tr><th>Name</th><th>Cron</th><th>Type</th><th>Enabled</th><th>Actions</th></tr></thead>
<tbody>
<tr *ngFor="let j of jobs">
<td>{{j.name}}</td>
<td>{{j.cronExpression}}</td>
<td>{{j.jobType}}</td>
<td>{{j.isEnabled ? 'Yes':'No'}}</td>
<td>
<button (click)="edit(j)" class="btn btn-sm btn-outline-secondary">Edit</button>
<button (click)="run(j)" class="btn btn-sm btn-success">Run Now</button>
<button (click)="history(j)" class="btn btn-sm btn-info">History</button>
</td>
</tr>
</tbody>
</table>
</div>
job-list.component.ts
@Component({ /* ... */ })
export class JobListComponent implements OnInit {
jobs: ScheduledJob[] = [];
constructor(private svc: SchedulerApiService, private router: Router, private modal: NgbModal) {}
ngOnInit(){ this.load(); }
load(){ this.svc.getJobs().subscribe(r=>this.jobs = r); }
edit(j?: ScheduledJob){ this.router.navigate(['/scheduler/edit', j?.jobId]); }
run(j: ScheduledJob){ this.svc.runNow(j.jobId!).subscribe(()=> alert('Triggered')); }
history(j: ScheduledJob){ this.router.navigate(['/scheduler/history', j.jobId]); }
}
You can use a cron expression helper UI (see below).
4) Job editor component (create / update + cron helper)
job-editor.component.html (essential)
<div class="container">
<h4>{{isEdit ? 'Edit Job' : 'Create Job'}}</h4>
<form (ngSubmit)="save()">
<div class="form-group">
<label>Name</label>
<input class="form-control" [(ngModel)]="model.name" name="name" required/>
</div>
<div class="form-group">
<label>Cron Expression</label>
<input class="form-control" [(ngModel)]="model.cronExpression" name="cronExpression" required/>
<small class="form-text text-muted">Use cron format: sec min hour day month day-of-week</small>
<div *ngIf="cronError" class="text-danger">{{cronError}}</div>
</div>
<div class="form-group">
<label>Job Type</label>
<select class="form-control" [(ngModel)]="model.jobType" name="jobType">
<option value="HttpPost">HttpPost</option>
<option value="Sql">Sql</option>
</select>
</div>
<div class="form-group">
<label>Parameters (JSON)</label>
<textarea class="form-control" [(ngModel)]="model.parameters" name="parameters" rows="4"></textarea>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" [(ngModel)]="model.isEnabled" name="isEnabled" />
<label class="form-check-label">Enabled</label>
</div>
<button class="btn btn-primary" type="submit">Save</button>
<button class="btn btn-secondary" (click)="cancel()" type="button">Cancel</button>
</form>
</div>
job-editor.component.ts
@Component({ /* ... */ })
export class JobEditorComponent implements OnInit {
model: ScheduledJob = { name:'', cronExpression:'0 0/5 * * * ?', jobType:'HttpPost', parameters:'{}', isEnabled:true };
isEdit=false; cronError?:string;
constructor(private route: ActivatedRoute, private svc: SchedulerApiService, private router: Router) {}
ngOnInit(){
const id = this.route.snapshot.paramMap.get('id');
if(id){ this.isEdit=true; this.svc.getJobs().subscribe(jobs => {
const job = jobs.find(x=>x.jobId===id);
if(job) this.model = job;
}); }
}
save(){
// basic cron validation (we only check non-empty; backend validates fully)
if(!this.model.cronExpression){ this.cronError="Cron expression required"; return; }
try {
JSON.parse(this.model.parameters || '{}');
} catch(e){ alert('Parameters must be valid JSON'); return; }
if(this.isEdit) {
this.svc.updateJob(this.model.jobId!, this.model).subscribe(()=>this.router.navigate(['/scheduler']));
} else {
this.svc.createJob(this.model).subscribe(()=>this.router.navigate(['/scheduler']));
}
}
cancel(){ this.router.navigate(['/scheduler']); }
}
For production, add a cron preview helper (use https://crontab.guru style library) or a small cron-expression validator on client.
5) Job history component
job-history.component.ts / template: fetch via getHistory and render list with status, errors and outputs.
Validation & Safety
Validate cron expression on backend before scheduling (Quartz will throw if invalid). Use CronExpression.IsValidExpression(cron).
Validate JSON parameters to avoid injection. Sanitize inputs.
Authentication & Authorization: restrict scheduler UI to admin roles.
Rate limit “run now” to avoid accidental overuse.
Job isolation: ensure job code runs with limited privileges and timeouts. Use CancellationToken support and set misfire instructions if needed.
Deployment & Production Notes
Persistence: we persisted job definitions and history but used in-memory Quartz scheduler. On restart SchedulerService loads jobs from DB. For HA (multiple nodes) use Quartz AdoJobStore or a distributed scheduler approach to avoid duplicate executions.
Idempotency: design jobs to be idempotent or use single-run locks (DB locks).
Monitoring: expose metrics: scheduled job count, last run status, failures. Integrate with Prometheus/ELK.
Retries: for transient failures implement retry policy in job implementation.
Timeouts: set reasonable job timeouts and cancel via CancellationToken.
Security: jobs that perform HTTP calls should avoid storing secrets in plaintext; use Key Vault or secure config.
Example: Adding a SQL Task Job
Create SqlJob implementing IJob which reads SQL from Parameters and executes it in a safe transaction. Always parameterise statements and avoid allowing arbitrary DDL.
Testing checklist
Create jobs with valid and invalid cron expressions.
Edit job and verify scheduler reloads the new schedule.
Disable job and verify it stops firing.
Click “Run Now” and verify immediate execution and history logs.
Simulate job failure and ensure JobHistory contains stack trace.
Restart application and validate scheduled jobs are reloaded and still run.
Useful Extensions / Next Steps
Cron expression builder UI (human-friendly)
Job grouping and enabling/disabling groups
Pause / resume scheduler functionality
Export / import job definitions (JSON)
Notifications for failures (email/Slack)
Quarantine failing jobs after repeated failures
Final notes
This solution gives you a fully dynamic scheduler where non-developers (admins) can manage scheduled tasks from UI while backend retains robust execution using Quartz.NET. It balances persistence, runtime scheduling and practical safety for real-world deployments.