From 5f71142b611201d1585a635840dc8a869339b26e Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 18:07:19 -0400 Subject: [PATCH 1/2] feat: job history view with filters, duration, and CSV export Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + .../solid_stack_web/application_controller.rb | 2 +- .../solid_stack_web/history_controller.rb | 42 ++++++ .../solid_stack_web/application_helper.rb | 8 ++ .../solid_stack_web/application.html.erb | 2 + .../solid_stack_web/history/index.html.erb | 73 ++++++++++ config/routes.rb | 1 + spec/requests/solid_stack_web/history_spec.rb | 134 ++++++++++++++++++ 9 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 app/controllers/solid_stack_web/history_controller.rb create mode 100644 app/views/solid_stack_web/history/index.html.erb create mode 100644 spec/requests/solid_stack_web/history_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fef07f..df4e318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cda1c4f..950789f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb index 12051f4..57da95a 100644 --- a/app/controllers/solid_stack_web/application_controller.rb +++ b/app/controllers/solid_stack_web/application_controller.rb @@ -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 diff --git a/app/controllers/solid_stack_web/history_controller.rb b/app/controllers/solid_stack_web/history_controller.rb new file mode 100644 index 0000000..1738717 --- /dev/null +++ b/app/controllers/solid_stack_web/history_controller.rb @@ -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 diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index d22a71b..8ade74f 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -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") diff --git a/app/views/layouts/solid_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb index 3e1f833..40dbdb0 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -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"}" %> diff --git a/app/views/solid_stack_web/history/index.html.erb b/app/views/solid_stack_web/history/index.html.erb new file mode 100644 index 0000000..d2f4184 --- /dev/null +++ b/app/views/solid_stack_web/history/index.html.erb @@ -0,0 +1,73 @@ +
+

Job History

+
+ <% 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 %> +
+
+ +
+ <% if @queue.present? %> + + <% end %> + + + + <% if @search.present? %> + <%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <% end %> +
+ <%= 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"}" %> +
+
+ +<% if @queue.present? %> +

+ Filtering by queue: <%= @queue %> — + <%= link_to "Clear filter", history_path(q: @search, period: @period) %> +

+<% end %> + +<% if @jobs.any? %> +
+ + + + + + + + + + + <% @jobs.each do |job| %> + + + + + + + <% end %> + +
Job ClassQueueDurationFinished At
<%= job.class_name %> + <%= link_to job.queue_name, + history_path(queue: job.queue_name, q: @search, period: @period), + class: "sqw-badge sqw-badge--queue" %> + <%= format_duration(job.finished_at - job.created_at) %><%= job.finished_at.strftime("%b %d %H:%M:%S") %>
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
+<% else %> +
+

No finished jobs found.

+
+<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index ed9d99f..83c6ada 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/solid_stack_web/history_spec.rb b/spec/requests/solid_stack_web/history_spec.rb new file mode 100644 index 0000000..0dc45eb --- /dev/null +++ b/spec/requests/solid_stack_web/history_spec.rb @@ -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 From c3c484fce9145b5343ff21acd0a6a2e75335f524 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 18:09:24 -0400 Subject: [PATCH 2/2] chore: seed finished jobs for history view date filter testing Co-Authored-By: Claude Sonnet 4.6 --- spec/dummy/db/seeds.rb | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index e106ea5..5f7d280 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -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..." @@ -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" \ No newline at end of file