Pattern: job monitor
Who uses it: whoever is accountable when unattended work fails — an integrations admin, an ops lead, the person the inbox assigned an exception to. How often: a glance most days, a deep look the moment something is late. What "done" looks like: every scheduled job shows its last result and next run, and nothing has failed silently. Not for authoring schedules — cron expressions, dependency chains and retry policy are your ERP's admin module; this screen is scoped to monitoring jobs someone already scheduled — view, retry, cancel, nothing that changes what runs next time. And not the place to read one run's own numbers: a finished job links to its own result (a report or a staging screen); this page only tracks whether runs happened and how they went.
The screen
| Job | Schedule | Last run | Next run | Action |
|---|---|---|---|---|
| Nightly GR/IR reconciliation | 02:00 daily | Failed today 02:14 | tonight 02:00 | |
| Vendor bank-file import | every 15 min | Running started 2 min ago | — | |
| Q3 open-items export | on demand | Succeeded today 08:52 | — | View result |
| Dunning letter batch | 08:00 weekdays | Stalled since 08:00 · 47 min | tomorrow 08:00 | |
| Currency rate refresh | 06:00 daily | Never run | tomorrow 06:00 |
The Last-run column is the whole screen: every state a schedule can be in — failed, running, succeeded, stalled, never run — is one column, always visible, no drill-in required. Stalled is deliberately distinct from running: a job announcing progress and a job that has gone quiet look identical unless the duration is shown.
Markup
<!-- A screen that updates itself owes the reader a way to stop it.
WCAG 2.2 SC 2.2.2 applies to auto-updating values from the FIRST
tick — the five-second grace is for moving content, not numbers.
A radiogroup, so arrow-key navigation and "n of m" come free; the
chosen value is written into the table's hx-trigger, Off removes it. -->
<div class="bo-cluster">
<span id="jm-refresh-label">Refresh</span>
<div class="bo-segmented" role="group" aria-labelledby="jm-refresh-label">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="jm-refresh" id="jm-off">
<label class="bo-segmented__option" for="jm-off">Off</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="jm-refresh" id="jm-30s" checked>
<label class="bo-segmented__option" for="jm-30s">30s</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="jm-refresh" id="jm-5m">
<label class="bo-segmented__option" for="jm-5m">5m</label>
</div>
</div>
<table class="bo-data-table">
<thead><tr>
<th scope="col">Job</th>
<th scope="col">Schedule</th>
<th scope="col">Last run</th>
<th scope="col">Next run</th>
<th scope="col"><span class="bo-visually-hidden">Action</span></th>
</tr></thead>
<tbody>
<tr>
<td>Nightly GR/IR reconciliation</td>
<td>02:00 daily</td>
<!-- aria-busy on the RUNNING cell — the programmatic channel
pairing the "Running" badge's word + tone. -->
<td aria-busy="true">
<span class="bo-badge bo-badge--warning">Running</span> started 2 min ago
</td>
<td>—</td>
<td><button class="bo-btn bo-btn--sm bo-btn--secondary" type="button">Cancel</button></td>
</tr>
</tbody>
</table> Anatomy
- Refresh control — a segmented control above the table (Off / 30s / 5m). Not decoration: a screen that updates itself owes the reader a way to stop it, and a radiogroup gives the keyboard and screen-reader behaviour for free. Off is a real option — someone reading a failure reason should not have the row move under them.
- Job list — a plain data table, one row per scheduled job. No grouping, no dashboard chrome: this is a worklist, not a visualization.
- Schedule column — read-only, stated in the job's own words ("02:00 daily"). Editing it is the admin module's job, named explicitly in the opener.
- Last-run column — a
badge (word + tone) plus
a timestamp/duration;
aria-busy="true"on the cell while a job runs, the second channel behind the "Running" word. This is the human-monitoring signal — the AI-manages/human-monitors split below names it explicitly. - Action — Retry after a failure, Cancel while running or stalled, or a link to the run's own result once it succeeded. Never more than one action per row.
Data contract
| Request | Returns / on failure |
|---|---|
| GET /jobs | the row fragment; each row carries data-job-id and data-status (running/failed/succeeded/stalled/never-run) so an agent reads state without parsing badge text |
| POST /jobs/:id/retry | the row re-rendered running; 409 if it is already running — re-render with the current state, never queue a second attempt silently |
| POST /jobs/:id/cancel | the row re-rendered with its last completed state; a job that cannot be cancelled mid-step says so rather than appearing to stop |
| polling | hx-trigger="every 30s" on the table while any row is running or stalled; polling stops once nothing is in flight — an idle screen has no reason to keep asking. The interval is the user's to set: the Refresh control writes the chosen value into hx-trigger, and Off removes it. WCAG 2.2 SC 2.2.2 requires this from the first tick — the five-second grace applies to moving content, not to auto-updating values |
The AI-manages / human-monitors split, stated: this screen IS the fourth criterion — its entire job is watching automation, including AI agents' own scheduled work. An agent retries or cancels through the same two endpoints a human's buttons call; the human's channel is the Last-run column, full stop. There is no separate "agent view" — one row, one truth, for both.
States
| State | What ships |
|---|---|
| Running | warning badge + aria-busy="true" on the cell, elapsed time, not a spinner alone |
| Failed | danger badge + when; Retry is the row's action; the failure REASON belongs one click away, not crammed into the cell |
| Stalled | distinct from running — same warning tone, but the word changes and the duration is shown; a job announcing nothing for an hour is not the same as one announcing progress |
| Succeeded | success badge + a link to the run's own result; the monitor never shows the result inline |
| Never run | plain muted text, not a badge — a badge implies something happened |
| No jobs scheduled | "No scheduled jobs" — genuinely rare in a live ERP, but the empty state still has to say something, not render a bare header row |
| No JS | the table itself reads fine as static markup, but Retry/Cancel are bare type="button" elements with no script or form wired to them on this page, so they do nothing — and the row's own polling (hx-trigger="every 30s", described in Data contract) needs htmx's own script to run at all, so a running/stalled job's status never advances without a manual reload |
Components used
complexity 2 of 4