solid_queue_web 0.7.0 → 0.9.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -15
  3. data/app/assets/stylesheets/solid_queue_web/_02_layout.css +29 -1
  4. data/app/assets/stylesheets/solid_queue_web/_04_table.css +8 -1
  5. data/app/assets/stylesheets/solid_queue_web/_05_badges.css +1 -0
  6. data/app/assets/stylesheets/solid_queue_web/_11_throughput.css +30 -1
  7. data/app/assets/stylesheets/solid_queue_web/_12_dark_mode.css +34 -0
  8. data/app/controllers/solid_queue_web/application_controller.rb +2 -0
  9. data/app/controllers/solid_queue_web/blocked_jobs_controller.rb +11 -0
  10. data/app/controllers/solid_queue_web/dashboard_controller.rb +1 -22
  11. data/app/controllers/solid_queue_web/failed_jobs_controller.rb +26 -22
  12. data/app/controllers/solid_queue_web/history_controller.rb +20 -1
  13. data/app/controllers/solid_queue_web/jobs_controller.rb +38 -23
  14. data/app/controllers/solid_queue_web/queues/jobs_controller.rb +15 -19
  15. data/app/controllers/solid_queue_web/queues/pauses_controller.rb +21 -0
  16. data/app/controllers/solid_queue_web/queues_controller.rb +5 -31
  17. data/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +31 -0
  18. data/app/controllers/solid_queue_web/search_controller.rb +1 -3
  19. data/app/javascript/solid_queue_web/application.js +2 -0
  20. data/app/javascript/solid_queue_web/theme_controller.js +26 -0
  21. data/app/services/solid_queue_web/dashboard_stats.rb +47 -0
  22. data/app/services/solid_queue_web/queue_stats.rb +52 -0
  23. data/app/views/layouts/solid_queue_web/application.html.erb +11 -7
  24. data/app/views/solid_queue_web/dashboard/index.html.erb +89 -23
  25. data/app/views/solid_queue_web/failed_jobs/index.html.erb +3 -1
  26. data/app/views/solid_queue_web/history/index.html.erb +8 -2
  27. data/app/views/solid_queue_web/jobs/index.html.erb +27 -10
  28. data/app/views/solid_queue_web/processes/index.html.erb +1 -1
  29. data/app/views/solid_queue_web/queues/index.html.erb +19 -2
  30. data/app/views/solid_queue_web/queues/jobs/index.html.erb +1 -1
  31. data/app/views/solid_queue_web/search/index.html.erb +3 -3
  32. data/config/importmap.rb +1 -0
  33. data/config/routes.rb +7 -9
  34. data/lib/solid_queue_web/engine.rb +4 -2
  35. data/lib/solid_queue_web/version.rb +1 -1
  36. data/lib/solid_queue_web.rb +27 -0
  37. metadata +22 -1
