solid_queue_web 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -6
  3. data/Rakefile +2 -2
  4. data/app/assets/stylesheets/solid_queue_web/_01_base.css +41 -0
  5. data/app/assets/stylesheets/solid_queue_web/_02_layout.css +133 -0
  6. data/app/assets/stylesheets/solid_queue_web/_03_stats.css +49 -0
  7. data/app/assets/stylesheets/solid_queue_web/_04_table.css +52 -0
  8. data/app/assets/stylesheets/solid_queue_web/_05_badges.css +27 -0
  9. data/app/assets/stylesheets/solid_queue_web/_06_buttons.css +38 -0
  10. data/app/assets/stylesheets/solid_queue_web/_07_forms.css +103 -0
  11. data/app/assets/stylesheets/solid_queue_web/_08_detail.css +84 -0
  12. data/app/assets/stylesheets/solid_queue_web/_09_pagination.css +27 -0
  13. data/app/assets/stylesheets/solid_queue_web/_10_responsive.css +73 -0
  14. data/app/assets/stylesheets/solid_queue_web/_11_throughput.css +68 -0
  15. data/app/assets/stylesheets/solid_queue_web/_12_dark_mode.css +34 -0
  16. data/app/assets/stylesheets/solid_queue_web/application.css +1 -617
  17. data/app/controllers/solid_queue_web/application_controller.rb +2 -0
  18. data/app/controllers/solid_queue_web/dashboard_controller.rb +28 -0
  19. data/app/controllers/solid_queue_web/failed_jobs_controller.rb +26 -22
  20. data/app/controllers/solid_queue_web/history_controller.rb +35 -0
  21. data/app/controllers/solid_queue_web/jobs_controller.rb +38 -23
  22. data/app/controllers/solid_queue_web/queues/jobs_controller.rb +1 -1
  23. data/app/controllers/solid_queue_web/queues_controller.rb +15 -0
  24. data/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +31 -0
  25. data/app/controllers/solid_queue_web/search_controller.rb +1 -3
  26. data/app/helpers/solid_queue_web/application_helper.rb +15 -1
  27. data/app/javascript/solid_queue_web/application.js +2 -0
  28. data/app/javascript/solid_queue_web/refresh_controller.js +3 -2
  29. data/app/javascript/solid_queue_web/theme_controller.js +26 -0
  30. data/app/views/layouts/solid_queue_web/application.html.erb +12 -7
  31. data/app/views/solid_queue_web/dashboard/index.html.erb +66 -6
  32. data/app/views/solid_queue_web/failed_jobs/index.html.erb +2 -1
  33. data/app/views/solid_queue_web/history/index.html.erb +73 -0
  34. data/app/views/solid_queue_web/jobs/index.html.erb +11 -8
  35. data/app/views/solid_queue_web/processes/index.html.erb +1 -1
  36. data/app/views/solid_queue_web/queues/index.html.erb +15 -1
  37. data/app/views/solid_queue_web/search/index.html.erb +2 -2
  38. data/config/importmap.rb +1 -0
  39. data/config/routes.rb +15 -12
  40. data/lib/solid_queue_web/engine.rb +4 -2
  41. data/lib/solid_queue_web/version.rb +1 -1
  42. data/lib/solid_queue_web.rb +22 -0
  43. metadata +31 -1
@@ -0,0 +1,35 @@
1
+ module SolidQueueWeb
2
+ class HistoryController < ApplicationController
3
+ def index
4
+ @queue = params[:queue].presence
5
+ @search = params[:q].presence
6
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
7
+
8
+ scope = SolidQueue::Job.where.not(finished_at: nil)
9
+ scope = scope.where(queue_name: @queue) if @queue.present?
10
+ scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
11
+ scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
+
13
+ respond_to do |format|
14
+ format.html { @pagy, @jobs = pagy(scope.order(finished_at: :desc)) }
15
+ format.csv do
16
+ send_data history_csv(scope),
17
+ filename: "job-history-#{Date.today}.csv",
18
+ type: "text/csv", disposition: "attachment"
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def history_csv(scope)
26
+ CSV.generate(headers: true) do |csv|
27
+ csv << %w[id class_name queue_name duration_seconds finished_at]
28
+ scope.order(finished_at: :desc).each do |job|
29
+ duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil
30
+ csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,15 +1,24 @@
1
1
  module SolidQueueWeb
