solid_stack_web 1.0.0 → 1.2.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: f3dbb76a6507f2e2a0480bafe3b9e18ac8f34777563c853911dfaf7b7f549f23
4
+ data.tar.gz: 49f69c487ad4580d704ce122e46024ba96ff05c0c4b25166d64148128a28949d
5
5
  SHA512:
6
- metadata.gz: 37ac38026dbde5e629d132f18795797f84a7b48b8e19689435b0288f44c57abf73e7dae5aa9e51884021920b4b336160597529b4f87c44cac027e9b88c1293d6
7
- data.tar.gz: c233686b589092befdcc5b19ca7bfd09a2f10e9f1d95b894f58afff4fb67475ddcae308bf6b65386f9af5479d670256dde1cd02cf3cf2b5466eadcd00a051a2d
6
+ metadata.gz: aa1598c4f1ee1a527ccc5b50240e5c866274e848ad2e7245dfe02ef6f05a6b1b889a7eb88ce80cad46f72f1b7f533a858b47cb1a3d130fd300c8bb32698d9842
7
+ data.tar.gz: 752a9f8176b029fa54f7c0dcef93081d4a6b25bc75dccebab67ae7561854ff3c5d1f9ae96804ac290841afbc38630b7329d0472b45fbaf7cbf95d3f342797b0b
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,19 +142,21 @@ 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
156
- - **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
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
146
+ - **Job browser** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; sortable by class, queue, priority, and enqueued-at; sort state is preserved across filter and period changes; **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
- - **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
151
+ - **Failed jobs** — list with retry / discard / bulk retry / bulk discard; sortable by class, queue, and failed-at; **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
165
- - **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
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
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; sortable by class, queue, and finished-at; 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
159
+ - **Sticky filter preferences** — last-used status, period, and queue filter saved to `localStorage`; a fresh visit to the jobs or history list with no URL params automatically restores the previous selection
168
160
  - **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
169
161
  - **Responsive layout** — stats cards, tables, and two-column grids adapt to narrow viewports; tables scroll horizontally rather than overflow; split page headers stack on small screens
170
162
  - **Empty-state improvements** — all list views show a contextual title and an actionable hint; search empty states include a "Clear search" link; filters-active history view offers "Clear filters"; processes and recurring tasks explain the next step
@@ -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
@@ -1,9 +1,14 @@
1
1
  module SolidStackWeb
2
2
  class FailedJobsController < ApplicationController
3
3
  def index
4
+ @sort = params[:sort].presence_in(sortable_columns) || "created_at"
5
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
6
+
4
7
  respond_to do |format|
5
8
  format.html do
6
- scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
9
+ scope = ::SolidQueue::FailedExecution.includes(:job).references(:job).order(sort_expression)
10
+ @error_class = params[:error_class].presence
11
+ scope = scope.where(id: ids_for_error_class(@error_class)) if @error_class
7
12
  @pagy, @executions = pagy(scope)
8
13
  end
9
14
  format.csv do
@@ -41,6 +46,28 @@ module SolidStackWeb
41
46
 
42
47
  private
43
48
 
49
+ def sortable_columns
50
+ %w[class_name queue_name created_at]
51
+ end
52
+
53
+ def sort_expression
54
+ sql_col = case @sort
55
+ when "class_name" then "solid_queue_jobs.class_name"
56
+ when "queue_name" then "solid_queue_jobs.queue_name"
57
+ else "solid_queue_failed_executions.created_at"
58
+ end
59
+ Arel.sql("#{sql_col} #{@direction == 'asc' ? 'ASC' : 'DESC'}")
60
+ end
61
+
62
+ def ids_for_error_class(ec)
63
+ ::SolidQueue::FailedExecution.pluck(:id, :error).filter_map do |id, raw|
64
+ error = raw.is_a?(Hash) ? raw : JSON.parse(raw)
65
+ id if error["exception_class"] == ec
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+
44
71
  def failed_jobs_csv
45
72
  CSV.generate(headers: true) do |csv|
46
73
  csv << %w[id class_name queue_name error_class error_message failed_at]
@@ -16,13 +16,23 @@ module SolidStackWeb
16
16
  private
17
17
 
18
18
  def set_filters