@@ -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)
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ module SolidQueueWeb
2
+ class DashboardStats
3
+ attr_reader :counts, :throughput, :sparkline, :depth_sparkline, :slow_jobs_count
4
+
5
+ def initialize
6
+ @now = Time.current
7
+ compute
8
+ end
9
+
10
+ private
11
+
12
+ def compute
13
+ @counts = {
14
+ ready: SolidQueue::ReadyExecution.count,
15
+ scheduled: SolidQueue::ScheduledExecution.count,
16
+ claimed: SolidQueue::ClaimedExecution.count,
17
+ failed: SolidQueue::FailedExecution.count,
18
+ blocked: SolidQueue::BlockedExecution.count,
19
+ queues: SolidQueue::Job.select(:queue_name).distinct.count,
20
+ processes: SolidQueue::Process.count,
21
+ recurring: SolidQueue::RecurringTask.count
22
+ }
23
+
24
+ finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..@now).pluck(:finished_at)
25
+ @throughput = {
26
+ completed_1h: finished_times.count { |t| t >= 1.hour.ago },
27
+ completed_24h: finished_times.size
28
+ }
29
+ @sparkline = 12.times.map do |i|
30
+ from = (12 - i).hours.ago
31
+ to = i == 11 ? @now : (11 - i).hours.ago
32
+ finished_times.count { |t| t >= from && t < to }
33
+ end
34
+
35
+ threshold = SolidQueueWeb.slow_job_threshold
36
+ @slow_jobs_count = threshold ? SolidQueue::ClaimedExecution.where("created_at <= ?", threshold.ago).count : 0
37
+
38
+ job_timestamps = SolidQueue::Job
39
+ .where("created_at >= ? OR finished_at IS NULL", 72.hours.ago)
40
+ .pluck(:created_at, :finished_at)
41
+ @depth_sparkline = 12.times.map do |i|
42
+ t = i == 11 ? @now : (12 - i).hours.ago
43
+ job_timestamps.count { |created, finished| created <= t && (finished.nil? || finished > t) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ module SolidQueueWeb
2
+ class QueueStats
3
+ attr_reader :completed_24h, :failed_24h, :oldest_ready, :failure_sparklines
4
+
5
+ def initialize(queues)
6
+ @queues = queues
7
+ @now = Time.current
8
+ compute
9
+ end
10
+
11
+ private
12
+
13
+ def compute
14
+ @completed_24h = SolidQueue::Job
15
+ .where(finished_at: 24.hours.ago..@now)
16
+ .group(:queue_name)
17
+ .count
18
+
19
+ @failed_24h = SolidQueue::FailedExecution
20
+ .joins(:job)
21
+ .where(created_at: 24.hours.ago..@now)
22
+ .group("solid_queue_jobs.queue_name")
23
+ .count
24
+
25
+ @oldest_ready = SolidQueue::ReadyExecution
26
+ .joins(:job)
27
+ .group("solid_queue_jobs.queue_name")
28
+ .minimum("solid_queue_jobs.created_at")
29
+
30
+ failed_raw = SolidQueue::FailedExecution
31
+ .joins(:job)
32
+ .where(created_at: 12.hours.ago..@now)
33
+ .pluck("solid_queue_jobs.queue_name", "solid_queue_failed_executions.created_at")
34
+ done_raw = SolidQueue::Job
35
+ .where(finished_at: 12.hours.ago..@now)
36
+ .pluck(:queue_name, :finished_at)
37
+
38
+ @failure_sparklines = @queues.each_with_object({}) do |queue, h|
39
+ failed_times = failed_raw.filter_map { |q, t| t if q == queue.name }
40
+ done_times = done_raw.filter_map { |q, t| t if q == queue.name }
41
+ h[queue.name] = 12.times.map do |i|
42
+ from = (12 - i).hours.ago
43
+ to = i == 11 ? @now : (11 - i).hours.ago
44
+ f = failed_times.count { |t| t >= from && t < to }
45
+ d = done_times.count { |t| t >= from && t < to }
46
+ total = f + d
47
+ total > 0 ? (f.to_f / total * 100).round : nil
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -9,17 +9,11 @@
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">
@@ -34,6 +28,16 @@
34
28
  </ul>
35
29
  </nav>
36
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>
37
41
  </div>
38
42
  </header>
39
43
 
@@ -1,63 +1,63 @@
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">
5
5
  <%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %>
6
- <div class="sqd-stat__value"><%= @stats[:ready] %></div>
6
+ <div class="sqd-stat__value"><%= @stats.counts[:ready] %></div>
7
7
  <div class="sqd-stat__label">Ready</div>
8
8
  <% end %>
9
9
  <%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %>
10
- <div class="sqd-stat__value"><%= @stats[:scheduled] %></div>
10
+ <div class="sqd-stat__value"><%= @stats.counts[:scheduled] %></div>
11
11
  <div class="sqd-stat__label">Scheduled</div>
12
12
  <% end %>
13
13
  <%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %>
14
- <div class="sqd-stat__value"><%= @stats[:claimed] %></div>
14
+ <div class="sqd-stat__value"><%= @stats.counts[:claimed] %></div>
15
15
  <div class="sqd-stat__label">Running</div>
16
16
  <% end %>
17
17
  <%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %>
18
- <div class="sqd-stat__value"><%= @stats[:blocked] %></div>
18
+ <div class="sqd-stat__value"><%= @stats.counts[:blocked] %></div>
19
19
  <div class="sqd-stat__label">Blocked</div>
20
20
  <% end %>
21
21
  <%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %>
22
- <div class="sqd-stat__value"><%= @stats[:failed] %></div>
22
+ <div class="sqd-stat__value"><%= @stats.counts[:failed] %></div>
23
23
  <div class="sqd-stat__label">Failed</div>
24
24
  <% end %>
25
25
  <%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %>
26
- <div class="sqd-stat__value"><%= @stats[:queues] %></div>
26
+ <div class="sqd-stat__value"><%= @stats.counts[:queues] %></div>
27
27
  <div class="sqd-stat__label">Queues</div>
28
28
  <% end %>
29
29
  <%= link_to recurring_tasks_path, class: "sqd-stat sqd-stat--recurring sqd-stat--link" do %>
30
- <div class="sqd-stat__value"><%= @stats[:recurring] %></div>
30
+ <div class="sqd-stat__value"><%= @stats.counts[:recurring] %></div>
31
31
  <div class="sqd-stat__label">Recurring</div>
32
32
  <% end %>
33
33
  <%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
34
- <div class="sqd-stat__value"><%= @stats[:processes] %></div>
34
+ <div class="sqd-stat__value"><%= @stats.counts[:processes] %></div>
35
35
  <div class="sqd-stat__label">Processes</div>
36
36
  <% end %>
37
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>
38
+ <div class="sqd-stat__value"><%= @stats.throughput[:completed_1h] %></div>
39
39
  <div class="sqd-stat__label">Done (1h)</div>
40
40
  <% end %>
41
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>
42
+ <div class="sqd-stat__value"><%= @stats.throughput[:completed_24h] %></div>
43
43
  <div class="sqd-stat__label">Done (24h)</div>
44
44
  <% end %>
45
45
  </div>
46
46
 
47
- <% max_val = [@sparkline.max, 1].max %>
47
+ <% max_val = [@stats.sparkline.max, 1].max %>
48
48
  <div class="sqd-card" style="margin-bottom: 1rem;">
49
49
  <div class="sqd-card__header">
50
50
  <span class="sqd-card__title">Throughput &mdash; Last 12 Hours</span>
51
51
  <div class="sqd-throughput__summary">
52
- <span>1h: <strong><%= @throughput[:completed_1h] %></strong></span>
53
- <span>24h: <strong><%= @throughput[:completed_24h] %></strong></span>
52
+ <span>1h: <strong><%= @stats.throughput[:completed_1h] %></strong></span>
53
+ <span>24h: <strong><%= @stats.throughput[:completed_24h] %></strong></span>
54
54
  </div>
55
55
  </div>
56
- <% if @throughput[:completed_24h] == 0 %>
56
+ <% if @stats.throughput[:completed_24h] == 0 %>
57
57
  <div class="sqd-sparkline__empty">No completed jobs in the last 24 hours</div>
58
58
  <% else %>
59
59
  <div class="sqd-sparkline" aria-label="Jobs completed per hour over the last 12 hours">
60
- <% @sparkline.each_with_index do |count, i| %>
60
+ <% @stats.sparkline.each_with_index do |count, i| %>
61
61
  <% pct = (count.to_f / max_val * 100).round %>
62
62
  <% hour_start = (12 - i).hours.ago %>
63
63
  <% show_tick = [0, 3, 6, 9, 11].include?(i) %>
@@ -74,7 +74,37 @@
74
74
  <% end %>
75
75
  </div>
76
76
 
77
- <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
77
+ <% current_depth = @stats.counts[:ready] + @stats.counts[:scheduled] + @stats.counts[:claimed] + @stats.counts[:blocked] + @stats.counts[:failed] %>
78
+ <% max_depth = [@stats.depth_sparkline.max, 1].max %>
79
+ <div class="sqd-card" style="margin-bottom: 1rem;">
80
+ <div class="sqd-card__header">
81
+ <span class="sqd-card__title">Queue Depth &mdash; Last 12 Hours</span>
82
+ <div class="sqd-throughput__summary">
83
+ <span>Now: <strong><%= current_depth %></strong></span>
84
+ </div>
85
+ </div>
86
+ <% if @stats.depth_sparkline.all?(&:zero?) %>
87
+ <div class="sqd-sparkline__empty">No active jobs in the last 12 hours</div>
88
+ <% else %>
89
+ <div class="sqd-sparkline" aria-label="Queue depth over the last 12 hours">
90
+ <% @stats.depth_sparkline.each_with_index do |depth, i| %>
91
+ <% pct = (depth.to_f / max_depth * 100).round %>
92
+ <% t = i == 11 ? Time.current : (12 - i).hours.ago %>
93
+ <% show_tick = [0, 3, 6, 9, 11].include?(i) %>
94
+ <div class="sqd-sparkline__col">
95
+ <div class="sqd-sparkline__bar-wrap">
96
+ <div class="sqd-sparkline__bar sqd-sparkline__bar--depth"
97
+ style="height: <%= [pct, 3].max %>%"
98
+ title="<%= i == 11 ? "now" : t.strftime("%-I%p").downcase %>: <%= depth %> <%= "job".pluralize(depth) %> in queue"></div>
99
+ </div>
100
+ <div class="sqd-sparkline__tick"><%= show_tick ? (i == 11 ? "now" : t.strftime("%-I%p").downcase) : "" %></div>
101
+ </div>
102
+ <% end %>
103
+ </div>
104
+ <% end %>
105
+ </div>
106
+
107
+ <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
78
108
  <div class="sqd-card">
79
109
  <div class="sqd-card__header">
80
110
  <span class="sqd-card__title">Quick Links</span>
@@ -88,16 +118,52 @@
88
118
  </div>
89
119
  </div>
90
120
 
91
- <% if @stats[:failed] > 0 %>
121
+ <% if @stats.counts[:failed] > 0 %>
122
+ <div class="sqd-card">
123
+ <div class="sqd-card__header">
124
+ <span class="sqd-card__title">Failed Jobs</span>
125
+ </div>
126
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
127
+ <p style="color: var(--danger); font-size: 13px;">
128
+ <%= pluralize(@stats.counts[:failed], "failed job") %> need attention.
129
+ </p>
130
+ <%= button_to "Retry All Failed", retry_all_failed_jobs_path,
131
+ method: :post,
132
+ class: "sqd-btn sqd-btn--primary",
133
+ data: { confirm: "Retry all #{@stats.counts[:failed]} failed #{"job".pluralize(@stats.counts[:failed])}?" } %>
134
+ <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>
135
+ </div>
136
+ </div>
137
+ <% end %>
138
+
139
+ <% if SolidQueueWeb.slow_job_threshold && @stats.slow_jobs_count > 0 %>
140
+ <div class="sqd-card">
141
+ <div class="sqd-card__header">
142
+ <span class="sqd-card__title">Slow Jobs</span>
143
+ </div>
144
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
145
+ <p style="color: var(--warning); font-size: 13px;">
146
+ <%= pluralize(@stats.slow_jobs_count, "job") %> running longer than <%= distance_of_time_in_words(SolidQueueWeb.slow_job_threshold) %>.
147
+ </p>
148
+ <%= link_to "Review →", jobs_path(status: "claimed"), class: "sqd-btn sqd-btn--muted" %>
149
+ </div>
150
+ </div>
151
+ <% end %>
152
+
153
+ <% if @stats.counts[:blocked] > 0 %>
92
154
  <div class="sqd-card">
93
155
  <div class="sqd-card__header">
94
- <span class="sqd-card__title">Attention Required</span>
156
+ <span class="sqd-card__title">Blocked Jobs</span>
95
157
  </div>
96
- <div style="padding: 1rem;">
97
- <p style="color: var(--danger); margin-bottom: 0.75rem;">
98
- <%= pluralize(@stats[:failed], "failed job") %> need attention.
158
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
159
+ <p style="color: var(--warning); font-size: 13px;">
160
+ <%= pluralize(@stats.counts[:blocked], "blocked job") %>.
99
161
  </p>
100
- <%= link_to "Review failed jobs →", failed_jobs_path, class: "sqd-btn sqd-btn--danger" %>
162
+ <%= button_to "Discard All Blocked", blocked_jobs_path,
163
+ method: :delete,
164
+ class: "sqd-btn sqd-btn--danger",
165
+ data: { confirm: "Discard all #{@stats.counts[:blocked]} blocked #{"job".pluralize(@stats.counts[:blocked])}? This cannot be undone." } %>
166
+ <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>
101
167
  </div>
102
168
  </div>
103
169
  <% 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 },
