diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd1bd..3159558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Slow job webhook alert — set `alert_slow_job_count_threshold` (integer) to fire a webhook whenever the number of currently-running slow jobs meets or exceeds the configured count; requires `slow_job_threshold` to define what "slow" means; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings as other alert types; event name `slow_job_threshold_exceeded` - Stale process webhook alert — set `alert_stale_process_threshold` (integer) to fire a webhook whenever the number of stale workers meets or exceeds the configured count; a process is stale when its heartbeat has not been updated within `SolidQueue.process_alive_threshold`; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings; event name `stale_process_detected` +- Job wait time column — the Running tab now shows a "Wait Time" column displaying how long each claimed job waited in the queue from enqueue to pickup; also included as `wait_time_seconds` in the CSV export for the claimed status ## [1.3.0] - 2026-05-27 diff --git a/README.md b/README.md index 43e83fa..4316116 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy - **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view - **Slow job detection** — when `slow_job_threshold` is configured, claimed jobs running longer than the threshold are flagged with an orange row, a "slow" badge, and a "Running For" duration column on the Running tab; a "Slow Jobs" warning card appears on the dashboard with a link to the Running tab +- **Job wait time** — the Running tab shows a "Wait Time" column with how long each job waited in the queue from enqueue to pickup; also exported as `wait_time_seconds` in the claimed-status CSV - **Webhook alerts** — set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold; set `alert_queue_thresholds` for per-queue depth alerts; set `alert_slow_job_count_threshold` (requires `slow_job_threshold`) for slow-job count alerts; set `alert_stale_process_threshold` for stale-worker alerts; all fire asynchronously with a configurable cooldown (default 1 h) to prevent repeated alerts - **Performance analytics** — per-job-class statistics at `/jobs/performance` showing run count, average, p50, p95, p99, standard deviation, min, and max duration; sorted by p95 descending so the slowest classes surface first; high std dev surfaces inconsistent jobs worth investigating; period filter scopes to 1h / 24h / 7d or all time; each class name links to the filtered History view - **Failed job trend chart** — a "Failures — Last 12 Hours" bar chart on the dashboard shows failures per hour over the last 12 hours; bars are red, making failure spikes visible before clicking into the failed jobs list diff --git a/ROADMAP.md b/ROADMAP.md index aad32c4..b2fdc59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib |---|---| | ~~**Slow job webhook alert**~~ | ✅ Shipped in v1.4 — `alert_slow_job_count_threshold` fires when slow-job count meets or exceeds the threshold. | | ~~**Process stale webhook alert**~~ | ✅ Shipped in v1.4 — `alert_stale_process_threshold` fires when stale worker count meets or exceeds the threshold. | -| **Job wait time column** | Show time from `enqueued_at` to `created_at` on claimed executions — a direct measure of queue SLA. | +| ~~**Job wait time column**~~ | ✅ Shipped in v1.4 — "Wait Time" column on the Running tab; `wait_time_seconds` in the claimed CSV export. | --- diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 1a3a635..78eddd5 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -82,10 +82,14 @@ def sort_expression def jobs_csv(scope) CSV.generate(headers: true) do |csv| - csv << %w[id class_name queue_name status priority enqueued_at] + headers = %w[id class_name queue_name status priority enqueued_at] + headers << "wait_time_seconds" if @status == "claimed" + csv << headers scope.each do |execution| job = execution.job - csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601] + row = [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601] + row << (execution.created_at - job.created_at).to_i if @status == "claimed" + csv << row end end end diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index 35b2597..b75ba39 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -164,6 +164,7 @@ <%= sort_header_th("Enqueued At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %> <% if @status == "claimed" %> Running For + Wait Time <% end %> @@ -192,6 +193,9 @@ "> <%= time_ago_in_words(execution.created_at) %> + + <%= format_duration(execution.created_at - job.created_at) %> + <% end %> <% end %> diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 04557a0..77c0295 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -353,6 +353,56 @@ end end + describe "job wait time column (claimed tab)" do + let!(:worker_process) do + SolidQueue::Process.create!( + kind: "Worker", pid: 88_888, hostname: "test-host", + name: "worker-wait-test", last_heartbeat_at: Time.current + ) + end + + let!(:claimed_job) do + SolidQueue::Job.create!( + queue_name: "default", class_name: "WaitTestJob", + arguments: {}, active_job_id: SecureRandom.uuid + ) + end + + let!(:claimed_execution) do + execution = SolidQueue::ClaimedExecution.create!(job: claimed_job, process: worker_process) + claimed_job.update_columns(created_at: 5.minutes.ago) + execution.update_columns(created_at: 2.minutes.ago) + execution + end + + it "shows the Wait Time column header on the claimed tab" do + get "/jobs/list", params: { status: "claimed" } + expect(response.body).to include("Wait Time") + end + + it "does not show the Wait Time column on other tabs" do + get "/jobs/list", params: { status: "ready" } + expect(response.body).not_to include("Wait Time") + end + + it "renders a formatted wait time for each claimed job" do + get "/jobs/list", params: { status: "claimed" } + expect(response.body).to include("3m") + end + + it "includes wait_time_seconds in the claimed CSV export" do + get "/jobs/list.csv", params: { status: "claimed" } + expect(response.body).to include("wait_time_seconds") + row = response.body.lines.last + expect(row.split(",").last.strip.to_i).to be_within(5).of(180) + end + + it "does not include wait_time_seconds in CSV for other statuses" do + get "/jobs/list.csv", params: { status: "ready" } + expect(response.body).not_to include("wait_time_seconds") + end + end + describe "GET /jobs/list?priority= (priority filter)" do let!(:high_priority_job) do SolidQueue::Job.create!(