19
- @queue = params[:queue].presence
20
- @search = params[:q].presence
21
- @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
19
+ @queue = params[:queue].presence
20
+ @search = params[:q].presence
21
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
22
+ @sort = params[:sort].presence_in(sortable_columns) || "finished_at"
23
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
24
+ end
25
+
26
+ def sortable_columns
27
+ %w[class_name queue_name finished_at]
28
+ end
29
+
30
+ def sort_expression
31
+ Arel.sql("#{@sort} #{@direction == 'asc' ? 'ASC' : 'DESC'}")
22
32
  end
23
33
 
24
34
  def filtered_scope
25
- scope = SolidQueue::Job.where.not(finished_at: nil).order(finished_at: :desc)
35
+ scope = SolidQueue::Job.where.not(finished_at: nil).order(sort_expression)
26
36
  scope = scope.where(queue_name: @queue) if @queue.present?
27
37
  scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
28
38
  scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
@@ -32,7 +42,7 @@ module SolidStackWeb
32
42
  def history_csv(scope)
33
43
  CSV.generate(headers: true) do |csv|
34
44
  csv << %w[id class_name queue_name duration_seconds finished_at]
35
- scope.order(finished_at: :desc).each do |job|
45
+ scope.each do |job|
36
46
  duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil
37
47
  csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601]
38
48
  end
@@ -10,11 +10,13 @@ module SolidStackWeb
10
10
  count = SolidQueue::Job.where(id: job_ids).destroy_all.size
11
11
 
12
12
  redirect_to jobs_path(
13
- status: status,
14
- q: params[:q].presence,
15
- queue: params[:queue].presence,
16
- period: params[:period].presence_in(PERIOD_DURATIONS.keys),
17
- priority: params[:priority].presence
13
+ status: status,
14
+ q: params[:q].presence,
15
+ queue: params[:queue].presence,
16
+ period: params[:period].presence_in(PERIOD_DURATIONS.keys),
17
+ priority: params[:priority].presence,
18
+ sort: params[:sort].presence,
19
+ direction: params[:direction].presence
18
20
  ), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
19
21
  rescue ArgumentError => e
20
22
  redirect_to jobs_path(status: params[:status]), alert: e.message
@@ -36,13 +36,13 @@ module SolidStackWeb
36
36
  @notice = "Job discarded."
37
37
 
38
38
  respond_to do |format|
39
- format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) }
39
+ format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction) }
40
40
  format.turbo_stream
41
41
  end
42
42
  else
43
43
  job_ids = filtered_scope.pluck(:job_id)
44
44
  count = SolidQueue::Job.where(id: job_ids).destroy_all.size
45
- redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
45
+ redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
46
46
  notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
47
47
  end
48
48
  end
@@ -54,10 +54,26 @@ module SolidStackWeb
54
54
  end
55
55
 
56
56
  def set_filters
57
- @search = params[:q].presence
58
- @queue = params[:queue].presence
59
- @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
60
- @priority = params[:priority].presence
57
+ @search = params[:q].presence
58
+ @queue = params[:queue].presence
59
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
60
+ @priority = params[:priority].presence
61
+ @sort = params[:sort].presence_in(sortable_columns) || "created_at"
62
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
63
+ end
64
+
65
+ def sortable_columns
66
+ %w[class_name queue_name priority created_at]
67
+ end
68
+
69
+ def sort_expression
70
+ sql_col = case @sort
71
+ when "class_name" then "solid_queue_jobs.class_name"
72
+ when "queue_name" then "solid_queue_jobs.queue_name"
73
+ when "priority" then "solid_queue_jobs.priority"
74
+ else "#{Job::EXECUTION_MODELS[@status].quoted_table_name}.created_at"
75
+ end
76
+ Arel.sql("#{sql_col} #{@direction == 'asc' ? 'ASC' : 'DESC'}")
61
77
  end
62
78
 
63
79
  def require_discardable
@@ -75,11 +91,11 @@ module SolidStackWeb
75
91
  end
76
92
 
77
93
  def filtered_scope
78
- scope = Job::EXECUTION_MODELS[@status].includes(:job).order(created_at: :desc)
79
- scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
80
- scope = scope.references(:job).where("solid_queue_jobs.queue_name = ?", @queue) if @queue.present?
81
- scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
82
- scope = scope.references(:job).where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
94
+ scope = Job::EXECUTION_MODELS[@status].includes(:job).references(:job).order(sort_expression)
95
+ scope = scope.where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
96
+ scope = scope.where("solid_queue_jobs.queue_name = ?", @queue) if @queue.present?
97
+ scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
98
+ scope = scope.where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
83
99
  scope
