busy-office-ui
Menu

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

Refresh
Scheduled jobs and their run history
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

  1. 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.
  2. Job list — a plain data table, one row per scheduled job. No grouping, no dashboard chrome: this is a worklist, not a visualization.
  3. 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.
  4. 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.
  5. 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

RequestReturns / on failure
GET /jobsthe 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/retrythe 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/cancelthe row re-rendered with its last completed state; a job that cannot be cancelled mid-step says so rather than appearing to stop
pollinghx-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

StateWhat ships
Runningwarning badge + aria-busy="true" on the cell, elapsed time, not a spinner alone
Faileddanger badge + when; Retry is the row's action; the failure REASON belongs one click away, not crammed into the cell
Stalleddistinct 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
Succeededsuccess badge + a link to the run's own result; the monitor never shows the result inline
Never runplain 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 JSthe 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

↑↓ move Enter open Esc close