@@ -90,7 +92,7 @@
90
92
  data-action="change->selection#toggle"
91
93
  aria-label="Select job <%= job.class_name %>">
92
94
  </td>
93
- <td><%= link_to job.class_name, job_path(job) %></td>
95
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
94
96
  <td>
95
97
  <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period),
96
98
  class: "sqd-mono", style: "color: inherit;" %>
@@ -1,6 +1,12 @@
1
- <%= turbo_frame_tag "history-table", data: { controller: "refresh", refresh_interval_value: 10000 } do %>
1
+ <%= turbo_frame_tag "history-table", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>
2
2
  <div class="sqd-page-header">
3
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 %>
4
10
  </div>
5
11
 
6
12
  <form class="sqd-search" action="<%= history_path %>" method="get" data-controller="search">
@@ -43,7 +49,7 @@
43
49
  <tbody>
44
50
  <% @jobs.each do |job| %>
45
51
  <tr>
46
- <td><%= link_to job.class_name, job_path(job) %></td>
52
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
47
53
  <td>
48
54
  <%= link_to job.queue_name, history_path(queue: job.queue_name, q: @search, period: @period),
49
55
  class: "sqd-mono", style: "color: inherit;" %>
@@ -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>
@@ -83,7 +87,7 @@
83
87
  </td>
