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 ![Dashboard Overview - Dark Theme](screenshots/dashboard-dark.png) +### Worker Monitoring + +![Worker Monitoring](screenshots/workers.png) + +### Queue Management + +![Queue Management](screenshots/queues.png) + ### Failed Jobs -![Failed Jobs](screenshots/failed-jobs-2.png) +![Failed Jobs](screenshots/failed-jobs.png) ## 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) -
#{job.class_name}
+
#{job.class_name}
Queued at: #{format_datetime(job.created_at)}
-
#{job.queue_name}
+
#{queue_link(job.queue_name)}
-
#{error[:message]}
+
#{error[:message].to_s.truncate(100)}
Failed at: #{format_datetime(failed_execution.created_at)}
-
- Backtrace -
#{error[:backtrace]}
-
#{format_arguments(job.arguments)} diff --git a/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb b/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb index 28115ae..9750499 100644 --- a/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb @@ -67,11 +67,12 @@ def generate_row(execution) <<-HTML -
#{job.class_name}
+
#{job.class_name}
Queued at: #{format_datetime(job.created_at)}
+ #{queue_link(job.queue_name)} #{format_arguments(job.arguments)} #{format_datetime(execution.created_at)} #{execution.process_id} diff --git a/app/presenters/solid_queue_monitor/job_details_presenter.rb b/app/presenters/solid_queue_monitor/job_details_presenter.rb new file mode 100644 index 0000000..d01a399 --- /dev/null +++ b/app/presenters/solid_queue_monitor/job_details_presenter.rb @@ -0,0 +1,696 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class JobDetailsPresenter < BasePresenter + def initialize(job, failed_execution: nil, claimed_execution: nil, scheduled_execution: nil, + recent_executions: [], back_path: nil) + @job = job + @failed_execution = failed_execution + @claimed_execution = claimed_execution + @scheduled_execution = scheduled_execution + @recent_executions = recent_executions + @back_path = back_path + calculate_timing + end + + def render + <<-HTML +
+ #{render_back_link} + #{render_header} + #{render_timeline} + #{render_timing_cards} + #{render_error_section if @failed_execution} + #{render_arguments_section} + #{render_details_section} + #{render_worker_section if @claimed_execution} + #{render_recent_executions} + #{render_raw_data_section} +
+ HTML + end + + private + + def calculate_timing + @created_at = @job.created_at + @scheduled_at = @job.scheduled_at || @scheduled_execution&.scheduled_at + @started_at = @claimed_execution&.created_at + @finished_at = @job.finished_at + @failed_at = @failed_execution&.created_at + + # Calculate durations + @queue_wait_time = calculate_queue_wait + @execution_time = calculate_execution_time + @total_time = calculate_total_time + end + + def calculate_queue_wait + return nil unless @started_at && @created_at + + @started_at - @created_at + end + + def calculate_execution_time + end_time = @finished_at || @failed_at + return nil unless @started_at && end_time + + end_time - @started_at + end + + def calculate_total_time + end_time = @finished_at || @failed_at + return nil unless @created_at && end_time + + end_time - @created_at + end + + def job_status + return :failed if @failed_execution + return :in_progress if @claimed_execution + return :scheduled if @scheduled_execution || @job.scheduled_at&.future? + return :completed if @job.finished_at + + :pending + end + + def status_label + { + failed: 'Failed', + in_progress: 'In Progress', + scheduled: 'Scheduled', + completed: 'Completed', + pending: 'Pending' + }[job_status] + end + + def status_class + { + failed: 'status-failed', + in_progress: 'status-in-progress', + scheduled: 'status-scheduled', + completed: 'status-completed', + pending: 'status-pending' + }[job_status] + end + + def render_back_link + <<-HTML + + HTML + end + + def render_header + <<-HTML +
+
+

#{@job.class_name}

