Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Quartz.Dashboard is a Blazor dashboard for Quartz.NET. It runs inside your ASP.NET Core application and renders the schedulers registered in that application.

Features

Fourteen pages, listed under The pages.

  • Reads the schedulers in its own container, through the container's IQuartzApiClient: usually the schedulers this process runs, with nothing to configure. A scheduler in another process is registered with AddQuartzHttpClient; see Fronting a scheduler in another process over HTTP.
  • Or shows every scheduler in a database, with nothing asked of the processes running them. AttachStore discovers them and shows each as a window on the shared store; use it for a cluster behind a load balancer. See Store-attached targets.
  • Every scheduler the container knows, including a registration nothing has built yet (shown as such). The header's picker switches between them and every page follows.
  • Cluster-aware. With a persistent job store, the executing view, fire counts and node listing cover the whole cluster, and the pages say which scope they show.
  • A control panel, from 4.3: edit a trigger in place, backfill it, filter the listings and act on a selection.
  • Execution history, installed automatically and bounded by age and count. It is in-memory and per-process unless you give it a store; for a scheduler in another process it is read from that process.
  • A live event stream from Quartz itself: in process for a local scheduler, over the HTTP API's event route for a remote one. The pages do not connect back to the dashboard's hub; the hub is still served and fed for your own clients.
  • Three composable authorization levels: who reaches the dashboard, which schedulers they see, and whether anyone may change anything. See Production hardening.
  • A time zone picker and a theme toggle in the header. Absolute times render in the picked zone; ages such as a last check-in or last fire are also shown relative.

Installation

The package brings Quartz.AspNetCore with it:

dotnet add package Quartz.Dashboard

Basic setup

Configure Quartz, enable the HTTP API, and add the dashboard services.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.AddQuartz();

builder.Services.AddQuartzHttpApi(options =>
{
    options.ApiPath = "/quartz-api";
});

builder.Services.AddQuartzDashboard();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

Map endpoints:

WebApplication app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();

// the dashboard's stylesheet and scripts are static web assets, so something
// has to serve them
app.MapStaticAssets();

app.MapQuartzHttpApi().RequireAuthorization();
app.MapQuartzDashboard().RequireAuthorization();

The dashboard UI is at /quartz by default.

  • The HTTP API is optional: the pages read local schedulers directly.
  • The order of the four Add… calls does not matter; nothing is built until the service provider is.

Three pipeline lines are required by the host:

  • app.MapStaticAssets() (or the older app.UseStaticFiles()) serves the dashboard's stylesheet and scripts, static web assets under _content/Quartz.Dashboard/. Without it they answer 404 and the pages render unstyled and inert. A project with no .razor files also needs <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>, or /_framework/blazor.web.js is missing and nothing responds to a click.
  • app.UseAuthentication() needs a registered scheme, such as AddAuthentication(…).AddCookie() or an OpenID Connect handler. Quartz authorizes but does not authenticate. With no scheme registered, UseAuthentication() fails at startup, resolving IAuthenticationSchemeProvider.
  • .RequireAuthorization() satisfies the startup check below.

A mapping that says nothing about authorization does not start

The dashboard has no authentication of its own. Its pages start, stand by, shut down, pause, resume, delete and trigger, and ReadOnly is false by default. AddJob, as on the HTTP API, takes a job's type as a string from the request, resolved later with Type.GetType against the host's probing path. With Quartz.Jobs on that path the string can reach NativeJob, which starts a process named in job data: an unauthenticated dashboard is remote code execution. Its execution history also shows every job exception's message.

So app.MapQuartzDashboard() with no authorization statement fails at startup, before the server binds its listener. The message names the three statements:

  • app.MapQuartzDashboard().RequireAuthorization() authorizes its pages and live-events hub;
  • QuartzDashboardOptions.AuthorizationPolicy authorizes those plus the Blazor circuit and static assets; see Policy and role-based authorization;
  • app.MapQuartzDashboard().AllowAnonymous() serves it to anyone, deliberately.

A non-null AuthorizationOptions.FallbackPolicy also satisfies the check. An application that calls AddQuartzDashboard() and never maps it is not checked.

What MapQuartzDashboard returns

An IEndpointConventionBuilder covering the pages and the live-events hub, so RequireAuthorization() on it covers the whole dashboard. The RazorComponentsEndpointConventionBuilder of the pages alone would miss the hub, and in the integrated overload would also reach the host application's own pages.

Options

AddQuartzDashboard(options => …) takes eight settings. None points the dashboard at a scheduler: it renders the schedulers registered in its own application, through the container's IQuartzApiClient.

OptionDefaultWhat it does
DashboardPath/quartzBase path of the UI; see Hosting under a custom path
AuthorizationPolicynonePolicy for pages, hub, circuit and assets; see Policy and role-based authorization
SchedulerAuthorizationPolicynonePolicy each scheduler is held to; see One scheduler at a time
ReadOnlyfalseHides every mutating action (pause, resume, trigger-now, reschedule, unschedule, delete, …)
IsJobTypeAllowednonePredicate over a job type name; refused raises UnauthorizedAccessException; see Narrowing which job types may be named
HistoryRetention24 hoursHow long the history store keeps executions and misfires; see Execution history and misfires
HistoryMaxEntriesPerScheduler2000Executions and misfires kept per scheduler each, oldest dropped first
ClusterDetectionInterval1 minuteHow often targets fronting one cluster are merged into one row; null never merges. See A cluster behind several targets

Both history bounds and the detection interval must be positive or startup fails; a zero window would forget every execution immediately and look like a missing history plugin.

Pointing a dashboard at another process

Register that scheduler with AddQuartzHttpClient; the dashboard renders it beside the local ones. See Fronting a scheduler in another process over HTTP. AddQuartzDashboard registers its client with TryAdd, so you can register your own IQuartzApiClient to make the pages read something else.

Writing your own IQuartzApiClient

An implementation must follow the interface's two promises to render like the shipped one:

  • A missing thing is a KeyNotFoundException. Every member taking a schedulerName throws it for an unknown scheduler; GetScheduler, GetJobDetail, GetTrigger and GetCalendar also throw it when the item is gone. The dashboard's error boundary shows the not-found page. Returning null! faults the page instead.
  • An unreportable value is a value, not a refusal. GetExecutionLimits returns ExecutionLimitsDto.CannotReport when the source cannot say; the overview draws that differently from "no limits".

Members added during 4.x arrive as default interface members, so your implementation keeps compiling. The default body reports the datum as unavailable, like CannotReport; override it when your source can answer. GetJobRunStatus, GetJobRunStatuses and QueryExecutionStatistics (4.4) throw NotSupportedException by default, and the pages leave out what they would show.

The pages

All fourteen are under {DashboardPath} (/quartz by default) and render the scheduler selected in the header's picker, so switching schedulers keeps you on the same page.

PageRoute
Overview/quartz
Jobs, and one job/quartz/jobs, /quartz/jobs/{Group}/{Name}
Triggers, and one trigger/quartz/triggers, /quartz/triggers/{Group}/{Name}
Calendars, and one calendar/quartz/calendars, /quartz/calendars/{CalendarName}
Currently Executing/quartz/executing
Schedulers/quartz/schedulers
Cluster/quartz/cluster
Execution History, and one execution/quartz/history, /quartz/history/{EntryId}
Live Logs/quartz/live
Action Log/quartz/actions

Detail pages are covered with their listing below. What the numbers mean for a cluster in trouble is in Operating a Cluster.

Overview

/quartz shows the scheduler's status and, outside read-only mode, start, stand-by, pause all, resume all and shutdown. Beside the totals (jobs, triggers, firings in flight, triggers in error, nodes) are four breakdowns:

  • Trigger-state histogram: counts of Normal, Paused, Blocked, Error and Complete triggers. Each links to the Triggers page filtered to that state, as /quartz/triggers?state=Paused does for any state. The counts are counting queries, so cost does not grow with trigger count.
  • Paused groups: how many trigger groups and job groups are paused, in one tile. It counts empty paused groups too, which no listing shows and which are a common silent cause of Nothing is firing.
  • Misfires within the history retention window, which the label names (Misfires (last 24 h) at the default HistoryRetention). A source with no misfire feed shows a dash, not a zero. Links to /quartz/history#misfires; see Execution history and misfires.
  • Execution groups: one row per execution group with its limit, the limit's scope, firings in flight and remaining headroom — and, on a node that routes, whether the group is served here and how many of its triggers are overdue; see Execution groups.

