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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +103 -26
  3. data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
  4. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +64 -12
  5. data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
  6. data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
  7. data/app/controllers/solid_stack_web/queues_controller.rb +4 -0
  8. data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
  9. data/app/helpers/solid_stack_web/application_helper.rb +58 -2
  10. data/app/javascript/solid_stack_web/application.js +5 -1
  11. data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
  12. data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
  13. data/app/models/solid_stack_web/alert_webhook.rb +67 -0
  14. data/app/models/solid_stack_web/cable_stats.rb +10 -0
  15. data/app/models/solid_stack_web/cache_stats.rb +10 -0
  16. data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
  17. data/app/models/solid_stack_web/queue_stats.rb +34 -0
  18. data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
  19. data/app/views/layouts/solid_stack_web/application.html.erb +2 -0
  20. data/app/views/solid_stack_web/dashboard/index.html.erb +37 -2
  21. data/app/views/solid_stack_web/history/index.html.erb +3 -0
  22. data/app/views/solid_stack_web/jobs/index.html.erb +4 -2
  23. data/app/views/solid_stack_web/processes/index.html.erb +3 -0
  24. data/app/views/solid_stack_web/queues/index.html.erb +6 -1
  25. data/app/views/solid_stack_web/stats/index.html.erb +48 -0
  26. data/config/importmap.rb +4 -0
  27. data/config/routes.rb +2 -0
  28. data/lib/solid_stack_web/version.rb +1 -1
  29. data/lib/solid_stack_web.rb +37 -1
  30. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 777352d5919952ea022a545b13608ace8f35e0f91077431c45a679e5c30bec19
4
- data.tar.gz: d36dbf96b4234d730249df1d259099883d0473c160c62ab9bee73fe88ff133c4
3
+ metadata.gz: c446164425203124cb715d09c520cfa7b2cc03a6abeefb4eb27ca0514327160a
4
+ data.tar.gz: ef9abaa3fe16c5ffc1e019dfff0ce97d7541950db9f6f25ae5ce034f4780efb5
5
5
  SHA512:
6
- metadata.gz: eefbb43300e8e244b6354d1fb952edc7577585c76f5721920d6fa28aba41038840d4924cae9799903a3742e0782a2e8f95d8b142502194d1726e87fbb8c35e7a
7
- data.tar.gz: 254f13440a71b0dcfbeb3330f2c1be4c21a5e038e09355493bba1fe5b3767b70d314ba37982d7adced753915a971d159d06bdc29741e5c37e223e520184f34e9
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
- ## 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
61
77
  end
62
78
  ```
63
79
 
64
- ### Job Filtering
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
- 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,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 "#{s}s" if s < 60
6
- return "#{s / 60}m #{s % 60}s" if s < 3600
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("selection", SelectionController)
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,10 @@
1
+ module SolidStackWeb
2
+ class CableStats
3
+ def to_h
4
+ {
5
+ messages: ::SolidCable::Message.count,
6
+ channels: ::SolidCable::Message.distinct.count(:channel)
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module SolidStackWeb
2
+ class CacheStats
3
+ def to_h
4
+ {
5
+ entries: ::SolidCache::Entry.count,
6
+ byte_size: ::SolidCache::Entry.sum(:byte_size)
7
+ }
8
+ end
9
+ end
10
+ 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">Processes</span>
34
- <span class="sqw-inline-stat__value"><%= @queue_stats[:processes] %></span>
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
- <turbo-frame id="sqw-jobs-filter" data-turbo-action="advance">
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
- </turbo-frame>
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
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -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.3.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.10
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.