solid_stack_web 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -49
  3. data/app/assets/stylesheets/solid_stack_web/_01_base.css +12 -0
  4. data/app/assets/stylesheets/solid_stack_web/_09_detail.css +3 -28
  5. data/app/controllers/solid_stack_web/application_controller.rb +15 -0
  6. data/app/controllers/solid_stack_web/jobs_controller.rb +5 -2
  7. data/app/controllers/solid_stack_web/queues_controller.rb +4 -9
  8. data/app/models/solid_stack_web/cache_size_stats.rb +10 -6
  9. data/app/views/layouts/solid_stack_web/application.html.erb +6 -5
  10. data/app/views/solid_stack_web/cable/index.html.erb +3 -3
  11. data/app/views/solid_stack_web/cable_messages/index.html.erb +4 -4
  12. data/app/views/solid_stack_web/cache/index.html.erb +5 -5
  13. data/app/views/solid_stack_web/cache_entries/index.html.erb +3 -3
  14. data/app/views/solid_stack_web/cache_entries/show.html.erb +11 -14
  15. data/app/views/solid_stack_web/errors/internal_server_error.html.erb +8 -0
  16. data/app/views/solid_stack_web/errors/not_found.html.erb +8 -0
  17. data/app/views/solid_stack_web/failed_jobs/index.html.erb +7 -7
  18. data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
  19. data/app/views/solid_stack_web/history/index.html.erb +5 -5
  20. data/app/views/solid_stack_web/jobs/index.html.erb +8 -8
  21. data/app/views/solid_stack_web/processes/index.html.erb +5 -5
  22. data/app/views/solid_stack_web/queues/index.html.erb +5 -5
  23. data/app/views/solid_stack_web/queues/show.html.erb +5 -5
  24. data/app/views/solid_stack_web/recurring_tasks/index.html.erb +8 -8
  25. data/app/views/solid_stack_web/stats/index.html.erb +1 -1
  26. data/docs/screenshots/demo.gif +0 -0
  27. data/lib/generators/solid_stack_web/install/install_generator.rb +19 -0
  28. data/lib/generators/solid_stack_web/install/templates/initializer.rb +51 -0
  29. data/lib/solid_stack_web/engine.rb +4 -0
  30. data/lib/solid_stack_web/version.rb +1 -1
  31. data/lib/solid_stack_web.rb +35 -0
  32. metadata +14 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a146449291d2d1e6bc46612021b6abf939a08f969fda923afceeb863323bf672
4
- data.tar.gz: 6499cd93a4db8268bbf637bfb5934e3777ec124eea3f44ccea877188c7c83ed1
3
+ metadata.gz: e7932ddf2c50cc9ae63a884c0dabdc0fe4553c9dfb823870d3e1b15b4dc2a3a6
4
+ data.tar.gz: 04d6d15b941d21dfae0d1562095e6cdb4cd67a7e1163efa3bc2d33563df2fb92
5
5
  SHA512:
6
- metadata.gz: be0e30ea4fa72ef666e82cefddd3936a99196bd43f809e546fa37bb59bfed39ed267b07b42268479baed8210303762d0629c49bb15c9cbb3661090b295bad91e
7
- data.tar.gz: 7bc30e8a5a62929ed893bc1189f3b141c52add6489f423f39076a7565219d419a9a60568f5faae9b7dbfb503c8f74144bab1a91f6d7db065b654c9ed2d2e29d7
6
+ metadata.gz: 37ac38026dbde5e629d132f18795797f84a7b48b8e19689435b0288f44c57abf73e7dae5aa9e51884021920b4b336160597529b4f87c44cac027e9b88c1293d6
7
+ data.tar.gz: c233686b589092befdcc5b19ca7bfd09a2f10e9f1d95b894f58afff4fb67475ddcae308bf6b65386f9af5479d670256dde1cd02cf3cf2b5466eadcd00a051a2d
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
6
6
  [![codecov](https://codecov.io/gh/eclectic-coding/solid_stack_web/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/solid_stack_web)
7
7
 
8
- A mountable Rails engine that provides a unified web dashboard for the full [Solid Stack](https://github.com/rails/solid_queue) — **Solid Queue**, **Solid Cache**, and **Solid Cable** — in a single interface with no asset pipeline dependency and no JavaScript runtime requirement.
8
+ A production-ready operations dashboard for the full Rails Solid Stack. Mount one engine to get deep visibility into **Solid Queue** (job browser, failed job retry, queue controls, recurring tasks, performance stats), **Solid Cache** (entry browser, size distribution, write timeline), and **Solid Cable** (channel browser, message list, purge controls) — with dark mode, CSV export, alert webhooks, and a JSON metrics endpoint, all with no asset pipeline dependency.
9
9
 
10
10
  ## Installation
11
11
 
@@ -29,6 +29,123 @@ mount SolidStackWeb::Engine, at: "/solid_stack"
29
29
 
30
30
  The dashboard will be available at `/solid_stack` (or whatever path you choose).
31
31
 
32
+ ### Install generator
33
+
34
+ Run the install generator to create a documented initializer and wire up the mount point in one step:
35
+
36
+ ```bash
37
+ rails generate solid_stack_web:install
38
+ ```
39
+
40
+ This creates `config/initializers/solid_stack_web.rb` with every configuration option commented inline, and injects `mount SolidStackWeb::Engine, at: "/solid_stack"` into `config/routes.rb`.
41
+
42
+ ---
43
+
44
+ ## Screenshots
45
+
46
+ ![SolidStackWeb dashboard](docs/screenshots/demo.gif)
47
+
48
+ ---
49
+
50
+ ## Metrics endpoint
51
+
52
+ `GET /metrics` (relative to your mount path) returns a JSON payload suitable for external monitoring tools, uptime checkers, or custom alerting:
53
+
54
+ ```json
55
+ {
56
+ "queue": {
57
+ "ready": 4,
58
+ "scheduled": 1,
59
+ "claimed": 2,
60
+ "blocked": 0,
61
+ "failed": 3,
62
+ "done_1h": 45,
63
+ "done_24h": 312,
64
+ "processes_healthy": 2,
65
+ "processes_stale": 0,
66
+ "slow_jobs": 7
67
+ },
68
+ "cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
69
+ "cable": { "messages": 50, "channels": 3, "messages_per_hour": 12, "oldest_message": "2026-05-20T10:00:00Z", "top_channels": { "ActionCable::Channel::Base": 30, "ChatChannel": 15, "NotificationsChannel": 5 } },
70
+ "generated_at": "2026-05-26T10:00:00Z"
71
+ }
72
+ ```
73
+
74
+ `slow_jobs` is only present when `slow_job_threshold` is configured. The endpoint is protected by the same authentication as the rest of the dashboard.
75
+
76
+ ---
77
+
78
+ ## General configuration
79
+
80
+ Create an initializer at `config/initializers/solid_stack_web.rb`:
81
+
82
+ ```ruby
83
+ SolidStackWeb.configure do |config|
84
+ # Number of items per paginated page (default: 25)
85
+ config.page_size = 50
86
+
87
+ # Authentication — block runs in controller context.
88
+ # Return a truthy value to allow access; falsy falls back to HTTP Basic.
89
+ config.authenticate do
90
+ current_user&.admin?
91
+ end
92
+
93
+ # Multi-database — pass a connects_to hash when Solid Queue / Cache / Cable
94
+ # live on a separate database from your primary (default: nil, uses primary).
95
+ config.connects_to = { database: { writing: :queue, reading: :queue } }
96
+ end
97
+ ```
98
+
99
+ ### Authentication
100
+
101
+ The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open.
102
+
103
+ ### Linking to the dashboard
104
+
105
+ `SolidStackWeb.mount_path` returns the path at which the engine is mounted, derived automatically from your routes. Use it to link to the dashboard from your application layout without hardcoding the path:
106
+
107
+ ```ruby
108
+ link_to "Queue Dashboard", SolidStackWeb.mount_path
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Security
114
+
115
+ ### Authentication
116
+
117
+ **The dashboard is open to all visitors by default.** Any production deployment must configure an `authenticate` block or the dashboard will be publicly accessible.
118
+
119
+ ```ruby
120
+ SolidStackWeb.configure do |config|
121
+ # Devise
122
+ config.authenticate { current_user&.admin? }
123
+
124
+ # HTTP Basic fallback (used when no authenticate block is set, or when
125
+ # the block returns false/nil and you want a browser credential prompt)
126
+ # Configure via HTTP_BASIC_AUTH_NAME / HTTP_BASIC_AUTH_PASSWORD env vars
127
+ # in your host app, or use a reverse proxy.
128
+ end
129
+ ```
130
+
131
+ If the `authenticate` block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no block is configured at all, the dashboard is open.
132
+
133
+ ### Sensitive cache values
134
+
135
+ `allow_value_preview` is `false` by default. Enabling it renders the raw serialised cache value on the entry detail page. Do not enable this if your cache stores session tokens, PII, or other sensitive data.
136
+
137
+ ### CSRF protection
138
+
139
+ All state-mutating actions (job discard, retry, queue pause/resume, cache flush) use form POST requests. Turbo handles CSRF tokens automatically for any standard Rails app with `protect_from_forgery`.
140
+
141
+ ### Rate limiting and network exposure
142
+
143
+ The dashboard is designed to be mounted behind your application's existing authentication. For additional hardening, consider:
144
+
145
+ - Mounting at a non-guessable path (e.g. `at: "/ops/#{Rails.application.credentials.dashboard_token}"`)
146
+ - Restricting access by IP at the reverse-proxy level
147
+ - Applying [Rack::Attack](https://github.com/rack/rack-attack) rules to the mount path
148
+
32
149
  ---
33
150
 
34
151
  ## Solid Queue
@@ -128,66 +245,45 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru
128
245
 
129
246
  ---
130
247
 
131
- ## Metrics endpoint
132
-
133
- `GET /metrics` (relative to your mount path) returns a JSON payload suitable for external monitoring tools, uptime checkers, or custom alerting:
248
+ ## Requirements
134
249
 
135
- ```json
136
- {
137
- "queue": {
138
- "ready": 4,
139
- "scheduled": 1,
140
- "claimed": 2,
141
- "blocked": 0,
142
- "failed": 3,
143
- "done_1h": 45,
144
- "done_24h": 312,
145
- "processes_healthy": 2,
146
- "processes_stale": 0,
147
- "slow_jobs": 7
148
- },
149
- "cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
150
- "cable": { "messages": 50, "channels": 3, "messages_per_hour": 12, "oldest_message": "2026-05-20T10:00:00Z", "top_channels": { "ActionCable::Channel::Base": 30, "ChatChannel": 15, "NotificationsChannel": 5 } },
151
- "generated_at": "2026-05-26T10:00:00Z"
152
- }
153
- ```
250
+ - Ruby >= 3.3
251
+ - Rails >= 8.1.3
252
+ - [solid_queue](https://github.com/rails/solid_queue) >= 1.0
253
+ - [solid_cache](https://github.com/rails/solid_cache) >= 1.0
254
+ - [solid_cable](https://github.com/rails/solid_cable) >= 1.0
255
+ - [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0
256
+ - [importmap-rails](https://github.com/rails/importmap-rails) >= 1.2
154
257
 
155
- `slow_jobs` is only present when `slow_job_threshold` is configured. The endpoint is protected by the same authentication as the rest of the dashboard.
258
+ ## Versioning
156
259
 
157
- ---
260
+ SolidStackWeb follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
158
261
 
159
- ## General configuration
262
+ ### Public API
160
263
 
161
- Create an initializer at `config/initializers/solid_stack_web.rb`:
264
+ The following are considered stable public API from v1.0.0 onwards — breaking changes to any of these require a major version bump:
162
265
 
163
- ```ruby
164
- SolidStackWeb.configure do |config|
165
- # Number of items per paginated page (default: 25)
166
- config.page_size = 50
266
+ - The `SolidStackWeb.configure` block and all documented configuration keys
267
+ - The `SolidStackWeb.mount_path` helper
268
+ - The `authenticate` block interface
269
+ - The `GET /metrics` JSON payload shape
270
+ - The `SolidStackWeb::Engine` class and its mount interface
271
+ - The `rails generate solid_stack_web:install` generator
167
272
 
168
- # Authentication — block runs in controller context.
169
- # Return a truthy value to allow access; falsy falls back to HTTP Basic.
170
- config.authenticate do
171
- current_user&.admin?
172
- end
173
- end
174
- ```
273
+ ### Not part of the public API
175
274
 
176
- ### Authentication
275
+ The following are internal and may change in any release without notice:
177
276
 
178
- The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open.
277
+ - Internal service classes (`CacheStats`, `QueueStats`, etc.)
278
+ - View templates, partial names, and CSS class names
279
+ - Controller and helper internals
280
+ - Private methods on any class
179
281
 
180
- ---
282
+ ### Deprecation policy
181
283
 
182
- ## Requirements
284
+ When a public API item is renamed or removed, the old interface is deprecated in a **minor** release — it continues to work but issues an `ActiveSupport::Deprecation` warning pointing to the replacement. The old interface is removed in the next **major** release. The [UPGRADING.md](UPGRADING.md) file documents every breaking change and the migration steps.
183
285
 
184
- - Ruby >= 3.3
185
- - Rails >= 8.1.3
186
- - [solid_queue](https://github.com/rails/solid_queue) >= 1.0
187
- - [solid_cache](https://github.com/rails/solid_cache) >= 1.0
188
- - [solid_cable](https://github.com/rails/solid_cable) >= 1.0
189
- - [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0
190
- - [importmap-rails](https://github.com/rails/importmap-rails) >= 1.2
286
+ ---
191
287
 
192
288
  ## Contributing
193
289
 
@@ -31,3 +31,15 @@ a:hover { text-decoration: underline; }
31
31
  .sqw-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 13px; }
32
32
  .sqw-muted { color: var(--muted); }
33
33
  .sqw-truncate { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+
35
+ .sqw-sr-only {
36
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
37
+ overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
38
+ }
39
+
40
+ .sqw-skip-link {
41
+ position: absolute; top: -100%; left: 0; z-index: 9999;
42
+ padding: 0.5rem 1rem; background: var(--accent); color: #fff;
43
+ font-weight: 600; text-decoration: none; border-radius: 0 0 4px 0;
44
+ }
45
+ .sqw-skip-link:focus { top: 0; }
@@ -70,20 +70,9 @@
70
70
  overflow-y: auto;
71
71
  }
72
72
 
73
- .sqw-detail {
74
- display: grid;
75
- grid-template-columns: auto 1fr;
76
- gap: 0.5rem 1.5rem;
77
- font-size: 13px;
78
- margin-bottom: 1.5rem;
79
- }
80
- .sqw-detail__row {
81
- display: contents;
82
- }
83
- .sqw-detail__row dt { color: var(--muted); white-space: nowrap; align-self: start; padding-top: 0.15rem; }
84
- .sqw-detail__row dd { word-break: break-all; }
85
-
86
- .sqw-value-preview { margin-top: 1rem; }
73
+ .sqw-detail-card--mt,
74
+ .sqw-detail-card + .sqw-detail-card,
75
+ .sqw-detail-grid + .sqw-detail-card { margin-top: 1.5rem; }
87
76
 
88
77
  .sqw-value-preview__header {
89
78
  display: flex;
@@ -93,20 +82,6 @@
93
82
  }
94
83
  .sqw-value-preview__header .sqw-section-title { margin-bottom: 0; }
95
84
 
96
- .sqw-value-pre {
97
- font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
98
- font-size: 12px;
99
- background: var(--bg);
100
- border: 1px solid var(--border);
101
- border-radius: var(--radius);
102
- padding: 0.75rem;
103
- overflow-x: auto;
104
- white-space: pre-wrap;
105
- word-break: break-word;
106
- max-height: 500px;
107
- overflow-y: auto;
108
- }
109
-
110
85
  .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
111
86
 
112
87
  .sqw-link { color: var(--primary); text-decoration: none; }
@@ -9,6 +9,13 @@ module SolidStackWeb
9
9
  before_action :authenticate!
10
10
  around_action :with_database_connection
11
11
 
12
+ rescue_from StandardError do |exception|
13
+ raise exception if Rails.application.config.consider_all_requests_local
14
+ render_internal_server_error
15
+ end
16
+
17
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
18
+
12
19
  helper_method :current_section
13
20
 
14
21
  private
@@ -43,5 +50,13 @@ module SolidStackWeb
43
50
  def request_basic_auth
44
51
  request_http_basic_authentication("Solid Stack Dashboard")
45
52
  end
53
+
54
+ def render_not_found
55
+ render "solid_stack_web/errors/not_found", status: :not_found
56
+ end
57
+
58
+ def render_internal_server_error
59
+ render "solid_stack_web/errors/internal_server_error", status: :internal_server_error
60
+ end
46
61
  end
47
62
  end
@@ -5,8 +5,11 @@ module SolidStackWeb
5
5
  before_action :require_discardable, only: [:destroy]
6
6
 
7
7
  def index
8
- @queue_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.queue_name").sort
9
- @priority_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.priority").sort
8
+ pairs = Job::EXECUTION_MODELS[@status].joins(:job)
9
+ .distinct
10
+ .pluck("solid_queue_jobs.queue_name", "solid_queue_jobs.priority")
11
+ @queue_options = pairs.map(&:first).uniq.sort
12
+ @priority_options = pairs.map(&:last).uniq.sort
10
13
 
11
14
  respond_to do |format|
12
15
  format.html { @pagy, @executions = pagy(filtered_scope) }
@@ -1,16 +1,11 @@
1
1
  module SolidStackWeb
2
2
  class QueuesController < ApplicationController
3
3
  def index
4
- queue_names = ::SolidQueue::ReadyExecution.distinct.pluck(:queue_name)
5
- paused = ::SolidQueue::Pause.pluck(:queue_name).to_set
4
+ counts = ::SolidQueue::ReadyExecution.group(:queue_name).count
5
+ paused = ::SolidQueue::Pause.pluck(:queue_name).to_set
6
6
 
7
- @queues = queue_names.sort.map do |name|
8
- {
9
- name: name,
10
- size: ::SolidQueue::ReadyExecution.where(queue_name: name).count,
11
- paused: paused.include?(name)
12
- }
13
- end
7
+ @queues = counts.map { |name, size| { name:, size:, paused: paused.include?(name) } }
8
+ .sort_by { |q| q[:name] }
14
9
 
15
10
  @sparklines = @queues.each_with_object({}) do |queue, h|
16
11
  h[queue[:name]] = QueueDepthSparkline.new(queue[:name])
@@ -18,16 +18,20 @@ module SolidStackWeb
18
18
  end
19
19
 
20
20
  def buckets
21
- @buckets ||= BUCKETS.map do |b|
22
- scope = ::SolidCache::Entry.all
23
- scope = scope.where("byte_size >= ?", b[:min]) if b[:min] > 0
24
- scope = scope.where("byte_size < ?", b[:max]) if b[:max]
25
- { label: b[:label], count: scope.count }
21
+ @buckets ||= begin
22
+ row = ::SolidCache::Entry.pluck(
23
+ Arel.sql("COALESCE(SUM(CASE WHEN byte_size < 1024 THEN 1 ELSE 0 END), 0)"),
24
+ Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 1024 AND byte_size < 10240 THEN 1 ELSE 0 END), 0)"),
25
+ Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 10240 AND byte_size < 102400 THEN 1 ELSE 0 END), 0)"),
26
+ Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 102400 AND byte_size < 1048576 THEN 1 ELSE 0 END), 0)"),
27
+ Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 1048576 THEN 1 ELSE 0 END), 0)")
28
+ ).first || Array.new(5, 0)
29
+ BUCKETS.zip(row).map { |b, count| { label: b[:label], count: count.to_i } }
26
30
  end
27
31
  end
28
32
 
29
33
  def total
30
- @total ||= ::SolidCache::Entry.count
34
+ @total ||= buckets.sum { |b| b[:count] }
31
35
  end
32
36
  end
33
37
  end
@@ -11,10 +11,11 @@
11
11
  <%= javascript_importmap_tags "solid_stack_web" %>
12
12
  </head>
13
13
  <body data-controller="theme">
14
+ <a href="#main-content" class="sqw-skip-link">Skip to main content</a>
14
15
  <header class="sqw-header">
15
16
  <div class="sqw-header__inner">
16
17
  <%= link_to "Solid Stack", root_path, class: "sqw-header__logo" %>
17
- <nav class="sqw-nav">
18
+ <nav class="sqw-nav" aria-label="Main navigation">
18
19
  <%= link_to "Queue", jobs_path,
19
20
  class: "sqw-nav__link#{" sqw-nav__link--active" if current_section == :queue}" %>
20
21
  <%= link_to "Cache", cache_path,
@@ -28,7 +29,7 @@
28
29
  </header>
29
30
 
30
31
  <% if current_section == :cache %>
31
- <nav class="sqw-subnav">
32
+ <nav class="sqw-subnav" aria-label="Cache section">
32
33
  <div class="sqw-subnav__inner">
33
34
  <%= link_to "Overview", cache_path,
34
35
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache"}" %>
@@ -39,7 +40,7 @@
39
40
  <% end %>
40
41
 
41
42
  <% if current_section == :cable %>
42
- <nav class="sqw-subnav">
43
+ <nav class="sqw-subnav" aria-label="Cable section">
43
44
  <div class="sqw-subnav__inner">
44
45
  <%= link_to "Overview", cable_path,
45
46
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cable"}" %>
@@ -48,7 +49,7 @@
48
49
  <% end %>
49
50
 
50
51
  <% if current_section == :queue %>
51
- <nav class="sqw-subnav">
52
+ <nav class="sqw-subnav" aria-label="Queue section">
52
53
  <div class="sqw-subnav__inner">
53
54
  <%= link_to "Jobs", jobs_path,
54
55
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "jobs"}" %>
@@ -68,7 +69,7 @@
68
69
  </nav>
69
70
  <% end %>
70
71
 
71
- <main class="sqw-main">
72
+ <main id="main-content" class="sqw-main">
72
73
  <div id="sqw-flash-container">
73
74
  <% flash.each do |type, message| %>
74
75
  <div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
@@ -50,9 +50,9 @@
50
50
  <table class="sqw-table">
51
51
  <thead>
52
52
  <tr>
53
- <th>Channel</th>
54
- <th>Messages</th>
55
- <th>Last Message</th>
53
+ <th scope="col">Channel</th>
54
+ <th scope="col">Messages</th>
55
+ <th scope="col">Last Message</th>
56
56
  </tr>
57
57
  </thead>
58
58
  <tbody>
@@ -23,9 +23,9 @@
23
23
  <table class="sqw-table">
24
24
  <thead>
25
25
  <tr>
26
- <th>ID</th>
27
- <th>Payload</th>
28
- <th>Sent</th>
26
+ <th scope="col">ID</th>
27
+ <th scope="col">Payload</th>
28
+ <th scope="col">Sent</th>
29
29
  </tr>
30
30
  </thead>
31
31
  <tbody>
@@ -42,7 +42,7 @@
42
42
  <% end %>
43
43
  </tbody>
44
44
  </table>
45
- <%== @pagy.series_nav if @pagy.pages > 1 %>
45
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
46
46
  <% else %>
47
47
  <div class="sqw-empty">
48
48
  <% if @search.present? %>
@@ -20,9 +20,9 @@
20
20
  <table class="sqw-table">
21
21
  <thead>
22
22
  <tr>
23
- <th>Range</th>
24
- <th>Entries</th>
25
- <th></th>
23
+ <th scope="col">Range</th>
24
+ <th scope="col">Entries</th>
25
+ <th scope="col"><span class="sqw-sr-only">Distribution</span></th>
26
26
  </tr>
27
27
  </thead>
28
28
  <tbody>
@@ -48,8 +48,8 @@
48
48
  <table class="sqw-table">
49
49
  <thead>
50
50
  <tr>
51
- <th>Key</th>
52
- <th>Size</th>
51
+ <th scope="col">Key</th>
52
+ <th scope="col">Size</th>
53
53
  </tr>
54
54
  </thead>
55
55
  <tbody>
@@ -26,7 +26,7 @@
26
26
  <thead>
27
27
  <tr>
28
28
  <% [["key", "Key"], ["byte_size", "Size"], ["created_at", "Created"]].each do |col, label| %>
29
- <th>
29
+ <th scope="col">
30
30
  <% next_dir = (@sort["column"] == col && @sort["direction"] == "desc") ? "asc" : "desc" %>
31
31
  <%= link_to cache_entries_path(q: @search, column: col, direction: next_dir) do %>
32
32
  <%= label %>
@@ -36,7 +36,7 @@
36
36
  <% end %>
37
37
  </th>
38
38
  <% end %>
39
- <th></th>
39
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
40
40
  </tr>
41
41
  </thead>
42
42
  <tbody>
@@ -58,7 +58,7 @@
58
58
  <% end %>
59
59
  </tbody>
60
60
  </table>
61
- <%== @pagy.series_nav if @pagy.pages > 1 %>
61
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
62
62
  <% else %>
63
63
  <div class="sqw-empty">
64
64
  <% if @search.present? %>
@@ -10,33 +10,30 @@
10
10
  </div>
11
11
  </div>
12
12
 
13
- <dl class="sqw-detail">
14
- <div class="sqw-detail__row">
13
+ <div class="sqw-detail-card sqw-detail-section">
14
+ <h2 class="sqw-section-title">Details</h2>
15
+ <dl class="sqw-dl">
15
16
  <dt>Key</dt>
16
17
  <dd class="sqw-monospace"><%= @entry.key %></dd>
17
- </div>
18
- <div class="sqw-detail__row">
19
18
  <dt>Size</dt>
20
19
  <dd><%= number_to_human_size(@entry.byte_size) %></dd>
21
- </div>
22
- <div class="sqw-detail__row">
23
20
  <dt>Created</dt>
24
21
  <dd class="sqw-muted"><%= local_time(@entry.created_at, format: :long) %></dd>
25
- </div>
26
- </dl>
22
+ </dl>
23
+ </div>
27
24
 
28
- <section class="sqw-value-preview">
25
+ <div class="sqw-detail-card sqw-detail-section">
26
+ <% formatted = SolidStackWeb.allow_value_preview ? format_cache_value(@entry.value) : nil %>
29
27
  <div class="sqw-value-preview__header">
30
28
  <h2 class="sqw-section-title">Value</h2>
31
- <% if SolidStackWeb.allow_value_preview %>
32
- <% formatted = format_cache_value(@entry.value) %>
29
+ <% if formatted %>
33
30
  <span class="sqw-badge sqw-badge--queue"><%= formatted[:label] %></span>
34
31
  <% end %>
35
32
  </div>
36
- <% if SolidStackWeb.allow_value_preview %>
33
+ <% if formatted %>
37
34
  <% content = formatted[:content] %>
38
35
  <% truncated = content.length > 4096 %>
39
- <pre class="sqw-value-pre"><%= truncated ? content[0, 4096] : content %></pre>
36
+ <pre class="sqw-code-block"><%= truncated ? content[0, 4096] : content %></pre>
40
37
  <% if truncated %>
41
38
  <p class="sqw-muted sqw-value-truncated">Showing first 4 KB of <%= number_to_human_size(@entry.byte_size) %> total.</p>
42
39
  <% end %>
@@ -45,4 +42,4 @@
45
42
  <p>Value preview is disabled. Set <code>config.allow_value_preview = true</code> in your initializer to enable.</p>
46
43
  </div>
47
44
  <% end %>
48
- </section>
45
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="sqw-page-header">
2
+ <h1 class="sqw-page-title">Something Went Wrong</h1>
3
+ </div>
4
+
5
+ <div class="sqw-empty">
6
+ <p class="sqw-empty__title">500 &mdash; An unexpected error occurred.</p>
7
+ <p class="sqw-empty__hint"><%= link_to "Back to Dashboard", root_path, class: "sqw-btn sqw-btn--secondary" %></p>
8
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="sqw-page-header">
2
+ <h1 class="sqw-page-title">Not Found</h1>
3
+ </div>
4
+
5
+ <div class="sqw-empty">
6
+ <p class="sqw-empty__title">404 &mdash; The record you&rsquo;re looking for doesn&rsquo;t exist or has been removed.</p>
7
+ <p class="sqw-empty__hint"><%= link_to "Back to Dashboard", root_path, class: "sqw-btn sqw-btn--secondary" %></p>
8
+ </div>
@@ -29,14 +29,14 @@
29
29
  <table class="sqw-table">
30
30
  <thead>
31
31
  <tr>
32
- <th><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
32
+ <th scope="col"><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
33
33
  data-selection-target="selectAll"
34
34
  data-action="change->selection#selectAll"></th>
35
- <th>Job Class</th>
36
- <th>Queue</th>
37
- <th>Error</th>
38
- <th>Failed At</th>
39
- <th></th>
35
+ <th scope="col">Job Class</th>
36
+ <th scope="col">Queue</th>
37
+ <th scope="col">Error</th>
38
+ <th scope="col">Failed At</th>
39
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
40
40
  </tr>
41
41
  </thead>
42
42
  <tbody>
@@ -62,7 +62,7 @@
62
62
  <% end %>
63
63
  </tbody>
64
64
  </table>
65
- <%== @pagy.series_nav if @pagy.pages > 1 %>
65
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
66
66
  </div>
67
67
  <% else %>
68
68
  <div class="sqw-empty">
@@ -45,7 +45,7 @@
45
45
  </div>
46
46
  </div>
47
47
 
48
- <div class="sqw-detail-card sqw-detail-section" style="margin-top: 1.5rem;">
48
+ <div class="sqw-detail-card sqw-detail-section sqw-detail-card--mt">
49
49
  <h2 class="sqw-section-title">Arguments</h2>
50
50
  <%= form_with url: failed_job_arguments_path(@execution), method: :patch do |f| %>
51
51
  <%= f.text_area :arguments, value: @arguments, rows: 12,
@@ -45,10 +45,10 @@
45
45
  <table class="sqw-table">
46
46
  <thead>
47
47
  <tr>
48
- <th>Job Class</th>
49
- <th>Queue</th>
50
- <th>Duration</th>
51
- <th>Finished At</th>
48
+ <th scope="col">Job Class</th>
49
+ <th scope="col">Queue</th>
50
+ <th scope="col">Duration</th>
51
+ <th scope="col">Finished At</th>
52
52
  </tr>
53
53
  </thead>
54
54
  <tbody>
@@ -66,7 +66,7 @@
66
66
  <% end %>
67
67
  </tbody>
68
68
  </table>
69
- <%== @pagy.series_nav if @pagy.pages > 1 %>
69
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
70
70
  </div>
71
71
  <% else %>
72
72
  <div class="sqw-empty">
@@ -96,16 +96,16 @@
96
96
  <thead>
97
97
  <tr>
98
98
  <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
99
- <th><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
99
+ <th scope="col"><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
100
100
  data-selection-target="selectAll"
101
101
  data-action="change->selection#selectAll"></th>
102
102
  <% end %>
103
- <th>Job Class</th>
104
- <th>Queue</th>
105
- <th>Priority</th>
106
- <th>Enqueued At</th>
107
- <% if @status == "scheduled" %><th>Scheduled At</th><% end %>
108
- <th></th>
103
+ <th scope="col">Job Class</th>
104
+ <th scope="col">Queue</th>
105
+ <th scope="col">Priority</th>
106
+ <th scope="col">Enqueued At</th>
107
+ <% if @status == "scheduled" %><th scope="col">Scheduled At</th><% end %>
108
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
109
109
  </tr>
110
110
  </thead>
111
111
  <tbody>
@@ -149,7 +149,7 @@
149
149
  <% end %>
150
150
  </tbody>
151
151
  </table>
152
- <%== @pagy.series_nav if @pagy.pages > 1 %>
152
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
153
153
  </div>
154
154
  <% else %>
155
155
  <%= render "empty" %>
@@ -8,11 +8,11 @@
8
8
  <table class="sqw-table">
9
9
  <thead>
10
10
  <tr>
11
- <th>Kind</th>
12
- <th>Name</th>
13
- <th>PID</th>
14
- <th>Host</th>
15
- <th>Last Heartbeat</th>
11
+ <th scope="col">Kind</th>
12
+ <th scope="col">Name</th>
13
+ <th scope="col">PID</th>
14
+ <th scope="col">Host</th>
15
+ <th scope="col">Last Heartbeat</th>
16
16
  </tr>
17
17
  </thead>
18
18
  <tbody>
@@ -6,11 +6,11 @@
6
6
  <table class="sqw-table">
7
7
  <thead>
8
8
  <tr>
9
- <th>Name</th>
10
- <th>Size</th>
11
- <th>Depth (12h)</th>
12
- <th>Status</th>
13
- <th></th>
9
+ <th scope="col">Name</th>
10
+ <th scope="col">Size</th>
11
+ <th scope="col">Depth (12h)</th>
12
+ <th scope="col">Status</th>
13
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
14
14
  </tr>
15
15
  </thead>
16
16
  <tbody>
@@ -35,10 +35,10 @@
35
35
  <table class="sqw-table">
36
36
  <thead>
37
37
  <tr>
38
- <th>Job Class</th>
39
- <th>Priority</th>
40
- <th>Enqueued At</th>
41
- <th></th>
38
+ <th scope="col">Job Class</th>
39
+ <th scope="col">Priority</th>
40
+ <th scope="col">Enqueued At</th>
41
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
42
42
  </tr>
43
43
  </thead>
44
44
  <tbody>
@@ -59,7 +59,7 @@
59
59
  <% end %>
60
60
  </tbody>
61
61
  </table>
62
- <%== @pagy.series_nav if @pagy.pages > 1 %>
62
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
63
63
  <% else %>
64
64
  <div class="sqw-empty">
65
65
  <p>No ready jobs in <strong><%= @queue_name %></strong>.</p>
@@ -6,14 +6,14 @@
6
6
  <table class="sqw-table">
7
7
  <thead>
8
8
  <tr>
9
- <th>Key</th>
10
- <th>Schedule</th>
11
- <th>Job / Command</th>
12
- <th>Queue</th>
13
- <th>Next Run</th>
14
- <th>Last Run</th>
15
- <th>Type</th>
16
- <th></th>
9
+ <th scope="col">Key</th>
10
+ <th scope="col">Schedule</th>
11
+ <th scope="col">Job / Command</th>
12
+ <th scope="col">Queue</th>
13
+ <th scope="col">Next Run</th>
14
+ <th scope="col">Last Run</th>
15
+ <th scope="col">Type</th>
16
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
17
17
  </tr>
18
18
  </thead>
19
19
  <tbody>
@@ -15,7 +15,7 @@
15
15
  ["min", "Min"],
16
16
  ["max", "Max"]
17
17
  ].each do |col, label| %>
18
- <th>
18
+ <th scope="col" <%= "aria-sort=\"#{@direction == 'asc' ? 'ascending' : 'descending'}\"".html_safe if @sort == col %>>
19
19
  <% next_dir = (@sort == col && @direction == "desc") ? "asc" : "desc" %>
20
20
  <%= link_to stats_path(sort: col, direction: next_dir) do %>
21
21
  <%= label %>
Binary file
@@ -0,0 +1,19 @@
1
+ require "rails/generators/base"
2
+
3
+ module SolidStackWeb
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a SolidStackWeb initializer and mounts the engine in routes.rb"
9
+
10
+ def create_initializer
11
+ template "initializer.rb", "config/initializers/solid_stack_web.rb"
12
+ end
13
+
14
+ def mount_engine
15
+ route 'mount SolidStackWeb::Engine, at: "/solid_stack"'
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ SolidStackWeb.configure do |config|
2
+ # Authentication — block runs in controller context.
3
+ # Return a truthy value to allow access; falsy falls back to HTTP Basic auth.
4
+ # If omitted entirely, the dashboard is open to anyone.
5
+ #
6
+ # config.authenticate do
7
+ # current_user&.admin?
8
+ # end
9
+
10
+ # Number of records shown per paginated page (default: 25).
11
+ # config.page_size = 25
12
+
13
+ # Database connection — pass a connects_to hash when Solid Queue / Cache / Cable
14
+ # live on a separate database from your primary.
15
+ #
16
+ # config.connects_to = { database: { writing: :queue, reading: :queue } }
17
+
18
+ # Slow-job threshold in seconds (default: nil — stat hidden).
19
+ # When set, the dashboard shows a "Slow (24h)" count on the overview card
20
+ # for finished jobs whose wall time exceeded this value.
21
+ # config.slow_job_threshold = 30
22
+
23
+ # Auto-refresh intervals in milliseconds.
24
+ # config.dashboard_refresh_interval = 5_000 # overview dashboard
25
+ # config.default_refresh_interval = 10_000 # jobs, processes, history
26
+
27
+ # Maximum number of results returned by the search feature (default: 25).
28
+ # config.search_results_limit = 25
29
+
30
+ # Show the raw serialised value on the cache entry detail page (default: false).
31
+ # Disable for stores that contain sensitive data.
32
+ # config.allow_value_preview = false
33
+
34
+ # Link to the dashboard from anywhere in your app without hardcoding the path:
35
+ #
36
+ # link_to "Queue Dashboard", SolidStackWeb.mount_path
37
+ #
38
+ # SolidStackWeb.mount_path is derived automatically from your routes — no
39
+ # configuration needed.
40
+
41
+ # Alert webhook — POST to this URL when a threshold is breached.
42
+ # Delivery failures are silently swallowed; configure a cooldown to avoid storms.
43
+ #
44
+ # config.alert_webhook_url = "https://hooks.example.com/my-alert"
45
+ # config.alert_failure_threshold = 10 # fire when failed jobs >= this
46
+ # config.alert_queue_thresholds = { # fire when a queue's ready depth >= value
47
+ # "critical" => 50,
48
+ # "default" => 500
49
+ # }
50
+ # config.alert_webhook_cooldown = 3600 # seconds between repeat alerts
51
+ end
@@ -25,6 +25,10 @@ module SolidStackWeb
25
25
  end
26
26
  end
27
27
 
28
+ initializer "solid_stack_web.deprecator" do |app|
29
+ app.deprecators[:solid_stack_web] = SolidStackWeb.deprecator
30
+ end
31
+
28
32
  initializer "solid_stack_web.pagy" do |app|
29
33
  app.config.after_initialize do
30
34
  Pagy::OPTIONS[:limit] = SolidStackWeb.page_size
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "0.8.0"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -53,6 +53,19 @@ module SolidStackWeb
53
53
  @allow_value_preview || false
54
54
  end
55
55
 
56
+ # Returns the path at which the engine is mounted in the host application,
57
+ # derived automatically from the host's routes. Host apps can use this to
58
+ # build links to the dashboard without hardcoding the mount path.
59
+ #
60
+ # link_to "Dashboard", SolidStackWeb.mount_path
61
+ #
62
+ def mount_path
63
+ route = Rails.application.routes.routes.find do |r|
64
+ r.app.respond_to?(:app) && r.app.app == SolidStackWeb::Engine
65
+ end
66
+ route&.path&.spec&.to_s&.sub(%r{\(.*\)\z}, "") || "/"
67
+ end
68
+
56
69
  def configure
57
70
  yield self
58
71
  end
@@ -61,5 +74,27 @@ module SolidStackWeb
61
74
  @authenticate = block if block_given?
62
75
  @authenticate
63
76
  end
77
+
78
+ def deprecator
79
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "SolidStackWeb")
80
+ end
81
+
82
+ private
83
+
84
+ # Define a deprecated writer for a renamed config key.
85
+ # The old writer issues a deprecation warning and forwards to the new key.
86
+ #
87
+ # Usage (add entries here as keys are renamed before 1.0):
88
+ # deprecated_config :old_key, :new_key
89
+ def deprecated_config(old_key, new_key)
90
+ singleton_class.define_method(:"#{old_key}=") do |value|
91
+ deprecator.warn(
92
+ "config.#{old_key}= is deprecated and will be removed in SolidStackWeb 1.0. " \
93
+ "Use config.#{new_key}= instead.",
94
+ caller_locations
95
+ )
96
+ public_send(:"#{new_key}=", value)
97
+ end
98
+ end
64
99
  end
65
100
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_stack_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -121,9 +121,14 @@ dependencies:
121
121
  - - ">="
122
122
  - !ruby/object:Gem::Version
123
123
  version: '3.0'
124
- description: Mount SolidStackWeb in any Rails app using the Solid Stack to get a single
125
- dashboard covering Solid Queue job monitoring, Solid Cache statistics, and Solid
126
- Cable connection observability — all without leaving your app.
124
+ description: SolidStackWeb is a mountable Rails engine that provides a production-ready
125
+ operations dashboard for the full Solid Stack. It covers Solid Queue (job browser,
126
+ failed job retry with inline argument editing, queue pause/resume, recurring tasks,
127
+ performance stats, CSV export, and alert webhooks), Solid Cache (entry browser,
128
+ size distribution, 24-hour write timeline, and optional value preview), and Solid
129
+ Cable (channel browser, per-channel message list, and purge controls). Ships with
130
+ dark mode, Turbo Stream responses, a JSON metrics endpoint, and no asset pipeline
131
+ dependency.
127
132
  email:
128
133
  - eclectic-coding@users.noreply.github.com
129
134
  executables: []
@@ -193,6 +198,8 @@ files:
193
198
  - app/views/solid_stack_web/cache_entries/index.html.erb
194
199
  - app/views/solid_stack_web/cache_entries/show.html.erb
195
200
  - app/views/solid_stack_web/dashboard/index.html.erb
201
+ - app/views/solid_stack_web/errors/internal_server_error.html.erb
202
+ - app/views/solid_stack_web/errors/not_found.html.erb
196
203
  - app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
197
204
  - app/views/solid_stack_web/failed_jobs/index.html.erb
198
205
  - app/views/solid_stack_web/failed_jobs/show.html.erb
@@ -210,6 +217,9 @@ files:
210
217
  - app/views/solid_stack_web/stats/index.html.erb
211
218
  - config/importmap.rb
212
219
  - config/routes.rb
220
+ - docs/screenshots/demo.gif
221
+ - lib/generators/solid_stack_web/install/install_generator.rb
222
+ - lib/generators/solid_stack_web/install/templates/initializer.rb
213
223
  - lib/solid_stack_web.rb
214
224
  - lib/solid_stack_web/engine.rb
215
225
  - lib/solid_stack_web/version.rb