The Nodes tile shows the node count and, when any exist, the number not Alive, turning red then. It links to the Cluster page. A non-clustered scheduler shows 1.

The page ends with recent Action Log entries.

Jobs, Triggers and Calendars

/quartz/jobs, /quartz/triggers and /quartz/calendars are searchable, paged listings; each row links to a detail page.

ListingDetail page showsActions outside read-only mode
Jobs: job details and keys; from 4.4, each job's last run, last success and failing ×Nthe JobDataMap, the triggers pointing at the job, and its run statustrigger-now with overrides, pause, resume, delete
Triggers: state, next and previous fire times, execution groupthe trigger's JobDataMap, priority, calendar, misfire instruction, preferred node, its retry policy, retries made for the current occurrence, its overlap policypause, resume, unschedule, reset error state, a cron reschedule editor and, from 4.3, Edit details and Backfill…; on the listing, bulk actions
Calendars: namesone calendarcreate, replace or delete a cron calendar
  • ?state= opens the trigger listing filtered, as the overview's histogram links do. Every other filter is a query parameter too; see Filtering the listings.
  • From 4.3, Pause, Pause group and Pause all ask for an optional reason, recorded with the signed-in user's name. A paused trigger shows Paused: reason (by who, when) on its page and its listing rows, and a paused job group shows its record on the Jobs page. See Pausing with a Reason.
  • Reset error state clears an ERROR trigger once its cause is fixed.
  • The cron reschedule editor previews the next five fires of the expression being edited, before it is saved, in the trigger's time zone and less what its calendar excludes.

Editing a trigger

From 4.3, Edit details on a trigger's page edits it in place through IScheduler.UpdateTriggerDetails. Fire times and state are kept; the cron editor is the one that reschedules.

FieldTakes
Description, execution grouptext; blank clears it
Prioritya whole number
Calendara calendar name, suggested from the scheduler's; blank clears it
Misfire instructionthe trigger family's instructions by name; a bare code for a trigger in none of the five
Retry policythe stored form, fixed;3;00:05:00, exp;5;00:00:10;2 or list;00:00:05;00:01:00; none never retries; blank inherits the job type's or the scheduler's
Preferred nodeany node, the first node to fire it, or a named node
Overlap policyone of the five; disabled, with the reason, for a trigger that does not derive from TriggerBase
JobDataMapadd, edit and remove entries; an entry nobody edited keeps its value and type
  • Only what changed is sent. A field left alone is not written back.
  • A refusal is shown beside the fields, and the editor stays open: a calendar the scheduler does not hold, an unreadable priority or retry policy.
  • Every save is in the Action Log as UpdateTriggerDetails, with the user and what changed (priority 5 → 7; calendar holidays → (none)). Map values are not logged.
  • An IQuartzApiClient of your own that does not implement UpdateTriggerDetails answers its default, NotSupportedException. The button is then disabled with that reason.

Backfilling a trigger

From 4.3, Backfill… on a trigger's page runs its schedule over a past range: one firing per slot. See Backfill.

FieldTakes
From, Toa date and time in the dashboard's time zone; From is included, To is not. The last day up to now by default
Spacinghh:mm:ss between the firings' starts; zero starts them together
Max slotsthe most the range may hold; 1000 by default
  • The dialog counts the slots as the range changes, the trigger's calendar applied.
  • A refusal is shown in the dialog, which stays open: a range ending after now, more slots than allowed.
  • Every backfill is in the Action Log as BackfillTrigger, with the user, the range and the counts (2026-09-01T00:00:00Z to 2026-09-02T00:00:00Z: 24 slots, 20 scheduled, 4 already scheduled).
  • Read-only mode hides the button. A window onto an attached store may backfill: the slots are rows every node sharing the store fires.
  • An IQuartzApiClient of your own that does not implement Backfill answers its default, NotSupportedException. The button is then disabled with that reason.

Filtering the listings

From 4.3, the Triggers page filters by everything TriggerQuery can: group and name (contains), job (group and name, exactly), calendar, state, and next fire before. Apply filters reads them; paging is unchanged.

  • Each filter is a query parameter, so a filtered listing is a link: /quartz/triggers?jobGroup=reports&jobName=export&state=Paused.

    ParameterFilter
    group, namecontains
    jobGroup, jobNameone job; both or neither
    calendarthe calendar's name
    statea TriggerState name
    beforean ISO 8601 instant with its offset; the field shows it in the header's time zone
  • The Jobs page filters by group and by name (contains), as ?group= and ?name=.

  • A target whose HTTP API is older than 4.2 ignores before. The dashboard spots rows the filter excludes, drops the filter and disables its field with the reason.

  • Filters on JobDataMap values are not offered: the map is a serialized blob, not a column a store can query.

Acting on a selection

From 4.3, each trigger row has a checkbox and each group's header one that selects its rows on the page. A selection offers Pause selected, Resume selected and Unschedule selected.

ActionMade throughOver HTTP
Pause recording nothing: no reason typed, nobody signed inIScheduler.PauseTriggers(keys): one callPOST …/triggers/keys/pause
Pause recording a reason or the signed-in userPauseTriggersWith(keys, details): one call, from 4.4POST …/triggers/keys/pause with reason and requestedBy
ResumeResumeTriggers(keys)POST …/triggers/keys/resume
Unschedule, after a confirmationUnscheduleJobs(keys)POST …/triggers/unschedule
  • The pause prompt asks once for the whole selection.
  • Rows the action did not reach stay listed with the reason until dismissed: gone, already paused, not paused, or what the scheduler said.
  • Each key gets its own Action Log entry, under the single-row action's name, marked as one of the selection.
  • Applying a filter or acting clears the selection. Read-only mode shows no checkboxes.

Continuations

A continuation is a trigger waiting for another trigger's firing; it deliberately does not fire on its own.

  • The trigger listing has an Awaiting only filter beside Error only and Executing only.
  • Waiting has its own colour in the state column, and each waiting row names the trigger it waits for, with the releasing outcomes on hover.
  • The detail page shows Continues after, linking to the parent, and When.
  • A removed parent is named without a link and marked no longer scheduled. The trigger is then in ERROR; reset error state runs it, giving it the fire time a release would have.
  • A released or reset trigger waits for nothing and shows neither field.

Currently Executing

/quartz/executing has one row per firing: job, trigger, node, execution group, fire time, run time and progress. It is the fire-instance listing, so with a persistent job store it covers the whole cluster; Node is the machine that owns the firing.

  • Progress is a bar, the percentage and the job's message, from ReportProgress. A firing that has not reported shows a dash. It is read from the store, so a store-attached window shows it too, and it can trail the job by up to a second.

  • Interrupting interrupts that one firing, not every firing of the job. This matters for a job without [DisallowConcurrentExecution], which can have several in flight.

  • Each interrupt is recorded in the Action Log with the fire instance and node, whether or not the firing was still running.

  • Past runs opens the trigger's execution history. A firing gets its own execution page once it finishes: the history row is written then.

  • A row that never goes away: a firing whose node died stays until another node's check-in sweep takes it over. See Fired triggers: backlog or leak.

Schedulers

/quartz/schedulers is the fleet: one row per scheduler the container knows, built or not, and one per scheduler reached through a target. It reads ISchedulerRegistry, so a scheduler registered with AddQuartz("acme", …) and not yet resolved is listed with not created status. Listing does not build it.

ColumnWhat it shows
TargetWhere the scheduler was found: an HTTP target, an agent, an attached store, or a cluster's a+b+c. A dash for this process
Reachedthis process, HTTP, agent, store or cluster
SchedulerThe name, linked when built, with the instance id under it
StatusThe status; last seen for an agent
NodesA cluster's member count; else the cluster-node query, asked only of a persistent clustered store
Job store, Threads, Running since, Jobs executed, VersionFrom SchedulerMetadata; a cluster sums jobs executed over its members

