roundhouse_ui 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5064ee74f5aea444f5762d2f466f0b4c05d9c7af6c44a48045e6703d48b72587
4
- data.tar.gz: 3b5a08f1769a5bd5ee66bdbe5ccd586842373b45530b95cbc4ff6340fe17796e
3
+ metadata.gz: 11537f41a49c350dbd057961b82994fdc7524afdf3285961a28fa13701daaf57
4
+ data.tar.gz: aedef27c3202b3d2eaf25a2b9fefb9290df222c66d1fde4ae8414152f3a477cf
5
5
  SHA512:
6
- metadata.gz: 6d3cf1e161baec06cb4b899d9f97065fb216341716719756c062a140a811bf51997748f9bcc322dc78aaa8ec36d56311decc511b70eea8d53a07a57a5b317b8c
7
- data.tar.gz: 15a01ddaa5fa81c37c67516c369d1dac2b795b011e5dad1927b3b70aeed47b365a4938765e78f9eae910da0a39e36fe174d6170ba0226d1eeab404a700f4c218
6
+ metadata.gz: f4b22e1a716bbbdfdbab62cc5b0dcb10094a3cefbefd7ad1f1a24367e1f383f4c4c1467d1aa840600f860b1bc65b433fbdbdc56da5e1f1c1545f1756a99ed83c
7
+ data.tar.gz: cbe28c835c0e6413ee88658242e455e39a47cbd124a1e06ee0a9d40106ea9eb3ba49e2491f8dbd6f3a757ad71c0a24f5ead03b0b263b8a96237db3d2f8732b74
data/README.md CHANGED
@@ -88,6 +88,14 @@ RoundhouseUi.configure do |c|
88
88
  # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
89
89
  # Default: true.
90
90
  # c.pause_enabled = false
91
+
92
+ # Seconds between dashboard stat polls (default 5). Raise it if polling shows
93
+ # up in your traces — each poll re-runs the host's auth/routing on the mount.
94
+ # c.poll_interval = 10
95
+
96
+ # Show the "slowest job classes" table on the Metrics page. Requires the
97
+ # DurationCollector middleware (see below). Default: false.
98
+ # c.collect_durations = true
91
99
  end