84
100
  end
85
101
  end
@@ -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,30 @@ module SolidStackWeb
87
87
  end
88
88
  end
89
89
 
90
+ def sort_header_th(label, col, url_proc, current_sort:, current_dir:)
91
+ is_active = current_sort == col
92
+ next_dir = (is_active && current_dir == "desc") ? "asc" : "desc"
93
+ indicator = is_active ? content_tag(:span, current_dir == "desc" ? "↓" : "↑", class: "sqw-sort-indicator") : nil
94
+ tag_opts = { scope: "col" }
95
+ tag_opts[:"aria-sort"] = is_active ? (current_dir == "asc" ? "ascending" : "descending") : nil if is_active
96
+ content_tag(:th, **tag_opts) do
97
+ link_to(url_proc.call(sort: col, direction: next_dir)) do
98
+ safe_join([label, indicator].compact)
99
+ end
100
+ end
101
+ end
102
+
103
+ def failed_job_sparkline_svg(sparkline)
104
+ build_sparkline_svg(sparkline, aria_label: "Failed jobs over the last 12 hours") do |count, i|
105
+ hours_ago = SolidStackWeb::FailedJobSparkline::HOURS - i
106
+ if hours_ago == 1
107
+ "#{count} #{count == 1 ? "failure" : "failures"} in the last hour"
108
+ else
109
+ "#{count} #{count == 1 ? "failure" : "failures"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
110
+ end
111
+ end
112
+ end
113
+
90
114
  private
91
115
 
92
116
  def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
@@ -1,5 +1,6 @@
1
1
  import "@hotwired/turbo"
2
2
  import { Application } from "@hotwired/stimulus"
3
+ import FilterPersistController from "solid_stack_web/filter_persist_controller"
3
4
  import RefreshController from "solid_stack_web/refresh_controller"
4
5
  import SearchController from "solid_stack_web/search_controller"
5
6
  import SelectionController from "solid_stack_web/selection_controller"
@@ -8,6 +9,7 @@ import ThemeController from "solid_stack_web/theme_controller"
8
9
  import TimestampController from "solid_stack_web/timestamp_controller"
9
10
 
10
11
  const application = Application.start()
12
+ application.register("filter-persist", FilterPersistController)
11
13
  application.register("refresh", RefreshController)
12
14
  application.register("search", SearchController)
13
15
  application.register("selection", SelectionController)
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const PERSIST_KEYS = ["status", "period", "queue"]
4
+
5
+ export default class extends Controller {
6
+ static values = { key: String }
7
+
8
+ connect() {
9
+ const params = new URLSearchParams(window.location.search)
10
+
11
+ if (params.toString()) {
12
+ const toSave = new URLSearchParams()
13
+ PERSIST_KEYS.forEach(k => { if (params.get(k)) toSave.set(k, params.get(k)) })
14
+ if (toSave.toString()) this.write(toSave.toString())
15
+ } else {
16
+ const stored = this.read()
17
+ if (stored) window.location.replace(`${window.location.pathname}?${stored}`)
18
+ }
19
+ }
20
+
21
+ read() {
22
+ try { return localStorage.getItem(this.keyValue) } catch (e) { return null }
23
+ }
24
+
25
+ write(value) {
26
+ try { localStorage.setItem(this.keyValue, value) } catch (e) { /* ignore */ }
27
+ }
28
+ }
@@ -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>
@@ -32,10 +41,11 @@
32
41
  <th scope="col"><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
33
42
  data-selection-target="selectAll"
34
43
  data-action="change->selection#selectAll"></th>
35
- <th scope="col">Job Class</th>
36
- <th scope="col">Queue</th>
44
+ <% sort_url = ->(p) { failed_jobs_path(error_class: @error_class, **p) } %>
45
+ <%= sort_header_th("Job Class", "class_name", sort_url, current_sort: @sort, current_dir: @direction) %>
46
+ <%= sort_header_th("Queue", "queue_name", sort_url, current_sort: @sort, current_dir: @direction) %>
37
47
  <th scope="col">Error</th>
