From cec1e4a8bb9e0aba48a8168c35a1edab9fb9b49b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Fri, 22 May 2026 15:20:15 -0400 Subject: [PATCH 1/6] Add bootable dashboard with flat routes and all section controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flat engine routes under /solid_stack (dashboard, jobs, failed_jobs, queues, processes, cache, cable) — no sub-namespace within the engine - Controllers for each section using ::SolidQueue/Cache/Cable:: top-level constants to avoid Ruby namespace lookup ambiguity - Turbo Stream destroy for jobs (remove row or replace with empty state) - Layout with full nav, flash messages, inline CSS via helper - CSS partials (_01_base through _06_buttons) using sqw- prefix - Require solid_queue/cache/cable explicitly in engine.rb Co-Authored-By: Claude Sonnet 4.6 --- .../stylesheets/solid_stack_web/_01_base.css | 32 +++++++++++ .../solid_stack_web/_02_layout.css | 56 +++++++++++++++++++ .../stylesheets/solid_stack_web/_03_stats.css | 31 ++++++++++ .../stylesheets/solid_stack_web/_04_table.css | 39 +++++++++++++ .../solid_stack_web/_05_badges.css | 20 +++++++ .../solid_stack_web/_06_buttons.css | 40 +++++++++++++ .../solid_stack_web/application.css | 16 +----- .../solid_stack_web/cable_controller.rb | 8 +++ .../solid_stack_web/cache_controller.rb | 8 +++ .../solid_stack_web/dashboard_controller.rb | 15 +++++ .../solid_stack_web/failed_jobs_controller.rb | 25 +++++++++ .../solid_stack_web/jobs_controller.rb | 42 ++++++++++++++ .../solid_stack_web/processes_controller.rb | 7 +++ .../solid_stack_web/queues_controller.rb | 26 +++++++++ .../solid_stack_web/application.html.erb | 23 +++++--- .../solid_stack_web/cable/index.html.erb | 27 +++++++++ .../cable/messages/index.html.erb | 27 +++++++++ .../cache/entries/index.html.erb | 14 +++++ .../solid_stack_web/cache/index.html.erb | 14 +++++ .../solid_stack_web/dashboard/index.html.erb | 34 +++++++++++ .../failed_jobs/index.html.erb | 41 ++++++++++++++ .../solid_stack_web/jobs/_empty.html.erb | 3 + .../jobs/destroy.turbo_stream.erb | 7 +++ app/views/solid_stack_web/jobs/index.html.erb | 50 +++++++++++++++++ .../solid_stack_web/processes/index.html.erb | 32 +++++++++++ .../queue/failed_jobs/index.html.erb | 41 ++++++++++++++ .../queue/jobs/_empty.html.erb | 3 + .../queue/jobs/destroy.turbo_stream.erb | 7 +++ .../solid_stack_web/queue/jobs/index.html.erb | 52 +++++++++++++++++ .../queue/processes/index.html.erb | 32 +++++++++++ .../queue/queues/index.html.erb | 44 +++++++++++++++ .../solid_stack_web/queues/index.html.erb | 44 +++++++++++++++ config/routes.rb | 22 +++++--- lib/solid_stack_web/engine.rb | 3 + 34 files changed, 855 insertions(+), 30 deletions(-) create mode 100644 app/assets/stylesheets/solid_stack_web/_01_base.css create mode 100644 app/assets/stylesheets/solid_stack_web/_02_layout.css create mode 100644 app/assets/stylesheets/solid_stack_web/_03_stats.css create mode 100644 app/assets/stylesheets/solid_stack_web/_04_table.css create mode 100644 app/assets/stylesheets/solid_stack_web/_05_badges.css create mode 100644 app/assets/stylesheets/solid_stack_web/_06_buttons.css create mode 100644 app/controllers/solid_stack_web/cable_controller.rb create mode 100644 app/controllers/solid_stack_web/cache_controller.rb create mode 100644 app/controllers/solid_stack_web/dashboard_controller.rb create mode 100644 app/controllers/solid_stack_web/failed_jobs_controller.rb create mode 100644 app/controllers/solid_stack_web/jobs_controller.rb create mode 100644 app/controllers/solid_stack_web/processes_controller.rb create mode 100644 app/controllers/solid_stack_web/queues_controller.rb create mode 100644 app/views/solid_stack_web/cable/index.html.erb create mode 100644 app/views/solid_stack_web/cable/messages/index.html.erb create mode 100644 app/views/solid_stack_web/cache/entries/index.html.erb create mode 100644 app/views/solid_stack_web/cache/index.html.erb create mode 100644 app/views/solid_stack_web/dashboard/index.html.erb create mode 100644 app/views/solid_stack_web/failed_jobs/index.html.erb create mode 100644 app/views/solid_stack_web/jobs/_empty.html.erb create mode 100644 app/views/solid_stack_web/jobs/destroy.turbo_stream.erb create mode 100644 app/views/solid_stack_web/jobs/index.html.erb create mode 100644 app/views/solid_stack_web/processes/index.html.erb create mode 100644 app/views/solid_stack_web/queue/failed_jobs/index.html.erb create mode 100644 app/views/solid_stack_web/queue/jobs/_empty.html.erb create mode 100644 app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb create mode 100644 app/views/solid_stack_web/queue/jobs/index.html.erb create mode 100644 app/views/solid_stack_web/queue/processes/index.html.erb create mode 100644 app/views/solid_stack_web/queue/queues/index.html.erb create mode 100644 app/views/solid_stack_web/queues/index.html.erb diff --git a/app/assets/stylesheets/solid_stack_web/_01_base.css b/app/assets/stylesheets/solid_stack_web/_01_base.css new file mode 100644 index 0000000..d423632 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_01_base.css @@ -0,0 +1,32 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f8f9fa; + --surface: #ffffff; + --border: #dee2e6; + --text: #212529; + --muted: #6c757d; + --primary: #0d6efd; + --danger: #dc3545; + --warning: #fd7e14; + --success: #198754; + --info: #0dcaf0; + --purple: #6f42c1; + --radius: 6px; + --shadow: 0 1px 3px rgba(0,0,0,.08); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--text); + background: var(--bg); +} + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +.sqw-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 13px; } +.sqw-muted { color: var(--muted); } +.sqw-truncate { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/app/assets/stylesheets/solid_stack_web/_02_layout.css b/app/assets/stylesheets/solid_stack_web/_02_layout.css new file mode 100644 index 0000000..77d2251 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_02_layout.css @@ -0,0 +1,56 @@ +.sqw-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); + position: sticky; + top: 0; + z-index: 100; +} + +.sqw-header__inner { + display: flex; + align-items: center; + gap: 2rem; + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; + height: 52px; +} + +.sqw-header__logo { + font-weight: 700; + font-size: 16px; + color: var(--text); + white-space: nowrap; +} +.sqw-header__logo:hover { text-decoration: none; color: var(--primary); } + +.sqw-nav { display: flex; gap: 0.25rem; } + +.sqw-nav__link { + padding: 0.35rem 0.75rem; + border-radius: var(--radius); + color: var(--muted); + font-size: 13px; + font-weight: 500; + transition: background 0.1s, color 0.1s; +} +.sqw-nav__link:hover { background: var(--bg); color: var(--text); text-decoration: none; } + +.sqw-main { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; +} + +.sqw-page-header { margin-bottom: 1.25rem; } +.sqw-page-title { font-size: 20px; font-weight: 600; } + +.sqw-flash { + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 13px; +} +.sqw-flash--notice { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; } +.sqw-flash--alert { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; } diff --git a/app/assets/stylesheets/solid_stack_web/_03_stats.css b/app/assets/stylesheets/solid_stack_web/_03_stats.css new file mode 100644 index 0000000..48b5616 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_03_stats.css @@ -0,0 +1,31 @@ +.sqw-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.sqw-stat { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + box-shadow: var(--shadow); + color: var(--text); + transition: box-shadow 0.15s; +} +a.sqw-stat:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); text-decoration: none; } + +.sqw-stat__label { font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } +.sqw-stat__value { font-size: 28px; font-weight: 700; line-height: 1; } + +.sqw-stat--ready .sqw-stat__value { color: var(--success); } +.sqw-stat--scheduled .sqw-stat__value { color: var(--info); } +.sqw-stat--claimed .sqw-stat__value { color: var(--primary); } +.sqw-stat--blocked .sqw-stat__value { color: var(--warning); } +.sqw-stat--failed .sqw-stat__value { color: var(--danger); } +.sqw-stat--cache .sqw-stat__value { color: var(--purple); } +.sqw-stat--cable .sqw-stat__value { color: var(--info); } diff --git a/app/assets/stylesheets/solid_stack_web/_04_table.css b/app/assets/stylesheets/solid_stack_web/_04_table.css new file mode 100644 index 0000000..bea430e --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_04_table.css @@ -0,0 +1,39 @@ +.sqw-table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.sqw-table th, +.sqw-table td { + padding: 0.6rem 0.875rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.sqw-table th { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted); + background: var(--bg); +} + +.sqw-table tbody tr:last-child td { border-bottom: none; } +.sqw-table tbody tr:hover { background: #f9fafb; } + +.sqw-actions { text-align: right; white-space: nowrap; } + +.sqw-empty { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 3rem 1.5rem; + text-align: center; + color: var(--muted); +} diff --git a/app/assets/stylesheets/solid_stack_web/_05_badges.css b/app/assets/stylesheets/solid_stack_web/_05_badges.css new file mode 100644 index 0000000..91b0f9b --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_05_badges.css @@ -0,0 +1,20 @@ +.sqw-badge { + display: inline-block; + padding: 0.2em 0.55em; + font-size: 11px; + font-weight: 600; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: .04em; +} + +.sqw-badge--ready { background: #d1e7dd; color: #0a3622; } +.sqw-badge--scheduled { background: #cff4fc; color: #055160; } +.sqw-badge--claimed { background: #cfe2ff; color: #084298; } +.sqw-badge--blocked { background: #fff3cd; color: #664d03; } +.sqw-badge--failed { background: #f8d7da; color: #58151c; } +.sqw-badge--paused { background: #e2e3e5; color: #41464b; } +.sqw-badge--queue { background: #e9ecef; color: #495057; font-weight: 500; text-transform: none; letter-spacing: 0; } +.sqw-badge--worker { background: #cfe2ff; color: #084298; } +.sqw-badge--supervisor { background: #d1e7dd; color: #0a3622; } +.sqw-badge--dispatcher { background: #fff3cd; color: #664d03; } diff --git a/app/assets/stylesheets/solid_stack_web/_06_buttons.css b/app/assets/stylesheets/solid_stack_web/_06_buttons.css new file mode 100644 index 0000000..8156af4 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_06_buttons.css @@ -0,0 +1,40 @@ +.sqw-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.4rem 0.875rem; + font-size: 13px; + font-weight: 500; + border: 1px solid transparent; + border-radius: var(--radius); + cursor: pointer; + background: var(--primary); + color: #fff; + transition: opacity 0.1s; +} +.sqw-btn:hover { opacity: 0.88; text-decoration: none; } + +.sqw-btn--sm { padding: 0.25rem 0.6rem; font-size: 12px; } +.sqw-btn--danger { background: var(--danger); } +.sqw-btn--muted { background: var(--surface); color: var(--text); border-color: var(--border); } +.sqw-btn--muted:hover { background: var(--bg); } + +.sqw-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.sqw-tab { + padding: 0.5rem 1rem; + font-size: 13px; + font-weight: 500; + color: var(--muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.1s, border-color 0.1s; +} +.sqw-tab:hover { color: var(--text); text-decoration: none; } +.sqw-tab--active { color: var(--primary); border-bottom-color: var(--primary); } diff --git a/app/assets/stylesheets/solid_stack_web/application.css b/app/assets/stylesheets/solid_stack_web/application.css index 0ebd7fe..537a451 100644 --- a/app/assets/stylesheets/solid_stack_web/application.css +++ b/app/assets/stylesheets/solid_stack_web/application.css @@ -1,15 +1 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the bottom of the - * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ +/* Styles are loaded inline via the inline_styles helper which globs _*.css files. */ diff --git a/app/controllers/solid_stack_web/cable_controller.rb b/app/controllers/solid_stack_web/cable_controller.rb new file mode 100644 index 0000000..722f362 --- /dev/null +++ b/app/controllers/solid_stack_web/cable_controller.rb @@ -0,0 +1,8 @@ +module SolidStackWeb + class CableController < ApplicationController + def index + @total_messages = ::SolidCable::Message.count + @channels = ::SolidCable::Message.distinct.pluck(:channel).sort + end + end +end diff --git a/app/controllers/solid_stack_web/cache_controller.rb b/app/controllers/solid_stack_web/cache_controller.rb new file mode 100644 index 0000000..00d2fa8 --- /dev/null +++ b/app/controllers/solid_stack_web/cache_controller.rb @@ -0,0 +1,8 @@ +module SolidStackWeb + class CacheController < ApplicationController + def index + @total_entries = ::SolidCache::Entry.count + @total_byte_size = ::SolidCache::Entry.sum(:byte_size) + end + end +end diff --git a/app/controllers/solid_stack_web/dashboard_controller.rb b/app/controllers/solid_stack_web/dashboard_controller.rb new file mode 100644 index 0000000..1d12f76 --- /dev/null +++ b/app/controllers/solid_stack_web/dashboard_controller.rb @@ -0,0 +1,15 @@ +module SolidStackWeb + class DashboardController < ApplicationController + def index + @queue_stats = { + ready: ::SolidQueue::ReadyExecution.count, + scheduled: ::SolidQueue::ScheduledExecution.count, + claimed: ::SolidQueue::ClaimedExecution.count, + blocked: ::SolidQueue::BlockedExecution.count, + failed: ::SolidQueue::FailedExecution.count, + } + @cache_entries = ::SolidCache::Entry.count + @cable_messages = ::SolidCable::Message.count + end + end +end diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb new file mode 100644 index 0000000..ac8da23 --- /dev/null +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -0,0 +1,25 @@ +module SolidStackWeb + class FailedJobsController < ApplicationController + def index + scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc) + @pagy, @executions = pagy(scope) + end + + def destroy + execution = ::SolidQueue::FailedExecution.find(params[:id]) + execution.job.destroy! + @executions_remain = ::SolidQueue::FailedExecution.exists? + + respond_to do |format| + format.html { redirect_to failed_jobs_path } + format.turbo_stream + end + end + + def retry + execution = ::SolidQueue::FailedExecution.find(params[:id]) + execution.retry + redirect_to failed_jobs_path + end + end +end diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb new file mode 100644 index 0000000..5785165 --- /dev/null +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -0,0 +1,42 @@ +module SolidStackWeb + class JobsController < ApplicationController + EXECUTION_MODELS = { + "ready" => ::SolidQueue::ReadyExecution, + "scheduled" => ::SolidQueue::ScheduledExecution, + "claimed" => ::SolidQueue::ClaimedExecution, + "blocked" => ::SolidQueue::BlockedExecution, + }.freeze + + DISCARDABLE = %w[ready scheduled blocked].freeze + + before_action :set_status + before_action :require_discardable, only: :destroy + + def index + scope = EXECUTION_MODELS[@status].includes(:job).order(created_at: :desc) + @pagy, @executions = pagy(scope) + end + + def destroy + model = EXECUTION_MODELS[@status] + execution = model.find(params[:id]) + execution.job.destroy! + @executions_remain = model.exists? + + respond_to do |format| + format.html { redirect_to jobs_path(status: @status) } + format.turbo_stream + end + end + + private + + def set_status + @status = params[:status].presence_in(EXECUTION_MODELS.keys) || "ready" + end + + def require_discardable + head :unprocessable_entity unless DISCARDABLE.include?(@status) + end + end +end diff --git a/app/controllers/solid_stack_web/processes_controller.rb b/app/controllers/solid_stack_web/processes_controller.rb new file mode 100644 index 0000000..cbe202d --- /dev/null +++ b/app/controllers/solid_stack_web/processes_controller.rb @@ -0,0 +1,7 @@ +module SolidStackWeb + class ProcessesController < ApplicationController + def index + @processes = ::SolidQueue::Process.order(:kind, :name) + end + end +end diff --git a/app/controllers/solid_stack_web/queues_controller.rb b/app/controllers/solid_stack_web/queues_controller.rb new file mode 100644 index 0000000..8d6c30d --- /dev/null +++ b/app/controllers/solid_stack_web/queues_controller.rb @@ -0,0 +1,26 @@ +module SolidStackWeb + class QueuesController < ApplicationController + def index + queue_names = ::SolidQueue::ReadyExecution.distinct.pluck(:queue_name) + paused = ::SolidQueue::Pause.pluck(:queue_name).to_set + + @queues = queue_names.sort.map do |name| + { + name: name, + size: ::SolidQueue::ReadyExecution.where(queue_name: name).count, + paused: paused.include?(name), + } + end + end + + def pause + ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:id]) + redirect_to queues_path + end + + def resume + ::SolidQueue::Pause.find_by(queue_name: params[:id])&.destroy + redirect_to queues_path + end + end +end diff --git a/app/views/layouts/solid_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb index 071f681..30ca313 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -9,15 +9,24 @@ <%= inline_styles %> - +
+ <% flash.each do |type, message| %> +
<%= message %>
+ <% end %> <%= yield %>
diff --git a/app/views/solid_stack_web/cable/index.html.erb b/app/views/solid_stack_web/cable/index.html.erb new file mode 100644 index 0000000..7d06a1a --- /dev/null +++ b/app/views/solid_stack_web/cable/index.html.erb @@ -0,0 +1,27 @@ +
+

