solid_stack_web 1.1.0 → 1.3.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: 1717d75a5baabd8728f6f9877b5146849be9322a627cf295bd922690aa284592
4
- data.tar.gz: ae814ccee9aa89798575f914652ccfbb51137473961d7fd1a22b7a4461449102
3
+ metadata.gz: d97689a002516820708b4d0fcd852eadb94d4c24cd67b2d6d9760cc842df2033
4
+ data.tar.gz: 14bb1935a9b57b09768ddfd32677b5b16a447d13c96ac605c8bd4f7491412240
5
5
  SHA512:
6
- metadata.gz: e0d79705beafccb16561d20a28cac815607f2a202bc6d0839449c501f8d4f5bd93bd83ccb4a7c4be0d6b701ecb69b9aca8fa5ee2a3d4462712fc8399c935162a
7
- data.tar.gz: 2663a57cf04ca3aa26463732638020c63ce76d10046979dbd22baae8f5b529d3092e3597aa9c4a8b9b06b0a43b9a648e0a573349bca1f39d6f944fe4a8313884
6
+ metadata.gz: e2f8057a998ccc88c642f5805fd63e850b9e23e60f3b02b48685a55f64d30958e1e2775caae4dc59f58ca68f58e40539df0c282b844c2f196b18bcaa06999887
7
+ data.tar.gz: e4ecd5a93a76121efc58e5f66ae50ceaa9590b065af359b840c0d6f7622ee8f0417ad3ff397152672828c645b37826f8e51c7a3a4cf3da32d8c50a62d81c3d58
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/eclectic-coding/solid_stack_web/actions/workflows/ci.yml/badge.svg)](https://github.com/eclectic-coding/solid_stack_web/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/solid_stack_web.svg)](https://badge.fury.io/rb/solid_stack_web)
5
+ [![Downloads](https://img.shields.io/gem/dt/solid_stack_web.svg)](https://rubygems.org/gems/solid_stack_web)
5
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
6
7
  [![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
8
 
@@ -143,19 +144,20 @@ The dashboard is designed to be mounted behind your application's existing authe
143
144
  ### Features
144
145
 
145
146
  - **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; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs respecting active filters
147
+ - **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
147
148
  - **Bulk selection** — checkbox-select individual jobs for discard; select-all support
148
149
  - **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
149
150
  - **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
150
151
  - **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
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
+ - **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
153
  - **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
153
154
  - **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
154
155
  - **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
155
156
  - **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; CSV export respects active filters
157
+ - **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
157
158
  - **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`
158
159
  - **Turbo Stream** job discard — removes the row inline without a full page reload
160
+ - **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
159
161
  - **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
160
162
  - **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
161
163
  - **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
@@ -6,6 +6,7 @@ module SolidStackWeb
6
6
  @cable_stats = CableStats.new.to_h
7
7
  @throughput = ThroughputSparkline.new
8
8
  @failures = FailedJobSparkline.new
9
+ AlertWebhook.check(@queue_stats)
9
10
  end
10
11
  end
11
12
  end
@@ -1,9 +1,12 @@
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)
7
10
  @error_class = params[:error_class].presence
8
11
  scope = scope.where(id: ids_for_error_class(@error_class)) if @error_class
9
12
  @pagy, @executions = pagy(scope)
@@ -43,6 +46,19 @@ module SolidStackWeb
43
46
 
44
47
  private
45
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
+
46
62
  def ids_for_error_class(ec)
47
63
  ::SolidQueue::FailedExecution.pluck(:id, :error).filter_map do |id, raw|
48
64
  error = raw.is_a?(Hash) ? raw : JSON.parse(raw)
@@ -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
@@ -66,20 +82,24 @@ module SolidStackWeb
66
82
 
67
83
  def jobs_csv
68
84
  CSV.generate(headers: true) do |csv|
69
- csv << %w[id class_name queue_name status priority enqueued_at]
85
+ headers = %w[id class_name queue_name status priority enqueued_at]
86
+ headers << "wait_time_seconds" if @status == "claimed"
87
+ csv << headers
70
88
  filtered_scope.each do |execution|
71
89
  job = execution.job
72
- csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
90
+ row = [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
91
+ row << (execution.created_at - job.created_at).to_i if @status == "claimed"
92
+ csv << row
73
93
  end
74
94
  end
75
95
  end
76
96
 
77
97
  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?
98
+ scope = Job::EXECUTION_MODELS[@status].includes(:job).references(:job).order(sort_expression)
99
+ scope = scope.where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
100
+ scope = scope.where("solid_queue_jobs.queue_name = ?", @queue) if @queue.present?
101
+ scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
102
+ scope = scope.where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
83
103
  scope
84
104
  end
85
105
  end
@@ -87,6 +87,19 @@ 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
+
90
103
  def failed_job_sparkline_svg(sparkline)
91
104
  build_sparkline_svg(sparkline, aria_label: "Failed jobs over the last 12 hours") do |count, i|
92
105
  hours_ago = SolidStackWeb::FailedJobSparkline::HOURS - i
@@ -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
+ }
@@ -39,6 +39,16 @@ module SolidStackWeb
39
39
  alerts << { type: "queue_depth", queue: queue_name.to_s, count: count, threshold: threshold } if count >= threshold
40
40
  end
41
41
 
42
+ if (job_threshold = SolidStackWeb.slow_job_threshold) && (count_threshold = SolidStackWeb.alert_slow_job_count_threshold)
43
+ count = ::SolidQueue::ClaimedExecution.where("created_at <= ?", job_threshold.seconds.ago).count
44
+ alerts << { type: "slow_jobs", count: count, threshold: count_threshold } if count >= count_threshold
45
+ end
46
+
47
+ if (threshold = SolidStackWeb.alert_stale_process_threshold)
48
+ count = @queue_stats[:processes_stale]
49
+ alerts << { type: "stale_processes", count: count, threshold: threshold } if count >= threshold
50
+ end
51
+
42
52
  alerts
43
53
  end
44
54
 
@@ -41,10 +41,11 @@
41
41
  <th scope="col"><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
42
42
  data-selection-target="selectAll"
43
43
  data-action="change->selection#selectAll"></th>
44
- <th scope="col">Job Class</th>
45
- <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) %>
46
47
  <th scope="col">Error</th>
47
- <th scope="col">Failed At</th>
48
+ <%= sort_header_th("Failed At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %>
48
49
  <th scope="col"><span class="sqw-sr-only">Actions</span></th>
49
50
  </tr>
50
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,11 +106,13 @@
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 %>
115
+ <% if @status == "claimed" %><th scope="col">Wait Time</th><% end %>
108
116
  <th scope="col"><span class="sqw-sr-only">Actions</span></th>
109
117
  </tr>
110
118
  </thead>
@@ -125,6 +133,9 @@
125
133
  <% if @status == "scheduled" %>
126
134
  <td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= local_time(execution.scheduled_at) %></td>
127
135
  <% end %>
136
+ <% if @status == "claimed" %>
137
+ <td class="sqw-monospace"><%= format_duration(execution.created_at - execution.job.created_at) %></td>
138
+ <% end %>
128
139
  <td class="sqw-actions">
129
140
  <% if @status == "scheduled" %>
130
141
  <%= button_to "Run Now", scheduled_job_path(execution),
@@ -140,7 +151,7 @@
140
151
  <% end %>
141
152
  <% end %>
142
153
  <% if %w[ready scheduled blocked].include?(@status) %>
143
- <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
154
+ <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
144
155
  method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
145
156
  data: { turbo_confirm: "Discard this job?" } %>
146
157
  <% end %>
@@ -41,11 +41,13 @@ SolidStackWeb.configure do |config|
41
41
  # Alert webhook — POST to this URL when a threshold is breached.
42
42
  # Delivery failures are silently swallowed; configure a cooldown to avoid storms.
43
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
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
47
  # "critical" => 50,
48
48
  # "default" => 500
49
49
  # }
50
- # config.alert_webhook_cooldown = 3600 # seconds between repeat alerts
50
+ # config.alert_slow_job_count_threshold = 3 # fire when N+ claimed jobs exceed slow_job_threshold duration
51
+ # config.alert_stale_process_threshold = 1 # fire when N+ workers have a stale heartbeat (>5 min old)
52
+ # config.alert_webhook_cooldown = 3600 # seconds between repeat alerts
51
53
  end
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "1.1.0"
2
+ VERSION = "1.3.0"
3
3
  end
@@ -6,6 +6,7 @@ module SolidStackWeb
6
6
  attr_writer :page_size, :connects_to, :slow_job_threshold,
7
7
  :alert_webhook_url, :alert_webhook_cooldown,
8
8
  :alert_failure_threshold, :alert_queue_thresholds,
9
+ :alert_slow_job_count_threshold, :alert_stale_process_threshold,
9
10
  :dashboard_refresh_interval, :default_refresh_interval,
10
11
  :search_results_limit, :allow_value_preview
11
12
 
@@ -37,6 +38,14 @@ module SolidStackWeb
37
38
  @alert_queue_thresholds || {}
38
39
  end
39
40
 
41
+ def alert_slow_job_count_threshold
42
+ @alert_slow_job_count_threshold
43
+ end
44
+
45
+ def alert_stale_process_threshold
46
+ @alert_stale_process_threshold
47
+ end
48
+
40
49
  def dashboard_refresh_interval
41
50
  @dashboard_refresh_interval || 5_000
42
51
  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.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -176,6 +176,7 @@ files:
176
176
  - app/controllers/solid_stack_web/stats_controller.rb
177
177
  - app/helpers/solid_stack_web/application_helper.rb
178
178
  - app/javascript/solid_stack_web/application.js
179
+ - app/javascript/solid_stack_web/filter_persist_controller.js
179
180
  - app/javascript/solid_stack_web/refresh_controller.js
180
181
  - app/javascript/solid_stack_web/search_controller.js
181
182
  - app/javascript/solid_stack_web/selection_controller.js