solid_stack_web 0.3.0 → 0.5.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +117 -26
  3. data/app/assets/stylesheets/solid_stack_web/_03_stats.css +27 -0
  4. data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
  5. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +85 -12
  6. data/app/assets/stylesheets/solid_stack_web/_09_detail.css +42 -0
  7. data/app/controllers/solid_stack_web/application_controller.rb +1 -1
  8. data/app/controllers/solid_stack_web/cache/flushes_controller.rb +8 -0
  9. data/app/controllers/solid_stack_web/cache_controller.rb +2 -0
  10. data/app/controllers/solid_stack_web/cache_entries_controller.rb +32 -0
  11. data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
  12. data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
  13. data/app/controllers/solid_stack_web/queues_controller.rb +4 -0
  14. data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
  15. data/app/helpers/solid_stack_web/application_helper.rb +90 -2
  16. data/app/javascript/solid_stack_web/application.js +7 -1
  17. data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
  18. data/app/javascript/solid_stack_web/search_controller.js +16 -0
  19. data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
  20. data/app/models/solid_stack_web/alert_webhook.rb +67 -0
  21. data/app/models/solid_stack_web/cable_stats.rb +10 -0
  22. data/app/models/solid_stack_web/cache_size_stats.rb +33 -0
  23. data/app/models/solid_stack_web/cache_stats.rb +11 -0
  24. data/app/models/solid_stack_web/cache_timeline.rb +40 -0
  25. data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
  26. data/app/models/solid_stack_web/queue_stats.rb +34 -0
  27. data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
  28. data/app/views/layouts/solid_stack_web/application.html.erb +13 -0
  29. data/app/views/solid_stack_web/cache/index.html.erb +84 -2
  30. data/app/views/solid_stack_web/cache_entries/index.html.erb +66 -0
  31. data/app/views/solid_stack_web/cache_entries/show.html.erb +48 -0
  32. data/app/views/solid_stack_web/dashboard/index.html.erb +45 -4
  33. data/app/views/solid_stack_web/history/index.html.erb +6 -3
  34. data/app/views/solid_stack_web/jobs/index.html.erb +11 -7
  35. data/app/views/solid_stack_web/processes/index.html.erb +3 -0
  36. data/app/views/solid_stack_web/queues/index.html.erb +6 -1
  37. data/app/views/solid_stack_web/stats/index.html.erb +48 -0
  38. data/config/importmap.rb +5 -0
  39. data/config/routes.rb +4 -0
  40. data/lib/solid_stack_web/version.rb +1 -1
  41. data/lib/solid_stack_web.rb +41 -1
  42. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 777352d5919952ea022a545b13608ace8f35e0f91077431c45a679e5c30bec19
4
- data.tar.gz: d36dbf96b4234d730249df1d259099883d0473c160c62ab9bee73fe88ff133c4
3
+ metadata.gz: 3159278d6dd76fc0e54660ab9c11536a63037815771ff04206adca43b800c4ef
4
+ data.tar.gz: 05f47e20a15b2f70cbf216713cb4c18da3e3f569b64eb1a271543489fecb95c1
5
5
  SHA512:
6
- metadata.gz: eefbb43300e8e244b6354d1fb952edc7577585c76f5721920d6fa28aba41038840d4924cae9799903a3742e0782a2e8f95d8b142502194d1726e87fbb8c35e7a
7
- data.tar.gz: 254f13440a71b0dcfbeb3330f2c1be4c21a5e038e09355493bba1fe5b3767b70d314ba37982d7adced753915a971d159d06bdc29741e5c37e223e520184f34e9
6
+ metadata.gz: cdd04812d4cdfa1d3c48ff2a81319f8956b71afee1dded7dbce009797822248e422990f02134fbb4f3c88e6e2e2702c2c91c78d94a6d04e28ebd8d1a2b0022b9
7
+ data.tar.gz: 431b97295f3682b1512b09ece023c280997560d4b4f065ef742411fddc63275c8257f0d34d02f6bb0589a555c8677d0515301cabfc8b0768465fd42ff6599a54
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,59 @@ 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
- ## Configuration
32
+ ---
48
33
 