38
- <th scope="col">Failed At</th>
48
+ <%= sort_header_th("Failed At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %>
39
49
  <th scope="col"><span class="sqw-sr-only">Actions</span></th>
40
50
  </tr>
41
51
  </thead>
@@ -10,11 +10,15 @@
10
10
  </div>
11
11
  </div>
12
12
 
13
- <form class="sqw-filters" action="<%= history_path %>" method="get" data-controller="search">
13
+ <form class="sqw-filters" action="<%= history_path %>" method="get"
14
+ data-controller="search filter-persist"
15
+ data-filter-persist-key-value="sqw-history-filters">
14
16
  <% if @queue.present? %>
15
17
  <input type="hidden" name="queue" value="<%= @queue %>">
16
18
  <% end %>
17
19
  <input type="hidden" name="period" value="<%= @period %>">
20
+ <input type="hidden" name="sort" value="<%= @sort %>">
21
+ <input type="hidden" name="direction" value="<%= @direction %>">
18
22
  <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
19
23
  placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
20
24
  data-action="input->search#filter">
@@ -44,11 +48,12 @@
44
48
  <div class="sqw-detail-card">
45
49
  <table class="sqw-table">
46
50
  <thead>
51
+ <% sort_url = ->(p) { history_path(queue: @queue, q: @search, period: @period, **p) } %>
47
52
  <tr>
48
- <th scope="col">Job Class</th>
49
- <th scope="col">Queue</th>
53
+ <%= sort_header_th("Job Class", "class_name", sort_url, current_sort: @sort, current_dir: @direction) %>
54
+ <%= sort_header_th("Queue", "queue_name", sort_url, current_sort: @sort, current_dir: @direction) %>
50
55
  <th scope="col">Duration</th>
51
- <th scope="col">Finished At</th>
56
+ <%= sort_header_th("Finished At", "finished_at", sort_url, current_sort: @sort, current_dir: @direction) %>
52
57
  </tr>
53
58
  </thead>
54
59
  <tbody>
@@ -1,7 +1,7 @@
1
1
  <div class="sqw-page-header sqw-page-header--split">
2
2
  <h1 class="sqw-page-title">Jobs</h1>
3
3
  <div class="sqw-header-actions">
4
- <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
4
+ <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
5
5
  class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
6
6
  <% if @status == "scheduled" && @executions&.any? %>
7
7
  <%= button_to "Run All Now (#{@pagy.count})",
@@ -24,7 +24,7 @@
24
24
 
25
25
  <div class="sqw-tabs">
26
26
  <% SolidStackWeb::Job::TAB_LABELS.each do |status, label| %>
27
- <%= link_to label, jobs_path(status: status, q: @search, queue: @queue, period: @period, priority: @priority),
27
+ <%= link_to label, jobs_path(status: status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
28
28
  class: "sqw-tab #{"sqw-tab--active" if @status == status}" %>
29
29
  <% end %>
30
30
  </div>
@@ -32,9 +32,13 @@
32
32
  <%= turbo_frame_tag "sqw-jobs-filter",
33
33
  data: { turbo_action: "advance", controller: "refresh",
34
34
  refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
35
- <form class="sqw-filters" action="<%= jobs_path %>" method="get" data-controller="search">
35
+ <form class="sqw-filters" action="<%= jobs_path %>" method="get"
36
+ data-controller="search filter-persist"
37
+ data-filter-persist-key-value="sqw-jobs-filters">
36
38
  <%= hidden_field_tag :status, @status %>
37
39
  <%= hidden_field_tag :period, @period %>
40
+ <%= hidden_field_tag :sort, @sort %>
41
+ <%= hidden_field_tag :direction, @direction %>
38
42
  <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
39
43
  placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
40
44
  data-action="input->search#filter">
@@ -57,16 +61,16 @@
57
61
  </select>
58
62
  <% end %>
59
63
  <% if @search.present? || @queue.present? || @priority.present? %>
60
- <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
64
+ <%= link_to "Clear", jobs_path(status: @status, period: @period, sort: @sort, direction: @direction), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
61
65
  <% end %>
62
66
  <div class="sqw-period-filter" role="group" aria-label="Time period">
63
- <%= link_to "All", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority),
67
+ <%= link_to "All", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, sort: @sort, direction: @direction),
64
68
  class: "sqw-period-btn #{"sqw-period-btn--active" if @period.nil?}" %>
65
- <%= link_to "1h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "1h"),
69
+ <%= link_to "1h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "1h", sort: @sort, direction: @direction),
66
70
  class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "1h"}" %>