Solid Cable

+
+ +
+
+ Total Messages + <%= @total_messages %> +
+
+ Channels + <%= @channels.size %> +
+
+ +<% if @channels.any? %> + + + + + + <% @channels.each do |channel| %> + + <% end %> + +
Channel
<%= channel %>
+<% end %> diff --git a/app/views/solid_stack_web/cable/messages/index.html.erb b/app/views/solid_stack_web/cable/messages/index.html.erb new file mode 100644 index 0000000..7d06a1a --- /dev/null +++ b/app/views/solid_stack_web/cable/messages/index.html.erb @@ -0,0 +1,27 @@ +
+

Solid Cable

+
+ +
+
+ Total Messages + <%= @total_messages %> +
+
+ Channels + <%= @channels.size %> +
+
+ +<% if @channels.any? %> + + + + + + <% @channels.each do |channel| %> + + <% end %> + +
Channel
<%= channel %>
+<% end %> diff --git a/app/views/solid_stack_web/cache/entries/index.html.erb b/app/views/solid_stack_web/cache/entries/index.html.erb new file mode 100644 index 0000000..91c93d6 --- /dev/null +++ b/app/views/solid_stack_web/cache/entries/index.html.erb @@ -0,0 +1,14 @@ +
+

Solid Cache

+
+ +
+
+ Total Entries + <%= @total_entries %> +
+
+ Total Size + <%= number_to_human_size(@total_byte_size) %> +
+
diff --git a/app/views/solid_stack_web/cache/index.html.erb b/app/views/solid_stack_web/cache/index.html.erb new file mode 100644 index 0000000..91c93d6 --- /dev/null +++ b/app/views/solid_stack_web/cache/index.html.erb @@ -0,0 +1,14 @@ +
+