2
2
  class JobsController < ApplicationController
3
- before_action :set_status, only: [ :destroy, :discard_all, :discard_selected ]
3
+ before_action :set_status, only: [:destroy, :discard_selected]
4
4
 
5
5
  def index
6
6
  @status = params[:status].presence_in(Job::STATUSES) || "ready"
7
7
  @search = params[:q].presence
8
8
  @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
9
- @jobs = Job::EXECUTION_MODELS[@status].includes(:job)
10
- @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
11
- @jobs = @jobs.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
- @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
9
+ scope = Job::EXECUTION_MODELS[@status].includes(:job)
10
+ scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
11
+ scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
+ scope = scope.order(created_at: :desc)
13
+
14
+ respond_to do |format|
15
+ format.html { @pagy, @jobs = pagy(scope) }
16
+ format.csv do
17
+ send_data jobs_csv(scope),
18
+ filename: "jobs-#{@status}-#{Date.today}.csv",
19
+ type: "text/csv", disposition: "attachment"
20
+ end
21
+ end
13
22
  end
14
23
 
15
24
  def show
@@ -21,33 +30,39 @@ module SolidQueueWeb
21
30
 
22
31
  def destroy
23
32
  model = execution_model_for!(@status)
24
- @execution = model.find(params[:id])
25
- @execution.discard
26
- @remaining_count = filtered_scope(model).count
27
- respond_to do |format|
28
- format.turbo_stream
29
- format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." }
33
+ if params[:id]
34
+ @execution = model.find(params[:id])
35
+ @execution.discard
36
+ @remaining_count = filtered_scope(model).count
37
+ respond_to do |format|
38
+ format.turbo_stream
39
+ format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." }
40
+ end
41
+ else
42
+ jobs = filtered_scope(model).map(&:job)
43
+ model.discard_all_from_jobs(jobs)
44
+ redirect_to jobs_path(status: @status, period: @period),
45
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
30
46
  end
31
47
  rescue ArgumentError => e
32
48
  redirect_to jobs_path(status: @status, period: @period), alert: e.message
33
49
  rescue => e
34
- redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard job: #{e.message}"
35
- end
36
-
37
- def discard_all
38
- model = execution_model_for!(@status)
39
- jobs = filtered_scope(model).map(&:job)
40
- model.discard_all_from_jobs(jobs)
41
50
  redirect_to jobs_path(status: @status, period: @period),
42
- notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
43
- rescue ArgumentError => e
44
- redirect_to jobs_path(status: @status, period: @period), alert: e.message
45
- rescue => e
46
- redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard jobs: #{e.message}"
51
+ alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
47
52
  end
48
53
 
49
54
  private
50
55
 
56
+ def jobs_csv(scope)
57
+ CSV.generate(headers: true) do |csv|
58
+ csv << %w[id class_name queue_name status priority enqueued_at]
59
+ scope.each do |execution|
60
+ job = execution.job
61
+ csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
62
+ end
63
+ end
64
+ end
65
+
51
66
  def derive_status(job)
52
67
  return "failed" if job.failed_execution.present?
53
68
  return "claimed" if job.claimed_execution.present?
@@ -2,7 +2,7 @@ module SolidQueueWeb
2
2
  module Queues
3
3
  class JobsController < ApplicationController
4
4
  before_action :set_queue
5
- before_action :set_status, only: [ :destroy, :discard_all ]
5
+ before_action :set_status, only: [:destroy, :discard_all]
6
6
 
7
7
  def index
8
8
  @status = params[:status].presence_in(Job::STATUSES) || "ready"
@@ -2,6 +2,21 @@ module SolidQueueWeb
2
2
  class QueuesController < ApplicationController
3
3
  def index
4
4
  @queues = SolidQueue::Queue.all.sort_by(&:name)
5
+
6
+ now = Time.current
7
+ @completed_24h = SolidQueue::Job
8
+ .where(finished_at: 24.hours.ago..now)
9
+ .group(:queue_name)
10
+ .count
11
+ @failed_24h = SolidQueue::FailedExecution
12
+ .joins(:job)
13
+ .where(created_at: 24.hours.ago..now)
14
+ .group("solid_queue_jobs.queue_name")
15
+ .count
16
+ @oldest_ready = SolidQueue::ReadyExecution
17
+ .joins(:job)
18
+ .group("solid_queue_jobs.queue_name")
19
+ .minimum("solid_queue_jobs.created_at")
5
20
  end