Rows are sorted by target, this process first, then by name.

  • Following a row selects that scheduler and opens its Overview, like the header's picker.
  • A registration nothing has built is not a link. The picker shows it greyed out, so a tenant that failed to start is visible.
  • If such a registration becomes the active scheduler (it is the only one, or the running one was shut down), the Overview says it has not been created.
  • The header's picker groups the same rows by target: This process first, then one group per target. The selection is remembered in the qz_scheduler cookie; a remembered key the listing no longer carries falls back to the first scheduler that exists.

Cluster

/quartz/cluster has one row per node of the selected scheduler's cluster, refreshed every five seconds, with the last refresh time in the header so a stalled page is visible.

  • Columns: instance id, state, last check-in, check-in interval, firings held Acquired and Executing, and Serves: all, only reports, (no execution group) or all but gpu, from the node's routing. Nodes publish nothing to the store, so for a single target only the answering node has an entry and the rest show a dash; for a cluster behind several targets, each member's.
  • The answering node is marked (this node).
  • The state (Alive, Overdue or Failed) is IScheduler.QueryClusterNodes()'s verdict, from the same predicate the store's recovery sweep uses. Meanings, and why a Failed node is listed briefly and then vanishes: Check-in, node states and failover and Reading the cluster.
  • A non-clustered store says so instead of showing an empty table: the only node is this one.
  • Check-in intervals are each node's own configuration. Verdicts use the answering node's clock, so with skewed clocks two nodes can disagree about a third.
  • For a cluster behind several targets, two more columns: the target that fronts each node, and Start, Standby, Shutdown and Interrupt buttons sent through that target's own key. Interrupt stops what the node is running. A node no target fronts shows a dash. ReadOnly hides the buttons.

Execution History

/quartz/history: one row per execution with the node that ran it, a node filter, a window, four stat cards whose titles name their scope, a chart of runs over time, and a misfires section. Details under Execution history and misfires.

Each row's fire time links to /quartz/history/{EntryId}, the execution's own page: job, trigger, node, fire time, duration, result, summary, whether it was run by hand, attempt, what it threw, the fire instance id, a table of its metrics, and the lines it logged when the scheduler captures them. It only reads, so read-only mode leaves it unchanged.

Live Logs

/quartz/live shows the scheduler's events as they happen, from Quartz's event stream. AddQuartzDashboard() calls AddQuartzSchedulerEvents(), which installs one publisher into every scheduler in the container; the page subscribes for the scheduler on screen.

  • Every event names the node that raised it, and the page says which node its own process is.
  • A remote scheduler has a feed too, read through the API's event route. A target whose API predates the route says so instead of looking idle.
  • Each row shows the job, trigger, firing, run time and outcome.
  • Fourteen kinds arrive; thirteen are shown (the heartbeat is consumed). Filter by type from the header. The interrupted-firing and trigger-in-error kinds are new in 4.1.
  • It is a live view, not a log: it starts when the page opens, keeps the newest hundred events, and does not survive a reload; see Current limitations.
  • The page opens no connection of its own, so it works behind a proxy that forwards only the dashboard's path. The SignalR hub is still served and fed for your own clients; a proxy must forward {DashboardPath}/hub only for those.

Action Log

/quartz/actions lists what was done from this dashboard, newest first: time, scheduler, user (from 4.3), action, target, where it landed, success, and any message. It answers "who paused this" when "who" is the dashboard. From 4.4, a pause given a reason carries it in the message as reason: ….

  • The store is in-memory and process-wide, holding the last 250 actions across schedulers. The page shows the most recent 100 of those that target the selected scheduler.
  • It records only this process's dashboard. An HTTP API action is logged where the API is mapped, as event 9007. Other nodes' and other dashboards' actions are not here, and nothing survives a restart.

Where each action landed:

  • The scheduler's origin is a tag on the row. Remote adds in another process: that scheduler performed the action.
  • A node-local action names the node it reached: interrupting a firing, start, stand-by, shutdown.
  • A cluster-wide action (pausing a trigger, deleting a job) names no node: it wrote the store, which binds every node.
  • A scheduler this browser session never listed shows no origin.

Every entry is also logged at Information through the application's ILogger: event 9100 on success, 9101 on failure, each naming the visitor, action, target and scheduler, ending with (origin …, node …). This copy survives a restart. Connections opening and closing are logged at Debug (9102, 9103). See Log Events.

The visitor is ClaimsPrincipal.Identity.Name, or (anonymous) when nothing authenticated, which is every entry for a dashboard mapped with AllowAnonymous().

Execution groups

The Overview's panel joins the scheduler's limits (IScheduler.GetExecutionLimits) to its firings in flight, so configured ceilings are visible in the UI.

  • Both firing states count. A reservation holds a slot like a running execution, as the acquisition filter counts it.
  • Counts are cluster-wide with a persistent store, because the firing listing is; with an in-memory store they are this node's. The panel names the scope, and flags a node-scoped limit compared against a cluster-wide count (each node enforces its own copy of such a limit).
  • other groups is a rule, not a bucket. It gives each unlimited group its own allowance, so nothing is in flight against it. A group it governs says so. It never covers the ungrouped bucket, matching the scheduler.
  • A derived group is labelled. With UseTriggerGroupWhenUnset, a trigger with no execution group is limited as if in a group named after its trigger group; the panel resolves and marks it the same way.
  • A scheduler that cannot report limits says so, for an IQuartzApiClient or IScheduler of your own that does not implement them.
  • A node that routes gets two more columns. When the scheduler reports ServedExecutionGroups or RefusedExecutionGroups, the panel says which lists it is, lists every routed group whether or not anything is in flight, and adds Served here (✓ or —) and Overdue: Normal triggers of the group due more than five minutes ago, from one more trigger query per refresh. Five minutes is a fixed figure, because the dashboard does not know the store's misfire threshold; the health check measures in thresholds. A group with overdue triggers that this node does not serve is marked not served here: check that a node serving it is running. A node that routes nothing makes no extra query and shows no extra column.

The schedulers the dashboard covers

AddQuartzDashboard() calls AddQuartzExecutionHistory() and AddQuartzSchedulerEvents(), which install Quartz's execution recorder and event publisher into every scheduler in the container, through ConfigureAllQuartzSchedulers, in any call order.

  • A scheduler registered with AddQuartz("acme", …) gets Live Logs and History like the default one.
  • Each scheduler gets its own instance of each, initialized with its name; both feeds are attributed to the producing scheduler.
  • DashboardHistoryPlugin and DashboardLiveEventsPlugin are still public and work when named in a quartz.plugin.*.type key, but are not registered here. Registering one beside the pair above records every execution, or pushes every event, twice.
  • The covered schedulers are those on the Schedulers page: every registration, built or not.

Fronting a scheduler in another process over HTTP

Register a scheduler from another process with AddQuartzHttpClient (see HTTP Client) and it appears beside the local ones:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// The credential and the timeout are the HttpClient's: the dashboard adds nothing of its own.
// A short timeout matters — every page reading this scheduler waits on it.
builder.Services.AddHttpClient("quartz", client =>
{
    client.BaseAddress = new Uri("https://scheduler.internal/quartz-api/");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.Add("X-Api-Key", "…");
});

// The scheduler's name has to be the one the target goes by: it is in every route.
builder.Services.AddQuartzHttpClient("QuartzScheduler", "quartz");

builder.Services.AddQuartzDashboard();

The target must map the HTTP API (app.MapQuartzHttpApi()), and the name given here must be its scheduler's name, which is in every route.

PageOver HTTP
Overview, Jobs, Triggers, Calendars, Currently Executing, ClusterRead through the API's routes
Every action (pause, resume, trigger now, reschedule, edit, bulk, delete)Performed by the target's scheduler, in its process
Execution HistoryThe target's own history
Live LogsThe target's own events; a target too old for the route says so
Schedulers, and the header's pickerListed as Remote