67
- <%= link_to "24h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "24h"),
71
+ <%= link_to "24h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "24h", sort: @sort, direction: @direction),
68
72
  class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "24h"}" %>
69
- <%= link_to "7d", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "7d"),
73
+ <%= link_to "7d", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "7d", sort: @sort, direction: @direction),
70
74
  class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "7d"}" %>
71
75
  </div>
72
76
  </form>
@@ -77,11 +81,13 @@
77
81
  <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
78
82
  <%= form_with url: job_selection_path, method: :delete, id: "job-selection-form",
79
83
  data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do |f| %>
80
- <%= f.hidden_field :status, value: @status %>
81
- <%= f.hidden_field :q, value: @search %>
82
- <%= f.hidden_field :queue, value: @queue %>
83
- <%= f.hidden_field :period, value: @period %>
84
- <%= f.hidden_field :priority, value: @priority %>
84
+ <%= f.hidden_field :status, value: @status %>
85
+ <%= f.hidden_field :q, value: @search %>
86
+ <%= f.hidden_field :queue, value: @queue %>
87
+ <%= f.hidden_field :period, value: @period %>
88
+ <%= f.hidden_field :priority, value: @priority %>
89
+ <%= f.hidden_field :sort, value: @sort %>
90
+ <%= f.hidden_field :direction, value: @direction %>
85
91
  <% end %>
86
92
 
87
93
  <div class="sqw-selection-bar" data-selection-target="bar" style="display: none;">
@@ -100,10 +106,11 @@
100
106
  data-selection-target="selectAll"
101
107
  data-action="change->selection#selectAll"></th>
102
108
  <% end %>
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>
109
+ <% sort_url = ->(p) { jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, **p) } %>
110
+ <%= sort_header_th("Job Class", "class_name", sort_url, current_sort: @sort, current_dir: @direction) %>
111
+ <%= sort_header_th("Queue", "queue_name", sort_url, current_sort: @sort, current_dir: @direction) %>
112
+ <%= sort_header_th("Priority", "priority", sort_url, current_sort: @sort, current_dir: @direction) %>
113
+ <%= sort_header_th("Enqueued At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %>
107
114
  <% if @status == "scheduled" %><th scope="col">Scheduled At</th><% end %>
108
115
  <th scope="col"><span class="sqw-sr-only">Actions</span></th>
109
116
  </tr>
@@ -140,7 +147,7 @@
140
147
  <% end %>
141
148
  <% end %>
142
149
  <% if %w[ready scheduled blocked].include?(@status) %>
143
- <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
150
+ <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
144
151
  method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
145
152
  data: { turbo_confirm: "Discard this job?" } %>
146
153
  <% end %>
@@ -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.2.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.2.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
@@ -175,6 +176,7 @@ files:
175
176
  - app/controllers/solid_stack_web/stats_controller.rb
176
177
  - app/helpers/solid_stack_web/application_helper.rb
177
178
  - app/javascript/solid_stack_web/application.js
179
+ - app/javascript/solid_stack_web/filter_persist_controller.js
178
180
  - app/javascript/solid_stack_web/refresh_controller.js
179
181
  - app/javascript/solid_stack_web/search_controller.js
180
182
  - app/javascript/solid_stack_web/selection_controller.js
@@ -187,6 +189,8 @@ files:
187
189
  - app/models/solid_stack_web/cache_size_stats.rb
188
190
  - app/models/solid_stack_web/cache_stats.rb
189
191
  - app/models/solid_stack_web/cache_timeline.rb
192
+ - app/models/solid_stack_web/error_frequency_report.rb
193
+ - app/models/solid_stack_web/failed_job_sparkline.rb
190
194
  - app/models/solid_stack_web/job.rb
191
195
  - app/models/solid_stack_web/queue_depth_sparkline.rb
192
196
  - app/models/solid_stack_web/queue_stats.rb
@@ -201,6 +205,7 @@ files:
201
205
  - app/views/solid_stack_web/errors/internal_server_error.html.erb
202
206
  - app/views/solid_stack_web/errors/not_found.html.erb
203
207
  - app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
208
+ - app/views/solid_stack_web/failed_jobs/errors/index.html.erb
204
209
  - app/views/solid_stack_web/failed_jobs/index.html.erb
205
210
  - app/views/solid_stack_web/failed_jobs/show.html.erb
206
211
  - app/views/solid_stack_web/history/index.html.erb