Solid Cache

+
+ +
+
+ Total Entries + <%= @total_entries %> +
+
+ Total Size + <%= number_to_human_size(@total_byte_size) %> +
+
diff --git a/app/views/solid_stack_web/dashboard/index.html.erb b/app/views/solid_stack_web/dashboard/index.html.erb new file mode 100644 index 0000000..3dc9e7e --- /dev/null +++ b/app/views/solid_stack_web/dashboard/index.html.erb @@ -0,0 +1,34 @@ +
+

Dashboard

+
+ + diff --git a/app/views/solid_stack_web/failed_jobs/index.html.erb b/app/views/solid_stack_web/failed_jobs/index.html.erb new file mode 100644 index 0000000..38ec1dd --- /dev/null +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -0,0 +1,41 @@ +
+

Failed Jobs

+
+ +
+ <% if @executions.any? %> + + + + + + + + + + + + <% @executions.each do |execution| %> + + + + + + + + <% end %> + +
Job ClassQueueErrorFailed At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.error&.lines&.first&.strip %><%= execution.created_at.strftime("%b %d %H:%M") %> + <%= button_to "Retry", retry_failed_job_path(execution), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <%= button_to "Discard", failed_job_path(execution), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> +
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> + <% else %> +
+

No failed jobs.

+
+ <% end %> +
diff --git a/app/views/solid_stack_web/jobs/_empty.html.erb b/app/views/solid_stack_web/jobs/_empty.html.erb new file mode 100644 index 0000000..64edb77 --- /dev/null +++ b/app/views/solid_stack_web/jobs/_empty.html.erb @@ -0,0 +1,3 @@ +
+

No <%= @status %> jobs.

+
diff --git a/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb b/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb new file mode 100644 index 0000000..7f9e414 --- /dev/null +++ b/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb @@ -0,0 +1,7 @@ +<% if @executions_remain %> + <%= turbo_stream.remove "execution_#{params[:id]}" %> +<% else %> + <%= turbo_stream.replace "sqw-jobs-table" do %> + <%= render "empty" %> + <% end %> +<% end %> diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb new file mode 100644 index 0000000..59f7a3c --- /dev/null +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -0,0 +1,50 @@ +
+

Jobs

+
+ +
+ <% [["ready", "Ready"], ["scheduled", "Scheduled"], ["claimed", "Running"], ["blocked", "Blocked"]].each do |status, label| %> + <%= link_to label, jobs_path(status: status), + class: "sqw-tab #{"sqw-tab--active" if @status == status}" %> + <% end %> +
+ +
+ <% if @executions.any? %> + + + + + + + + <% if @status == "scheduled" %><% end %> + + + + + <% @executions.each do |execution| %> + + + + + + <% if @status == "scheduled" %> + + <% end %> + + + <% end %> + +
Job ClassQueuePriorityEnqueued AtScheduled At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.job.priority %><%= execution.created_at.strftime("%b %d %H:%M") %><%= execution.scheduled_at&.strftime("%b %d %H:%M") %> + <% if %w[ready scheduled blocked].include?(@status) %> + <%= button_to "Discard", job_path(execution, status: @status), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> + <% end %> +
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> + <% else %> + <%= render "empty" %> + <% end %> +
diff --git a/app/views/solid_stack_web/processes/index.html.erb b/app/views/solid_stack_web/processes/index.html.erb new file mode 100644 index 0000000..92cc4de --- /dev/null +++ b/app/views/solid_stack_web/processes/index.html.erb @@ -0,0 +1,32 @@ +
+

