Dashboard
The observability layer the plugin adds to the payload-jobs collection.
Adding jobs() decorates Payload's payload-jobs collection in the admin. There is no separate route or app; the queue is operated where it lives.
What you get
Payload stores execution state as raw fields (processing, hasError, completedAt, waitUntil, totalTried), which take practice to read at a glance. The plugin derives one readable status per job from them (queued, scheduled, retrying, running, succeeded, failed, or cancelled) and shows it as a list column. The same derivation is exported as deriveJobStatus for your own code.
Above the list sits a queue-health bar: per-status counts at a glance (the queue-control health endpoint additionally reports a recovered count when reliability is on). Counts above the cap render as 100+ (configurable via healthBarCap). Each pill is a link to the list view pre-filtered to that exact status, using the same where-clause the count was computed with. The total chip resets search and filters instead of linking away: inside the list view it clears them in place through Payload's list-query context, so you land back on the full unfiltered table without a navigation; outside a list view (for example a custom dashboard embedding JobsHealthBar) it falls back to a plain link to the unfiltered list. The job detail view gains an error panel that renders the stored error readably and a timeline view of the job log.
Records are locked down by default: execution-state fields are read-only and surfaced in the header, and inputs are editable only on Create. Jobs remain creatable from the admin, but a finished run cannot be edited into inconsistency. Set readOnlyRecord: false to opt out. The list itself defaults to the columns jobTitle, status, queue, totalTried, updatedAt, with relative-time cells for dates.
The Job column
The first column, jobTitle, is a stored field kept in sync with workflowSlug or taskSlug on every write, and doubles as the collection's useAsTitle. It renders as a link to the document, matching Payload's default linked-first-column behavior. workflowSlug is no longer a default column on its own; the derived title covers both workflow and task jobs in one place.
When a task or workflow is configured with Payload's own label, that label displays in place of the slug, with the slug available on hover. The same label maps drive the document header's Job/Workflow/Task fact and the log timeline (below). Jobs whose task or workflow has no configured label keep showing the raw slug, so nothing regresses for existing configs.
List search
The list search box matches jobTitle, workflowSlug, taskSlug, and queue (admin.listSearchableFields; a host-set value on the collection wins over this default). jobTitle alone isn't enough: it's populated by a beforeChange hook, and jobs created through payload.jobs.queue() or a schedule bypass collection hooks by default (Payload writes them with db.create unless jobs.runHooks or depth is set), so jobTitle is empty on those documents. Matching the slug fields directly means runtime-queued and schedule-created jobs are still found by workflow, task, or queue name.
Set jobs: { runHooks: true } in buildConfig if you also want document titles and breadcrumbs to populate for runtime-created jobs; the dashboard's search works either way.
Readable timestamps
startedAt ("Started") and leaseExpiresAt ("Lease expires", only present with reliability on) render as relative time ("2 minutes ago", "in 3 hours") with the exact timestamp available on hover. They are read-only, runner-owned fields, so unlike Payload's native date picker they render without a clear button.
waitUntil ("Scheduled for") keeps Payload's native date picker while creating a job, then switches to the same read-only relative display once the document exists, since the runner owns that value from then on. A scheduled job also shows a "Scheduled for" fact alongside status, queue, and attempts in the document header.
Relative phrasing is produced with Intl.RelativeTimeFormat against the admin's active locale, so it localizes along with the rest of the admin UI.
Scheduled-row cues
The Status column carries two extra cues for scheduled and cron-driven jobs. A scheduled row shows its next run as relative time next to the status pill, with the exact timestamp on hover. A document created by Payload's handleSchedules (its meta.scheduled flag) additionally gets a light-gray "Cron" pill, so schedule-spawned jobs are visually distinct from ones queued directly through payload.jobs.queue().
The Attempts column, and the equivalent Attempts fact in the document header, show a tooltip on hover explaining what the count includes (execution attempts for the job, including retries), since totalTried alone doesn't say that.
Create form: workflow and task selects
workflowSlug and taskSlug render as selects on the create form, with options merged from Payload's own configured slugs (config.jobs.workflows / config.jobs.tasks). A job runs a workflow or a task, never both: picking one hides the other, and a select with no configured options hides entirely. Submitting both on create is rejected server-side with a translated error (errorWorkflowTaskExclusive).
Create form: queue select
queue stays a text field at the schema level, but renders as a native-looking select in the admin. It is not a real Payload select field on purpose: select options become a hard enum in both the Mongo and Postgres adapters, so adding a queue name later would need a migration on Postgres. queue stays free-form underneath, so payload.jobs.queue({ queue: 'anything' }) keeps working for queue names that were never declared in config; an existing job with an undeclared queue value still displays correctly, since the field injects it as an extra option instead of showing blank.
The select's options come from automatic queue discovery, so you rarely need to name a queue twice. The union covers, in order: 'default', the plugin's own queues option, queueControl.queues (if queue control is on), queues named in static autoRun entries, every schedule[].queue on a configured task or workflow, and workflow-level queue. autoRun as a function is skipped, since it can't be inspected statically. Use the queues option for a queue that exists only at runtime and isn't otherwise declared anywhere in config:
jobs({ queues: ['emails', 'exports'] })Create form: input placeholder
Picking a task or workflow on the create form fills input with a placeholder derived from that entry's inputSchema. An entry with no schema fills {}.
Every task and workflow keeps its own draft while the form is open: switching away parks what you typed, switching back restores it, and a slug you pick for the first time opens on its placeholder. A draft that is not valid JSON, or an empty object, is not kept: the slug reopens on its placeholder. Clearing the selection resets the field to {}. Existing jobs are not touched: their inputs are read-only.
The placeholder is a real value: what you leave in the editor is what the job receives, "<users id>" included.
A field with a static defaultValue contributes that. Otherwise:
| Field | Placeholder |
|---|---|
text, textarea, email, code, date | "" |
number | 0 |
checkbox | false |
select, radio | the first option |
relationship, upload | "<collection id>", naming relationTo |
polymorphic relationship, upload | { relationTo: "<first collection>", value: "<its id>" } |
json, richText | {} |
point | [0, 0] |
any hasMany field | one element of the above, e.g. ["<people id>"] |
group | a nested object; array one row of one |
blocks | one row of the first block, with its blockType |
row, collapsible, unnamed tabs and groups | their fields, flattened into the parent |
The derivation cannot invent meaningful values, only shapes. For a slug whose placeholder should carry real ones, write them yourself; they are merged over the derived object, so top-level keys you omit keep their derived value:
jobs({
input: {
examples: {
atosSync: { phases: ['persons'], limit: 100 },
},
},
})Custom input editors
input.components swaps the JSON editor for a component of yours, keyed by task or workflow slug. The placeholder above still applies, and the editor is swapped as the selection changes.
jobs({
input: {
components: {
importAthletes: '/components/ImportAthletesInput#ImportAthletesInput',
'*': '/components/GenericInput#GenericInput',
noop: false,
},
},
})Keys are task and workflow slugs, plus '*' for every slug. An exact slug wins over the wildcard, and an explicit false sends a slug back to the JSON field, wildcard included. A task and a workflow that share a slug share the editor.
Your component receives JobInputComponentProps, exported from @10x-media/jobs/types:
| Prop | Type | Description |
|---|---|---|
path | string | The input field's form path. Read and write the value through useField({ path }). |
slug | string | The selected task or workflow slug. |
kind | 'task' | 'workflow' | Which of the two the slug names. |
placeholder | Record<string, unknown> | undefined | What the create form would have pre-filled for this slug: the derived object, with its input.examples entry merged over it. |
readOnly | boolean | undefined | Set on an existing job, whose inputs are locked. |
The editor must be a client module. Every control reads the job's input through useField and writes a patched object back, which is also how the pre-filled placeholder reaches it:
'use client'
import type { JobInputComponentProps } from '@10x-media/jobs/types'
import { CheckboxInput, TextInput, useField } from '@payloadcms/ui'
export const ImportAthletesInput = ({ path, readOnly }: JobInputComponentProps) => {
const { setValue, value } = useField<{ limit?: number; dryRun?: boolean } | null>({ path })
const input = value ?? {}
const patch = (part: typeof input) => setValue({ ...input, ...part })
return (
<>
<TextInput
label="Limit"
onChange={(e) => patch({ limit: e.target.value === '' ? undefined : Number(e.target.value) })}
path={`${path}.limit`}
readOnly={readOnly}
value={String(input.limit ?? '')}
/>
<CheckboxInput
checked={Boolean(input.dryRun)}
label="Dry run"
onToggle={() => patch({ dryRun: !input.dryRun })}
readOnly={readOnly}
/>
</>
)
}Paths resolve through the import map, registered under admin.dependencies as for custom log blocks: re-run payload generate:importmap after adding or changing one. A path it cannot resolve falls back to the JSON field.
Options
All dashboard options live at the top level of the plugin options:
| Option | Type | Default | Description |
|---|---|---|---|
hidden | boolean | false | Hide the jobs collection from the admin nav. |
defaultColumns | Override<string[]> | see above | Replace the list columns, or transform the defaults with a function. |
readOnlyRecord | boolean | true | Lock execution state and inputs after Create. |
cells | Record<string, PayloadComponent | false> | built-ins | Per-field list-cell overrides, keyed by job field name. false keeps Payload's default cell. |
status | PayloadComponent | false | built-in | Replace or remove the derived Status column. |
log.entryComponents | Record<string, Partial<Record<JobLogSlot, PayloadComponent | false>>> | {} | Custom renderers for the blocks inside an expanded log-attempt row, keyed by task slug ('*' for every task). See Custom log blocks. |
input.examples | Record<string, Record<string, unknown>> | {} | Hand-written create-form placeholder values for input, keyed by task or workflow slug, merged over the ones derived from inputSchema. See Create form: input placeholder. |
input.components | Record<string, PayloadComponent | false> | {} | Custom editors for input on the create form, keyed by task or workflow slug ('*' for every slug). See Custom input editors. |
beforeListTable | PayloadComponent[] | false | health bar | Replace or remove the components above the list. |
healthBarCap | number | false | 100 | Per-status count cap; false shows exact counts. |
queues | string[] | [] | Extra queue names for the admin queue select, on top of the sources auto-discovered from config. |
jobs({
defaultColumns: ({ defaultColumns }) => ['status', ...defaultColumns.filter((c) => c !== 'status')],
healthBarCap: false,
})Collection overrides
overrides.jobs extends the enhanced payload-jobs collection: the outermost layer, applied after the dashboard fields above and after reliability fields if that's on. fields is a function that receives the fully-enhanced defaults, so you compose with them rather than replace them; hooks are appended after the plugin's own hooks, not in place of them; admin and access are merged key-by-key; slug is locked and any value you pass is ignored. The CollectionOverride and FieldsOverride types are exported from @10x-media/jobs for typing an override you define separately.
import { jobs } from '@10x-media/jobs'
jobs({
overrides: {
jobs: {
fields: ({ defaultFields }) => [
...defaultFields,
{ name: 'source', type: 'text' },
],
hooks: {
afterChange: [
async ({ doc }) => {
// Runs after the plugin's own afterChange
await notifySlack(doc)
},
],
},
},
},
})Task and workflow labels
Payload's own TaskConfig/WorkflowConfig accept a label, and the dashboard now renders it everywhere a job shows its workflow or task: the Job column, the document header's Job/Workflow/Task fact, and each row of the log timeline (as "Label (slug / taskID)"). The slug stays available on hover wherever the label replaces it, so nothing is lost, just deprioritized.
The one place a label can't apply is an inline step: a workflow handler that calls runTask('inline', ...) instead of running a registered task. Payload's own runner writes that log entry with the reserved taskSlug: 'inline', not a real task config, so there is no label to look up. Those rows render as inline: <taskID> instead of bare inline, so at least the identifying ID is visible. A registered task or workflow slug always gets its full label treatment.
Custom log blocks
Each row of the log timeline expands to show that attempt's input, output, and error as JSON dumps. log.entryComponents replaces the body of any of those three blocks with a component of yours, keyed by task slug and then by block:
jobs({
log: {
entryComponents: {
sendEmail: {
input: '/components/EmailInput#EmailInput',
output: '/components/EmailOutput#EmailOutput',
},
'*': { error: '/components/PrettyError#PrettyError' },
},
},
})Keys are task slugs, plus '*' for every task and inline for Payload's reserved inline steps. An exact slug wins over the wildcard slot by slot, so sendEmail above customizes its own input and output while still inheriting the wildcard error. An explicit false opts one slot back out to the default JSON block, wildcard included:
jobs({
log: {
entryComponents: {
'*': { error: '/components/PrettyError#PrettyError' },
importCsv: { error: false },
},
},
})Only the JSON body is yours. The block's label, its frame, and the rules for when it appears stay the plugin's. The scope is the three blocks inside an expanded attempt row; the job's top-level input field and the error panel are not affected.
Your component receives JobLogSlotProps, exported from @10x-media/jobs/types:
| Prop | Type | Description |
|---|---|---|
value | unknown | The block's raw value: the attempt's input, output, or error. undefined when the attempt does not carry it. |
slot | 'input' | 'output' | 'error' | Which block is being rendered. |
entry | JobLogEntry | The whole log attempt (taskSlug, taskID, state, executedAt, completedAt, and the rest). |
index | number | Zero-based position of the attempt in the log array. |
jobID | string | number | undefined | The job document's ID. |
Both server and client components work. The log field renders through an RSC wrapper, so a server component may await the Local API; a client component needs its own 'use client'.
The input block always renders, as it did before the option existed; an attempt carrying no input at all still gets one, and your component receives undefined where the default block shows {}. The output and error blocks follow their value. A registered renderer runs for an empty one, so an attempt that returned {} still gets a readable block, but never for a value the attempt does not carry at all, so a succeeded attempt gets no error block even under a wildcard error.
Paths resolve through the import map. They live in plugin options rather than in a field slot the generator walks, so the plugin registers them under admin.dependencies for you, and you re-run payload generate:importmap after adding or changing one. A path the import map cannot resolve falls back to the default JSON block, and Payload logs a console error naming the missing key.
Components
The pieces are exported for reuse in your own admin customizations: JobStatusCell, JobStatusHeader, JobTitleCell, JobErrorPanel, JobLogTimeline, JobDocDescription, RelativeTimeCell, ReadOnlyDateField, WaitUntilField, QueueSelectField, AttemptsCell, JobInputField, and JobsTotalChip from @10x-media/jobs/client, and the server-rendered JobsHealthBar, JobLogTimelineServer, and JobInputFieldServer from @10x-media/jobs/rsc.
Counting caveat
With Payload's default deleteJobOnComplete: true, succeeded jobs are deleted, so the succeeded count stays at zero and the bar reflects live states only. Keep completed jobs (jobs: { deleteJobOnComplete: false } in buildConfig) if you want success counts and an audit trail.