49
- Create an initializer at `config/initializers/solid_stack_web.rb`:
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
- # Number of items per paginated page (default: 25)
54
- config.page_size = 50
55
-
56
- # Authentication — block runs in controller context.
57
- # Return a truthy value to allow access; falsy falls back to HTTP Basic.
58
- config.authenticate do
59
- current_user&.admin?
60
- end
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
77
+
78
+ # Show the raw serialized value on the cache entry detail page (default: false).
79
+ # Disable for stores that contain sensitive data.
80
+ config.allow_value_preview = true
61
81
  end
62
82
  ```
63
83
 
64
- ### Job Filtering
84
+ #### Job Filtering
65
85
 
66
86
  The jobs list supports four independent filters, all driven by query params:
67
87
 
@@ -74,10 +94,81 @@ The jobs list supports four independent filters, all driven by query params:
74
94
 
75
95
  Filters are preserved when switching between status tabs (Ready / Scheduled / Running / Blocked) and when discarding a job. They can be combined freely.
76
96
 
97
+ ---
98
+
99
+ ## Solid Cache
100
+
101
+ ### Features
102
+
103
+ - **Overview dashboard card** — live entry count (linked to the entry browser), total byte size, and oldest-entry age (`time_ago_in_words` with exact timestamp on hover; hidden when cache is empty)
104
+ - **Cache timeline** — two side-by-side 24-hour bar charts showing entries written per hour and bytes written per hour; each bar has a hover tooltip
105
+ - **Size distribution** — byte-range histogram (< 1 KB → > 1 MB) with entry counts and proportional inline bars; hidden when cache is empty
106
+ - **Largest entries** — top 10 entries by byte size, each linked to the detail page; hidden when cache is empty
107
+ - **Entry browser** — `GET /cache/entries` lists all `SolidCache::Entry` records in a paginated, sortable table; columns: key, byte size, created-at; sortable by any column; key auto-submits search after 4 characters
108
+ - **Key search** — filter entries by key substring; results update automatically after 4 characters
109
+ - **Entry detail page** — `GET /cache/entries/:id` shows the full key, byte size, and created-at; optionally displays the raw serialized value (see `allow_value_preview` below)
110
+ - **Delete entry** — per-row delete button or detail-page button removes a single cache entry
111
+ - **Flush All** — header button deletes every cache entry with a confirmation prompt
112
+
113
+ ---
114
+
115
+ ## Solid Cable
116
+
117
+ _Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._
118
+
119
+ ---
120
+
121
+ ## Metrics endpoint
122
+
123
+ `GET /metrics` (relative to your mount path) returns a JSON payload suitable for external monitoring tools, uptime checkers, or custom alerting:
124
+
125
+ ```json
126
+ {
127
+ "queue": {
128
+ "ready": 4,
129
+ "scheduled": 1,
130
+ "claimed": 2,
131
+ "blocked": 0,
132
+ "failed": 3,
133
+ "done_1h": 45,
134
+ "done_24h": 312,
135
+ "processes_healthy": 2,
136
+ "processes_stale": 0,
137
+ "slow_jobs": 7
138
+ },
139
+ "cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
140
+ "cable": { "messages": 50, "channels": 3 },
141
+ "generated_at": "2026-05-26T10:00:00Z"
142
+ }
143
+ ```
144
+
145
+ `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.
146
+
147
+ ---
148
+
149
+ ## General configuration
150
+
151
+ Create an initializer at `config/initializers/solid_stack_web.rb`:
152
+
153
+ ```ruby
154
+ SolidStackWeb.configure do |config|
155
+ # Number of items per paginated page (default: 25)
156
+ config.page_size = 50
157
+
158
+ # Authentication — block runs in controller context.
159
+ # Return a truthy value to allow access; falsy falls back to HTTP Basic.
160
+ config.authenticate do
161
+ current_user&.admin?
162
+ end
163
+ end
164
+ ```
165
+
77
166
  ### Authentication
78
167
 
79
168
  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
169
 
170
+ ---
171
+
81
172
  ## Requirements
82
173
 
83
174
  - Ruby >= 3.3
