solid_stack_web 0.9.0 → 1.1.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: 85093a248316f7f8921636f68bdc08e2053e496eeb85f17bbbd7cddac57e4ed6
4
- data.tar.gz: 3321fa62f08ce3615f733740cfac444d60ed65960b9adc9e4894919416482b46
3
+ metadata.gz: 1717d75a5baabd8728f6f9877b5146849be9322a627cf295bd922690aa284592
4
+ data.tar.gz: ae814ccee9aa89798575f914652ccfbb51137473961d7fd1a22b7a4461449102
5
5
  SHA512:
6
- metadata.gz: 278a4dbde1baa31bb57f58c72d41ea13ef9ddbb83043839228abb4cb0ab06286c4ca954d63ee0c2f2e6914a1b8c24f946731b73afe8db1176ba28a8c157c6b52
7
- data.tar.gz: f6b953b896ba256d03e26f802ab594d9e822fbd114ce68f6b7f673d435fceca8009a9ec2c55ea0f45a6dacfcebb34b9b9c23c64564faa212e00a8450292d95c4
6
+ metadata.gz: e0d79705beafccb16561d20a28cac815607f2a202bc6d0839449c501f8d4f5bd93bd83ccb4a7c4be0d6b701ecb69b9aca8fa5ee2a3d4462712fc8399c935162a
7
+ data.tar.gz: 2663a57cf04ca3aa26463732638020c63ce76d10046979dbd22baae8f5b529d3092e3597aa9c4a8b9b06b0a43b9a648e0a573349bca1f39d6f944fe4a8313884
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
 
@@ -21,23 +21,19 @@ Run:
21
21
  bundle install
22
22
  ```
23
23
 
24
- Mount the engine in `config/routes.rb`:
25
-
26
- ```ruby
27
- mount SolidStackWeb::Engine, at: "/solid_stack"
28
- ```
29
-
30
- The dashboard will be available at `/solid_stack` (or whatever path you choose).
31
-
32
- ### Install generator
33
-
34
24
  Run the install generator to create a documented initializer and wire up the mount point in one step:
35
25
 
36
26
  ```bash
37
27
  rails generate solid_stack_web:install
38
28
  ```
39
29
 
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`.
30
+ 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`. The dashboard will then be available at `/solid_stack` (or whatever path you choose).
31
+
32
+ ---
33
+
34
+ ## Screenshots
35
+
36
+ ![SolidStackWeb dashboard](docs/screenshots/demo.gif)
41
37
 
42
38
  ---
43
39
 
@@ -71,7 +67,7 @@ This creates `config/initializers/solid_stack_web.rb` with every configuration o
71
67
 
72
68
  ## General configuration
73
69
 
74
- Create an initializer at `config/initializers/solid_stack_web.rb`:
70
+ The install generator creates `config/initializers/solid_stack_web.rb` with all options documented inline. The available options are:
75
71
 
76
72
  ```ruby
77
73
  SolidStackWeb.configure do |config|
@@ -83,6 +79,10 @@ SolidStackWeb.configure do |config|
83
79
  config.authenticate do
84
80
  current_user&.admin?
85
81
  end
82
+
83
+ # Multi-database — pass a connects_to hash when Solid Queue / Cache / Cable
84
+ # live on a separate database from your primary (default: nil, uses primary).
85
+ config.connects_to = { database: { writing: :queue, reading: :queue } }
86
86
  end