84
88
  <td>
85
89
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
86
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
90
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
87
91
  </td>
88
92
  <td>
89
93
  <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
@@ -112,6 +116,7 @@
112
116
  <% if @jobs.empty? %>
113
117
  <div class="sqd-empty">No <%= @status %> jobs.</div>
114
118
  <% else %>
119
+ <% slow_threshold = @status == "claimed" ? SolidQueueWeb.slow_job_threshold : nil %>
115
120
  <table>
116
121
  <thead>
117
122
  <tr>
@@ -120,15 +125,22 @@
120
125
  <th scope="col">Priority</th>
121
126
  <th scope="col">Scheduled At</th>
122
127
  <th scope="col">Enqueued At</th>
128
+ <% if @status == "claimed" %>
129
+ <th scope="col">Running For</th>
130
+ <% end %>
123
131
  </tr>
124
132
  </thead>
125
133
  <tbody>
126
134
  <% @jobs.each do |execution| %>
127
135
  <% job = execution.job %>
128
- <tr id="execution_<%= execution.id %>">
136
+ <% slow = slow_threshold && execution.created_at <= slow_threshold.ago %>
137
+ <tr id="execution_<%= execution.id %>"<%= slow ? ' class="sqd-row--slow"'.html_safe : "" %>>
129
138
  <td>