+ #{status_label} +
+
+ #{queue_link(@job.queue_name)} + + Priority #{@job.priority} + + Job ##{@job.id} +
+ #{render_actions} +
+ HTML + end + + def render_actions + actions = [] + + if @failed_execution + actions << <<-HTML +
+ + +
+ HTML + + actions << <<-HTML +
+ + +
+ HTML + end + + if @scheduled_execution + actions << <<-HTML +
+ + +
+ HTML + end + + return '' if actions.empty? + + <<-HTML +
+ #{actions.join} +
+ HTML + end + + def render_timeline + events = build_timeline_events + return '' if events.size < 2 + + <<-HTML +
+

Timeline

+
+
+ #{render_timeline_events(events)} +
+
+
+ HTML + end + + def build_timeline_events + events = [] + events << { label: 'Created', time: @created_at, status: :done } if @created_at + events << { label: 'Scheduled', time: @scheduled_at, status: :done } if @scheduled_at && @scheduled_at != @created_at + events << { label: 'Started', time: @started_at, status: :done } if @started_at + + case job_status + when :completed + events << { label: 'Completed', time: @finished_at, status: :success } + when :failed + events << { label: 'Failed', time: @failed_at, status: :failed } + when :in_progress + events << { label: 'Running...', time: nil, status: :active } + end + + events + end + + def render_timeline_events(events) + total = events.size + events.map.with_index do |event, index| + is_last = index == total - 1 + status_class = "timeline-#{event[:status]}" + + <<-HTML +
+
+ #{is_last ? '' : '
'} +
+
#{event[:label]}
+
#{event[:time] ? format_datetime(event[:time]) : ''}
+
+
+ HTML + end.join + end + + def render_timing_cards + <<-HTML +
+ #{render_timing_card('Queue Wait', @queue_wait_time, queue_wait_indicator, timing_unavailable_reason(:queue_wait))} + #{render_timing_card('Execution', @execution_time, execution_indicator, timing_unavailable_reason(:execution))} + #{render_timing_card('Total Time', @total_time, nil, nil)} +
+ HTML + end + + def render_timing_card(label, duration, indicator, unavailable_reason) + formatted = duration ? format_duration(duration) : '-' + indicator_html = indicator ? "
#{indicator[:label]}
" : '' + tooltip = unavailable_reason && !duration ? " title=\"#{unavailable_reason}\"" : '' + + <<-HTML +
+
#{formatted}
+
#{label}
+ #{indicator_html} +
+ HTML + end + + def timing_unavailable_reason(timing_type) + return nil if @claimed_execution # In-progress jobs have all timing data + return nil unless %i[queue_wait execution].include?(timing_type) + + if @failed_execution || @job.finished_at + 'Not available - execution record deleted after job completed' + else + 'Available once job starts processing' + end + end + + def queue_wait_indicator + return nil unless @queue_wait_time + + if @queue_wait_time > 300 # > 5 minutes + { class: 'indicator-warning', label: 'High' } + elsif @queue_wait_time > 60 # > 1 minute + { class: 'indicator-normal', label: 'Normal' } + else + { class: 'indicator-good', label: 'Fast' } + end + end + + def execution_indicator + return nil unless @execution_time + + if @execution_time > 60 # > 1 minute + { class: 'indicator-warning', label: 'Slow' } + elsif @execution_time > 10 # > 10 seconds + { class: 'indicator-normal', label: 'Normal' } + else + { class: 'indicator-good', label: 'Fast' } + end + end + + def format_duration(seconds) + return '-' unless seconds + + if seconds < 1 + "#{(seconds * 1000).round}ms" + elsif seconds < 60 + "#{seconds.round(1)}s" + elsif seconds < 3600 + minutes = (seconds / 60).floor + secs = (seconds % 60).round + "#{minutes}m #{secs}s" + else + hours = (seconds / 3600).floor + minutes = ((seconds % 3600) / 60).floor + "#{hours}h #{minutes}m" + end + end + + def render_error_section + error = parse_error(@failed_execution.error) + + <<-HTML +
+
+

Error

