From edef5ae7f51c10044e1a056ea6d21047e6aa3456 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:07:56 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20slow=20job=20detection=20=E2=80=94?= =?UTF-8?q?=20flag=20claimed=20jobs=20exceeding=20configurable=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `SolidQueueWeb.slow_job_threshold` (default nil = disabled). When set, claimed jobs running longer than the threshold are highlighted with a warning row and "slow" badge on the Running tab. A "Slow Jobs" card appears on the dashboard when any jobs exceed the threshold. Co-Authored-By: Claude Sonnet 4.6 --- .../stylesheets/solid_queue_web/_04_table.css | 6 ++- .../solid_queue_web/_05_badges.css | 1 + .../solid_queue_web/dashboard_stats.rb | 5 +- .../solid_queue_web/dashboard/index.html.erb | 14 +++++ app/views/solid_queue_web/jobs/index.html.erb | 15 +++++- lib/solid_queue_web.rb | 7 ++- .../solid_queue_web/dashboard_spec.rb | 32 +++++++++++ spec/requests/solid_queue_web/jobs_spec.rb | 53 +++++++++++++++++++ 8 files changed, 129 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/solid_queue_web/_04_table.css b/app/assets/stylesheets/solid_queue_web/_04_table.css index 8ba8488..8d2c654 100644 --- a/app/assets/stylesheets/solid_queue_web/_04_table.css +++ b/app/assets/stylesheets/solid_queue_web/_04_table.css @@ -49,4 +49,8 @@ tbody tr:hover { background: var(--bg); } text-align: center; padding: 3rem 1rem; color: var(--muted); -} \ No newline at end of file +} + +.sqd-row--slow { background: rgba(253, 126, 20, 0.07); } +.sqd-row--slow:hover { background: rgba(253, 126, 20, 0.13); } +.sqd-slow-duration { color: var(--warning); font-weight: 600; } \ No newline at end of file diff --git a/app/assets/stylesheets/solid_queue_web/_05_badges.css b/app/assets/stylesheets/solid_queue_web/_05_badges.css index 1c5e3fd..dbb8bc4 100644 --- a/app/assets/stylesheets/solid_queue_web/_05_badges.css +++ b/app/assets/stylesheets/solid_queue_web/_05_badges.css @@ -21,6 +21,7 @@ .sqd-badge--supervisor { background: #e0d7f5; color: #4a2c8a; } .sqd-badge--worker { background: #d1e7dd; color: #0f5132; } .sqd-badge--dispatcher { background: #cff4fc; color: #055160; } +.sqd-badge--slow { background: #ffe8cc; color: #7c3d00; } .sqd-process-meta { font-size: 12px; color: var(--muted); } .sqd-process-meta span + span::before { content: " · "; } diff --git a/app/services/solid_queue_web/dashboard_stats.rb b/app/services/solid_queue_web/dashboard_stats.rb index 1621792..7c84e43 100644 --- a/app/services/solid_queue_web/dashboard_stats.rb +++ b/app/services/solid_queue_web/dashboard_stats.rb @@ -1,6 +1,6 @@ module SolidQueueWeb class DashboardStats - attr_reader :counts, :throughput, :sparkline, :depth_sparkline + attr_reader :counts, :throughput, :sparkline, :depth_sparkline, :slow_jobs_count def initialize @now = Time.current @@ -32,6 +32,9 @@ def compute finished_times.count { |t| t >= from && t < to } end + threshold = SolidQueueWeb.slow_job_threshold + @slow_jobs_count = threshold ? SolidQueue::ClaimedExecution.where("created_at <= ?", threshold.ago).count : 0 + job_timestamps = SolidQueue::Job .where("created_at >= ? OR finished_at IS NULL", 72.hours.ago) .pluck(:created_at, :finished_at) diff --git a/app/views/solid_queue_web/dashboard/index.html.erb b/app/views/solid_queue_web/dashboard/index.html.erb index 3b63cbc..616d37c 100644 --- a/app/views/solid_queue_web/dashboard/index.html.erb +++ b/app/views/solid_queue_web/dashboard/index.html.erb @@ -136,6 +136,20 @@ <% end %> + <% if SolidQueueWeb.slow_job_threshold && @stats.slow_jobs_count > 0 %> +
+
+ Slow Jobs +
+
+

+ <%= pluralize(@stats.slow_jobs_count, "job") %> running longer than <%= distance_of_time_in_words(SolidQueueWeb.slow_job_threshold) %>. +

+ <%= link_to "Review →", jobs_path(status: "claimed"), class: "sqd-btn sqd-btn--muted" %> +
+
+ <% end %> + <% if @stats.counts[:blocked] > 0 %>
diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index de7f5ef..d41ae94 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -116,6 +116,7 @@ <% if @jobs.empty? %>
No <%= @status %> jobs.
<% else %> + <% slow_threshold = @status == "claimed" ? SolidQueueWeb.slow_job_threshold : nil %> @@ -124,14 +125,21 @@ + <% if @status == "claimed" %> + + <% end %> <% @jobs.each do |execution| %> <% job = execution.job %> - + <% slow = slow_threshold && execution.created_at <= slow_threshold.ago %> + > + <% if @status == "claimed" %> + + <% end %> <% end %> diff --git a/lib/solid_queue_web.rb b/lib/solid_queue_web.rb index eb643a6..37f1538 100644 --- a/lib/solid_queue_web.rb +++ b/lib/solid_queue_web.rb @@ -4,7 +4,8 @@ module SolidQueueWeb class << self - attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit + attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit, + :slow_job_threshold def page_size @page_size || 25 @@ -22,6 +23,10 @@ def search_results_limit @search_results_limit || 25 end + def slow_job_threshold + @slow_job_threshold + end + def configure yield self end diff --git a/spec/requests/solid_queue_web/dashboard_spec.rb b/spec/requests/solid_queue_web/dashboard_spec.rb index 088ad0d..475e00a 100644 --- a/spec/requests/solid_queue_web/dashboard_spec.rb +++ b/spec/requests/solid_queue_web/dashboard_spec.rb @@ -46,6 +46,38 @@ end end + describe "slow jobs card" do + after { SolidQueueWeb.slow_job_threshold = nil } + + it "shows the slow jobs card when threshold is set and jobs exceed it" do + process = SolidQueue::Process.create!( + kind: "Worker", pid: 99_998, hostname: "test-host", + name: "worker-slow-test", last_heartbeat_at: Time.current + ) + job = SolidQueue::Job.create!( + queue_name: "default", class_name: "SlowJob", + arguments: {}, active_job_id: SecureRandom.uuid + ) + execution = SolidQueue::ClaimedExecution.create!(job: job, process: process) + execution.update_columns(created_at: 10.minutes.ago) + SolidQueueWeb.slow_job_threshold = 1.second + + get "/jobs" + expect(response.body).to include("Slow Jobs") + end + + it "does not show the slow jobs card when threshold is not configured" do + get "/jobs" + expect(response.body).not_to include("Slow Jobs") + end + + it "does not show the slow jobs card when no jobs exceed the threshold" do + SolidQueueWeb.slow_job_threshold = 1.hour + get "/jobs" + expect(response.body).not_to include("Slow Jobs") + end + end + describe "authentication" do after { SolidQueueWeb.instance_variable_set(:@authenticate, nil) } diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 3165857..b18f052 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -269,4 +269,57 @@ expect(response.body).to include("Could not discard jobs") end end + + describe "slow job detection (claimed tab)" do + let!(:worker_process) do + SolidQueue::Process.create!( + kind: "Worker", pid: 99_999, hostname: "test-host", + name: "worker-test", last_heartbeat_at: Time.current + ) + end + + let!(:claimed_job) do + SolidQueue::Job.create!( + queue_name: "default", class_name: "SlowJob", + arguments: {}, active_job_id: SecureRandom.uuid + ) + end + + let!(:claimed_execution) do + SolidQueue::ClaimedExecution.create!(job: claimed_job, process: worker_process) + end + + after { SolidQueueWeb.slow_job_threshold = nil } + + it "shows the Running For column on the claimed tab" do + get "/jobs/list", params: { status: "claimed" } + expect(response.body).to include("Running For") + end + + it "does not show the Running For column on other tabs" do + get "/jobs/list", params: { status: "ready" } + expect(response.body).not_to include("Running For") + end + + it "highlights slow jobs when threshold is configured and exceeded" do + SolidQueueWeb.slow_job_threshold = 1.second + claimed_execution.update_columns(created_at: 10.minutes.ago) + + get "/jobs/list", params: { status: "claimed" } + expect(response.body).to include('class="sqd-row--slow"') + expect(response.body).to include("sqd-badge--slow") + end + + it "does not highlight jobs within the threshold" do + SolidQueueWeb.slow_job_threshold = 1.hour + get "/jobs/list", params: { status: "claimed" } + expect(response.body).not_to include('class="sqd-row--slow"') + end + + it "does not highlight any row when threshold is not configured" do + claimed_execution.update_columns(created_at: 10.hours.ago) + get "/jobs/list", params: { status: "claimed" } + expect(response.body).not_to include('class="sqd-row--slow"') + end + end end From a341feed972a2c763cd9a32968c95f2872ba2f9d Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:10:38 -0400 Subject: [PATCH 2/6] docs: add slow job detection to CHANGELOG and README Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ba8bc..6188fd6 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 - Failure rate sparkline per queue — a mini 12-bar chart in the Queues table shows the percentage of jobs that failed (vs. completed) in each of the last 12 hours; bars are red, sized proportionally to the failure rate (0–100 %), and include a tooltip with the hour label and exact percentage; empty hours render as a faint border-colored bar; queues with no activity in the last 12 hours show "—" - Queue depth trend — a "Queue Depth — Last 12 Hours" card on the dashboard shows estimated queue depth at 12 hourly snapshots; depth at time T is the count of jobs created before T that had not yet finished by T; bars are purple (distinct from the blue throughput and red failure rate charts); the card header shows current depth; empty state shown when no active jobs exist in the window +- Slow job detection — a configurable `slow_job_threshold` setting (default nil = disabled) flags claimed jobs that have been running longer than the threshold; when set, the Running tab gains a "Running For" column showing each job's elapsed time, slow jobs are highlighted with an orange row background and a "slow" badge, and a "Slow Jobs" warning card appears on the dashboard linking to the Running tab ## [0.8.0] - 2026-05-20 diff --git a/README.md b/README.md index 2a8c285..21a35b0 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller - **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 ## Screenshots @@ -96,6 +97,7 @@ SolidQueueWeb.configure do |config| config.dashboard_refresh_interval = 10_000 # dashboard auto-refresh in ms (default: 5_000) config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000) config.search_results_limit = 10 # max results per status in global search (default: 25) + config.slow_job_threshold = 5.minutes # flag claimed jobs running longer than this (default: nil = disabled) end SolidQueueWeb.authenticate do @@ -111,9 +113,6 @@ No authentication is enforced by default. When the `authenticate` block returns Planned features, roughly ordered by priority: -**Observability** -- Slow job detection — flag jobs exceeding a configurable duration threshold - **Operations** - Scheduled job management — reschedule a job to run immediately, or push its `scheduled_at` forward - Bulk retry with delay — retry all failed jobs with a configurable stagger to avoid thundering herd From 5ea6adb1300e38149e1ef0e7af6ee7d9b6501e66 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:12:55 -0400 Subject: [PATCH 3/6] chore: add slow job seed data and dummy app initializer Seeds 3 extra claimed jobs running 8m, 20m, and 45m to demonstrate slow job highlighting; dummy app initializer sets slow_job_threshold to 5 minutes. Co-Authored-By: Claude Sonnet 4.6 --- .../config/initializers/solid_queue_web.rb | 3 +++ spec/dummy/db/seeds.rb | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 spec/dummy/config/initializers/solid_queue_web.rb diff --git a/spec/dummy/config/initializers/solid_queue_web.rb b/spec/dummy/config/initializers/solid_queue_web.rb new file mode 100644 index 0000000..a2e500a --- /dev/null +++ b/spec/dummy/config/initializers/solid_queue_web.rb @@ -0,0 +1,3 @@ +SolidQueueWeb.configure do |config| + config.slow_job_threshold = 5.minutes +end \ No newline at end of file diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index cf12aa4..9bb25f4 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -71,13 +71,30 @@ arguments: { batch: i + 1 }.to_json, priority: 0, active_job_id: SecureRandom.uuid, - created_at: rand(1..10).minutes.ago, - updated_at: rand(1..10).minutes.ago + created_at: rand(1..4).minutes.ago, + updated_at: rand(1..4).minutes.ago ) job.ready_execution&.destroy SolidQueue::ClaimedExecution.create!(job: job, process: workers.sample, created_at: job.created_at) end +puts "Seeding slow claimed jobs (for slow job detection)..." +# These exceed a 5-minute threshold — visible as orange rows when slow_job_threshold is configured +slow_durations = [8.minutes, 20.minutes, 45.minutes] +slow_durations.each_with_index do |duration, i| + job = SolidQueue::Job.create!( + queue_name: queues.sample, + class_name: job_classes.sample, + arguments: { slow_job: true, idx: i }.to_json, + priority: 0, + active_job_id: SecureRandom.uuid, + created_at: duration.ago, + updated_at: duration.ago + ) + job.ready_execution&.destroy + SolidQueue::ClaimedExecution.create!(job: job, process: workers.sample, created_at: duration.ago) +end + puts "Seeding failed jobs..." errors = [ { class: "RuntimeError", message: "undefined method `foo' for nil:NilClass" }, From 1b284144b42ac0a14a6268141775b092cbaca1bc Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:28:02 -0400 Subject: [PATCH 4/6] chore: remove underline from job class links and fix dark mode link color Adds sqd-table-link class to all job class links across jobs, failed jobs, history, queue jobs, and search views; sets text-decoration none and color: var(--primary) so the link color follows the dark mode theme instead of using the browser default bright blue. Co-Authored-By: Claude Sonnet 4.6 --- app/assets/stylesheets/solid_queue_web/_04_table.css | 3 +++ app/views/solid_queue_web/failed_jobs/index.html.erb | 2 +- app/views/solid_queue_web/history/index.html.erb | 2 +- app/views/solid_queue_web/jobs/index.html.erb | 4 ++-- app/views/solid_queue_web/queues/jobs/index.html.erb | 2 +- app/views/solid_queue_web/search/index.html.erb | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/solid_queue_web/_04_table.css b/app/assets/stylesheets/solid_queue_web/_04_table.css index 8d2c654..f54c226 100644 --- a/app/assets/stylesheets/solid_queue_web/_04_table.css +++ b/app/assets/stylesheets/solid_queue_web/_04_table.css @@ -44,6 +44,9 @@ td { tr:last-child td { border-bottom: none; } tbody tr:hover { background: var(--bg); } +.sqd-table-link, +.sqd-table-link:hover, +.sqd-table-link:visited { text-decoration: none; color: var(--primary); } .sqd-empty { text-align: center; diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb index d44f63b..232b981 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -92,7 +92,7 @@ data-action="change->selection#toggle" aria-label="Select job <%= job.class_name %>"> - + <% @jobs.each do |job| %> - + - + From a8df2c4149e7a859c536f4297e121c854582b88d Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:30:39 -0400 Subject: [PATCH 5/6] chore: fix trailing newline in dummy initializer Co-Authored-By: Claude Sonnet 4.6 --- spec/dummy/config/initializers/solid_queue_web.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/dummy/config/initializers/solid_queue_web.rb b/spec/dummy/config/initializers/solid_queue_web.rb index a2e500a..66e6d17 100644 --- a/spec/dummy/config/initializers/solid_queue_web.rb +++ b/spec/dummy/config/initializers/solid_queue_web.rb @@ -1,3 +1,3 @@ SolidQueueWeb.configure do |config| config.slow_job_threshold = 5.minutes -end \ No newline at end of file +end From 3459829c853a0873fa70b34223cd11f40d4a80a0 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 18:32:29 -0400 Subject: [PATCH 6/6] fix: scope dummy initializer to development env only The slow_job_threshold config was being set in the test environment, causing the "no threshold configured" spec to fail on CI. Co-Authored-By: Claude Sonnet 4.6 --- spec/dummy/config/initializers/solid_queue_web.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/dummy/config/initializers/solid_queue_web.rb b/spec/dummy/config/initializers/solid_queue_web.rb index 66e6d17..f0c2b03 100644 --- a/spec/dummy/config/initializers/solid_queue_web.rb +++ b/spec/dummy/config/initializers/solid_queue_web.rb @@ -1,3 +1,5 @@ -SolidQueueWeb.configure do |config| - config.slow_job_threshold = 5.minutes +if Rails.env.development? + SolidQueueWeb.configure do |config| + config.slow_job_threshold = 5.minutes + end end
Priority Scheduled At Enqueued AtRunning For
<%= @status %> + <% if slow %> + slow + <% end %> <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> @@ -143,6 +151,11 @@ <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %> <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %>"> + <%= time_ago_in_words(execution.created_at) %> +
<%= link_to job.class_name, job_path(job) %><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %> <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period), class: "sqd-mono", style: "color: inherit;" %> diff --git a/app/views/solid_queue_web/history/index.html.erb b/app/views/solid_queue_web/history/index.html.erb index 25049ce..553f146 100644 --- a/app/views/solid_queue_web/history/index.html.erb +++ b/app/views/solid_queue_web/history/index.html.erb @@ -49,7 +49,7 @@
<%= link_to job.class_name, job_path(job) %><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %> <%= link_to job.queue_name, history_path(queue: job.queue_name, q: @search, period: @period), class: "sqd-mono", style: "color: inherit;" %> diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index d41ae94..e8e1967 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -87,7 +87,7 @@ <%= @status %> - <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> + <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status), @@ -140,7 +140,7 @@ <% if slow %> slow <% end %> - <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> + <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status), diff --git a/app/views/solid_queue_web/queues/jobs/index.html.erb b/app/views/solid_queue_web/queues/jobs/index.html.erb index f7690fe..0fd38b9 100644 --- a/app/views/solid_queue_web/queues/jobs/index.html.erb +++ b/app/views/solid_queue_web/queues/jobs/index.html.erb @@ -60,7 +60,7 @@
<%= @status %> - <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> + <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> <%= job.priority %> diff --git a/app/views/solid_queue_web/search/index.html.erb b/app/views/solid_queue_web/search/index.html.erb index 2dfe8fb..617e722 100644 --- a/app/views/solid_queue_web/search/index.html.erb +++ b/app/views/solid_queue_web/search/index.html.erb @@ -50,7 +50,7 @@ <% data[:executions].each do |execution| %> <% job = execution.job %>
<%= link_to job.class_name, job_path(job) %><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %> <%= job.queue_name %> <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %>