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 withAddQuartzHttpClient; see Fronting a scheduler in another process over HTTP. - Or shows every scheduler in a database, with nothing asked of the processes running them.
AttachStorediscovers 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 olderapp.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.razorfiles also needs<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>, or/_framework/blazor.web.jsis missing and nothing responds to a click.app.UseAuthentication()needs a registered scheme, such asAddAuthentication(…).AddCookie()or an OpenID Connect handler. Quartz authorizes but does not authenticate. With no scheme registered,UseAuthentication()fails at startup, resolvingIAuthenticationSchemeProvider..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.AuthorizationPolicyauthorizes 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.
| Option | Default | What it does |
|---|---|---|
DashboardPath | /quartz | Base path of the UI; see Hosting under a custom path |
AuthorizationPolicy | none | Policy for pages, hub, circuit and assets; see Policy and role-based authorization |
SchedulerAuthorizationPolicy | none | Policy each scheduler is held to; see One scheduler at a time |
ReadOnly | false | Hides every mutating action (pause, resume, trigger-now, reschedule, unschedule, delete, …) |
IsJobTypeAllowed | none | Predicate over a job type name; refused raises UnauthorizedAccessException; see Narrowing which job types may be named |
HistoryRetention | 24 hours | How long the history store keeps executions and misfires; see Execution history and misfires |
HistoryMaxEntriesPerScheduler | 2000 | Executions and misfires kept per scheduler each, oldest dropped first |
ClusterDetectionInterval | 1 minute | How 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 aschedulerNamethrows it for an unknown scheduler;GetScheduler,GetJobDetail,GetTriggerandGetCalendaralso throw it when the item is gone. The dashboard's error boundary shows the not-found page. Returningnull!faults the page instead. - An unreportable value is a value, not a refusal.
GetExecutionLimitsreturnsExecutionLimitsDto.CannotReportwhen 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.
| Page | Route |
|---|---|
| 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,ErrorandCompletetriggers. Each links to the Triggers page filtered to that state, as/quartz/triggers?state=Pauseddoes 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 defaultHistoryRetention). 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.
| Listing | Detail page shows | Actions outside read-only mode |
|---|---|---|
| Jobs: job details and keys; from 4.4, each job's last run, last success and failing ×N | the JobDataMap, the triggers pointing at the job, and its run status | trigger-now with overrides, pause, resume, delete |
| Triggers: state, next and previous fire times, execution group | the trigger's JobDataMap, priority, calendar, misfire instruction, preferred node, its retry policy, retries made for the current occurrence, its overlap policy | pause, resume, unschedule, reset error state, a cron reschedule editor and, from 4.3, Edit details and Backfill…; on the listing, bulk actions |
| Calendars: names | one calendar | create, 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
ERRORtrigger 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.
| Field | Takes |
|---|---|
| Description, execution group | text; blank clears it |
| Priority | a whole number |
| Calendar | a calendar name, suggested from the scheduler's; blank clears it |
| Misfire instruction | the trigger family's instructions by name; a bare code for a trigger in none of the five |
| Retry policy | the 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 node | any node, the first node to fire it, or a named node |
| Overlap policy | one of the five; disabled, with the reason, for a trigger that does not derive from TriggerBase |
JobDataMap | add, 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
IQuartzApiClientof your own that does not implementUpdateTriggerDetailsanswers 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.
| Field | Takes |
|---|---|
| From, To | a date and time in the dashboard's time zone; From is included, To is not. The last day up to now by default |
| Spacing | hh:mm:ss between the firings' starts; zero starts them together |
| Max slots | the 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
IQuartzApiClientof your own that does not implementBackfillanswers 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.Parameter Filter group,namecontains jobGroup,jobNameone job; both or neither calendarthe calendar's name statea TriggerStatenamebeforean 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
JobDataMapvalues 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.
| Action | Made through | Over HTTP |
|---|---|---|
| Pause recording nothing: no reason typed, nobody signed in | IScheduler.PauseTriggers(keys): one call | POST …/triggers/keys/pause |
| Pause recording a reason or the signed-in user | PauseTriggersWith(keys, details): one call, from 4.4 | POST …/triggers/keys/pause with reason and requestedBy |
| Resume | ResumeTriggers(keys) | POST …/triggers/keys/resume |
| Unschedule, after a confirmation | UnscheduleJobs(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.
| Column | What it shows |
|---|---|
| Target | Where the scheduler was found: an HTTP target, an agent, an attached store, or a cluster's a+b+c. A dash for this process |
| Reached | this process, HTTP, agent, store or cluster |
| Scheduler | The name, linked when built, with the instance id under it |
| Status | The status; last seen for an agent |
| Nodes | A cluster's member count; else the cluster-node query, asked only of a persistent clustered store |
| Job store, Threads, Running since, Jobs executed, Version | From 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_schedulercookie; 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
AcquiredandExecuting, and Serves:all,only reports, (no execution group)orall 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,OverdueorFailed) isIScheduler.QueryClusterNodes()'s verdict, from the same predicate the store's recovery sweep uses. Meanings, and why aFailednode 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.
ReadOnlyhides 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}/hubonly 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.
Remoteadds 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 groupsis 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
IQuartzApiClientorISchedulerof your own that does not implement them. - A node that routes gets two more columns. When the scheduler reports
ServedExecutionGroupsorRefusedExecutionGroups, 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:Normaltriggers 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.
DashboardHistoryPluginandDashboardLiveEventsPluginare still public and work when named in aquartz.plugin.*.typekey, 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.
| Page | Over HTTP |
|---|---|
| Overview, Jobs, Triggers, Calendars, Currently Executing, Cluster | Read 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 History | The target's own history |
| Live Logs | The target's own events; a target too old for the route says so |
| Schedulers, and the header's picker | Listed 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 Target | Effect |
|---|---|
| Key | w1/QuartzScheduler: the picker's value, every IQuartzApiClient call, the hub group |
| Listing | One row per target; the picker groups them under the target |
| Service key | The target: GetRequiredKeyedService<IScheduler>("w1"), and the keyed history store and event source |
| Bare name | Resolves to nothing. A bare key is a scheduler reached through no target |
| Duplicate target | SchedulerConfigException at the second AddQuartzHttpClient, or at host start against an attached store of that name |
| Cluster | Targets 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.AuthorizationPolicydecides 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 asUnknown, but job and trigger pages wait for the client. - The dashboard's
ReadOnlyand the API's are separate.QuartzDashboardOptions.ReadOnlyonly hides this dashboard's controls.QuartzHttpApiOptions.ReadOnlyon 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.
| Option | Default | What 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 |
HeartbeatInterval | 15 s | How often each agent reports; the agents adopt it at registration |
OfflineAfterMissedHeartbeats | 3 | Missed heartbeats before the scheduler is listed as Unknown |
OperationTimeout | 30 s | How long one request to an agent may take before it is cancelled on both sides |
MaxMessageBytes | 4 MiB | The most one answer may carry; the agent refuses a larger page with 413, telling the page to lower take |
ForgetAfter | 1 h | How 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;
}));
| Page | Over an agent |
|---|---|
| Overview, Jobs, Triggers, Calendars, Currently Executing, Cluster | Read 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 History | The worker's own history, recorded in memory by UseDashboardAgent |
| Live Logs | The worker's own events, streamed while the page watches |
| Schedulers, and the header's picker | Listed as Agent, with last seen beside the status |
How the row behaves over time:
| The agent | The row |
|---|---|
| Registers | Running (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 again | Asked again, and back to its status |
| Disconnects | Unknown until it reconnects (9114) |
| Shuts down | Shutdown, after its goodbye |
Stays away for ForgetAfter | Dropped from the listing (9115) |
| Reconnects under the same target and instance id | Takes its own row over |
| A second process registers a target a live agent holds | Refused (9113) and retried every 30 s; give each process a target of its own |
- The agent decides what it accepts.
ReadOnlyhere hides the buttons; the worker'sDashboardAgentOptionsrefuse. An agent refuses to store a job by type name until itsIsJobTypeAllowedis 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.
| Scheduler | Key | Target |
|---|---|---|
| Of this process | reporting | none |
AddQuartzHttpClient without Target | QuartzScheduler | none |
AddQuartzHttpClient with Target = "w1" | w1/QuartzScheduler | w1 |
Window onto the store attached as prod | prod/reporting | prod |
Cluster of targets a, b, c | a+b+c/QuartzScheduler | a+b+c |
- The key is what the picker holds, what every
IQuartzApiClientmember 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 namedprodfail 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
4029and 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_NAME | Reported |
|---|---|
| At least one node within its check-in interval plus the threshold | Running, with the node count |
| Rows, but every one convicted | Shutdown: every node that checked in has stopped |
| No rows | Unknown |
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.
| Available | Not available |
|---|---|
| Jobs, triggers, calendars, groups, paused groups: read, added, edited and deleted | Start, Standby, Shutdown |
| Pause, resume, reschedule, edit in place, backfill, unschedule, trigger now, bulk actions | Interrupting a running job or a firing |
Currently Executing, from QRTZ_FIRED_TRIGGERS | Live Logs and the live event stream |
| Cluster, from the nodes' check-ins | The 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,standbyandshutdownfor it with a400. 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
UseTriggerPersistenceDelegateregistrations. 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
ReadOnlyand 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);
});
| Rule | Detail |
|---|---|
| Who is asked | Every HTTP target or agent whose scheduler name another target also fronts |
| Gate | JobStoreClustered and JobStorePersistent. A non-clustered scheduler reports NON_CLUSTERED and is never merged |
| Merge | Targets whose QueryClusterNodes answers share a node, transitively. Two or more form a cluster |
| Key | Member targets sorted, joined with +. A membership change renames the row |
| Membership | Sticky. 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 answered | Never merged |
| When | At host start, every ClusterDetectionInterval, and whenever a target is added or removed |
| Log | 9105 formed, 9106 membership changed, 9107 dissolved (Information); 9108 round failed (Warning); 9109 member unreachable, 9110 target did not answer (Debug) |
What goes where:
| Operation | Routed to |
|---|---|
| Jobs, triggers, calendars, pause, resume, trigger now, reschedule, bulk, delete, cluster nodes | The preferred member: the first, by target, that answered the last round |
Interrupt (job) | Every member; true if any interrupted |
| Interrupt a firing | The member whose node owns the firing, per the store; every member when none does |
| Execution limits, set | Every member, in target order; the first failure names the members already set |
| Execution limits, read | The preferred member's |
| Status | Running if any member is, else Standby if any, else what the rest say; Unknown when none answered |
| Scheduler details | The preferred member's, with jobs executed and executing summed over the members and the earliest start |
| Execution History | The preferred member's. With UseExecutionHistory() on the nodes that is the shared table |
| Live Logs | Every member's events, interleaved; each names its node |
| Start, Standby, Shutdown | Refused 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.
SchedulerAuthorizationPolicysees the cluster asSchedulerResource("QuartzScheduler") { Target = "a+b+c" }, and a member asTarget = "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
MapQuartzDashboardwins. The parameterless overload usesDashboardPath. - 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
DashboardPathto the external path (e.g./my-api/quartzwhen the proxy forwards/my-api/*verbatim), and forward WebSocket connections for{DashboardPath}/_blazor, which every page needs. {DashboardPath}/hubis 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),DashboardPathis relative to the path base, and the default/quartzworks under the prefix. With minimal hosting (WebApplication), callapp.UseRouting()explicitly afterapp.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),UsePathBasehas nothing to strip. Keep the defaultDashboardPathand set the path base before static files and routing:The proxy Set the path base with Sends the stripped prefix as X-Forwarded-Prefixthe forwarded-headers middleware Sends nothing a 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
KnownProxiesorKnownIPNetworks. - 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 row Run again passes Has an input, from 4.4 with RecordInputThe input, under SchedulerConstants.JobInput: with the original input, logged asTriggerJobWithDataHas none, or one too large to keep No 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 withStoreDurably()to run it again.Page Where History The toast and the page's error, once Run again fails Execution The 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:
| Filter | Parameter |
|---|---|
| Job, a fragment | job |
| Trigger, a fragment | trigger |
| Node | node |
| Failed after retries | outcome=failed |
| Results | result=failed,cancelled |
| One job exactly | jobGroup and jobName, which the Job Detail page's View execution history link writes |
| One trigger exactly | triggerGroup and triggerName, which the Trigger Detail page's View execution history and Currently Executing's Past runs write. From 4.6 |
| Window | window=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.
| Page | Shows |
|---|---|
| Jobs | Last run, Last success and a failing ×N badge linking to that job's final failures; one read per page |
| Job Detail | A 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.
| Window | Buckets |
|---|---|
| All retained | A day each; an hour each while every run is from the last three days |
| Last hour | 5 minutes |
| Last 24 hours | An hour |
| Last 7 days | 6 hours |
| Last 30 days | A 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
IQuartzApiClientof 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:
| Seam | Behaviour |
|---|---|
Quartz.Extensibility.IExecutionHistoryStore | Preferred. Written by the recorder, read by the pages and served by the HTTP API |
IDashboardHistoryStore | The 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 oneCOUNT(*). AddQuartzDashboardno longer registersDashboardHistoryPlugin; 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-closedFallbackPolicy. 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/quartzredirects to login and authenticated users get the dashboard. To expose it to unauthenticated users, do not apply a fail-closedFallbackPolicyto the dashboard paths, or set anAuthorizationPolicythey satisfy; and writeAllowAnonymous()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:
| Mode | Set | The agent presents |
|---|---|---|
| Shared secret | Tokens.Primary (and Secondary while rotating) | DashboardAgentOptions.Token, as an Authorization: Bearer header |
| Host authentication | AuthorizationPolicy | What 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
IQuartzApiClientcall is made; - every call through the dashboard's own
IQuartzApiClientis authorized again, likeReadOnly, 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.
SchedulerAuthorizationPolicylimits 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.ReadOnlyrefuses 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.Jobsand the same name withVersion,CultureandPublicKeyTokenare the same type. - It does not cover the HTTP API. Set
QuartzHttpApiOptions.IsJobTypeAllowedfor 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,
SchedulerAuthorizationPolicykeeps 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}/_blazorwith a customDashboardPath). - 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
HistoryRetentionandHistoryMaxEntriesPerScheduler, so each cluster node keeps its own. Rows name their node, so a sharedIDashboardHistoryStoregives 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) andSchedulerAuthorizationPolicy(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)) | |
|---|---|---|
DashboardPath | Honoured; the dashboard is self-contained under it | Rejected: routes fixed at /quartz; a custom path fails startup |
| Blazor circuit and framework assets | Dashboard-owned endpoints with the dashboard's authorization metadata | The host's: a fail-closed FallbackPolicy governs them; AllowAnonymous on static assets is your call |
| The not authorized frame | Drawn | Not 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 runwith the project's launch profile normally does); dotnet publishand 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' ownHttpClients. 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
ILoggercopy 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 toQRTZ_EXECUTION_HISTORYandQRTZ_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.
