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):

Overview

A dynamic scheduler consists of:

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)

  1. User opens Scheduler UI and creates a job (cron, parameters, enabled).

  2. API saves job to ScheduledJobs table.

  3. SchedulerService (backend) watches DB (or reloads on change) and schedules jobs in Quartz.

  4. When cron matches, Quartz invokes job class with parameters.

  5. Job logic runs (HTTP call, DB task, script, etc.) and writes JobHistory.

  6. 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

Deployment & Production Notes

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

Useful Extensions / Next Steps

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.