solid_queue_web 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c49ff8a8bc89fae42038615cbc586d01e108db9549af1184d77623c51c9fb90
4
- data.tar.gz: 5dc479a2e9b27676cb8a00f5d9034f3769af6310d90d6996dd3f5b4bc6569dea
3
+ metadata.gz: 439fb2adc133452cb89ebcf1d4376e9fd45d4e19a7f1ebc4a37d4b72e89f4b54
4
+ data.tar.gz: 644a02acffccaeba6420d1374f411796e4219b4883d6cdaa17ffe25b1c2a4b01
5
5
  SHA512:
6
- metadata.gz: a6b5cc22477ff63ce321eeb3947e6200753c6960dc04e510f5e3d08aff750d87511e42668e48e154bc977dd123b9485e85e31eb9ca0c62ee315af3ada929dfd1
7
- data.tar.gz: 59107143c3c2f59e25685e638dc61d2377cd9498cf7ef942dc43f3001b998ea73a218bdc64cbb2318859fb25bc8e8ec691ce688e9aaa7e6b17d76ccd5a746a30
6
+ metadata.gz: b65ef968cbcd72f56df7039b7cfa22fbbc94db21d7e1edeabdad1a07293ba35f17b614471d1034fdcf0d2979ed8bfb3deacb2101b99667f87f1553546b6ad36c
7
+ data.tar.gz: 616474ab808602a116c997787580e0d665c6e4ba1dab9b36eab60d74b36285bd629198666fcbd4e16a0128a63875f079a43571470ff3a0320e9316b9a8c8d599
data/README.md CHANGED
@@ -2,14 +2,17 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/solid_queue_web.svg)](https://rubygems.org/gems/solid_queue_web)
4
4
 
5
- A read-only Rails engine that mounts a monitoring dashboard for [Solid Queue](https://github.com/rails/solid_queue). View queues, inspect jobs by status, and browse failed executions — all without leaving your app.
5
+ A Rails engine that mounts a monitoring dashboard for [Solid Queue](https://github.com/rails/solid_queue). View queues, inspect jobs by status, browse failed executions, and take action — all without leaving your app.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues and processes
10
10
  - **Queues** — all queues sorted by name
11
- - **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue
12
- - **Failed jobs** — list of failed executions with error details
11
+ - **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; discard individual or all jobs
12
+ - **Failed jobs** — list of failed executions with error details; retry or discard individually or in bulk
13
+ - **Job detail** — full arguments, timestamps, and error backtrace; action buttons based on job status
14
+ - **Queue management** — pause and resume individual queues
15
+ - **Processes** — workers, dispatchers, and supervisors with heartbeat health status
13
16
  - No external CSS framework — works out of the box
14
17
 
15
18
  ## Installation
@@ -76,9 +76,21 @@ body {
76
76
  .sqd-page-title {
77
77
  font-size: 20px;
78
78
  font-weight: 600;
79
+ margin-bottom: 0;
80
+ }
81
+
82
+ .sqd-page-header {
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
79
86
  margin-bottom: 1.5rem;
80
87
  }
81
88
 
89
+ .sqd-actions {
90
+ display: flex;
91
+ gap: 0.5rem;
92
+ }
93
+
82
94
  /* Flash notices */
83
95
  .sqd-flash {
84
96
  padding: 0.75rem 1rem;
@@ -127,6 +139,18 @@ body {
127
139
  .sqd-stat--queues .sqd-stat__value { color: var(--purple); }
128
140
  .sqd-stat--processes .sqd-stat__value { color: var(--muted); }
129
141
 
142
+ .sqd-stat--link {
143
+ display: block;
144
+ text-decoration: none;
145
+ color: inherit;
146
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
147
+ }
148
+ .sqd-stat--link:hover {
149
+ border-color: var(--primary);
150
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
151
+ transform: translateY(-2px);
152
+ }
153
+
130
154
  /* Tables */
131
155
  .sqd-card {
132
156
  background: var(--surface);
@@ -198,8 +222,15 @@ tbody tr:hover { background: var(--bg); }
198
222
  .sqd-badge--claimed { background: #cfe2ff; color: #084298; }
199
223
  .sqd-badge--failed { background: #f8d7da; color: #842029; }
200
224
  .sqd-badge--blocked { background: #fff3cd; color: #664d03; }
201
- .sqd-badge--paused { background: #e2e3e5; color: #41464b; }
202
- .sqd-badge--running { background: #d1e7dd; color: #0f5132; }
225
+ .sqd-badge--paused { background: #e2e3e5; color: #41464b; }
226
+ .sqd-badge--running { background: #d1e7dd; color: #0f5132; }
227
+ .sqd-badge--supervisor { background: #e0d7f5; color: #4a2c8a; }
228
+ .sqd-badge--worker { background: #d1e7dd; color: #0f5132; }
229
+ .sqd-badge--dispatcher { background: #cff4fc; color: #055160; }
230
+
231
+ .sqd-process-meta { font-size: 12px; color: var(--muted); }
232
+ .sqd-process-meta span + span::before { content: " · "; }
233
+ .sqd-muted-text { color: var(--muted); font-size: 13px; }
203
234
 
204
235
  /* Buttons */
205
236
  .sqd-btn {
@@ -218,6 +249,10 @@ tbody tr:hover { background: var(--bg); }
218
249
  .sqd-btn--primary { background: var(--primary); color: #fff; border-color: var(--primary); }
219
250
  .sqd-btn--danger { background: var(--danger); color: #fff; border-color: var(--danger); }
220
251
  .sqd-btn--muted { background: var(--surface); color: var(--text); border-color: var(--border); }
252
+ .sqd-btn--sm { padding: 0.2rem 0.55rem; font-size: 11px; }
253
+
254
+ .sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; }
255
+ .sqd-row-actions form { display: inline; margin-left: 0.25rem; }
221
256
 
222
257
  /* Filters */
223
258
  .sqd-filters {
@@ -291,4 +326,71 @@ nav.pagy a {
291
326
  nav.pagy a:hover:not([aria-disabled="true"]) { background: var(--bg); }
292
327
  nav.pagy a[aria-current="page"] { background: var(--primary); color: #fff; border-color: var(--primary); }
293
328
  nav.pagy a[role="separator"],
294
- nav.pagy a[aria-disabled="true"] { color: var(--muted); cursor: default; }
329
+ nav.pagy a[aria-disabled="true"] { color: var(--muted); cursor: default; }
330
+
331
+ /* Job detail page */
332
+ .sqd-breadcrumb {
333
+ font-size: 12px;
334
+ color: var(--muted);
335
+ margin-bottom: 0.25rem;
336
+ }
337
+
338
+ .sqd-breadcrumb a { color: var(--muted); text-decoration: none; }
339
+ .sqd-breadcrumb a:hover { color: var(--text); }
340
+
341
+ .sqd-detail-grid {
342
+ display: grid;
343
+ grid-template-columns: 1fr 1fr;
344
+ gap: 1.5rem;
345
+ }
346
+
347
+ @media (max-width: 768px) {
348
+ .sqd-detail-grid { grid-template-columns: 1fr; }
349
+ }
350
+
351
+ .sqd-detail-section { padding: 1.25rem; }
352
+
353
+ .sqd-section-title {
354
+ font-size: 13px;
355
+ font-weight: 600;
356
+ text-transform: uppercase;
357
+ letter-spacing: 0.05em;
358
+ color: var(--muted);
359
+ margin-bottom: 1rem;
360
+ }
361
+
362
+ .sqd-section-title--danger { color: var(--danger); }
363
+
364
+ .sqd-dl {
365
+ display: grid;
366
+ grid-template-columns: auto 1fr;
367
+ gap: 0.5rem 1.5rem;
368
+ font-size: 13px;
369
+ }
370
+
371
+ .sqd-dl dt { color: var(--muted); white-space: nowrap; }
372
+ .sqd-dl dd { word-break: break-all; }
373
+
374
+ .sqd-pre {
375
+ font-family: monospace;
376
+ font-size: 12px;
377
+ background: var(--bg);
378
+ border: 1px solid var(--border);
379
+ border-radius: 5px;
380
+ padding: 0.75rem;
381
+ overflow-x: auto;
382
+ white-space: pre-wrap;
383
+ word-break: break-word;
384
+ max-height: 400px;
385
+ overflow-y: auto;
386
+ }
387
+
388
+ .sqd-pre--muted { color: var(--muted); }
389
+
390
+ .sqd-error-header {
391
+ font-size: 13px;
392
+ padding: 0.5rem 0.75rem;
393
+ background: #f8d7da;
394
+ color: #842029;
395
+ border-radius: 5px;
396
+ }
@@ -1,5 +1,7 @@
1
1
  module SolidQueueWeb
2
2
  class ApplicationController < ActionController::Base
3
+ include Pagy::Method
4
+
3
5
  before_action :authenticate!
4
6
 
5
7
  private
@@ -1,10 +1,38 @@
1
1
  module SolidQueueWeb
2
2
  class FailedJobsController < ApplicationController
3
3
  def index
4
- @failed_jobs = SolidQueue::FailedExecution
5
- .includes(:job)
6
- .order(created_at: :desc)
7
- .limit(100)
4
+ @pagy, @failed_jobs = pagy(
5
+ SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
6
+ )
7
+ end
8
+
9
+ def retry
10
+ execution = SolidQueue::FailedExecution.find(params[:id])
11
+ execution.retry
12
+ redirect_to failed_jobs_path, notice: "Job queued for retry."
13
+ rescue => e
14
+ redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}"
15
+ end
16
+
17
+ def destroy
18
+ execution = SolidQueue::FailedExecution.find(params[:id])
19
+ execution.discard
20
+ redirect_to failed_jobs_path, notice: "Job discarded."
21
+ rescue => e
22
+ redirect_to failed_jobs_path, alert: "Could not discard job: #{e.message}"
23
+ end
24
+
25
+ def retry_all
26
+ executions = SolidQueue::FailedExecution.includes(:job).to_a
27
+ jobs = executions.map(&:job)
28
+ SolidQueue::FailedExecution.retry_all(jobs)
29
+ redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
30
+ end
31
+
32
+ def discard_all
33
+ count = SolidQueue::FailedExecution.count
34
+ SolidQueue::FailedExecution.discard_all_in_batches
35
+ redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} discarded."
8
36
  end
9
37
  end
10
38
  end
@@ -1,21 +1,85 @@
1
1
  module SolidQueueWeb
2
2
  class JobsController < ApplicationController
3
3
  STATUSES = %w[ready scheduled claimed blocked failed].freeze
4
+ DISCARDABLE = %w[ready scheduled blocked].freeze
5
+
6
+ before_action :set_status_and_queue, only: [ :destroy, :discard_all ]
7
+
8
+ EXECUTION_MODELS = {
9
+ "ready" => SolidQueue::ReadyExecution,
10
+ "scheduled" => SolidQueue::ScheduledExecution,
11
+ "claimed" => SolidQueue::ClaimedExecution,
12
+ "blocked" => SolidQueue::BlockedExecution,
13
+ "failed" => SolidQueue::FailedExecution
14
+ }.freeze
4
15
 
5
16
  def index
6
17
  @status = params[:status].presence_in(STATUSES) || "ready"
7
18
  @queue = params[:queue].presence
19
+ @jobs = EXECUTION_MODELS[@status].includes(:job)
20
+ @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
21
+ @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
22
+ end
23
+
24
+ def show
25
+ @job = SolidQueue::Job
26
+ .includes(:ready_execution, :scheduled_execution, :claimed_execution, :blocked_execution, :failed_execution)
27
+ .find(params[:id])
28
+ @failed_execution = @job.failed_execution
29
+ @execution_status = derive_status(@job)
30
+ end
8
31
 
9
- @jobs = case @status
10
- when "ready" then SolidQueue::ReadyExecution.includes(:job)
11
- when "scheduled" then SolidQueue::ScheduledExecution.includes(:job)
12
- when "claimed" then SolidQueue::ClaimedExecution.includes(:job)
13
- when "blocked" then SolidQueue::BlockedExecution.includes(:job)
14
- when "failed" then SolidQueue::FailedExecution.includes(:job)
32
+ def destroy
33
+ model = execution_model_for!(@status)
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, queue: @queue), notice: "Job discarded." }
15
40
  end
41
+ rescue ArgumentError => e
42
+ redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
43
+ rescue => e
44
+ redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard job: #{e.message}"
45
+ end
46
+
47
+ def discard_all
48
+ model = execution_model_for!(@status)
49
+ jobs = filtered_scope(model).map(&:job)
50
+ model.discard_all_from_jobs(jobs)
51
+ redirect_to jobs_path(status: @status, queue: @queue),
52
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
53
+ rescue ArgumentError => e
54
+ redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
55
+ rescue => e
56
+ redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard jobs: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def derive_status(job)
62
+ return "failed" if job.failed_execution.present?
63
+ return "claimed" if job.claimed_execution.present?
64
+ return "blocked" if job.blocked_execution.present?
65
+ return "ready" if job.ready_execution.present?
66
+ return "scheduled" if job.scheduled_execution.present?
67
+ "finished"
68
+ end
69
+
70
+ def set_status_and_queue
71
+ @status = params[:status]
72
+ @queue = params[:queue].presence
73
+ end
74
+
75
+ def filtered_scope(model)
76
+ scope = model.includes(:job)
77
+ @queue.present? ? scope.where(jobs: { queue_name: @queue }) : scope
78
+ end
16
79
 
17
- @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
18
- @jobs = @jobs.order(created_at: :desc).limit(100)
80
+ def execution_model_for!(status)
81
+ raise ArgumentError, "Cannot discard #{status} jobs from this page." unless DISCARDABLE.include?(status)
82
+ EXECUTION_MODELS[status]
19
83
  end
20
84
  end
21
85
  end
@@ -0,0 +1,7 @@
1
+ module SolidQueueWeb
2
+ class ProcessesController < ApplicationController
3
+ def index
4
+ @processes = SolidQueue::Process.order(:kind, :name).to_a
5
+ end
6
+ end
7
+ end
@@ -3,5 +3,21 @@ module SolidQueueWeb
3
3
  def index
4
4
  @queues = SolidQueue::Queue.all.sort_by(&:name)
5
5
  end
6
+
7
+ def pause
8
+ queue = SolidQueue::Queue.find_by_name(params[:name])
9
+ queue.pause
10
+ redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
11
+ rescue => e
12
+ redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
13
+ end
14
+
15
+ def resume
16
+ queue = SolidQueue::Queue.find_by_name(params[:name])
17
+ queue.resume
18
+ redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
19
+ rescue => e
20
+ redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
21
+ end
6
22
  end
7
23
  end
@@ -18,6 +18,7 @@
18
18
  <li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "" %></li>
19
19
  <li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "" %></li>
20
20
  <li><%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "" %></li>
21
+ <li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "" %></li>
21
22
  </ul>
22
23
  </nav>
23
24
  </header>
@@ -1,34 +1,34 @@
1
1
  <h1 class="sqd-page-title">Dashboard</h1>
2
2
 
3
3
  <div class="sqd-stats">
4
- <div class="sqd-stat sqd-stat--ready">
4
+ <%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %>
5
5
  <div class="sqd-stat__value"><%= @stats[:ready] %></div>
6
6
  <div class="sqd-stat__label">Ready</div>
7
- </div>
8
- <div class="sqd-stat sqd-stat--scheduled">
7
+ <% end %>
8
+ <%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %>
9
9
  <div class="sqd-stat__value"><%= @stats[:scheduled] %></div>
10
10
  <div class="sqd-stat__label">Scheduled</div>
11
- </div>
12
- <div class="sqd-stat sqd-stat--claimed">
11
+ <% end %>
12
+ <%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %>
13
13
  <div class="sqd-stat__value"><%= @stats[:claimed] %></div>
14
14
  <div class="sqd-stat__label">Running</div>
15
- </div>
16
- <div class="sqd-stat sqd-stat--blocked">
15
+ <% end %>
16
+ <%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %>
17
17
  <div class="sqd-stat__value"><%= @stats[:blocked] %></div>
18
18
  <div class="sqd-stat__label">Blocked</div>
19
- </div>
20
- <div class="sqd-stat sqd-stat--failed">
19
+ <% end %>
20
+ <%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %>
21
21
  <div class="sqd-stat__value"><%= @stats[:failed] %></div>
22
22
  <div class="sqd-stat__label">Failed</div>
23
- </div>
24
- <div class="sqd-stat sqd-stat--queues">
23
+ <% end %>
24
+ <%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %>
25
25
  <div class="sqd-stat__value"><%= @stats[:queues] %></div>
26
26
  <div class="sqd-stat__label">Queues</div>
27
- </div>
28
- <div class="sqd-stat sqd-stat--processes">
27
+ <% end %>
28
+ <%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
29
29
  <div class="sqd-stat__value"><%= @stats[:processes] %></div>
30
30
  <div class="sqd-stat__label">Processes</div>
31
- </div>
31
+ <% end %>
32
32
  </div>
33
33
 
34
34
  <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
@@ -1,4 +1,18 @@
1
- <h1 class="sqd-page-title">Failed Jobs</h1>
1
+ <div class="sqd-page-header">
2
+ <h1 class="sqd-page-title">Failed Jobs</h1>
3
+ <% if @failed_jobs.any? %>
4
+ <div class="sqd-actions">
5
+ <%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--primary",
6
+ data: { confirm: "Retry all #{@failed_jobs.size} failed jobs?" } %>
7
+ <%= button_to "Discard All", discard_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--danger",
8
+ data: { confirm: "Discard all #{@failed_jobs.size} failed jobs? This cannot be undone." } %>
9
+ </div>
10
+ <% end %>
11
+ </div>
12
+
13
+ <% if @pagy.last > 1 %>
14
+ <%= @pagy.series_nav.html_safe %>
15
+ <% end %>
2
16
 
3
17
  <div class="sqd-card">
4
18
  <% if @failed_jobs.empty? %>
@@ -11,13 +25,14 @@
11
25
  <th>Queue</th>
12
26
  <th>Error</th>
13
27
  <th>Failed At</th>
28
+ <th></th>
14
29
  </tr>
15
30
  </thead>
16
31
  <tbody>
17
32
  <% @failed_jobs.each do |execution| %>
18
33
  <% job = execution.job %>
19
34
  <tr>
20
- <td><%= job.class_name %></td>
35
+ <td><%= link_to job.class_name, job_path(job) %></td>
21
36
  <td class="sqd-mono"><%= job.queue_name %></td>
22
37
  <td>
23
38
  <% if execution.exception_class.present? %>
@@ -25,10 +40,17 @@
25
40
  <strong><%= execution.exception_class %></strong>: <%= execution.message %>
26
41
  </div>
27
42
  <% else %>
28
- <span class="sqd-muted">—</span>
43
+ <span style="color:var(--muted)">—</span>
29
44
  <% end %>
30
45
  </td>
31
46
  <td class="sqd-mono"><%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
47
+ <td class="sqd-row-actions">
48
+ <%= button_to "Retry", retry_failed_job_path(execution), method: :post,
49
+ class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
50
+ <%= button_to "Discard", failed_job_path(execution), method: :delete,
51
+ class: "sqd-btn sqd-btn--danger sqd-btn--sm",
52
+ data: { confirm: "Discard this job?" } %>
53
+ </td>
32
54
  </tr>
33
55
  <% end %>
34
56
  </tbody>
@@ -0,0 +1,9 @@
1
+ <% if @remaining_count == 0 %>
2
+ <%= turbo_stream.replace "jobs-list" do %>
3
+ <div class="sqd-card" id="jobs-list">
4
+ <div class="sqd-empty">No <%= @status %> jobs.</div>
5
+ </div>
6
+ <% end %>
7
+ <% else %>
8
+ <%= turbo_stream.remove "execution_#{@execution.id}" %>
9
+ <% end %>
@@ -1,14 +1,28 @@
1
- <h1 class="sqd-page-title">Jobs</h1>
1
+ <h1 class="sqd-page-title" style="margin-bottom: 1.5rem;">Jobs</h1>
2
2
 
3
- <div class="sqd-filters">
4
- <%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %>
5
- <%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue), class: @status == "scheduled" ? "active" : "" %>
6
- <%= link_to "Running", jobs_path(status: "claimed", queue: @queue), class: @status == "claimed" ? "active" : "" %>
7
- <%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue), class: @status == "blocked" ? "active" : "" %>
8
- <%= link_to "Failed", jobs_path(status: "failed", queue: @queue), class: @status == "failed" ? "active" : "" %>
3
+ <%= turbo_frame_tag "jobs-table" do %>
4
+ <% discardable = SolidQueueWeb::JobsController::DISCARDABLE.include?(@status) %>
5
+
6
+ <div class="sqd-page-header">
7
+ <div class="sqd-filters">
8
+ <%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %>
9
+ <%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue), class: @status == "scheduled" ? "active" : "" %>
10
+ <%= link_to "Running", jobs_path(status: "claimed", queue: @queue), class: @status == "claimed" ? "active" : "" %>
11
+ <%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue), class: @status == "blocked" ? "active" : "" %>
12
+ <%= link_to "Failed", jobs_path(status: "failed", queue: @queue), class: @status == "failed" ? "active" : "" %>
13
+ </div>
14
+ <% if discardable && @jobs.any? %>
15
+ <div class="sqd-actions">
16
+ <%= button_to "Discard All", discard_all_jobs_path,
17
+ method: :post,
18
+ params: { status: @status, queue: @queue },
19
+ class: "sqd-btn sqd-btn--danger",
20
+ data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
21
+ </div>
22
+ <% end %>
9
23
  </div>
10
24
 
11
- <div class="sqd-card">
25
+ <div class="sqd-card" id="jobs-list">
12
26
  <% if @jobs.empty? %>
13
27
  <div class="sqd-empty">No <%= @status %> jobs.</div>
14
28
  <% else %>
@@ -20,15 +34,16 @@
20
34
  <th>Priority</th>
21
35
  <th>Scheduled At</th>
22
36
  <th>Enqueued At</th>
37
+ <% if discardable %><th></th><% end %>
23
38
  </tr>
24
39
  </thead>
25
40
  <tbody>
26
41
  <% @jobs.each do |execution| %>
27
42
  <% job = execution.job %>
28
- <tr>
43
+ <tr id="execution_<%= execution.id %>">
29
44
  <td>
30
45
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
31
- <span style="margin-left: 0.5rem;"><%= job.class_name %></span>
46
+ <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;" %>
32
47
  </td>
33
48
  <td>
34
49
  <%= link_to job.queue_name, jobs_path(status: @status, queue: job.queue_name),
@@ -39,6 +54,15 @@
39
54
  <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
40
55
  </td>
41
56
  <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
57
+ <% if discardable %>
58
+ <td class="sqd-row-actions">
59
+ <%= button_to "Discard", job_path(execution),
60
+ method: :delete,
61
+ params: { status: @status, queue: @queue },
62
+ class: "sqd-btn sqd-btn--danger sqd-btn--sm",
63
+ data: { confirm: "Discard this job?" } %>
64
+ </td>
65
+ <% end %>
42
66
  </tr>
43
67
  <% end %>
44
68
  </tbody>
@@ -46,9 +70,14 @@
46
70
  <% end %>
47
71
  </div>
48
72
 
73
+ <% if @pagy.last > 1 %>
74
+ <%= @pagy.series_nav.html_safe %>
75
+ <% end %>
76
+
49
77
  <% if @queue.present? %>
50
78
  <p style="margin-top: 0.75rem; font-size: 13px; color: var(--muted);">
51
79
  Filtering by queue: <strong><%= @queue %></strong> &mdash;
52
80
  <%= link_to "Clear filter", jobs_path(status: @status) %>
53
81
  </p>
54
82
  <% end %>
83
+ <% end %>
@@ -0,0 +1,75 @@
1
+ <div class="sqd-page-header">
2
+ <div>
3
+ <div class="sqd-breadcrumb">
4
+ <%= link_to "Jobs", jobs_path(status: @execution_status) %> &rsaquo; Detail
5
+ </div>
6
+ <h1 class="sqd-page-title"><%= @job.class_name %></h1>
7
+ </div>
8
+
9
+ <div class="sqd-actions">
10
+ <% if @execution_status == "failed" && @failed_execution %>
11
+ <%= button_to "Retry", retry_failed_job_path(@failed_execution), method: :post,
12
+ class: "sqd-btn sqd-btn--primary" %>
13
+ <%= button_to "Discard", failed_job_path(@failed_execution), method: :delete,
14
+ class: "sqd-btn sqd-btn--danger",
15
+ data: { confirm: "Discard this job?" } %>
16
+ <% elsif SolidQueueWeb::JobsController::DISCARDABLE.include?(@execution_status) %>
17
+ <% execution = @job.public_send("#{@execution_status}_execution") %>
18
+ <% if execution %>
19
+ <%= button_to "Discard", job_path(execution),
20
+ method: :delete,
21
+ params: { status: @execution_status },
22
+ class: "sqd-btn sqd-btn--danger",
23
+ data: { confirm: "Discard this job?" } %>
24
+ <% end %>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="sqd-detail-grid">
30
+ <div class="sqd-card sqd-detail-section">
31
+ <h2 class="sqd-section-title">Details</h2>
32
+ <dl class="sqd-dl">
33
+ <dt>Status</dt>
34
+ <dd><span class="sqd-badge sqd-badge--<%= @execution_status %>"><%= @execution_status %></span></dd>
35
+
36
+ <dt>Queue</dt>
37
+ <dd class="sqd-mono"><%= @job.queue_name %></dd>
38
+
39
+ <dt>Priority</dt>
40
+ <dd><%= @job.priority %></dd>
41
+
42
+ <dt>Active Job ID</dt>
43
+ <dd class="sqd-mono sqd-truncate" title="<%= @job.active_job_id %>"><%= @job.active_job_id %></dd>
44
+
45
+ <dt>Concurrency Key</dt>
46
+ <dd class="sqd-mono"><%= @job.concurrency_key.presence || "—" %></dd>
47
+
48
+ <dt>Enqueued At</dt>
49
+ <dd class="sqd-mono"><%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></dd>
50
+
51
+ <dt>Scheduled At</dt>
52
+ <dd class="sqd-mono"><%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %></dd>
53
+
54
+ <dt>Finished At</dt>
55
+ <dd class="sqd-mono"><%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %></dd>
56
+ </dl>
57
+ </div>
58
+
59
+ <div class="sqd-card sqd-detail-section">
60
+ <h2 class="sqd-section-title">Arguments</h2>
61
+ <pre class="sqd-pre"><%= JSON.pretty_generate(@job.arguments) rescue @job.arguments.inspect %></pre>
62
+ </div>
63
+ </div>
64
+
65
+ <% if @failed_execution %>
66
+ <div class="sqd-card sqd-detail-section" style="margin-top: 1.5rem;">
67
+ <h2 class="sqd-section-title sqd-section-title--danger">Error</h2>
68
+ <p class="sqd-error-header">
69
+ <strong><%= @failed_execution.exception_class %></strong>: <%= @failed_execution.message %>
70
+ </p>
71
+ <% if @failed_execution.backtrace.present? %>
72
+ <pre class="sqd-pre sqd-pre--muted" style="margin-top: 0.75rem;"><%= Array(@failed_execution.backtrace).join("\n") %></pre>
73
+ <% end %>
74
+ </div>
75
+ <% end %>
@@ -0,0 +1,54 @@
1
+ <h1 class="sqd-page-title">Processes</h1>
2
+
3
+ <div class="sqd-card">
4
+ <% if @processes.empty? %>
5
+ <div class="sqd-empty">No processes registered.</div>
6
+ <% else %>
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th>Kind</th>
11
+ <th>Name</th>
12
+ <th>PID</th>
13
+ <th>Host</th>
14
+ <th>Details</th>
15
+ <th>Last Heartbeat</th>
16
+ <th>Status</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @processes.each do |process| %>
21
+ <% stale = process.last_heartbeat_at < SolidQueue.process_alive_threshold.ago %>
22
+ <tr>
23
+ <td><span class="sqd-badge sqd-badge--<%= process.kind.downcase %>"><%= process.kind %></span></td>
24
+ <td class="sqd-mono"><%= process.name %></td>
25
+ <td class="sqd-mono"><%= process.pid %></td>
26
+ <td class="sqd-mono"><%= process.hostname %></td>
27
+ <td class="sqd-process-meta">
28
+ <% meta = process.metadata || {} %>
29
+ <% if meta["queues"].present? %>
30
+ <span title="Queues"><%= Array(meta["queues"]).join(", ") %></span>
31
+ <% end %>
32
+ <% if meta["thread_count"].present? %>
33
+ <span class="sqd-muted-text"><%= meta["thread_count"] %> threads</span>
34
+ <% end %>
35
+ <% if meta["polling_interval"].present? %>
36
+ <span class="sqd-muted-text">every <%= meta["polling_interval"] %>s</span>
37
+ <% end %>
38
+ </td>
39
+ <td class="sqd-mono sqd-muted-text" title="<%= process.last_heartbeat_at %>">
40
+ <%= time_ago_in_words(process.last_heartbeat_at) %> ago
41
+ </td>
42
+ <td>
43
+ <% if stale %>
44
+ <span class="sqd-badge sqd-badge--failed">Stale</span>
45
+ <% else %>
46
+ <span class="sqd-badge sqd-badge--running">Healthy</span>
47
+ <% end %>
48
+ </td>
49
+ </tr>
50
+ <% end %>
51
+ </tbody>
52
+ </table>
53
+ <% end %>
54
+ </div>
@@ -11,6 +11,7 @@
11
11
  <th>Size</th>
12
12
  <th>Latency</th>
13
13
  <th>Status</th>
14
+ <th></th>
14
15
  </tr>
15
16
  </thead>
16
17
  <tbody>
@@ -26,6 +27,16 @@
26
27
  <span class="sqd-badge sqd-badge--running">Running</span>
27
28
  <% end %>
28
29
  </td>
30
+ <td class="sqd-row-actions">
31
+ <% if queue.paused? %>
32
+ <%= button_to "Resume", resume_queue_path(queue.name), method: :post,
33
+ class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
34
+ <% else %>
35
+ <%= button_to "Pause", pause_queue_path(queue.name), method: :post,
36
+ class: "sqd-btn sqd-btn--muted sqd-btn--sm",
37
+ data: { confirm: "Pause queue \"#{queue.name}\"?" } %>
38
+ <% end %>
39
+ </td>
29
40
  </tr>
30
41
  <% end %>
31
42
  </tbody>
data/config/routes.rb CHANGED
@@ -1,7 +1,25 @@
1
1
  SolidQueueWeb::Engine.routes.draw do
2
2
  root to: "dashboard#index"
3
3
 
4
- resources :queues, only: [ :index ]
5
- resources :jobs, only: [ :index ]
6
- resources :failed_jobs, only: [ :index ]
4
+ resources :processes, only: [ :index ]
5
+ resources :queues, only: [ :index ], param: :name do
6
+ member do
7
+ post :pause
8
+ post :resume
9
+ end
10
+ end
11
+ resources :jobs, path: "list", only: [ :index, :show, :destroy ] do
12
+ collection do
13
+ post :discard_all
14
+ end
15
+ end
16
+ resources :failed_jobs, only: [ :index, :destroy ] do
17
+ collection do
18
+ post :retry_all
19
+ post :discard_all
20
+ end
21
+ member do
22
+ post :retry
23
+ end
24
+ end
7
25
  end
@@ -1,7 +1,16 @@
1
1
  require "solid_queue"
2
+ require "pagy"
3
+ require "pagy/toolbox/paginators/method"
4
+ require "turbo-rails"
2
5
 
3
6
  module SolidQueueWeb
4
7
  class Engine < ::Rails::Engine
5
8
  isolate_namespace SolidQueueWeb
9
+
10
+ config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")
11
+
12
+ initializer "solid_queue_web.pagy" do
13
+ Pagy::OPTIONS[:limit] = 25
14
+ end
6
15
  end
7
16
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueueWeb
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -37,8 +37,37 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.0'
40
- description: Mount SolidQueueWeb in any Rails app using Solid Queue to get a real-time
41
- read-only view of your queues, jobs by status, and failed executions.
40
+ - !ruby/object:Gem::Dependency
41
+ name: pagy
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '43.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '43.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: turbo-rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ description: Mount SolidQueueWeb in any Rails app using Solid Queue to get a dashboard
69
+ for your queues, jobs by status, failed executions, and job actions (retry, discard)
70
+ — all without leaving your app.
42
71
  email:
43
72
  - eclectic-coding@users.noreply.github.com
44
73
  executables: []
@@ -53,6 +82,7 @@ files:
53
82
  - app/controllers/solid_queue_web/dashboard_controller.rb
54
83
  - app/controllers/solid_queue_web/failed_jobs_controller.rb
55
84
  - app/controllers/solid_queue_web/jobs_controller.rb
85
+ - app/controllers/solid_queue_web/processes_controller.rb
56
86
  - app/controllers/solid_queue_web/queues_controller.rb
57
87
  - app/helpers/solid_queue_web/application_helper.rb
58
88
  - app/jobs/solid_queue_web/application_job.rb
@@ -60,7 +90,10 @@ files:
60
90
  - app/views/layouts/solid_queue_web/application.html.erb
61
91
  - app/views/solid_queue_web/dashboard/index.html.erb
62
92
  - app/views/solid_queue_web/failed_jobs/index.html.erb
93
+ - app/views/solid_queue_web/jobs/destroy.turbo_stream.erb
63
94
  - app/views/solid_queue_web/jobs/index.html.erb
95
+ - app/views/solid_queue_web/jobs/show.html.erb
96
+ - app/views/solid_queue_web/processes/index.html.erb
64
97
  - app/views/solid_queue_web/queues/index.html.erb
65
98
  - config/routes.rb
66
99
  - lib/solid_queue_web.rb
@@ -90,5 +123,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
123
  requirements: []
91
124
  rubygems_version: 4.0.10
92
125
  specification_version: 4
93
- summary: A read-only Rails engine dashboard for monitoring Solid Queue jobs.
126
+ summary: A Rails engine dashboard for monitoring and managing Solid Queue jobs.
94
127
  test_files: []