Several processes whose schedulers share a name

Give each registration a Target. Every worker's default scheduler is QuartzScheduler, so two workers are two targets, not two names:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("worker-1", client => client.BaseAddress = new Uri("https://worker-1.internal/quartz-api/"));
builder.Services.AddHttpClient("worker-2", client => client.BaseAddress = new Uri("https://worker-2.internal/quartz-api/"));

// Both workers run the default scheduler, QuartzScheduler. The target tells them apart: the
// dashboard lists w1/QuartzScheduler and w2/QuartzScheduler, and resolves each by that key.
builder.Services.AddQuartzHttpClient(options =>
{
    options.SchedulerName = "QuartzScheduler";
    options.HttpClientName = "worker-1";
    options.Target = "w1";
});

builder.Services.AddQuartzHttpClient(options =>
{
    options.SchedulerName = "QuartzScheduler";
    options.HttpClientName = "worker-2";
    options.Target = "w2";
});

builder.Services.AddQuartzDashboard();
With TargetEffect
Keyw1/QuartzScheduler: the picker's value, every IQuartzApiClient call, the hub group
ListingOne row per target; the picker groups them under the target
Service keyThe target: GetRequiredKeyedService<IScheduler>("w1"), and the keyed history store and event source
Bare nameResolves to nothing. A bare key is a scheduler reached through no target
Duplicate targetSchedulerConfigException at the second AddQuartzHttpClient, or at host start against an attached store of that name
ClusterTargets on one clustered persistent store merge into one row; see A cluster behind several targets

A target name cannot contain / or +. Registrations without a Target keep their bare name and their per-name refusal of a duplicate.

Before pointing one at production:

  • One target is one process. Behind a load balancer, node-local operations land on whichever node the balancer picks, and two page renders may reach two nodes.
    • Node-local: interrupting a firing (must reach the running node), start, stand-by and shutdown (act on the answering node), node-scoped execution limits, the node's figures in the scheduler details (instance id, running since, jobs executed, jobs executing here), and live events (the answering node's; each names its node).
    • Not node-local: with a persistent store, jobs, triggers and firings in flight are the whole cluster's whichever node answers.
    • Point one target at each node's own address, and the dashboard shows the nodes as one cluster.
  • The credential is the HttpClient's. QuartzDashboardOptions.AuthorizationPolicy decides who opens the dashboard; the target sees only what the named client was configured with (a header, a handler, a certificate). Nothing is forwarded from the signed-in user.
  • Give the client a short timeout. Pages reading that scheduler wait for it, and HttpClient's default is 100 seconds. The scheduler listing has its own deadline and shows a non-answering scheduler as Unknown, but job and trigger pages wait for the client.
  • The dashboard's ReadOnly and the API's are separate. QuartzDashboardOptions.ReadOnly only hides this dashboard's controls. QuartzHttpApiOptions.ReadOnly on the target refuses every mutating route, including from a dashboard still showing buttons, which then reports the refusal.

A target whose HTTP API is older than 4.1 has no history routes. The History page says "this scheduler runs in another process and its Quartz HTTP API does not serve execution history", and the Overview's misfire tile shows a dash, not zero. Such a target has no update-details route either: Save details shows its refusal beside the fields. One older than 4.2 ignores the next fire before filter, which is then disabled with the reason. One older than 4.4 keeps no run status, so the Jobs columns and the Job Detail panel are left out, and the History page's results and one-job filters say the host cannot answer them.

Fronting a scheduler on another machine

A worker the dashboard cannot dial — behind NAT or a firewall, on the far side of a trust boundary, with no port to open — dials the dashboard instead, with Quartz.Dashboard.Agent. The dashboard accepts agents with AcceptAgents, which maps the agent hub at {DashboardPath}/agents:

builder.Services.AddQuartzDashboard(options => options.AcceptAgents(agents =>
{
    // The token every agent presents, as an Authorization: Bearer header. Keep it in a
    // secret store: whoever holds it can register a scheduler on this dashboard.
    agents.Tokens.Primary = builder.Configuration["Dashboard:AgentToken"];
}));

The worker's side is one call, UseDashboardAgent. Each agent registers a target and its scheduler, and the dashboard lists it as worker-1/QuartzScheduler, reached through an agent.

OptionDefaultWhat it is
Tokens.Primary, Tokens.Secondary—The bearer tokens the hub accepts; two slots so a rotation never locks an agent out
AuthorizationPolicy—A policy the hub is held to, for agents presenting the host's own credential. With tokens set as well, both apply
HeartbeatInterval15 sHow often each agent reports; the agents adopt it at registration
OfflineAfterMissedHeartbeats3Missed heartbeats before the scheduler is listed as Unknown
OperationTimeout30 sHow long one request to an agent may take before it is cancelled on both sides
MaxMessageBytes4 MiBThe most one answer may carry; the agent refuses a larger page with 413, telling the page to lower take
ForgetAfter1 hHow long a disconnected agent stays listed before the row is dropped
IsJobTypeAllowed—A job-type predicate applied here before a request is sent on, so an operator can stop a type at the dashboard without touching every worker. It binds nothing on the worker

A hub with neither tokens nor a policy refuses to start, as the pages do. MapQuartzDashboard().RequireAuthorization() does not cover the hub: humans and machines authenticate differently, so what authorizes the hub is stated in AcceptAgents and nowhere else. The token travels as an Authorization: Bearer header; a token in the URL is not read.

To rotate the token, accept both for the time it takes to roll the agents over:

builder.Services.AddQuartzDashboard(options => options.AcceptAgents(agents =>
{
    // Both are accepted while the rotation runs: set Secondary to the new token, roll the
    // agents over to it, move it to Primary, and clear Secondary.
    agents.Tokens.Primary = builder.Configuration["Dashboard:AgentToken"];
    agents.Tokens.Secondary = builder.Configuration["Dashboard:NextAgentToken"];
}));

To hold the hub to the host's authentication instead:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("agents", policy => policy.RequireAuthenticatedUser().RequireRole("quartz-agent"));

builder.Services.AddQuartzDashboard(options => options.AcceptAgents(agents =>
{
    // No shared secret: the hub is held to the host's own authentication, and an agent
    // presents what its AccessTokenProvider hands it. With Tokens set as well, both apply.
    agents.AuthorizationPolicy = "agents";

    // A liveness judgement of 20 s × 3, instead of 15 s × 3; the agents adopt the interval.
    agents.HeartbeatInterval = TimeSpan.FromSeconds(20);
    agents.OfflineAfterMissedHeartbeats = 3;
}));
PageOver an agent
Overview, Jobs, Triggers, Calendars, Currently Executing, ClusterRead through the connection, as the API's routes
Every action (pause, resume, trigger now, reschedule, edit, bulk, delete)Performed by the worker's scheduler, in its process, unless the agent refuses it
Execution HistoryThe worker's own history, recorded in memory by UseDashboardAgent
Live LogsThe worker's own events, streamed while the page watches
Schedulers, and the header's pickerListed as Agent, with last seen beside the status

How the row behaves over time:

The agentThe row
RegistersRunning (or whatever it reports); last seen is now
Misses three heartbeats (45 s)Unknown with last seen, without waiting on the connection; logged once (9116)
Heartbeats againAsked again, and back to its status
DisconnectsUnknown until it reconnects (9114)
Shuts downShutdown, after its goodbye
Stays away for ForgetAfterDropped from the listing (9115)
Reconnects under the same target and instance idTakes its own row over
A second process registers a target a live agent holdsRefused (9113) and retried every 30 s; give each process a target of its own
  • The agent decides what it accepts. ReadOnly here hides the buttons; the worker's DashboardAgentOptions refuse. An agent refuses to store a job by type name until its IsJobTypeAllowed is set, so a Jobs page that adds one shows that refusal naming the option.
  • The cluster rule applies. Agents whose schedulers share one clustered store are merged into one cluster row, and the Cluster page reaches each node through its agent.
  • One dashboard instance. The hub keeps its registrations in the process that accepted them. A dashboard scaled out behind a load balancer shows each agent on the instance it dialled.
  • AddQuartzDashboard() needs no scheduler of its own to accept agents: it registers the shared services a scheduler repository needs.