87
87
  ```
88
88
 
@@ -100,20 +100,59 @@ link_to "Queue Dashboard", SolidStackWeb.mount_path
100
100
 
101
101
  ---
102
102
 
103
+ ## Security
104
+
105
+ ### Authentication
106
+
107
+ **The dashboard is open to all visitors by default.** Any production deployment must configure an `authenticate` block or the dashboard will be publicly accessible.
108
+
109
+ ```ruby
110
+ SolidStackWeb.configure do |config|
111
+ # Devise
112
+ config.authenticate { current_user&.admin? }
113
+
114
+ # HTTP Basic fallback (used when no authenticate block is set, or when
115
+ # the block returns false/nil and you want a browser credential prompt)
116
+ # Configure via HTTP_BASIC_AUTH_NAME / HTTP_BASIC_AUTH_PASSWORD env vars
117
+ # in your host app, or use a reverse proxy.
118
+ end
119
+ ```
120
+
121
+ 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.
122
+
123
+ ### Sensitive cache values
124
+
125
+ `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.
126
+
127
+ ### CSRF protection
128
+
129
+ 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`.
130
+
131
+ ### Rate limiting and network exposure
132
+
133
+ The dashboard is designed to be mounted behind your application's existing authentication. For additional hardening, consider:
134
+
135
+ - Mounting at a non-guessable path (e.g. `at: "/ops/#{Rails.application.credentials.dashboard_token}"`)
136
+ - Restricting access by IP at the reverse-proxy level
137
+ - Applying [Rack::Attack](https://github.com/rack/rack-attack) rules to the mount path
138
+
139
+ ---
140
+
103
141
  ## Solid Queue
104
142
 
105
143
  ### Features
106
144
 
107
- - **Overview dashboard** — live counts across all queue statuses; done (1h/24h), healthy/stale process counts, and optionally slow jobs (when `slow_job_threshold` is configured); 12-hour throughput sparkline with per-bar hover tooltips
145
+ - **Overview dashboard** — live counts across all queue statuses; done (1h/24h), healthy/stale process counts, and optionally slow jobs (when `slow_job_threshold` is configured); 12-hour throughput sparkline and a 12-hour failures sparkline (red bars) with per-bar hover tooltips — failure spikes visible before clicking into the failed jobs list
108
146
  - **Job browser** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs respecting active filters
109
147
  - **Bulk selection** — checkbox-select individual jobs for discard; select-all support
110
148
  - **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard; pause/resume controls on the queue page
111
149
  - **Queue depth sparklines** — Queues index shows a 12-hour depth chart per queue; each bar is the ready-job count at an hourly snapshot with an instant hover tooltip
112
150
  - **Job detail page** — full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
113
151
  - **Failed jobs** — list with retry / discard / bulk retry / bulk discard; **Failed job detail page** — full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action
152
+ - **Error frequency report** — `GET /failed_jobs/errors` groups all failed jobs by exception class and message prefix with a count and expandable sample backtrace; links through to a filtered list for each error group
114
153
  - **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" back-dates all matching executions at once
115
154
  - **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button
116
- - **Performance statistics page** — `GET /stats` aggregates finished jobs by class name with execution count, avg, p50, p95, min, and max duration; click any column header to sort; defaults to p95 descending
155
+ - **Performance statistics page** — `GET /stats` aggregates finished jobs by class name with execution count, avg, p50, p95, p99, std dev, min, and max duration; click any column header to sort; defaults to p95 descending; high std dev flags inconsistent jobs worth investigating
117
156
  - **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
118
157
  - **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval`
119
158
  - **Turbo Stream** job discard — removes the row inline without a full page reload
