diff --git a/.gitignore b/.gitignore
index a310286..8bc84e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,7 @@
# Claude Code personal workflows (local only)
.claude/
CLAUDE.md
+
+# Codex personal workflows (local only)
+.agents/
+AGENTS.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0315a3e..5e6be7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog
+## [2.0.0] - 2026-05-12
+
+### Changed
+
+- **Major architectural refactor**: the dashboard now renders via standard Rails ERB views, helpers, and partials instead of Ruby presenters emitting HTML strings. Configuration, mount point, URLs, HTTP Basic auth, and CSP nonce support are unchanged.
+- CSS and JavaScript are now served as external assets via a new `AssetsController` with content-hashed URLs and `Cache-Control: immutable`. No Sprockets or Propshaft dependency is required, so Rails API-only host applications keep working as in v1.x.
+- Dashboard pages now work under a strict `script-src 'self'; style-src 'self'` Content Security Policy without requiring host-app nonce configuration. Nonce-on-link-tag behavior is preserved for hosts running nonce-only CSPs.
+- Runtime configuration such as auto-refresh interval, auto-refresh enabled state, and theme preference is now passed via `
` attributes instead of inline JavaScript interpolation.
+
+### Removed
+
+- `SolidQueueMonitor::HtmlGenerator`, `StylesheetGenerator`, `ChartPresenter`, `BasePresenter`, and all `*Presenter` classes. These were internal and not documented as public API. Users who reached into them via monkey patches will need to migrate to view/helper overrides.
+- `SolidQueueMonitor::BaseController#render_page` now that Rails implicit rendering handles all pages.
+- The brief inline `
- HTML
- end
-
- def generate_row(failed_execution)
- job = failed_execution.job
- error = parse_error(failed_execution.error)
-
- <<-HTML
-
-
-
-
-
- Queued at: #{format_datetime(job.created_at)}
-
-
-
- #{queue_link(job.queue_name)}
-
-
- #{error[:message].to_s.truncate(100)}
-
- #{format_arguments(job.arguments)}
- #{format_datetime(failed_execution.created_at)}
-
-
- Retry
- Discard
-
-
-
- HTML
- end
-
- def parse_error(error)
- return { message: 'Unknown error', backtrace: '' } unless error
-
- if error.is_a?(String)
- { message: error, backtrace: '' }
- elsif error.is_a?(Hash)
- message = error['message'] || error[:message] || 'Unknown error'
- backtrace = error['backtrace'] || error[:backtrace] || []
- backtrace = backtrace.join("\n") if backtrace.is_a?(Array)
- { message: message, backtrace: backtrace }
- else
- { message: 'Unknown error format', backtrace: error.to_s }
- end
- end
-
- def get_queue_name(failed_execution, job)
- # Try to get queue_name from failed_execution if the method exists
- if failed_execution.respond_to?(:queue_name) && !failed_execution.queue_name.nil?
- failed_execution.queue_name
- else
- # Fall back to job's queue_name
- job.queue_name
- end
- rescue NoMethodError
- # If there's an error accessing queue_name, fall back to job's queue_name
- job.queue_name
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb b/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb
deleted file mode 100644
index c07447a..0000000
--- a/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class InProgressJobsPresenter < BasePresenter
- include SolidQueueMonitor::Engine.routes.url_helpers
-
- 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
- section_wrapper('In Progress Jobs',
- generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages))
- end
-
- private
-
- def generate_filter_form
- <<-HTML
-
- HTML
- end
-
- def generate_table
- <<-HTML
-
-
-
-
- #{sortable_header('class_name', 'Job')}
- #{sortable_header('queue_name', 'Queue')}
- Arguments
- #{sortable_header('created_at', 'Started At')}
- Process ID
-
-
-
- #{@jobs.map { |execution| generate_row(execution) }.join}
-
-
-
- HTML
- end
-
- def generate_row(execution)
- job = execution.job
- <<-HTML
-
-
-
-
- Queued at: #{format_datetime(job.created_at)}
-
-
- #{queue_link(job.queue_name)}
- #{format_arguments(job.arguments)}
- #{format_datetime(execution.created_at)}
- #{execution.process_id}
-
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/job_details_presenter.rb b/app/presenters/solid_queue_monitor/job_details_presenter.rb
deleted file mode 100644
index 18a4499..0000000
--- a/app/presenters/solid_queue_monitor/job_details_presenter.rb
+++ /dev/null
@@ -1,707 +0,0 @@
-# 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, nonce: nil)
- @job = job
- @failed_execution = failed_execution
- @claimed_execution = claimed_execution
- @scheduled_execution = scheduled_execution
- @recent_executions = recent_executions
- @back_path = back_path
- @nonce = nonce
- 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 script_tag_open
- @nonce ? %(
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/jobs_presenter.rb b/app/presenters/solid_queue_monitor/jobs_presenter.rb
deleted file mode 100644
index 7b3511d..0000000
--- a/app/presenters/solid_queue_monitor/jobs_presenter.rb
+++ /dev/null
@@ -1,144 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- 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: {}, sort: {})
- @jobs = jobs
- @current_page = current_page
- @total_pages = total_pages
- @filters = filters
- @sort = sort
- end
-
- def render
- <<-HTML
-
-
-
Recent Jobs
- #{generate_filter_form}
- #{generate_table}
- #{generate_pagination(@current_page, @total_pages)}
-
-
- HTML
- end
-
- private
-
- def generate_filter_form
- <<-HTML
-
- HTML
- end
-
- def generate_table
- <<-HTML
-
-
-
-
- ID
- #{sortable_header('class_name', 'Job')}
- #{sortable_header('queue_name', 'Queue')}
- Arguments
- Status
- #{sortable_header('created_at', 'Created At')}
- Actions
-
-
-
- #{@jobs.map { |job| generate_row(job) }.join}
-
-
-
- HTML
- end
-
- def generate_row(job)
- status = job_status(job)
-
- # Build the row HTML
- row_html = <<-HTML
-
- #{job.id}
- #{job.class_name}
- #{queue_link(job.queue_name)}
- #{format_arguments(job.arguments)}
- #{status}
- #{format_datetime(job.created_at)}
- HTML
-
- # Add actions column only for failed jobs
- if status == 'failed'
- # Find the failed execution record for this job
- 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/queue_details_presenter.rb b/app/presenters/solid_queue_monitor/queue_details_presenter.rb
deleted file mode 100644
index 353d691..0000000
--- a/app/presenters/solid_queue_monitor/queue_details_presenter.rb
+++ /dev/null
@@ -1,195 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class QueueDetailsPresenter < BasePresenter
- def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}, sort: {})
- @queue_name = queue_name
- @paused = paused
- @jobs = jobs
- @counts = counts
- @current_page = current_page
- @total_pages = total_pages
- @filters = filters
- @sort = sort
- 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
-
- 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
-
- HTML
- end
-
- def generate_filter_form
- <<-HTML
-
- HTML
- end
-
- def generate_table
- return 'No jobs in this queue
' if @jobs.empty?
-
- <<-HTML
-
-
-
-
- ID
- #{sortable_header('class_name', 'Job')}
- Arguments
- Status
- #{sortable_header('created_at', 'Created At')}
- Actions
-
-
-
- #{@jobs.map { |job| generate_row(job) }.join}
-
-
-
- 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
deleted file mode 100644
index ee8ef71..0000000
--- a/app/presenters/solid_queue_monitor/queues_presenter.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class QueuesPresenter < BasePresenter
- def initialize(records, paused_queues = [], sort: {}, queue_stats: {})
- @records = records
- @paused_queues = paused_queues
- @sort = sort
- @queue_stats = queue_stats
- end
-
- def render
- section_wrapper('Queues', generate_table)
- end
-
- private
-
- def generate_table
- <<-HTML
-
-
-
-
- #{sortable_header('queue_name', 'Queue Name')}
- Status
- #{sortable_header('job_count', 'Total Jobs')}
- Ready Jobs
- Scheduled Jobs
- Failed Jobs
- Actions
-
-
-
- #{@records.map { |queue| generate_row(queue) }.join}
-
-
-
- HTML
- end
-
- def generate_row(queue)
- queue_name = queue.queue_name || 'default'
- paused = @paused_queues.include?(queue_name)
-
- <<-HTML
-
- #{queue_link(queue_name)}
- #{status_badge(paused)}
- #{queue.job_count}
- #{@queue_stats.dig(:ready, queue_name) || 0}
- #{@queue_stats.dig(:scheduled, queue_name) || 0}
- #{@queue_stats.dig(:failed, queue_name) || 0}
- #{action_button(queue_name, paused)}
-
- HTML
- end
-
- def status_badge(paused)
- if paused
- 'Paused '
- else
- 'Active '
- end
- end
-
- def action_button(queue_name, paused)
- if paused
- <<-HTML
-
- HTML
- else
- <<-HTML
-
- HTML
- end
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb b/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb
deleted file mode 100644
index d3557ad..0000000
--- a/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class ReadyJobsPresenter < BasePresenter
- 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
- section_wrapper('Ready Jobs',
- generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages))
- end
-
- private
-
- def generate_filter_form
- <<-HTML
-
- HTML
- end
-
- def generate_table
- <<-HTML
-
-
-
-
- #{sortable_header('class_name', 'Job')}
- #{sortable_header('queue_name', 'Queue')}
- #{sortable_header('priority', 'Priority')}
- Arguments
- #{sortable_header('created_at', 'Created At')}
-
-
-
- #{@jobs.map { |execution| generate_row(execution) }.join}
-
-
-
- HTML
- end
-
- def generate_row(execution)
- <<-HTML
-
- #{execution.job.class_name}
- #{queue_link(execution.queue_name)}
- #{execution.priority}
- #{format_arguments(execution.job.arguments)}
- #{format_datetime(execution.created_at)}
-
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb b/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb
deleted file mode 100644
index ecbefe6..0000000
--- a/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- 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: {}, sort: {})
- @jobs = jobs
- @current_page = current_page
- @total_pages = total_pages
- @filters = filters
- @sort = sort
- end
-
- def render
- section_wrapper('Recurring Jobs',
- generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages))
- end
-
- private
-
- def generate_filter_form
- <<-HTML
-
- HTML
- end
-
- def generate_table
- <<-HTML
-
-
-
-
- #{sortable_header('key', 'Key')}
- #{sortable_header('class_name', 'Job')}
- Schedule
- #{sortable_header('queue_name', 'Queue')}
- #{sortable_header('priority', 'Priority')}
- Last Updated
-
-
-
- #{@jobs.map { |task| generate_row(task) }.join}
-
-
-
- HTML
- end
-
- def generate_row(task)
- <<-HTML
-
- #{task.key}
- #{task.class_name}
- #{task.schedule}
- #{queue_link(task.queue_name)}
- #{task.priority || 'Default'}
- #{format_datetime(task.updated_at)}
-
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb b/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb
deleted file mode 100644
index 5318f1f..0000000
--- a/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb
+++ /dev/null
@@ -1,178 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- 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: {}, sort: {}, nonce: nil)
- @jobs = jobs
- @current_page = current_page
- @total_pages = total_pages
- @filters = filters
- @sort = sort
- @nonce = nonce
- end
-
- def render
- section_wrapper('Scheduled Jobs', generate_filter_form + generate_table_with_actions)
- end
-
- private
-
- def script_tag_open
- @nonce ? %(
- HTML
- end
-
- def generate_table
- <<-HTML
-
- #{generate_pagination(@current_page, @total_pages)}
- HTML
- end
-
- def generate_row(execution)
- <<-HTML
-
-
-
-
- #{execution.job.class_name}
- #{queue_link(execution.queue_name)}
- #{format_datetime(execution.scheduled_at)}
- #{format_arguments(execution.job.arguments)}
-
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/search_results_presenter.rb b/app/presenters/solid_queue_monitor/search_results_presenter.rb
deleted file mode 100644
index 83a3cab..0000000
--- a/app/presenters/solid_queue_monitor/search_results_presenter.rb
+++ /dev/null
@@ -1,190 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class SearchResultsPresenter < BasePresenter
- def initialize(query, results)
- @query = query
- @results = results
- end
-
- def render
- section_wrapper('Search Results', generate_content)
- end
-
- private
-
- def generate_content
- if @query.blank?
- generate_empty_query_message
- elsif total_count.zero?
- generate_no_results_message
- else
- generate_results_summary + generate_all_sections
- end
- end
-
- def generate_empty_query_message
- <<-HTML
-
-
Enter a search term in the header to find jobs across all categories.
-
- HTML
- end
-
- def generate_no_results_message
- <<-HTML
-
-
No results found for "#{escape_html(@query)}"
-
0 results
-
- HTML
- end
-
- def generate_results_summary
- <<-HTML
-
-
Found #{total_count} #{total_count == 1 ? 'result' : 'results'} for "#{escape_html(@query)}"
-
- HTML
- end
-
- def generate_all_sections
- sections = []
- sections << generate_ready_section if @results[:ready].any?
- sections << generate_scheduled_section if @results[:scheduled].any?
- sections << generate_failed_section if @results[:failed].any?
- sections << generate_in_progress_section if @results[:in_progress].any?
- sections << generate_completed_section if @results[:completed].any?
- sections << generate_recurring_section if @results[:recurring].any?
- sections.join
- end
-
- def generate_ready_section
- generate_section('Ready Jobs', @results[:ready]) do |execution|
- generate_job_row(execution.job, execution.queue_name, execution.created_at)
- end
- end
-
- def generate_scheduled_section
- generate_section('Scheduled Jobs', @results[:scheduled]) do |execution|
- generate_job_row(execution.job, execution.queue_name, execution.scheduled_at, 'Scheduled for')
- end
- end
-
- def generate_failed_section
- generate_section('Failed Jobs', @results[:failed]) do |execution|
- generate_failed_row(execution)
- end
- end
-
- def generate_in_progress_section
- generate_section('In Progress Jobs', @results[:in_progress]) do |execution|
- generate_job_row(execution.job, execution.job.queue_name, execution.created_at, 'Started at')
- end
- end
-
- def generate_completed_section
- generate_section('Completed Jobs', @results[:completed]) do |job|
- generate_completed_row(job)
- end
- end
-
- def generate_recurring_section
- generate_section('Recurring Tasks', @results[:recurring]) do |task|
- generate_recurring_row(task)
- end
- end
-
- def generate_section(title, items, &block)
- <<-HTML
-
-
#{title} (#{items.size})
-
-
-
-
- #{section_headers(title)}
-
-
-
- #{items.map(&block).join}
-
-
-
-
- HTML
- end
-
- def section_headers(title)
- case title
- when 'Recurring Tasks'
- 'Key Class Schedule Queue '
- when 'Failed Jobs'
- 'Job Queue Error Failed At '
- when 'Completed Jobs'
- 'Job Queue Arguments Completed At '
- else
- 'Job Queue Arguments Time '
- end
- end
-
- def generate_job_row(job, queue_name, time, time_label = 'Created at')
- <<-HTML
-
- #{job.class_name}
- #{queue_link(queue_name)}
- #{format_arguments(job.arguments)}
-
- #{time_label}: #{format_datetime(time)}
-
-
- HTML
- end
-
- def generate_failed_row(execution)
- job = execution.job
- <<-HTML
-
- #{job.class_name}
- #{queue_link(job.queue_name)}
- #{escape_html(execution.error.to_s.truncate(100))}
-
- #{format_datetime(execution.created_at)}
-
-
- HTML
- end
-
- def generate_completed_row(job)
- <<-HTML
-
- #{job.class_name}
- #{queue_link(job.queue_name)}
- #{format_arguments(job.arguments)}
-
- #{format_datetime(job.finished_at)}
-
-
- HTML
- end
-
- def generate_recurring_row(task)
- <<-HTML
-
- #{task.key}
- #{task.class_name || '-'}
- #{task.schedule}
- #{queue_link(task.queue_name)}
-
- HTML
- end
-
- def total_count
- @total_count ||= @results.values.sum(&:size)
- end
-
- def escape_html(text)
- text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/stats_presenter.rb b/app/presenters/solid_queue_monitor/stats_presenter.rb
deleted file mode 100644
index 9c46002..0000000
--- a/app/presenters/solid_queue_monitor/stats_presenter.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class StatsPresenter < BasePresenter
- def initialize(stats)
- @stats = stats
- end
-
- def render
- <<-HTML
-
-
Queue Statistics
-
- #{generate_stat_card('Active Jobs', @stats[:active_jobs])}
- #{generate_stat_card('Ready', @stats[:ready])}
- #{generate_stat_card('In Progress', @stats[:in_progress])}
- #{generate_stat_card('Scheduled', @stats[:scheduled])}
- #{generate_stat_card('Recurring', @stats[:recurring])}
- #{generate_stat_card('Failed', @stats[:failed])}
-
-
- HTML
- end
-
- private
-
- def generate_stat_card(title, value)
- <<-HTML
-
- HTML
- end
- end
-end
diff --git a/app/presenters/solid_queue_monitor/workers_presenter.rb b/app/presenters/solid_queue_monitor/workers_presenter.rb
deleted file mode 100644
index aa67d2b..0000000
--- a/app/presenters/solid_queue_monitor/workers_presenter.rb
+++ /dev/null
@@ -1,325 +0,0 @@
-# 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: {}, 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
-
- 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
-
- HTML
- end
-
- def kind_options
- kinds = %w[Worker Dispatcher Scheduler]
- kinds.map do |kind|
- selected = @filters[:kind] == kind ? 'selected' : ''
- "#{kind} "
- 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?
-
- suffix = @dead_count > 1 ? 'es' : ''
- message = "Remove all #{@dead_count} dead process#{suffix}? " \
- 'This will clean up processes that have stopped sending heartbeats.'
-
- <<-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
-
-
-
-
- Kind
- #{sortable_header('hostname', 'Hostname')}
- PID
- Queues
- #{sortable_header('last_heartbeat_at', 'Last Heartbeat')}
- Status
- Jobs Processing
- Actions
-
-
-
- #{@processes.map { |process| generate_row(process) }.join}
-
-
-
- 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/asset_cache.rb b/app/services/solid_queue_monitor/asset_cache.rb
new file mode 100644
index 0000000..ad51f75
--- /dev/null
+++ b/app/services/solid_queue_monitor/asset_cache.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'digest'
+
+module SolidQueueMonitor
+ class AssetCache
+ ASSET_ROOT = SolidQueueMonitor::Engine.root.join('app/assets').freeze
+ SUBDIRS_BY_EXT = { '.css' => 'stylesheets', '.js' => 'javascripts' }.freeze
+ MUTEX = Mutex.new
+
+ @entries = {}
+
+ class << self
+ def fetch_by_name(file_name)
+ path = path_for(file_name)
+ return nil unless path&.file?
+
+ cached = @entries[path.to_s]
+ return cached if cached && cached[:mtime] == path.mtime
+
+ MUTEX.synchronize do
+ cached = @entries[path.to_s]
+ return cached if cached && cached[:mtime] == path.mtime
+
+ content = path.read
+ @entries[path.to_s] = {
+ content: content,
+ mtime: path.mtime,
+ etag: Digest::SHA256.hexdigest(content)[0, 16]
+ }
+ end
+ end
+
+ def fingerprint_for(file_name)
+ fetch_by_name(file_name)&.dig(:etag)
+ end
+
+ def clear!
+ MUTEX.synchronize { @entries = {} }
+ end
+
+ private
+
+ def path_for(file_name)
+ ext = File.extname(file_name)
+ subdir = SUBDIRS_BY_EXT[ext]
+ return nil unless subdir
+
+ candidate = ASSET_ROOT.join(subdir, 'solid_queue_monitor', file_name).expand_path
+ return nil unless candidate.to_s.start_with?(ASSET_ROOT.to_s)
+
+ candidate
+ end
+ end
+ end
+end
diff --git a/app/services/solid_queue_monitor/chart_presenter.rb b/app/services/solid_queue_monitor/chart_presenter.rb
deleted file mode 100644
index f7303bf..0000000
--- a/app/services/solid_queue_monitor/chart_presenter.rb
+++ /dev/null
@@ -1,239 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class ChartPresenter
- CHART_WIDTH = 1200
- CHART_HEIGHT = 280
- PADDING = { top: 40, right: 30, bottom: 60, left: 60 }.freeze
- COLORS = {
- created: '#3b82f6', # Blue
- completed: '#10b981', # Green
- failed: '#ef4444' # Red
- }.freeze
-
- def initialize(chart_data)
- @data = chart_data
- @plot_width = CHART_WIDTH - PADDING[:left] - PADDING[:right]
- @plot_height = CHART_HEIGHT - PADDING[:top] - PADDING[:bottom]
- end
-
- def render
- <<-HTML
-
-
-
-
- #{render_svg}
-
- #{render_legend}
-
-
- #{render_tooltip}
- HTML
- end
-
- private
-
- def render_summary
- totals = @data[:totals] || { created: 0, completed: 0, failed: 0 }
- <<-HTML
-
- #{totals[:created]} created
- ·
- #{totals[:completed]} completed
- ·
- #{totals[:failed]} failed
-
- HTML
- end
-
- def render_time_select
- options = @data[:available_ranges].map do |key, label|
- selected = key == @data[:time_range] ? 'selected' : ''
- "#{label} "
- end.join
-
- <<-HTML
-
-
- #{options}
-
-
- HTML
- end
-
- def render_svg
- return render_empty_state if all_series_empty?
-
- max_value = calculate_max_value
- max_value = 10 if max_value.zero?
-
- <<-SVG
-
- #{render_grid_lines(max_value)}
- #{render_axes}
- #{render_x_labels}
- #{render_y_labels(max_value)}
- #{render_series_line(:failed, max_value)}
- #{render_series_line(:completed, max_value)}
- #{render_series_line(:created, max_value)}
- #{render_series_points(:failed, max_value)}
- #{render_series_points(:completed, max_value)}
- #{render_series_points(:created, max_value)}
-
- SVG
- end
-
- def all_series_empty?
- %i[created completed failed].all? { |series| series_empty?(series) }
- end
-
- def series_empty?(series)
- @data[series].nil? || @data[series].all?(&:zero?)
- end
-
- def render_empty_state
- <<-HTML
-
- No job activity in this time range
-
- HTML
- end
-
- def render_series_line(series, max_value)
- return '' if series_empty?(series)
-
- render_line(series, max_value)
- end
-
- def render_series_points(series, max_value)
- return '' if series_empty?(series)
-
- render_data_points(series, max_value)
- end
-
- def calculate_max_value
- all_values = @data[:created] + @data[:completed] + @data[:failed]
- max = all_values.max || 0
- # Round up to nice number
- return 10 if max <= 10
-
- magnitude = 10**Math.log10(max).floor
- ((max.to_f / magnitude).ceil * magnitude)
- end
-
- def render_grid_lines(_max_value)
- lines = []
- 5.times do |i|
- y = PADDING[:top] + (@plot_height * i / 4.0)
- lines << ""
- end
- lines.join("\n")
- end
-
- def render_axes
- <<-SVG
-
-
- SVG
- end
-
- def render_x_labels
- labels = @data[:labels]
- return '' if labels.empty?
-
- # Show fewer labels if too many
- step = labels.size > 12 ? (labels.size / 6.0).ceil : 1
-
- label_elements = labels.each_with_index.map do |label, i|
- next unless (i % step).zero? || i == labels.size - 1
-
- x = PADDING[:left] + (@plot_width * i / (labels.size - 1).to_f)
- "#{label} "
- end.compact
-
- label_elements.join("\n")
- end
-
- def render_y_labels(max_value)
- labels = []
- 5.times do |i|
- value = (max_value * (4 - i) / 4.0).round
- y = PADDING[:top] + (@plot_height * i / 4.0)
- labels << "#{value} "
- end
- labels.join("\n")
- end
-
- def render_line(series, max_value)
- points = calculate_points(series, max_value)
- return '' if points.empty?
-
- points_str = points.map { |p| "#{p[:x]},#{p[:y]}" }.join(' ')
-
- ""
- end
-
- def render_data_points(series, max_value)
- points = calculate_points(series, max_value)
- values = @data[series]
-
- points.each_with_index.map do |point, i|
- <<-SVG
-
- SVG
- end.join("\n")
- end
-
- def calculate_points(series, max_value)
- values = @data[series]
- return [] if values.blank?
-
- values.each_with_index.map do |value, i|
- x = PADDING[:left] + (@plot_width * i / (values.size - 1).to_f)
- y = CHART_HEIGHT - PADDING[:bottom] - (@plot_height * value / max_value.to_f)
- { x: x.round(2), y: y.round(2) }
- end
- end
-
- def render_legend
- <<-HTML
-
-
-
- Created
-
-
-
- Completed
-
-
-
- Failed
-
-
- HTML
- end
-
- def render_tooltip
- <<-HTML
-
- HTML
- end
- end
-end
diff --git a/app/services/solid_queue_monitor/html_generator.rb b/app/services/solid_queue_monitor/html_generator.rb
deleted file mode 100644
index 1ff15aa..0000000
--- a/app/services/solid_queue_monitor/html_generator.rb
+++ /dev/null
@@ -1,427 +0,0 @@
-# frozen_string_literal: true
-
-module SolidQueueMonitor
- class HtmlGenerator
- include Rails.application.routes.url_helpers
- include SolidQueueMonitor::Engine.routes.url_helpers
-
- def initialize(title:, content:, message: nil, message_type: nil, search_query: nil, nonce: nil)
- @title = title
- @content = content
- @message = message
- @message_type = message_type
- @search_query = search_query
- @nonce = nonce
- end
-
- def generate
- <<-HTML
-
-
-
- Solid Queue Monitor - #{@title}
- #{generate_head}
-
-
- #{generate_body}
-
-
- HTML
- end
-
- private
-
- def generate_head
- <<-HTML
-
-
- #{style_tag_open}
- #{SolidQueueMonitor::StylesheetGenerator.new.generate}
-
- HTML
- end
-
- def generate_body
- <<-HTML
- #{render_message}
-
- #{generate_header}
-
-
#{@title}
- #{@content}
-
- #{generate_footer}
-
- #{generate_auto_refresh_script}
- #{generate_chart_script}
- HTML
- end
-
- def render_message
- return '' unless @message
-
- class_name = @message_type == 'success' ? 'message-success' : 'message-error'
- <<-HTML
- #{@message}
- #{script_tag_open}
- document.addEventListener('DOMContentLoaded', function() {
- var el = document.getElementById('flash-message');
- if (!el) return;
- setTimeout(function() {
- el.classList.add('is-fading');
- setTimeout(function() { el.classList.add('is-hidden'); }, 500);
- }, 5000);
- });
-
- HTML
- end
-
- def generate_header
- nav_items = [
- { path: root_path, label: 'Overview', match: 'Overview' },
- { path: ready_jobs_path, label: 'Ready Jobs', match: 'Ready Jobs' },
- { path: in_progress_jobs_path, label: 'In Progress Jobs', match: 'In Progress' },
- { 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: workers_path, label: 'Workers', match: 'Workers' }
- ]
-
- nav_links = nav_items.map do |item|
- active_class = @title&.include?(item[:match]) ? 'active' : ''
- "#{item[:label]} "
- end.join("\n ")
-
- <<-HTML
-
- HTML
- end
-
- def generate_footer
- <<-HTML
-
- HTML
- end
-
- def generate_search_box
- search_value = @search_query ? escape_html(@search_query) : ''
- <<-HTML
-
- HTML
- end
-
- def escape_html(text)
- text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
- end
-
- def style_tag_open
- @nonce ? %(