6
21
 
7
22
  def pause
@@ -0,0 +1,31 @@
1
+ module SolidQueueWeb
2
+ class RetryFailedJobsController < ApplicationController
3
+ before_action :set_filter_params
4
+
5
+ def create
6
+ executions = params[:id] ? [SolidQueue::FailedExecution.find(params[:id])] : filtered_scope.to_a
7
+ jobs = executions.map(&:job)
8
+ SolidQueue::FailedExecution.retry_all(jobs)
9
+ redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
10
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
11
+ rescue => e
12
+ redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}"
13
+ end
14
+
15
+ private
16
+
17
+ def set_filter_params
18
+ @queue = params[:queue].presence
19
+ @search = params[:q].presence
20
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
21
+ end
22
+
23
+ def filtered_scope
24
+ scope = SolidQueue::FailedExecution.includes(:job)
25
+ scope = scope.references(:job).where(solid_queue_jobs: { queue_name: @queue }) if @queue.present?
26
+ scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
27
+ scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
28
+ scope
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,5 @@
1
1
  module SolidQueueWeb
2
2
  class SearchController < ApplicationController
3
- LIMIT = 25
4
-
5
3
  def index
6
4
  @query = params[:q].presence
7
5
  @job_classes = SolidQueue::Job.distinct.order(:class_name).pluck(:class_name)
@@ -15,7 +13,7 @@ module SolidQueueWeb
15
13
  .where("solid_queue_jobs.class_name LIKE ?", "%#{@query}%")
16
14
  .order(created_at: :desc)
17
15
  total = scope.count
18
- executions = scope.limit(LIMIT).to_a
16
+ executions = scope.limit(SolidQueueWeb.search_results_limit).to_a
19
17
  @results[status] = { executions: executions, total: total } unless executions.empty?
20
18
  end
21
19
  end
@@ -1,8 +1,22 @@
1
1
  module SolidQueueWeb
2
2
  module ApplicationHelper
3
3
  def inline_styles
4
- css = SolidQueueWeb::Engine.root.join("app/assets/stylesheets/solid_queue_web/application.css").read
4
+ dir = SolidQueueWeb::Engine.root.join("app/assets/stylesheets/solid_queue_web")
5
+ css = dir.glob("_*.css").sort.map(&:read).join("\n")
5
6
  content_tag(:style, css.html_safe)
6
7
  end
8
+
9
+ def format_duration(seconds)
10
+ s = seconds.to_i
11
+ return "< 1s" if s < 1
12
+
13
+ if s < 60
14
+ "#{s}s"
15
+ elsif s < 3600
16
+ "#{s / 60}m #{s % 60}s"
17
+ else
18
+ "#{s / 3600}h #{(s % 3600) / 60}m"
19
+ end
20
+ end
7
21
  end
8
22
  end
@@ -3,8 +3,10 @@ import { Application } from "@hotwired/stimulus"
3
3
  import SearchController from "solid_queue_web/search_controller"
4
4
  import RefreshController from "solid_queue_web/refresh_controller"
5
5
  import SelectionController from "solid_queue_web/selection_controller"
6
+ import ThemeController from "solid_queue_web/theme_controller"
6
7
 
7
8
  const application = Application.start()
8
9
  application.register("search", SearchController)
9
10
  application.register("refresh", RefreshController)
10
11
  application.register("selection", SelectionController)
