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 +4 -4
- data/README.md +5 -3
- data/app/controllers/solid_stack_web/dashboard_controller.rb +1 -0
- data/app/controllers/solid_stack_web/failed_jobs_controller.rb +17 -1
- data/app/controllers/solid_stack_web/history_controller.rb +15 -5
- data/app/controllers/solid_stack_web/jobs/selections_controller.rb +7 -5
- data/app/controllers/solid_stack_web/jobs_controller.rb +33 -13
- data/app/helpers/solid_stack_web/application_helper.rb +13 -0
- data/app/javascript/solid_stack_web/application.js +2 -0
- data/app/javascript/solid_stack_web/filter_persist_controller.js +28 -0
- data/app/models/solid_stack_web/alert_webhook.rb +10 -0
- data/app/views/solid_stack_web/failed_jobs/index.html.erb +4 -3
- data/app/views/solid_stack_web/history/index.html.erb +9 -4
- data/app/views/solid_stack_web/jobs/index.html.erb +29 -18
- data/lib/generators/solid_stack_web/install/templates/initializer.rb +6 -4
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +9 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d97689a002516820708b4d0fcd852eadb94d4c24cd67b2d6d9760cc842df2033
|
|
4
|
+
data.tar.gz: 14bb1935a9b57b09768ddfd32677b5b16a447d13c96ac605c8bd4f7491412240
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2f8057a998ccc88c642f5805fd63e850b9e23e60f3b02b48685a55f64d30958e1e2775caae4dc59f58ca68f58e40539df0c282b844c2f196b18bcaa06999887
|
|
7
|
+
data.tar.gz: e4ecd5a93a76121efc58e5f66ae50ceaa9590b065af359b840c0d6f7622ee8f0417ad3ff397152672828c645b37826f8e51c7a3a4cf3da32d8c50a62d81c3d58
|
data/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/eclectic-coding/solid_stack_web/actions/workflows/ci.yml)
|
|
4
4
|
[](https://badge.fury.io/rb/solid_stack_web)
|
|
5
|
+
[](https://rubygems.org/gems/solid_stack_web)
|
|
5
6
|
[](https://www.ruby-lang.org)
|
|
6
7
|
[](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
|
|
@@ -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).
|
|
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
|
|
20
|
-
@search
|
|
21
|
-
@period
|
|
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(
|
|
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.
|
|
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:
|
|
14
|
-
q:
|
|
15
|
-
queue:
|
|
16
|
-
period:
|
|
17
|
-
priority:
|
|
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
|
|
58
|
-
@queue
|
|
59
|
-
@period
|
|
60
|
-
@priority
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
79
|
-
scope = scope.
|
|
80
|
-
scope = scope.
|
|
81
|
-
scope = scope.
|
|
82
|
-
scope = scope.
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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"
|
|
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,
|
|
81
|
-
<%= f.hidden_field :q,
|
|
82
|
-
<%= f.hidden_field :queue,
|
|
83
|
-
<%= f.hidden_field :period,
|
|
84
|
-
<%= f.hidden_field :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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
45
|
-
# config.alert_failure_threshold
|
|
46
|
-
# config.alert_queue_thresholds
|
|
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.
|
|
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
|
data/lib/solid_stack_web.rb
CHANGED
|
@@ -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.
|
|
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
|