130
139
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
131
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
140
+ <% if slow %>
141
+ <span class="sqd-badge sqd-badge--slow">slow</span>
142
+ <% end %>
143
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
132
144
  </td>
133
145
  <td>
134
146
  <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
@@ -139,6 +151,11 @@
139
151
  <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
140
152
  </td>
141
153
  <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
154
+ <% if @status == "claimed" %>
155
+ <td class="sqd-mono<%= slow ? " sqd-slow-duration" : "" %>">
156
+ <%= time_ago_in_words(execution.created_at) %>
157
+ </td>
158
+ <% end %>
142
159
  </tr>
143
160
  <% end %>
144
161
  </tbody>
@@ -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">
@@ -12,6 +12,7 @@
12
12
  <th scope="col">Latency</th>
13
13
  <th scope="col">Done (24h)</th>
14
14
  <th scope="col">Failed (24h)</th>
15
+ <th scope="col">Failure Rate (12h)</th>
15
16
  <th scope="col">Status</th>
16
17
  <th scope="col"><span class="sqd-sr-only">Actions</span></th>
17
18
  </tr>
@@ -34,6 +35,22 @@
34
35
  </td>
35
36
  <td style="color: var(--success);"><%= @completed_24h[queue.name] || 0 %></td>
36
37
  <td style="color: <%= (@failed_24h[queue.name] || 0) > 0 ? "var(--danger)" : "inherit" %>;"><%= @failed_24h[queue.name] || 0 %></td>
38
+ <td>
39
+ <% sparkline = @failure_sparklines[queue.name] %>
40
+ <% if sparkline.any? %>
41
+ <div class="sqd-mini-sparkline" aria-label="Failure rate last 12 hours for <%= queue.name %>">
42
+ <% sparkline.each_with_index do |rate, i| %>
43
+ <% pct = rate || 0 %>
44
+ <% hour_label = (12 - i).hours.ago.strftime("%-I%p").downcase %>
45
+ <div class="sqd-mini-sparkline__bar sqd-mini-sparkline__bar--<%= rate ? "data" : "empty" %>"
46
+ style="height: <%= [pct, 2].max %>%"
47
+ title="<%= hour_label %>: <%= rate ? "#{rate}% failure rate" : "no data" %>"></div>
48
+ <% end %>
49
+ </div>
50
+ <% else %>
51
+ <span style="color: var(--muted)">—</span>
52
+ <% end %>
53
+ </td>
37
54
  <td>