Processes

+
+ +<% if @processes.any? %> + + + + + + + + + + + + <% @processes.each do |process| %> + + + + + + + + <% end %> + +
KindNamePIDHostLast Heartbeat
<%= process.kind %><%= process.name %><%= process.pid %><%= process.hostname %><%= process.last_heartbeat_at&.strftime("%b %d %H:%M:%S") %>
+<% else %> +
+

No active processes.

+
+<% end %> diff --git a/app/views/solid_stack_web/queue/failed_jobs/index.html.erb b/app/views/solid_stack_web/queue/failed_jobs/index.html.erb new file mode 100644 index 0000000..0e1b427 --- /dev/null +++ b/app/views/solid_stack_web/queue/failed_jobs/index.html.erb @@ -0,0 +1,41 @@ +
+

Failed Jobs

+
+ +
+ <% if @executions.any? %> + + + + + + + + + + + + <% @executions.each do |execution| %> + + + + + + + + <% end %> + +
Job ClassQueueErrorFailed At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.error&.lines&.first&.strip %><%= execution.created_at.strftime("%b %d %H:%M") %> + <%= button_to "Retry", retry_queue_failed_job_path(execution), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <%= button_to "Discard", queue_failed_job_path(execution), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> +
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> + <% else %> +
+

No failed jobs.

+
+ <% end %> +
diff --git a/app/views/solid_stack_web/queue/jobs/_empty.html.erb b/app/views/solid_stack_web/queue/jobs/_empty.html.erb new file mode 100644 index 0000000..64edb77 --- /dev/null +++ b/app/views/solid_stack_web/queue/jobs/_empty.html.erb @@ -0,0 +1,3 @@ +
+

No <%= @status %> jobs.

+
diff --git a/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb b/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb new file mode 100644 index 0000000..7f9e414 --- /dev/null +++ b/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb @@ -0,0 +1,7 @@ +<% if @executions_remain %> + <%= turbo_stream.remove "execution_#{params[:id]}" %> +<% else %> + <%= turbo_stream.replace "sqw-jobs-table" do %> + <%= render "empty" %> + <% end %> +<% end %> diff --git a/app/views/solid_stack_web/queue/jobs/index.html.erb b/app/views/solid_stack_web/queue/jobs/index.html.erb new file mode 100644 index 0000000..047c25b --- /dev/null +++ b/app/views/solid_stack_web/queue/jobs/index.html.erb @@ -0,0 +1,52 @@ +
+

Jobs

+
+ +
+ <% [["ready", "Ready"], ["scheduled", "Scheduled"], ["claimed", "Running"], ["blocked", "Blocked"]].each do |status, label| %> + <%= link_to label, queue_jobs_path(status: status), + class: "sqw-tab #{"sqw-tab--active" if @status == status}" %> + <% end %> +
+ +
+ <% if @executions.any? %> + + + + + + + + <% if @status == "scheduled" %><% end %> + + + + + <% @executions.each do |execution| %> + + + + + + <% if @status == "scheduled" %> + + <% end %> + + + <% end %> + +
Job ClassQueuePriorityEnqueued AtScheduled At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.job.priority %><%= execution.created_at.strftime("%b %d %H:%M") %><%= execution.scheduled_at&.strftime("%b %d %H:%M") %> + <% if %w[ready scheduled blocked].include?(@status) %> + <%= button_to "Discard", queue_job_path(execution, status: @status), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> + <% end %> +
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> + <% else %> +
+

No <%= @status %> jobs.

+
+ <% end %> +
diff --git a/app/views/solid_stack_web/queue/processes/index.html.erb b/app/views/solid_stack_web/queue/processes/index.html.erb new file mode 100644 index 0000000..92cc4de --- /dev/null +++ b/app/views/solid_stack_web/queue/processes/index.html.erb @@ -0,0 +1,32 @@ +
+

Processes

+
+ +<% if @processes.any? %> + + + + + + + + + + + + <% @processes.each do |process| %> + + + + + + + + <% end %> + +
KindNamePIDHostLast Heartbeat
<%= process.kind %><%= process.name %><%= process.pid %><%= process.hostname %><%= process.last_heartbeat_at&.strftime("%b %d %H:%M:%S") %>
+<% else %> +
+

No active processes.

+
+<% end %> diff --git a/app/views/solid_stack_web/queue/queues/index.html.erb b/app/views/solid_stack_web/queue/queues/index.html.erb new file mode 100644 index 0000000..a3759c6 --- /dev/null +++ b/app/views/solid_stack_web/queue/queues/index.html.erb @@ -0,0 +1,44 @@ +
+

Queues

+
+ +<% if @queues.any? %> + + + + + + + + + + + <% @queues.each do |queue| %> + + + + + + + <% end %> + +
NameSizeStatus
<%= queue[:name] %><%= queue[:size] %> + <% if queue[:paused] %> + Paused + <% else %> + Running + <% end %> + + <% if queue[:paused] %> + <%= button_to "Resume", resume_queue_queue_path(queue[:name]), + method: :delete, class: "sqw-btn sqw-btn--sm" %> + <% else %> + <%= button_to "Pause", pause_queue_queue_path(queue[:name]), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <% end %> +
+<% else %> +
+

No queues with ready jobs.

+
+<% end %> diff --git a/app/views/solid_stack_web/queues/index.html.erb b/app/views/solid_stack_web/queues/index.html.erb new file mode 100644 index 0000000..ca239f9 --- /dev/null +++ b/app/views/solid_stack_web/queues/index.html.erb @@ -0,0 +1,44 @@ +
+

Queues

+
+ +<% if @queues.any? %> + + + + + + + + + + + <% @queues.each do |queue| %> + + + + + + + <% end %> + +
NameSizeStatus
<%= queue[:name] %><%= queue[:size] %> + <% if queue[:paused] %> + Paused + <% else %> + Running + <% end %> + + <% if queue[:paused] %> + <%= button_to "Resume", resume_queue_path(queue[:name]), + method: :delete, class: "sqw-btn sqw-btn--sm" %> + <% else %> + <%= button_to "Pause", pause_queue_path(queue[:name]), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <% end %> +
+<% else %> +
+

No queues with ready jobs.

