solid_queue_web 0.4.0 → 0.5.5

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: 439fb2adc133452cb89ebcf1d4376e9fd45d4e19a7f1ebc4a37d4b72e89f4b54
4
- data.tar.gz: 644a02acffccaeba6420d1374f411796e4219b4883d6cdaa17ffe25b1c2a4b01
3
+ metadata.gz: 4fede0428a11872f9c6d461a4c58b658c59a5dbb4549de6fe0f13d1f1bafd6ae
4
+ data.tar.gz: 3fd89b402ec8034cdcec4e80c260e83dedcf4c96519375283fe0678696548d88
5
5
  SHA512:
6
- metadata.gz: b65ef968cbcd72f56df7039b7cfa22fbbc94db21d7e1edeabdad1a07293ba35f17b614471d1034fdcf0d2979ed8bfb3deacb2101b99667f87f1553546b6ad36c
7
- data.tar.gz: 616474ab808602a116c997787580e0d665c6e4ba1dab9b36eab60d74b36285bd629198666fcbd4e16a0128a63875f079a43571470ff3a0320e9316b9a8c8d599
6
+ metadata.gz: 361aaa9eca22abd7a34f574c434f04700dcce5d0c0f29dd0bf4f929bccedf08b32db280db3c8e29c6b237325b791dc09528cced2f9d8419b8f02879e32a0aa36
7
+ data.tar.gz: f2d79da345dec941c24a468729f78de4b210d9f4d8bb6b8203c6e60da16577e7b5538c2b66b8a544524bbc32cf7289fc26a77e3529e27444f825b889afde582a
data/README.md CHANGED
@@ -1,19 +1,60 @@
1
1
  # SolidQueueWeb
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/solid_queue_web.svg)](https://rubygems.org/gems/solid_queue_web)
3
+ [![CI](https://github.com/eclectic-coding/solid_queue_web/actions/workflows/ci.yml/badge.svg)](https://github.com/eclectic-coding/solid_queue_web/actions/workflows/ci.yml)
4
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D)](https://rubygems.org/gems/solid_queue_web)
5
+ [![Gem Version](https://img.shields.io/gem/v/solid_queue_web)](https://rubygems.org/gems/solid_queue_web)
6
+ [![Downloads](https://img.shields.io/gem/dt/solid_queue_web)](https://rubygems.org/gems/solid_queue_web)
7
+ [![Coverage](https://codecov.io/gh/eclectic-coding/solid_queue_web/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/solid_queue_web)
4
8
 
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.
9
+ A monitoring and management dashboard for [Solid Queue](https://github.com/rails/solid_queue), mountable as a Rails engine in any app.
10
+
11
+ ## The problem
12
+
13
+ Solid Queue ships without a web interface. When jobs fail, queues back up, or workers go silent in production, the only options are `rails console` or raw SQL queries. SolidQueueWeb gives your team a real-time dashboard to inspect, retry, and discard jobs without leaving the browser — and without standing up any additional infrastructure.
14
+
15
+ ## Why SolidQueueWeb?
16
+
17
+ - Purpose-built for Solid Queue — uses its native models directly, no adapters
18
+ - No external CSS framework — drops into any Rails app without asset conflicts
19
+ - Zero-config to start — one line in `routes.rb` and you're running
20
+ - Built for Rails 8 — Turbo Frames for in-place updates, Stimulus for dynamic search, Pagy for efficient pagination
21
+ - Inspired by Sidekiq Web UI and the GoodJob dashboard, adapted for the Solid Queue ecosystem
22
+
23
+ ## Real-world use case
24
+
25
+ A Rails app processes order confirmations, email notifications, and report generation through Solid Queue. An operations team needs to:
26
+
27
+ - Monitor queue depth before a high-traffic event
28
+ - Retry a batch of failed notification jobs after a third-party API outage
29
+ - Pause a queue while a fix is being deployed
30
+ - Identify blocked or long-running jobs before they impact users
31
+
32
+ SolidQueueWeb surfaces all of this in a browser UI available at any route you choose.
6
33
 
7
34
  ## Features
8
35
 
9
- - **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues and processes
36
+ - **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes
10
37
  - **Queues** — all queues sorted by name
11
- - **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; discard individual or all jobs
38
+ - **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; search by job class name with dynamic auto-submit; discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search
12
39
  - **Failed jobs** — list of failed executions with error details; retry or discard individually or in bulk
13
40
  - **Job detail** — full arguments, timestamps, and error backtrace; action buttons based on job status
14
41
  - **Queue management** — pause and resume individual queues
42
+ - **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification
15
43
  - **Processes** — workers, dispatchers, and supervisors with heartbeat health status
16
- - No external CSS framework — works out of the box
44
+
45
+ ## Screenshots
46
+
47
+ ![SolidQueueWeb dashboard](docs/solid-queue-web.png)
48
+
49
+ ## Compatibility
50
+
51
+ | Dependency | Version |
52
+ |-------------|------------|
53
+ | Ruby | >= 3.3 |
54
+ | Rails | >= 8.1.3 |
55
+ | Solid Queue | >= 1.0 |
56
+
57
+ Tested on Ruby 3.3, 3.4, and 4.0.
17
58
 
18
59
  ## Installation
19
60
 
@@ -37,7 +78,7 @@ Add to your `config/routes.rb`:
37
78
  mount SolidQueueWeb::Engine, at: "/jobs"
38
79
  ```
39
80
 
40
- The dashboard will be available at `/jobs`.
81
+ The dashboard will be available at `/jobs`. See [Authentication](#authentication) to restrict access to admin users.
41
82
 
42
83
  ## Authentication
43
84
 
@@ -53,12 +94,6 @@ end
53
94
 
54
95
  HTTP Basic authentication is used as a fallback when the block returns falsy.
55
96
 
56
- ## Requirements
57
-
58
- - Ruby >= 3.3
59
- - Rails >= 8.1.3
60
- - solid_queue >= 1.0
61
-
62
97
  ## Contributing
63
98
 
64
99
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/solid_queue_web).
@@ -4,6 +4,24 @@
4
4
 
5
5
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
6
6
 
7
+ .sqd-sr-only {
8
+ position: absolute;
9
+ width: 1px;
10
+ height: 1px;
11
+ padding: 0;
12
+ margin: -1px;
13
+ overflow: hidden;
14
+ clip: rect(0, 0, 0, 0);
15
+ white-space: nowrap;
16
+ border: 0;
17
+ }
18
+
19
+ :focus-visible {
20
+ outline: 2px solid var(--primary);
21
+ outline-offset: 2px;
22
+ border-radius: 2px;
23
+ }
24
+
7
25
  :root {
8
26
  --bg: #f8f9fa;
9
27
  --surface: #ffffff;
@@ -30,6 +48,11 @@ body {
30
48
  .sqd-header {
31
49
  background: var(--surface);
32
50
  border-bottom: 1px solid var(--border);
51
+ }
52
+
53
+ .sqd-header__inner {
54
+ max-width: 1200px;
55
+ margin: 0 auto;
33
56
  padding: 0 1.5rem;
34
57
  display: flex;
35
58
  align-items: center;
@@ -67,6 +90,28 @@ body {
67
90
  color: var(--text);
68
91
  }
69
92
 
93
+ .sqd-nav-toggle {
94
+ display: none;
95
+ flex-direction: column;
96
+ justify-content: center;
97
+ gap: 5px;
98
+ width: 36px;
99
+ height: 36px;
100
+ padding: 6px;
101
+ margin-left: auto;
102
+ background: none;
103
+ border: 1px solid var(--border);
104
+ border-radius: 5px;
105
+ cursor: pointer;
106
+ }
107
+
108
+ .sqd-nav-toggle span {
109
+ display: block;
110
+ height: 2px;
111
+ background: var(--text);
112
+ border-radius: 1px;
113
+ }
114
+
70
115
  .sqd-main {
71
116
  max-width: 1200px;
72
117
  margin: 0 auto;
@@ -104,7 +149,7 @@ body {
104
149
  /* Stat cards */
105
150
  .sqd-stats {
106
151
  display: grid;
107
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
152
+ grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
108
153
  gap: 1rem;
109
154
  margin-bottom: 2rem;
110
155
  }
@@ -138,6 +183,7 @@ body {
138
183
  .sqd-stat--blocked .sqd-stat__value { color: var(--warning); }
139
184
  .sqd-stat--queues .sqd-stat__value { color: var(--purple); }
140
185
  .sqd-stat--processes .sqd-stat__value { color: var(--muted); }
186
+ .sqd-stat--recurring .sqd-stat__value { color: var(--info); }
141
187
 
142
188
  .sqd-stat--link {
143
189
  display: block;
@@ -222,6 +268,8 @@ tbody tr:hover { background: var(--bg); }
222
268
  .sqd-badge--claimed { background: #cfe2ff; color: #084298; }
223
269
  .sqd-badge--failed { background: #f8d7da; color: #842029; }
224
270
  .sqd-badge--blocked { background: #fff3cd; color: #664d03; }
271
+ .sqd-badge--static { background: #d1e7dd; color: #0f5132; }
272
+ .sqd-badge--dynamic { background: #e0d7f5; color: #4a2c8a; }
225
273
  .sqd-badge--paused { background: #e2e3e5; color: #41464b; }
226
274
  .sqd-badge--running { background: #d1e7dd; color: #0f5132; }
227
275
  .sqd-badge--supervisor { background: #e0d7f5; color: #4a2c8a; }
@@ -254,6 +302,36 @@ tbody tr:hover { background: var(--bg); }
254
302
  .sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; }
255
303
  .sqd-row-actions form { display: inline; margin-left: 0.25rem; }
256
304
 
305
+ /* Search */
306
+ .sqd-search {
307
+ display: flex;
308
+ gap: 0.5rem;
309
+ align-items: center;
310
+ margin-bottom: 1rem;
311
+ }
312
+
313
+ .sqd-search__input {
314
+ width: 280px;
315
+ padding: 0.35rem 0.75rem;
316
+ border: 1px solid var(--border);
317
+ border-radius: 5px;
318
+ font-size: 13px;
319
+ background: var(--surface);
320
+ color: var(--text);
321
+ line-height: 1.5;
322
+ }
323
+
324
+ .sqd-search__input:focus {
325
+ outline: 2px solid var(--primary);
326
+ outline-offset: -1px;
327
+ border-color: var(--primary);
328
+ }
329
+
330
+ @media (max-width: 640px) {
331
+ .sqd-search { flex-wrap: wrap; }
332
+ .sqd-search__input { width: 100%; }
333
+ }
334
+
257
335
  /* Filters */
258
336
  .sqd-filters {
259
337
  display: flex;
@@ -344,8 +422,84 @@ nav.pagy a[aria-disabled="true"] { color: var(--muted); cursor: default; }
344
422
  gap: 1.5rem;
345
423
  }
346
424
 
425
+ .sqd-grid-2 {
426
+ display: grid;
427
+ grid-template-columns: 1fr 1fr;
428
+ gap: 1rem;
429
+ }
430
+
347
431
  @media (max-width: 768px) {
348
432
  .sqd-detail-grid { grid-template-columns: 1fr; }
433
+ .sqd-grid-2 { grid-template-columns: 1fr; }
434
+ }
435
+
436
+ @media (max-width: 640px) {
437
+ .sqd-main {
438
+ padding: 1.5rem 1rem;
439
+ }
440
+
441
+ .sqd-page-header {
442
+ flex-direction: column;
443
+ align-items: flex-start;
444
+ gap: 0.75rem;
445
+ }
446
+
447
+ .sqd-card {
448
+ overflow-x: auto;
449
+ }
450
+
451
+ .sqd-card__header {
452
+ flex-wrap: wrap;
453
+ }
454
+
455
+ .sqd-stats {
456
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
457
+ }
458
+
459
+ .sqd-truncate {
460
+ max-width: 160px;
461
+ }
462
+ }
463
+
464
+ @media (max-width: 576px) {
465
+ .sqd-header {
466
+ position: relative;
467
+ }
468
+
469
+ .sqd-header__inner {
470
+ padding: 0 1rem;
471
+ }
472
+
473
+ .sqd-nav-toggle {
474
+ display: flex;
475
+ }
476
+
477
+ .sqd-nav-wrapper {
478
+ display: none;
479
+ position: absolute;
480
+ top: 100%;
481
+ left: 0;
482
+ right: 0;
483
+ background: var(--surface);
484
+ border-bottom: 1px solid var(--border);
485
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
486
+ z-index: 50;
487
+ padding: 0.5rem;
488
+ }
489
+
490
+ .sqd-nav-wrapper.sqd-nav--open {
491
+ display: block;
492
+ }
493
+
494
+ .sqd-nav {
495
+ flex-direction: column;
496
+ gap: 0.25rem;
497
+ }
498
+
499
+ .sqd-nav a {
500
+ padding: 0.5rem 0.75rem;
501
+ font-size: 14px;
502
+ }
349
503
  }
350
504
 
351
505
  .sqd-detail-section { padding: 1.25rem; }
@@ -8,7 +8,8 @@ module SolidQueueWeb
8
8
  failed: SolidQueue::FailedExecution.count,
9
9
  blocked: SolidQueue::BlockedExecution.count,
10
10
  queues: SolidQueue::Job.select(:queue_name).distinct.count,
11
- processes: SolidQueue::Process.count
11
+ processes: SolidQueue::Process.count,
12
+ recurring: SolidQueue::RecurringTask.count
12
13
  }
13
14
  end
14
15
  end
@@ -1,9 +1,9 @@
1
1
  module SolidQueueWeb
2
2
  class FailedJobsController < ApplicationController
3
+ before_action :set_filter_params, only: [ :index, :retry_all, :discard_all ]
4
+
3
5
  def index
4
- @pagy, @failed_jobs = pagy(
5
- SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
6
- )
6
+ @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc))
7
7
  end
8
8
 
9
9
  def retry
@@ -23,16 +23,31 @@ module SolidQueueWeb
23
23
  end
24
24
 
25
25
  def retry_all
26
- executions = SolidQueue::FailedExecution.includes(:job).to_a
27
- jobs = executions.map(&:job)
26
+ jobs = filtered_scope.map(&:job)
28
27
  SolidQueue::FailedExecution.retry_all(jobs)
29
- redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
28
+ redirect_to failed_jobs_path(queue: @queue, q: @search),
29
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
30
30
  end
31
31
 
32
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."
33
+ jobs = filtered_scope.map(&:job)
34
+ SolidQueue::FailedExecution.discard_all_from_jobs(jobs)
35
+ redirect_to failed_jobs_path(queue: @queue, q: @search),
36
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
37
+ end
38
+
39
+ private
40
+
41
+ def set_filter_params
42
+ @queue = params[:queue].presence
43
+ @search = params[:q].presence
44
+ end
45
+
46
+ def filtered_scope
47
+ scope = SolidQueue::FailedExecution.includes(:job)
48
+ scope = scope.references(:job).where(solid_queue_jobs: { queue_name: @queue }) if @queue.present?
49
+ scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
50
+ scope
36
51
  end
37
52
  end
38
53
  end
@@ -1,23 +1,12 @@
1
1
  module SolidQueueWeb
2
2
  class JobsController < ApplicationController
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
3
+ before_action :set_status, only: [ :destroy, :discard_all ]
15
4
 
16
5
  def index
17
- @status = params[:status].presence_in(STATUSES) || "ready"
18
- @queue = params[:queue].presence
19
- @jobs = EXECUTION_MODELS[@status].includes(:job)
20
- @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
6
+ @status = params[:status].presence_in(Job::STATUSES) || "ready"
7
+ @search = params[:q].presence
8
+ @jobs = Job::EXECUTION_MODELS[@status].includes(:job)
9
+ @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
21
10
  @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
22
11
  end
23
12
 
@@ -25,7 +14,6 @@ module SolidQueueWeb
25
14
  @job = SolidQueue::Job
26
15
  .includes(:ready_execution, :scheduled_execution, :claimed_execution, :blocked_execution, :failed_execution)
27
16
  .find(params[:id])
28
- @failed_execution = @job.failed_execution
29
17
  @execution_status = derive_status(@job)
30
18
  end
31
19
 
@@ -36,24 +24,24 @@ module SolidQueueWeb
36
24
  @remaining_count = filtered_scope(model).count
37
25
  respond_to do |format|
38
26
  format.turbo_stream
39
- format.html { redirect_to jobs_path(status: @status, queue: @queue), notice: "Job discarded." }
27
+ format.html { redirect_to jobs_path(status: @status), notice: "Job discarded." }
40
28
  end
41
29
  rescue ArgumentError => e
42
- redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
30
+ redirect_to jobs_path(status: @status), alert: e.message
43
31
  rescue => e
44
- redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard job: #{e.message}"
32
+ redirect_to jobs_path(status: @status), alert: "Could not discard job: #{e.message}"
45
33
  end
46
34
 
47
35
  def discard_all
48
36
  model = execution_model_for!(@status)
49
37
  jobs = filtered_scope(model).map(&:job)
50
38
  model.discard_all_from_jobs(jobs)
51
- redirect_to jobs_path(status: @status, queue: @queue),
39
+ redirect_to jobs_path(status: @status),
52
40
  notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
53
41
  rescue ArgumentError => e
54
- redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
42
+ redirect_to jobs_path(status: @status), alert: e.message
55
43
  rescue => e
56
- redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard jobs: #{e.message}"
44
+ redirect_to jobs_path(status: @status), alert: "Could not discard jobs: #{e.message}"
57
45
  end
58
46
 
59
47
  private
@@ -67,19 +55,17 @@ module SolidQueueWeb
67
55
  "finished"
68
56
  end
69
57
 
70
- def set_status_and_queue
58
+ def set_status
71
59
  @status = params[:status]
72
- @queue = params[:queue].presence
73
60
  end
74
61
 
75
62
  def filtered_scope(model)
76
- scope = model.includes(:job)
77
- @queue.present? ? scope.where(jobs: { queue_name: @queue }) : scope
63
+ model.includes(:job)
78
64
  end
79
65
 
80
66
  def execution_model_for!(status)
81
- raise ArgumentError, "Cannot discard #{status} jobs from this page." unless DISCARDABLE.include?(status)
82
- EXECUTION_MODELS[status]
67
+ raise ArgumentError, "Cannot discard #{status} jobs from this page." unless Job::DISCARDABLE.include?(status)
68
+ Job::EXECUTION_MODELS[status]
83
69
  end
84
70
  end
85
71
  end
@@ -0,0 +1,63 @@
1
+ module SolidQueueWeb
2
+ module Queues
3
+ class JobsController < ApplicationController
4
+ before_action :set_queue
5
+ before_action :set_status, only: [ :destroy, :discard_all ]
6
+
7
+ def index
8
+ @status = params[:status].presence_in(Job::STATUSES) || "ready"
9
+ @search = params[:q].presence
10
+ @jobs = Job::EXECUTION_MODELS[@status].includes(:job)
11
+ .where(solid_queue_jobs: { queue_name: @queue })
12
+ @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
13
+ @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
14
+ end
15
+
16
+ def destroy
17
+ model = execution_model_for!(@status)
18
+ @execution = model.find(params[:id])
19
+ @execution.discard
20
+ @remaining_count = filtered_scope(model).count
21
+ respond_to do |format|
22
+ format.turbo_stream
23
+ format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." }
24
+ end
25
+ rescue ArgumentError => e
26
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
27
+ rescue => e
28
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard job: #{e.message}"
29
+ end
30
+
31
+ def discard_all
32
+ model = execution_model_for!(@status)
33
+ jobs = filtered_scope(model).map(&:job)
34
+ model.discard_all_from_jobs(jobs)
35
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status),
36
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
37
+ rescue ArgumentError => e
38
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
39
+ rescue => e
40
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard jobs: #{e.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def set_queue
46
+ @queue = params[:queue_name]
47
+ end
48
+
49
+ def set_status
50
+ @status = params[:status]
51
+ end
52
+
53
+ def filtered_scope(model)
54
+ model.includes(:job).where(solid_queue_jobs: { queue_name: @queue })
55
+ end
56
+
57
+ def execution_model_for!(status)
58
+ raise ArgumentError, "Cannot discard #{status} jobs from this page." unless Job::DISCARDABLE.include?(status)
59
+ Job::EXECUTION_MODELS[status]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ module SolidQueueWeb
2
+ class RecurringTasksController < ApplicationController
3
+ def index
4
+ @recurring_tasks = SolidQueue::RecurringTask.includes(:recurring_executions).order(:key)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ import "@hotwired/turbo"
2
+ import { Application } from "@hotwired/stimulus"
3
+ import SearchController from "solid_queue_web/search_controller"
4
+
5
+ const application = Application.start()
6
+ application.register("search", SearchController)
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ filter({ target }) {
5
+ clearTimeout(this._timer)
6
+ const len = target.value.length
7
+ if (len >= 4 || len === 0) {
8
+ this._timer = setTimeout(() => target.form.requestSubmit(), 300)
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,13 @@
1
+ module SolidQueueWeb
2
+ class Job
3
+ STATUSES = %w[ready scheduled claimed blocked failed].freeze
4
+ DISCARDABLE = %w[ready scheduled blocked].freeze
5
+ EXECUTION_MODELS = {
6
+ "ready" => SolidQueue::ReadyExecution,
7
+ "scheduled" => SolidQueue::ScheduledExecution,
8
+ "claimed" => SolidQueue::ClaimedExecution,
9
+ "blocked" => SolidQueue::BlockedExecution,
10
+ "failed" => SolidQueue::FailedExecution
11
+ }.freeze
12
+ end
13
+ end
@@ -7,28 +7,40 @@
7
7
  <%= csrf_meta_tags %>
8
8
  <%= csp_meta_tag %>
9
9
  <%= inline_styles %>
10
+ <%= javascript_importmap_tags "solid_queue_web" %>
10
11
  </head>
11
12
  <body>
12
13
 
13
14
  <header class="sqd-header">
14
- <%= link_to "Solid Queue", root_path, class: "sqd-header__title" %>
15
- <nav>
16
- <ul class="sqd-nav">
17
- <li><%= link_to "Dashboard", root_path, class: current_page?(root_path) ? "active" : "" %></li>
18
- <li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "" %></li>
19
- <li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "" %></li>
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>
22
- </ul>
23
- </nav>
15
+ <div class="sqd-header__inner">
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
+ <div class="sqd-nav-wrapper">
24
+ <nav aria-label="Main">
25
+ <ul class="sqd-nav">
26
+ <li><%= link_to "Dashboard", root_path, class: current_page?(root_path) ? "active" : "", aria: { current: current_page?(root_path) ? "page" : nil } %></li>
27
+ <li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "", aria: { current: current_page?(queues_path) ? "page" : nil } %></li>
28
+ <li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "", aria: { current: current_page?(jobs_path) ? "page" : nil } %></li>
29
+ <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
+ <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
+ <li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
32
+ </ul>
33
+ </nav>
34
+ </div>
35
+ </div>
24
36
  </header>
25
37
 
26
38
  <main class="sqd-main">
27
39
  <% if notice.present? %>
28
- <div class="sqd-flash sqd-flash--notice"><%= notice %></div>
40
+ <div class="sqd-flash sqd-flash--notice" role="status"><%= notice %></div>
29
41
  <% end %>
30
42
  <% if alert.present? %>
31
- <div class="sqd-flash sqd-flash--alert"><%= alert %></div>
43
+ <div class="sqd-flash sqd-flash--alert" role="alert"><%= alert %></div>
32
44
  <% end %>
33
45
 
34
46
  <%= yield %>
@@ -25,6 +25,10 @@
25
25
  <div class="sqd-stat__value"><%= @stats[:queues] %></div>
26
26
  <div class="sqd-stat__label">Queues</div>
27
27
  <% end %>
28
+ <%= link_to recurring_tasks_path, class: "sqd-stat sqd-stat--recurring sqd-stat--link" do %>
29
+ <div class="sqd-stat__value"><%= @stats[:recurring] %></div>
30
+ <div class="sqd-stat__label">Recurring</div>
31
+ <% end %>
28
32
  <%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
29
33
  <div class="sqd-stat__value"><%= @stats[:processes] %></div>
30
34
  <div class="sqd-stat__label">Processes</div>
@@ -41,6 +45,7 @@
41
45
  <%= link_to "View scheduled jobs", jobs_path(status: "scheduled"), class: "sqd-btn sqd-btn--muted" %>
42
46
  <%= link_to "View failed jobs", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>
43
47
  <%= link_to "Manage queues", queues_path, class: "sqd-btn sqd-btn--muted" %>
48
+ <%= link_to "View recurring tasks", recurring_tasks_path, class: "sqd-btn sqd-btn--muted" %>
44
49
  </div>
45
50
  </div>
46
51
 
@@ -2,14 +2,33 @@
2
2
  <h1 class="sqd-page-title">Failed Jobs</h1>
3
3
  <% if @failed_jobs.any? %>
4
4
  <div class="sqd-actions">
5
- <%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--primary",
5
+ <%= button_to "Retry All", retry_all_failed_jobs_path,
6
+ method: :post,
7
+ params: { queue: @queue, q: @search },
8
+ class: "sqd-btn sqd-btn--primary",
6
9
  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",
10
+ <%= button_to "Discard All", discard_all_failed_jobs_path,
11
+ method: :post,
12
+ params: { queue: @queue, q: @search },
13
+ class: "sqd-btn sqd-btn--danger",
8
14
  data: { confirm: "Discard all #{@failed_jobs.size} failed jobs? This cannot be undone." } %>
9
15
  </div>
10
16
  <% end %>
11
17
  </div>
12
18
 
19
+ <form class="sqd-search" action="<%= failed_jobs_path %>" method="get" data-controller="search">
20
+ <% if @queue.present? %>
21
+ <input type="hidden" name="queue" value="<%= @queue %>">
22
+ <% end %>
23
+ <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
24
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
25
+ data-action="input->search#filter">
26
+ <button type="submit" class="sqd-btn sqd-btn--muted">Search</button>
27
+ <% if @search.present? %>
28
+ <%= link_to "Clear", failed_jobs_path(queue: @queue), class: "sqd-btn sqd-btn--muted" %>
29
+ <% end %>
30
+ </form>
31
+
13
32
  <% if @pagy.last > 1 %>
14
33
  <%= @pagy.series_nav.html_safe %>
15
34
  <% end %>
@@ -21,11 +40,11 @@
21
40
  <table>
22
41
  <thead>
23
42
  <tr>
24
- <th>Job Class</th>
25
- <th>Queue</th>
26
- <th>Error</th>
27
- <th>Failed At</th>
28
- <th></th>
43
+ <th scope="col">Job Class</th>
44
+ <th scope="col">Queue</th>
45
+ <th scope="col">Error</th>
46
+ <th scope="col">Failed At</th>
47
+ <th scope="col"><span class="sqd-sr-only">Actions</span></th>
29
48
  </tr>
30
49
  </thead>
31
50
  <tbody>
@@ -33,7 +52,10 @@
33
52
  <% job = execution.job %>
34
53
  <tr>
35
54
  <td><%= link_to job.class_name, job_path(job) %></td>
36
- <td class="sqd-mono"><%= job.queue_name %></td>
55
+ <td>
56
+ <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search),
57
+ class: "sqd-mono", style: "color: inherit;" %>
58
+ </td>
37
59
  <td>
38
60
  <% if execution.exception_class.present? %>
39
61
  <div class="sqd-error-msg sqd-truncate" title="<%= execution.exception_class %>: <%= execution.message %>">
@@ -56,4 +78,11 @@
56
78
  </tbody>
57
79
  </table>
58
80
  <% end %>
59
- </div>
81
+ </div>
82
+
83
+ <% if @queue.present? %>
84
+ <p style="margin-top: 0.75rem; font-size: 13px; color: var(--muted);">
85
+ Filtering by queue: <strong><%= @queue %></strong> &mdash;
86
+ <%= link_to "Clear filter", failed_jobs_path(q: @search) %>
87
+ </p>
88
+ <% end %>
@@ -1,27 +1,38 @@
1
1
  <h1 class="sqd-page-title" style="margin-bottom: 1.5rem;">Jobs</h1>
2
2
 
3
- <%= turbo_frame_tag "jobs-table" do %>
4
- <% discardable = SolidQueueWeb::JobsController::DISCARDABLE.include?(@status) %>
3
+ <%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance" } do %>
4
+ <% discardable = SolidQueueWeb::Job::DISCARDABLE.include?(@status) %>
5
5
 
6
6
  <div class="sqd-page-header">
7
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" : "" %>
8
+ <%= link_to "Ready", jobs_path(status: "ready", q: @search), class: @status == "ready" ? "active" : "" %>
9
+ <%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search), class: @status == "scheduled" ? "active" : "" %>
10
+ <%= link_to "Running", jobs_path(status: "claimed", q: @search), class: @status == "claimed" ? "active" : "" %>
11
+ <%= link_to "Blocked", jobs_path(status: "blocked", q: @search), class: @status == "blocked" ? "active" : "" %>
12
+ <%= link_to "Failed", jobs_path(status: "failed", q: @search), class: @status == "failed" ? "active" : "" %>
13
13
  </div>
14
14
  <% if discardable && @jobs.any? %>
15
15
  <div class="sqd-actions">
16
16
  <%= button_to "Discard All", discard_all_jobs_path,
17
17
  method: :post,
18
- params: { status: @status, queue: @queue },
18
+ params: { status: @status },
19
19
  class: "sqd-btn sqd-btn--danger",
20
20
  data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
21
21
  </div>
22
22
  <% end %>
23
23
  </div>
24
24
 
25
+ <form class="sqd-search" action="<%= jobs_path %>" method="get" data-controller="search">
26
+ <input type="hidden" name="status" value="<%= @status %>">
27
+ <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
28
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
29
+ data-action="input->search#filter">
30
+ <button type="submit" class="sqd-btn sqd-btn--muted">Search</button>
31
+ <% if @search.present? %>
32
+ <%= link_to "Clear", jobs_path(status: @status), class: "sqd-btn sqd-btn--muted" %>
33
+ <% end %>
34
+ </form>
35
+
25
36
  <div class="sqd-card" id="jobs-list">
26
37
  <% if @jobs.empty? %>
27
38
  <div class="sqd-empty">No <%= @status %> jobs.</div>
@@ -29,12 +40,12 @@
29
40
  <table>
30
41
  <thead>
31
42
  <tr>
32
- <th>Job Class</th>
33
- <th>Queue</th>
34
- <th>Priority</th>
35
- <th>Scheduled At</th>
36
- <th>Enqueued At</th>
37
- <% if discardable %><th></th><% end %>
43
+ <th scope="col">Job Class</th>
44
+ <th scope="col">Queue</th>
45
+ <th scope="col">Priority</th>
46
+ <th scope="col">Scheduled At</th>
47
+ <th scope="col">Enqueued At</th>
48
+ <% if discardable %><th scope="col"><span class="sqd-sr-only">Actions</span></th><% end %>
38
49
  </tr>
39
50
  </thead>
40
51
  <tbody>
@@ -43,10 +54,10 @@
43
54
  <tr id="execution_<%= execution.id %>">
44
55
  <td>
45
56
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
46
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;" %>
57
+ <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
47
58
  </td>
48
59
  <td>
49
- <%= link_to job.queue_name, jobs_path(status: @status, queue: job.queue_name),
60
+ <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
50
61
  class: "sqd-mono", style: "color: inherit;" %>
51
62
  </td>
52
63
  <td><%= job.priority %></td>
@@ -58,7 +69,7 @@
58
69
  <td class="sqd-row-actions">
59
70
  <%= button_to "Discard", job_path(execution),
60
71
  method: :delete,
61
- params: { status: @status, queue: @queue },
72
+ params: { status: @status },
62
73
  class: "sqd-btn sqd-btn--danger sqd-btn--sm",
63
74
  data: { confirm: "Discard this job?" } %>
64
75
  </td>
@@ -73,11 +84,4 @@
73
84
  <% if @pagy.last > 1 %>
74
85
  <%= @pagy.series_nav.html_safe %>
75
86
  <% end %>
76
-
77
- <% if @queue.present? %>
78
- <p style="margin-top: 0.75rem; font-size: 13px; color: var(--muted);">
79
- Filtering by queue: <strong><%= @queue %></strong> &mdash;
80
- <%= link_to "Clear filter", jobs_path(status: @status) %>
81
- </p>
82
- <% end %>
83
87
  <% end %>
@@ -7,13 +7,13 @@
7
7
  </div>
8
8
 
9
9
  <div class="sqd-actions">
10
- <% if @execution_status == "failed" && @failed_execution %>
11
- <%= button_to "Retry", retry_failed_job_path(@failed_execution), method: :post,
10
+ <% if @execution_status == "failed" && @job.failed_execution %>
11
+ <%= button_to "Retry", retry_failed_job_path(@job.failed_execution), method: :post,
12
12
  class: "sqd-btn sqd-btn--primary" %>
13
- <%= button_to "Discard", failed_job_path(@failed_execution), method: :delete,
13
+ <%= button_to "Discard", failed_job_path(@job.failed_execution), method: :delete,
14
14
  class: "sqd-btn sqd-btn--danger",
15
15
  data: { confirm: "Discard this job?" } %>
16
- <% elsif SolidQueueWeb::JobsController::DISCARDABLE.include?(@execution_status) %>
16
+ <% elsif SolidQueueWeb::Job::DISCARDABLE.include?(@execution_status) %>
17
17
  <% execution = @job.public_send("#{@execution_status}_execution") %>
18
18
  <% if execution %>
19
19
  <%= button_to "Discard", job_path(execution),
@@ -45,6 +45,11 @@
45
45
  <dt>Concurrency Key</dt>
46
46
  <dd class="sqd-mono"><%= @job.concurrency_key.presence || "—" %></dd>
47
47
 
48
+ <% if @job.blocked_execution %>
49
+ <dt>Blocked Until</dt>
50
+ <dd class="sqd-mono"><%= @job.blocked_execution.expires_at ? @job.blocked_execution.expires_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %></dd>
51
+ <% end %>
52
+
48
53
  <dt>Enqueued At</dt>
49
54
  <dd class="sqd-mono"><%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></dd>
50
55
 
@@ -62,14 +67,14 @@
62
67
  </div>
63
68
  </div>
64
69
 
65
- <% if @failed_execution %>
70
+ <% if @job.failed_execution %>
66
71
  <div class="sqd-card sqd-detail-section" style="margin-top: 1.5rem;">
67
72
  <h2 class="sqd-section-title sqd-section-title--danger">Error</h2>
68
73
  <p class="sqd-error-header">
69
- <strong><%= @failed_execution.exception_class %></strong>: <%= @failed_execution.message %>
74
+ <strong><%= @job.failed_execution.exception_class %></strong>: <%= @job.failed_execution.message %>
70
75
  </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>
76
+ <% if @job.failed_execution.backtrace.present? %>
77
+ <pre class="sqd-pre sqd-pre--muted" style="margin-top: 0.75rem;"><%= Array(@job.failed_execution.backtrace).join("\n") %></pre>
73
78
  <% end %>
74
79
  </div>
75
80
  <% end %>
@@ -7,13 +7,13 @@
7
7
  <table>
8
8
  <thead>
9
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>
10
+ <th scope="col">Kind</th>
11
+ <th scope="col">Name</th>
12
+ <th scope="col">PID</th>
13
+ <th scope="col">Host</th>
14
+ <th scope="col">Details</th>
15
+ <th scope="col">Last Heartbeat</th>
16
+ <th scope="col">Status</th>
17
17
  </tr>
18
18
  </thead>
19
19
  <tbody>
@@ -7,11 +7,11 @@
7
7
  <table>
8
8
  <thead>
9
9
  <tr>
10
- <th>Name</th>
11
- <th>Size</th>
12
- <th>Latency</th>
13
- <th>Status</th>
14
- <th></th>
10
+ <th scope="col">Name</th>
11
+ <th scope="col">Size</th>
12
+ <th scope="col">Latency</th>
13
+ <th scope="col">Status</th>
14
+ <th scope="col"><span class="sqd-sr-only">Actions</span></th>
15
15
  </tr>
16
16
  </thead>
17
17
  <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 in <%= @queue %>.</div>
5
+ </div>
6
+ <% end %>
7
+ <% else %>
8
+ <%= turbo_stream.remove "execution_#{@execution.id}" %>
9
+ <% end %>
@@ -0,0 +1,89 @@
1
+ <div class="sqd-page-header">
2
+ <div>
3
+ <div class="sqd-breadcrumb">
4
+ <%= link_to "Queues", queues_path %> &rsaquo; <%= @queue %>
5
+ </div>
6
+ <h1 class="sqd-page-title">Jobs</h1>
7
+ </div>
8
+ </div>
9
+
10
+ <%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance" } do %>
11
+ <% discardable = SolidQueueWeb::Job::DISCARDABLE.include?(@status) %>
12
+
13
+ <div class="sqd-page-header">
14
+ <div class="sqd-filters">
15
+ <%= link_to "Ready", queue_jobs_path(queue_name: @queue, status: "ready", q: @search), class: @status == "ready" ? "active" : "" %>
16
+ <%= link_to "Scheduled", queue_jobs_path(queue_name: @queue, status: "scheduled", q: @search), class: @status == "scheduled" ? "active" : "" %>
17
+ <%= link_to "Running", queue_jobs_path(queue_name: @queue, status: "claimed", q: @search), class: @status == "claimed" ? "active" : "" %>
18
+ <%= link_to "Blocked", queue_jobs_path(queue_name: @queue, status: "blocked", q: @search), class: @status == "blocked" ? "active" : "" %>
19
+ <%= link_to "Failed", queue_jobs_path(queue_name: @queue, status: "failed", q: @search), class: @status == "failed" ? "active" : "" %>
20
+ </div>
21
+ <% if discardable && @jobs.any? %>
22
+ <div class="sqd-actions">
23
+ <%= button_to "Discard All", discard_all_queue_jobs_path(queue_name: @queue),
24
+ method: :post,
25
+ params: { status: @status },
26
+ class: "sqd-btn sqd-btn--danger",
27
+ data: { confirm: "Discard all #{@jobs.size} #{@status} jobs in #{@queue}? This cannot be undone." } %>
28
+ </div>
29
+ <% end %>
30
+ </div>
31
+
32
+ <form class="sqd-search" action="<%= queue_jobs_path(queue_name: @queue) %>" method="get" data-controller="search">
33
+ <input type="hidden" name="status" value="<%= @status %>">
34
+ <input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
35
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
36
+ data-action="input->search#filter">
37
+ <button type="submit" class="sqd-btn sqd-btn--muted">Search</button>
38
+ <% if @search.present? %>
39
+ <%= link_to "Clear", queue_jobs_path(queue_name: @queue, status: @status), class: "sqd-btn sqd-btn--muted" %>
40
+ <% end %>
41
+ </form>
42
+
43
+ <div class="sqd-card" id="jobs-list">
44
+ <% if @jobs.empty? %>
45
+ <div class="sqd-empty">No <%= @status %> jobs in <%= @queue %>.</div>
46
+ <% else %>
47
+ <table>
48
+ <thead>
49
+ <tr>
50
+ <th scope="col">Job Class</th>
51
+ <th scope="col">Priority</th>
52
+ <th scope="col">Scheduled At</th>
53
+ <th scope="col">Enqueued At</th>
54
+ <% if discardable %><th scope="col"><span class="sqd-sr-only">Actions</span></th><% end %>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ <% @jobs.each do |execution| %>
59
+ <% job = execution.job %>
60
+ <tr id="execution_<%= execution.id %>">
61
+ <td>
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" } %>
64
+ </td>
65
+ <td><%= job.priority %></td>
66
+ <td class="sqd-mono">
67
+ <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
68
+ </td>
69
+ <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
70
+ <% if discardable %>
71
+ <td class="sqd-row-actions">
72
+ <%= button_to "Discard", queue_job_path(queue_name: @queue, id: execution),
73
+ method: :delete,
74
+ params: { status: @status },
75
+ class: "sqd-btn sqd-btn--danger sqd-btn--sm",
76
+ data: { confirm: "Discard this job?" } %>
77
+ </td>
78
+ <% end %>
79
+ </tr>
80
+ <% end %>
81
+ </tbody>
82
+ </table>
83
+ <% end %>
84
+ </div>
85
+
86
+ <% if @pagy.last > 1 %>
87
+ <%= @pagy.series_nav.html_safe %>
88
+ <% end %>
89
+ <% end %>
@@ -0,0 +1,68 @@
1
+ <h1 class="sqd-page-title">Recurring Tasks</h1>
2
+
3
+ <div class="sqd-card" style="margin-top: 1.5rem;">
4
+ <% if @recurring_tasks.empty? %>
5
+ <div class="sqd-empty">No recurring tasks configured.</div>
6
+ <% else %>
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th scope="col">Key</th>
11
+ <th scope="col">Schedule</th>
12
+ <th scope="col">Job / Command</th>
13
+ <th scope="col">Queue</th>
14
+ <th scope="col">Next Run</th>
15
+ <th scope="col">Last Run</th>
16
+ <th scope="col">Type</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @recurring_tasks.each do |task| %>
21
+ <tr>
22
+ <td class="sqd-mono"><%= task.key %></td>
23
+ <td class="sqd-mono"><%= task.schedule %></td>
24
+ <td>
25
+ <% if task.class_name.present? %>
26
+ <%= task.class_name %>
27
+ <% if task.arguments.present? %>
28
+ <div class="sqd-muted-text sqd-mono" style="font-size: 11px;">
29
+ <%= task.arguments.inspect %>
30
+ </div>
31
+ <% end %>
32
+ <% else %>
33
+ <span class="sqd-mono sqd-muted-text"><%= task.command %></span>
34
+ <% end %>
35
+ <% if task.description.present? %>
36
+ <div class="sqd-muted-text" style="font-size: 12px; margin-top: 0.2rem;">
37
+ <%= task.description %>
38
+ </div>
39
+ <% end %>
40
+ </td>
41
+ <td class="sqd-mono"><%= task.queue_name.presence || "default" %></td>
42
+ <td class="sqd-mono">
43
+ <%
44
+ next_run = begin
45
+ task.next_time.strftime("%Y-%m-%d %H:%M %Z")
46
+ rescue
47
+ nil
48
+ end
49
+ %>
50
+ <%= next_run || "—" %>
51
+ </td>
52
+ <td class="sqd-mono">
53
+ <% last_run = task.last_enqueued_time %>
54
+ <%= last_run ? last_run.strftime("%Y-%m-%d %H:%M %Z") : "—" %>
55
+ </td>
56
+ <td>
57
+ <% if task.static? %>
58
+ <span class="sqd-badge sqd-badge--static">Static</span>
59
+ <% else %>
60
+ <span class="sqd-badge sqd-badge--dynamic">Dynamic</span>
61
+ <% end %>
62
+ </td>
63
+ </tr>
64
+ <% end %>
65
+ </tbody>
66
+ </table>
67
+ <% end %>
68
+ </div>
@@ -0,0 +1,2 @@
1
+ pin "solid_queue_web", to: "solid_queue_web/application.js"
2
+ pin "solid_queue_web/search_controller", to: "solid_queue_web/search_controller.js"
data/config/routes.rb CHANGED
@@ -1,12 +1,18 @@
1
1
  SolidQueueWeb::Engine.routes.draw do
2
2
  root to: "dashboard#index"
3
3
 
4
+ resources :recurring_tasks, only: [ :index ]
4
5
  resources :processes, only: [ :index ]
5
6
  resources :queues, only: [ :index ], param: :name do
6
7
  member do
7
8
  post :pause
8
9
  post :resume
9
10
  end
11
+ resources :jobs, path: "list", only: [ :index, :destroy ], controller: "queues/jobs" do
12
+ collection do
13
+ post :discard_all
14
+ end
15
+ end
10
16
  end
11
17
  resources :jobs, path: "list", only: [ :index, :show, :destroy ] do
12
18
  collection do
@@ -9,6 +9,19 @@ module SolidQueueWeb
9
9
 
10
10
  config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")
11
11
 
12
+ initializer "solid_queue_web.assets" do |app|
13
+ if app.config.respond_to?(:assets)
14
+ app.config.assets.paths << root.join("app/javascript")
15
+ end
16
+ end
17
+
18
+ initializer "solid_queue_web.importmap", before: "importmap" do |app|
19
+ if app.config.respond_to?(:importmap)
20
+ app.config.importmap.paths << root.join("config/importmap.rb")
21
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
22
+ end
23
+ end
24
+
12
25
  initializer "solid_queue_web.pagy" do
13
26
  Pagy::OPTIONS[:limit] = 25
14
27
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueueWeb
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.5"
3
3
  end
@@ -1,4 +1,5 @@
1
1
  require "solid_queue_web/version"
2
+ require "importmap-rails"
2
3
  require "solid_queue_web/engine"
3
4
 
4
5
  module SolidQueueWeb
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.4.0
4
+ version: 0.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: importmap-rails
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '1.2'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '1.2'
68
82
  description: Mount SolidQueueWeb in any Rails app using Solid Queue to get a dashboard
69
83
  for your queues, jobs by status, failed executions, and job actions (retry, discard)
70
84
  — all without leaving your app.
@@ -83,10 +97,15 @@ files:
83
97
  - app/controllers/solid_queue_web/failed_jobs_controller.rb
84
98
  - app/controllers/solid_queue_web/jobs_controller.rb
85
99
  - app/controllers/solid_queue_web/processes_controller.rb
100
+ - app/controllers/solid_queue_web/queues/jobs_controller.rb
86
101
  - app/controllers/solid_queue_web/queues_controller.rb
102
+ - app/controllers/solid_queue_web/recurring_tasks_controller.rb
87
103
  - app/helpers/solid_queue_web/application_helper.rb
104
+ - app/javascript/solid_queue_web/application.js
105
+ - app/javascript/solid_queue_web/search_controller.js
88
106
  - app/jobs/solid_queue_web/application_job.rb
89
107
  - app/models/solid_queue_web/application_record.rb
108
+ - app/models/solid_queue_web/job.rb
90
109
  - app/views/layouts/solid_queue_web/application.html.erb
91
110
  - app/views/solid_queue_web/dashboard/index.html.erb
92
111
  - app/views/solid_queue_web/failed_jobs/index.html.erb
@@ -95,6 +114,10 @@ files:
95
114
  - app/views/solid_queue_web/jobs/show.html.erb
96
115
  - app/views/solid_queue_web/processes/index.html.erb
97
116
  - app/views/solid_queue_web/queues/index.html.erb
117
+ - app/views/solid_queue_web/queues/jobs/destroy.turbo_stream.erb
118
+ - app/views/solid_queue_web/queues/jobs/index.html.erb
119
+ - app/views/solid_queue_web/recurring_tasks/index.html.erb
120
+ - config/importmap.rb
98
121
  - config/routes.rb
99
122
  - lib/solid_queue_web.rb
100
123
  - lib/solid_queue_web/engine.rb