.offrails/guides/jobs-dashboard.md

Jobs Dashboard (Oban Web / Mission Control → /admin/jobs + app jobs)

TL;DR

  • Every app has an Oban Web-style dashboard at /admin/jobs (overview, queues, job list with filters and bulk actions, job detail with errors, cron) and the same operations via app jobs ….
  • Both are thin layers over kernel::jobs::admin, so browser and terminal can't disagree.
  • Access: open in development; production needs an admin (or APP_ADMIN_PASSWORD, rate-limited), otherwise it isn't mounted.
  • Kernel-page rules apply: own layout, no-store, links in with hx-boost="false", no confirm() dialogs.

Read the rest when changing the dashboard, its access rules, or the app jobs CLI.

Use the part. The jobs-dashboard parts are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: live throughput and queue-wait charts, a now-running lane with progress, errors grouped by root cause, per-worker success rates and cron with run-now. When the product needs this, add the part rather than writing it from this guide: cargo offrails add-part jobs-dashboard (dependencies come first; workflow). Keep its // SECURITY: markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part.

Every app gets a live jobs dashboard at /admin/jobs and the same operations from the CLI. Both are thin layers over one kernel module, kernel::jobs::admin, so the browser and the terminal can't disagree.

It's generated by generators/new.md, alongside the queue in kernel/jobs/, and it is never a stub. It looks and works the same in every FRONTEND_MODE: server-rendered Askama + vendored htmx + one plain stylesheet. It doesn't use React, Tailwind or the app's layout, so app UI changes never break it. The /dev tools and the dev mailbox share its chrome (dev-tools).

Files

src/kernel/jobs/admin.rs        queries + actions (shared by web and CLI)
src/kernel/jobs/web.rs          axum Router for /admin/jobs, view structs, inline-SVG chart
src/kernel/ui.rs                Chrome (header, tabs, live toggle), page() with Cache-Control: no-store,
                                ago/until/duration/utc/truncate/thousands helpers, BOOTED_AT
templates/kernel/layout.html    shared kernel layout (jobs, /dev, mailbox)
templates/kernel/jobs/          index.html, _stats.html, _chart.html, _queues.html, _nodes.html,
                                list.html, _rows.html, show.html, cron.html, confirm.html
static/kernel/admin.css         plain CSS, light + dark (prefers-color-scheme), usable at 375px
static/kernel/admin.js          the kernel pages' only script: reload on a back/forward-cache restore
static/vendor/htmx-<ver>.min.js vendored in every mode (frontend-htmx.md shows how to fetch it)

Assets (kernel/assets.rs) has admin_css and admin_js fields (/static/kernel/admin.css?v=<digest>), so the kernel pages get cache-busted assets without Tailwind. The kernel layout's <meta name="htmx-config"> is {"allowEval":false,"includeIndicatorStyles":false,"historyCacheSize":0} (the first two pair with the CSP: frontend-htmx).

kernel::jobs::admin (the API)

pub async fn stats(db) -> Result<Stats, DbErr>;                       // GROUP BY queue, state (one query)
//   Stats { by_state: Vec<(JobState, u64)> /* all 7, zeros included */, by_queue: BTreeMap<String, HashMap<JobState, u64>> }, .count(state)
pub async fn queues(db, now) -> Result<Vec<QueueRow>, DbErr>;        // job_queues ∪ queues seen in jobs ∪ live nodes' queues
//   QueueRow { name, paused, limit /* sum over live nodes */, nodes, executing, available, scheduled, retryable, discarded }, .orphaned()
pub async fn nodes(db, now) -> Result<Vec<NodeRow>, DbErr>;          // job_nodes + executing count per node; alive = heartbeat < 60s
pub async fn throughput(db, now, minutes: i64) -> Result<Vec<Bucket>, DbErr>; // Bucket { minute, completed, failed }
pub async fn list(db, filter: &JobFilter, before_id: Option<i64>, limit: u64) -> Result<Vec<job::Model>, DbErr>;
pub async fn count_matching(db, filter) -> Result<u64, DbErr>;
pub async fn workers(db) -> Result<Vec<String>, DbErr>;             // distinct, for the filter select
pub async fn get(db, id) -> Result<Option<job::Model>, DbErr>;
pub async fn retry(db, ids: &[i64]) -> Result<u64, DbErr>;           // anything but executing/available → available now
pub async fn cancel(db, ids) -> Result<u64, DbErr>;                  // non-final → cancelled (executing: aborted by its node's heartbeat)
pub async fn delete(db, ids) -> Result<u64, DbErr>;                  // anything except executing
pub async fn run_now(db, ids) -> Result<u64, DbErr>;                 // scheduled/retryable → available, scheduled_at = now
pub async fn retry_matching(db, filter) -> Result<u64, DbErr>;      // bulk, capped at BULK_CAP = 10,000; refuses an empty filter
pub async fn cancel_matching(db, filter) -> Result<u64, DbErr>;
pub async fn pause(db, queue) -> Result<(), DbErr>;                  // upsert job_queues.paused = true
pub async fn resume(db, queue) -> Result<(), DbErr>;
pub async fn last_runs(db) -> Result<HashMap<String, DateTimeUtc>, DbErr>;   // cron key → latest run_at
pub async fn last_cron_job(db, key) -> Result<Option<i64>, DbErr>;

