solid_stack_web 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +84 -15
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +4 -0
- data/app/assets/stylesheets/solid_stack_web/_09_detail.css +20 -22
- data/app/controllers/solid_stack_web/dashboard_controller.rb +1 -0
- data/app/controllers/solid_stack_web/failed_jobs/errors_controller.rb +9 -0
- data/app/controllers/solid_stack_web/failed_jobs_controller.rb +11 -0
- data/app/controllers/solid_stack_web/stats_controller.rb +12 -3
- data/app/helpers/solid_stack_web/application_helper.rb +11 -0
- data/app/models/solid_stack_web/error_frequency_report.rb +34 -0
- data/app/models/solid_stack_web/failed_job_sparkline.rb +23 -0
- data/app/views/solid_stack_web/cache_entries/show.html.erb +11 -14
- data/app/views/solid_stack_web/dashboard/index.html.erb +12 -0
- data/app/views/solid_stack_web/failed_jobs/errors/index.html.erb +48 -0
- data/app/views/solid_stack_web/failed_jobs/index.html.erb +10 -1
- data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
- data/app/views/solid_stack_web/stats/index.html.erb +4 -0
- data/config/routes.rb +2 -0
- data/docs/screenshots/demo.gif +0 -0
- data/lib/solid_stack_web/engine.rb +4 -0
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +22 -0
- metadata +14 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1717d75a5baabd8728f6f9877b5146849be9322a627cf295bd922690aa284592
|
|
4
|
+
data.tar.gz: ae814ccee9aa89798575f914652ccfbb51137473961d7fd1a22b7a4461449102
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0d79705beafccb16561d20a28cac815607f2a202bc6d0839449c501f8d4f5bd93bd83ccb4a7c4be0d6b701ecb69b9aca8fa5ee2a3d4462712fc8399c935162a
|
|
7
|
+
data.tar.gz: 2663a57cf04ca3aa26463732638020c63ce76d10046979dbd22baae8f5b529d3092e3597aa9c4a8b9b06b0a43b9a648e0a573349bca1f39d6f944fe4a8313884
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.ruby-lang.org)
|
|
6
6
|
[](https://codecov.io/gh/eclectic-coding/solid_stack_web)
|
|
7
7
|
|
|
8
|
-
A
|
|
8
|
+
A production-ready operations dashboard for the full Rails Solid Stack. Mount one engine to get deep visibility into **Solid Queue** (job browser, failed job retry, queue controls, recurring tasks, performance stats), **Solid Cache** (entry browser, size distribution, write timeline), and **Solid Cable** (channel browser, message list, purge controls) — with dark mode, CSV export, alert webhooks, and a JSON metrics endpoint, all with no asset pipeline dependency.
|
|
9
9
|
|
|
10
10
|
## Installation
|
|
11
11
|
|
|
@@ -21,23 +21,19 @@ Run:
|
|
|
21
21
|
bundle install
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Mount the engine in `config/routes.rb`:
|
|
25
|
-
|
|
26
|
-
```ruby
|
|
27
|
-
mount SolidStackWeb::Engine, at: "/solid_stack"
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
The dashboard will be available at `/solid_stack` (or whatever path you choose).
|
|
31
|
-
|
|
32
|
-
### Install generator
|
|
33
|
-
|
|
34
24
|
Run the install generator to create a documented initializer and wire up the mount point in one step:
|
|
35
25
|
|
|
36
26
|
```bash
|
|
37
27
|
rails generate solid_stack_web:install
|
|
38
28
|
```
|
|
39
29
|
|
|
40
|
-
This creates `config/initializers/solid_stack_web.rb` with every configuration option commented inline, and injects `mount SolidStackWeb::Engine, at: "/solid_stack"` into `config/routes.rb`.
|
|
30
|
+
This creates `config/initializers/solid_stack_web.rb` with every configuration option commented inline, and injects `mount SolidStackWeb::Engine, at: "/solid_stack"` into `config/routes.rb`. The dashboard will then be available at `/solid_stack` (or whatever path you choose).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Screenshots
|
|
35
|
+
|
|
36
|
+

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