Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Job detail page — `jobs/:id` show view with full arguments (pretty-printed JSON), queue, priority, enqueued time, status badge, Active Job ID, and status-specific metadata (scheduled_at, concurrency key, blocked-until); job class in the list is now a link to the detail page; Discard button available on the detail page for ready, scheduled, and blocked jobs
- Job filtering — filter the jobs list by queue name, job class (substring), priority, and time period (1h / 24h / 7d / all) via query-param driven scopes; active filters are preserved across status tabs
- Job filter Turbo Frame — filter form and results table wrapped in a `<turbo-frame>` so applying filters reloads only the table without a full page refresh; `data-turbo-action="advance"` keeps the URL in sync; Turbo JS loaded from esm.sh CDN in the engine layout

Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PATH
solid_cable (>= 1.0)
solid_cache (>= 1.0)
solid_queue (>= 1.0)
turbo-rails (>= 2.0)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -296,6 +297,9 @@ GEM
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
Expand Down Expand Up @@ -424,6 +428,7 @@ CHECKSUMS
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol

## Features

- **Overview dashboard** with live counts across all three Solid Stack components
- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes
- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
- **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
Expand Down Expand Up @@ -80,6 +81,7 @@ The `authenticate` block is evaluated in the context of each request's controlle
- [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
- [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0

## Contributing

Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
> _Make the job management layer genuinely useful for operators._

### Added
- **Job detail page** — `jobs/:id` show view with full arguments, queue, priority, enqueued time, and execution metadata
- **Bulk selection** — checkbox-driven multi-select on the jobs and failed-jobs lists
- **Bulk discard** — discard all selected jobs in a single request
- **Bulk retry (failed jobs)** — retry selected failed jobs with optional stagger interval (5 s / 10 s / 30 s / 1 m) to avoid thundering-herd restarts
Expand Down
13 changes: 13 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_07_dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
position: relative;
transition: box-shadow 0.15s;
}
.sqw-gem-card:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); }

.sqw-gem-card--queue { border-top-color: var(--primary); }
.sqw-gem-card--cache { border-top-color: var(--purple); }
Expand Down Expand Up @@ -41,6 +44,16 @@
}
.sqw-gem-card__link:hover { color: var(--primary); text-decoration: none; }

/* Stretch the header link to cover the whole card */
.sqw-gem-card__link::after {
content: "";
position: absolute;
inset: 0;
}

/* Stat links and other interactive elements sit above the overlay */
.sqw-inline-stat { position: relative; z-index: 1; }

.sqw-gem-card__body {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
Expand Down
70 changes: 70 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_09_detail.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.sqw-detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}

.sqw-breadcrumb {
font-size: 12px;
color: var(--muted);
margin-bottom: 0.25rem;
}
.sqw-breadcrumb a { color: var(--muted); text-decoration: none; }
.sqw-breadcrumb a:hover { color: var(--text); }

.sqw-page-header--split {
display: flex;
align-items: center;
justify-content: space-between;
}

.sqw-detail-actions {
display: flex;
gap: 0.5rem;
}

/* Two-column detail layout: Details card | Arguments card */
.sqw-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}

.sqw-detail-section {
padding: 1.25rem;
}

.sqw-section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
margin-bottom: 0.75rem;
}

/* Definition list — dt naturally sized, dd takes the rest */
.sqw-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1.5rem;
font-size: 13px;
}
.sqw-dl dt { color: var(--muted); white-space: nowrap; }
.sqw-dl dd { word-break: break-all; }

.sqw-code-block {
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
}
12 changes: 10 additions & 2 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ def index
@pagy, @executions = pagy(filtered_scope)
end

def show
@execution = Job::EXECUTION_MODELS[@status].includes(:job).find(params[:id])
@job = @execution.job
@arguments = JSON.parse(@job.arguments) if @job.arguments.present?
rescue JSON::ParserError
@arguments = nil
end

def destroy
execution = Job::EXECUTION_MODELS[@status].find(params[:id])
execution.job.destroy!
@execution = Job::EXECUTION_MODELS[@status].find(params[:id])
@execution.job.destroy!
@executions_remain = Job::EXECUTION_MODELS[@status].exists?

respond_to do |format|
Expand Down
4 changes: 2 additions & 2 deletions app/views/solid_stack_web/jobs/destroy.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<% if @executions_remain %>
<%= turbo_stream.remove "execution_#{params[:id]}" %>
<%= turbo_stream.remove "execution_#{@execution.id}" %>
<% else %>
<%= turbo_stream.replace "sqw-jobs-table" do %>
<%= render "empty" %>
<% end %>
<% end %>
<% end %>
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<tbody>
<% @executions.each do |execution| %>
<tr id="execution_<%= execution.id %>">
<td class="sqw-monospace"><%= execution.job.class_name %></td>
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
Expand Down
57 changes: 57 additions & 0 deletions app/views/solid_stack_web/jobs/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<div class="sqw-page-header sqw-page-header--split">
<div>
<div class="sqw-breadcrumb">
<%= link_to "Jobs", jobs_path(status: params[:status]) %> &rsaquo; Detail
</div>
<h1 class="sqw-page-title sqw-monospace"><%= @job.class_name %></h1>
</div>