+
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 078c603..d4f5406 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,21 @@ SolidStackWeb::Engine.routes.draw do - root to: "queue/jobs#index" + root to: "dashboard#index" - scope :queue, as: :queue do - root to: "queue/jobs#index", as: :root - end + resources :jobs, only: [:index, :destroy] - scope :cache, as: :cache do - root to: "cache/entries#index", as: :root + resources :failed_jobs, only: [:index, :destroy] do + member { post :retry } end - scope :cable, as: :cable do - root to: "cable/messages#index", as: :root + resources :queues, only: [:index] do + member do + post :pause + delete :resume + end end + + resources :processes, only: [:index] + + get "cache", to: "cache#index", as: :cache + get "cable", to: "cable#index", as: :cable end diff --git a/lib/solid_stack_web/engine.rb b/lib/solid_stack_web/engine.rb index 7cc0139..4cb7414 100644 --- a/lib/solid_stack_web/engine.rb +++ b/lib/solid_stack_web/engine.rb @@ -1,5 +1,8 @@ require "pagy" require "pagy/toolbox/paginators/method" +require "solid_queue" +require "solid_cache" +require "solid_cable" module SolidStackWeb class Engine < ::Rails::Engine From 6cef031c5ba8b62ec722a58a88b354de2cd08ad8 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Fri, 22 May 2026 15:20:35 -0400 Subject: [PATCH 2/6] Remove leftover namespaced view directories Co-Authored-By: Claude Sonnet 4.6 --- .../cable/messages/index.html.erb | 27 ---------- .../cache/entries/index.html.erb | 14 ----- .../queue/failed_jobs/index.html.erb | 41 --------------- .../queue/jobs/_empty.html.erb | 3 -- .../queue/jobs/destroy.turbo_stream.erb | 7 --- .../solid_stack_web/queue/jobs/index.html.erb | 52 ------------------- .../queue/processes/index.html.erb | 32 ------------ .../queue/queues/index.html.erb | 44 ---------------- 8 files changed, 220 deletions(-) delete mode 100644 app/views/solid_stack_web/cable/messages/index.html.erb delete mode 100644 app/views/solid_stack_web/cache/entries/index.html.erb delete mode 100644 app/views/solid_stack_web/queue/failed_jobs/index.html.erb delete mode 100644 app/views/solid_stack_web/queue/jobs/_empty.html.erb delete mode 100644 app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb delete mode 100644 app/views/solid_stack_web/queue/jobs/index.html.erb delete mode 100644 app/views/solid_stack_web/queue/processes/index.html.erb delete mode 100644 app/views/solid_stack_web/queue/queues/index.html.erb diff --git a/app/views/solid_stack_web/cable/messages/index.html.erb b/app/views/solid_stack_web/cable/messages/index.html.erb deleted file mode 100644 index 7d06a1a..0000000 --- a/app/views/solid_stack_web/cable/messages/index.html.erb +++ /dev/null @@ -1,27 +0,0 @@ -
-

Solid Cable

-
- -
-
- Total Messages - <%= @total_messages %> -
-
- Channels - <%= @channels.size %> -
-
- -<% if @channels.any? %> - - - - - - <% @channels.each do |channel| %> - - <% end %> - -
Channel
<%= channel %>
-<% end %> diff --git a/app/views/solid_stack_web/cache/entries/index.html.erb b/app/views/solid_stack_web/cache/entries/index.html.erb deleted file mode 100644 index 91c93d6..0000000 --- a/app/views/solid_stack_web/cache/entries/index.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
-

Solid Cache

-
- -
-
- Total Entries - <%= @total_entries %> -
-
- Total Size - <%= number_to_human_size(@total_byte_size) %> -
-
diff --git a/app/views/solid_stack_web/queue/failed_jobs/index.html.erb b/app/views/solid_stack_web/queue/failed_jobs/index.html.erb deleted file mode 100644 index 0e1b427..0000000 --- a/app/views/solid_stack_web/queue/failed_jobs/index.html.erb +++ /dev/null @@ -1,41 +0,0 @@ -
-

Failed Jobs

-
- -
- <% if @executions.any? %> - - - - - - - - - - - - <% @executions.each do |execution| %> - - - - - - - - <% end %> - -
Job ClassQueueErrorFailed At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.error&.lines&.first&.strip %><%= execution.created_at.strftime("%b %d %H:%M") %> - <%= button_to "Retry", retry_queue_failed_job_path(execution), - method: :post, class: "sqw-btn sqw-btn--sm" %> - <%= button_to "Discard", queue_failed_job_path(execution), - method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", - data: { turbo_confirm: "Discard this job?" } %> -
- <%== pagy_nav(@pagy) if @pagy.pages > 1 %> - <% else %> -
-

No failed jobs.

-
- <% end %> -
diff --git a/app/views/solid_stack_web/queue/jobs/_empty.html.erb b/app/views/solid_stack_web/queue/jobs/_empty.html.erb deleted file mode 100644 index 64edb77..0000000 --- a/app/views/solid_stack_web/queue/jobs/_empty.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
-

No <%= @status %> jobs.

-
diff --git a/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb b/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb deleted file mode 100644 index 7f9e414..0000000 --- a/app/views/solid_stack_web/queue/jobs/destroy.turbo_stream.erb +++ /dev/null @@ -1,7 +0,0 @@ -<% if @executions_remain %> - <%= turbo_stream.remove "execution_#{params[:id]}" %> -<% else %> - <%= turbo_stream.replace "sqw-jobs-table" do %> - <%= render "empty" %> - <% end %> -<% end %> diff --git a/app/views/solid_stack_web/queue/jobs/index.html.erb b/app/views/solid_stack_web/queue/jobs/index.html.erb deleted file mode 100644 index 047c25b..0000000 --- a/app/views/solid_stack_web/queue/jobs/index.html.erb +++ /dev/null @@ -1,52 +0,0 @@ -
-

Jobs

-
- -
- <% [["ready", "Ready"], ["scheduled", "Scheduled"], ["claimed", "Running"], ["blocked", "Blocked"]].each do |status, label| %> - <%= link_to label, queue_jobs_path(status: status), - class: "sqw-tab #{"sqw-tab--active" if @status == status}" %> - <% end %> -
- -
- <% if @executions.any? %> - - - - - - - - <% if @status == "scheduled" %><% end %> - - - - - <% @executions.each do |execution| %> - - - - - - <% if @status == "scheduled" %> - - <% end %> - - - <% end %> - -
Job ClassQueuePriorityEnqueued AtScheduled At
<%= execution.job.class_name %><%= execution.job.queue_name %><%= execution.job.priority %><%= execution.created_at.strftime("%b %d %H:%M") %><%= execution.scheduled_at&.strftime("%b %d %H:%M") %> - <% if %w[ready scheduled blocked].include?(@status) %> - <%= button_to "Discard", queue_job_path(execution, status: @status), - method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", - data: { turbo_confirm: "Discard this job?" } %> - <% end %> -
- <%== pagy_nav(@pagy) if @pagy.pages > 1 %> - <% else %> -
-

No <%= @status %> jobs.

-
- <% end %> -
diff --git a/app/views/solid_stack_web/queue/processes/index.html.erb b/app/views/solid_stack_web/queue/processes/index.html.erb deleted file mode 100644 index 92cc4de..0000000 --- a/app/views/solid_stack_web/queue/processes/index.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -
-

Processes

-
- -<% if @processes.any? %> - - - - - - - - - - - - <% @processes.each do |process| %> - - - - - - - - <% end %> - -
KindNamePIDHostLast Heartbeat
<%= process.kind %><%= process.name %><%= process.pid %><%= process.hostname %><%= process.last_heartbeat_at&.strftime("%b %d %H:%M:%S") %>
-<% else %> -
-

No active processes.

-
-<% end %> diff --git a/app/views/solid_stack_web/queue/queues/index.html.erb b/app/views/solid_stack_web/queue/queues/index.html.erb deleted file mode 100644 index a3759c6..0000000 --- a/app/views/solid_stack_web/queue/queues/index.html.erb +++ /dev/null @@ -1,44 +0,0 @@ -
-

Queues

-
- -<% if @queues.any? %> - - - - - - - - - - - <% @queues.each do |queue| %> - - - - - - - <% end %> - -
NameSizeStatus
<%= queue[:name] %><%= queue[:size] %> - <% if queue[:paused] %> - Paused - <% else %> - Running - <% end %> - - <% if queue[:paused] %> - <%= button_to "Resume", resume_queue_queue_path(queue[:name]), - method: :delete, class: "sqw-btn sqw-btn--sm" %> - <% else %> - <%= button_to "Pause", pause_queue_queue_path(queue[:name]), - method: :post, class: "sqw-btn sqw-btn--sm" %> - <% end %> -
-<% else %> -
-

No queues with ready jobs.