+ +
+
+
#{error[:type]}
+
#{error[:message]}
+
+ #{render_backtrace(error[:backtrace])} +
+ HTML + end + + def render_backtrace(backtrace) + return '' if backtrace.blank? + + lines = backtrace.is_a?(Array) ? backtrace : backtrace.to_s.split("\n") + app_lines = lines.select { |line| line.include?('/app/') || line.include?('/lib/') } + + <<-HTML +
+
+ Backtrace +
+ + +
+
+
#{format_backtrace_lines(app_lines.presence || lines.first(5))}
+ +
+ + HTML + end + + def format_backtrace_lines(lines) + lines.map.with_index do |line, index| + "#{index + 1}. #{CGI.escapeHTML(line.to_s.strip)}" + end.join("\n") + end + + def parse_error(error) + return { type: 'Unknown', message: 'Unknown error', backtrace: [] } unless error + + # Convert to hash if it's a serialized string + error_hash = deserialize_error(error) + + { + type: extract_error_type(error_hash), + message: extract_error_message(error_hash), + backtrace: extract_backtrace(error_hash) + } + end + + def deserialize_error(error) + return error if error.is_a?(Hash) + + if error.is_a?(String) + # Try JSON first + if error.strip.start_with?('{') + begin + return JSON.parse(error) + rescue JSON::ParserError + # Continue to try other formats + end + end + + # Try YAML (SolidQueue may use YAML serialization) + begin + parsed = YAML.safe_load(error, permitted_classes: [Symbol]) + return parsed if parsed.is_a?(Hash) + rescue StandardError + # Continue with string + end + + # Return as simple error hash + { 'message' => error } + else + { 'message' => error.to_s } + end + end + + def extract_error_type(error_hash) + error_hash['exception_class'] || error_hash[:exception_class] || + error_hash['error_class'] || error_hash[:error_class] || + error_hash['class'] || error_hash[:class] || 'Error' + end + + def extract_error_message(error_hash) + error_hash['message'] || error_hash[:message] || + error_hash['error'] || error_hash[:error] || 'Unknown error' + end + + def extract_backtrace(error_hash) + bt = error_hash['backtrace'] || error_hash[:backtrace] || + error_hash['stack_trace'] || error_hash[:stack_trace] || [] + + # Ensure it's an array + return bt if bt.is_a?(Array) + return bt.split("\n") if bt.is_a?(String) && bt.present? + + [] + end + + def render_arguments_section + args = @job.arguments + formatted_args = format_job_arguments_pretty(args) + + <<-HTML +
+
+

Arguments

+
+ +
+
+
#{CGI.escapeHTML(formatted_args)}
+
+ HTML + end + + def format_job_arguments_pretty(args) + return '-' if args.blank? + + JSON.pretty_generate(args) + rescue JSON::GeneratorError + args.inspect + end + + def render_details_section + <<-HTML +
+

Job Details

+
+
+ Class + #{@job.class_name} +
+
+ Queue + #{queue_link(@job.queue_name, css_class: 'queue-badge')} +
+
+ Priority + #{@job.priority} +
+
+ Active Job ID + #{@job.active_job_id || '-'} +
+ #{render_concurrency_key} +
+ Created At + #{format_datetime(@job.created_at)} +
+ #{render_scheduled_at} + #{render_finished_at} + #{render_failed_at} +
+
+ HTML + end + + def render_concurrency_key + return '' if @job.concurrency_key.blank? + + <<-HTML +
+ Concurrency Key + #{@job.concurrency_key} +
+ HTML + end + + def render_scheduled_at + return '' unless @scheduled_at + + <<-HTML +
+ Scheduled At + #{format_datetime(@scheduled_at)} +
+ HTML + end + + def render_finished_at + return '' unless @job.finished_at + + <<-HTML +
+ Finished At + #{format_datetime(@job.finished_at)} +
+ HTML + end + + def render_failed_at + return '' unless @failed_at + + <<-HTML +
+ Failed At + #{format_datetime(@failed_at)} +
+ HTML + end + + def render_worker_section + process = @claimed_execution.instance_variable_get(:@process) + return '' unless process + + <<-HTML +
+

