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 history view — `GET /history` lists all finished jobs with class name, queue, duration, and finished-at time; filterable by queue, class substring, and time period (1h / 24h / 7d); clicking a queue badge filters the history to that queue; CSV export respects active filters; "History" link added to the queue subnav
- Scheduled job management — "Run Now" and offset buttons (+1h / +24h / +7d) on each scheduled job row; Turbo Stream removes the row on run-now and updates the scheduled-at cell on offset reschedule; "Run All Now (N)" header button back-dates all matching scheduled executions; backed by `ScheduledJobsController` using standard CRUD (`update` for single, `create` for bulk via `run_all_now` collection route)

## [0.2.0] - 2026-05-25
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol

- **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 / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
- **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" in the header back-dates all matching executions at once
- **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
- **Failed job detail page** — drill into any failed job to see the full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ApplicationController < ActionController::Base

def current_section
case controller_name
when "jobs", "failed_jobs", "queues", "processes" then :queue
when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs" then :queue
when "cache" then :cache
when "cable" then :cable
else :overview
Expand Down
42 changes: 42 additions & 0 deletions app/controllers/solid_stack_web/history_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module SolidStackWeb
class HistoryController < ApplicationController
before_action :set_filters

def index
respond_to do |format|
format.html { @pagy, @jobs = pagy(filtered_scope) }
format.csv do
send_data history_csv(filtered_scope),
filename: "job-history-#{Date.today}.csv",
type: "text/csv", disposition: "attachment"
end
end
end

private

def set_filters
@queue = params[:queue].presence
@search = params[:q].presence
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
end

def filtered_scope
scope = SolidQueue::Job.where.not(finished_at: nil).order(finished_at: :desc)
scope = scope.where(queue_name: @queue) if @queue.present?
scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
scope
end

def history_csv(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name duration_seconds finished_at]
scope.order(finished_at: :desc).each do |job|
duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil
csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601]
end
end
end
end
end
8 changes: 8 additions & 0 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
module SolidStackWeb
module ApplicationHelper
def format_duration(seconds)
s = seconds.to_i
return "#{s}s" if s < 60
return "#{s / 60}m #{s % 60}s" if s < 3600

"#{s / 3600}h #{(s % 3600) / 60}m"
end

def inline_styles
dir = SolidStackWeb::Engine.root.join("app/assets/stylesheets/solid_stack_web")
css = dir.glob("_*.css").sort.map(&:read).join("\n")
Expand Down
2 changes: 2 additions & 0 deletions app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "failed_jobs"}" %>
<%= link_to "Queues", queues_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
<%= link_to "History", history_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
<%= link_to "Processes", processes_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "processes"}" %>
</div>
Expand Down
73 changes: 73 additions & 0 deletions app/views/solid_stack_web/history/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Job History</h1>
<div class="sqw-header-actions">
<% if @jobs&.any? %>
<%= link_to "Export CSV", history_path(format: :csv, queue: @queue, q: @search, period: @period),
class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
<% end %>
</div>
</div>

<form class="sqw-filters" action="<%= history_path %>" method="get">
<% if @queue.present? %>
<input type="hidden" name="queue" value="<%= @queue %>">
<% end %>
<input type="hidden" name="period" value="<%= @period %>">
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
<button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
<% if @search.present? %>
<%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
<% end %>
<div class="sqw-period-filter" role="group" aria-label="Time period">
<%= link_to "All", history_path(queue: @queue, q: @search),
class: "sqw-period-btn #{"sqw-period-btn--active" if @period.nil?}" %>
<%= link_to "1h", history_path(queue: @queue, q: @search, period: "1h"),
class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "1h"}" %>
<%= link_to "24h", history_path(queue: @queue, q: @search, period: "24h"),
class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "24h"}" %>
<%= link_to "7d", history_path(queue: @queue, q: @search, period: "7d"),
class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "7d"}" %>
</div>
</form>

<% if @queue.present? %>
<p class="sqw-muted" style="font-size: 13px; margin-bottom: 0.75rem;">
Filtering by queue: <strong><%= @queue %></strong> &mdash;
<%= link_to "Clear filter", history_path(q: @search, period: @period) %>
</p>
<% end %>

<% if @jobs.any? %>
<div class="sqw-detail-card">
<table class="sqw-table">
<thead>
<tr>
<th>Job Class</th>
<th>Queue</th>
<th>Duration</th>
<th>Finished At</th>
</tr>
</thead>
<tbody>
<% @jobs.each do |job| %>
<tr>
<td class="sqw-monospace"><%= job.class_name %></td>
<td>
<%= link_to job.queue_name,
history_path(queue: job.queue_name, q: @search, period: @period),
class: "sqw-badge sqw-badge--queue" %>
</td>
<td class="sqw-monospace"><%= format_duration(job.finished_at - job.created_at) %></td>
<td class="sqw-muted"><%= job.finished_at.strftime("%b %d %H:%M:%S") %></td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
</div>
<% else %>
<div class="sqw-empty">
<p>No finished jobs found.</p>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

resources :processes, only: [:index]

get "history", to: "history#index", as: :history
get "cache", to: "cache#index", as: :cache
get "cable", to: "cable#index", as: :cable
end
31 changes: 30 additions & 1 deletion spec/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,34 @@
SolidQueue::BlockedExecution.set_callback(:create, :before, :set_expires_at)
end

