solid_stack_web 1.0.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: e7932ddf2c50cc9ae63a884c0dabdc0fe4553c9dfb823870d3e1b15b4dc2a3a6
4
- data.tar.gz: 04d6d15b941d21dfae0d1562095e6cdb4cd67a7e1163efa3bc2d33563df2fb92
3
+ metadata.gz: 1717d75a5baabd8728f6f9877b5146849be9322a627cf295bd922690aa284592
4
+ data.tar.gz: ae814ccee9aa89798575f914652ccfbb51137473961d7fd1a22b7a4461449102
5
5
  SHA512:
6
- metadata.gz: 37ac38026dbde5e629d132f18795797f84a7b48b8e19689435b0288f44c57abf73e7dae5aa9e51884021920b4b336160597529b4f87c44cac027e9b88c1293d6
7
- data.tar.gz: c233686b589092befdcc5b19ca7bfd09a2f10e9f1d95b894f58afff4fb67475ddcae308bf6b65386f9af5479d670256dde1cd02cf3cf2b5466eadcd00a051a2d
6
+ metadata.gz: e0d79705beafccb16561d20a28cac815607f2a202bc6d0839449c501f8d4f5bd93bd83ccb4a7c4be0d6b701ecb69b9aca8fa5ee2a3d4462712fc8399c935162a
7
+ data.tar.gz: 2663a57cf04ca3aa26463732638020c63ce76d10046979dbd22baae8f5b529d3092e3597aa9c4a8b9b06b0a43b9a648e0a573349bca1f39d6f944fe4a8313884
data/README.md CHANGED
@@ -21,23 +21,13 @@ 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).
41
31
 
42
32
  ---
43
33
 
@@ -77,7 +67,7 @@ This creates `config/initializers/solid_stack_web.rb` with every configuration o
77
67
 
78
68
  ## General configuration
79
69
 
80
- 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:
81
71
 
82
72
  ```ruby
83
73
  SolidStackWeb.configure do |config|
@@ -152,16 +142,17 @@ The dashboard is designed to be mounted behind your application's existing authe
152
142
 
153
143
  ### Features
154
144
 
155
- - **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
156
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
157
147
  - **Bulk selection** — checkbox-select individual jobs for discard; select-all support
158
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
159
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
160
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
161
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
162
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
163
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
164
- - **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
165
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
166
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`
167
158
  - **Turbo Stream** job discard — removes the row inline without a full page reload
@@ -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;
@@ -84,6 +84,29 @@
84
84
 
85
85
  .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
86
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
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
98
+ font-size: 11px;
99
+ background: var(--bg);
100
+ border: 1px solid var(--border);
101
+ border-radius: var(--radius);
102
+ padding: 0.5rem 0.75rem;
103
+ overflow-x: auto;
104
+ white-space: pre;
105
+ max-height: 200px;
106
+ overflow-y: auto;
107
+ margin-top: 0.25rem;
108
+ }
109
+
87
110
  .sqw-link { color: var(--primary); text-decoration: none; }
88
111
  .sqw-link:hover { text-decoration: underline; }
89
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
@@ -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>
@@ -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"
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -160,6 +160,7 @@ files:
160
160
  - app/controllers/solid_stack_web/cache_entries_controller.rb
161
161
  - app/controllers/solid_stack_web/dashboard_controller.rb
162
162
  - app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb
163
+ - app/controllers/solid_stack_web/failed_jobs/errors_controller.rb
163
164
  - app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
164
165
  - app/controllers/solid_stack_web/failed_jobs_controller.rb
165
166
  - app/controllers/solid_stack_web/history_controller.rb
@@ -187,6 +188,8 @@ files:
187
188
  - app/models/solid_stack_web/cache_size_stats.rb
188
189
  - app/models/solid_stack_web/cache_stats.rb
189
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
190
193
  - app/models/solid_stack_web/job.rb
191
194
  - app/models/solid_stack_web/queue_depth_sparkline.rb
192
195
  - app/models/solid_stack_web/queue_stats.rb
@@ -201,6 +204,7 @@ files:
201
204
  - app/views/solid_stack_web/errors/internal_server_error.html.erb
202
205
  - app/views/solid_stack_web/errors/not_found.html.erb
203
206
  - app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
207
+ - app/views/solid_stack_web/failed_jobs/errors/index.html.erb
204
208
  - app/views/solid_stack_web/failed_jobs/index.html.erb
205
209
  - app/views/solid_stack_web/failed_jobs/show.html.erb
206
210
  - app/views/solid_stack_web/history/index.html.erb