92
100
  ```
93
101
 
@@ -150,6 +158,29 @@ The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying
150
158
  is then skipped when it would next run; a *currently running* job stops only if it
151
159
  checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
152
160
 
161
+ ## Slowest job classes
162
+
163
+ Sidekiq doesn't track per-class durations, so Roundhouse can record them itself.
164
+ Install the opt-in server middleware and set `collect_durations = true`; the
165
+ Metrics page then lists the slowest classes by total time (count + average).
166
+
167
+ ```ruby
168
+ # config/initializers/sidekiq.rb
169
+ Sidekiq.configure_server do |config|
170
+ config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
171
+ end
172
+ ```
173
+
174
+ It's two cheap Redis writes per job (a counter + a summed-ms float) into a single
175
+ hash, and a job failure never propagates from the collector.
176
+
177
+ ## Bulk actions on a filter
178
+
179
+ On **Retries** and **Dead**, searching narrows the set; with a filter active you
180
+ can retry or delete **every** matching job in one action (not just the visible
181
+ page), capped at 1,000 per run. Gated to when a filter is present so it can't
182
+ become "retry everything", `read_only`-aware, and audit-logged.
183
+
153
184
  ## Observability deep-links
154
185
 
155
186
  The core depends on nothing — it asks the configured adapter for a URL and renders a link
@@ -5,6 +5,7 @@ module RoundhouseUi
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  PER_PAGE = 25
8
+ BULK_CAP = 1_000 # safety ceiling on a single match-set action
8
9
 
9
10
  # Returns [entries_for_page, has_next?]. Scans only far enough to fill the
10
11
  # requested page plus one (to know if a next page exists) — never loads the
@@ -32,6 +33,25 @@ module RoundhouseUi
32
33
  [ jobs, has_next ]
33
34
  end
34
35
 
36
+ # Apply an op ("retry"/"delete") to every entry matching the query, capped at
37
+ # BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set
38
+ # mid-iteration skips entries. Returns [count_acted_on, capped?].
39
+ def bulk_apply(set, query, op, cap = BULK_CAP)
40
+ matches = []
41
+ capped = false
42
+ set.each do |entry|
43
+ next if query.present? && !entry_matches?(entry, query)
44
+
45
+ matches << entry
46
+ if matches.size >= cap
47
+ capped = true
48
+ break
49
+ end
50
+ end
51
+ matches.each { |entry| op == "delete" ? entry.delete : entry.retry }
52
+ [ matches.size, capped ]
53
+ end
54
+
35
55
  def entry_matches?(entry, query)
36
56
  needle = query.downcase
37
57
  [ entry.klass, entry.jid, entry.item["error_class"], entry.item["error_message"], entry.args.to_s ]
@@ -2,7 +2,7 @@ module RoundhouseUi
2
2
  class DeadController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy bulk]
5
+ before_action :require_writable!, only: %i[requeue destroy bulk bulk_all]
6
6
 
7
7
  def index
8
8
  @query = params[:q].to_s.strip
@@ -36,6 +36,17 @@ module RoundhouseUi
36
36
  redirect_to dead_set_path, notice: "#{verb} #{count} job(s)."
37
37
  end
38
38
 
39
+ # Smart bulk: act on EVERY job matching the current filter (not just the
40
+ # selected/visible ones), capped for safety. Only offered when a filter is
41
+ # active, so it can't become "retry the entire dead set" by accident.
42
+ def bulk_all
43
+ count, capped = bulk_apply(Sidekiq::DeadSet.new, params[:q].to_s.strip, params[:op])
44
+ verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
+ note = "#{verb} #{count} matching job(s)."
46
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
47
+ redirect_to dead_set_path, notice: note
48
+ end
49
+
39
50
  private
40
51
 
41
52
  def require_writable!
@@ -3,6 +3,7 @@ module RoundhouseUi
3
3
  class MetricsController < ApplicationController
4
4
  def show
5
5
  @metrics = Metrics.new
6
+ @durations = DurationCollector.summary if RoundhouseUi.collect_durations
6
7
  end
7
8
  end
8
9
  end
@@ -2,7 +2,7 @@ module RoundhouseUi
2
2
  class RetriesController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy]
5
+ before_action :require_writable!, only: %i[requeue destroy bulk_all]
6
6
 
7
7
  def index
8
8
  @query = params[:q].to_s.strip
@@ -24,6 +24,16 @@ module RoundhouseUi
24
24
  redirect_to retries_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the retry set."
25
25
  end
26
26
 
27
+ # Smart bulk: retry/delete EVERY job matching the current filter, capped for
28
+ # safety. Offered only when a filter is active.
29
+ def bulk_all
30
+ count, capped = bulk_apply(Sidekiq::RetrySet.new, params[:q].to_s.strip, params[:op])
31
+ verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
32
+ note = "#{verb} #{count} matching job(s)."
33
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
34
+ redirect_to retries_path, notice: note
35
+ end
36
+
27
37
  private
28
38
 
29
39
  def require_writable!
@@ -163,6 +163,27 @@
163
163
  .rh-disclose summary::-webkit-details-marker { display:none; }
164
164
  .rh-disclose summary::before { content:"▸ "; }
165
165
  .rh-disclose[open] summary::before { content:"▾ "; }
166
+
167
+ /* connection state — poll failures surface here instead of silently going stale */
168
+ .rh-live.is-stale .rh-dot { background:var(--crit); }
169
+ .rh-live.is-stale .rh-dot::after { animation:none; }
170
+
171
+ /* keyboard focus — visible on every interactive control */
172
+ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
173
+ summary:focus-visible, .rh-nav:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
174
+
175
+ /* on-call / mobile: stack the rail on top as a horizontal nav, collapse the grid */
176
+ @media (max-width:760px) {
177
+ .rh-app { grid-template-columns:1fr; }
178
+ .rh-rail { position:static; height:auto; flex-direction:row; flex-wrap:wrap; gap:6px 14px; align-items:center; border-right:none; border-bottom:1px solid var(--line-soft); }
179
+ .rh-rail .rh-grp { flex-direction:row; flex-wrap:wrap; gap:4px; }
180
+ .rh-rail .rh-grp-label { display:none; }
181
+ .rh-brand { width:100%; }
182
+ .rh-main { padding:16px 16px 60px; }
183
+ .rh-cards { grid-template-columns:1fr 1fr; }
184
+ .rh-top { flex-wrap:wrap; }
185
+ }
186
+ @media (max-width:440px) { .rh-cards { grid-template-columns:1fr; } }
166
187
  .rh-field { margin-bottom:16px; max-width:640px; }
167
188
  .rh-field label { display:block; font-size:12px; color:var(--muted); margin-bottom:6px; }
168
189
  .rh-field input, .rh-field textarea { width:100%; background:var(--panel); border:1px solid var(--line); border-radius:9px; padding:9px 12px; color:var(--text); font:13px var(--mono); }
@@ -254,6 +275,7 @@
254
275
  fetch("<%= dashboard_stats_path %>", { headers: { Accept: "application/json" } })
255
276
  .then(function (r) { return r.json(); })
256
277
  .then(function (d) {
278
+ setConn(true);
257
279
  apply(d);
258
280
  var now = Date.now();
259
281
  var backlog = d.enqueued + d.scheduled + d.retries;
@@ -285,7 +307,13 @@
285
307
  }
286
308
  lastProcessed = d.processed; lastFailed = d.failed; lastBacklog = backlog; lastT = now;
287
309
  })
288
- .catch(function () {});
310
+ .catch(function () { setConn(false); });
311
+ }
312
+ // Surface a failed poll instead of letting the numbers silently go stale.
313
+ function setConn(ok) {
314
+ var el = document.getElementById("rh-live"), lbl = document.getElementById("rh-live-label");
315
+ if (el) el.classList.toggle("is-stale", !ok);
316
+ if (lbl) lbl.textContent = ok ? "live" : "reconnecting…";
289
317
  }
290
318
  function startOnce() { if (started) return; started = true; poll(); setInterval(poll, POLL_MS); }
291
319
  function wireChart() {
@@ -450,7 +478,7 @@
450
478
  <h1><%= yield :title %></h1>
451
479
  <span class="rh-crumb"><%= yield :crumb %></span>
452
480
  <span class="rh-spacer"></span>
453
- <span class="rh-live"><span class="rh-dot"></span> live · <span class="num" data-stat="rate">—</span>/min</span>
481
+ <span class="rh-live" id="rh-live"><span class="rh-dot"></span> <span id="rh-live-label">live</span> · <span class="num" data-stat="rate">—</span>/min</span>
454
482
  <% if RoundhouseUi.read_only %><span class="rh-ro">read-only</span><% end %>
455
483
  <button class="rh-kbd" id="rh-palette-open" type="button" title="Command palette (⌘K)">⌘K</button>
456
484
  <button class="rh-iconbtn" id="rh-width" type="button" title="Toggle full width" aria-label="Toggle full width">⟷</button>
@@ -6,6 +6,14 @@
6
6
 
7
7
  <h2 class="rh-h2">Dead set · <%= number_with_delimiter @total %> jobs</h2>
8
8
 
9
+ <% if @query.present? %>
10
+ <div class="rh-bulkbar">
11
+ <%= button_to "↻ Retry all matching", bulk_all_dead_path, method: :post, params: { op: "retry", q: @query }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Retry every dead job matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
12
+ <%= button_to "✕ Delete all matching", bulk_all_dead_path, method: :post, params: { op: "delete", q: @query }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every dead job matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
13
+ <span class="rh-sub">acts on every match for “<%= @query %>”, not just this page</span>
14
+ </div>
15
+ <% end %>
16
+
9
17
  <%= form_with url: bulk_dead_path, method: :post, data: { turbo: false } do %>
10
18
  <div class="rh-bulkbar">
11
19
  <button type="submit" name="op" value="retry" class="rh-btn">↻ Retry selected</button>
@@ -47,3 +47,24 @@
47
47
  <div class="d">backlog clears in, at current rate</div>
48
48
  </div>
49
49
  </div>
50
+
51
+ <% if RoundhouseUi.collect_durations %>
52
+ <h2 class="rh-h2">Slowest job classes <span class="hint">by total time · from RoundhouseUi::DurationCollector</span></h2>
53
+ <% if @durations.blank? %>
54
+ <div class="rh-panel"><div class="rh-empty">No durations recorded yet — jobs need to run with the collector middleware installed.</div></div>
55
+ <% else %>
56
+ <table class="rh-table">
57
+ <thead><tr><th>Job class</th><th class="r">Runs</th><th class="r">Avg</th><th class="r">Total time</th></tr></thead>
58
+ <tbody>
59
+ <% @durations.each do |d| %>
60
+ <tr>
61
+ <td class="rh-mono"><%= d[:klass] %></td>
62
+ <td class="r rh-mono"><%= number_with_delimiter d[:count] %></td>
63
+ <td class="r rh-mono <%= "rh-lat-warn" if d[:avg_ms] >= 1000 %>"><%= d[:avg_ms] >= 1000 ? "#{(d[:avg_ms] / 1000).round(1)}s" : "#{d[:avg_ms].round}ms" %></td>
64
+ <td class="r rh-mono"><%= d[:total_ms] >= 1000 ? "#{(d[:total_ms] / 1000).round(1)}s" : "#{d[:total_ms].round}ms" %></td>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ <% end %>
70
+ <% end %>
@@ -5,6 +5,15 @@
5
5
  </form>
6
6
 
7
7
  <h2 class="rh-h2">Retries · <%= number_with_delimiter @total %> jobs</h2>
8
+
9
+ <% if @query.present? %>
10
+ <div class="rh-bulkbar">
11
+ <%= button_to "↻ Run all matching now", bulk_all_retries_path, method: :post, params: { op: "retry", q: @query }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Run every retry matching “#{@query}” now (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
12
+ <%= button_to "✕ Delete all matching", bulk_all_retries_path, method: :post, params: { op: "delete", q: @query }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every retry matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
13
+ <span class="rh-sub">acts on every match for “<%= @query %>”, not just this page</span>
14
+ </div>
15
+ <% end %>
16
+
8
17
  <table class="rh-table">
9
18
  <thead><tr><th>Job</th><th>Last error</th><th class="r">Attempt</th><th class="r">Next try</th><th class="r">Actions</th></tr></thead>
10
19
  <tbody>
data/config/routes.rb CHANGED
@@ -30,13 +30,15 @@ RoundhouseUi::Engine.routes.draw do
30
30
  post "scheduled/:jid/delete" => "scheduled#destroy", as: :delete_scheduled
31
31
 
32
32
  get "retries" => "retries#index", as: :retries
33
+ post "retries/bulk_all" => "retries#bulk_all", as: :bulk_all_retries
33
34
  post "retries/:jid/run" => "retries#requeue", as: :run_retry
34
35
  post "retries/:jid/delete" => "retries#destroy", as: :delete_retry
35
36
 
36
37
  get "dead" => "dead#index", as: :dead_set
37
- post "dead/bulk" => "dead#bulk", as: :bulk_dead
38
- post "dead/:jid/retry" => "dead#requeue", as: :retry_dead_job
39
- post "dead/:jid/delete" => "dead#destroy", as: :delete_dead_job
38
+ post "dead/bulk" => "dead#bulk", as: :bulk_dead
39
+ post "dead/bulk_all" => "dead#bulk_all", as: :bulk_all_dead
40
+ post "dead/:jid/retry" => "dead#requeue", as: :retry_dead_job
41
+ post "dead/:jid/delete" => "dead#destroy", as: :delete_dead_job
40
42
 
41
43
  get "errors" => "errors#index", as: :errors
42
44
  get "capsules" => "capsules#index", as: :capsules
@@ -0,0 +1,53 @@
1
+ module RoundhouseUi
2
+ # Opt-in server middleware that records per-class execution time, so the UI can
3
+ # answer "which job classes are slow?" — something Sidekiq doesn't track. Two
4
+ # cheap Redis writes per job (a counter + a summed-ms float). Off by default;
5
+ # enable in your Sidekiq server config:
6
+ #
7
+ # Sidekiq.configure_server do |config|
8
+ # config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
9
+ # end
10
+ class DurationCollector
11
+ KEY = "roundhouse:durations".freeze
12
+
13
+ def call(_worker, job, _queue)
14
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ yield
16
+ ensure
17
+ record(job["class"], (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0)
18
+ end
19
+
20
+ private
21
+
22
+ def record(klass, elapsed_ms)
23
+ return unless klass
24
+
25
+ Sidekiq.redis do |conn|
26
+ conn.call("HINCRBY", KEY, "#{klass}\x00count", 1)
27
+ conn.call("HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms)
28
+ end
29
+ rescue => e
30
+ # Metrics collection must never break a job.
31
+ Sidekiq.logger&.warn("[roundhouse] duration collect failed: #{e.message}")
32
+ end
33
+
34
+ # [{ klass:, count:, total_ms:, avg_ms: }], slowest (by total time) first.
35
+ def self.summary(limit: 20)
36
+ raw = Sidekiq.redis { |conn| conn.call("HGETALL", KEY) }
37
+ pairs = raw.is_a?(Hash) ? raw : raw.each_slice(2).to_a # redis-client: flat array; redis-rb: hash
38
+
39
+ by_class = Hash.new { |h, k| h[k] = { klass: k, count: 0, total_ms: 0.0 } }
40
+ pairs.each do |field, value|
41
+ klass, kind = field.split("\x00", 2)
42
+ next unless kind
43
+
44
+ kind == "count" ? by_class[klass][:count] = value.to_i : by_class[klass][:total_ms] = value.to_f
45
+ end
46
+
47
+ by_class.values
48
+ .map { |r| r.merge(avg_ms: r[:count].positive? ? r[:total_ms] / r[:count] : 0.0) }
49
+ .sort_by { |r| -r[:total_ms] }
50
+ .first(limit)
51
+ end
52
+ end
53
+ end
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -12,6 +12,7 @@ require "roundhouse_ui/cancel_middleware"
12
12
  require "roundhouse_ui/metrics"
13
13
  require "roundhouse_ui/error_groups"
14
14
  require "roundhouse_ui/health"
15
+ require "roundhouse_ui/duration_collector"
15
16
 
16
17
  # Brand name is "Roundhouse"; the gem and Ruby namespace are RoundhouseUi
17
18
  # (matching the published gem name `roundhouse_ui`).
@@ -59,6 +60,12 @@ module RoundhouseUi
59
60
  # Sidekiq's retry/dead sets, so this is the only way to surface them here.
60
61
  attr_accessor :show_sidekiq_failures
61
62
 
63
+ # Opt-in: record per-class job durations (via RoundhouseUi::DurationCollector
64
+ # server middleware) so the Metrics page can show the slowest job classes.
65
+ # Default false; reads/writes a single Redis hash. The flag gates the UI; the
66
+ # collection itself is enabled by installing the middleware.
67
+ attr_accessor :collect_durations
68
+
62
69
  # Seconds between dashboard stat polls. Lower = livelier, but each poll also
63
70
  # re-runs the host's auth/routing on the mount, so a busy console can add DB
64
71
  # load. Default 5s; raise it if polling shows up in your traces.
@@ -93,4 +100,5 @@ module RoundhouseUi
93
100
  self.show_sidekiq_failures = false
94
101
  self.pause_enabled = true
95
102
  self.poll_interval = 5
103
+ self.collect_durations = false
96
104
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roundhouse_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - R.J. Robinson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -96,6 +96,7 @@ files:
96
96
  - lib/roundhouse_ui/audit.rb
97
97
  - lib/roundhouse_ui/cancel_middleware.rb
98
98
  - lib/roundhouse_ui/cancellation.rb
99
+ - lib/roundhouse_ui/duration_collector.rb
99
100
  - lib/roundhouse_ui/engine.rb
100
101
  - lib/roundhouse_ui/error_groups.rb
101
102
  - lib/roundhouse_ui/fetch.rb