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/ 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/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 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..b1a77ec --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_02_layout.css @@ -0,0 +1,83 @@ +.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-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; + 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/_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/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/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/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..81c4800 --- /dev/null +++ b/app/controllers/solid_stack_web/dashboard_controller.rb @@ -0,0 +1,22 @@ +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, + 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) + } + 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..3cb4bea --- /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..4f2a582 --- /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..7b9b998 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -9,15 +9,39 @@ <%= inline_styles %> - + + + <% if current_section == :queue %> + + <% end %> +
+ <% 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/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..78dbcee --- /dev/null +++ b/app/views/solid_stack_web/dashboard/index.html.erb @@ -0,0 +1,72 @@ +
+

Overview

+
+ +
+
+
+ Solid Queue + <%= link_to "View Jobs →", jobs_path, class: "sqw-gem-card__link" %> +
+ +
+ +
+
+ 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/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/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 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 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*