@@ -22,6 +22,33 @@ a.sqw-stat:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); text-decoration: none;
22
22
  .sqw-stat__label { font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
23
23
  .sqw-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
24
24
 
25
+ .sqw-size-grid {
26
+ display: grid;
27
+ grid-template-columns: 1fr 1fr;
28
+ gap: 1.5rem;
29
+ margin-top: 1.5rem;
30
+ }
31
+
32
+ .sqw-size-section { }
33
+
34
+ .sqw-dist-bar-cell {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ min-width: 160px;
39
+ }
40
+
41
+ .sqw-dist-bar {
42
+ height: 8px;
43
+ background: var(--purple);
44
+ border-radius: 4px;
45
+ min-width: 2px;
46
+ opacity: 0.7;
47
+ transition: width 0.3s ease;
48
+ }
49
+
50
+ .sqw-dist-pct { font-size: 12px; white-space: nowrap; }
51
+
25
52
  .sqw-stat--ready .sqw-stat__value { color: var(--success); }
26
53
  .sqw-stat--scheduled .sqw-stat__value { color: var(--info); }
27
54
  .sqw-stat--claimed .sqw-stat__value { color: var(--primary); }
@@ -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 {
@@ -79,6 +69,7 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
79
69
  }
80
70
 
81
71
  .sqw-inline-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
72
+ .sqw-inline-stat__value--sm { font-size: 16px; }
82
73
 
83
74
  .sqw-inline-stat--ready .sqw-inline-stat__value { color: var(--success); }
84
75
  .sqw-inline-stat--scheduled .sqw-inline-stat__value { color: var(--info); }
@@ -87,4 +78,86 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
87
78
  .sqw-inline-stat--failed .sqw-inline-stat__value { color: var(--danger); }
88
79
  .sqw-inline-stat--cache .sqw-inline-stat__value { color: var(--purple); }
89
80
  .sqw-inline-stat--cable .sqw-inline-stat__value { color: var(--info); }
90
- .sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
81
+ .sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
82
+
83
+ .sqw-sparkline-wrap {
84
+ padding: 0.75rem 1.25rem 1rem;
85
+ border-top: 1px solid var(--border);
86
+ color: var(--primary);
87
+ }
88
+
89
+ .sqw-sparkline-label {
90
+ display: block;
91
+ font-size: 10px;
92
+ font-weight: 500;
93
+ text-transform: uppercase;
94
+ letter-spacing: .06em;
95
+ color: var(--muted);
96
+ margin-bottom: 0.5rem;
97
+ }
98
+
99
+ .sqw-sparkline-positioner {
100
+ position: relative;
101
+ }
102
+
103
+ .sqw-sparkline {
104
+ display: block;
105
+ width: 100%;
106
+ height: 40px;
107
+ }
108
+
109
+ .sqw-sparkline--sm { height: 24px; }
110
+ .sqw-sparkline--lg { height: 64px; }
111
+
112
+ .sqw-timeline-grid {
113
+ display: grid;
114
+ grid-template-columns: 1fr 1fr;
115
+ gap: 0;
116
+ margin-top: 1.5rem;
117
+ border: 1px solid var(--border);
118
+ border-radius: var(--radius);
119
+ background: var(--surface);
120
+ box-shadow: var(--shadow);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .sqw-timeline-chart {
125
+ border-top: none;
126
+ color: var(--purple);
127
+ }
128
+
129
+ .sqw-timeline-chart + .sqw-timeline-chart {
130
+ border-left: 1px solid var(--border);
131
+ }
132
+
133
+ .sqw-queue-sparkline {
134
+ width: 120px;
135
+ color: var(--primary);
136
+ }
137
+
138
+ .sqw-sparkline rect {
139
+ cursor: pointer;
140
+ }
141
+
142
+ .sqw-sparkline-tip {
143
+ position: fixed;
144
+ transform: translate(-50%, -100%);
145
+ background: var(--text);
146
+ color: var(--surface);
147
+ font-size: 11px;
148
+ font-weight: 500;
149
+ white-space: nowrap;
150
+ padding: 3px 7px;
151
+ border-radius: 4px;
152
+ pointer-events: none;
153
+ z-index: 100;
154
+ }
155
+
156
+ .sqw-sparkline-axis {
157
+ display: flex;
158
+ justify-content: space-between;
159
+ font-size: 9px;
160
+ color: var(--muted);
161
+ margin-top: 3px;
162
+ opacity: 0.7;
163
+ }
@@ -70,6 +70,48 @@
70
70
  overflow-y: auto;
71
71
  }
72
72
 
73
+ .sqw-detail {
74
+ display: grid;
75
+ grid-template-columns: auto 1fr;
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; }
87
+
88
+ .sqw-value-preview__header {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.5rem;
92
+ margin-bottom: 0.75rem;
93
+ }
94
+ .sqw-value-preview__header .sqw-section-title { margin-bottom: 0; }
95
+
96
+ .sqw-value-pre {
97
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
98
+ font-size: 12px;
99
+ background: var(--bg);
100
+ border: 1px solid var(--border);
101
+ border-radius: var(--radius);
102
+ padding: 0.75rem;
103
+ overflow-x: auto;
104
+ white-space: pre-wrap;
105
+ word-break: break-word;
106
+ max-height: 500px;
107
+ overflow-y: auto;
108
+ }
109
+
110
+ .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
111
+
112
+ .sqw-link { color: var(--primary); text-decoration: none; }
113
+ .sqw-link:hover { text-decoration: underline; }
114
+
73
115
  .sqw-code-input {
74
116
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
75
117
  font-size: 12px;
@@ -16,7 +16,7 @@ module SolidStackWeb
16
16
  def current_section
17
17
  case controller_name
18
18
  when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
19
- when "cache" then :cache
19
+ when "cache", "cache_entries" then :cache
20
20
  when "cable" then :cable
21
21
  else :overview
22
22
  end
@@ -0,0 +1,8 @@
1
+ module SolidStackWeb
2
+ class Cache::FlushesController < ApplicationController
3
+ def destroy
4
+ ::SolidCache::Entry.delete_all
5
+ redirect_to cache_entries_path, notice: "All cache entries flushed."
6
+ end
7
+ end
8
+ end
@@ -3,6 +3,8 @@ module SolidStackWeb
3
3
  def index
4
4
  @total_entries = ::SolidCache::Entry.count
5
5
  @total_byte_size = ::SolidCache::Entry.sum(:byte_size)
6
+ @size_stats = CacheSizeStats.new
7
+ @timeline = CacheTimeline.new
6
8
  end
7
9
  end
8
10
  end
@@ -0,0 +1,32 @@
1
+ module SolidStackWeb
2
+ class CacheEntriesController < ApplicationController
3
+ def index
4
+ @search = params[:q].presence
5
+ @sort = resolve_sort
6
+
7
+ scope = ::SolidCache::Entry.all
8
+ scope = scope.where("key LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%") if @search
9
+ scope = scope.order(@sort["column"] => @sort["direction"])
10
+
11
+ @pagy, @entries = pagy(scope)
12
+ end
13
+
14
+ def show
15
+ @entry = ::SolidCache::Entry.find(params[:id])
16
+ end
17
+
18
+ def destroy
19
+ ::SolidCache::Entry.find(params[:id]).destroy
20
+ redirect_to cache_entries_path(q: params[:q], column: params[:column], direction: params[:direction]),
21
+ notice: "Cache entry deleted."
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_sort
27
+ column = %w[byte_size created_at key].include?(params[:column]) ? params[:column] : "byte_size"
28
+ direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "desc"
29
+ { "column" => column, "direction" => direction }
30
+ end
31
+ end
32
+ end
@@ -1,22 +1,10 @@
1
1
  module SolidStackWeb
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
- @queue_stats = {
5
- ready: ::SolidQueue::ReadyExecution.count,
6
- scheduled: ::SolidQueue::ScheduledExecution.count,
7
- claimed: ::SolidQueue::ClaimedExecution.count,
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
@@ -11,6 +11,10 @@ module SolidStackWeb
11
11
  paused: paused.include?(name)
12
12
  }
13
13
  end
14
+
15
+ @sparklines = @queues.each_with_object({}) do |queue, h|
16
+ h[queue[:name]] = QueueDepthSparkline.new(queue[:name])
17
+ end
14
18
  end
15
19
 
16
20
  def show
@@ -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,101 @@
1
1
  module SolidStackWeb
2
2
  module ApplicationHelper
3
+ def format_cache_value(raw)
4
+ str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
5
+ parsed = JSON.parse(str)
6
+ { label: "JSON", content: JSON.pretty_generate(parsed) }
7
+ rescue JSON::ParserError, JSON::GeneratorError
8
+ { label: "Text", content: str }
9
+ end
10
+
3
11
  def format_duration(seconds)
12
+ return "—" if seconds.nil?
13
+ return "#{(seconds * 1000).round}ms" if seconds < 1
4
14
  s = seconds.to_i
5
- return "#{s}s" if s < 60
6
- return "#{s / 60}m #{s % 60}s" if s < 3600
15
+ return "#{sprintf("%g", seconds.round(1))}s" if s < 60
16
+ return "#{s / 60}m #{s % 60}s" if s < 3600
7
17
 
8
18
  "#{s / 3600}h #{(s % 3600) / 60}m"
9
19
  end
10
20
 
21
+ def cache_entries_timeline_svg(timeline)
22
+ build_sparkline_svg(
23
+ Struct.new(:buckets, :max).new(timeline.entry_buckets, timeline.entry_max),
24
+ css_class: "sqw-sparkline sqw-sparkline--lg",
25
+ aria_label: "Cache entries written over the last 24 hours"
26
+ ) do |count, i|
27
+ hours_ago = CacheTimeline::HOURS - 1 - i
28
+ hours_ago.zero? ? "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} this hour" \
29
+ : "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} #{hours_ago}h ago"
30
+ end
31
+ end
32
+
33
+ def cache_bytes_timeline_svg(timeline)
34
+ build_sparkline_svg(
35
+ Struct.new(:buckets, :max).new(timeline.byte_buckets, timeline.byte_max),
36
+ css_class: "sqw-sparkline sqw-sparkline--lg",
37
+ aria_label: "Cache bytes written over the last 24 hours"
38
+ ) do |bytes, i|
39
+ hours_ago = CacheTimeline::HOURS - 1 - i
40
+ size = number_to_human_size(bytes)
41
+ hours_ago.zero? ? "#{size} written this hour" : "#{size} written #{hours_ago}h ago"
42
+ end
43
+ end
44
+
45
+ def throughput_sparkline_svg(sparkline)
46
+ build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
47
+ hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
48
+ if hours_ago == 1
49
+ "#{count} #{count == 1 ? "job" : "jobs"} in the last hour"
50
+ else
51
+ "#{count} #{count == 1 ? "job" : "jobs"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
52
+ end
53
+ end
54
+ end
55
+
56
+ def queue_depth_sparkline_svg(sparkline)
57
+ build_sparkline_svg(sparkline, css_class: "sqw-sparkline sqw-sparkline--sm",
58
+ aria_label: "Queue depth over the last 12 hours") do |count, i|
59
+ hours_ago = SolidStackWeb::QueueDepthSparkline::HOURS - 1 - i
60
+ jobs_word = count == 1 ? "job" : "jobs"
61
+ hours_ago.zero? ? "#{count} ready #{jobs_word} now" : "#{count} ready #{jobs_word} #{hours_ago}h ago"
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
68
+ buckets = sparkline.buckets
69
+ peak = [sparkline.max.to_f, 1.0].max
70
+ h = 40
71
+ bar_w = 8
72
+ gap = 2
73
+ total_w = buckets.size * (bar_w + gap) - gap
74
+
75
+ bars = buckets.each_with_index.map do |count, i|
76
+ x = i * (bar_w + gap)
77
+ bar_h = [(count / peak * (h - 4)).round, 2].max
78
+ y = h - bar_h
79
+ opacity = count.zero? ? "0.18" : "1"
80
+ tip = tooltip_text.call(count, i)
81
+ attrs = %( x="#{x}" y="#{y}" width="#{bar_w}" height="#{bar_h}" rx="1") +
82
+ %( fill="currentColor" opacity="#{opacity}") +
83
+ %( data-sparkline-tooltip-target="bar") +
84
+ %( data-tip="#{ERB::Util.html_escape(tip)}") +
85
+ %( data-action="mouseenter->sparkline-tooltip#show mouseleave->sparkline-tooltip#hide")
86
+ "<rect#{attrs}></rect>"
87
+ end.join
88
+
89
+ content_tag(:svg, bars.html_safe,
90
+ viewBox: "0 0 #{total_w} #{h}",
91
+ preserveAspectRatio: "none",
92
+ class: css_class,
93
+ role: "img",
94
+ "aria-label": aria_label)
95
+ end
96
+
97
+ public
98
+
11
99
  def inline_styles
12
100
  dir = SolidStackWeb::Engine.root.join("app/assets/stylesheets/solid_stack_web")
13
101
  css = dir.glob("_*.css").sort.map(&:read).join("\n")