solid_stack_web 0.3.0 → 0.4.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 +103 -26
- data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +64 -12
- data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
- data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
- data/app/controllers/solid_stack_web/queues_controller.rb +4 -0
- data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
- data/app/helpers/solid_stack_web/application_helper.rb +58 -2
- data/app/javascript/solid_stack_web/application.js +5 -1
- data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
- data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
- data/app/models/solid_stack_web/alert_webhook.rb +67 -0
- data/app/models/solid_stack_web/cable_stats.rb +10 -0
- data/app/models/solid_stack_web/cache_stats.rb +10 -0
- data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
- data/app/models/solid_stack_web/queue_stats.rb +34 -0
- data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +2 -0
- data/app/views/solid_stack_web/dashboard/index.html.erb +37 -2
- data/app/views/solid_stack_web/history/index.html.erb +3 -0
- data/app/views/solid_stack_web/jobs/index.html.erb +4 -2
- data/app/views/solid_stack_web/processes/index.html.erb +3 -0
- data/app/views/solid_stack_web/queues/index.html.erb +6 -1
- data/app/views/solid_stack_web/stats/index.html.erb +48 -0
- data/config/importmap.rb +4 -0
- data/config/routes.rb +2 -0
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +37 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c446164425203124cb715d09c520cfa7b2cc03a6abeefb4eb27ca0514327160a
|
|
4
|
+
data.tar.gz: ef9abaa3fe16c5ffc1e019dfff0ce97d7541950db9f6f25ae5ce034f4780efb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 30b738bcc2e57c4f81fb03d7b4a74bf88e18a1b69d4b71bc646357703006d6a1b639090a77a11d258427b7ad080f0b93b317cd949ff9b3b3353f77caa3e69066
|
|
7
|
+
data.tar.gz: 37f9a4d50e42360411c4213be03ce5c343874314a048b5f09b13b450bfc2ccf3568a2033cf5d4d137ee59723658f9a3cccbcd6353cf7f2f677159bd2b04fba73
|
data/README.md
CHANGED
|
@@ -7,21 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
A mountable Rails engine that provides a unified web dashboard for the full [Solid Stack](https://github.com/rails/solid_queue) — **Solid Queue**, **Solid Cache**, and **Solid Cable** — in a single interface with no asset pipeline dependency and no JavaScript runtime requirement.
|
|
9
9
|
|
|
10
|
-
## Features
|
|
11
|
-
|
|
12
|
-
- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
|
|
13
|
-
- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters; **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard
|
|
14
|
-
- **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
|
|
15
|
-
- **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)" in the header back-dates all matching executions at once
|
|
16
|
-
- **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 that immediately enqueues the task
|
|
17
|
-
- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
|
|
18
|
-
- **Failed job detail page** — drill into any failed job to see the full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action
|
|
19
|
-
- **Solid Cache** — entry count and total byte size at a glance
|
|
20
|
-
- **Solid Cable** — active message count and distinct channel count
|
|
21
|
-
- **Turbo Stream** job discard — removes the row inline without a full page reload
|
|
22
|
-
- **Authentication hook** — plug in your own auth logic (Devise, Basic Auth, custom) via a one-line initializer
|
|
23
|
-
- **Zero asset pipeline coupling** — CSS is injected inline; safe to mount in any host app
|
|
24
|
-
|
|
25
10
|
## Installation
|
|
26
11
|
|
|
27
12
|
Add the gem to your application's `Gemfile`:
|
|
@@ -44,24 +29,55 @@ mount SolidStackWeb::Engine, at: "/solid_stack"
|
|
|
44
29
|
|
|
45
30
|
The dashboard will be available at `/solid_stack` (or whatever path you choose).
|
|
46
31
|
|
|
47
|
-
|
|
32
|
+
---
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
## Solid Queue
|
|
35
|
+
|
|
36
|
+
### Features
|
|
37
|
+
|
|
38
|
+
- **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
|
|
39
|
+
- **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
|
|
40
|
+
- **Bulk selection** — checkbox-select individual jobs for discard; select-all support
|
|
41
|
+
- **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
|
|
42
|
+
- **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
|
|
43
|
+
- **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
|
|
44
|
+
- **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
|
|
45
|
+
- **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
|
|
46
|
+
- **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
|
|
47
|
+
- **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
|
|
48
|
+
- **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
|
|
49
|
+
- **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`
|
|
50
|
+
- **Turbo Stream** job discard — removes the row inline without a full page reload
|
|
51
|
+
|
|
52
|
+
### Configuration
|
|
50
53
|
|
|
51
54
|
```ruby
|
|
52
55
|
SolidStackWeb.configure do |config|
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
# Slow job threshold in seconds (default: nil — stat hidden).
|
|
57
|
+
# When set, the dashboard shows a "Slow (24h)" count of finished jobs
|
|
58
|
+
# whose wall time exceeded this value. Links to the Stats page.
|
|
59
|
+
config.slow_job_threshold = 30
|
|
60
|
+
|
|
61
|
+
# Alert webhooks — fired on every GET /metrics poll when a threshold is met.
|
|
62
|
+
# Delivery failures are silently swallowed; cooldown prevents alert storms.
|
|
63
|
+
config.alert_webhook_url = "https://hooks.example.com/my-alert"
|
|
64
|
+
config.alert_failure_threshold = 10 # POST when failed jobs >= this
|
|
65
|
+
config.alert_queue_thresholds = { # POST when a queue's ready depth >= value
|
|
66
|
+
"critical" => 50,
|
|
67
|
+
"default" => 500
|
|
68
|
+
}
|
|
69
|
+
config.alert_webhook_cooldown = 3600 # seconds between alerts (default: 3600)
|
|
70
|
+
|
|
71
|
+
# Auto-refresh intervals in milliseconds.
|
|
72
|
+
config.dashboard_refresh_interval = 5_000 # overview dashboard (default: 5000)
|
|
73
|
+
config.default_refresh_interval = 10_000 # jobs, processes, history (default: 10000)
|
|
74
|
+
|
|
75
|
+
# Maximum results shown by the search feature (default: 25).
|
|
76
|
+
config.search_results_limit = 25
|
|
61
77
|
end
|
|
62
78
|
```
|
|
63
79
|
|
|
64
|
-
|
|
80
|
+
#### Job Filtering
|
|
65
81
|
|
|
66
82
|
The jobs list supports four independent filters, all driven by query params:
|
|
67
83
|
|
|
@@ -74,10 +90,71 @@ The jobs list supports four independent filters, all driven by query params:
|
|
|
74
90
|
|
|
75
91
|
Filters are preserved when switching between status tabs (Ready / Scheduled / Running / Blocked) and when discarding a job. They can be combined freely.
|
|
76
92
|
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Solid Cache
|
|
96
|
+
|
|
97
|
+
_Deep cache monitoring coming in v0.5.0. Currently shows entry count and total byte size on the overview dashboard._
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Solid Cable
|
|
102
|
+
|
|
103
|
+
_Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Metrics endpoint
|
|
108
|
+
|
|
109
|
+
`GET /metrics` (relative to your mount path) returns a JSON payload suitable for external monitoring tools, uptime checkers, or custom alerting:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"queue": {
|
|
114
|
+
"ready": 4,
|
|
115
|
+
"scheduled": 1,
|
|
116
|
+
"claimed": 2,
|
|
117
|
+
"blocked": 0,
|
|
118
|
+
"failed": 3,
|
|
119
|
+
"done_1h": 45,
|
|
120
|
+
"done_24h": 312,
|
|
121
|
+
"processes_healthy": 2,
|
|
122
|
+
"processes_stale": 0,
|
|
123
|
+
"slow_jobs": 7
|
|
124
|
+
},
|
|
125
|
+
"cache": { "entries": 1024, "byte_size": 2097152 },
|
|
126
|
+
"cable": { "messages": 50, "channels": 3 },
|
|
127
|
+
"generated_at": "2026-05-26T10:00:00Z"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`slow_jobs` is only present when `slow_job_threshold` is configured. The endpoint is protected by the same authentication as the rest of the dashboard.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## General configuration
|
|
136
|
+
|
|
137
|
+
Create an initializer at `config/initializers/solid_stack_web.rb`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
SolidStackWeb.configure do |config|
|
|
141
|
+
# Number of items per paginated page (default: 25)
|
|
142
|
+
config.page_size = 50
|
|
143
|
+
|
|
144
|
+
# Authentication — block runs in controller context.
|
|
145
|
+
# Return a truthy value to allow access; falsy falls back to HTTP Basic.
|
|
146
|
+
config.authenticate do
|
|
147
|
+
current_user&.admin?
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
77
152
|
### Authentication
|
|
78
153
|
|
|
79
154
|
The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open.
|
|
80
155
|
|
|
156
|
+
---
|
|
157
|
+
|
|
81
158
|
## Requirements
|
|
82
159
|
|
|
83
160
|
- Ruby >= 3.3
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
.sqw-actions { text-align: right; white-space: nowrap; }
|
|
31
31
|
.sqw-actions form { display: inline; }
|
|
32
32
|
|
|
33
|
+
.sqw-table th a { color: inherit; text-decoration: none; }
|
|
34
|
+
.sqw-table th a:hover { color: var(--text); }
|
|
35
|
+
.sqw-sort-indicator { margin-left: 0.2rem; }
|
|
36
|
+
|
|
33
37
|
.sqw-empty {
|
|
34
38
|
background: var(--surface);
|
|
35
39
|
border: 1px solid var(--border);
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
border-radius: var(--radius);
|
|
13
13
|
box-shadow: var(--shadow);
|
|
14
14
|
overflow: hidden;
|
|
15
|
-
position: relative;
|
|
16
15
|
transition: box-shadow 0.15s;
|
|
17
16
|
}
|
|
18
17
|
.sqw-gem-card:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); }
|
|
@@ -44,16 +43,6 @@
|
|
|
44
43
|
}
|
|
45
44
|
.sqw-gem-card__link:hover { color: var(--primary); text-decoration: none; }
|
|
46
45
|
|
|
47
|
-
/* Stretch the header link to cover the whole card */
|
|
48
|
-
.sqw-gem-card__link::after {
|
|
49
|
-
content: "";
|
|
50
|
-
position: absolute;
|
|
51
|
-
inset: 0;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/* Stat links and other interactive elements sit above the overlay */
|
|
55
|
-
.sqw-inline-stat { position: relative; z-index: 1; }
|
|
56
|
-
|
|
57
46
|
.sqw-gem-card__body {
|
|
58
47
|
display: grid;
|
|
59
48
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
|
@@ -68,6 +57,7 @@
|
|
|
68
57
|
color: var(--text);
|
|
69
58
|
transition: opacity 0.15s;
|
|
70
59
|
}
|
|
60
|
+
|
|
71
61
|
a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
|
|
72
62
|
|
|
73
63
|
.sqw-inline-stat__label {
|
|
@@ -87,4 +77,66 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
|
|
|
87
77
|
.sqw-inline-stat--failed .sqw-inline-stat__value { color: var(--danger); }
|
|
88
78
|
.sqw-inline-stat--cache .sqw-inline-stat__value { color: var(--purple); }
|
|
89
79
|
.sqw-inline-stat--cable .sqw-inline-stat__value { color: var(--info); }
|
|
90
|
-
.sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
|
|
80
|
+
.sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
|
|
81
|
+
|
|
82
|
+
.sqw-sparkline-wrap {
|
|
83
|
+
padding: 0.75rem 1.25rem 1rem;
|
|
84
|
+
border-top: 1px solid var(--border);
|
|
85
|
+
color: var(--primary);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.sqw-sparkline-label {
|
|
89
|
+
display: block;
|
|
90
|
+
font-size: 10px;
|
|
91
|
+
font-weight: 500;
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
letter-spacing: .06em;
|
|
94
|
+
color: var(--muted);
|
|
95
|
+
margin-bottom: 0.5rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.sqw-sparkline-positioner {
|
|
99
|
+
position: relative;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sqw-sparkline {
|
|
103
|
+
display: block;
|
|
104
|
+
width: 100%;
|
|
105
|
+
height: 40px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.sqw-sparkline--sm {
|
|
109
|
+
height: 24px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sqw-queue-sparkline {
|
|
113
|
+
width: 120px;
|
|
114
|
+
color: var(--primary);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.sqw-sparkline rect {
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.sqw-sparkline-tip {
|
|
122
|
+
position: fixed;
|
|
123
|
+
transform: translate(-50%, -100%);
|
|
124
|
+
background: var(--text);
|
|
125
|
+
color: var(--surface);
|
|
126
|
+
font-size: 11px;
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
padding: 3px 7px;
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
pointer-events: none;
|
|
132
|
+
z-index: 100;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.sqw-sparkline-axis {
|
|
136
|
+
display: flex;
|
|
137
|
+
justify-content: space-between;
|
|
138
|
+
font-size: 9px;
|
|
139
|
+
color: var(--muted);
|
|
140
|
+
margin-top: 3px;
|
|
141
|
+
opacity: 0.7;
|
|
142
|
+
}
|
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
module SolidStackWeb
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
3
|
def index
|
|
4
|
-
@queue_stats =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
blocked: ::SolidQueue::BlockedExecution.count,
|
|
9
|
-
failed: ::SolidQueue::FailedExecution.count,
|
|
10
|
-
processes: ::SolidQueue::Process.count
|
|
11
|
-
}
|
|
12
|
-
@cache_stats = {
|
|
13
|
-
entries: ::SolidCache::Entry.count,
|
|
14
|
-
byte_size: ::SolidCache::Entry.sum(:byte_size)
|
|
15
|
-
}
|
|
16
|
-
@cable_stats = {
|
|
17
|
-
messages: ::SolidCable::Message.count,
|
|
18
|
-
channels: ::SolidCable::Message.distinct.count(:channel)
|
|
19
|
-
}
|
|
4
|
+
@queue_stats = QueueStats.new.to_h
|
|
5
|
+
@cache_stats = CacheStats.new.to_h
|
|
6
|
+
@cable_stats = CableStats.new.to_h
|
|
7
|
+
@throughput = ThroughputSparkline.new
|
|
20
8
|
end
|
|
21
9
|
end
|
|
22
10
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class MetricsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
queue_stats = QueueStats.new.to_h
|
|
5
|
+
AlertWebhook.check(queue_stats)
|
|
6
|
+
|
|
7
|
+
render json: {
|
|
8
|
+
queue: queue_stats,
|
|
9
|
+
cache: CacheStats.new.to_h,
|
|
10
|
+
cable: CableStats.new.to_h,
|
|
11
|
+
generated_at: Time.current.iso8601
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class StatsController < ApplicationController
|
|
3
|
+
SORTABLE_COLUMNS = %w[class_name count avg p50 p95 min max].freeze
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "p95"
|
|
7
|
+
@direction = params[:direction] == "asc" ? "asc" : "desc"
|
|
8
|
+
|
|
9
|
+
jobs = SolidQueue::Job.where.not(finished_at: nil).select(:class_name, :created_at, :finished_at)
|
|
10
|
+
@stats = build_stats(jobs)
|
|
11
|
+
@stats.sort_by! { |row| row[@sort.to_sym] || 0 }
|
|
12
|
+
@stats.reverse! if @direction == "desc"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_stats(jobs)
|
|
18
|
+
jobs.group_by(&:class_name).map do |class_name, group|
|
|
19
|
+
durations = group.map { |j| (j.finished_at - j.created_at).to_f }.sort
|
|
20
|
+
count = durations.size
|
|
21
|
+
{
|
|
22
|
+
class_name: class_name,
|
|
23
|
+
count: count,
|
|
24
|
+
avg: durations.sum / count,
|
|
25
|
+
min: durations.first,
|
|
26
|
+
max: durations.last,
|
|
27
|
+
p50: percentile(durations, 50),
|
|
28
|
+
p95: percentile(durations, 95)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def percentile(sorted, pct)
|
|
34
|
+
return 0.0 if sorted.empty?
|
|
35
|
+
k = (sorted.size - 1) * pct / 100.0
|
|
36
|
+
sorted[k.floor] + (sorted[k.ceil] - sorted[k.floor]) * (k - k.floor)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,13 +1,69 @@
|
|
|
1
1
|
module SolidStackWeb
|
|
2
2
|
module ApplicationHelper
|
|
3
3
|
def format_duration(seconds)
|
|
4
|
+
return "—" if seconds.nil?
|
|
5
|
+
return "#{(seconds * 1000).round}ms" if seconds < 1
|
|
4
6
|
s = seconds.to_i
|
|
5
|
-
return "#{
|
|
6
|
-
return "#{s / 60}m #{s % 60}s"
|
|
7
|
+
return "#{sprintf("%g", seconds.round(1))}s" if s < 60
|
|
8
|
+
return "#{s / 60}m #{s % 60}s" if s < 3600
|
|
7
9
|
|
|
8
10
|
"#{s / 3600}h #{(s % 3600) / 60}m"
|
|
9
11
|
end
|
|
10
12
|
|
|
13
|
+
def throughput_sparkline_svg(sparkline)
|
|
14
|
+
build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
|
|
15
|
+
hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
|
|
16
|
+
if hours_ago == 1
|
|
17
|
+
"#{count} #{count == 1 ? "job" : "jobs"} in the last hour"
|
|
18
|
+
else
|
|
19
|
+
"#{count} #{count == 1 ? "job" : "jobs"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def queue_depth_sparkline_svg(sparkline)
|
|
25
|
+
build_sparkline_svg(sparkline, css_class: "sqw-sparkline sqw-sparkline--sm",
|
|
26
|
+
aria_label: "Queue depth over the last 12 hours") do |count, i|
|
|
27
|
+
hours_ago = SolidStackWeb::QueueDepthSparkline::HOURS - 1 - i
|
|
28
|
+
jobs_word = count == 1 ? "job" : "jobs"
|
|
29
|
+
hours_ago.zero? ? "#{count} ready #{jobs_word} now" : "#{count} ready #{jobs_word} #{hours_ago}h ago"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
|
|
36
|
+
buckets = sparkline.buckets
|
|
37
|
+
peak = [sparkline.max.to_f, 1.0].max
|
|
38
|
+
h = 40
|
|
39
|
+
bar_w = 8
|
|
40
|
+
gap = 2
|
|
41
|
+
total_w = buckets.size * (bar_w + gap) - gap
|
|
42
|
+
|
|
43
|
+
bars = buckets.each_with_index.map do |count, i|
|
|
44
|
+
x = i * (bar_w + gap)
|
|
45
|
+
bar_h = [(count / peak * (h - 4)).round, 2].max
|
|
46
|
+
y = h - bar_h
|
|
47
|
+
opacity = count.zero? ? "0.18" : "1"
|
|
48
|
+
tip = tooltip_text.call(count, i)
|
|
49
|
+
attrs = %( x="#{x}" y="#{y}" width="#{bar_w}" height="#{bar_h}" rx="1") +
|
|
50
|
+
%( fill="currentColor" opacity="#{opacity}") +
|
|
51
|
+
%( data-sparkline-tooltip-target="bar") +
|
|
52
|
+
%( data-tip="#{ERB::Util.html_escape(tip)}") +
|
|
53
|
+
%( data-action="mouseenter->sparkline-tooltip#show mouseleave->sparkline-tooltip#hide")
|
|
54
|
+
"<rect#{attrs}></rect>"
|
|
55
|
+
end.join
|
|
56
|
+
|
|
57
|
+
content_tag(:svg, bars.html_safe,
|
|
58
|
+
viewBox: "0 0 #{total_w} #{h}",
|
|
59
|
+
preserveAspectRatio: "none",
|
|
60
|
+
class: css_class,
|
|
61
|
+
role: "img",
|
|
62
|
+
"aria-label": aria_label)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
public
|
|
66
|
+
|
|
11
67
|
def inline_styles
|
|
12
68
|
dir = SolidStackWeb::Engine.root.join("app/assets/stylesheets/solid_stack_web")
|
|
13
69
|
css = dir.glob("_*.css").sort.map(&:read).join("\n")
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import "@hotwired/turbo"
|
|
2
2
|
import { Application } from "@hotwired/stimulus"
|
|
3
|
+
import RefreshController from "solid_stack_web/refresh_controller"
|
|
3
4
|
import SelectionController from "solid_stack_web/selection_controller"
|
|
5
|
+
import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
|
|
4
6
|
|
|
5
7
|
const application = Application.start()
|
|
6
|
-
application.register("
|
|
8
|
+
application.register("refresh", RefreshController)
|
|
9
|
+
application.register("selection", SelectionController)
|
|
10
|
+
application.register("sparkline-tooltip", SparklineTooltipController)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { interval: { type: Number, default: 5000 } }
|
|
5
|
+
|
|
6
|
+
initialize() {
|
|
7
|
+
this._onVisibilityChange = this._onVisibilityChange.bind(this)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
document.addEventListener("visibilitychange", this._onVisibilityChange)
|
|
12
|
+
this._schedule()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
clearTimeout(this._timer)
|
|
17
|
+
document.removeEventListener("visibilitychange", this._onVisibilityChange)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_schedule() {
|
|
21
|
+
this._timer = setTimeout(() => this._reload(), this.intervalValue)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async _reload() {
|
|
25
|
+
clearTimeout(this._timer)
|
|
26
|
+
const hasSelection = this.element.querySelector("input[type='checkbox']:checked")
|
|
27
|
+
if (!document.hidden && !hasSelection) {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(window.location.href, {
|
|
30
|
+
headers: { "Turbo-Frame": this.element.id, Accept: "text/html" }
|
|
31
|
+
})
|
|
32
|
+
if (response.ok) {
|
|
33
|
+
const html = await response.text()
|
|
34
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
35
|
+
const frame = doc.querySelector(`turbo-frame#${this.element.id}`)
|
|
36
|
+
if (frame && this.element.isConnected) this.element.innerHTML = frame.innerHTML
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// network error — skip this tick
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (this.element.isConnected) this._schedule()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_onVisibilityChange() {
|
|
46
|
+
if (document.hidden) {
|
|
47
|
+
clearTimeout(this._timer)
|
|
48
|
+
} else if (!this.element.querySelector("input[type='checkbox']:checked")) {
|
|
49
|
+
this._reload()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["bar", "tip"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this._tip = this.tipTarget
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
show({ currentTarget }) {
|
|
11
|
+
const label = currentTarget.dataset.tip
|
|
12
|
+
if (!label) return
|
|
13
|
+
const rect = currentTarget.getBoundingClientRect()
|
|
14
|
+
this._tip.textContent = label
|
|
15
|
+
this._tip.style.left = `${rect.left + rect.width / 2}px`
|
|
16
|
+
this._tip.style.top = `${rect.top - 6}px`
|
|
17
|
+
this._tip.hidden = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
hide() {
|
|
21
|
+
this._tip.hidden = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module SolidStackWeb
|
|
5
|
+
class AlertWebhook
|
|
6
|
+
COOLDOWN_CACHE_KEY = "solid_stack_web/alert_webhook/cooldown"
|
|
7
|
+
|
|
8
|
+
def self.check(queue_stats)
|
|
9
|
+
new(queue_stats).check
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(queue_stats)
|
|
13
|
+
@queue_stats = queue_stats
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def check
|
|
17
|
+
return unless SolidStackWeb.alert_webhook_url
|
|
18
|
+
return if on_cooldown?
|
|
19
|
+
|
|
20
|
+
alerts = build_alerts
|
|
21
|
+
return if alerts.empty?
|
|
22
|
+
|
|
23
|
+
deliver(alerts)
|
|
24
|
+
set_cooldown
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_alerts
|
|
30
|
+
alerts = []
|
|
31
|
+
|
|
32
|
+
if (threshold = SolidStackWeb.alert_failure_threshold)
|
|
33
|
+
count = @queue_stats[:failed]
|
|
34
|
+
alerts << { type: "failed_jobs", count: count, threshold: threshold } if count >= threshold
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
SolidStackWeb.alert_queue_thresholds.each do |queue_name, threshold|
|
|
38
|
+
count = ::SolidQueue::ReadyExecution.where(queue_name: queue_name.to_s).count
|
|
39
|
+
alerts << { type: "queue_depth", queue: queue_name.to_s, count: count, threshold: threshold } if count >= threshold
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
alerts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def deliver(alerts)
|
|
46
|
+
payload = { alerts: alerts, generated_at: Time.current.iso8601 }
|
|
47
|
+
uri = URI(SolidStackWeb.alert_webhook_url)
|
|
48
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
49
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
50
|
+
request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
|
|
51
|
+
request.body = payload.to_json
|
|
52
|
+
http.request(request)
|
|
53
|
+
end
|
|
54
|
+
rescue StandardError
|
|
55
|
+
# webhook delivery failures must not affect the response
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on_cooldown?
|
|
59
|
+
Rails.cache.read(COOLDOWN_CACHE_KEY).present?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_cooldown
|
|
63
|
+
Rails.cache.write(COOLDOWN_CACHE_KEY, Time.current.iso8601,
|
|
64
|
+
expires_in: SolidStackWeb.alert_webhook_cooldown)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class QueueDepthSparkline
|
|
3
|
+
HOURS = 12
|
|
4
|
+
|
|
5
|
+
def initialize(queue_name)
|
|
6
|
+
@queue_name = queue_name
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def buckets
|
|
10
|
+
@buckets ||= begin
|
|
11
|
+
now = Time.current
|
|
12
|
+
origin = now - HOURS.hours
|
|
13
|
+
|
|
14
|
+
jobs = ::SolidQueue::Job
|
|
15
|
+
.where(queue_name: @queue_name)
|
|
16
|
+
.where("created_at <= ? AND (finished_at IS NULL OR finished_at >= ?)", now, origin)
|
|
17
|
+
.pluck(:created_at, :finished_at)
|
|
18
|
+
|
|
19
|
+
HOURS.times.map do |i|
|
|
20
|
+
snapshot = origin + (i + 1).hours
|
|
21
|
+
jobs.count { |created_at, finished_at| created_at <= snapshot && (finished_at.nil? || finished_at > snapshot) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def max
|
|
27
|
+
buckets.max || 0
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class QueueStats
|
|
3
|
+
def to_h
|
|
4
|
+
finished_24h = finished_since(24.hours.ago)
|
|
5
|
+
stats = {
|
|
6
|
+
ready: ::SolidQueue::ReadyExecution.count,
|
|
7
|
+
scheduled: ::SolidQueue::ScheduledExecution.count,
|
|
8
|
+
claimed: ::SolidQueue::ClaimedExecution.count,
|
|
9
|
+
blocked: ::SolidQueue::BlockedExecution.count,
|
|
10
|
+
failed: ::SolidQueue::FailedExecution.count,
|
|
11
|
+
done_1h: finished_since(1.hour.ago).count,
|
|
12
|
+
done_24h: finished_24h.count,
|
|
13
|
+
processes_healthy: ::SolidQueue::Process.where("last_heartbeat_at > ?", 5.minutes.ago).count,
|
|
14
|
+
processes_stale: ::SolidQueue::Process.where("last_heartbeat_at <= ? OR last_heartbeat_at IS NULL", 5.minutes.ago).count
|
|
15
|
+
}
|
|
16
|
+
add_slow_jobs(stats, finished_24h)
|
|
17
|
+
stats
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def finished_since(time)
|
|
23
|
+
::SolidQueue::Job.where.not(finished_at: nil).where("finished_at >= ?", time)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_slow_jobs(stats, finished_24h)
|
|
27
|
+
threshold = SolidStackWeb.slow_job_threshold
|
|
28
|
+
return unless threshold
|
|
29
|
+
|
|
30
|
+
stats[:slow_jobs] = finished_24h.select(:created_at, :finished_at)
|
|
31
|
+
.count { |j| (j.finished_at - j.created_at) > threshold }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SolidStackWeb
|
|
2
|
+
class ThroughputSparkline
|
|
3
|
+
HOURS = 12
|
|
4
|
+
|
|
5
|
+
def buckets
|
|
6
|
+
@buckets ||= begin
|
|
7
|
+
now = Time.current
|
|
8
|
+
origin = now - HOURS.hours
|
|
9
|
+
times = ::SolidQueue::Job.where(finished_at: origin..now).pluck(:finished_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
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
|
|
36
36
|
<%= link_to "Recurring", recurring_tasks_path,
|
|
37
37
|
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "recurring_tasks"}" %>
|
|
38
|
+
<%= link_to "Stats", stats_path,
|
|
39
|
+
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "stats"}" %>
|
|
38
40
|
<%= link_to "History", history_path,
|
|
39
41
|
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
|
|
40
42
|
<%= link_to "Processes", processes_path,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<%= turbo_frame_tag "sqw-dashboard", target: "_top",
|
|
2
|
+
data: { controller: "refresh", refresh_interval_value: SolidStackWeb.dashboard_refresh_interval } do %>
|
|
1
3
|
<div class="sqw-page-header">
|
|
2
4
|
<h1 class="sqw-page-title">Overview</h1>
|
|
3
5
|
</div>
|
|
@@ -29,10 +31,42 @@
|
|
|
29
31
|
<span class="sqw-inline-stat__label">Failed</span>
|
|
30
32
|
<span class="sqw-inline-stat__value"><%= @queue_stats[:failed] %></span>
|
|
31
33
|
</a>
|
|
34
|
+
<a href="<%= history_path(period: "1h") %>" class="sqw-inline-stat sqw-inline-stat--neutral">
|
|
35
|
+
<span class="sqw-inline-stat__label">Done (1h)</span>
|
|
36
|
+
<span class="sqw-inline-stat__value"><%= @queue_stats[:done_1h] %></span>
|
|
37
|
+
</a>
|
|
38
|
+
<a href="<%= history_path(period: "24h") %>" class="sqw-inline-stat sqw-inline-stat--neutral">
|
|
39
|
+
<span class="sqw-inline-stat__label">Done (24h)</span>
|
|
40
|
+
<span class="sqw-inline-stat__value"><%= @queue_stats[:done_24h] %></span>
|
|
41
|
+
</a>
|
|
42
|
+
<% if @queue_stats.key?(:slow_jobs) %>
|
|
43
|
+
<a href="<%= stats_path %>" class="sqw-inline-stat sqw-inline-stat--<%= @queue_stats[:slow_jobs] > 0 ? "failed" : "neutral" %>">
|
|
44
|
+
<span class="sqw-inline-stat__label">Slow (24h)</span>
|
|
45
|
+
<span class="sqw-inline-stat__value"><%= @queue_stats[:slow_jobs] %></span>
|
|
46
|
+
</a>
|
|
47
|
+
<% end %>
|
|
32
48
|
<a href="<%= processes_path %>" class="sqw-inline-stat sqw-inline-stat--neutral">
|
|
33
|
-
<span class="sqw-inline-stat__label">
|
|
34
|
-
<span class="sqw-inline-stat__value"><%= @queue_stats[:
|
|
49
|
+
<span class="sqw-inline-stat__label">Healthy</span>
|
|
50
|
+
<span class="sqw-inline-stat__value"><%= @queue_stats[:processes_healthy] %></span>
|
|
35
51
|
</a>
|
|
52
|
+
<% if @queue_stats[:processes_stale] > 0 %>
|
|
53
|
+
<a href="<%= processes_path %>" class="sqw-inline-stat sqw-inline-stat--failed">
|
|
54
|
+
<span class="sqw-inline-stat__label">Stale</span>
|
|
55
|
+
<span class="sqw-inline-stat__value"><%= @queue_stats[:processes_stale] %></span>
|
|
56
|
+
</a>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="sqw-sparkline-wrap" data-controller="sparkline-tooltip">
|
|
60
|
+
<span class="sqw-sparkline-label">Throughput — last 12 hours</span>
|
|
61
|
+
<div class="sqw-sparkline-positioner">
|
|
62
|
+
<%= throughput_sparkline_svg(@throughput) %>
|
|
63
|
+
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="sqw-sparkline-axis">
|
|
66
|
+
<span>12h ago</span>
|
|
67
|
+
<span>6h ago</span>
|
|
68
|
+
<span>now</span>
|
|
69
|
+
</div>
|
|
36
70
|
</div>
|
|
37
71
|
</div>
|
|
38
72
|
|
|
@@ -70,3 +104,4 @@
|
|
|
70
104
|
</div>
|
|
71
105
|
</div>
|
|
72
106
|
</div>
|
|
107
|
+
<% end %>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<%= turbo_frame_tag "sqw-history-table",
|
|
2
|
+
data: { controller: "refresh", refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
|
|
1
3
|
<div class="sqw-page-header sqw-page-header--split">
|
|
2
4
|
<h1 class="sqw-page-title">Job History</h1>
|
|
3
5
|
<div class="sqw-header-actions">
|
|
@@ -70,4 +72,5 @@
|
|
|
70
72
|
<div class="sqw-empty">
|
|
71
73
|
<p>No finished jobs found.</p>
|
|
72
74
|
</div>
|
|
75
|
+
<% end %>
|
|
73
76
|
<% end %>
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
<% end %>
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
<%= turbo_frame_tag "sqw-jobs-filter",
|
|
33
|
+
data: { turbo_action: "advance", controller: "refresh",
|
|
34
|
+
refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
|
|
33
35
|
<form class="sqw-filters" action="<%= jobs_path %>" method="get">
|
|
34
36
|
<%= hidden_field_tag :status, @status %>
|
|
35
37
|
<%= hidden_field_tag :period, @period %>
|
|
@@ -151,4 +153,4 @@
|
|
|
151
153
|
<%= render "empty" %>
|
|
152
154
|
<% end %>
|
|
153
155
|
</div>
|
|
154
|
-
|
|
156
|
+
<% end %>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<%= turbo_frame_tag "sqw-processes", target: "_top",
|
|
2
|
+
data: { controller: "refresh", refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
|
|
1
3
|
<div class="sqw-page-header">
|
|
2
4
|
<h1 class="sqw-page-title">Processes</h1>
|
|
3
5
|
</div>
|
|
@@ -30,3 +32,4 @@
|
|
|
30
32
|
<p>No active processes.</p>
|
|
31
33
|
</div>
|
|
32
34
|
<% end %>
|
|
35
|
+
<% end %>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<tr>
|
|
9
9
|
<th>Name</th>
|
|
10
10
|
<th>Size</th>
|
|
11
|
+
<th>Depth (12h)</th>
|
|
11
12
|
<th>Status</th>
|
|
12
13
|
<th></th>
|
|
13
14
|
</tr>
|
|
@@ -17,6 +18,10 @@
|
|
|
17
18
|
<tr>
|
|
18
19
|
<td class="sqw-monospace"><%= link_to queue[:name], queue_path(queue[:name]) %></td>
|
|
19
20
|
<td><%= link_to queue[:size], queue_path(queue[:name]) %></td>
|
|
21
|
+
<td class="sqw-queue-sparkline" data-controller="sparkline-tooltip">
|
|
22
|
+
<%= queue_depth_sparkline_svg(@sparklines[queue[:name]]) %>
|
|
23
|
+
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
|
|
24
|
+
</td>
|
|
20
25
|
<td>
|
|
21
26
|
<% if queue[:paused] %>
|
|
22
27
|
<span class="sqw-badge sqw-badge--paused">Paused</span>
|
|
@@ -41,4 +46,4 @@
|
|
|
41
46
|
<div class="sqw-empty">
|
|
42
47
|
<p>No queues with ready jobs.</p>
|
|
43
48
|
</div>
|
|
44
|
-
<% end %>
|
|
49
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<div class="sqw-page-header">
|
|
2
|
+
<h1 class="sqw-page-title">Performance Stats</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<% if @stats.any? %>
|
|
6
|
+
<table class="sqw-table">
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>
|
|
9
|
+
<% [
|
|
10
|
+
["class_name", "Job Class"],
|
|
11
|
+
["count", "Executions"],
|
|
12
|
+
["avg", "Avg"],
|
|
13
|
+
["p50", "p50"],
|
|
14
|
+
["p95", "p95"],
|
|
15
|
+
["min", "Min"],
|
|
16
|
+
["max", "Max"]
|
|
17
|
+
].each do |col, label| %>
|
|
18
|
+
<th>
|
|
19
|
+
<% next_dir = (@sort == col && @direction == "desc") ? "asc" : "desc" %>
|
|
20
|
+
<%= link_to stats_path(sort: col, direction: next_dir) do %>
|
|
21
|
+
<%= label %>
|
|
22
|
+
<% if @sort == col %>
|
|
23
|
+
<span class="sqw-sort-indicator"><%= @direction == "desc" ? "↓" : "↑" %></span>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
</th>
|
|
27
|
+
<% end %>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<% @stats.each do |row| %>
|
|
32
|
+
<tr>
|
|
33
|
+
<td class="sqw-monospace"><%= row[:class_name] %></td>
|
|
34
|
+
<td><%= row[:count] %></td>
|
|
35
|
+
<td class="sqw-muted"><%= format_duration(row[:avg]) %></td>
|
|
36
|
+
<td class="sqw-muted"><%= format_duration(row[:p50]) %></td>
|
|
37
|
+
<td><strong><%= format_duration(row[:p95]) %></strong></td>
|
|
38
|
+
<td class="sqw-muted"><%= format_duration(row[:min]) %></td>
|
|
39
|
+
<td class="sqw-muted"><%= format_duration(row[:max]) %></td>
|
|
40
|
+
</tr>
|
|
41
|
+
<% end %>
|
|
42
|
+
</tbody>
|
|
43
|
+
</table>
|
|
44
|
+
<% else %>
|
|
45
|
+
<div class="sqw-empty">
|
|
46
|
+
<p>No finished jobs yet.</p>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
data/config/importmap.rb
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
|
|
2
|
+
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
|
|
1
3
|
pin "solid_stack_web", to: "solid_stack_web/application.js"
|
|
4
|
+
pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
|
|
2
5
|
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
|
|
6
|
+
pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
|
data/config/routes.rb
CHANGED
|
@@ -31,6 +31,8 @@ SolidStackWeb::Engine.routes.draw do
|
|
|
31
31
|
|
|
32
32
|
resources :processes, only: [:index]
|
|
33
33
|
|
|
34
|
+
get "metrics", to: "metrics#index", as: :metrics
|
|
35
|
+
get "stats", to: "stats#index", as: :stats
|
|
34
36
|
get "history", to: "history#index", as: :history
|
|
35
37
|
get "cache", to: "cache#index", as: :cache
|
|
36
38
|
get "cable", to: "cable#index", as: :cable
|
data/lib/solid_stack_web.rb
CHANGED
|
@@ -3,7 +3,11 @@ require "solid_stack_web/engine"
|
|
|
3
3
|
|
|
4
4
|
module SolidStackWeb
|
|
5
5
|
class << self
|
|
6
|
-
attr_writer :page_size, :connects_to
|
|
6
|
+
attr_writer :page_size, :connects_to, :slow_job_threshold,
|
|
7
|
+
:alert_webhook_url, :alert_webhook_cooldown,
|
|
8
|
+
:alert_failure_threshold, :alert_queue_thresholds,
|
|
9
|
+
:dashboard_refresh_interval, :default_refresh_interval,
|
|
10
|
+
:search_results_limit
|
|
7
11
|
|
|
8
12
|
def page_size
|
|
9
13
|
@page_size || 25
|
|
@@ -13,6 +17,38 @@ module SolidStackWeb
|
|
|
13
17
|
@connects_to
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
def slow_job_threshold
|
|
21
|
+
@slow_job_threshold
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def alert_webhook_url
|
|
25
|
+
@alert_webhook_url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def alert_webhook_cooldown
|
|
29
|
+
@alert_webhook_cooldown || 3600
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def alert_failure_threshold
|
|
33
|
+
@alert_failure_threshold
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def alert_queue_thresholds
|
|
37
|
+
@alert_queue_thresholds || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dashboard_refresh_interval
|
|
41
|
+
@dashboard_refresh_interval || 5_000
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_refresh_interval
|
|
45
|
+
@default_refresh_interval || 10_000
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def search_results_limit
|
|
49
|
+
@search_results_limit || 25
|
|
50
|
+
end
|
|
51
|
+
|
|
16
52
|
def configure
|
|
17
53
|
yield self
|
|
18
54
|
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: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -153,16 +153,26 @@ files:
|
|
|
153
153
|
- app/controllers/solid_stack_web/history_controller.rb
|
|
154
154
|
- app/controllers/solid_stack_web/jobs/selections_controller.rb
|
|
155
155
|
- app/controllers/solid_stack_web/jobs_controller.rb
|
|
156
|
+
- app/controllers/solid_stack_web/metrics_controller.rb
|
|
156
157
|
- app/controllers/solid_stack_web/processes_controller.rb
|
|
157
158
|
- app/controllers/solid_stack_web/queues/pauses_controller.rb
|
|
158
159
|
- app/controllers/solid_stack_web/queues_controller.rb
|
|
159
160
|
- app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb
|
|
160
161
|
- app/controllers/solid_stack_web/recurring_tasks_controller.rb
|
|
161
162
|
- app/controllers/solid_stack_web/scheduled_jobs_controller.rb
|
|
163
|
+
- app/controllers/solid_stack_web/stats_controller.rb
|
|
162
164
|
- app/helpers/solid_stack_web/application_helper.rb
|
|
163
165
|
- app/javascript/solid_stack_web/application.js
|
|
166
|
+
- app/javascript/solid_stack_web/refresh_controller.js
|
|
164
167
|
- app/javascript/solid_stack_web/selection_controller.js
|
|
168
|
+
- app/javascript/solid_stack_web/sparkline_tooltip_controller.js
|
|
169
|
+
- app/models/solid_stack_web/alert_webhook.rb
|
|
170
|
+
- app/models/solid_stack_web/cable_stats.rb
|
|
171
|
+
- app/models/solid_stack_web/cache_stats.rb
|
|
165
172
|
- app/models/solid_stack_web/job.rb
|
|
173
|
+
- app/models/solid_stack_web/queue_depth_sparkline.rb
|
|
174
|
+
- app/models/solid_stack_web/queue_stats.rb
|
|
175
|
+
- app/models/solid_stack_web/throughput_sparkline.rb
|
|
166
176
|
- app/views/layouts/solid_stack_web/application.html.erb
|
|
167
177
|
- app/views/solid_stack_web/cable/index.html.erb
|
|
168
178
|
- app/views/solid_stack_web/cache/index.html.erb
|
|
@@ -180,6 +190,7 @@ files:
|
|
|
180
190
|
- app/views/solid_stack_web/queues/show.html.erb
|
|
181
191
|
- app/views/solid_stack_web/recurring_tasks/index.html.erb
|
|
182
192
|
- app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb
|
|
193
|
+
- app/views/solid_stack_web/stats/index.html.erb
|
|
183
194
|
- config/importmap.rb
|
|
184
195
|
- config/routes.rb
|
|
185
196
|
- lib/solid_stack_web.rb
|
|
@@ -207,7 +218,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
207
218
|
- !ruby/object:Gem::Version
|
|
208
219
|
version: '0'
|
|
209
220
|
requirements: []
|
|
210
|
-
rubygems_version: 4.0.
|
|
221
|
+
rubygems_version: 4.0.12
|
|
211
222
|
specification_version: 4
|
|
212
223
|
summary: A unified Rails engine dashboard for Solid Queue, Solid Cache, and Solid
|
|
213
224
|
Cable.
|