solid_queue_web 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 770d3981147f5b4b76fb23bdf00c65064534f286bad9c13d6331f00df500109d
4
- data.tar.gz: 8bdd99286a28795b050dede93a70f69cb627d1e11dab7111ac4aa7c8f617430e
3
+ metadata.gz: 6897882e9e9b87a677fe9c6af855b33395cbf4bf2d13fb55086f461d26255f7d
4
+ data.tar.gz: 1b6882dce3a746811ba9553d0fb2174257c3f13146d5a448fd793c8c60a76661
5
5
  SHA512:
6
- metadata.gz: ae1d0e53e8710cdaa66a84da64a5eed60fff93734c3c5e56715ed95dfa03ec3115731b1f30c5203db666c4d0abc17870f38b5faae3c8fbdd651d333457de9ea8
7
- data.tar.gz: a9f4a6fc3ff1c183bd538a5df7b5c45128ffea0291c194bd3241b5bcf7a9cab3114e761a2874e6c9665a3e491f8d2369450bce657492a954c2dda287dd9464ba
6
+ metadata.gz: d19bc85fc4350e8ecb194111c87a7b882d1e5929142ec94211bcf6fef23840810ec4eb51ae4d957a3c2288263bba9955aabca9ea1874f4d3b7f6d34590f54a28
7
+ data.tar.gz: 3516ec14c55cd3839bf22d721a58d6a68016b0891b09088e5463e8f2e61e611b77d9eaca461ea49c6ffc225873589880b678fb829b53a877683e32de3d75456b
data/README.md CHANGED
@@ -44,6 +44,9 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
44
44
  - **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection
45
45
  - **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header
46
46
  - **Job history** — browsable list of all finished jobs with class name, queue, duration, and finished timestamp; filterable by period (1h / 24h / 7d), queue, and class name search; Done (1h) / Done (24h) dashboard cards link directly to the filtered history view; auto-refreshes every 10 seconds
47
+ - **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller
48
+ - **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy
49
+ - **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view
47
50
 
48
51
  ## Screenshots
49
52
 
@@ -81,13 +84,20 @@ Add to your `config/routes.rb`:
81
84
  mount SolidQueueWeb::Engine, at: "/jobs"
82
85
  ```
83
86
 
84
- The dashboard will be available at `/jobs`. See [Authentication](#authentication) to restrict access to admin users.
87
+ The dashboard will be available at `/jobs`.
85
88
 
86
- ## Authentication
89
+ ## Configuration
87
90
 
88
- The engine ships with no authentication by default. Add a block to an initializer (e.g. `config/initializers/solid_queue_web.rb`) to protect the dashboard:
91
+ All settings are optional the dashboard works with zero configuration. Create `config/initializers/solid_queue_web.rb` to customize behavior:
89
92
 
90
93
  ```ruby
94
+ SolidQueueWeb.configure do |config|
95
+ config.page_size = 50 # rows per page across all paginated views (default: 25)
96
+ config.dashboard_refresh_interval = 10_000 # dashboard auto-refresh in ms (default: 5_000)
97
+ config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000)
98
+ config.search_results_limit = 10 # max results per status in global search (default: 25)
99
+ end
100
+
91
101
  SolidQueueWeb.authenticate do
92
102
  # Called in the context of ApplicationController — use any helper available there.
93
103
  # Return a truthy value to allow access, falsy to deny (triggers HTTP Basic prompt).
@@ -95,22 +105,26 @@ SolidQueueWeb.authenticate do
95
105
  end