# ── Solid Queue: Finished jobs (history) ─────────────────────────────────────

puts " finished jobs..."

[
{ age: 20.minutes, duration: 12 },
{ age: 45.minutes, duration: 3 },
{ age: 2.hours, duration: 87 },
{ age: 6.hours, duration: 5 },
{ age: 18.hours, duration: 210 },
{ age: 2.days, duration: 44 },
{ age: 4.days, duration: 9 },
{ age: 8.days, duration: 130 },
].each do |attrs|
finished_at = attrs[:age].ago
job = SolidQueue::Job.new(
class_name: JOB_CLASSES.sample,
queue_name: QUEUES.sample,
arguments: [{ record_id: rand(1..500) }].to_json,
priority: rand(0..5),
active_job_id: SecureRandom.uuid
)
job.finished_at = finished_at
job.created_at = finished_at - attrs[:duration].seconds
job.updated_at = finished_at
job.save!(validate: false)
end

# ── Solid Cache ───────────────────────────────────────────────────────────────

puts " cache entries..."
Expand Down Expand Up @@ -205,7 +233,8 @@
"#{SolidQueue::ScheduledExecution.count} scheduled, " \
"#{SolidQueue::ClaimedExecution.count} claimed, " \
"#{SolidQueue::BlockedExecution.count} blocked, " \
"#{SolidQueue::FailedExecution.count} failed), " \
"#{SolidQueue::FailedExecution.count} failed, " \
"#{SolidQueue::Job.where.not(finished_at: nil).count} finished), " \
"#{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"
134 changes: 134 additions & 0 deletions spec/requests/solid_stack_web/history_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
require "rails_helper"

RSpec.describe "History", type: :request do
let(:engine_root) { "/solid_stack" }

def finished_job(class_name: "TestJob", queue_name: "default", duration: 30, finished_at: 5.minutes.ago)
job = SolidQueue::Job.new(
queue_name:, class_name:, priority: 0,
arguments: { "executions" => 0, "exception_executions" => {} },
active_job_id: SecureRandom.uuid
)
job.finished_at = finished_at
job.created_at = finished_at - duration.seconds
job.updated_at = finished_at
job.save!(validate: false)
job
end

describe "GET /history" do
it "returns 200" do
get "#{engine_root}/history"
expect(response).to have_http_status(:ok)
end

it "shows the page title" do
get "#{engine_root}/history"
expect(response.body).to include("Job History")
end

it "displays finished jobs" do
finished_job(class_name: "ReportGeneratorJob")
get "#{engine_root}/history"
expect(response.body).to include("ReportGeneratorJob")
end

it "shows an empty state when no finished jobs exist" do
get "#{engine_root}/history"
expect(response.body).to include("No finished jobs found")
end

it "displays class name, queue, duration, and finished_at columns" do
finished_job(class_name: "InvoiceJob", queue_name: "critical", duration: 45)
get "#{engine_root}/history"
expect(response.body).to include("InvoiceJob")
expect(response.body).to include("critical")
expect(response.body).to include("45s")
end

it "shows History as active in the subnav" do
get "#{engine_root}/history"
expect(response.body).to include("sqw-subnav__link--active")
end
end

describe "GET /history?period=" do
it "filters to jobs finished within the period" do
finished_job(class_name: "RecentJob", finished_at: 30.minutes.ago)
finished_job(class_name: "OldJob", finished_at: 48.hours.ago)

get "#{engine_root}/history", params: { period: "1h" }

expect(response.body).to include("RecentJob")
expect(response.body).not_to include("OldJob")
end

it "ignores invalid period values and shows all jobs" do
finished_job(class_name: "AnyJob")

get "#{engine_root}/history", params: { period: "bogus" }

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

describe "GET /history?queue=" do
it "filters by queue name" do
finished_job(class_name: "CriticalJob", queue_name: "critical")
finished_job(class_name: "DefaultJob", queue_name: "default")

get "#{engine_root}/history", params: { queue: "critical" }

expect(response.body).to include("CriticalJob")
expect(response.body).not_to include("DefaultJob")
end

it "shows the active queue filter" do
get "#{engine_root}/history", params: { queue: "critical" }
expect(response.body).to include("Filtering by queue")
expect(response.body).to include("critical")
end
end

describe "GET /history?q=" do
it "filters by class name substring" do
finished_job(class_name: "InvoiceGeneratorJob")
finished_job(class_name: "CleanupJob")

get "#{engine_root}/history", params: { q: "Invoice" }

expect(response.body).to include("InvoiceGeneratorJob")
expect(response.body).not_to include("CleanupJob")
end
end

describe "GET /history.csv" do
it "returns a CSV attachment" do
get "#{engine_root}/history.csv"

expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("text/csv")
expect(response.headers["Content-Disposition"]).to include("attachment")
expect(response.headers["Content-Disposition"]).to include(".csv")
end

it "includes the correct headers" do
get "#{engine_root}/history.csv"

headers = response.body.lines.first.chomp
expect(headers).to eq("id,class_name,queue_name,duration_seconds,finished_at")
end

it "includes one row per finished job" do
finished_job(class_name: "ExportJob", queue_name: "default", duration: 12)

get "#{engine_root}/history.csv"

rows = CSV.parse(response.body, headers: true)
expect(rows.length).to eq(1)
expect(rows.first["class_name"]).to eq("ExportJob")
expect(rows.first["duration_seconds"]).to eq("12")
end
end
end