38
55
  <% if queue.paused? %>
39
56
  <span class="sqd-badge sqd-badge--paused">Paused</span>
@@ -43,10 +60,10 @@
43
60
  </td>
44
61
  <td class="sqd-row-actions">
45
62
  <% if queue.paused? %>
46
- <%= button_to "Resume", resume_queue_path(queue.name), method: :post,
63
+ <%= button_to "Resume", queue_pause_path(queue.name), method: :delete,
47
64
  class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
48
65
  <% else %>
49
- <%= button_to "Pause", pause_queue_path(queue.name), method: :post,
66
+ <%= button_to "Pause", queue_pause_path(queue.name), method: :post,
50
67
  class: "sqd-btn sqd-btn--muted sqd-btn--sm",
51
68
  data: { confirm: "Pause queue \"#{queue.name}\"?" } %>
52
69
  <% end %>
@@ -60,7 +60,7 @@
60
60
  <tr id="execution_<%= execution.id %>">
61
61
  <td>
62
62
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
63
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
63
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
64
64
  </td>
65
65
  <td><%= job.priority %></td>
66
66
  <td class="sqd-mono">
@@ -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" %>
@@ -50,7 +50,7 @@
50
50
  <% data[:executions].each do |execution| %>
51
51
  <% job = execution.job %>
52
52
  <tr>
53
- <td><%= link_to job.class_name, job_path(job) %></td>
53
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
54
54
  <td class="sqd-mono"><%= job.queue_name %></td>
55
55
  <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
56
56
  </tr>
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"
data/config/routes.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  SolidQueueWeb::Engine.routes.draw do
2
2
  root to: "dashboard#index"
3
+ resource :blocked_jobs, only: [:destroy]
3
4
 
4
5
  get "search", to: "search#index", as: :search
5
6
  get "history", to: "history#index", as: :history
@@ -7,13 +8,10 @@ SolidQueueWeb::Engine.routes.draw do
7
8
  resources :recurring_tasks, only: [:index]
8
9
  resources :processes, only: [:index]
9
10
  resources :queues, only: [:index], param: :name do
10
- member do
11
- post :pause
12
- post :resume
13
- end
11
+ resource :pause, only: [:create, :destroy], controller: "queues/pauses"
14
12
  resources :jobs, path: "list", only: [:index, :destroy], controller: "queues/jobs" do
15
13
  collection do
16
- post :discard_all
14
+ post :discard_all, action: :destroy
17
15
  end
18
16
  end
19
17
  end
@@ -23,7 +21,7 @@ SolidQueueWeb::Engine.routes.draw do
23
21
  resource :job_selection, path: "list/selection", only: [:destroy], controller: "jobs/selections"
24
22
  resources :jobs, path: "list", only: [:index, :show, :destroy] do
25
23
  collection do
26
- post :discard_all
24
+ post :discard_all, action: :destroy
27
25
  end
28
26
  end
29
27
 
@@ -31,11 +29,11 @@ SolidQueueWeb::Engine.routes.draw do
31
29
  controller: "failed_jobs/selections"
32
30
  resources :failed_jobs, only: [:index, :destroy] do
33
31
  collection do
34
- post :retry_all
35
- post :discard_all
32
+ post :retry_all, to: "retry_failed_jobs#create"
33
+ post :discard_all, action: :destroy
36
34
  end
37
35
  member do
38
- post :retry
36
+ post :retry, to: "retry_failed_jobs#create"
39
37
  end
40
38
  end
41
39
  end
@@ -22,8 +22,10 @@ module SolidQueueWeb
22
22
  end
23
23
  end
24
24
 
25
- initializer "solid_queue_web.pagy" do
26
- Pagy::OPTIONS[:limit] = 25
25
+ initializer "solid_queue_web.pagy" do |app|
26
+ app.config.after_initialize do
27
+ Pagy::OPTIONS[:limit] = SolidQueueWeb.page_size
28
+ end
27
29
  end
28
30
  end
29
31
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueueWeb
2
- VERSION = "0.7.0"
2
+ VERSION = "0.9.0"
3
3
  end