#[derive(Default)] pub struct JobFilter { pub state: Option<JobState>, pub queue: Option<String>, pub worker: Option<String>, pub tag: Option<String>, pub q: Option<String> }
  • retry sets state = 'available', scheduled_at = now, max_attempts = max(max_attempts, attempt + 1), and clears completed_at/discarded_at/cancelled_at. Errors are kept, because the history matters. Each row update is filtered by its old state (no races with a running node).
  • cancel pushes "cancelled: by an operator" to errors and frees an Incomplete unique key.
  • list uses keyset pagination (id < before_id ORDER BY id DESC LIMIT n). Text search q: all digits matches id; otherwise a case-insensitive substring of args or worker, portable on both backends as LOWER(CAST(col AS text)) LIKE '%q%' ESCAPE '\' (sea-query Func::lower(Func::cast_as(Expr::col(col), "text")) + LikeExpr::new(..).escape('\\')). tag matches the quoted JSON element (%"user:42"%), so user:4 doesn't match user:42. Search for values: Postgres jsonb re-renders args as {"n": 1}, so JSON fragments without spaces miss.
  • throughput: select only completed_at (completed), discarded_at (discarded) and attempted_at (retryable) since the window start, and bucket per minute in Rust. Portable, fine up to ~50k jobs/hour; beyond that log a decision and switch to a backend GROUP BY in the kernel.
  • Every action logs info!(action, count, "jobs admin"). The web actions also set a flash ("Retried 3 jobs.", or "Nothing to run: the job's state doesn't allow it." when 0 rows changed), shown once by the kernel layout.

Pages

All under /admin/jobs, full pages normally. Live parts are re-fetched with HX-Request and a part= query.

RouteShows
GET /admin/jobsOverview. State counters in operator order (executing · available · scheduled · retryable · discarded · cancelled · completed), each linking to the filtered list. Throughput chart for the last 60 minutes with totals. Queues table: name (links to the list), paused / "no nodes" badges, limit, nodes, counts, Pause/Resume button. Nodes table: node, queues + limits, running count, started, last heartbeat (dead nodes dimmed). Empty states explain how to start a worker. ?part=stats|queues|nodes|chart with HX-Request returns that fragment
GET /admin/jobs/list?state=&queue=&worker=&tag=&q=&before=Jobs. State tabs with counts. Filter bar (GET, so URLs are shareable): queue and worker selects, tag and search inputs, Clear. Bulk bar: Retry / Cancel / Delete selected, plus "Retry all N matching" / "Cancel all N matching" when a filter is set. Rows: checkbox, id, state badge, worker + args (80 chars, monospace), queue, attempt/max, the time that matters for the state ("started 3s ago", "runs in 4m", "retries in 2m", "failed 2h ago"), tags. "Older →" keyset pager. ?part=rows returns the <tbody>
GET /admin/jobs/{id}Job. State badge, relative time and actions (Run now / Retry / Cancel / Delete, shown only when valid). Timeline: Inserted → Scheduled → Attempted → Completed/Discarded/Cancelled, plus queue time (attempted − scheduled) and run time. Queue, attempt, priority, node, unique key (shortened), tags. Args and meta pretty-printed. Errors: one block per attempt, newest first, with the full error chain. An unknown id (deleted, pruned, or reached with Back) → 303 to the list with the flash "Job N no longer exists (deleted or pruned)."
GET /admin/jobs/cronCron. SCHEDULE entries: key, expression, worker (links to the list), next run (relative + UTC), last run (links to the job it enqueued)
POST /admin/jobs/{id}/{action}action ∈ retry, cancel, run, delete; anything else 404. Redirect (303) to return_to if it starts with /admin/jobs (no open redirects), else the job page (delete: the list). Responds with X-Jobs-Changed: <n>
POST /admin/jobs/bulkForm: action + ids (checkboxes), or all_matching=1 + the filter fields. "All matching" buttons carry their action in formaction="/admin/jobs/bulk?action=retry", so the handler merges query and form pairs (axum_extra::extract::Form<Vec<(String, String)>>). All matching first renders a confirmation page ("Retry 1,234 jobs?") that posts back with confirmed=1. An empty filter changes nothing
POST /admin/jobs/queues/{name}/{action}pause / resume; anything else 404

Route params are whole segments ({id}/{action}): axum 0.8 rejects {id}.json-style partial-segment params.