12
+ application.register("theme", ThemeController)
@@ -23,7 +23,8 @@ export default class extends Controller {
23
23
 
24
24
  async _reload() {
25
25
  clearTimeout(this._timer)
26
- if (!document.hidden) {
26
+ const hasSelection = this.element.querySelector("input[type='checkbox']:checked")
27
+ if (!document.hidden && !hasSelection) {
27
28
  try {
28
29
  const response = await fetch(window.location.href, {
29
30
  headers: { "Turbo-Frame": this.element.id, Accept: "text/html" }
@@ -44,7 +45,7 @@ export default class extends Controller {
44
45
  _onVisibilityChange() {
45
46
  if (document.hidden) {
46
47
  clearTimeout(this._timer)
47
- } else {
48
+ } else if (!this.element.querySelector("input[type='checkbox']:checked")) {
48
49
  this._reload()
49
50
  }
50
51
  }
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+
6
+ connect() {
7
+ const saved = localStorage.getItem("sqd-theme")
8
+ const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
9
+ this.apply(saved || preferred)
10
+ }
11
+
12
+ toggle() {
13
+ const current = document.documentElement.getAttribute("data-theme") || "light"
14
+ const next = current === "dark" ? "light" : "dark"
15
+ localStorage.setItem("sqd-theme", next)
16
+ this.apply(next)
17
+ }
18
+
19
+ apply(theme) {
20
+ document.documentElement.setAttribute("data-theme", theme)
21
+ if (this.hasToggleTarget) {
22
+ this.toggleTarget.textContent = theme === "dark" ? "☀" : "☽"
23
+ this.toggleTarget.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
24
+ }
25
+ }
26
+ }
@@ -9,23 +9,18 @@
9
9
  <%= inline_styles %>
10
10
  <%= javascript_importmap_tags "solid_queue_web" %>
11
11
  </head>
12
- <body>
12
+ <body data-controller="theme">
13
13
 
14
14
  <header class="sqd-header">
15
15
  <div class="sqd-header__inner">
16
16
  <%= link_to "Solid Queue", root_path, class: "sqd-header__title" %>
17
- <button class="sqd-nav-toggle" aria-label="Toggle navigation" aria-expanded="false"
18
- onclick="var open=document.querySelector('.sqd-nav-wrapper').classList.toggle('sqd-nav--open');this.setAttribute('aria-expanded',open)">
19
- <span></span>
20
- <span></span>
21
- <span></span>
22
- </button>
23
17
  <div class="sqd-nav-wrapper">
24
18
  <nav aria-label="Main">
25
19
  <ul class="sqd-nav">
26
20
  <li><%= link_to "Dashboard", root_path, class: current_page?(root_path) ? "active" : "", aria: { current: current_page?(root_path) ? "page" : nil } %></li>
27
21
  <li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "", aria: { current: current_page?(queues_path) ? "page" : nil } %></li>
28
22
  <li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "", aria: { current: current_page?(jobs_path) ? "page" : nil } %></li>
23
+ <li><%= link_to "History", history_path, class: current_page?(history_path) ? "active" : "", aria: { current: current_page?(history_path) ? "page" : nil } %></li>
29
24
  <li><%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "", aria: { current: current_page?(failed_jobs_path) ? "page" : nil } %></li>
30
25
  <li><%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %></li>
31
26
  <li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
@@ -33,6 +28,16 @@
33
28
  </ul>
34
29
  </nav>
35
30
  </div>
31
+ <div class="sqd-header__controls">
32
+ <button class="sqd-theme-toggle" aria-label="Switch to dark mode"
33
+ data-theme-target="toggle" data-action="theme#toggle">☽</button>
34
+ <button class="sqd-nav-toggle" aria-label="Toggle navigation" aria-expanded="false"
35
+ onclick="var open=document.querySelector('.sqd-nav-wrapper').classList.toggle('sqd-nav--open');this.setAttribute('aria-expanded',open)">
36
+ <span></span>
37
+ <span></span>
38
+ <span></span>
39
+ </button>
40
+ </div>
36
41
  </div>
37
42
  </header>
38
43
 
@@ -1,4 +1,4 @@
1
- <%= turbo_frame_tag "dashboard", target: "_top", data: { controller: "refresh", refresh_interval_value: 5000 } do %>
1
+ <%= turbo_frame_tag "dashboard", target: "_top", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.dashboard_refresh_interval } do %>
2
2
  <h1 class="sqd-page-title">Dashboard</h1>
3
3
 
4
4
  <div class="sqd-stats">
@@ -34,9 +34,47 @@
34
34
  <div class="sqd-stat__value"><%= @stats[:processes] %></div>
35
35
  <div class="sqd-stat__label">Processes</div>
36
36
  <% end %>
37
+ <%= link_to history_path(period: "1h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
38
+ <div class="sqd-stat__value"><%= @throughput[:completed_1h] %></div>
39
+ <div class="sqd-stat__label">Done (1h)</div>
40
+ <% end %>
41
+ <%= link_to history_path(period: "24h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
42
+ <div class="sqd-stat__value"><%= @throughput[:completed_24h] %></div>
43
+ <div class="sqd-stat__label">Done (24h)</div>
44
+ <% end %>
45
+ </div>
46
+
47
+ <% max_val = [@sparkline.max, 1].max %>
48
+ <div class="sqd-card" style="margin-bottom: 1rem;">
49
+ <div class="sqd-card__header">
50
+ <span class="sqd-card__title">Throughput &mdash; Last 12 Hours</span>
51
+ <div class="sqd-throughput__summary">
52
+ <span>1h: <strong><%= @throughput[:completed_1h] %></strong></span>
53
+ <span>24h: <strong><%= @throughput[:completed_24h] %></strong></span>
54
+ </div>
55
+ </div>
56
+ <% if @throughput[:completed_24h] == 0 %>
57
+ <div class="sqd-sparkline__empty">No completed jobs in the last 24 hours</div>
58
+ <% else %>
59
+ <div class="sqd-sparkline" aria-label="Jobs completed per hour over the last 12 hours">
60
+ <% @sparkline.each_with_index do |count, i| %>
61
+ <% pct = (count.to_f / max_val * 100).round %>
62
+ <% hour_start = (12 - i).hours.ago %>
63
+ <% show_tick = [0, 3, 6, 9, 11].include?(i) %>
64
+ <div class="sqd-sparkline__col">
65
+ <div class="sqd-sparkline__bar-wrap">
66
+ <div class="sqd-sparkline__bar"
67
+ style="height: <%= [pct, 3].max %>%"
68
+ title="<%= hour_start.strftime('%-I%p').downcase %>: <%= count %> <%= "job".pluralize(count) %>"></div>
69
+ </div>
70
+ <div class="sqd-sparkline__tick"><%= show_tick ? (i == 11 ? "now" : hour_start.strftime("%-I%p").downcase) : "" %></div>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+ <% end %>
37
75
  </div>
38
76
 
39
- <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
77
+ <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
40
78
  <div class="sqd-card">
41
79
  <div class="sqd-card__header">
42
80
  <span class="sqd-card__title">Quick Links</span>
@@ -53,13 +91,35 @@
53
91
  <% if @stats[:failed] > 0 %>
54
92
  <div class="sqd-card">
55
93
  <div class="sqd-card__header">
56
- <span class="sqd-card__title">Attention Required</span>
94
+ <span class="sqd-card__title">Failed Jobs</span>
57
95
  </div>
58
- <div style="padding: 1rem;">
59
- <p style="color: var(--danger); margin-bottom: 0.75rem;">
96
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
97
+ <p style="color: var(--danger); font-size: 13px;">
60
98
  <%= pluralize(@stats[:failed], "failed job") %> need attention.
61
99
  </p>
62
- <%= link_to "Review failed jobs →", failed_jobs_path, class: "sqd-btn sqd-btn--danger" %>
100
+ <%= button_to "Retry All Failed", retry_all_failed_path,
101
+ method: :post,
102
+ class: "sqd-btn sqd-btn--primary",
103
+ data: { confirm: "Retry all #{@stats[:failed]} failed #{"job".pluralize(@stats[:failed])}?" } %>
104
+ <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>
105
+ </div>
106
+ </div>
107
+ <% end %>
108
+
109
+ <% if @stats[:blocked] > 0 %>
110
+ <div class="sqd-card">
111
+ <div class="sqd-card__header">
112
+ <span class="sqd-card__title">Blocked Jobs</span>
113
+ </div>
114
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
115
+ <p style="color: var(--warning); font-size: 13px;">
116
+ <%= pluralize(@stats[:blocked], "blocked job") %>.
117
+ </p>
118
+ <%= button_to "Discard All Blocked", discard_all_blocked_path,
119
+ method: :post,
120
+ class: "sqd-btn sqd-btn--danger",
121
+ data: { confirm: "Discard all #{@stats[:blocked]} blocked #{"job".pluralize(@stats[:blocked])}? This cannot be undone." } %>
122
+ <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>
63
123
  </div>
64
124
  </div>
65
125
  <% end %>
@@ -2,6 +2,8 @@
2
2
  <h1 class="sqd-page-title">Failed Jobs</h1>
3
3
  <% if @failed_jobs.any? %>
4
4
  <div class="sqd-actions">
5
+ <%= link_to "Export CSV", failed_jobs_path(format: :csv, queue: @queue, q: @search, period: @period),
6
+ class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
5
7
  <%= button_to "Retry All", retry_all_failed_jobs_path,
6
8
  method: :post,
7
9
  params: { queue: @queue, q: @search, period: @period },
@@ -24,7 +26,6 @@
24
26
  <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
25
27
  placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
26
28
  data-action="input->search#filter">
27
- <button type="submit" class="sqd-btn sqd-btn--muted">Search</button>
28
29
  <% if @search.present? %>
29
30
  <%= link_to "Clear", failed_jobs_path(queue: @queue, period: @period), class: "sqd-btn sqd-btn--muted" %>
30
31
  <% end %>
@@ -0,0 +1,73 @@
1
+ <%= turbo_frame_tag "history-table", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>
2
+ <div class="sqd-page-header">
3
+ <h1 class="sqd-page-title">Job History</h1>
4
+ <% if @jobs.any? %>
5
+ <div class="sqd-actions">
6
+ <%= link_to "Export CSV", history_path(format: :csv, queue: @queue, q: @search, period: @period),
7
+ class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
8
+ </div>
9
+ <% end %>
10
+ </div>
11
+
12
+ <form class="sqd-search" action="<%= history_path %>" method="get" data-controller="search">
13
+ <% if @queue.present? %>
14
+ <input type="hidden" name="queue" value="<%= @queue %>">
15
+ <% end %>
16
+ <input type="hidden" name="period" value="<%= @period %>">
17
+ <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
18
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
19
+ data-action="input->search#filter">
20
+ <% if @search.present? %>
21
+ <%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqd-btn sqd-btn--muted" %>
22
+ <% end %>
23
+ <div class="sqd-period-filter" role="group" aria-label="Time period">
24
+ <%= link_to "All", history_path(queue: @queue, q: @search), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil } %>
25
+ <%= link_to "1h", history_path(queue: @queue, q: @search, period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil } %>
26
+ <%= link_to "24h", history_path(queue: @queue, q: @search, period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil } %>
27
+ <%= link_to "7d", history_path(queue: @queue, q: @search, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil } %>
28
+ </div>
29
+ </form>
30
+
31
+ <% if @queue.present? %>
32
+ <p style="margin-top: 0.5rem; font-size: 13px; color: var(--muted);">
33
+ Filtering by queue: <strong><%= @queue %></strong> &mdash;
34
+ <%= link_to "Clear filter", history_path(q: @search, period: @period) %>
35
+ </p>
36
+ <% end %>
37
+
38
+ <% if @jobs.any? %>
39
+ <div class="sqd-card">
40
+ <table>
41
+ <thead>
42
+ <tr>
43
+ <th scope="col">Job Class</th>
44
+ <th scope="col">Queue</th>
45
+ <th scope="col">Duration</th>
46
+ <th scope="col">Finished At</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <% @jobs.each do |job| %>
51
+ <tr>
52
+ <td><%= link_to job.class_name, job_path(job) %></td>
53
+ <td>
54
+ <%= link_to job.queue_name, history_path(queue: job.queue_name, q: @search, period: @period),
55
+ class: "sqd-mono", style: "color: inherit;" %>
56
+ </td>
57
+ <td class="sqd-mono"><%= format_duration(job.finished_at - job.created_at) %></td>
58
+ <td class="sqd-mono"><%= job.finished_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
59
+ </tr>
60
+ <% end %>
61
+ </tbody>
62
+ </table>
63
+ </div>
64
+ <% else %>
65
+ <div class="sqd-card">
66
+ <div class="sqd-empty">No finished jobs found.</div>
67
+ </div>
68
+ <% end %>
69
+
70
+ <% if @pagy.last > 1 %>
71
+ <%= @pagy.series_nav.html_safe %>
72
+ <% end %>
73
+ <% end %>
@@ -1,6 +1,6 @@
1
1
  <h1 class="sqd-page-title" style="margin-bottom: 1.5rem;">Jobs</h1>
2
2
 
3
- <%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance", controller: "refresh", refresh_interval_value: 10000 } do %>
3
+ <%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance", controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>
4
4
  <% discardable = SolidQueueWeb::Job::DISCARDABLE.include?(@status) %>
5
5
 
6
6
  <div class="sqd-page-header">
@@ -11,13 +11,17 @@
11
11
  <%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period), class: @status == "blocked" ? "active" : "" %>
12
12
  <%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period), class: @status == "failed" ? "active" : "" %>
13
13
  </div>
14
- <% if discardable && @jobs.any? %>
14
+ <% if @jobs.any? %>
15
15
  <div class="sqd-actions">
16
- <%= button_to "Discard All", discard_all_jobs_path,
17
- method: :post,
18
- params: { status: @status, period: @period },
19
- class: "sqd-btn sqd-btn--danger",
20
- data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
16
+ <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, period: @period),
17
+ class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
18
+ <% if discardable %>
19
+ <%= button_to "Discard All", discard_all_jobs_path,
20
+ method: :post,
21
+ params: { status: @status, period: @period },
22
+ class: "sqd-btn sqd-btn--danger",
23
+ data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
24
+ <% end %>
21
25
  </div>
22
26
  <% end %>
23
27
  </div>
@@ -28,7 +32,6 @@
28
32
  <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
29
33
  placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
30
34
  data-action="input->search#filter">
31
- <button type="submit" class="sqd-btn sqd-btn--muted">Search</button>
32
35
  <% if @search.present? %>
33
36
  <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqd-btn sqd-btn--muted" %>
34
37
  <% end %>
@@ -1,4 +1,4 @@
1
- <%= turbo_frame_tag "processes", target: "_top", data: { controller: "refresh", refresh_interval_value: 10000 } do %>
1
+ <%= turbo_frame_tag "processes", target: "_top", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>
2
2
  <h1 class="sqd-page-title">Processes</h1>
3
3
 
4
4
  <div class="sqd-card">
@@ -10,6 +10,8 @@
10
10
  <th scope="col">Name</th>
11
11
  <th scope="col">Size</th>
12
12
  <th scope="col">Latency</th>
13
+ <th scope="col">Done (24h)</th>
14
+ <th scope="col">Failed (24h)</th>
13
15
  <th scope="col">Status</th>
14
16
  <th scope="col"><span class="sqd-sr-only">Actions</span></th>
15
17
  </tr>
@@ -19,7 +21,19 @@
19
21
  <tr>
20
22
  <td class="sqd-mono"><%= queue.name %></td>
21
23
  <td><%= queue.size %></td>
22
- <td><%= queue.human_latency %></td>
24
+ <td>
25
+ <% if (oldest = @oldest_ready[queue.name]) %>
26
+ <% age = Time.current - oldest %>
27
+ <% latency_color = age > 86_400 ? "var(--danger)" : age > 3_600 ? "var(--warning)" : "inherit" %>
28
+ <abbr title="<%= oldest.strftime("%Y-%m-%d %H:%M:%S UTC") %>">
29
+ <span style="color: <%= latency_color %>"><%= format_duration(age) %></span>
30
+ </abbr>
31
+ <% else %>
32
+ <span style="color: var(--muted)">—</span>
33
+ <% end %>
34
+ </td>
35
+ <td style="color: var(--success);"><%= @completed_24h[queue.name] || 0 %></td>
36
+ <td style="color: <%= (@failed_24h[queue.name] || 0) > 0 ? "var(--danger)" : "inherit" %>;"><%= @failed_24h[queue.name] || 0 %></td>
23
37
  <td>
24
38
  <% if queue.paused? %>
25
39
  <span class="sqd-badge sqd-badge--paused">Paused</span>
@@ -27,8 +27,8 @@
27
27
  <span class="sqd-badge sqd-badge--<%= status %>"><%= status %></span>
28
28
  <span class="sqd-muted-text">
29
29
  <%= pluralize(data[:total], "match", "matches") %>
30
- <% if data[:total] > SolidQueueWeb::SearchController::LIMIT %>
31
- &mdash; showing first <%= SolidQueueWeb::SearchController::LIMIT %>
30
+ <% if data[:total] > SolidQueueWeb.search_results_limit %>
31
+ &mdash; showing first <%= SolidQueueWeb.search_results_limit %>
32
32
  <% end %>
33
33
  </span>
34
34
  <% if status == "failed" %>
data/config/importmap.rb CHANGED
@@ -2,3 +2,4 @@ pin "solid_queue_web", to: "solid_queue_web/application.js"
2
2
  pin "solid_queue_web/search_controller", to: "solid_queue_web/search_controller.js"
3
3
  pin "solid_queue_web/refresh_controller", to: "solid_queue_web/refresh_controller.js"
4
4
  pin "solid_queue_web/selection_controller", to: "solid_queue_web/selection_controller.js"
5
+ pin "solid_queue_web/theme_controller", to: "solid_queue_web/theme_controller.js"