-
-<% end %> From 1518192401a38c393b61ecf1366d2f5a939f1c6b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Fri, 22 May 2026 16:28:28 -0400 Subject: [PATCH 3/6] Add overview dashboard and contextual two-tier navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat stat grid on the root with three gem section cards (Solid Queue, Solid Cache, Solid Cable), each showing key health stats and linking through to the relevant section. Introduces a two-tier nav: primary bar shows Queue / Cache / Cable with active highlighting; a secondary bar appears contextually under Queue with Jobs / Failed / Queues / Processes sub-links. Dashboard link removed — the logo serves as home. Co-Authored-By: Claude Sonnet 4.6 --- .../solid_stack_web/_02_layout.css | 27 +++++ .../solid_stack_web/_07_dashboard.css | 77 +++++++++++++++ .../solid_stack_web/application_controller.rb | 11 +++ .../solid_stack_web/dashboard_controller.rb | 11 ++- .../solid_stack_web/jobs_controller.rb | 2 +- .../solid_stack_web/queues_controller.rb | 2 +- .../solid_stack_web/application.html.erb | 29 ++++-- .../solid_stack_web/dashboard/index.html.erb | 98 +++++++++++++------ .../solid_stack_web/dashboard_spec.rb | 41 ++++++++ 9 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 app/assets/stylesheets/solid_stack_web/_07_dashboard.css create mode 100644 spec/requests/solid_stack_web/dashboard_spec.rb diff --git a/app/assets/stylesheets/solid_stack_web/_02_layout.css b/app/assets/stylesheets/solid_stack_web/_02_layout.css index 77d2251..b1a77ec 100644 --- a/app/assets/stylesheets/solid_stack_web/_02_layout.css +++ b/app/assets/stylesheets/solid_stack_web/_02_layout.css @@ -36,6 +36,33 @@ transition: background 0.1s, color 0.1s; } .sqw-nav__link:hover { background: var(--bg); color: var(--text); text-decoration: none; } +.sqw-nav__link--active { background: var(--bg); color: var(--text); } + +.sqw-subnav { + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.sqw-subnav__inner { + display: flex; + align-items: center; + gap: 0.125rem; + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; + height: 36px; +} + +.sqw-subnav__link { + padding: 0.2rem 0.625rem; + border-radius: var(--radius); + color: var(--muted); + font-size: 12px; + font-weight: 500; + transition: background 0.1s, color 0.1s; +} +.sqw-subnav__link:hover { background: var(--surface); color: var(--text); text-decoration: none; } +.sqw-subnav__link--active { background: var(--surface); color: var(--text); } .sqw-main { max-width: 1200px; diff --git a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css new file mode 100644 index 0000000..98c9766 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css @@ -0,0 +1,77 @@ +.sqw-gem-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.sqw-gem-card { + background: var(--surface); + border: 1px solid var(--border); + border-top: 3px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.sqw-gem-card--queue { border-top-color: var(--primary); } +.sqw-gem-card--cache { border-top-color: var(--purple); } +.sqw-gem-card--cable { border-top-color: var(--info); } + +.sqw-gem-card__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.sqw-gem-card__title { + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text); +} + +.sqw-gem-card__link { + font-size: 12px; + font-weight: 500; + color: var(--muted); +} +.sqw-gem-card__link:hover { color: var(--primary); text-decoration: none; } + +.sqw-gem-card__body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 1.25rem; + padding: 1.25rem; +} + +.sqw-inline-stat { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: var(--text); + transition: opacity 0.15s; +} +a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; } + +.sqw-inline-stat__label { + font-size: 11px; + font-weight: 500; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .04em; +} + +.sqw-inline-stat__value { font-size: 28px; font-weight: 700; line-height: 1; } + +.sqw-inline-stat--ready .sqw-inline-stat__value { color: var(--success); } +.sqw-inline-stat--scheduled .sqw-inline-stat__value { color: var(--info); } +.sqw-inline-stat--claimed .sqw-inline-stat__value { color: var(--primary); } +.sqw-inline-stat--blocked .sqw-inline-stat__value { color: var(--warning); } +.sqw-inline-stat--failed .sqw-inline-stat__value { color: var(--danger); } +.sqw-inline-stat--cache .sqw-inline-stat__value { color: var(--purple); } +.sqw-inline-stat--cable .sqw-inline-stat__value { color: var(--info); } +.sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); } \ No newline at end of file diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb index 60a524f..2e4b5c4 100644 --- a/app/controllers/solid_stack_web/application_controller.rb +++ b/app/controllers/solid_stack_web/application_controller.rb @@ -5,8 +5,19 @@ class ApplicationController < ActionController::Base before_action :authenticate! around_action :with_database_connection + helper_method :current_section + private + def current_section + case controller_name + when "jobs", "failed_jobs", "queues", "processes" then :queue + when "cache" then :cache + when "cable" then :cable + else :overview + end + end + def with_database_connection config = SolidStackWeb.connects_to return yield unless config diff --git a/app/controllers/solid_stack_web/dashboard_controller.rb b/app/controllers/solid_stack_web/dashboard_controller.rb index 1d12f76..81c4800 100644 --- a/app/controllers/solid_stack_web/dashboard_controller.rb +++ b/app/controllers/solid_stack_web/dashboard_controller.rb @@ -7,9 +7,16 @@ def index claimed: ::SolidQueue::ClaimedExecution.count, blocked: ::SolidQueue::BlockedExecution.count, failed: ::SolidQueue::FailedExecution.count, + processes: ::SolidQueue::Process.count + } + @cache_stats = { + entries: ::SolidCache::Entry.count, + byte_size: ::SolidCache::Entry.sum(:byte_size) + } + @cable_stats = { + messages: ::SolidCable::Message.count, + channels: ::SolidCable::Message.distinct.count(:channel) } - @cache_entries = ::SolidCache::Entry.count - @cable_messages = ::SolidCable::Message.count end end end diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 5785165..3cb4bea 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -4,7 +4,7 @@ class JobsController < ApplicationController "ready" => ::SolidQueue::ReadyExecution, "scheduled" => ::SolidQueue::ScheduledExecution, "claimed" => ::SolidQueue::ClaimedExecution, - "blocked" => ::SolidQueue::BlockedExecution, + "blocked" => ::SolidQueue::BlockedExecution }.freeze DISCARDABLE = %w[ready scheduled blocked].freeze diff --git a/app/controllers/solid_stack_web/queues_controller.rb b/app/controllers/solid_stack_web/queues_controller.rb index 8d6c30d..4f2a582 100644 --- a/app/controllers/solid_stack_web/queues_controller.rb +++ b/app/controllers/solid_stack_web/queues_controller.rb @@ -8,7 +8,7 @@ def index { name: name, size: ::SolidQueue::ReadyExecution.where(queue_name: name).count, - paused: paused.include?(name), + paused: paused.include?(name) } end end diff --git a/app/views/layouts/solid_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb index 30ca313..7b9b998 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -13,16 +13,31 @@
<%= link_to "Solid Stack", root_path, class: "sqw-header__logo" %>
+ + <% if current_section == :queue %> + + <% end %> +
<% flash.each do |type, message| %>
<%= message %>
diff --git a/app/views/solid_stack_web/dashboard/index.html.erb b/app/views/solid_stack_web/dashboard/index.html.erb index 3dc9e7e..78dbcee 100644 --- a/app/views/solid_stack_web/dashboard/index.html.erb +++ b/app/views/solid_stack_web/dashboard/index.html.erb @@ -1,34 +1,72 @@
-

Dashboard

+

Overview

