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
ScheduledJobstable.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);
}
}

Join the conversation! Your thoughts help the community grow.