Worker

+
+
+ Hostname + #{process.hostname} +
+
+ PID + #{process.pid} +
+
+ Process Type + #{process.kind} +
+
+ Started At + #{format_datetime(@claimed_execution.created_at)} +
+
+
+ HTML + end + + def render_recent_executions + return '' if @recent_executions.empty? + + <<-HTML +
+
+

Recent Executions

+ Other #{@job.class_name} jobs +
+
+ + + + + + + + + + + #{@recent_executions.map { |job| render_execution_row(job) }.join} + +
StatusArgumentsCreatedDuration
+
+
+ HTML + end + + def render_execution_row(job) + status = determine_job_status(job) + status_badge = render_status_badge(status) + duration = calculate_job_duration(job) + args_preview = truncate_arguments(job.arguments) + + <<-HTML + + #{status_badge} + #{args_preview} + #{time_ago_in_words(job.created_at)} ago + #{duration} + + HTML + end + + def determine_job_status(job) + return :failed if job.failed_execution.present? + return :in_progress if job.claimed_execution.present? + return :scheduled if job.scheduled_execution.present? + return :ready if job.ready_execution.present? + return :completed if job.finished_at + + :pending + end + + def render_status_badge(status) + labels = { + failed: 'Failed', + completed: 'Completed', + in_progress: 'In Progress', + scheduled: 'Scheduled', + ready: 'Ready', + pending: 'Pending' + } + classes = { + failed: 'status-failed', + completed: 'status-completed', + in_progress: 'status-in-progress', + scheduled: 'status-scheduled', + ready: 'status-pending', + pending: 'status-pending' + } + + "#{labels[status]}" + end + + def calculate_job_duration(job) + return '-' unless job.finished_at || job.failed_execution&.created_at + + end_time = job.finished_at || job.failed_execution&.created_at + format_duration(end_time - job.created_at) + end + + def truncate_arguments(args) + return '-' if args.blank? + + preview = args.inspect.truncate(60) + CGI.escapeHTML(preview) + end + + def render_raw_data_section + <<-HTML +
+
+
+ + + +

Raw Data

+
+ +
+ +
+ + HTML + end + end +end diff --git a/app/presenters/solid_queue_monitor/jobs_presenter.rb b/app/presenters/solid_queue_monitor/jobs_presenter.rb index 407c780..e7216da 100644 --- a/app/presenters/solid_queue_monitor/jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/jobs_presenter.rb @@ -95,9 +95,9 @@ def generate_row(job) # Build the row HTML row_html = <<-HTML - #{job.id} - #{job.class_name} - #{job.queue_name} + #{job.id} + #{job.class_name} + #{queue_link(job.queue_name)} #{format_arguments(job.arguments)} #{status} #{format_datetime(job.created_at)} diff --git a/app/presenters/solid_queue_monitor/queue_details_presenter.rb b/app/presenters/solid_queue_monitor/queue_details_presenter.rb new file mode 100644 index 0000000..beb1931 --- /dev/null +++ b/app/presenters/solid_queue_monitor/queue_details_presenter.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class QueueDetailsPresenter < BasePresenter + def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}) + @queue_name = queue_name + @paused = paused + @jobs = jobs + @counts = counts + @current_page = current_page + @total_pages = total_pages + @filters = filters + end + + def render + section_wrapper("Queue: #{@queue_name}", + render_header + render_stats_cards + generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages)) + end + + private + + def render_header + <<-HTML +
+
+ #{status_badge} +
+
+ #{action_button} +
+
+ HTML + end + + def status_badge + if @paused + 'Paused' + else + 'Active' + end + end + + def action_button + if @paused + <<-HTML +
+ + + +
+ HTML + else + <<-HTML +
+ + + +
+ HTML + end + end + + def render_stats_cards + <<-HTML +
+
+ #{generate_stat_card('Total Jobs', @counts[:total])} + #{generate_stat_card('Ready', @counts[:ready])} + #{generate_stat_card('Scheduled', @counts[:scheduled])} + #{generate_stat_card('In Progress', @counts[:in_progress])} + #{generate_stat_card('Completed', @counts[:completed])} + #{generate_stat_card('Failed', @counts[:failed])} +
+
+ HTML + end + + def generate_stat_card(title, value) + <<-HTML +
+