-
- " class="sqw-stat sqw-stat--ready"> - Ready - <%= @queue_stats[:ready] %> - - " class="sqw-stat sqw-stat--scheduled"> - Scheduled - <%= @queue_stats[:scheduled] %> - - " class="sqw-stat sqw-stat--claimed"> - Running - <%= @queue_stats[:claimed] %> - - " class="sqw-stat sqw-stat--blocked"> - Blocked - <%= @queue_stats[:blocked] %> - - - Failed - <%= @queue_stats[:failed] %> - - - Cache Entries - <%= @cache_entries %> - - - Cable Messages - <%= @cable_messages %> - +
+ + +
+
+ Solid Cache + <%= link_to "View Cache →", cache_path, class: "sqw-gem-card__link" %> +
+
+
+ Entries + <%= @cache_stats[:entries] %> +
+
+ Size + <%= number_to_human_size(@cache_stats[:byte_size]) %> +
+
+
+ +
+
+ Solid Cable + <%= link_to "View Cable →", cable_path, class: "sqw-gem-card__link" %> +
+
+
+ Messages + <%= @cable_stats[:messages] %> +
+
+ Channels + <%= @cable_stats[:channels] %> +
+
+
diff --git a/spec/requests/solid_stack_web/dashboard_spec.rb b/spec/requests/solid_stack_web/dashboard_spec.rb new file mode 100644 index 0000000..1d3f9ab --- /dev/null +++ b/spec/requests/solid_stack_web/dashboard_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +RSpec.describe "Dashboard", type: :request do + let(:engine_root) { "/solid_stack" } + + describe "GET /" do + it "returns 200" do + get engine_root + expect(response).to have_http_status(:ok) + end + + it "renders all three gem sections" do + get engine_root + expect(response.body).to include("Solid Queue") + expect(response.body).to include("Solid Cache") + expect(response.body).to include("Solid Cable") + end + + it "links through to each gem section" do + get engine_root + expect(response.body).to include("View Jobs") + expect(response.body).to include("View Cache") + expect(response.body).to include("View Cable") + end + + it "shows all queue status stats" do + get engine_root + %w[ready scheduled claimed blocked failed].each do |status| + expect(response.body).to include("sqw-inline-stat--#{status}") + end + end + + it "reflects live job counts" do + SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default") + + get engine_root + + expect(response.body).to match(/class="sqw-inline-stat__value">\s*1\s* Date: Sat, 23 May 2026 07:08:48 -0400 Subject: [PATCH 4/6] Add dev seed data for all three gem sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates realistic development fixtures: 15 ready, 8 scheduled, 3 claimed, 3 blocked, and 5 failed Solid Queue jobs with 5 worker processes; 8 Solid Cache entries; and 9 Solid Cable messages across 4 channels. Excludes seeds.rb from RuboCop — seed files are write-once scripts where trailing comma style rules add no value. Co-Authored-By: Claude Sonnet 4.6 --- .rubocop.yml | 1 + spec/dummy/db/seeds.rb | 199 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 spec/dummy/db/seeds.rb diff --git a/.rubocop.yml b/.rubocop.yml index e518bc1..4c43e84 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: - "bin/release" - "README.md" - "spec/dummy/config/database.yml" + - "spec/dummy/db/seeds.rb" Layout/SpaceInsideArrayLiteralBrackets: Enabled: true diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb new file mode 100644 index 0000000..4da2445 --- /dev/null +++ b/spec/dummy/db/seeds.rb @@ -0,0 +1,199 @@ +JOB_CLASSES = %w[ + EmailNotificationJob + UserReportJob + DataImportJob + ImageProcessingJob + WebhookDeliveryJob + DailyCleanupJob + SyncInventoryJob + GeneratePdfJob + SendDigestJob + RecalculateScoresJob +].freeze + +QUEUES = %w[default mailers critical low_priority].freeze + +ERRORS = [ + "Net::ReadTimeout: Net::ReadTimeout with #", + "ActiveRecord::RecordNotFound: Couldn't find User with 'id'=42", + "Faraday::ConnectionFailed: Failed to open TCP connection to api.example.com:443", + "RuntimeError: Rate limit exceeded — retry after 60s", + "JSON::ParserError: unexpected token at ''", +].freeze + +# ── Solid Queue: Processes ──────────────────────────────────────────────────── + +puts " processes..." + +supervisor = SolidQueue::Process.create!( + kind: "Supervisor", + name: "supervisor-#{SecureRandom.hex(4)}", + pid: 12_000, + hostname: "web-01.local", + last_heartbeat_at: 5.seconds.ago, + metadata: { queues: QUEUES }.to_json +) + +workers = Array.new(3) do |i| + SolidQueue::Process.create!( + kind: "Worker", + name: "worker-#{i + 1}-#{SecureRandom.hex(4)}", + pid: 12_001 + i, + hostname: "web-01.local", + supervisor_id: supervisor.id, + last_heartbeat_at: rand(1..10).seconds.ago, + metadata: { queues: QUEUES, threads: 5 }.to_json + ) +end + +SolidQueue::Process.create!( + kind: "Dispatcher", + name: "dispatcher-#{SecureRandom.hex(4)}", + pid: 12_010, + hostname: "web-01.local", + supervisor_id: supervisor.id, + last_heartbeat_at: 3.seconds.ago, + metadata: { batch_size: 500, polling_interval: 1 }.to_json +) + +# ── Solid Queue: Ready jobs ─────────────────────────────────────────────────── + +puts " ready jobs..." + +15.times do + SolidQueue::Job.create!( + class_name: JOB_CLASSES.sample, + queue_name: QUEUES.sample, + arguments: [{ user_id: rand(1..1000) }].to_json, + priority: rand(0..10), + active_job_id: SecureRandom.uuid + ) +end + +# ── Solid Queue: Scheduled jobs ─────────────────────────────────────────────── + +puts " scheduled jobs..." + +8.times do |i| + SolidQueue::Job.create!( + class_name: JOB_CLASSES.sample, + queue_name: QUEUES.sample, + arguments: [{ report_id: rand(1..500) }].to_json, + priority: rand(0..5), + active_job_id: SecureRandom.uuid, + scheduled_at: (i + 1).hours.from_now + ) +end + +# ── Solid Queue: Claimed / blocked / failed (skip dispatch callback) ────────── + +SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution) +# set_expires_at calls job.concurrency_duration which constantizes class_name — skip it since +# we provide expires_at directly and the job classes don't exist in the dummy app. +SolidQueue::BlockedExecution.skip_callback(:create, :before, :set_expires_at) + +begin + puts " claimed jobs..." + workers.each_with_index do |worker, i| + job = SolidQueue::Job.create!( + class_name: JOB_CLASSES.sample, + queue_name: QUEUES.sample, + arguments: [{ batch: i }].to_json, + priority: 0, + active_job_id: SecureRandom.uuid + ) + SolidQueue::ClaimedExecution.create!(job: job, process_id: worker.id) + end + + puts " blocked jobs..." + concurrency_key = "EmailNotificationJob/user-7" + 3.times do + job = SolidQueue::Job.create!( + class_name: "EmailNotificationJob", + queue_name: "mailers", + arguments: [{ user_id: 7 }].to_json, + priority: 0, + active_job_id: SecureRandom.uuid, + concurrency_key: concurrency_key + ) + SolidQueue::BlockedExecution.create!( + job: job, + queue_name: "mailers", + priority: 0, + concurrency_key: concurrency_key, + expires_at: 10.minutes.from_now + ) + end + + puts " failed jobs..." + 5.times do + job = SolidQueue::Job.create!( + class_name: JOB_CLASSES.sample, + queue_name: QUEUES.sample, + arguments: [{ record_id: rand(1..100) }].to_json, + priority: 0, + active_job_id: SecureRandom.uuid + ) + SolidQueue::FailedExecution.create!(job: job, error: ERRORS.sample) + end +ensure + SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution) + SolidQueue::BlockedExecution.set_callback(:create, :before, :set_expires_at) +end + +# ── Solid Cache ─────────────────────────────────────────────────────────────── + +puts " cache entries..." + +{ + "users/1/profile" => { name: "Alice", email: "alice@example.com", role: "admin" }.to_json, + "users/2/profile" => { name: "Bob", email: "bob@example.com", role: "member" }.to_json, + "posts/recent" => Array.new(10) { { id: rand(1..1000), title: "Post #{rand(100)}" } }.to_json, + "site/settings" => { theme: "dark", locale: "en", timezone: "UTC" }.to_json, + "stats/daily" => { visits: 4821, signups: 34, revenue: 1290.50 }.to_json, + "nav/menu" => %w[Home About Pricing Blog Contact].to_json, + "products/featured" => Array.new(5) { { id: rand(1..200), name: "Product #{rand(100)}" } }.to_json, + "api/rate_limit/user_42" => { remaining: 87, reset_at: 1.hour.from_now.to_i }.to_json, +}.each do |key, value| + SolidCache::Entry.write(key, value) +end + +# ── Solid Cable ─────────────────────────────────────────────────────────────── + +puts " cable messages..." + +{ + "notifications:user_1" => [ + { type: "notification", message: "You have a new follower" }, + { type: "notification", message: "Your export is ready to download" }, + ], + "notifications:user_2" => [ + { type: "notification", message: "Alice commented on your post" }, + ], + "presence:lobby" => [ + { type: "presence", user: "alice", status: "online" }, + { type: "presence", user: "bob", status: "online" }, + { type: "presence", user: "carol", status: "away" }, + ], + "chat:general" => [ + { type: "message", text: "Hey everyone!", user: "bob" }, + { type: "message", text: "Good morning 👋", user: "alice" }, + { type: "message", text: "Anyone seen the deploy notes?", user: "dave" }, + ], +}.each do |channel, messages| + messages.each { |payload| SolidCable::Message.broadcast(channel, payload.to_json) } +end + +# ── Summary ─────────────────────────────────────────────────────────────────── + +puts "" +puts "Seeded:" +puts " Solid Queue — #{SolidQueue::Job.count} jobs " \ + "(#{SolidQueue::ReadyExecution.count} ready, " \ + "#{SolidQueue::ScheduledExecution.count} scheduled, " \ + "#{SolidQueue::ClaimedExecution.count} claimed, " \ + "#{SolidQueue::BlockedExecution.count} blocked, " \ + "#{SolidQueue::FailedExecution.count} failed), " \ + "#{SolidQueue::Process.count} processes" +puts " Solid Cache — #{SolidCache::Entry.count} entries" +puts " Solid Cable — #{SolidCable::Message.count} messages across #{SolidCable::Message.distinct.count(:channel)} channels" \ No newline at end of file From 9af76e0fc96831aa2831f0f0847cceff4210462b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Sun, 24 May 2026 08:26:02 -0400 Subject: [PATCH 5/6] Update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index edc6828..a11dbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /spec/dummy/log/*.log /spec/dummy/storage/ /spec/dummy/tmp/ + +coverage/ From 08ce1b2a34af48cd618701647448f324170761f9 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Sun, 24 May 2026 08:44:41 -0400 Subject: [PATCH 6/6] Add 0.1.0 CHANGELOG entry and complete README Documents all features shipped in the initial release: unified dashboard, Solid Queue job/queue/process management, Solid Cache and Solid Cable stats, Turbo Stream discard, authentication hook, and configuration reference. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 21 +++++++++++++++- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a97ca36..6e89245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -[Unreleased]: https://github.com/eclectic-coding/solid_stack_web/compare/HEAD +## [0.1.0] - 2026-05-24 + +### Added + +- Overview dashboard with live stats across Solid Queue, Solid Cache, and Solid Cable +- Solid Queue job monitoring: filterable list by status (ready, scheduled, claimed, blocked) with paginated results +- Solid Queue failed jobs: dedicated view with per-job retry and discard actions +- Queue management: pause and resume individual queues +- Worker process list showing all registered Solid Queue processes +- Solid Cache monitoring: entry count and total byte size +- Solid Cable monitoring: message count and active channel count +- Turbo Stream job discard: removes the row inline, or replaces the table with an empty state when the last job is discarded +- Authentication hook — configure via `SolidQueueWeb.authenticate { |controller| ... }` in an initializer; falls back to HTTP Basic if the block returns falsy +- Configurable page size via `SolidQueueWeb.page_size` (default: 25) +- Inline CSS delivery — no asset pipeline dependency, safe to mount in any host app +- Two-tier contextual navigation per section (Queue / Cache / Cable) +- No runtime JavaScript dependency — all interactions use standard form POSTs or Turbo Stream + +[Unreleased]: https://github.com/eclectic-coding/solid_stack_web/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/eclectic-coding/solid_stack_web/releases/tag/v0.1.0 diff --git a/README.md b/README.md index ecd1751..8e332ef 100644 --- a/README.md +++ b/README.md @@ -5,30 +5,78 @@ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org) [![codecov](https://codecov.io/gh/eclectic-coding/solid_stack_web/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/solid_stack_web) -Short description and motivation. +A mountable Rails engine that provides a unified web dashboard for the full [Solid Stack](https://github.com/rails/solid_queue) — **Solid Queue**, **Solid Cache**, and **Solid Cable** — in a single interface with no asset pipeline dependency and no JavaScript runtime requirement. -## Usage -How to use my plugin. +## Features + +- **Overview dashboard** with live counts across all three Solid Stack components +- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked), manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes +- **Solid Cache** — entry count and total byte size at a glance +- **Solid Cable** — active message count and distinct channel count +- **Turbo Stream** job discard — removes the row inline without a full page reload +- **Authentication hook** — plug in your own auth logic (Devise, Basic Auth, custom) via a one-line initializer +- **Zero asset pipeline coupling** — CSS is injected inline; safe to mount in any host app ## Installation -Add this line to your application's Gemfile: + +Add the gem to your application's `Gemfile`: ```ruby gem "solid_stack_web" ``` -And then execute: +Run: + ```bash -$ bundle +bundle install ``` -Or install it yourself as: -```bash -$ gem install solid_stack_web +Mount the engine in `config/routes.rb`: + +```ruby +mount SolidStackWeb::Engine, at: "/solid_stack" +``` + +The dashboard will be available at `/solid_stack` (or whatever path you choose). + +## Configuration + +Create an initializer at `config/initializers/solid_stack_web.rb`: + +```ruby +SolidStackWeb.configure do |config| + # Number of items per paginated page (default: 25) + config.page_size = 50 + + # Authentication — block runs in controller context. + # Return a truthy value to allow access; falsy falls back to HTTP Basic. + config.authenticate do + current_user&.admin? + end +end ``` +### Authentication + +The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open. + +## Requirements + +- Ruby >= 3.3 +- Rails >= 8.1.3 +- [solid_queue](https://github.com/rails/solid_queue) >= 1.0 +- [solid_cache](https://github.com/rails/solid_cache) >= 1.0 +- [solid_cable](https://github.com/rails/solid_cable) >= 1.0 + ## Contributing -Contribution directions go here. + +1. Fork the repository +2. Create a feature branch (`git checkout -b feat/my-feature`) +3. Run the test suite: `bundle exec rake` +4. Open a pull request + +Bug reports and feature requests are welcome on [GitHub Issues](https://github.com/eclectic-coding/solid_stack_web/issues). ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). \ No newline at end of file