From 2dcb72bb6797fe810327ee41ac7a47092a0d3fb3 Mon Sep 17 00:00:00 2001 From: Vishal Sadriya Date: Fri, 6 Feb 2026 22:54:40 +0530 Subject: [PATCH] feat: add sortable column headers to all job list tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add clickable column headers for sorting across all job tables: - Ready Jobs: class_name, queue_name, priority, created_at - Scheduled Jobs: class_name, queue_name, scheduled_at - Failed Jobs: class_name, queue_name, created_at - In Progress Jobs: class_name, queue_name, created_at - Recurring Jobs: key, class_name, queue_name, priority - Jobs: class_name, queue_name, status, created_at - Queues: queue_name, job_count - Workers: hostname, last_heartbeat_at Features: - Click to sort ascending, click again for descending - Visual indicators: ↑ (asc), ↓ (desc), ⇅ (default/sortable) - Sort state preserved across pagination and filtering - Special handling for execution tables with JOIN queries - Special handling for GROUP BY queries on queues page Includes comprehensive test coverage with 27 new specs. --- .rubocop.yml | 2 +- .../solid_queue_monitor/base_controller.rb | 31 +++ .../failed_jobs_controller.rb | 10 +- .../in_progress_jobs_controller.rb | 10 +- .../overview_controller.rb | 10 +- .../solid_queue_monitor/queues_controller.rb | 29 +- .../ready_jobs_controller.rb | 10 +- .../recurring_jobs_controller.rb | 10 +- .../scheduled_jobs_controller.rb | 10 +- .../solid_queue_monitor/workers_controller.rb | 11 +- .../solid_queue_monitor/base_presenter.rb | 52 +++- .../failed_jobs_presenter.rb | 12 +- .../in_progress_jobs_presenter.rb | 9 +- .../solid_queue_monitor/jobs_presenter.rb | 9 +- .../queue_details_presenter.rb | 7 +- .../solid_queue_monitor/queues_presenter.rb | 7 +- .../ready_jobs_presenter.rb | 11 +- .../recurring_jobs_presenter.rb | 11 +- .../scheduled_jobs_presenter.rb | 9 +- .../solid_queue_monitor/workers_presenter.rb | 7 +- .../stylesheet_generator.rb | 16 ++ .../solid_queue_monitor/sorting_spec.rb | 255 ++++++++++++++++++ 22 files changed, 465 insertions(+), 73 deletions(-) create mode 100644 spec/requests/solid_queue_monitor/sorting_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 9e3df3a..93c6c65 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,7 +27,7 @@ Metrics/ClassLength: - 'app/presenters/solid_queue_monitor/job_details_presenter.rb' Metrics/ParameterLists: - Max: 7 + Max: 8 Metrics/ModuleLength: Max: 200 diff --git a/app/controllers/solid_queue_monitor/base_controller.rb b/app/controllers/solid_queue_monitor/base_controller.rb index 19fe641..9275610 100644 --- a/app/controllers/solid_queue_monitor/base_controller.rb +++ b/app/controllers/solid_queue_monitor/base_controller.rb @@ -202,5 +202,36 @@ def filter_params status: params[:status] } end + + def sort_params + { + sort_by: params[:sort_by], + sort_direction: params[:sort_direction] + } + end + + def apply_sorting(relation, allowed_columns, default_column, default_direction = :desc) + column = sort_params[:sort_by] + direction = sort_params[:sort_direction] + column = default_column unless allowed_columns.include?(column) + direction = %w[asc desc].include?(direction) ? direction.to_sym : default_direction + relation.order(column => direction) + end + + def apply_execution_sorting(relation, allowed_columns, default_column, default_direction = :desc) + column = sort_params[:sort_by] + direction = sort_params[:sort_direction] + column = default_column unless allowed_columns.include?(column) + direction = %w[asc desc].include?(direction) ? direction.to_sym : default_direction + + # Columns that exist on the jobs table, not on execution tables + job_table_columns = %w[class_name queue_name] + + if job_table_columns.include?(column) + relation.joins(:job).order("solid_queue_jobs.#{column}" => direction) + else + relation.order(column => direction) + end + end end end diff --git a/app/controllers/solid_queue_monitor/failed_jobs_controller.rb b/app/controllers/solid_queue_monitor/failed_jobs_controller.rb index 0f3e608..696a887 100644 --- a/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/failed_jobs_controller.rb @@ -2,14 +2,18 @@ module SolidQueueMonitor class FailedJobsController < BaseController + SORTABLE_COLUMNS = %w[class_name queue_name created_at].freeze + def index - base_query = SolidQueue::FailedExecution.includes(:job).order(created_at: :desc) - @failed_jobs = paginate(filter_failed_jobs(base_query)) + base_query = SolidQueue::FailedExecution.includes(:job) + sorted_query = apply_execution_sorting(filter_failed_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc) + @failed_jobs = paginate(sorted_query) render_page('Failed Jobs', SolidQueueMonitor::FailedJobsPresenter.new(@failed_jobs[:records], current_page: @failed_jobs[:current_page], total_pages: @failed_jobs[:total_pages], - filters: filter_params).render) + filters: filter_params, + sort: sort_params).render) end def retry diff --git a/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb b/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb index 32f34d9..48e3bf5 100644 --- a/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb @@ -2,14 +2,18 @@ module SolidQueueMonitor class InProgressJobsController < BaseController + SORTABLE_COLUMNS = %w[class_name queue_name created_at].freeze + def index - base_query = SolidQueue::ClaimedExecution.includes(:job).order(created_at: :desc) - @in_progress_jobs = paginate(filter_in_progress_jobs(base_query)) + base_query = SolidQueue::ClaimedExecution.includes(:job) + sorted_query = apply_execution_sorting(filter_in_progress_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc) + @in_progress_jobs = paginate(sorted_query) render_page('In Progress Jobs', SolidQueueMonitor::InProgressJobsPresenter.new(@in_progress_jobs[:records], current_page: @in_progress_jobs[:current_page], total_pages: @in_progress_jobs[:total_pages], - filters: filter_params).render) + filters: filter_params, + sort: sort_params).render) end private diff --git a/app/controllers/solid_queue_monitor/overview_controller.rb b/app/controllers/solid_queue_monitor/overview_controller.rb index 5d88b48..3ade073 100644 --- a/app/controllers/solid_queue_monitor/overview_controller.rb +++ b/app/controllers/solid_queue_monitor/overview_controller.rb @@ -2,12 +2,15 @@ module SolidQueueMonitor class OverviewController < BaseController + SORTABLE_COLUMNS = %w[class_name queue_name created_at].freeze + def index @stats = SolidQueueMonitor::StatsCalculator.calculate @chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate - recent_jobs_query = SolidQueue::Job.order(created_at: :desc).limit(100) - @recent_jobs = paginate(filter_jobs(recent_jobs_query)) + recent_jobs_query = SolidQueue::Job.limit(100) + sorted_query = apply_sorting(filter_jobs(recent_jobs_query), SORTABLE_COLUMNS, 'created_at', :desc) + @recent_jobs = paginate(sorted_query) preload_job_statuses(@recent_jobs[:records]) @@ -31,7 +34,8 @@ def generate_overview_content SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records], current_page: @recent_jobs[:current_page], total_pages: @recent_jobs[:total_pages], - filters: filter_params).render + filters: filter_params, + sort: sort_params).render end end end diff --git a/app/controllers/solid_queue_monitor/queues_controller.rb b/app/controllers/solid_queue_monitor/queues_controller.rb index f9ad5c6..2a3764d 100644 --- a/app/controllers/solid_queue_monitor/queues_controller.rb +++ b/app/controllers/solid_queue_monitor/queues_controller.rb @@ -2,13 +2,16 @@ module SolidQueueMonitor class QueuesController < BaseController + SORTABLE_COLUMNS = %w[queue_name job_count].freeze + QUEUE_DETAILS_SORTABLE_COLUMNS = %w[class_name created_at].freeze + def index - @queues = SolidQueue::Job.group(:queue_name) - .select('queue_name, COUNT(*) as job_count') - .order('job_count DESC') + base_query = SolidQueue::Job.group(:queue_name) + .select('queue_name, COUNT(*) as job_count') + @queues = apply_queue_sorting(base_query) @paused_queues = QueuePauseService.paused_queues - render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues, @paused_queues).render) + render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues, @paused_queues, sort: sort_params).render) end def show @@ -16,9 +19,9 @@ def show @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) + base_query = SolidQueue::Job.where(queue_name: @queue_name) + sorted_query = apply_sorting(filter_queue_jobs(base_query), QUEUE_DETAILS_SORTABLE_COLUMNS, 'created_at', :desc) + @jobs = paginate(sorted_query) preload_job_statuses(@jobs[:records]) @counts = calculate_queue_counts(@queue_name) @@ -31,7 +34,8 @@ def show counts: @counts, current_page: @jobs[:current_page], total_pages: @jobs[:total_pages], - filters: queue_filter_params + filters: queue_filter_params, + sort: sort_params ).render) end @@ -97,5 +101,14 @@ def queue_filter_params status: params[:status] } end + + def apply_queue_sorting(relation) + column = sort_params[:sort_by] + direction = sort_params[:sort_direction] + column = 'job_count' unless SORTABLE_COLUMNS.include?(column) + direction = 'desc' unless %w[asc desc].include?(direction) + + relation.order("#{column} #{direction}") + end end end diff --git a/app/controllers/solid_queue_monitor/ready_jobs_controller.rb b/app/controllers/solid_queue_monitor/ready_jobs_controller.rb index 4448f3f..9ba4b98 100644 --- a/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/ready_jobs_controller.rb @@ -2,14 +2,18 @@ module SolidQueueMonitor class ReadyJobsController < BaseController + SORTABLE_COLUMNS = %w[class_name queue_name priority created_at].freeze + def index - base_query = SolidQueue::ReadyExecution.includes(:job).order(created_at: :desc) - @ready_jobs = paginate(filter_ready_jobs(base_query)) + base_query = SolidQueue::ReadyExecution.includes(:job) + sorted_query = apply_execution_sorting(filter_ready_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc) + @ready_jobs = paginate(sorted_query) render_page('Ready Jobs', SolidQueueMonitor::ReadyJobsPresenter.new(@ready_jobs[:records], current_page: @ready_jobs[:current_page], total_pages: @ready_jobs[:total_pages], - filters: filter_params).render) + filters: filter_params, + sort: sort_params).render) end end end diff --git a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb index ba55b7d..6e5121a 100644 --- a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb @@ -2,14 +2,18 @@ module SolidQueueMonitor class RecurringJobsController < BaseController + SORTABLE_COLUMNS = %w[key class_name queue_name priority].freeze + def index - base_query = filter_recurring_jobs(SolidQueue::RecurringTask.order(:key)) - @recurring_jobs = paginate(base_query) + base_query = filter_recurring_jobs(SolidQueue::RecurringTask.all) + sorted_query = apply_sorting(base_query, SORTABLE_COLUMNS, 'key', :asc) + @recurring_jobs = paginate(sorted_query) render_page('Recurring Jobs', SolidQueueMonitor::RecurringJobsPresenter.new(@recurring_jobs[:records], current_page: @recurring_jobs[:current_page], total_pages: @recurring_jobs[:total_pages], - filters: filter_params).render) + filters: filter_params, + sort: sort_params).render) 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 7409476..bacfc6e 100644 --- a/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb @@ -2,14 +2,18 @@ module SolidQueueMonitor class ScheduledJobsController < BaseController + SORTABLE_COLUMNS = %w[class_name queue_name scheduled_at].freeze + def index - base_query = SolidQueue::ScheduledExecution.includes(:job).order(scheduled_at: :asc) - @scheduled_jobs = paginate(filter_scheduled_jobs(base_query)) + base_query = SolidQueue::ScheduledExecution.includes(:job) + sorted_query = apply_execution_sorting(filter_scheduled_jobs(base_query), SORTABLE_COLUMNS, 'scheduled_at', :asc) + @scheduled_jobs = paginate(sorted_query) render_page('Scheduled Jobs', SolidQueueMonitor::ScheduledJobsPresenter.new(@scheduled_jobs[:records], current_page: @scheduled_jobs[:current_page], total_pages: @scheduled_jobs[:total_pages], - filters: filter_params).render) + filters: filter_params, + sort: sort_params).render) end def create diff --git a/app/controllers/solid_queue_monitor/workers_controller.rb b/app/controllers/solid_queue_monitor/workers_controller.rb index 7fe0040..fdfbdee 100644 --- a/app/controllers/solid_queue_monitor/workers_controller.rb +++ b/app/controllers/solid_queue_monitor/workers_controller.rb @@ -2,16 +2,19 @@ module SolidQueueMonitor class WorkersController < BaseController + SORTABLE_COLUMNS = %w[hostname last_heartbeat_at].freeze + def index - base_query = SolidQueue::Process.order(created_at: :desc) - filtered_query = filter_workers(base_query) - @processes = paginate(filtered_query) + base_query = SolidQueue::Process.all + sorted_query = apply_sorting(filter_workers(base_query), SORTABLE_COLUMNS, 'last_heartbeat_at', :desc) + @processes = paginate(sorted_query) render_page('Workers', SolidQueueMonitor::WorkersPresenter.new( @processes[:records], current_page: @processes[:current_page], total_pages: @processes[:total_pages], - filters: worker_filter_params + filters: worker_filter_params, + sort: sort_params ).render) end diff --git a/app/presenters/solid_queue_monitor/base_presenter.rb b/app/presenters/solid_queue_monitor/base_presenter.rb index 83d06c5..61d07df 100644 --- a/app/presenters/solid_queue_monitor/base_presenter.rb +++ b/app/presenters/solid_queue_monitor/base_presenter.rb @@ -118,6 +118,34 @@ def queue_link(queue_name, css_class: nil) "#{queue_name}" end + def sortable_header(column, label) + return "#{label}" unless @sort + + column_str = column.to_s + is_active = @sort[:sort_by] == column_str + next_direction = is_active && @sort[:sort_direction] == 'asc' ? 'desc' : 'asc' + arrow = sort_arrow(is_active) + css_class = is_active ? 'sortable-header active' : 'sortable-header' + + "#{label}#{arrow}" + end + + def sort_arrow(is_active) + return ' ⇅' unless is_active + + @sort[:sort_direction] == 'asc' ? ' ↑' : ' ↓' + end + + def filter_query_string + params = [] + params << "class_name=#{@filters[:class_name]}" if @filters && @filters[:class_name].present? + params << "queue_name=#{@filters[:queue_name]}" if @filters && @filters[:queue_name].present? + params << "arguments=#{@filters[:arguments]}" if @filters && @filters[:arguments].present? + params << "status=#{@filters[:status]}" if @filters && @filters[:status].present? + + params.empty? ? '' : "&#{params.join('&')}" + end + def request_path if defined?(controller) && controller.respond_to?(:request) controller.request.path @@ -138,14 +166,28 @@ def engine_mount_point private def query_params - params = [] - params << "class_name=#{@filters[:class_name]}" if @filters && @filters[:class_name].present? - params << "queue_name=#{@filters[:queue_name]}" if @filters && @filters[:queue_name].present? - params << "status=#{@filters[:status]}" if @filters && @filters[:status].present? - + params = build_filter_params + build_sort_params params.empty? ? '' : "&#{params.join('&')}" end + def build_filter_params + return [] unless @filters + + filter_keys = %i[class_name queue_name status] + filter_keys.filter_map do |key| + "#{key}=#{@filters[key]}" if @filters[key].present? + end + end + + def build_sort_params + return [] unless @sort + + sort_keys = %i[sort_by sort_direction] + sort_keys.filter_map do |key| + "#{key}=#{@sort[key]}" if @sort[key].present? + end + end + def full_path(route_name, *args) SolidQueueMonitor::Engine.routes.url_helpers.send(route_name, *args) rescue NoMethodError diff --git a/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb b/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb index 1b24e36..cef39e8 100644 --- a/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb @@ -5,11 +5,12 @@ class FailedJobsPresenter < BasePresenter include Rails.application.routes.url_helpers include SolidQueueMonitor::Engine.routes.url_helpers - def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) + def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}) @jobs = jobs @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -60,10 +61,11 @@ def generate_table - Job - Queue + #{sortable_header('class_name', 'Job')} + #{sortable_header('queue_name', 'Queue')} Error Arguments + #{sortable_header('created_at', 'Failed At')} Actions @@ -261,11 +263,9 @@ def generate_row(failed_execution)
#{error[:message].to_s.truncate(100)}
-
- Failed at: #{format_datetime(failed_execution.created_at)} -
#{format_arguments(job.arguments)} + #{format_datetime(failed_execution.created_at)}
- Job - Queue + #{sortable_header('class_name', 'Job')} + #{sortable_header('queue_name', 'Queue')} Arguments - Started At + #{sortable_header('created_at', 'Started At')} Process ID diff --git a/app/presenters/solid_queue_monitor/jobs_presenter.rb b/app/presenters/solid_queue_monitor/jobs_presenter.rb index e7216da..746ca8f 100644 --- a/app/presenters/solid_queue_monitor/jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/jobs_presenter.rb @@ -5,11 +5,12 @@ class JobsPresenter < BasePresenter include Rails.application.routes.url_helpers include SolidQueueMonitor::Engine.routes.url_helpers - def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) + def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}) @jobs = jobs @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -73,11 +74,11 @@ def generate_table ID - Job - Queue + #{sortable_header('class_name', 'Job')} + #{sortable_header('queue_name', 'Queue')} Arguments Status - Created At + #{sortable_header('created_at', 'Created At')} Actions diff --git a/app/presenters/solid_queue_monitor/queue_details_presenter.rb b/app/presenters/solid_queue_monitor/queue_details_presenter.rb index beb1931..c36caa0 100644 --- a/app/presenters/solid_queue_monitor/queue_details_presenter.rb +++ b/app/presenters/solid_queue_monitor/queue_details_presenter.rb @@ -2,7 +2,7 @@ module SolidQueueMonitor class QueueDetailsPresenter < BasePresenter - def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}) + def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}, sort: {}) @queue_name = queue_name @paused = paused @jobs = jobs @@ -10,6 +10,7 @@ def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_page @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -129,10 +130,10 @@ def generate_table ID - Job + #{sortable_header('class_name', 'Job')} Arguments Status - Created At + #{sortable_header('created_at', 'Created At')} Actions diff --git a/app/presenters/solid_queue_monitor/queues_presenter.rb b/app/presenters/solid_queue_monitor/queues_presenter.rb index 13ff926..ed5152a 100644 --- a/app/presenters/solid_queue_monitor/queues_presenter.rb +++ b/app/presenters/solid_queue_monitor/queues_presenter.rb @@ -2,9 +2,10 @@ module SolidQueueMonitor class QueuesPresenter < BasePresenter - def initialize(records, paused_queues = []) + def initialize(records, paused_queues = [], sort: {}) @records = records @paused_queues = paused_queues + @sort = sort end def render @@ -19,9 +20,9 @@ def generate_table - + #{sortable_header('queue_name', 'Queue Name')} - + #{sortable_header('job_count', 'Total Jobs')} diff --git a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb b/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb index 6e62c8b..d3557ad 100644 --- a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb @@ -2,11 +2,12 @@ module SolidQueueMonitor class ReadyJobsPresenter < BasePresenter - def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) + def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}) @jobs = jobs @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -50,11 +51,11 @@ def generate_table
Queue NameStatusTotal JobsReady Jobs Scheduled Jobs Failed Jobs
- - - + #{sortable_header('class_name', 'Job')} + #{sortable_header('queue_name', 'Queue')} + #{sortable_header('priority', 'Priority')} - + #{sortable_header('created_at', '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 a771f69..ecbefe6 100644 --- a/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb @@ -5,11 +5,12 @@ class RecurringJobsPresenter < BasePresenter include Rails.application.routes.url_helpers include SolidQueueMonitor::Engine.routes.url_helpers - def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) + def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}) @jobs = jobs @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -48,11 +49,11 @@ def generate_table
JobQueuePriorityArgumentsCreated At
- - + #{sortable_header('key', 'Key')} + #{sortable_header('class_name', 'Job')} - - + #{sortable_header('queue_name', 'Queue')} + #{sortable_header('priority', 'Priority')} diff --git a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb b/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb index 051d29c..a1283eb 100644 --- a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +++ b/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb @@ -5,11 +5,12 @@ class ScheduledJobsPresenter < BasePresenter include Rails.application.routes.url_helpers include SolidQueueMonitor::Engine.routes.url_helpers - def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) + def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}) @jobs = jobs @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort end def render @@ -140,9 +141,9 @@ def generate_table - - - + #{sortable_header('class_name', 'Job')} + #{sortable_header('queue_name', 'Queue')} + #{sortable_header('scheduled_at', 'Scheduled At')} diff --git a/app/presenters/solid_queue_monitor/workers_presenter.rb b/app/presenters/solid_queue_monitor/workers_presenter.rb index de32a4e..873f31e 100644 --- a/app/presenters/solid_queue_monitor/workers_presenter.rb +++ b/app/presenters/solid_queue_monitor/workers_presenter.rb @@ -5,11 +5,12 @@ class WorkersPresenter < BasePresenter HEARTBEAT_STALE_THRESHOLD = 5.minutes HEARTBEAT_DEAD_THRESHOLD = 10.minutes - def initialize(processes, current_page: 1, total_pages: 1, filters: {}) + def initialize(processes, current_page: 1, total_pages: 1, filters: {}, sort: {}) @processes = processes.to_a # Load records once to avoid multiple queries @current_page = current_page @total_pages = total_pages @filters = filters + @sort = sort preload_claimed_data calculate_summary_stats end @@ -140,10 +141,10 @@ def generate_table - + #{sortable_header('hostname', 'Hostname')} - + #{sortable_header('last_heartbeat_at', 'Last Heartbeat')} diff --git a/app/services/solid_queue_monitor/stylesheet_generator.rb b/app/services/solid_queue_monitor/stylesheet_generator.rb index 6b5e242..862165c 100644 --- a/app/services/solid_queue_monitor/stylesheet_generator.rb +++ b/app/services/solid_queue_monitor/stylesheet_generator.rb @@ -205,6 +205,22 @@ def generate color: var(--text-muted); } + .solid_queue_monitor .sortable-header { + color: var(--text-muted); + text-decoration: none; + cursor: pointer; + transition: color 0.2s; + } + + .solid_queue_monitor .sortable-header:hover { + color: var(--primary-color); + } + + .solid_queue_monitor .sortable-header.active { + color: var(--primary-color); + font-weight: 600; + } + .solid_queue_monitor .status-badge { display: inline-block; padding: 0.25rem 0.5rem; diff --git a/spec/requests/solid_queue_monitor/sorting_spec.rb b/spec/requests/solid_queue_monitor/sorting_spec.rb new file mode 100644 index 0000000..d452c2b --- /dev/null +++ b/spec/requests/solid_queue_monitor/sorting_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Sorting' do + describe 'Ready Jobs sorting' do + let!(:job_a) { create(:solid_queue_job, class_name: 'AJob', queue_name: 'default', created_at: 2.hours.ago) } + let!(:job_b) { create(:solid_queue_job, class_name: 'BJob', queue_name: 'high', created_at: 1.hour.ago) } + let!(:ready_a) { create(:solid_queue_ready_execution, job: job_a, queue_name: 'default', priority: 10) } + let!(:ready_b) { create(:solid_queue_ready_execution, job: job_b, queue_name: 'high', priority: 5) } + + it 'sorts by class_name ascending' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AJob')).to be < response.body.index('BJob') + end + + it 'sorts by class_name descending' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'desc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('BJob')).to be < response.body.index('AJob') + end + + it 'sorts by queue_name' do + get '/ready_jobs', params: { sort_by: 'queue_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('default')).to be < response.body.index('high') + end + + it 'sorts by priority' do + get '/ready_jobs', params: { sort_by: 'priority', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + # Priority 5 should come before priority 10 + expect(response.body.index('BJob')).to be < response.body.index('AJob') + end + + it 'uses default sort when invalid column provided' do + get '/ready_jobs', params: { sort_by: 'invalid_column', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + end + + it 'shows sort indicator arrow' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response.body).to include('↑') + end + + it 'shows descending arrow when sorting descending' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'desc' } + + expect(response.body).to include('↓') + end + + it 'includes sortable header links' do + get '/ready_jobs' + + expect(response.body).to include('sortable-header') + expect(response.body).to include('sort_by=class_name') + end + + it 'shows default sort indicator on unsorted columns' do + get '/ready_jobs' + + expect(response.body).to include('⇅') + end + end + + describe 'Scheduled Jobs sorting' do + let!(:job_a) { create(:solid_queue_job, class_name: 'AJob') } + let!(:job_b) { create(:solid_queue_job, class_name: 'BJob') } + let!(:scheduled_a) { create(:solid_queue_scheduled_execution, job: job_a, scheduled_at: 2.hours.from_now) } + let!(:scheduled_b) { create(:solid_queue_scheduled_execution, job: job_b, scheduled_at: 1.hour.from_now) } + + it 'sorts by scheduled_at ascending by default' do + get '/scheduled_jobs' + + expect(response).to have_http_status(:ok) + # Earlier scheduled time should come first + expect(response.body.index('BJob')).to be < response.body.index('AJob') + end + + it 'sorts by scheduled_at descending' do + get '/scheduled_jobs', params: { sort_by: 'scheduled_at', sort_direction: 'desc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AJob')).to be < response.body.index('BJob') + end + + it 'sorts by class_name' do + get '/scheduled_jobs', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AJob')).to be < response.body.index('BJob') + end + end + + describe 'Failed Jobs sorting' do + let!(:job_a) { create(:solid_queue_job, class_name: 'AFailedJob', queue_name: 'default') } + let!(:job_b) { create(:solid_queue_job, class_name: 'BFailedJob', queue_name: 'high') } + let!(:failed_a) { create(:solid_queue_failed_execution, job: job_a, created_at: 2.hours.ago) } + let!(:failed_b) { create(:solid_queue_failed_execution, job: job_b, created_at: 1.hour.ago) } + + it 'sorts by created_at descending by default' do + get '/failed_jobs' + + expect(response).to have_http_status(:ok) + # More recent should come first + expect(response.body.index('BFailedJob')).to be < response.body.index('AFailedJob') + end + + it 'sorts by class_name ascending' do + get '/failed_jobs', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AFailedJob')).to be < response.body.index('BFailedJob') + end + + it 'sorts by queue_name' do + get '/failed_jobs', params: { sort_by: 'queue_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + end + end + + describe 'Recurring Jobs sorting' do + before do + # Create recurring tasks directly + SolidQueue::RecurringTask.create!(key: 'a_task', class_name: 'ARecurringJob', queue_name: 'default', schedule: '0 * * * *') + SolidQueue::RecurringTask.create!(key: 'b_task', class_name: 'BRecurringJob', queue_name: 'high', schedule: '0 * * * *') + end + + it 'sorts by key ascending by default' do + get '/recurring_jobs' + + expect(response).to have_http_status(:ok) + expect(response.body.index('a_task')).to be < response.body.index('b_task') + end + + it 'sorts by key descending' do + get '/recurring_jobs', params: { sort_by: 'key', sort_direction: 'desc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('b_task')).to be < response.body.index('a_task') + end + + it 'sorts by class_name' do + get '/recurring_jobs', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('ARecurringJob')).to be < response.body.index('BRecurringJob') + end + end + + describe 'Workers sorting' do + let!(:worker_a) { create(:solid_queue_process, hostname: 'alpha-host', last_heartbeat_at: 1.minute.ago) } + let!(:worker_b) { create(:solid_queue_process, hostname: 'beta-host', last_heartbeat_at: 2.minutes.ago) } + + it 'sorts by last_heartbeat_at descending by default' do + get '/workers' + + expect(response).to have_http_status(:ok) + # More recent heartbeat should come first + expect(response.body.index('alpha-host')).to be < response.body.index('beta-host') + end + + it 'sorts by hostname ascending' do + get '/workers', params: { sort_by: 'hostname', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('alpha-host')).to be < response.body.index('beta-host') + end + + it 'sorts by hostname descending' do + get '/workers', params: { sort_by: 'hostname', sort_direction: 'desc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('beta-host')).to be < response.body.index('alpha-host') + end + end + + describe 'Overview page sorting' do + let!(:job_a) { create(:solid_queue_job, class_name: 'AOverviewJob', created_at: 2.hours.ago) } + let!(:job_b) { create(:solid_queue_job, class_name: 'BOverviewJob', created_at: 1.hour.ago) } + + it 'sorts by created_at descending by default' do + get '/' + + expect(response).to have_http_status(:ok) + # More recent should come first + expect(response.body.index('BOverviewJob')).to be < response.body.index('AOverviewJob') + end + + it 'sorts by class_name ascending' do + get '/', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AOverviewJob')).to be < response.body.index('BOverviewJob') + end + end + + describe 'Queue details sorting' do + let!(:job_a) { create(:solid_queue_job, class_name: 'AQueueJob', queue_name: 'test_queue', created_at: 2.hours.ago) } + let!(:job_b) { create(:solid_queue_job, class_name: 'BQueueJob', queue_name: 'test_queue', created_at: 1.hour.ago) } + + it 'sorts by created_at descending by default' do + get '/queues/test_queue' + + expect(response).to have_http_status(:ok) + expect(response.body.index('BQueueJob')).to be < response.body.index('AQueueJob') + end + + it 'sorts by class_name ascending' do + get '/queues/test_queue', params: { sort_by: 'class_name', sort_direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response.body.index('AQueueJob')).to be < response.body.index('BQueueJob') + end + end + + describe 'Sorting with filters preserved' do + let!(:job) { create(:solid_queue_job, class_name: 'FilteredJob', queue_name: 'filtered_queue') } + let!(:ready) { create(:solid_queue_ready_execution, job: job, queue_name: 'filtered_queue') } + + it 'preserves filters when sorting' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'asc', class_name: 'Filtered' } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('FilteredJob') + expect(response.body).to include('class_name=Filtered') + end + end + + describe 'Sorting with pagination' do + before do + # Create more jobs than a single page + 12.times do |i| + job = create(:solid_queue_job, class_name: "Job#{format('%02d', i)}") + create(:solid_queue_ready_execution, job: job) + end + end + + it 'preserves sorting when navigating pages' do + get '/ready_jobs', params: { sort_by: 'class_name', sort_direction: 'asc', page: 2 } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('sort_by=class_name') + expect(response.body).to include('sort_direction=asc') + end + end +end
KeyJobScheduleQueuePriorityLast Updated
JobQueueScheduled AtArguments
KindHostnamePID QueuesLast HeartbeatStatus Jobs Processing Actions