#{title}

+

#{value}

+
+ HTML + end + + def generate_filter_form + <<-HTML +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Reset +
+
+
+ HTML + end + + def generate_table + return '

No jobs in this queue

' if @jobs.empty? + + <<-HTML +
+ + + + + + + + + + + + + #{@jobs.map { |job| generate_row(job) }.join} + +
IDJobArgumentsStatusCreated AtActions
+
+ HTML + end + + def generate_row(job) + status = job_status(job) + + row_html = <<-HTML + + #{job.id} + #{job.class_name} + #{format_arguments(job.arguments)} + #{status} + #{format_datetime(job.created_at)} + HTML + + # Add actions column for failed jobs + if status == 'failed' + failed_execution = SolidQueue::FailedExecution.find_by(job_id: job.id) + + row_html += if failed_execution + <<-HTML + +
+
+ + +
+
+ + +
+
+ + HTML + else + '' + end + else + row_html += '' + end + + row_html += '' + row_html + end + + def job_status(job) + SolidQueueMonitor::StatusCalculator.new(job).calculate + end + end +end diff --git a/app/presenters/solid_queue_monitor/queues_presenter.rb b/app/presenters/solid_queue_monitor/queues_presenter.rb index 153ef05..13ff926 100644 --- a/app/presenters/solid_queue_monitor/queues_presenter.rb +++ b/app/presenters/solid_queue_monitor/queues_presenter.rb @@ -42,7 +42,7 @@ def generate_row(queue) <<-HTML - #{queue_name} + #{queue_link(queue_name)} #{status_badge(paused)} #{queue.job_count} #{ready_jobs_count(queue_name)} diff --git a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb b/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb index b009d56..6e62c8b 100644 --- a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb @@ -68,8 +68,8 @@ def generate_table def generate_row(execution) <<-HTML - #{execution.job.class_name} - #{execution.queue_name} + #{execution.job.class_name} + #{queue_link(execution.queue_name)} #{execution.priority} #{format_arguments(execution.job.arguments)} #{format_datetime(execution.created_at)} diff --git a/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb b/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb index 9ae5f7a..a771f69 100644 --- a/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb @@ -70,7 +70,7 @@ def generate_row(task) #{task.key} #{task.class_name} #{task.schedule} - #{task.queue_name} + #{queue_link(task.queue_name)} #{task.priority || 'Default'} #{format_datetime(task.updated_at)} diff --git a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb b/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb index 7169247..051d29c 100644 --- a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb @@ -161,8 +161,8 @@ def generate_row(execution) - #{execution.job.class_name} - #{execution.queue_name} + #{execution.job.class_name} + #{queue_link(execution.queue_name)} #{format_datetime(execution.scheduled_at)} #{format_arguments(execution.job.arguments)} diff --git a/app/presenters/solid_queue_monitor/workers_presenter.rb b/app/presenters/solid_queue_monitor/workers_presenter.rb new file mode 100644 index 0000000..de32a4e --- /dev/null +++ b/app/presenters/solid_queue_monitor/workers_presenter.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class WorkersPresenter < BasePresenter + HEARTBEAT_STALE_THRESHOLD = 5.minutes + HEARTBEAT_DEAD_THRESHOLD = 10.minutes + + def initialize(processes, current_page: 1, total_pages: 1, filters: {}) + @processes = processes.to_a # Load records once to avoid multiple queries + @current_page = current_page + @total_pages = total_pages + @filters = filters + preload_claimed_data + calculate_summary_stats + end + + def render + section_wrapper('Workers', generate_content) + end + + private + + def generate_content + generate_summary + generate_filter_form + generate_table_or_empty + generate_pagination(@current_page, @total_pages) + end + + def generate_filter_form + <<-HTML +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Reset +
+
+
+ HTML + end + + def kind_options + kinds = %w[Worker Dispatcher Scheduler] + kinds.map do |kind| + selected = @filters[:kind] == kind ? 'selected' : '' + "" + end.join + end + + def calculate_summary_stats + all_processes = all_processes_for_summary + @total_count = all_processes.count + @healthy_count = all_processes.count { |p| worker_status(p) == :healthy } + @stale_count = all_processes.count { |p| worker_status(p) == :stale } + @dead_count = all_processes.count { |p| worker_status(p) == :dead } + end + + def generate_summary + <<-HTML +
+
+ Total Processes + #{@total_count} +
+
+ Healthy + #{@healthy_count} +
+
+ Stale + #{@stale_count} +
+
+ Dead + #{@dead_count} + #{prune_all_link} +
+
+ HTML + end + + def prune_all_link + return '' if @dead_count.zero? + + <<-HTML + + Prune all + + + HTML + end + + def all_processes_for_summary + @all_processes_for_summary ||= SolidQueue::Process.all.to_a + end + + def generate_table_or_empty + if @processes.empty? + generate_empty_state + else + generate_table + end + end + + def generate_empty_state + <<-HTML +
+