Live updates

  • Overview fragments carry hx-get="/admin/jobs?part=stats" hx-trigger="every 2s" hx-swap="outerHTML" (queues and nodes every 2s, the chart every 10s). The list's <tbody id="rows"> polls every 3s, only on the first page, so paging stays stable. Row checkboxes have stable ids plus hx-preserve, so a selection survives refreshes.
  • The header's Live / Paused toggle is a plain link that flips live=0. With live off, fragments render without hx-* attributes. No JS, no trigger filters ([...] needs eval, which the CSP forbids).
  • Polling is cheap: the counts are one GROUP BY and lists are keyset queries.

Back/Forward and caching

Every response of the dashboard router (pages, fragments, redirects) carries Cache-Control: no-store: web::router() ends with .layer(kernel::ui::no_store()) (a SetResponseHeaderLayer::overriding). The kernel layout's htmx config adds historyCacheSize: 0, and static/kernel/admin.js (the kernel pages' only script) reloads a page the browser restores from its back/forward cache:

window.addEventListener("pageshow", (event) => {
  if (event.persisted) window.location.reload();
});

So Back after deleting a job, or after a bulk retry, shows the current truth, never a stale snapshot. The browser smoke (dev-tools) checks it.

The chart

Chart::new(&[Bucket]) builds an inline <svg viewBox="0 0 600 120" preserveAspectRatio="none"> from numbers only, so it's the one sanctioned |safe outside rendered Markdown. Each minute is a <g> with a <title> ("12:04 · 38 completed · 2 failed"), a hover background rect, and stacked bar-ok / bar-err rects. Colors come from classes styled in admin.css (dark mode works); never style= (the CSP forbids inline styles).

Look

admin.css defines tokens (--bg --panel --fg --muted --border --hover --accent --ok --warn --err --info) with a dark palette under prefers-color-scheme: dark. Sticky header with the app name, tabs (Overview · Jobs · Cron, plus Dev · Mailbox · Routes in development) and the live toggle. Stat cards with a state-colored edge, dense tables, monospace ids/args/errors, state badges colored by state, sticky filter bar, and tables that scroll inside their panel at 375px. All kernel responses carry Cache-Control: no-store.

Mounting and access

In routes.rs (the layer, not each handler, enforces access, so no handler can forget it):

if config.is_development() || config.admin_password.is_some() {
    app = app.merge(jobs::web::router().route_layer(
        from_extractor_with_state::<AdminAccess, _>(state.clone()),
    ));
} else {
    tracing::warn!("jobs dashboard disabled: set APP_ADMIN_PASSWORD");
}

AdminAccess lives in controllers/application.rs, because only the app knows its users:

SituationAccess rule
APP_ENV=developmentOpen
Roles exist (ACCOUNTS + ROLES: admin+member)RequireAdmin. Logged-out users are redirected to login (the authentication generator rewrites AdminAccess)
No roles, APP_ADMIN_PASSWORD setHTTP Basic (any user name), constant-time comparison; 401 with WWW-Authenticate: Basic realm="admin". Rate-limit failed passwords (kernel::rate_limit, 10 per 3 min per IP, key admin-basic-auth:<ip>): a request without credentials (the browser's first) doesn't count; once exhausted, answer 429 even for the right password until the window passes. Needs RateLimiter::exhausted (peek without counting) next to check
Production, neitherNot mounted (404). Boot logs the warning above
  • The CSRF layer applies to every POST (a cross-site POST to /admin/jobs/... is 403); same-origin forms pass naturally.
  • The tabs to /dev pages appear only in development.
  • Links from app pages into /admin/jobs carry hx-boost="false" (the app layout boosts; the dashboard has its own <head>). See dev-tools.
  • Args and errors can contain personal data. Don't put secrets in args (jobs rule 1).
  • Tests (tests/app/kernel_web_test.rs + security_test.rs): overview, fragments, list filters/pagination/polling, job page, single and bulk actions (with the confirmation step, the open-redirect check and the flashes), a deleted job's page redirecting to the list, no-store on every response, queue pause/resume, cron page, CSRF; production without an admin → 404; with APP_ADMIN_PASSWORD → 401 without/with a wrong password, 200 with it, and no /dev links.

CLI: app jobs

The same admin functions, for terminals, scripts and agents. Output is an aligned table, or JSON with --json.

app jobs stats [--json]                               # counts per state, queues (limit, nodes, paused / "no nodes"), nodes
app jobs list [--state discarded] [--queue q] [--worker w] [--tag t] [--q text] [--limit 50] [--json]
app jobs show <id> [--json]                           # full row incl. every error, newest first
app jobs retry <id>… | --all [filters] [--yes]        # --all needs at least one filter, prints the count, asks y/N
app jobs cancel <id>… | --all [filters] [--yes]
app jobs delete <id>…
app jobs run <id>…                                    # run now
app jobs pause <queue> / app jobs resume <queue>
app jobs prune [--days N]                             # run the Pruner once (default APP_JOBS_PRUNE_DAYS)

job::Model and JobState derive serde::Serialize (rename_all = "snake_case") for --json. Against production: fly ssh console -C "app jobs list --state discarded" (see deployment). Agents: when a human reports "emails aren't arriving" or "X never happened", run app jobs list --state discarded --json and app jobs show <id> before reading code.