Store-attached targets

Point the dashboard at the database, and every scheduler in it gets pages. Nothing is asked of the processes doing the work (no port, plugin or change), and one attachment covers every scheduler and node in that database.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddQuartz();

builder.Services.AddQuartzDashboard(options => options.AttachStore("prod", store =>
{
    // The cluster's own store configuration, not an approximation of it: the window reads
    // the blobs the nodes wrote, so the dialect, the table prefix, the serializer and any
    // custom trigger serializers all have to be the ones they were written with.
    store.UseSqlServer(connectionString);

    // The nodes keep their history in the database, so the window can read it. Leave this
    // out and the window's History page has nothing to show: an in-memory history is the
    // process's own, and no node's process is this one.
    store.UseExecutionHistory();
}));

Use this for a cluster behind a load balancer. An HTTP target reaches an arbitrary node per render; a store-attached target talks to no node.

What a window is

A window is a scheduler this process builds over the same store, with ZeroSizeThreadPool, and never starts (its store refuses to). It is the documented admin-process pattern (a process that writes a schedule for others to run) plus discovery and presentation.

Discovery queries distinct SCHED_NAME values in QRTZ_SCHEDULER_STATE, QRTZ_TRIGGERS and QRTZ_JOB_DETAILS. A name in any of them is a scheduler: a non-clustered scheduler writes no check-in row, and one with only durable, unscheduled jobs has no triggers. Discovery runs at start-up and then every AttachStoreOptions.RediscoveryInterval (default one minute):

services.AddQuartzDashboard(options => options.AttachStore(
    "prod",
    store => store.UsePostgres(connectionString),

    // Every five minutes rather than every minute, for a database whose set of schedulers
    // changes rarely. null asks once, at start-up, and never again.
    attach => attach.RediscoveryInterval = TimeSpan.FromMinutes(5)));

Rediscovery only adds. A scheduler whose rows were deleted keeps its window and shows an empty schedule, rather than a page vanishing under an operator.

Identity is target/name

Every scheduler the dashboard shows has a key, which SchedulerRef parses: target/name for a scheduler reached through a target, the bare name for one reached through none.

SchedulerKeyTarget
Of this processreportingnone
AddQuartzHttpClient without TargetQuartzSchedulernone
AddQuartzHttpClient with Target = "w1"w1/QuartzSchedulerw1
Window onto the store attached as prodprod/reportingprod
Cluster of targets a, b, ca+b+c/QuartzSchedulera+b+c
  • The key is what the picker holds, what every IQuartzApiClient member takes, and the hub's group name.
  • A bare key resolves only to a scheduler reached through no target. A bare name that only targets hold is KeyNotFoundException, naming the keys that would resolve. A window answers to its bare name too, so everything written against 4.4 keeps working.
  • A target name cannot contain / or +, and one name holds one target of any kind: a store and an HTTP target named prod fail host start naming both.
  • A scheduler of this process may contain / in its name; its exact name wins over a key that reads like it.
  • A window's name colliding with a scheduler this process already has is refused, naming both, logged as 4027; the rest of the database is shown. That refusal is permanent.
  • A window that fails to build for any other reason is logged as 4029 and retried next round, without stopping the others.

Status comes from the cluster, never from the window

A window is never started, so its own SchedulerStatus is always Created. The Schedulers page, the picker and the Overview derive status from QRTZ_SCHEDULER_STATE instead:

Check-in rows under that SCHED_NAMEReported
At least one node within its check-in interval plus the thresholdRunning, with the node count
Rows, but every one convictedShutdown: every node that checked in has stopped
No rowsUnknown

A non-clustered scheduler writes no check-in row, so a window cannot tell it from one never started, and reports Unknown rather than "stopped". For liveness of a non-clustered scheduler, reach it over HTTP.

The window writes no check-in row and is never listed as a node; the Cluster page says whose rows it shows.

What a window can and cannot do

The store is the contract.

AvailableNot available
Jobs, triggers, calendars, groups, paused groups: read, added, edited and deletedStart, Standby, Shutdown
Pause, resume, reschedule, edit in place, backfill, unschedule, trigger now, bulk actionsInterrupting a running job or a firing
Currently Executing, from QRTZ_FIRED_TRIGGERSLive Logs and the live event stream
Cluster, from the nodes' check-insThe node's own figures: instance id, running since, jobs executed
Execution History and the misfire tile, when the cluster keeps history in the database
  • The left column writes the shared tables, and whichever node picks up the work honours it, as in any cluster.
  • The right column belongs to one process; a database cannot instruct a node. The pages do not offer these, the client refuses them, and the Overview says so.
  • The HTTP API of a process holding a window refuses start, standby and shutdown for it with a 400. The window's store refuses a start from anywhere: starting would run the cluster's start-up here, whose recovery deletes the fired-trigger rows of firings the nodes are running.
  • To reach one node for those, use an HTTP target or an agent.

Three things to get right

  • Use the cluster's store recipe: the same dialect, table prefix, serializer and UseTriggerPersistenceDelegate registrations. A mismatched serializer fails on the first trigger it cannot rebuild.
  • UseExecutionHistory() on both sides. The nodes must keep history in the database (UsePersistentStore(store => store.UseExecutionHistory())), and the attached store must say so too. Otherwise the History page says there is no history here. The window never writes or sweeps history; retention stays with the nodes.
  • The dashboard holds the database credentials, as powerful as any node's. Whoever can open the dashboard can read and write the cluster's schedule. Use ReadOnly and the authorization policies, and do not attach a store across a trust boundary; that is what an agent is for.

A window is not reported to the health check: AddHealthChecks().AddQuartz("reporting") in a dashboard process reports it unhealthy, with a message to check the nodes instead.

A cluster behind several targets

Targets that front the nodes of one cluster are shown as one scheduler: a+b+c/QuartzScheduler, origin Cluster, members a, b, c. Nothing is configured; the dashboard detects it.

services.AddQuartzDashboard(options =>
{
    // Targets on one clustered persistent store are merged into one row, w1+w2/QuartzScheduler,
    // at host start and then this often; null never merges them.
    options.ClusterDetectionInterval = TimeSpan.FromSeconds(30);
});
RuleDetail
Who is askedEvery HTTP target or agent whose scheduler name another target also fronts
GateJobStoreClustered and JobStorePersistent. A non-clustered scheduler reports NON_CLUSTERED and is never merged
MergeTargets whose QueryClusterNodes answers share a node, transitively. Two or more form a cluster
KeyMember targets sorted, joined with +. A membership change renames the row
MembershipSticky. A member that stops answering stays as an unreachable node; it leaves when it answers with nodes the cluster does not have, or its target is removed
Never answeredNever merged
WhenAt host start, every ClusterDetectionInterval, and whenever a target is added or removed
Log9105 formed, 9106 membership changed, 9107 dissolved (Information); 9108 round failed (Warning); 9109 member unreachable, 9110 target did not answer (Debug)

What goes where:

OperationRouted to
Jobs, triggers, calendars, pause, resume, trigger now, reschedule, bulk, delete, cluster nodesThe preferred member: the first, by target, that answered the last round
Interrupt (job)Every member; true if any interrupted
Interrupt a firingThe member whose node owns the firing, per the store; every member when none does
Execution limits, setEvery member, in target order; the first failure names the members already set
Execution limits, readThe preferred member's
StatusRunning if any member is, else Standby if any, else what the rest say; Unknown when none answered
Scheduler detailsThe preferred member's, with jobs executed and executing summed over the members and the earliest start
Execution HistoryThe preferred member's. With UseExecutionHistory() on the nodes that is the shared table
Live LogsEvery member's events, interleaved; each names its node
Start, Standby, ShutdownRefused with NotSupportedException. Use the Cluster page's per-node buttons, which go through the member's key
  • Member rows are hidden from the Schedulers page and the picker; they stay resolvable by key, a/QuartzScheduler.
  • Failover is per round: a dead preferred member answers with its error until the next round re-elects.
  • SchedulerAuthorizationPolicy sees the cluster as SchedulerResource("QuartzScheduler") { Target = "a+b+c" }, and a member as Target = "a".