<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<div class="sqw-detail-actions">
<%= button_to "Discard Job", job_path(@execution.id, status: @status),
method: :delete, class: "sqw-btn sqw-btn--danger",
data: { turbo_confirm: "Discard this job?" } %>
</div>
<% end %>
</div>

<div class="sqw-detail-grid">
<div class="sqw-detail-card sqw-detail-section">
<h2 class="sqw-section-title">Details</h2>
<dl class="sqw-dl">
<dt>Status</dt>
<dd><span class="sqw-badge sqw-badge--<%= @status %>"><%= SolidStackWeb::Job::TAB_LABELS[@status] %></span></dd>

<dt>Queue</dt>
<dd class="sqw-monospace"><%= @job.queue_name %></dd>

<dt>Priority</dt>
<dd><%= @job.priority %></dd>

<dt>Active Job ID</dt>
<dd class="sqw-monospace sqw-truncate" title="<%= @job.active_job_id %>"><%= @job.active_job_id.presence || "—" %></dd>

<dt>Concurrency Key</dt>
<dd class="sqw-monospace"><%= @job.concurrency_key.presence || "—" %></dd>

<% if @status == "blocked" %>
<dt>Blocked Until</dt>
<dd class="sqw-monospace"><%= @execution.expires_at ? @execution.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<% end %>

<dt>Enqueued At</dt>
<dd class="sqw-monospace"><%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>

<dt>Scheduled At</dt>
<dd class="sqw-monospace"><%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>

<dt>Finished At</dt>
<dd class="sqw-monospace"><%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
</dl>
</div>

<div class="sqw-detail-card sqw-detail-section">
<h2 class="sqw-section-title">Arguments</h2>
<pre class="sqw-code-block"><%= @arguments ? JSON.pretty_generate(@arguments) : (@job.arguments || "—") %></pre>
</div>
</div>
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
SolidStackWeb::Engine.routes.draw do
root to: "dashboard#index"

resources :jobs, only: [:index, :destroy]
resources :jobs, only: [:index, :show, :destroy]

resources :failed_jobs, only: [:index, :destroy] do
member { post :retry }
Expand Down
1 change: 1 addition & 0 deletions lib/solid_stack_web/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "solid_queue"
require "solid_cache"
require "solid_cable"
require "turbo-rails"

module SolidStackWeb
class Engine < ::Rails::Engine
Expand Down
1 change: 1 addition & 0 deletions solid_stack_web.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "solid_queue", ">= 1.0"
spec.add_dependency "solid_cache", ">= 1.0"
spec.add_dependency "solid_cable", ">= 1.0"
spec.add_dependency "turbo-rails", ">= 2.0"
end
58 changes: 58 additions & 0 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,64 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end

describe "GET /jobs/:id" do
it "returns 200 and shows the job class" do
job = create_ready(class_name: "ReportJob")
get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" }

expect(response).to have_http_status(:ok)
expect(response.body).to include("ReportJob")
end

it "shows queue and priority" do
job = create_ready(queue_name: "critical", priority: 5)
get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" }

expect(response.body).to include("critical")
expect(response.body).to include("5")
end

it "pretty-prints JSON arguments" do
job = SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default", priority: 0,
arguments: [{ user_id: 42 }].to_json)
get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" }

expect(response.body).to include("user_id")
expect(response.body).to include("42")
end

it "shows a discard button for ready jobs" do
job = create_ready
get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" }

expect(response.body).to include("Discard Job")
end

it "does not show a discard button for claimed jobs" do
SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution)
job = SolidQueue::Job.create!(class_name: "WorkJob", queue_name: "default", priority: 0)
process = SolidQueue::Process.create!(
kind: "Worker", name: "worker-spec", pid: 99_999,
hostname: "test", last_heartbeat_at: Time.current
)
execution = SolidQueue::ClaimedExecution.create!(job: job, process_id: process.id)
SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution)

get "#{engine_root}/jobs/#{execution.id}", params: { status: "claimed" }

expect(response).to have_http_status(:ok)
expect(response.body).not_to include("Discard Job")
end

it "shows a breadcrumb link to the jobs list" do
job = create_ready
get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" }

expect(response.body).to include("sqw-breadcrumb")
expect(response.body).to include("Jobs")
end
end

describe "combined filters" do
it "applies class and queue filters together" do
create_ready(class_name: "ReportJob", queue_name: "reports")
Expand Down