No worker processes found.

+

Workers will appear here when Solid Queue processes are running.

+
+ HTML + end + + def generate_table + <<-HTML +
+ + + + + + + + + + + + + + + #{@processes.map { |process| generate_row(process) }.join} + +
KindHostnamePIDQueuesLast HeartbeatStatusJobs ProcessingActions
+
+ HTML + end + + def generate_row(process) + status = worker_status(process) + row_class = case status + when :dead then 'worker-dead' + when :stale then 'worker-stale' + else '' + end + + <<-HTML + + #{kind_badge(process.kind)} + #{hostname(process)} + #{process.pid} + #{queues_display(process)} + #{format_heartbeat(process.last_heartbeat_at)} + #{status_badge(status)} + #{jobs_processing(process)} + #{action_button(process, status)} + + HTML + end + + def action_button(process, status) + return '-' unless status == :dead + + <<-HTML +
+ +
+ HTML + end + + def kind_badge(kind) + badge_class = case kind + when 'Worker' then 'kind-worker' + when 'Dispatcher' then 'kind-dispatcher' + when 'Scheduler' then 'kind-scheduler' + else 'kind-other' + end + "#{kind}" + end + + def hostname(process) + process.hostname || parse_metadata(process)['hostname'] || '-' + end + + def queues_display(process) + metadata = parse_metadata(process) + queues = metadata['queues'] + + return '-' if queues.nil? + + # Handle string queues (e.g., "*" for all queues) + if queues.is_a?(String) + return "#{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('Actions') + end + + it 'shows Remove button for dead processes' do + get '/workers' + + expect(response.body).to include('Remove') + end + + it 'shows Prune all link when dead processes exist' do + get '/workers' + + expect(response.body).to include('Prune all') + end + end + + context 'with only healthy processes' do + before do + create(:solid_queue_process, kind: 'Worker', hostname: 'worker-1', last_heartbeat_at: Time.current) + end + + it 'does not show Prune all link' do + get '/workers' + + expect(response.body).not_to include('Prune all') + end + end + + context 'with filters' do + before do + create(:solid_queue_process, kind: 'Worker', hostname: 'worker-1', last_heartbeat_at: Time.current) + create(:solid_queue_process, kind: 'Dispatcher', hostname: 'dispatcher-1', last_heartbeat_at: Time.current) + end + + it 'filters by kind' do + get '/workers', params: { kind: 'Worker' } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('worker-1') + expect(response.body).not_to include('dispatcher-1') + end + + it 'filters by hostname' do + get '/workers', params: { hostname: 'dispatcher' } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('dispatcher-1') + expect(response.body).not_to include('worker-1') + end + + it 'filters by status' do + create(:solid_queue_process, kind: 'Worker', hostname: 'dead-worker', last_heartbeat_at: 15.minutes.ago) + + get '/workers', params: { status: 'dead' } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('dead-worker') + end + end + + context 'with stale process' do + before do + create(:solid_queue_process, kind: 'Worker', hostname: 'stale-worker', last_heartbeat_at: 7.minutes.ago) + end + + it 'shows stale status' do + get '/workers' + + expect(response.body).to include('Stale') + end + end + end + + describe 'POST /remove_worker/:id' do + context 'with a dead process' do + let!(:dead_process) do + create(:solid_queue_process, kind: 'Worker', hostname: 'dead-worker', last_heartbeat_at: 15.minutes.ago) + end + + it 'removes the process' do + expect do + post "/remove_worker/#{dead_process.id}" + end.to change(SolidQueue::Process, :count).by(-1) + end + + it 'redirects to workers page' do + post "/remove_worker/#{dead_process.id}" + + expect(response).to redirect_to('/workers') + end + end + + context 'with non-existent process' do + it 'handles gracefully' do + post '/remove_worker/99999' + + expect(response).to redirect_to('/workers') + end + end + end + + describe 'POST /prune_workers' do + context 'with dead processes' do + before do + create(:solid_queue_process, kind: 'Worker', hostname: 'healthy-worker', last_heartbeat_at: Time.current) + create(:solid_queue_process, kind: 'Worker', hostname: 'dead-worker-1', last_heartbeat_at: 15.minutes.ago) + create(:solid_queue_process, kind: 'Worker', hostname: 'dead-worker-2', last_heartbeat_at: 20.minutes.ago) + end + + it 'removes dead processes' do + expect do + post '/prune_workers' + end.to change(SolidQueue::Process, :count).by(-2) + end + + it 'keeps healthy processes' do + post '/prune_workers' + + expect(SolidQueue::Process.where(hostname: 'healthy-worker')).to exist + end + + it 'redirects to workers page' do + post '/prune_workers' + + expect(response).to redirect_to('/workers') + end + end + + context 'with no dead processes' do + before do + create(:solid_queue_process, kind: 'Worker', hostname: 'healthy-worker', last_heartbeat_at: Time.current) + end + + it 'does not remove any processes' do + expect do + post '/prune_workers' + end.not_to(change(SolidQueue::Process, :count)) + end + + it 'redirects to workers page' do + post '/prune_workers' + + expect(response).to redirect_to('/workers') + end + end + end + + context 'with authentication enabled' do + before do + allow(SolidQueueMonitor).to receive_messages(authentication_enabled: true, username: 'admin', password: 'password123') + end + + it 'requires authentication for workers index' do + get '/workers' + + expect(response).to have_http_status(:unauthorized) + end + + it 'allows access with valid credentials' do + get '/workers', headers: { + 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('admin', 'password123') + } + + expect(response).to have_http_status(:ok) + end + + it 'requires authentication for remove action' do + post '/remove_worker/1' + + expect(response).to have_http_status(:unauthorized) + end + + it 'requires authentication for prune action' do + post '/prune_workers' + + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/services/solid_queue_monitor/chart_data_service_spec.rb b/spec/services/solid_queue_monitor/chart_data_service_spec.rb index 18e95a6..03668ca 100644 --- a/spec/services/solid_queue_monitor/chart_data_service_spec.rb +++ b/spec/services/solid_queue_monitor/chart_data_service_spec.rb @@ -95,7 +95,7 @@ end end - context 'with job data' do # rubocop:disable RSpec/MultipleMemoizedHelpers + context 'with job data' do let(:now) { Time.current } let(:created_timestamps) { [now - 30.minutes, now - 45.minutes] } let(:completed_timestamps) { [now - 20.minutes] }