Hosting under a custom path

When the dashboard hosts its own Blazor root, set a custom base path at the map site:

app.MapQuartzDashboard("/my-api/quartz").RequireAuthorization();

or at registration, when the path comes from configuration:

services.AddQuartzDashboard(options =>
{
    options.DashboardPath = "/my-api/quartz";
});
  • If both are given, the pattern passed to MapQuartzDashboard wins. The parameterless overload uses DashboardPath.
  • A map-site pattern follows the option's rule: a plain URL path starting with /, with no {, }, ?, #, . or .. segments and no empty ones.

With a custom path, the whole dashboard is under it: pages, navigation links, the SignalR hub, the interactive circuit ({DashboardPath}/_blazor), the framework script ({DashboardPath}/_framework/blazor.web.js) and the static assets ({DashboardPath}/_content/Quartz.Dashboard/*). The shell emits a <base href> rooted at the dashboard.

  • Behind a proxy that forwards a path prefix without setting a path base, set DashboardPath to the external path (e.g. /my-api/quartz when the proxy forwards /my-api/* verbatim), and forward WebSocket connections for {DashboardPath}/_blazor, which every page needs.
  • {DashboardPath}/hub is the live-events hub. From 4.1 the dashboard's own pages do not use it, so the application need not reach its own public address for Live Logs. Forward it only for your own clients.
  • With UsePathBase() (or a proxy that sets the path base), DashboardPath is relative to the path base, and the default /quartz works under the prefix. With minimal hosting (WebApplication), call app.UseRouting() explicitly after app.UsePathBase(...), or routing matches the unstripped path and every dashboard route returns 404:
app.UsePathBase("/my-api");
app.UseRouting();
  • Behind a proxy that strips the prefix (the browser asks for /my-api/quartz, the application receives /quartz), UsePathBase has nothing to strip. Keep the default DashboardPath and set the path base before static files and routing:

    The proxySet the path base with
    Sends the stripped prefix as X-Forwarded-Prefixthe forwarded-headers middleware
    Sends nothinga middleware that writes the known prefix
// The proxy strips /my-api and names it in X-Forwarded-Prefix, which becomes the path base.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedPrefix
});
app.UseRouting();
// The proxy strips /my-api and says nothing, so the application puts it back.
app.Use((context, next) =>
{
    context.Request.PathBase = "/my-api";
    return next(context);
});
app.UseRouting();
  • The forwarded-headers middleware ignores the header from a proxy it does not know. Only loopback is known by default; add your proxy to KnownProxies or KnownIPNetworks.
  • Forward WebSocket connections for the circuit as well, here /my-api/_blazor.

Upgrading existing custom-path deployments

The Blazor circuit used to stay at the site root. With a custom DashboardPath it now connects at {DashboardPath}/_blazor; update reverse-proxy rules scoped to /_blazor, such as a WebSocket-upgrade location.

Warning

A custom path is not supported with MapQuartzDashboard(blazor) (integrating into an existing Blazor application). Page routes are fixed at /quartz there, and a custom path fails startup with a descriptive exception. There is no MapQuartzDashboard(blazor, pattern) overload.

Execution history and misfires

AddQuartzDashboard() calls AddQuartzExecutionHistory(), so the History page at /quartz/history fills without further setup. Each row: job, trigger, node, fire time, duration, result, the job's summary and metrics, and the error if any.

  • Result: Succeeded, Skipped, Failed, Failed (retrying) or Cancelled; a Manual badge on a run asked for with Trigger now. A row written before 4.4 shows Succeeded or Failed. See Job Outcomes.

  • Stat cards over every run the filters and the window match: runs, success rate, failed, longest run. A source that cannot count runs over time shows the page's success rate, failures, average and P95 duration, titled (page).

  • The chart, from 4.4: runs per bucket stacked by result, and beneath it the median and 95th percentile duration. It follows every filter and the window. Hover a column for its figures; Show as a table lists them. See Run statistics.

  • Filters: window, job, trigger, node, outcome and, from 4.4, results and one job exactly.

  • The node filter narrows to one machine, and the stat card titles then say so, so one node's success rate is not read as the fleet's.

  • The outcome filter handles retries. A job with a retry policy of three writes four failed rows for one bad night. A failure followed by another attempt shows Failed (retrying); Failed after retries shows only occurrences that gave up.

  • The results filter lists the ticked results; none ticked lists every result. A scheduler in another process whose host is older than 4.4 cannot filter by result or by one job, and the page says so.

  • Run again, on those failed rows, fires the job through IScheduler.TriggerJob. It is logged in the action log and hidden in read-only mode. A row that will be retried has no button. The toast says which input it used:

    The rowRun again passes
    Has an input, from 4.4 with RecordInputThe input, under SchedulerConstants.JobInput: with the original input, logged as TriggerJobWithData
    Has none, or one too large to keepNo map: the job's own data, without input

    The execution page shows the input and has the same button, labelled with the input it will pass.

    The job must still be stored. A job without StoreDurably() is deleted with its last trigger, so a spent one-off cannot run again. The dashboard says The job {group.name} is no longer stored. A job that isn't durable is deleted with its last trigger; store it with StoreDurably() to run it again.

    PageWhere
    HistoryThe toast and the page's error, once Run again fails
    ExecutionThe tooltip of Run again, which is disabled

    The action log records the scheduler's own refusal. ScheduleJob<TJob, TInput> already stores its job durably.

Every filter is a query parameter, so a narrowed view is a link:

FilterParameter
Job, a fragmentjob
Trigger, a fragmenttrigger
Nodenode
Failed after retriesoutcome=failed
Resultsresult=failed,cancelled
One job exactlyjobGroup and jobName, which the Job Detail page's View execution history link writes
One trigger exactlytriggerGroup and triggerName, which the Trigger Detail page's View execution history and Currently Executing's Past runs write. From 4.6
Windowwindow=1h, 24h, 7d or 30d; absent is all retained

Below, misfires lists firings that did not happen, which never appear as executions: trigger, its job, the node that noticed, the missed firing, when it was noticed, and the reason — Misfire, Overlap for one a trigger's overlap policy skipped, or Vetoed for one a trigger listener refused. The overview's tile counts misfires only. With one job or one trigger selected, the section lists that one's.

Job run status

From 4.4, the history keeps a status per job: its last run, last success, last failure and how many occurrences in a row failed for good. See Read a job's status.

PageShows
JobsLast run, Last success and a failing ×N badge linking to that job's final failures; one read per page
Job DetailA Runs panel: the last run linking to its execution, its summary, the last success and failure, the counts

A source that keeps no status (an IDashboardHistoryStore of your own, a scheduler in another process whose host is older than 4.4) leaves the columns and the panel out.

Run statistics

From 4.4, the History page charts what its filters list, and the Job Detail page charts that job. Both read IExecutionHistoryStore.QueryExecutionStatistics; see Count runs over time.

WindowBuckets
All retainedA day each; an hour each while every run is from the last three days
Last hour5 minutes
Last 24 hoursAn hour
Last 7 days6 hours
Last 30 daysA day
  • A window starts on a bucket boundary, so its first bucket is whole. Buckets are UTC; labels are in the selected time zone.
  • Counts and durations are two panels over one time axis, never one chart with two scales.
  • The History page reads the statistics when a filter changes and every 30 seconds; the rows refresh every second.
  • An empty stretch is a gap. A store that stopped counting at its row limit says so under the chart.
  • A source that cannot count (an IQuartzApiClient of your own, a scheduler in another process whose host is older than 4.4) leaves the chart out.
  • With the database history, the chart needs PostgreSQL, Oracle, SQL Server 2012+, MySQL 8.0+, SQLite 3.25+ or Firebird 3+. On an older server the read fails and the chart is left out; the stat cards count the page.

The history belongs to Quartz: AddQuartzExecutionHistory()'s recorder writes it, the container's IExecutionHistoryStore holds it, and the HTTP API serves the same rows. The shipped store is per-process and in-memory, bounded by age (HistoryRetention, 24 hours) and count (HistoryMaxEntriesPerScheduler, 2000 per feed per scheduler). With the dashboard registered, the bounds come from those two settings:

services.AddQuartzDashboard(options =>
{
    options.HistoryRetention = TimeSpan.FromHours(6);
    options.HistoryMaxEntriesPerScheduler = 500;
});

The age bound stops a quiet scheduler showing arbitrarily old executions. It is measured on the scheduler's TimeProvider and applies on read as well as write, so a scheduler that stops writing still forgets.

To keep history across restarts, register your own store before AddQuartzDashboard (the shipped registration is a TryAdd). Either seam works:

SeamBehaviour
Quartz.Extensibility.IExecutionHistoryStorePreferred. Written by the recorder, read by the pages and served by the HTTP API
IDashboardHistoryStoreThe 4.0 seam, unchanged; adapted onto the other in both directions. Same five members, other names
  • Both feeds carry SchedulerInstanceId, so one store shared by a cluster stays readable.
  • Both have CountMisfires(name, since), which a database-backed store can answer with one COUNT(*).
  • AddQuartzDashboard no longer registers DashboardHistoryPlugin; the recorder does its work. The type still works, but registering it beside the recorder records every execution twice.

Enabling the history plugins

To also write history to the application's log, add the Quartz history plugins:

builder.AddQuartz(q =>
{
    q.UseJobHistoryLogging();
    q.UseTriggerHistoryLogging();
});

Production hardening

Policy and role-based authorization

Use an explicit policy for dashboard access, and secure API endpoints separately:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("QuartzDashboardOps", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireRole("Operations", "SchedulerAdmin");
    });
});

builder.Services.AddQuartzDashboard(options =>
{
    options.AuthorizationPolicy = "QuartzDashboardOps";
});
app.MapQuartzHttpApi().RequireAuthorization("QuartzDashboardOps");
app.MapQuartzDashboard();

With AuthorizationPolicy set, the policy covers the pages, the SignalR hub, the Blazor circuit (/_blazor) and the static asset endpoint, including under a fail-closed FallbackPolicy.

Without a policy, the dashboard adds no authorization. Startup refuses the mapping unless there is a RequireAuthorization() or AllowAnonymous() on what MapQuartzDashboard returns, or a fail-closed FallbackPolicy:

  • The static asset endpoint (_content/Quartz.Dashboard/*) and the Blazor circuit (/_blazor) explicitly allow anonymous access, so they keep working under a fail-closed FallbackPolicy. They are public package content, not what the startup check is about.
  • The pages and the SignalR hub have no authorization metadata of their own; the host's policies govern them. Under a fail-closed FallbackPolicy, an unauthenticated request to /quartz redirects to login and authenticated users get the dashboard. To expose it to unauthenticated users, do not apply a fail-closed FallbackPolicy to the dashboard paths, or set an AuthorizationPolicy they satisfy; and write AllowAnonymous() at the map site to record the intent.

Fail-closed FallbackPolicy with MapStaticAssets()

The host's app.MapStaticAssets() (the .NET 9/10 default) and the framework script _framework/blazor.web.js are host- and framework-owned endpoints Quartz cannot annotate. A fail-closed FallbackPolicy blocks them for unauthenticated users whatever the dashboard configuration. To serve them before login (a styled login page, say), use app.MapStaticAssets().AllowAnonymous();; static web assets are public. The classic app.UseStaticFiles() middleware runs before authorization and ignores the FallbackPolicy. See API-only projects for RequiresAspNetWebAssets.

With a custom DashboardPath this does not apply to the dashboard: its framework script and static assets are then served under the dashboard path by dashboard-owned endpoints carrying the dashboard's authorization metadata.

Authorizing the agent hub

The agent hub ({DashboardPath}/agents) is a third surface, for machines, and the dashboard's own authorization does not reach it. It has two modes, stated in AcceptAgents, and a hub with neither refuses to start:

ModeSetThe agent presents
Shared secretTokens.Primary (and Secondary while rotating)DashboardAgentOptions.Token, as an Authorization: Bearer header
Host authenticationAuthorizationPolicyWhat its AccessTokenProvider hands it; the policy decides

Both set applies both. A connection with no Authorization header, or a token the hub does not hold, is aborted and logged (9111); a token in the URL is not read. A hub authenticated by token alone states AllowAnonymous to the host, so a fail-closed FallbackPolicy does not answer every agent 401; the token check runs before any hub method. See Fronting a scheduler on another machine.

One scheduler at a time

AuthorizationPolicy decides who reaches the dashboard; by default they then see every scheduler. Set SchedulerAuthorizationPolicy to authorize each scheduler separately, as IAuthorizationService.AuthorizeAsync(user, new SchedulerResource(name), policy):

  • the picker and the Schedulers page offer only schedulers the visitor passes for, and the picker refuses a value not in its rendered list;
  • a page on a refused scheduler renders a not authorized frame and reads nothing: the page is never created, so no IQuartzApiClient call is made;
  • every call through the dashboard's own IQuartzApiClient is authorized again, like ReadOnly, so a page with the wrong scheduler name is refused;
  • the live-events hub refuses to join a refused scheduler's group.

The three settings compose: AuthorizationPolicy decides who is in, SchedulerAuthorizationPolicy which schedulers they see, ReadOnly what anyone may change. QuartzHttpApiOptions.SchedulerAuthorizationPolicy uses the same resource, so one AuthorizationHandler<TRequirement, SchedulerResource> serves the dashboard and the HTTP API. Worked example: Multi-tenancy.

A scheduler reached through a target arrives as SchedulerResource(name) { Target = target }: the key w1/QuartzScheduler is SchedulerName = "QuartzScheduler", Target = "w1". A handler matching on the name keeps matching; one that cares which process the name was found in reads Target.

Standalone hosting is where this applies today

The not authorized frame is drawn by the dashboard's layout, which is rendered only when the dashboard owns its Blazor root (the MapQuartzDashboard() overloads without a components builder). In both hosting modes, every scheduler listing, the hub subscription and every call through the dashboard's IQuartzApiClient are filtered, so the dashboard never directs a visitor to a refused scheduler. But under an application's own layout (Integrating with an existing Blazor Server app) the frame is not drawn: an application that routes visitors to schedulers by its own means must not route them to refused ones.

Unset, the behaviour is as in earlier releases: whoever passes AuthorizationPolicy sees every scheduler.

Read-only mode

ReadOnly = true hides every mutating control: pause, resume, trigger-now, trigger-with-overrides, reschedule, edit details, reset-from-error-state, unschedule, interrupt, delete, the listings' selection checkboxes, calendar create and replace, and the scheduler's start, stand-by, pause-all, resume-all and shutdown. Every listing, filter, detail page and live view remains.

  • It is one setting per process, not per scheduler or per operation. For different powers, run two dashboards: a read-only one for observers and a write-enabled one behind an operators-only policy. SchedulerAuthorizationPolicy limits which schedulers each shows, not what may be done to them.
  • It does not affect the HTTP API, which is mapped and authorized separately. A read-only dashboard over an API anyone may post to is read-only in appearance only. QuartzHttpApiOptions.ReadOnly refuses every mutating API route, and binds a dashboard fronting the API from another process.

Narrowing which job types may be named

IsJobTypeAllowed refuses one kind of write. Adding or scheduling a job through the dashboard's IQuartzApiClient names the job type as a string, so a visitor who may write can name any IJob, including NativeJob (which starts the executable its job data names) when Quartz.Jobs is on the probing path:

services.AddQuartzDashboard(options =>
{
    // The predicate sees the job type name as it was written, so a namespace prefix covers
    // every spelling of the same type. The HTTP API takes the same predicate under
    // QuartzHttpApiOptions.IsJobTypeAllowed; the two surfaces are configured separately.
    options.IsJobTypeAllowed = jobType => jobType.StartsWith("Acme.Jobs.", StringComparison.Ordinal);
});
  • A refused name raises UnauthorizedAccessException, as a refused scheduler does.
  • It is enforced in the client, which every call goes through, like ReadOnly.
  • The predicate gets the name as written; nothing resolves it, since the dashboard stores job type names unresolved, like the HTTP API. Prefer a namespace prefix to exact names: Acme.Jobs.Nightly, Acme.Jobs and the same name with Version, Culture and PublicKeyToken are the same type.
  • It does not cover the HTTP API. Set QuartzHttpApiOptions.IsJobTypeAllowed for that surface too when serving both.

Browser security headers are the host's

Quartz sets no CORS policy and no browser security headers. ASP.NET Core sends no Access-Control-Allow-Origin unless asked, so the API is same-origin by default. But the dashboard is framable, and its mutating controls are one confirm dialog away: a page framing an authenticated operator's dashboard can drive a two-click sequence. Send the header your other admin surfaces send:

app.Use(async (context, next) =>
{
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["Content-Security-Policy"] = "frame-ancestors 'none'";
    await next();
});

app.UseAntiforgery() in the setup above is also the application's call; MapRazorComponents requires it.

API key or custom authorization checks

For machine-to-machine access, bind your API auth scheme (an API key handler, say) to a policy used by MapQuartzHttpApi(). For dashboard-only custom checks, use ASP.NET Core policy/handler-based authorization, so the UI, hub and API are enforced consistently.

Deployment guidance for multi-scheduler and clustered setups

  • Clustered ADO.NET job stores: dashboard actions are scheduler operations that can affect the cluster; restrict write access to trusted operator roles.
  • Many local schedulers in one host: the picker lists every registered scheduler; use clear, environment-specific names. For tenants, SchedulerAuthorizationPolicy keeps them apart; see Multi-tenancy.
  • Reverse proxy and Blazor Server: enable WebSocket/SignalR forwarding, and sticky sessions where your hosting stack requires them. The circuit connects to /_blazor (or {DashboardPath}/_blazor with a custom DashboardPath).
  • Split operator experiences: a read-only dashboard (ReadOnly = true) for observers and a separate write-enabled one for operators.
  • Operational retention: the built-in history store is per-process and in-memory, bounded by HistoryRetention and HistoryMaxEntriesPerScheduler, so each cluster node keeps its own. Rows name their node, so a shared IDashboardHistoryStore gives one page over the whole fleet. Use external retention or reporting for long-term analytics.

What authorization does not narrow

Both limits are by design, stay in 4.x, and match the HTTP API's.

  • A visitor who passes is trusted with everything the dashboard shows. The only narrowings are ReadOnly (process-wide) and SchedulerAuthorizationPolicy (which schedulers). Within a scheduler a writer may trigger, pause, reschedule, unschedule, interrupt, delete and shut down, and any visitor may read every job's data map and every exception message in the history. There are no per-operation permissions. Authorize the dashboard as you would a shell on the machine.
  • There is no rate limiting. ASP.NET Core's rate limiter middleware applies as to any endpoint; Quartz configures none.

Integrating with an existing Blazor Server app

If the host already uses Blazor Server (MapRazorComponents<App>().AddInteractiveServerRenderMode()), use the MapQuartzDashboard overload that takes its RazorComponentsEndpointConventionBuilder. This avoids a second /_blazor SignalR endpoint, which would conflict.

builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddQuartzDashboard();
app.UseAntiforgery();

RazorComponentsEndpointConventionBuilder blazor = app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.MapQuartzHttpApi().RequireAuthorization();
app.MapQuartzDashboard(blazor).RequireAuthorization();

The dashboard's pages, layout, CSS and JavaScript interop are registered into the host's Blazor setup through AddAdditionalAssemblies. No <link> or <script> tags are needed in App.razor.

Warning

Do not call the parameterless MapQuartzDashboard() beside your own MapRazorComponents: that registers two /_blazor endpoints and the dashboard's interactive pages fail.

What integrated hosting changes

Everything else on this page applies to both modes.

Standalone (MapQuartzDashboard())Integrated (MapQuartzDashboard(blazor))
DashboardPathHonoured; the dashboard is self-contained under itRejected: routes fixed at /quartz; a custom path fails startup
Blazor circuit and framework assetsDashboard-owned endpoints with the dashboard's authorization metadataThe host's: a fail-closed FallbackPolicy governs them; AllowAnonymous on static assets is your call
The not authorized frameDrawnNot drawn; see One scheduler at a time

In both modes, every scheduler listing and hub subscription is filtered by SchedulerAuthorizationPolicy, so the dashboard never offers a refused scheduler. The gap is only an application routing a visitor to one by its own means.

API-only projects (no .razor files)

A host project with no .razor files of its own (a pure API project, say) must add:

<PropertyGroup>
  <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>

This makes the SDK include the Blazor framework scripts (_framework/blazor.web.js, blazor.server.js) in the app's static web assets. Without it, /_framework/blazor.web.js returns HTTP 404: as of .NET 10 these files are static web assets, no longer embedded in the ASP.NET Core assemblies.

The third symptom: an empty 200

A missing MapStaticAssets() or RequiresAspNetWebAssets shows as 404. A third failure looks like success: every static asset answers 200 with Content-Length: 0.

/_framework/blazor.web.js       200   0 bytes
/_framework/blazor.server.js    200   0 bytes
/YourApp.styles.css             200   0 bytes

The pages return 200 and prerender, but never become interactive: the circuit has no script. The only clue is a startup warning:

warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      The WebRootPath was not found: ...\wwwroot. Static files may be unavailable.

Cause (ASP.NET Core, not Quartz): the static web assets manifest is resolved for an unpublished build only in the Development environment. The same build with no ASPNETCORE_ENVIRONMENT (so Production) has the endpoints, resolving to nothing. Either fix works:

  • run in Development (dotnet run with the project's launch profile normally does);
  • dotnet publish and run the published output, as a deployment does.

In practice it is a local trap: an unpublished build started with dotnet run --no-launch-profile, or from a shell without ASPNETCORE_ENVIRONMENT.

Current limitations

  • One HTTP target is one process: one registration is one address; see Fronting a scheduler in another process over HTTP. A fleet is fronted as one by giving each process a Target, and nodes on one clustered store are shown as one cluster; per-target credentials are the targets' own HttpClients. For a cluster behind a load balancer, use a store-attached target. A process that dials out to the dashboard instead of being dialed is an agent.
  • A dashboard that accepts agents runs as one instance: the hub keeps its registrations in the process that accepted them, so a dashboard scaled out behind a load balancer shows each agent on the instance it dialled.
  • Cluster failover is per detection round: the member the store-backed calls go to is re-elected every ClusterDetectionInterval, and a member that dies between rounds answers with its error until then.
  • A store-attached target cannot do anything node-local, and its liveness is inferred; see What a window can and cannot do.
  • Neither Live Logs nor the Action Log is the record. Live Logs keeps a hundred events from page open; the Action Log keeps 250 from this process's dashboard. Neither survives a restart or is lossless; use metrics for anything you need to go back to. The ILogger copy of Action Log entries does survive.
  • History is in-memory and per-process by default, so a dashboard attached to a cluster shows an empty History page. On a persistent store, UsePersistentStore(store => store.UseExecutionHistory()) makes every node write to QRTZ_EXECUTION_HISTORY and QRTZ_MISFIRE_HISTORY. A scheduler on the in-memory job store has no database for history; see Execution history and misfires.
  • Read-only is one setting per process, not per scheduler or per operation: "acme may look, globex may act" and "this tenant may pause but not delete" cannot be expressed. Which schedulers a visitor sees can; see One scheduler at a time.
  • The misfire tile and Cluster page show what the store can answer: a dash for no misfire feed, one node for a store with no check-in state. Neither is a count of zero.
  • Typed editors are narrow: cron calendars, cron trigger reschedules, job-data overrides and, from 4.3, a trigger's details. Building an arbitrary trigger from the UI is not offered.
  • It is a scheduler console, not a workflow tool. Job dependencies, DAGs and business-process visualisation are outside what Quartz models.
Help us by improving this page!
Last Updated: 10/18/26, 9:21 AM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
HTTP Client
Next
Dashboard Agent