@@ -207,6 +246,36 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru
207
246
  - [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0
208
247
  - [importmap-rails](https://github.com/rails/importmap-rails) >= 1.2
209
248
 
249
+ ## Versioning
250
+
251
+ SolidStackWeb follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
252
+
253
+ ### Public API
254
+
255
+ The following are considered stable public API from v1.0.0 onwards — breaking changes to any of these require a major version bump:
256
+
257
+ - The `SolidStackWeb.configure` block and all documented configuration keys
258
+ - The `SolidStackWeb.mount_path` helper
259
+ - The `authenticate` block interface
260
+ - The `GET /metrics` JSON payload shape
261
+ - The `SolidStackWeb::Engine` class and its mount interface
262
+ - The `rails generate solid_stack_web:install` generator
263
+
264
+ ### Not part of the public API
265
+
266
+ The following are internal and may change in any release without notice:
267
+
268
+ - Internal service classes (`CacheStats`, `QueueStats`, etc.)
269
+ - View templates, partial names, and CSS class names
270
+ - Controller and helper internals
271
+ - Private methods on any class
272
+
273
+ ### Deprecation policy
274
+
275
+ 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.
276
+
277
+ ---
278
+
210
279
  ## Contributing
211
280
 
212
281
  1. Fork the repository
@@ -86,6 +86,10 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
86
86
  color: var(--primary);
87
87
  }
88
88
 
89
+ .sqw-sparkline-wrap--failures {
90
+ color: var(--danger);
91
+ }
92
+
89
93
  .sqw-sparkline-label {
90
94
  display: block;
91
95
  font-size: 10px;
@@ -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,22 +82,31 @@
93
82
  }
94
83
  .sqw-value-preview__header .sqw-section-title { margin-bottom: 0; }
95
84
 
96
- .sqw-value-pre {
85
+ .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
86
+
87
+ .sqw-error-details > summary {
88
+ cursor: pointer;
89
+ list-style: none;
90
+ display: block;
91
+ max-width: 480px;
92
+ }
93
+ .sqw-error-details > summary::-webkit-details-marker { display: none; }
94
+ .sqw-error-details[open] > summary { margin-bottom: 0.5rem; }
95
+
96
+ .sqw-error-backtrace {
97
97
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
98
- font-size: 12px;
98
+ font-size: 11px;
99
99
  background: var(--bg);
100
100
  border: 1px solid var(--border);
101
101
  border-radius: var(--radius);
102
- padding: 0.75rem;
102
+ padding: 0.5rem 0.75rem;
103
103
  overflow-x: auto;
104
- white-space: pre-wrap;
105
- word-break: break-word;
106
- max-height: 500px;
104
+ white-space: pre;
105
+ max-height: 200px;
107
106
  overflow-y: auto;
107
+ margin-top: 0.25rem;
108
108
  }
109
109
 
110
- .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
111
-
112
110
  .sqw-link { color: var(--primary); text-decoration: none; }
113
111
  .sqw-link:hover { text-decoration: underline; }
114
112
 
@@ -5,6 +5,7 @@ module SolidStackWeb
5
5
  @cache_stats = CacheStats.new.to_h
6
6
  @cable_stats = CableStats.new.to_h
7
7
  @throughput = ThroughputSparkline.new
8
+ @failures = FailedJobSparkline.new
8
9
  end
9
10
  end
10
11
  end
@@ -0,0 +1,9 @@
1
+ module SolidStackWeb
2
+ module FailedJobs
3
+ class ErrorsController < ApplicationController
4
+ def index
5
+ @groups = ErrorFrequencyReport.new.groups
6
+ end
7
+ end
8
+ end
9
+ end
@@ -4,6 +4,8 @@ module SolidStackWeb
4
4
  respond_to do |format|
5
5
  format.html do
6
6
  scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
7
+ @error_class = params[:error_class].presence
8
+ scope = scope.where(id: ids_for_error_class(@error_class)) if @error_class
7
9
  @pagy, @executions = pagy(scope)
8
10
  end
9
11
  format.csv do
@@ -41,6 +43,15 @@ module SolidStackWeb
41
43
 
42
44
  private
43
45
 
46
+ def ids_for_error_class(ec)
47
+ ::SolidQueue::FailedExecution.pluck(:id, :error).filter_map do |id, raw|
48
+ error = raw.is_a?(Hash) ? raw : JSON.parse(raw)
49
+ id if error["exception_class"] == ec
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+
44
55
  def failed_jobs_csv
45
56
  CSV.generate(headers: true) do |csv|
46
57
  csv << %w[id class_name queue_name error_class error_message failed_at]
@@ -1,6 +1,6 @@
1
1
  module SolidStackWeb
2
2
  class StatsController < ApplicationController
3
- SORTABLE_COLUMNS = %w[class_name count avg p50 p95 min max].freeze
3
+ SORTABLE_COLUMNS = %w[class_name count avg p50 p95 p99 stddev min max].freeze
4
4
 
5
5
  def index
6
6
  @sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "p95"
@@ -18,14 +18,17 @@ module SolidStackWeb
18
18
  jobs.group_by(&:class_name).map do |class_name, group|
19
19
  durations = group.map { |j| (j.finished_at - j.created_at).to_f }.sort
20
20
  count = durations.size
21
+ avg = durations.sum / count
21
22
  {
22
23
  class_name: class_name,
23
24
  count: count,
24
- avg: durations.sum / count,
25
+ avg: avg,
25
26
  min: durations.first,
26
27
  max: durations.last,
27
28
  p50: percentile(durations, 50),
28
- p95: percentile(durations, 95)
29
+ p95: percentile(durations, 95),
30
+ p99: percentile(durations, 99),
31
+ stddev: stddev(durations, avg)
29
32
  }
30
33
  end
31
34
  end
@@ -35,5 +38,11 @@ module SolidStackWeb
35
38
  k = (sorted.size - 1) * pct / 100.0
36
39
  sorted[k.floor] + (sorted[k.ceil] - sorted[k.floor]) * (k - k.floor)
37
40
  end
41
+
42
+ def stddev(durations, avg)
43
+ return 0.0 if durations.size < 2
44
+ variance = durations.sum { |d| (d - avg)**2 } / durations.size
45
+ Math.sqrt(variance)
46
+ end
38
47
  end
39
48
  end
@@ -87,6 +87,17 @@ module SolidStackWeb
87
87
  end
88
88
  end
89
89
 
90
+ def failed_job_sparkline_svg(sparkline)
91
+ build_sparkline_svg(sparkline, aria_label: "Failed jobs over the last 12 hours") do |count, i|
92
+ hours_ago = SolidStackWeb::FailedJobSparkline::HOURS - i
93
+ if hours_ago == 1
94
+ "#{count} #{count == 1 ? "failure" : "failures"} in the last hour"
95
+ else
96
+ "#{count} #{count == 1 ? "failure" : "failures"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
97
+ end
98
+ end
99
+ end
100
+
90
101
  private
91
102
 
92
103
  def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
@@ -0,0 +1,34 @@
1
+ module SolidStackWeb
2
+ class ErrorFrequencyReport
3
+ Row = Data.define(:exception_class, :message_prefix, :count, :sample_backtrace)
4
+
5
+ MESSAGE_LIMIT = 120
6
+
7
+ def groups
8
+ ::SolidQueue::FailedExecution
9
+ .order(created_at: :desc)
10
+ .each_with_object({}) do |execution, acc|
11
+ key = [execution.exception_class.to_s, message_prefix(execution.message)]
12
+ entry = acc[key] ||= { count: 0, sample_backtrace: nil }
13
+ entry[:count] += 1
14
+ entry[:sample_backtrace] ||= execution.backtrace
15
+ end
16
+ .map do |(exception_class, prefix), data|
17
+ Row.new(
18
+ exception_class: exception_class,
19
+ message_prefix: prefix,
20
+ count: data[:count],
21
+ sample_backtrace: data[:sample_backtrace]
22
+ )
23
+ end
24
+ .sort_by { |row| -row.count }
25
+ end
26
+
27
+ private
28
+
29
+ def message_prefix(message)
30
+ return "" if message.nil?
31
+ message.length > MESSAGE_LIMIT ? "#{message[0, MESSAGE_LIMIT]}…" : message
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ module SolidStackWeb
2
+ class FailedJobSparkline
3
+ HOURS = 12
4
+
5
+ def buckets
6
+ @buckets ||= begin
7
+ now = Time.current
8
+ origin = now - HOURS.hours
9
+ times = ::SolidQueue::FailedExecution.where(created_at: origin..now).pluck(:created_at)
10
+
11
+ HOURS.times.map do |i|
12
+ from = origin + i.hours
13
+ to = origin + (i + 1).hours
14
+ times.count { |t| t >= from && t < to }
15
+ end
16
+ end
17
+ end
18
+
19
+ def max
20
+ buckets.max || 0
21
+ end
22
+ end
23
+ end
@@ -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>
@@ -68,6 +68,18 @@
68
68
  <span>now</span>
69
69
  </div>
70
70
  </div>
71
+ <div class="sqw-sparkline-wrap sqw-sparkline-wrap--failures" data-controller="sparkline-tooltip">
72
+ <span class="sqw-sparkline-label">Failures — last 12 hours</span>
73
+ <div class="sqw-sparkline-positioner">
74
+ <%= failed_job_sparkline_svg(@failures) %>
75
+ <div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
76
+ </div>
77
+ <div class="sqw-sparkline-axis">
78
+ <span>12h ago</span>
79
+ <span>6h ago</span>
80
+ <span>now</span>
81
+ </div>
82
+ </div>
71
83
  </div>
72
84
 
73
85
  <div class="sqw-gem-card sqw-gem-card--cache">
@@ -0,0 +1,48 @@
1
+ <div class="sqw-page-header sqw-page-header--split">
2
+ <h1 class="sqw-page-title">Error Summary</h1>
3
+ <div class="sqw-header-actions">
4
+ <%= link_to "← Failed Jobs", failed_jobs_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
5
+ </div>
6
+ </div>
7
+
8
+ <% if @groups.any? %>
9
+ <table class="sqw-table">
10
+ <thead>
11
+ <tr>
12
+ <th scope="col">Error Class</th>
13
+ <th scope="col">Message</th>
14
+ <th scope="col">Count</th>
15
+ <th scope="col"><span class="sqw-sr-only">Actions</span></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @groups.each do |group| %>
20
+ <tr>
21
+ <td class="sqw-monospace"><%= group.exception_class.presence || "—" %></td>
22
+ <td>
23
+ <% if group.sample_backtrace.present? %>
24
+ <details class="sqw-error-details">
25
+ <summary class="sqw-muted sqw-truncate" title="<%= group.message_prefix %>">
26
+ <%= group.message_prefix.presence || "—" %>
27
+ </summary>
28
+ <pre class="sqw-error-backtrace"><%= Array(group.sample_backtrace).first(10).join("\n") %></pre>
29
+ </details>
30
+ <% else %>
31
+ <span class="sqw-muted sqw-truncate" title="<%= group.message_prefix %>"><%= group.message_prefix.presence || "—" %></span>
32
+ <% end %>
33
+ </td>
34
+ <td><%= group.count %></td>
35
+ <td class="sqw-actions">
36
+ <%= link_to "View Jobs", failed_jobs_path(error_class: group.exception_class),
37
+ class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
38
+ </td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+ <% else %>
44
+ <div class="sqw-empty">
45
+ <p class="sqw-empty__title">No failed jobs</p>
46
+ <p class="sqw-empty__hint">All clear — your jobs are running without errors.</p>
47
+ </div>
48
+ <% end %>
@@ -1,6 +1,15 @@
1
1
  <div class="sqw-page-header sqw-page-header--split">
2
- <h1 class="sqw-page-title">Failed Jobs</h1>
2
+ <div>
3
+ <h1 class="sqw-page-title">Failed Jobs</h1>
4
+ <% if @error_class %>
5
+ <div class="sqw-breadcrumb">
6
+ Filtered by <span class="sqw-monospace"><%= @error_class %></span>
7
+ — <%= link_to "Clear filter", failed_jobs_path %>
8
+ </div>
9
+ <% end %>
10
+ </div>
3
11
  <div class="sqw-header-actions">
12
+ <%= link_to "Error Summary", failed_job_errors_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
4
13
  <%= link_to "Export CSV", failed_jobs_path(format: :csv),
5
14
  class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
6
15
  </div>
@@ -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,
@@ -12,6 +12,8 @@
12
12
  ["avg", "Avg"],
13
13
  ["p50", "p50"],
14
14
  ["p95", "p95"],
15
+ ["p99", "p99"],
16
+ ["stddev", "Std Dev"],
15
17
  ["min", "Min"],
16
18
  ["max", "Max"]
17
19
  ].each do |col, label| %>
@@ -35,6 +37,8 @@
35
37
  <td class="sqw-muted"><%= format_duration(row[:avg]) %></td>
36
38
  <td class="sqw-muted"><%= format_duration(row[:p50]) %></td>
37
39
  <td><strong><%= format_duration(row[:p95]) %></strong></td>
40
+ <td class="sqw-muted"><%= format_duration(row[:p99]) %></td>
41
+ <td class="sqw-muted"><%= format_duration(row[:stddev]) %></td>
38
42
  <td class="sqw-muted"><%= format_duration(row[:min]) %></td>
39
43
  <td class="sqw-muted"><%= format_duration(row[:max]) %></td>
40
44
  </tr>
data/config/routes.rb CHANGED
@@ -20,6 +20,8 @@ SolidStackWeb::Engine.routes.draw do
20
20
  end
21
21
  end
22
22
 
23
+ get "failed_jobs/errors", to: "failed_jobs/errors#index", as: :failed_job_errors
24
+
23
25
  resources :failed_jobs, only: [:index, :show, :destroy] do
24
26
  member { post :retry }
25
27
  resource :arguments, only: [:update], controller: "failed_jobs/arguments"
Binary file
@@ -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.9.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -74,5 +74,27 @@ module SolidStackWeb
74
74
  @authenticate = block if block_given?
75
75
  @authenticate
76
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
77
99
  end
78
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.9.0
4
+ version: 1.1.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: []
@@ -155,6 +160,7 @@ files:
155
160
  - app/controllers/solid_stack_web/cache_entries_controller.rb
156
161
  - app/controllers/solid_stack_web/dashboard_controller.rb
157
162
  - app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb
163
+ - app/controllers/solid_stack_web/failed_jobs/errors_controller.rb
158
164
  - app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
159
165
  - app/controllers/solid_stack_web/failed_jobs_controller.rb
160
166
  - app/controllers/solid_stack_web/history_controller.rb
@@ -182,6 +188,8 @@ files:
182
188
  - app/models/solid_stack_web/cache_size_stats.rb
183
189
  - app/models/solid_stack_web/cache_stats.rb
184
190
  - app/models/solid_stack_web/cache_timeline.rb
191
+ - app/models/solid_stack_web/error_frequency_report.rb
192
+ - app/models/solid_stack_web/failed_job_sparkline.rb
185
193
  - app/models/solid_stack_web/job.rb
186
194
  - app/models/solid_stack_web/queue_depth_sparkline.rb
187
195
  - app/models/solid_stack_web/queue_stats.rb
@@ -196,6 +204,7 @@ files:
196
204
  - app/views/solid_stack_web/errors/internal_server_error.html.erb
197
205
  - app/views/solid_stack_web/errors/not_found.html.erb
198
206
  - app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
207
+ - app/views/solid_stack_web/failed_jobs/errors/index.html.erb
199
208
  - app/views/solid_stack_web/failed_jobs/index.html.erb
200
209
  - app/views/solid_stack_web/failed_jobs/show.html.erb
201
210
  - app/views/solid_stack_web/history/index.html.erb
@@ -212,6 +221,7 @@ files:
212
221
  - app/views/solid_stack_web/stats/index.html.erb
213
222
  - config/importmap.rb
214
223
  - config/routes.rb
224
+ - docs/screenshots/demo.gif
215
225
  - lib/generators/solid_stack_web/install/install_generator.rb
216
226
  - lib/generators/solid_stack_web/install/templates/initializer.rb
217
227
  - lib/solid_stack_web.rb