diff --git a/.rubocop.yml b/.rubocop.yml
index dd24941..9e3df3a 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -24,6 +24,10 @@ Metrics/ClassLength:
Max: 500
Exclude:
- 'app/services/solid_queue_monitor/stylesheet_generator.rb'
+ - 'app/presenters/solid_queue_monitor/job_details_presenter.rb'
+
+Metrics/ParameterLists:
+ Max: 7
Metrics/ModuleLength:
Max: 200
@@ -66,6 +70,9 @@ RSpec/NamedSubject:
RSpec/LetSetup:
Enabled: false
+RSpec/MultipleMemoizedHelpers:
+ Max: 10
+
Capybara/RSpec/PredicateMatcher:
Enabled: false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f06d23f..592db5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## [1.0.0] - 2026-01-23
+
+### Added
+
+- **Worker Monitoring** - New dedicated workers page showing all Solid Queue processes
+ - Real-time view of workers, dispatchers, and schedulers
+ - Health status indicators (healthy, stale, dead) based on heartbeat
+ - Shows queues each worker is processing
+ - Displays jobs currently being processed by each worker
+ - Summary cards showing total, healthy, stale, and dead process counts
+- **Dead Process Detection** - Identify and clean up zombie processes
+ - Visual highlighting for stale (>5 min) and dead (>10 min) processes
+ - "Prune Dead Processes" button to remove defunct process records
+ - Automatic detection based on last heartbeat timestamp
+- **Job Details Page** - Dedicated page for viewing complete job information
+ - Full job timeline showing created, scheduled, started, and finished states
+ - Timing breakdown with wait time and execution duration
+ - Complete error details with backtrace for failed jobs
+ - Job arguments displayed in formatted JSON
+ - Quick actions (retry/discard) for failed jobs
+ - Clickable job class names throughout the UI link to details page
+- **Queue Details Page** - Detailed view for individual queues
+ - Shows all jobs in a specific queue
+ - Displays queue status (active/paused) with pause/resume controls
+ - Job counts and filtering options
+
+### Changed
+
+- Updated ROADMAP to reflect v1.0.0 milestone completion
+- All high-priority features from roadmap are now complete
+
## [0.6.0] - 2026-01-20
### Added
diff --git a/Gemfile.lock b/Gemfile.lock
index b34a7a0..54c769c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,36 +1,38 @@
PATH
remote: .
specs:
- solid_queue_monitor (0.6.0)
+ solid_queue_monitor (1.0.0)
rails (>= 7.0)
solid_queue (>= 0.1.0)
GEM
remote: https://rubygems.org/
specs:
- actioncable (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ action_text-trix (2.1.16)
+ railties
+ actioncable (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailbox (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
- actionmailer (8.0.2)
- actionpack (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailer (8.1.2)
+ actionpack (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.0.2)
- actionview (= 8.0.2)
- activesupport (= 8.0.2)
+ actionpack (8.1.2)
+ actionview (= 8.1.2)
+ activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -38,95 +40,98 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.0.2)
- actionpack (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actiontext (8.1.2)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.0.2)
- activesupport (= 8.0.2)
+ actionview (8.1.2)
+ activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (8.0.2)
- activesupport (= 8.0.2)
+ activejob (8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.3.6)
- activemodel (8.0.2)
- activesupport (= 8.0.2)
- activerecord (8.0.2)
- activemodel (= 8.0.2)
- activesupport (= 8.0.2)
+ activemodel (8.1.2)
+ activesupport (= 8.1.2)
+ activerecord (8.1.2)
+ activemodel (= 8.1.2)
+ activesupport (= 8.1.2)
timeout (>= 0.4.0)
- activestorage (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activesupport (= 8.0.2)
+ activestorage (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activesupport (= 8.1.2)
marcel (~> 1.0)
- activesupport (8.0.2)
+ activesupport (8.1.2)
base64
- benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
+ json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.2)
- base64 (0.2.0)
- benchmark (0.4.0)
- bigdecimal (3.1.9)
+ base64 (0.3.0)
+ bigdecimal (4.0.1)
builder (3.3.0)
- concurrent-ruby (1.3.5)
- connection_pool (2.5.0)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
crass (1.0.6)
- date (3.4.1)
+ date (3.5.1)
diff-lcs (1.6.0)
- drb (2.2.1)
+ drb (2.2.3)
+ erb (6.0.1)
erubi (1.13.1)
- et-orbi (1.2.11)
+ et-orbi (1.4.0)
tzinfo
factory_bot (6.5.1)
activesupport (>= 6.1.0)
factory_bot_rails (6.4.4)
factory_bot (~> 6.5)
railties (>= 5.0.0)
- fugit (1.11.1)
- et-orbi (~> 1, >= 1.2.11)
+ fugit (1.12.1)
+ et-orbi (~> 1.4)
raabro (~> 1.4)
- globalid (1.2.1)
+ globalid (1.3.0)
activesupport (>= 6.1)
- i18n (1.14.7)
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
- io-console (0.8.0)
- irb (1.15.1)
+ io-console (0.8.2)
+ irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.10.2)
language_server-protocol (3.17.0.4)
lint_roller (1.1.0)
- logger (1.6.6)
- loofah (2.24.0)
+ logger (1.7.0)
+ loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.8.1)
+ mail (2.9.0)
+ logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
- marcel (1.0.4)
+ marcel (1.1.0)
mini_mime (1.1.5)
- mini_portile2 (2.8.8)
- minitest (5.25.5)
- net-imap (0.5.6)
+ mini_portile2 (2.8.9)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -135,71 +140,75 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
- nio4r (2.7.4)
- nokogiri (1.18.4)
+ nio4r (2.7.5)
+ nokogiri (1.19.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.18.4-arm64-darwin)
+ nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
parallel (1.26.3)
parser (3.3.7.1)
ast (~> 2.4.1)
racc
- pp (0.6.2)
+ pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- psych (5.2.3)
+ prism (1.8.0)
+ psych (5.3.1)
date
stringio
raabro (1.4.0)
racc (1.8.1)
- rack (3.1.12)
- rack-session (2.1.0)
+ rack (3.2.4)
+ rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
- rackup (2.2.1)
+ rackup (2.3.1)
rack (>= 3)
- rails (8.0.2)
- actioncable (= 8.0.2)
- actionmailbox (= 8.0.2)
- actionmailer (= 8.0.2)
- actionpack (= 8.0.2)
- actiontext (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activemodel (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ rails (8.1.2)
+ actioncable (= 8.1.2)
+ actionmailbox (= 8.1.2)
+ actionmailer (= 8.1.2)
+ actionpack (= 8.1.2)
+ actiontext (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activemodel (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
bundler (>= 1.15.0)
- railties (= 8.0.2)
+ railties (= 8.1.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
- rails-dom-testing (2.2.0)
+ rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ railties (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
- rake (13.2.1)
- rdoc (6.12.0)
+ rake (13.3.1)
+ rdoc (7.1.0)
+ erb
psych (>= 4.0.0)
+ tsort
regexp_parser (2.10.0)
- reline (0.6.0)
+ reline (0.6.3)
io-console (~> 0.5)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
@@ -252,31 +261,32 @@ GEM
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
- solid_queue (1.1.3)
+ solid_queue (1.3.1)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
- fugit (~> 1.11.0)
+ fugit (~> 1.11)
railties (>= 7.1)
- thor (~> 1.3.1)
+ thor (>= 1.3.1)
sqlite3 (2.9.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.9.0-arm64-darwin)
- stringio (3.1.5)
- thor (1.3.2)
- timeout (0.4.3)
+ stringio (3.2.0)
+ thor (1.5.0)
+ timeout (0.6.0)
+ tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
- uri (1.0.3)
+ uri (1.1.1)
useragent (0.16.11)
- websocket-driver (0.7.7)
+ websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
- zeitwerk (2.7.2)
+ zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-24
diff --git a/README.md b/README.md
index 6510783..ce34122 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,16 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
- **Dashboard Overview**: Get a quick snapshot of your queue's health with statistics on all job types
- **Job Activity Chart**: Visual line chart showing jobs created, completed, and failed over time with 9 time range options (15m to 1 week)
- **Dark Theme**: Toggle between light and dark themes with system preference detection and localStorage persistence
+- **Worker Monitoring**: Real-time view of all Solid Queue processes (workers, dispatchers, schedulers)
+ - Health status indicators (healthy, stale, dead) based on heartbeat
+ - Shows queues each worker is processing and jobs currently being executed
+ - Prune dead processes with one click
+- **Job Details Page**: Dedicated page for viewing complete job information
+ - Full job timeline showing created, scheduled, started, and finished states
+ - Timing breakdown with queue wait time and execution duration
+ - Complete error details with backtrace for failed jobs
+ - Job arguments displayed in formatted JSON
+- **Queue Details Page**: Detailed view for individual queues with job counts and filtering
- **Ready Jobs**: View jobs that are ready to be executed
- **In Progress Jobs**: Monitor jobs currently being processed by workers
- **Scheduled Jobs**: See upcoming jobs scheduled for future execution with ability to execute immediately or reject permanently
@@ -43,16 +53,24 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou

+### Worker Monitoring
+
+
+
+### Queue Management
+
+
+
### Failed Jobs
-
+
## Installation
Add this line to your application's Gemfile:
```ruby
-gem 'solid_queue_monitor', '~> 0.6.0'
+gem 'solid_queue_monitor', '~> 1.0'
```
Then execute:
@@ -116,12 +134,15 @@ After installation, visit `/solid_queue` in your browser to access the dashboard
The dashboard provides several views:
-- **Overview**: Shows statistics and recent jobs
+- **Overview**: Shows statistics, recent jobs, and job activity chart
- **Ready Jobs**: Jobs that are ready to be executed
- **Scheduled Jobs**: Jobs scheduled for future execution with execute and reject actions
- **Recurring Jobs**: Jobs that run on a recurring schedule
- **Failed Jobs**: Jobs that have failed with error details and retry/discard actions
-- **Queues**: Distribution of jobs across different queues
+- **Queues**: Distribution of jobs across different queues with pause/resume controls
+- **Workers**: Real-time monitoring of all Solid Queue processes with health status
+
+Click on any job class name to view detailed information including timeline, timing breakdown, arguments, and error details (for failed jobs).
### API-only Applications
@@ -141,9 +162,11 @@ This makes it easy to find specific jobs when debugging issues in your applicati
## Use Cases
- **Production Monitoring**: Keep an eye on your background job processing in production environments
-- **Debugging**: Quickly identify and troubleshoot failed jobs
+- **Worker Health Monitoring**: Track the health of your Solid Queue processes and identify dead workers
+- **Debugging**: Quickly identify and troubleshoot failed jobs with detailed error information and backtraces
- **Job Management**: Execute scheduled jobs on demand or reject unwanted jobs permanently
-- **Performance Analysis**: Track job distribution and identify bottlenecks
+- **Incident Response**: Pause queues during incidents to prevent job processing while investigating issues
+- **Performance Analysis**: Track job distribution, timing metrics, and identify bottlenecks
- **DevOps Integration**: Easily integrate with your monitoring stack
## Compatibility
diff --git a/ROADMAP.md b/ROADMAP.md
index 454cdf1..649cd50 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,31 +2,32 @@
This document tracks planned features for solid_queue_monitor, comparing with other solutions like `solid-queue-dashboard` and `mission_control-jobs`.
-## High Priority - Core Functionality Gaps
+## Target: v1.0.0 (Stable Release)
+
+### High Priority - Core Functionality Gaps
| Feature | solid-queue-dashboard | mission_control-jobs | Impact | Status |
|---------|:---------------------:|:--------------------:|--------|:------:|
| Auto-refresh | ✓ | - | High - Real-time monitoring essential for ops | ✅ Done (v0.4.0) |
-| Charts/Visualizations | ✓ | - | High - Visual trends are compelling | ⬚ Planned |
+| Charts/Visualizations | ✓ | - | High - Visual trends are compelling | ✅ Done (v0.6.0) |
| Pause/Unpause Queues | - | ✓ | High - Critical for production incident response | ✅ Done (v0.5.0) |
-| Worker Monitoring | - | ✓ | High - See which workers are processing what | ⬚ Planned |
-| Dead Process Detection | ✓ | - | High - Identify stuck/zombie processes | ⬚ Planned |
-| Execution History | ✓ | - | Medium - Job audit trail | ⬚ Planned |
-| Failure Rate Tracking | ✓ | - | Medium - Trends over time | ⬚ Planned |
+| Dark Mode Toggle | - | - | High - User preference for theme | ✅ Done (v0.6.0) |
+| Worker Monitoring | - | ✓ | High - See which workers are processing what | ✅ Done |
+| Dead Process Detection | ✓ | - | High - Identify stuck/zombie processes | ✅ Done |
-## Medium Priority - Power Features
+### Medium Priority - Power Features
| Feature | Description | Status |
|---------|-------------|:------:|
+| Job Details Page | Dedicated page for single job with full context | ✅ Done |
+| Search/Full-text Search | Better search across all job data | ⬚ Planned |
+| Sorting Options | Sort by various columns | ⬚ Planned |
| Sensitive Argument Masking | Filter passwords/tokens from job arguments display | ⬚ Planned |
| Backtrace Cleaner | Remove framework noise from error backtraces | ⬚ Planned |
| Manual Job Triggering | Enqueue a job directly from the dashboard | ⬚ Planned |
| Cancel Running Jobs | Stop long-running jobs | ⬚ Planned |
-| Search/Full-text Search | Better search across all job data | ⬚ Planned |
-| Sorting Options | Sort by various columns | ⬚ Planned |
-| Job Details Page | Dedicated page for single job with full context | ⬚ Planned |
-## Lower Priority - Enterprise Features
+### Lower Priority - Enterprise Features (Post 1.0)
| Feature | Description | Status |
|---------|-------------|:------:|
@@ -37,7 +38,19 @@ This document tracks planned features for solid_queue_monitor, comparing with ot
| Export Jobs (CSV/JSON) | Download job data for analysis | ⬚ Planned |
| Webhooks/Notifications | Alert on failures via Slack/email | ⬚ Planned |
| API Endpoints (JSON) | Return JSON for custom integrations | ⬚ Planned |
-| Dark Mode Toggle | User preference for theme | ⬚ Planned |
+
+---
+
+## Suggested v1.0.0 Scope
+
+For a stable 1.0.0 release, all high-priority features have been completed:
+
+1. ~~**Dead Process Detection** - Prune button for stale/dead workers~~ ✅
+2. ~~**Worker Monitoring** - See which workers are processing what~~ ✅
+3. ~~**Charts/Visualizations** - Visual trends for job activity~~ ✅
+
+Optional but valuable for 1.0.0:
+- **Sorting Options** - Click column headers to sort
---
diff --git a/app/controllers/solid_queue_monitor/jobs_controller.rb b/app/controllers/solid_queue_monitor/jobs_controller.rb
new file mode 100644
index 0000000..55a6d55
--- /dev/null
+++ b/app/controllers/solid_queue_monitor/jobs_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module SolidQueueMonitor
+ class JobsController < BaseController
+ def show
+ @job = SolidQueue::Job.find_by(id: params[:id])
+
+ unless @job
+ set_flash_message('Job not found.', 'error')
+ redirect_to root_path
+ return
+ end
+
+ job_data = load_job_data(@job)
+
+ render_page("Job ##{@job.id}", SolidQueueMonitor::JobDetailsPresenter.new(
+ @job,
+ **job_data
+ ).render)
+ end
+
+ private
+
+ def load_job_data(job)
+ {
+ failed_execution: SolidQueue::FailedExecution.find_by(job_id: job.id),
+ claimed_execution: load_claimed_execution(job),
+ scheduled_execution: SolidQueue::ScheduledExecution.find_by(job_id: job.id),
+ recent_executions: load_recent_executions(job),
+ back_path: determine_back_path
+ }
+ end
+
+ def load_claimed_execution(job)
+ claimed = SolidQueue::ClaimedExecution.find_by(job_id: job.id)
+ return nil unless claimed
+
+ # Preload process info
+ claimed.instance_variable_set(:@process, SolidQueue::Process.find_by(id: claimed.process_id))
+ claimed
+ end
+
+ def load_recent_executions(job)
+ SolidQueue::Job
+ .where(class_name: job.class_name)
+ .where.not(id: job.id)
+ .order(created_at: :desc)
+ .limit(10)
+ .includes(:failed_execution, :claimed_execution, :ready_execution, :scheduled_execution)
+ end
+
+ def determine_back_path
+ referer = request.referer
+ return root_path unless referer
+
+ # Extract path from referer
+ uri = URI.parse(referer)
+ path = uri.path
+
+ # Return referer if it's within the engine
+ if path.include?('/failed_jobs') || path.include?('/ready_jobs') ||
+ path.include?('/scheduled_jobs') || path.include?('/in_progress_jobs') ||
+ path.include?('/recurring_jobs')
+ referer
+ else
+ root_path
+ end
+ rescue URI::InvalidURIError
+ root_path
+ end
+ end
+end
diff --git a/app/controllers/solid_queue_monitor/queues_controller.rb b/app/controllers/solid_queue_monitor/queues_controller.rb
index d298ffe..f9ad5c6 100644
--- a/app/controllers/solid_queue_monitor/queues_controller.rb
+++ b/app/controllers/solid_queue_monitor/queues_controller.rb
@@ -11,12 +11,36 @@ def index
render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues, @paused_queues).render)
end
+ def show
+ @queue_name = params[:queue_name]
+ @paused = QueuePauseService.paused_queues.include?(@queue_name)
+
+ # Get all jobs for this queue with filtering and pagination
+ base_query = SolidQueue::Job.where(queue_name: @queue_name).order(created_at: :desc)
+ filtered_query = filter_queue_jobs(base_query)
+ @jobs = paginate(filtered_query)
+ preload_job_statuses(@jobs[:records])
+
+ @counts = calculate_queue_counts(@queue_name)
+
+ render_page("Queue: #{@queue_name}",
+ SolidQueueMonitor::QueueDetailsPresenter.new(
+ queue_name: @queue_name,
+ paused: @paused,
+ jobs: @jobs[:records],
+ counts: @counts,
+ current_page: @jobs[:current_page],
+ total_pages: @jobs[:total_pages],
+ filters: queue_filter_params
+ ).render)
+ end
+
def pause
queue_name = params[:queue_name]
result = QueuePauseService.new(queue_name).pause
set_flash_message(result[:message], result[:success] ? 'success' : 'error')
- redirect_to queues_path
+ redirect_to params[:redirect_to] || queues_path
end
def resume
@@ -24,7 +48,54 @@ def resume
result = QueuePauseService.new(queue_name).resume
set_flash_message(result[:message], result[:success] ? 'success' : 'error')
- redirect_to queues_path
+ redirect_to params[:redirect_to] || queues_path
+ end
+
+ private
+
+ def calculate_queue_counts(queue_name)
+ {
+ total: SolidQueue::Job.where(queue_name: queue_name).count,
+ ready: SolidQueue::ReadyExecution.where(queue_name: queue_name).count,
+ scheduled: SolidQueue::ScheduledExecution.where(queue_name: queue_name).count,
+ in_progress: SolidQueue::ClaimedExecution.joins(:job).where(solid_queue_jobs: { queue_name: queue_name }).count,
+ failed: SolidQueue::FailedExecution.joins(:job).where(solid_queue_jobs: { queue_name: queue_name }).count,
+ completed: SolidQueue::Job.where(queue_name: queue_name).where.not(finished_at: nil).count
+ }
+ end
+
+ def filter_queue_jobs(relation)
+ relation = relation.where('class_name LIKE ?', "%#{params[:class_name]}%") if params[:class_name].present?
+ relation = filter_by_arguments(relation) if params[:arguments].present?
+
+ if params[:status].present?
+ case params[:status]
+ when 'completed'
+ relation = relation.where.not(finished_at: nil)
+ when 'failed'
+ failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
+ relation = relation.where(id: failed_job_ids)
+ when 'scheduled'
+ scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id)
+ relation = relation.where(id: scheduled_job_ids)
+ when 'pending'
+ ready_job_ids = SolidQueue::ReadyExecution.pluck(:job_id)
+ relation = relation.where(id: ready_job_ids)
+ when 'in_progress'
+ claimed_job_ids = SolidQueue::ClaimedExecution.pluck(:job_id)
+ relation = relation.where(id: claimed_job_ids)
+ end
+ end
+
+ relation
+ end
+
+ def queue_filter_params
+ {
+ class_name: params[:class_name],
+ arguments: params[:arguments],
+ status: params[:status]
+ }
end
end
end
diff --git a/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb b/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb
index f7280b8..7409476 100644
--- a/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb
+++ b/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb
@@ -22,6 +22,15 @@ def create
redirect_to scheduled_jobs_path
end
+ def execute
+ SolidQueueMonitor::ExecuteJobService.new.call(params[:id])
+ set_flash_message('Job moved to ready queue', 'success')
+ redirect_to params[:redirect_to] || scheduled_jobs_path
+ rescue ActiveRecord::RecordNotFound
+ set_flash_message('Job not found', 'error')
+ redirect_to scheduled_jobs_path
+ end
+
def reject_all
result = SolidQueueMonitor::RejectJobService.new.reject_many(params[:job_ids])
diff --git a/app/controllers/solid_queue_monitor/workers_controller.rb b/app/controllers/solid_queue_monitor/workers_controller.rb
new file mode 100644
index 0000000..7fe0040
--- /dev/null
+++ b/app/controllers/solid_queue_monitor/workers_controller.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module SolidQueueMonitor
+ class WorkersController < BaseController
+ def index
+ base_query = SolidQueue::Process.order(created_at: :desc)
+ filtered_query = filter_workers(base_query)
+ @processes = paginate(filtered_query)
+
+ render_page('Workers', SolidQueueMonitor::WorkersPresenter.new(
+ @processes[:records],
+ current_page: @processes[:current_page],
+ total_pages: @processes[:total_pages],
+ filters: worker_filter_params
+ ).render)
+ end
+
+ def remove
+ process = SolidQueue::Process.find_by(id: params[:id])
+
+ if process
+ process.destroy
+ set_flash_message('Process removed successfully.', 'success')
+ else
+ set_flash_message('Process not found.', 'error')
+ end
+
+ redirect_to workers_path
+ end
+
+ def prune
+ dead_threshold = 10.minutes.ago
+ dead_processes = SolidQueue::Process.where(last_heartbeat_at: ..dead_threshold)
+ count = dead_processes.count
+
+ if count.positive?
+ dead_processes.destroy_all
+ set_flash_message("Successfully removed #{count} dead process#{'es' if count > 1}.", 'success')
+ else
+ set_flash_message('No dead processes to remove.', 'success')
+ end
+
+ redirect_to workers_path
+ end
+
+ private
+
+ def filter_workers(relation)
+ relation = relation.where(kind: params[:kind]) if params[:kind].present?
+ relation = relation.where('hostname LIKE ?', "%#{params[:hostname]}%") if params[:hostname].present?
+
+ if params[:status].present?
+ case params[:status]
+ when 'healthy'
+ relation = relation.where('last_heartbeat_at > ?', 5.minutes.ago)
+ when 'stale'
+ relation = relation.where('last_heartbeat_at <= ? AND last_heartbeat_at > ?', 5.minutes.ago, 10.minutes.ago)
+ when 'dead'
+ relation = relation.where(last_heartbeat_at: ..10.minutes.ago)
+ end
+ end
+
+ relation
+ end
+
+ def worker_filter_params
+ {
+ kind: params[:kind],
+ hostname: params[:hostname],
+ status: params[:status]
+ }
+ end
+ end
+end
diff --git a/app/presenters/solid_queue_monitor/base_presenter.rb b/app/presenters/solid_queue_monitor/base_presenter.rb
index 2723dc2..83d06c5 100644
--- a/app/presenters/solid_queue_monitor/base_presenter.rb
+++ b/app/presenters/solid_queue_monitor/base_presenter.rb
@@ -111,6 +111,13 @@ def format_hash(hash)
"#{formatted}"
end
+ def queue_link(queue_name, css_class: nil)
+ return '-' if queue_name.blank?
+
+ classes = ['queue-link', css_class].compact.join(' ')
+ "#{queue_name}"
+ end
+
def request_path
if defined?(controller) && controller.respond_to?(:request)
controller.request.path
diff --git a/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb b/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb
index 13a77bb..1b24e36 100644
--- a/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb
+++ b/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb
@@ -251,23 +251,19 @@ def generate_row(failed_execution)
#{error[:backtrace]}
- #{format_backtrace_lines(app_lines.presence || lines.first(5))}
+
+ #{CGI.escapeHTML(formatted_args)}
+ | Status | +Arguments | +Created | +Duration | +
|---|
#{value}
+| ID | +Job | +Arguments | +Status | +Created At | +Actions | +
|---|
No worker processes found.
+Workers will appear here when Solid Queue processes are running.
+| Kind | +Hostname | +PID | +Queues | +Last Heartbeat | +Status | +Jobs Processing | +Actions | +
|---|
#{process.pid}#{queues == '*' ? 'All Queues' : queues}"
+ end
+
+ return '-' if queues.empty?
+
+ if queues.length <= 3
+ queues.map { |q| "#{q}" }.join(' ')
+ else
+ visible = queues.first(2).map { |q| "#{q}" }.join(' ')
+ "#{visible} +#{queues.length - 2} more"
+ end
+ end
+
+ def format_heartbeat(heartbeat_at)
+ return '-' unless heartbeat_at
+
+ time_ago = time_ago_in_words(heartbeat_at)
+ "#{time_ago} ago"
+ end
+
+ def worker_status(process)
+ return :dead unless process.last_heartbeat_at
+
+ time_since_heartbeat = Time.current - process.last_heartbeat_at
+
+ if time_since_heartbeat > HEARTBEAT_DEAD_THRESHOLD
+ :dead
+ elsif time_since_heartbeat > HEARTBEAT_STALE_THRESHOLD
+ :stale
+ else
+ :healthy
+ end
+ end
+
+ def status_badge(status)
+ badges = {
+ healthy: 'Healthy',
+ stale: 'Stale',
+ dead: 'Dead'
+ }
+ badges[status]
+ end
+
+ def jobs_processing(process)
+ count = @claimed_counts[process.id] || 0
+
+ if count.zero?
+ 'Idle'
+ else
+ jobs = @claimed_jobs[process.id] || []
+ job_names = jobs.map(&:class_name).uniq.first(3)
+
+ tooltip = jobs.first(10).map { |j| "#{j.class_name} (ID: #{j.id})" }.join('
')
+
+ <<-HTML
+
+ #{count} job#{count > 1 ? 's' : ''}
+ (#{job_names.join(', ')}#{jobs.length > 3 ? '...' : ''})
+
+ HTML
+ end
+ end
+
+ def preload_claimed_data
+ return if @processes.empty?
+
+ process_ids = @processes.map(&:id)
+
+ # Preload claimed execution counts
+ @claimed_counts = SolidQueue::ClaimedExecution
+ .where(process_id: process_ids)
+ .group(:process_id)
+ .count
+
+ # Preload claimed jobs for processes that have any
+ claimed_executions = SolidQueue::ClaimedExecution
+ .includes(:job)
+ .where(process_id: process_ids)
+
+ @claimed_jobs = claimed_executions.each_with_object({}) do |execution, hash|
+ hash[execution.process_id] ||= []
+ hash[execution.process_id] << execution.job
+ end
+ end
+
+ def parse_metadata(process)
+ @parsed_metadata ||= {}
+ @parsed_metadata[process.id] ||= parse_process_metadata(process)
+ end
+
+ def parse_process_metadata(process)
+ return {} unless process.metadata
+
+ if process.metadata.is_a?(String)
+ JSON.parse(process.metadata)
+ else
+ process.metadata
+ end
+ rescue JSON::ParserError
+ {}
+ end
+ end
+end
diff --git a/app/services/solid_queue_monitor/html_generator.rb b/app/services/solid_queue_monitor/html_generator.rb
index 617c63f..51ab10f 100644
--- a/app/services/solid_queue_monitor/html_generator.rb
+++ b/app/services/solid_queue_monitor/html_generator.rb
@@ -95,7 +95,8 @@ def generate_header
{ path: scheduled_jobs_path, label: 'Scheduled Jobs', match: 'Scheduled Jobs' },
{ path: recurring_jobs_path, label: 'Recurring Jobs', match: 'Recurring Jobs' },
{ path: failed_jobs_path, label: 'Failed Jobs', match: 'Failed Jobs' },
- { path: queues_path, label: 'Queues', match: 'Queues' }
+ { path: queues_path, label: 'Queues', match: 'Queues' },
+ { path: workers_path, label: 'Workers', match: 'Workers' }
]
nav_links = nav_items.map do |item|
diff --git a/app/services/solid_queue_monitor/stylesheet_generator.rb b/app/services/solid_queue_monitor/stylesheet_generator.rb
index cee447f..06502cb 100644
--- a/app/services/solid_queue_monitor/stylesheet_generator.rb
+++ b/app/services/solid_queue_monitor/stylesheet_generator.rb
@@ -101,6 +101,29 @@ def generate
background: var(--background-color);
}
+ .solid_queue_monitor .section-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: var(--card-background);
+ border-radius: 0.5rem;
+ box-shadow: var(--card-shadow);
+ }
+
+ .solid_queue_monitor .section-header-left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .solid_queue_monitor .section-header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
.solid_queue_monitor .stats-container {
margin-bottom: 2rem;
}
@@ -1154,6 +1177,232 @@ def generate
align-items: center;
gap: 0.75rem;
}
+
+ /* Workers Page Styles */
+ .solid_queue_monitor .workers-summary {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .solid_queue_monitor .summary-card {
+ background: var(--card-background);
+ border: 1px solid var(--border-color);
+ border-radius: 0.5rem;
+ padding: 1rem 1.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ border-left: 4px solid var(--border-color);
+ position: relative;
+ }
+
+ .solid_queue_monitor .summary-card .summary-label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ }
+
+ .solid_queue_monitor .summary-card .summary-value {
+ font-size: 1.75rem;
+ font-weight: 600;
+ color: var(--text-color);
+ }
+
+ .solid_queue_monitor .summary-healthy {
+ border-left-color: #10b981;
+ }
+
+ .solid_queue_monitor .summary-healthy .summary-value {
+ color: #10b981;
+ }
+
+ .solid_queue_monitor .summary-stale {
+ border-left-color: #f59e0b;
+ }
+
+ .solid_queue_monitor .summary-stale .summary-value {
+ color: #f59e0b;
+ }
+
+ .solid_queue_monitor .summary-dead {
+ border-left-color: #ef4444;
+ }
+
+ .solid_queue_monitor .summary-dead .summary-value {
+ color: #ef4444;
+ }
+
+ .solid_queue_monitor .summary-action {
+ font-size: 0.75rem;
+ color: #f59e0b;
+ text-decoration: none;
+ border: 1px solid #f59e0b;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ margin-top: 0.5rem;
+ transition: all 0.2s;
+ }
+
+ .solid_queue_monitor .summary-action:hover {
+ background: #f59e0b;
+ color: #000;
+ }
+
+ .solid_queue_monitor .kind-badge {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ }
+
+ .solid_queue_monitor .kind-worker {
+ background: rgba(59, 130, 246, 0.15);
+ color: #3b82f6;
+ }
+
+ .solid_queue_monitor .kind-dispatcher {
+ background: rgba(249, 115, 22, 0.15);
+ color: #f97316;
+ }
+
+ .solid_queue_monitor .kind-scheduler {
+ background: rgba(168, 85, 247, 0.15);
+ color: #a855f7;
+ }
+
+ .solid_queue_monitor .kind-other {
+ background: rgba(107, 114, 128, 0.15);
+ color: #6b7280;
+ }
+
+ .solid_queue_monitor .status-healthy {
+ background: rgba(16, 185, 129, 0.15);
+ color: #10b981;
+ }
+
+ .solid_queue_monitor .status-stale {
+ background: rgba(245, 158, 11, 0.15);
+ color: #f59e0b;
+ }
+
+ .solid_queue_monitor .status-dead {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ }
+
+ .solid_queue_monitor .queue-tag {
+ display: inline-block;
+ background: var(--card-background);
+ border: 1px solid var(--border-color);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ margin-right: 0.25rem;
+ }
+
+ .solid_queue_monitor .queue-more {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ }
+
+ .solid_queue_monitor .jobs-idle {
+ color: var(--text-muted);
+ font-style: italic;
+ }
+
+ .solid_queue_monitor .jobs-processing {
+ color: #10b981;
+ }
+
+ .solid_queue_monitor .jobs-processing .job-names {
+ color: var(--text-muted);
+ font-size: 0.8em;
+ }
+
+ .solid_queue_monitor .worker-dead {
+ background: rgba(239, 68, 68, 0.05);
+ }
+
+ .solid_queue_monitor .worker-stale {
+ background: rgba(245, 158, 11, 0.05);
+ }
+
+ .solid_queue_monitor .action-placeholder {
+ color: var(--text-muted);
+ }
+
+ /* Table Link Styles */
+ .solid_queue_monitor .job-class-link {
+ color: var(--text-color);
+ text-decoration: none;
+ transition: color 0.2s;
+ }
+
+ .solid_queue_monitor .job-class-link:hover {
+ color: #3b82f6;
+ text-decoration: underline;
+ }
+
+ .solid_queue_monitor .queue-link {
+ color: var(--text-color);
+ text-decoration: none;
+ transition: color 0.2s;
+ }
+
+ .solid_queue_monitor .queue-link:hover {
+ color: #3b82f6;
+ text-decoration: underline;
+ }
+
+ .solid_queue_monitor .back-link {
+ color: var(--text-muted);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ transition: color 0.2s;
+ }
+
+ .solid_queue_monitor .back-link:hover {
+ color: var(--text-color);
+ }
+
+ .solid_queue_monitor .job-back-link {
+ margin-bottom: 1rem;
+ }
+
+ .solid_queue_monitor .empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-muted);
+ }
+
+ .solid_queue_monitor .empty-state p {
+ margin: 0.5rem 0;
+ }
+
+ .solid_queue_monitor .empty-state-hint {
+ font-size: 0.875rem;
+ opacity: 0.7;
+ }
+
+ @media (max-width: 768px) {
+ .solid_queue_monitor .workers-summary {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ }
+
+ @media (max-width: 480px) {
+ .solid_queue_monitor .workers-summary {
+ grid-template-columns: 1fr;
+ }
+ }
CSS
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 1ce4bcd..31defc9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -14,8 +14,12 @@
resources :failed_jobs, only: [:index]
resources :in_progress_jobs, only: [:index]
resources :queues, only: [:index]
+ get 'queues/:queue_name', to: 'queues#show', as: :queue_details, constraints: { queue_name: /[^\/]+/ }
+ resources :workers, only: [:index]
+ resources :jobs, only: [:show]
post 'execute_jobs', to: 'scheduled_jobs#create', as: :execute_jobs
+ post 'execute_scheduled_job/:id', to: 'scheduled_jobs#execute', as: :execute_scheduled_job
post 'reject_jobs', to: 'scheduled_jobs#reject_all', as: :reject_jobs
post 'retry_failed_job/:id', to: 'failed_jobs#retry', as: :retry_failed_job
@@ -25,4 +29,7 @@
post 'pause_queue', to: 'queues#pause', as: :pause_queue
post 'resume_queue', to: 'queues#resume', as: :resume_queue
+
+ post 'remove_worker/:id', to: 'workers#remove', as: :remove_worker
+ post 'prune_workers', to: 'workers#prune', as: :prune_workers
end
diff --git a/lib/solid_queue_monitor/version.rb b/lib/solid_queue_monitor/version.rb
index 75f0214..059a828 100644
--- a/lib/solid_queue_monitor/version.rb
+++ b/lib/solid_queue_monitor/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module SolidQueueMonitor
- VERSION = '0.6.0'
+ VERSION = '1.0.0'
end
diff --git a/screenshots/failed-jobs.png b/screenshots/failed-jobs.png
new file mode 100644
index 0000000..50eb147
Binary files /dev/null and b/screenshots/failed-jobs.png differ
diff --git a/screenshots/queues.png b/screenshots/queues.png
new file mode 100644
index 0000000..149abbc
Binary files /dev/null and b/screenshots/queues.png differ
diff --git a/screenshots/workers.png b/screenshots/workers.png
new file mode 100644
index 0000000..663646f
Binary files /dev/null and b/screenshots/workers.png differ
diff --git a/spec/presenters/solid_queue_monitor/job_details_presenter_spec.rb b/spec/presenters/solid_queue_monitor/job_details_presenter_spec.rb
new file mode 100644
index 0000000..a412197
--- /dev/null
+++ b/spec/presenters/solid_queue_monitor/job_details_presenter_spec.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SolidQueueMonitor::JobDetailsPresenter do
+ let(:job) { create(:solid_queue_job, class_name: 'TestJob', queue_name: 'default', priority: 5) }
+
+ describe '#render' do
+ subject(:rendered_html) { presenter.render }
+
+ let(:presenter) { described_class.new(job, back_path: '/failed_jobs') }
+
+ it 'renders the job details page container' do
+ expect(rendered_html).to include('job-details-page')
+ end
+
+ it 'renders the job class name' do
+ expect(rendered_html).to include('TestJob')
+ end
+
+ it 'renders the queue name' do
+ expect(rendered_html).to include('default')
+ end
+
+ it 'renders the priority' do
+ expect(rendered_html).to include('Priority')
+ end
+
+ it 'renders the back link' do
+ expect(rendered_html).to include('href="/failed_jobs"')
+ expect(rendered_html).to include('Back')
+ end
+
+ it 'renders the job arguments section' do
+ expect(rendered_html).to include('Arguments')
+ end
+
+ it 'renders the job details section' do
+ expect(rendered_html).to include('Job Details')
+ end
+
+ it 'renders the raw data section' do
+ expect(rendered_html).to include('Raw Data')
+ end
+
+ context 'with a failed execution' do
+ let(:failed_execution) do
+ create(:solid_queue_failed_execution, job: job, error: { 'message' => 'Test error', 'backtrace' => %w[line1 line2] })
+ end
+ let(:presenter) { described_class.new(job, failed_execution: failed_execution, back_path: '/') }
+
+ it 'renders the error section' do
+ expect(rendered_html).to include('Error')
+ end
+
+ it 'renders retry and discard buttons' do
+ expect(rendered_html).to include('Retry')
+ expect(rendered_html).to include('Discard')
+ end
+
+ it 'renders the failed status badge' do
+ expect(rendered_html).to include('Failed')
+ expect(rendered_html).to include('status-failed')
+ end
+ end
+
+ context 'with a claimed execution (in progress)' do
+ let(:process) { create(:solid_queue_process, kind: 'Worker', hostname: 'worker-1', pid: 12_345) }
+ let(:claimed_execution) do
+ execution = create(:solid_queue_claimed_execution, job: job, process_id: process.id)
+ execution.instance_variable_set(:@process, process)
+ execution
+ end
+ let(:presenter) { described_class.new(job, claimed_execution: claimed_execution, back_path: '/') }
+
+ it 'renders the worker section' do
+ expect(rendered_html).to include('Worker')
+ end
+
+ it 'renders the hostname' do
+ expect(rendered_html).to include('worker-1')
+ end
+
+ it 'renders the in progress status' do
+ expect(rendered_html).to include('In Progress')
+ expect(rendered_html).to include('status-in-progress')
+ end
+ end
+
+ context 'with a scheduled execution' do
+ let(:scheduled_at) { 1.hour.from_now }
+ let(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: job, scheduled_at: scheduled_at) }
+ let(:presenter) { described_class.new(job, scheduled_execution: scheduled_execution, back_path: '/') }
+
+ it 'renders the scheduled status' do
+ expect(rendered_html).to include('Scheduled')
+ expect(rendered_html).to include('status-scheduled')
+ end
+ end
+
+ context 'with a completed job' do
+ let(:completed_job) { create(:solid_queue_job, :completed, class_name: 'CompletedJob') }
+ let(:presenter) { described_class.new(completed_job, back_path: '/') }
+
+ it 'renders the completed status' do
+ expect(rendered_html).to include('Completed')
+ expect(rendered_html).to include('status-completed')
+ end
+ end
+
+ context 'with recent executions' do
+ let(:recent_jobs) do
+ create_list(:solid_queue_job, 3, class_name: 'TestJob', finished_at: Time.current)
+ end
+ let(:presenter) { described_class.new(job, recent_executions: recent_jobs, back_path: '/') }
+
+ it 'renders the recent executions section' do
+ expect(rendered_html).to include('Recent Executions')
+ end
+
+ it 'renders the recent executions table' do
+ expect(rendered_html).to include('recent-executions-table')
+ end
+ end
+ end
+
+ describe 'timing calculations' do
+ context 'with start and end times' do
+ let(:started_at) { 5.minutes.ago }
+ let(:finished_at) { Time.current }
+ let(:completed_job) { create(:solid_queue_job, created_at: 10.minutes.ago, finished_at: finished_at) }
+ let(:claimed_execution) do
+ execution = build(:solid_queue_claimed_execution, job: completed_job, created_at: started_at)
+ execution.instance_variable_set(:@process, nil)
+ execution
+ end
+ let(:presenter) { described_class.new(completed_job, claimed_execution: claimed_execution, back_path: '/') }
+
+ it 'renders timing cards' do
+ rendered_html = presenter.render
+ expect(rendered_html).to include('timing-cards')
+ expect(rendered_html).to include('Queue Wait')
+ expect(rendered_html).to include('Execution')
+ expect(rendered_html).to include('Total Time')
+ end
+ end
+ end
+
+ describe 'timeline rendering' do
+ let(:completed_job) { create(:solid_queue_job, :completed, created_at: 10.minutes.ago) }
+ let(:presenter) { described_class.new(completed_job, back_path: '/') }
+
+ it 'renders the timeline section' do
+ rendered_html = presenter.render
+ expect(rendered_html).to include('Timeline')
+ expect(rendered_html).to include('timeline-track')
+ end
+ end
+end
diff --git a/spec/requests/solid_queue_monitor/jobs_spec.rb b/spec/requests/solid_queue_monitor/jobs_spec.rb
new file mode 100644
index 0000000..d4888ce
--- /dev/null
+++ b/spec/requests/solid_queue_monitor/jobs_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jobs' do
+ describe 'GET /jobs/:id' do
+ let(:job) { create(:solid_queue_job, class_name: 'MyTestJob', queue_name: 'default', priority: 5) }
+
+ it 'returns a successful response' do
+ get "/jobs/#{job.id}"
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'displays the job details page' do
+ get "/jobs/#{job.id}"
+
+ expect(response.body).to include('MyTestJob')
+ expect(response.body).to include('Job Details')
+ end
+
+ it 'displays the job queue and priority' do
+ get "/jobs/#{job.id}"
+
+ expect(response.body).to include('default')
+ expect(response.body).to include('Priority')
+ end
+
+ context 'when job is not found' do
+ it 'redirects to root with error message' do
+ get '/jobs/999999'
+
+ expect(response).to redirect_to('/')
+ end
+ end
+
+ context 'with a failed job' do
+ let(:failed_job) { create(:solid_queue_job, class_name: 'FailedTestJob') }
+ let!(:failed_execution) do
+ create(:solid_queue_failed_execution, job: failed_job, error: { 'message' => 'Test error', 'backtrace' => ['line 1', 'line 2'] })
+ end
+
+ it 'displays error information' do
+ get "/jobs/#{failed_job.id}"
+
+ expect(response.body).to include('Error')
+ expect(response.body).to include('FailedTestJob')
+ end
+
+ it 'displays retry and discard buttons' do
+ get "/jobs/#{failed_job.id}"
+
+ expect(response.body).to include('Retry')
+ expect(response.body).to include('Discard')
+ end
+ end
+
+ context 'with an in-progress job' do
+ let(:in_progress_job) { create(:solid_queue_job, class_name: 'InProgressJob') }
+ let(:process) { create(:solid_queue_process, kind: 'Worker', hostname: 'worker-1') }
+ let!(:claimed_execution) { create(:solid_queue_claimed_execution, job: in_progress_job, process_id: process.id) }
+
+ it 'displays worker information' do
+ get "/jobs/#{in_progress_job.id}"
+
+ expect(response.body).to include('Worker')
+ expect(response.body).to include('In Progress')
+ end
+ end
+
+ context 'with a scheduled job' do
+ let(:scheduled_job) { create(:solid_queue_job, :scheduled, class_name: 'ScheduledJob') }
+ let!(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: scheduled_job) }
+
+ it 'displays scheduled status' do
+ get "/jobs/#{scheduled_job.id}"
+
+ expect(response.body).to include('ScheduledJob')
+ expect(response.body).to include('Scheduled')
+ end
+ end
+
+ context 'with a completed job' do
+ let(:completed_job) { create(:solid_queue_job, :completed, class_name: 'CompletedJob') }
+
+ it 'displays completed status' do
+ get "/jobs/#{completed_job.id}"
+
+ expect(response.body).to include('CompletedJob')
+ expect(response.body).to include('Completed')
+ end
+ end
+ end
+
+ context 'with authentication enabled' do
+ before do
+ allow(SolidQueueMonitor).to receive_messages(authentication_enabled: true, username: 'admin', password: 'password123')
+ end
+
+ let(:job) { create(:solid_queue_job) }
+ let(:valid_credentials) do
+ ActionController::HttpAuthentication::Basic.encode_credentials('admin', 'password123')
+ end
+
+ it 'requires authentication for show' do
+ get "/jobs/#{job.id}"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'allows access with valid credentials' do
+ get "/jobs/#{job.id}", headers: { 'HTTP_AUTHORIZATION' => valid_credentials }
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+end
diff --git a/spec/requests/solid_queue_monitor/workers_spec.rb b/spec/requests/solid_queue_monitor/workers_spec.rb
new file mode 100644
index 0000000..d2aea1d
--- /dev/null
+++ b/spec/requests/solid_queue_monitor/workers_spec.rb
@@ -0,0 +1,244 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Workers' do
+ describe 'GET /workers' do
+ context 'with no processes' do
+ it 'returns a successful response' do
+ get '/workers'
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'displays empty state message' do
+ get '/workers'
+
+ expect(response.body).to include('No worker processes found')
+ end
+ end
+
+ context 'with processes' do
+ before do
+ create(:solid_queue_process, kind: 'Worker', hostname: 'worker-1', pid: 1001, last_heartbeat_at: Time.current)
+ create(:solid_queue_process, kind: 'Dispatcher', hostname: 'dispatcher-1', pid: 1002, last_heartbeat_at: Time.current)
+ create(:solid_queue_process, kind: 'Scheduler', hostname: 'scheduler-1', pid: 1003, last_heartbeat_at: 15.minutes.ago)
+ end
+
+ it 'returns a successful response' do
+ get '/workers'
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'displays process information' do
+ get '/workers'
+
+ expect(response.body).to include('worker-1')
+ expect(response.body).to include('dispatcher-1')
+ expect(response.body).to include('scheduler-1')
+ end
+
+ it 'displays kind badges' do
+ get '/workers'
+
+ expect(response.body).to include('Worker')
+ expect(response.body).to include('Dispatcher')
+ expect(response.body).to include('Scheduler')
+ end
+
+ it 'displays status badges' do
+ get '/workers'
+
+ expect(response.body).to include('Healthy')
+ expect(response.body).to include('Dead')
+ end
+
+ it 'displays summary counts' do
+ get '/workers'
+
+ expect(response.body).to include('Total Processes')
+ end
+
+ it 'displays Actions column' do
+ get '/workers'
+
+ expect(response.body).to include('