96
106
  ```
97
107
 
98
- HTTP Basic authentication is used as a fallback when the block returns falsy.
108
+ No authentication is enforced by default. When the `authenticate` block returns falsy, HTTP Basic auth is used as a fallback.
99
109
 
100
110
  ## Roadmap
101
111
 
102
- Planned features, roughly ordered by priority:
112
+ Planned features, roughly ordered by priority:
103
113
 
104
- **Near-term**
105
- - Dark modeCSS custom properties are already structured for it; toggle persists to `localStorage`
114
+ **Observability**
115
+ - Job failure rate chart sparkline per queue showing failure percentage over time, mirroring the throughput chart
116
+ - Queue depth trend — historical queue size over time, not just the current snapshot
117
+ - Slow job detection — flag jobs exceeding a configurable duration threshold
106
118
 
107
- **Medium-term**
108
- - Dashboard quick actionsRetry All Failed / Clear All Blocked directly from the dashboard
109
- - Configurable page size`?per=25|50|100` via Pagy's built-in support
119
+ **Operations**
120
+ - Scheduled job managementreschedule a job to run immediately, or push its `scheduled_at` forward
121
+ - Bulk retry with delay retry all failed jobs with a configurable stagger to avoid thundering herd
122
+ - Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity)
110
123
 
111
- **Larger scope**
112
- - CSV export of any filtered view (jobs, failed jobs, history)
124
+ **Infrastructure**
113
125
  - Webhook / alert config — POST to a URL when the failure count exceeds a threshold
126
+ - Multi-database support — when Solid Queue runs on a separate database from the host app
127
+ - Read replica support — route dashboard queries to a replica to avoid impacting the primary
114
128
 
115
129
  Pull requests for any of these are welcome. See [Contributing](#contributing) below.
116
130
 
@@ -13,6 +13,13 @@
13
13
  height: 56px;
14
14
  }
15
15
 
16
+ .sqd-header__controls {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 0.5rem;
20
+ margin-left: auto;
21
+ }
22
+
16
23
  .sqd-header__title {
17
24
  font-size: 16px;
18
25
  font-weight: 600;
@@ -43,6 +50,28 @@
43
50
  color: var(--text);
44
51
  }
45
52
 
53
+ .sqd-theme-toggle {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ width: 32px;
58
+ height: 32px;
59
+ padding: 0;
60
+ background: none;
61
+ border: 1px solid var(--border);
62
+ border-radius: 5px;
63
+ cursor: pointer;
64
+ font-size: 16px;
65
+ color: var(--text);
66
+ line-height: 1;
67
+ flex-shrink: 0;
68
+ transition: background 0.1s, border-color 0.1s;
69
+ }
70
+
71
+ .sqd-theme-toggle:hover {
72
+ background: var(--bg);
73
+ }
74
+
46
75
  .sqd-nav-toggle {
47
76
  display: none;
48
77
  flex-direction: column;
@@ -51,7 +80,6 @@
51
80
  width: 36px;
52
81
  height: 36px;
53
82
  padding: 6px;
54
- margin-left: auto;
55
83
  background: none;
56
84
  border: 1px solid var(--border);
57
85
  border-radius: 5px;
@@ -0,0 +1,34 @@
1
+ [data-theme="dark"] {
2
+ --bg: #0d1117;
3
+ --surface: #161b22;
4
+ --border: #30363d;
5
+ --text: #e6edf3;
6
+ --muted: #8b949e;
7
+ --primary: #58a6ff;
8
+ --danger: #f85149;
9
+ --warning: #d29922;
10
+ --success: #3fb950;
11
+ --info: #39c5cf;
12
+ --purple: #bc8cff;
13
+ }
14
+
15
+ [data-theme="dark"] .sqd-badge--ready { background: #1b3a2b; color: #3fb950; }
16
+ [data-theme="dark"] .sqd-badge--scheduled { background: #0e2a33; color: #39c5cf; }
17
+ [data-theme="dark"] .sqd-badge--claimed { background: #112040; color: #58a6ff; }
18
+ [data-theme="dark"] .sqd-badge--failed { background: #3d1118; color: #f85149; }
19
+ [data-theme="dark"] .sqd-badge--blocked { background: #2d2010; color: #d29922; }
20
+ [data-theme="dark"] .sqd-badge--static { background: #1b3a2b; color: #3fb950; }
21
+ [data-theme="dark"] .sqd-badge--dynamic { background: #2c1f45; color: #bc8cff; }
22
+ [data-theme="dark"] .sqd-badge--paused { background: #2d2d2d; color: #8b949e; }
23
+ [data-theme="dark"] .sqd-badge--running { background: #1b3a2b; color: #3fb950; }
24
+ [data-theme="dark"] .sqd-badge--supervisor { background: #2c1f45; color: #bc8cff; }
25
+ [data-theme="dark"] .sqd-badge--worker { background: #1b3a2b; color: #3fb950; }
26
+ [data-theme="dark"] .sqd-badge--dispatcher { background: #0e2a33; color: #39c5cf; }
27
+
28
+ [data-theme="dark"] .sqd-flash--notice { background: #1b3a2b; color: #3fb950; border-color: #2d6a4f; }
29
+ [data-theme="dark"] .sqd-flash--alert { background: #3d1118; color: #f85149; border-color: #6a2030; }
30
+
31
+ [data-theme="dark"] .sqd-theme-toggle {
32
+ border-color: var(--border);
33
+ color: var(--text);
34
+ }
@@ -1,3 +1,5 @@
1
+ require "csv"
2
+
1
3
  module SolidQueueWeb
2
4
  class ApplicationController < ActionController::Base
3
5
  include Pagy::Method
@@ -24,5 +24,21 @@ module SolidQueueWeb
24
24
  finished_times.count { |t| t >= from && t < to }
25
25
  end
26
26
  end
27
+
28
+ def retry_all_failed
29
+ jobs = SolidQueue::FailedExecution.includes(:job).map(&:job)
30
+ SolidQueue::FailedExecution.retry_all(jobs)
31
+ redirect_to root_path, notice: "#{jobs.size} failed #{"job".pluralize(jobs.size)} queued for retry."
32
+ rescue => e
33
+ redirect_to root_path, alert: "Could not retry failed jobs: #{e.message}"
34
+ end
35
+
36
+ def discard_all_blocked
37
+ jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job)
38
+ SolidQueue::BlockedExecution.discard_all_from_jobs(jobs)
39
+ redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded."
40
+ rescue => e
41
+ redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}"
42
+ end
27
43
  end
28
44
  end
@@ -1,43 +1,47 @@
1
1
  module SolidQueueWeb
2
2
  class FailedJobsController < ApplicationController
3
- before_action :set_filter_params, only: [:index, :retry_all, :discard_all]
3
+ before_action :set_filter_params, only: [:index, :destroy]
4
4
 
5
5
  def index
6
- @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc))
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}"
6
+ respond_to do |format|
7
+ format.html { @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc)) }
8
+ format.csv do
9
+ send_data failed_jobs_csv,
10
+ filename: "failed-jobs-#{Date.today}.csv",
11
+ type: "text/csv", disposition: "attachment"
12
+ end
13
+ end
15
14
  end
16
15
 
17
16
  def destroy
18
- execution = SolidQueue::FailedExecution.find(params[:id])
19
- execution.discard
20
- redirect_to failed_jobs_path, notice: "Job discarded."
17
+ executions = params[:id] ? [SolidQueue::FailedExecution.find(params[:id])] : filtered_scope.to_a
18
+ perform_discard(executions)
21
19
  rescue => e
22
20
  redirect_to failed_jobs_path, alert: "Could not discard job: #{e.message}"
23
21
  end
24
22
 
25
- def retry_all
26
- jobs = filtered_scope.map(&:job)
27
- SolidQueue::FailedExecution.retry_all(jobs)
28
- redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
29
- notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
23
+ private
24
+
25
+ def failed_jobs_csv
26
+ CSV.generate(headers: true) do |csv|
27
+ csv << %w[id class_name queue_name error_class error_message failed_at]
28
+ filtered_scope.order(created_at: :desc).each do |execution|
29
+ job = execution.job
30
+ error = execution.error || {}
31
+ csv << [job.id, job.class_name, job.queue_name,
32
+ error["exception_class"], error["message"],
33
+ execution.created_at.iso8601]
34
+ end
35
+ end
30
36
  end
31
37
 
32
- def discard_all
33
- jobs = filtered_scope.map(&:job)
38
+ def perform_discard(executions)
39
+ jobs = executions.map(&:job)
34
40
  SolidQueue::FailedExecution.discard_all_from_jobs(jobs)
35
41
  redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
36
42
  notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
37
43
  end
38
44
 
39
- private
40
-
41
45
  def set_filter_params
42
46
  @queue = params[:queue].presence
43
47
  @search = params[:q].presence
@@ -10,7 +10,26 @@ module SolidQueueWeb
10
10
  scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
11
11
  scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
12
 
13
- @pagy, @jobs = pagy(scope.order(finished_at: :desc))
13
+ respond_to do |format|
14
+ format.html { @pagy, @jobs = pagy(scope.order(finished_at: :desc)) }
15
+ format.csv do
16
+ send_data history_csv(scope),
17
+ filename: "job-history-#{Date.today}.csv",
18
+ type: "text/csv", disposition: "attachment"
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def history_csv(scope)
26
+ CSV.generate(headers: true) do |csv|
27
+ csv << %w[id class_name queue_name duration_seconds finished_at]
28
+ scope.order(finished_at: :desc).each do |job|
29
+ duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil
30
+ csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601]
31
+ end
32
+ end
14
33
  end
15
34
  end
16
35
  end
@@ -1,15 +1,24 @@
1
1
  module SolidQueueWeb
2
2
  class JobsController < ApplicationController
3
- before_action :set_status, only: [:destroy, :discard_all, :discard_selected]
3
+ before_action :set_status, only: [:destroy, :discard_selected]
4
4
 
5
5
  def index
6
6
  @status = params[:status].presence_in(Job::STATUSES) || "ready"
7
7
  @search = params[:q].presence
8
8
  @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
9
- @jobs = Job::EXECUTION_MODELS[@status].includes(:job)
10
- @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
11
- @jobs = @jobs.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
- @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
9
+ scope = Job::EXECUTION_MODELS[@status].includes(:job)
10
+ scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
11
+ scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
12
+ scope = scope.order(created_at: :desc)
13
+
14
+ respond_to do |format|
15
+ format.html { @pagy, @jobs = pagy(scope) }
16
+ format.csv do
17
+ send_data jobs_csv(scope),
18
+ filename: "jobs-#{@status}-#{Date.today}.csv",
19
+ type: "text/csv", disposition: "attachment"
20
+ end
21
+ end
13
22
  end
14
23
 
15
24
  def show
@@ -21,33 +30,39 @@ module SolidQueueWeb
21
30
 
22
31
  def destroy
23
32
  model = execution_model_for!(@status)
24
- @execution = model.find(params[:id])
25
- @execution.discard
26
- @remaining_count = filtered_scope(model).count
27
- respond_to do |format|
28
- format.turbo_stream
29
- format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." }
33
+ if params[:id]
34
+ @execution = model.find(params[:id])
35
+ @execution.discard
36
+ @remaining_count = filtered_scope(model).count
37
+ respond_to do |format|
38
+ format.turbo_stream
39
+ format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." }
40
+ end
41
+ else
42
+ jobs = filtered_scope(model).map(&:job)
43
+ model.discard_all_from_jobs(jobs)
44
+ redirect_to jobs_path(status: @status, period: @period),
45
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
30
46
  end
31
47
  rescue ArgumentError => e
32
48
  redirect_to jobs_path(status: @status, period: @period), alert: e.message
33
49
  rescue => e
34
- redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard job: #{e.message}"
35
- end
36
-
37
- def discard_all
38
- model = execution_model_for!(@status)
39
- jobs = filtered_scope(model).map(&:job)
40
- model.discard_all_from_jobs(jobs)
41
50
  redirect_to jobs_path(status: @status, period: @period),
42
- notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
43
- rescue ArgumentError => e
44
- redirect_to jobs_path(status: @status, period: @period), alert: e.message
45
- rescue => e
46
- redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard jobs: #{e.message}"
51
+ alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
47
52
  end
48
53
 
49
54
  private
50
55
 
56
+ def jobs_csv(scope)
57
+ CSV.generate(headers: true) do |csv|
58
+ csv << %w[id class_name queue_name status priority enqueued_at]
59
+ scope.each do |execution|
60
+ job = execution.job
61
+ csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
62
+ end
63
+ end
64
+ end
65
+
51
66
  def derive_status(job)
52
67
  return "failed" if job.failed_execution.present?
53
68
  return "claimed" if job.claimed_execution.present?
@@ -0,0 +1,31 @@
1
+ module SolidQueueWeb
2
+ class RetryFailedJobsController < ApplicationController
3
+ before_action :set_filter_params
4
+
5
+ def create
6
+ executions = params[:id] ? [SolidQueue::FailedExecution.find(params[:id])] : filtered_scope.to_a
7
+ jobs = executions.map(&:job)
8
+ SolidQueue::FailedExecution.retry_all(jobs)
9
+ redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
10
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
11
+ rescue => e
12
+ redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}"
13
+ end
14
+
15
+ private
16
+
17
+ def set_filter_params
18
+ @queue = params[:queue].presence
19
+ @search = params[:q].presence
20
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
21
+ end
22
+
23
+ def filtered_scope
24
+ scope = SolidQueue::FailedExecution.includes(:job)
25
+ scope = scope.references(:job).where(solid_queue_jobs: { queue_name: @queue }) if @queue.present?
26
+ scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
27
+ scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
28
+ scope
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,5 @@
1
1
  module SolidQueueWeb
2
2
  class SearchController < ApplicationController
3
- LIMIT = 25
4
-
5
3
  def index
6
4
  @query = params[:q].presence
7
5
  @job_classes = SolidQueue::Job.distinct.order(:class_name).pluck(:class_name)
@@ -15,7 +13,7 @@ module SolidQueueWeb
15
13
  .where("solid_queue_jobs.class_name LIKE ?", "%#{@query}%")
16
14
  .order(created_at: :desc)
17
15
  total = scope.count
18
- executions = scope.limit(LIMIT).to_a
16
+ executions = scope.limit(SolidQueueWeb.search_results_limit).to_a
19
17
  @results[status] = { executions: executions, total: total } unless executions.empty?
20
18
  end
21
19
  end
@@ -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
+ }
@@ -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,4 +1,4 @@
1
- <%= turbo_frame_tag "dashboard", target: "_top", data: { controller: "refresh", refresh_interval_value: 5000 } do %>
1
+ <%= turbo_frame_tag "dashboard", target: "_top", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.dashboard_refresh_interval } do %>
2
2
  <h1 class="sqd-page-title">Dashboard</h1>
3
3
 
4
4
  <div class="sqd-stats">
@@ -74,7 +74,7 @@
74
74
  <% end %>
75
75
  </div>
76
76
 
77
- <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
77
+ <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
78
78
  <div class="sqd-card">
79
79
  <div class="sqd-card__header">
80
80
  <span class="sqd-card__title">Quick Links</span>
@@ -91,13 +91,35 @@
91
91
  <% if @stats[:failed] > 0 %>
92
92
  <div class="sqd-card">
93
93
  <div class="sqd-card__header">
94
- <span class="sqd-card__title">Attention Required</span>
94
+ <span class="sqd-card__title">Failed Jobs</span>
95
95
  </div>
96
- <div style="padding: 1rem;">
97
- <p style="color: var(--danger); margin-bottom: 0.75rem;">
96
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
97
+ <p style="color: var(--danger); font-size: 13px;">
98
98
  <%= pluralize(@stats[:failed], "failed job") %> need attention.
99
99
  </p>
100
- <%= link_to "Review failed jobs →", failed_jobs_path, class: "sqd-btn sqd-btn--danger" %>
100
+ <%= button_to "Retry All Failed", retry_all_failed_path,
101
+ method: :post,
102
+ class: "sqd-btn sqd-btn--primary",
103
+ data: { confirm: "Retry all #{@stats[:failed]} failed #{"job".pluralize(@stats[:failed])}?" } %>
104
+ <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>
105
+ </div>
106
+ </div>
107
+ <% end %>
108
+
109
+ <% if @stats[:blocked] > 0 %>
110
+ <div class="sqd-card">
111
+ <div class="sqd-card__header">
112
+ <span class="sqd-card__title">Blocked Jobs</span>
113
+ </div>
114
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
115
+ <p style="color: var(--warning); font-size: 13px;">
116
+ <%= pluralize(@stats[:blocked], "blocked job") %>.
117
+ </p>
118
+ <%= button_to "Discard All Blocked", discard_all_blocked_path,
119
+ method: :post,
120
+ class: "sqd-btn sqd-btn--danger",
121
+ data: { confirm: "Discard all #{@stats[:blocked]} blocked #{"job".pluralize(@stats[:blocked])}? This cannot be undone." } %>
122
+ <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>
101
123
  </div>
102
124
  </div>
103
125
  <% end %>
@@ -2,6 +2,8 @@
2
2
  <h1 class="sqd-page-title">Failed Jobs</h1>
3
3
  <% if @failed_jobs.any? %>
4
4
  <div class="sqd-actions">
5
+ <%= link_to "Export CSV", failed_jobs_path(format: :csv, queue: @queue, q: @search, period: @period),
6
+ class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
5
7
  <%= button_to "Retry All", retry_all_failed_jobs_path,
6
8
  method: :post,
7
9
  params: { queue: @queue, q: @search, period: @period },
@@ -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">
@@ -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>
@@ -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">
@@ -27,8 +27,8 @@
27
27
  <span class="sqd-badge sqd-badge--<%= status %>"><%= status %></span>
28
28
  <span class="sqd-muted-text">
29
29
  <%= pluralize(data[:total], "match", "matches") %>
30
- <% if data[:total] > SolidQueueWeb::SearchController::LIMIT %>
31
- &mdash; showing first <%= SolidQueueWeb::SearchController::LIMIT %>
30
+ <% if data[:total] > SolidQueueWeb.search_results_limit %>
31
+ &mdash; showing first <%= SolidQueueWeb.search_results_limit %>
32
32
  <% end %>
33
33
  </span>
34
34
  <% if status == "failed" %>
data/config/importmap.rb CHANGED
@@ -2,3 +2,4 @@ pin "solid_queue_web", to: "solid_queue_web/application.js"
2
2
  pin "solid_queue_web/search_controller", to: "solid_queue_web/search_controller.js"
3
3
  pin "solid_queue_web/refresh_controller", to: "solid_queue_web/refresh_controller.js"
4
4
  pin "solid_queue_web/selection_controller", to: "solid_queue_web/selection_controller.js"
5
+ pin "solid_queue_web/theme_controller", to: "solid_queue_web/theme_controller.js"
data/config/routes.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  SolidQueueWeb::Engine.routes.draw do
2
2
  root to: "dashboard#index"
3
+ post "retry_all_failed", to: "dashboard#retry_all_failed", as: :retry_all_failed
4
+ post "discard_all_blocked", to: "dashboard#discard_all_blocked", as: :discard_all_blocked
3
5
 
4
6
  get "search", to: "search#index", as: :search
5
7
  get "history", to: "history#index", as: :history
@@ -23,7 +25,7 @@ SolidQueueWeb::Engine.routes.draw do
23
25
  resource :job_selection, path: "list/selection", only: [:destroy], controller: "jobs/selections"
24
26
  resources :jobs, path: "list", only: [:index, :show, :destroy] do
25
27
  collection do
26
- post :discard_all
28
+ post :discard_all, action: :destroy
27
29
  end
28
30
  end
29
31
 
@@ -31,11 +33,11 @@ SolidQueueWeb::Engine.routes.draw do
31
33
  controller: "failed_jobs/selections"
32
34
  resources :failed_jobs, only: [:index, :destroy] do
33
35
  collection do
34
- post :retry_all
35
- post :discard_all
36
+ post :retry_all, to: "retry_failed_jobs#create"
37
+ post :discard_all, action: :destroy
36
38
  end
37
39
  member do
38
- post :retry
40
+ post :retry, to: "retry_failed_jobs#create"
39
41
  end
40
42
  end
41
43
  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.8.0"
3
3
  end
@@ -4,6 +4,28 @@ require "solid_queue_web/engine"
4
4
 
5
5
  module SolidQueueWeb
6
6
  class << self
7
+ attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit
8
+
9
+ def page_size
10
+ @page_size || 25
11
+ end
12
+
13
+ def dashboard_refresh_interval
14
+ @dashboard_refresh_interval || 5_000
15
+ end
16
+
17
+ def default_refresh_interval
18
+ @default_refresh_interval || 10_000
19
+ end
20
+
21
+ def search_results_limit
22
+ @search_results_limit || 25
23
+ end
24
+
25
+ def configure
26
+ yield self
27
+ end
28
+
7
29
  def authenticate(&block)
8
30
  @authenticate = block if block_given?
9
31
  @authenticate
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: 8.1.3
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: solid_queue
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -102,6 +116,7 @@ files:
102
116
  - app/assets/stylesheets/solid_queue_web/_09_pagination.css
103
117
  - app/assets/stylesheets/solid_queue_web/_10_responsive.css
104
118
  - app/assets/stylesheets/solid_queue_web/_11_throughput.css
119
+ - app/assets/stylesheets/solid_queue_web/_12_dark_mode.css
105
120
  - app/assets/stylesheets/solid_queue_web/application.css
106
121
  - app/controllers/solid_queue_web/application_controller.rb
107
122
  - app/controllers/solid_queue_web/dashboard_controller.rb
@@ -114,12 +129,14 @@ files:
114
129
  - app/controllers/solid_queue_web/queues/jobs_controller.rb
115
130
  - app/controllers/solid_queue_web/queues_controller.rb
116
131
  - app/controllers/solid_queue_web/recurring_tasks_controller.rb
132
+ - app/controllers/solid_queue_web/retry_failed_jobs_controller.rb
117
133
  - app/controllers/solid_queue_web/search_controller.rb
118
134
  - app/helpers/solid_queue_web/application_helper.rb
119
135
  - app/javascript/solid_queue_web/application.js
120
136
  - app/javascript/solid_queue_web/refresh_controller.js
121
137
  - app/javascript/solid_queue_web/search_controller.js
122
138
  - app/javascript/solid_queue_web/selection_controller.js
139
+ - app/javascript/solid_queue_web/theme_controller.js
123
140
  - app/jobs/solid_queue_web/application_job.rb
124
141
  - app/models/solid_queue_web/application_record.rb
125
142
  - app/models/solid_queue_web/job.rb