roundhouse_ui 0.4.0 → 0.6.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: 30b9ee99930148d47a69b86e7a4c079231f8a2504a9a24aaa17a625e39e96429
4
- data.tar.gz: 333e7892afa4690288d35c671249f3fad6c3a19e304f9b4952346fe0f873a9fd
3
+ metadata.gz: 73ff2f1bc8db03a06168ee39d18b1de518e1a373860e1e0faafdbd08370d5eab
4
+ data.tar.gz: e1bb6668de35f02e64c369d016a21af989984f604a6009efba39160bd05b5b6a
5
5
  SHA512:
6
- metadata.gz: 9391d5af40b1fa0db7de47fab56009cf6d11bb80f2c16d4d65bc4b7b2b376eb7ac7cff595dfe0ebdaa0df401436f811cfc1f9a3d52028aef491c7d5821b0e322
7
- data.tar.gz: 8099ad548223bbb7d91f13a51b86e62010eb269f446d380c95fa4a416a1f4f95979631e9cbdd5115973bfa98d363ab04f5ef9d1801462a51ce215a5a28f57abe
6
+ metadata.gz: a27aa6ed4d0a91982e1997a6dcb6492a734edbfcbe3c4cf1c914366b4019b8ba49e922c7bfe59a45da662493d12b196c1f5bcd5ac47f9486248458877f42bb7f
7
+ data.tar.gz: faa3b64f3da583b3e361b431b8e6fa7b89b4c077a07cc981d54dfd2b20f248144c2798a1497aae4220de86fe385aca7f335b15d6b63c9d9648b22aa05f4acde5
data/README.md CHANGED
@@ -83,6 +83,11 @@ RoundhouseUi.configure do |c|
83
83
  # Fold sidekiq-failures' `failed` set into the Errors view (see below).
84
84
  # No-op unless the sidekiq-failures gem is loaded. Default: off.
85
85
  c.show_sidekiq_failures = true
86
+
87
+ # Set false to hide queue pause/resume controls and the "not enforced" warning,
88
+ # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
89
+ # Default: true.
90
+ # c.pause_enabled = false
86
91
  end
87
92
  ```
88
93
 
@@ -110,7 +115,8 @@ Roundhouse detects whether a fetcher has reported in).
110
115
  > ⚠️ **Sidekiq Pro/Enterprise users:** super_fetch / reliable fetch sets its own
111
116
  > `fetch_class`, and Sidekiq allows only one. Installing `RoundhouseUi::Fetch` would
112
117
  > **replace reliable fetch and lose its crash-recovery guarantees** — don't. On those
113
- > tiers, leave the fetch strategy out; pause/resume stays inert and the UI says so.
118
+ > tiers, leave the fetch strategy out and set `RoundhouseUi.pause_enabled = false` to
119
+ > hide the pause controls and the (otherwise permanent) "not enforced" warning.
114
120
 
115
121
  ## Surfacing sidekiq-failures
116
122
 
@@ -9,9 +9,7 @@ module RoundhouseUi
9
9
 
10
10
  def index
11
11
  @threshold = LONG_RUNNING
12
- @work = Sidekiq::WorkSet.new.map do |process_id, tid, work|
13
- { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
14
- end
12
+ @work = Sidekiq::WorkSet.new.map { |process_id, tid, work| normalize(process_id, tid, work) }
15
13
  end
16
14
 
17
15
  def cancel
@@ -21,6 +19,18 @@ module RoundhouseUi
21
19
 
22
20
  private
23
21
 
22
+ # Sidekiq 7+ yields a Sidekiq::Work (queue/run_at/job methods); Sidekiq 6.x
23
+ # yields a plain Hash (string keys, an epoch run_at, a JSON payload). Normalize
24
+ # both to the same shape the view expects (run_at as a Time, job as a JobRecord).
25
+ def normalize(process_id, tid, work)
26
+ if work.respond_to?(:queue)
27
+ { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
28
+ else
29
+ { process: process_id, tid: tid, queue: work["queue"],
30
+ run_at: Time.at(work["run_at"]), job: Sidekiq::JobRecord.new(work["payload"]) }
31
+ end
32
+ end
33
+
24
34
  def require_writable!
25
35
  return unless RoundhouseUi.read_only
26
36
  redirect_to busy_path, alert: "Roundhouse is in read-only mode — cancellation is disabled."
@@ -13,6 +13,10 @@ module RoundhouseUi
13
13
  capsules = Hash.new { |h, k| h[k] = { name: k, concurrency: 0, processes: 0, queues: {} } }
14
14
 
15
15
  Sidekiq::ProcessSet.new.each do |process|
16
+ # `capsules` is a Sidekiq 8.0.8+ Process attribute; absent on 6.x/early 7,
17
+ # where calling it would raise NoMethodError. Skip → view shows empty state.
18
+ next unless process.respond_to?(:capsules)
19
+
16
20
  (process.capsules || {}).each do |name, data|
17
21
  cap = capsules[name]
18
22
  cap[:concurrency] += data["concurrency"].to_i
@@ -9,5 +9,17 @@ module RoundhouseUi
9
9
 
10
10
  link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
11
11
  end
12
+
13
+ # Deep-link for a grouped error row (no single JID) — a class-wide search.
14
+ # respond_to? keeps older/custom adapters that lack error_url working.
15
+ def error_trace_link(klass:, error: nil)
16
+ adapter = RoundhouseUi.observability
17
+ return unless adapter.respond_to?(:error_url)
18
+
19
+ url = adapter.error_url(klass: klass, error: error)
20
+ return unless url
21
+
22
+ link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
23
+ end
12
24
  end
13
25
  end
@@ -157,7 +157,9 @@
157
157
  <script nonce="<%= content_nonce %>">
158
158
  (function () {
159
159
  var fmt = function (n) { return Number(n).toLocaleString(); };
160
- var started = false, lastProcessed = null, lastFailed = null, lastBacklog = null, lastT = null, series = [];
160
+ var started = false, lastProcessed = null, lastFailed = null, lastBacklog = null, lastT = null;
161
+ var POLL_MS = <%= (RoundhouseUi.poll_interval.to_f * 1000).round %>;
162
+ var samples = [], buckets = [], bucketStart = null; // samples = current bucket; buckets = finalized per-interval averages
161
163
  function setText(id, t) { var el = document.getElementById(id); if (el) el.textContent = t; }
162
164
  function humanizeEta(s) {
163
165
  if (s < 60) return "~" + Math.round(s) + "s";
@@ -166,15 +168,23 @@
166
168
  return "~" + (s / 86400).toFixed(1) + "d";
167
169
  }
168
170
 
171
+ // Bucket interval in seconds, from the picker — a new chart point lands
172
+ // once per interval, so "per 1m" advances every minute (not every poll).
173
+ function chartInterval() { var s = document.getElementById("rh-chart-interval"); return s ? parseInt(s.value, 10) : 30; }
174
+ function avg(a) { return a.length ? a.reduce(function (s, v) { return s + v; }, 0) / a.length : 0; }
169
175
  function draw() {
170
176
  var cv = document.getElementById("rh-chart"); if (!cv) return;
171
- var ctx = cv.getContext("2d"), w = cv.width, h = cv.height, pad = 6, n = series.length;
172
- ctx.clearRect(0, 0, w, h); if (n < 2) return;
173
- var max = Math.max.apply(null, series) * 1.25 || 1;
177
+ var ctx = cv.getContext("2d"), w = cv.width, h = cv.height, pad = 6;
178
+ ctx.clearRect(0, 0, w, h);
179
+ // finalized buckets + the in-progress bucket as a provisional last point,
180
+ // so the chart shows data immediately instead of waiting a full interval.
181
+ var pts = buckets.slice(-60); if (samples.length) pts = pts.concat([ avg(samples) ]);
182
+ var n = pts.length; if (n < 2) return;
183
+ var max = Math.max.apply(null, pts) * 1.25 || 1;
174
184
  var x = function (i) { return i / (n - 1) * w; }, y = function (v) { return h - pad - v / max * (h - pad * 2); };
175
185
  var g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, "rgba(110,139,255,.30)"); g.addColorStop(1, "rgba(110,139,255,0)");
176
- ctx.beginPath(); ctx.moveTo(0, h); series.forEach(function (v, i) { ctx.lineTo(x(i), y(v)); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = g; ctx.fill();
177
- ctx.beginPath(); series.forEach(function (v, i) { i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v)); }); ctx.strokeStyle = "#6E8BFF"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
186
+ ctx.beginPath(); ctx.moveTo(0, h); pts.forEach(function (v, i) { ctx.lineTo(x(i), y(v)); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = g; ctx.fill();
187
+ ctx.beginPath(); pts.forEach(function (v, i) { i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v)); }); ctx.strokeStyle = "#6E8BFF"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
178
188
  }
179
189
  function apply(d) {
180
190
  Object.keys(d).forEach(function (k) {
@@ -196,7 +206,15 @@
196
206
  var rate = dt > 0 ? Math.max(0, dp / dt) : 0;
197
207
  var re = document.querySelector('[data-stat="rate"]'); if (re) re.textContent = fmt(Math.round(rate * 60));
198
208
  var ne = document.getElementById("rh-chart-now"); if (ne) ne.textContent = Math.round(rate);
199
- series.push(rate); if (series.length > 60) series.shift(); draw();
209
+ // Accumulate into the current bucket; finalize it (one averaged point)
210
+ // once the chosen interval has elapsed. Live "/s" still updates each poll.
211
+ samples.push(rate);
212
+ if (bucketStart == null) bucketStart = now;
213
+ if ((now - bucketStart) / 1000 >= chartInterval()) {
214
+ buckets.push(avg(samples)); if (buckets.length > 240) buckets.shift();
215
+ samples = []; bucketStart = now;
216
+ }
217
+ draw();
200
218
 
201
219
  // Metrics tab live rates (these elements exist only on that page).
202
220
  setText("rh-m-throughput", Math.round(rate) + "/s");
@@ -212,7 +230,7 @@
212
230
  })
213
231
  .catch(function () {});
214
232
  }
215
- function startOnce() { if (started) return; started = true; poll(); setInterval(poll, 2500); }
233
+ function startOnce() { if (started) return; started = true; poll(); setInterval(poll, POLL_MS); }
216
234
  function syncTheme() { var b = document.getElementById("rh-theme"); if (b) b.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀" : "☾"; }
217
235
  function syncWidth() { var b = document.getElementById("rh-width"); if (b) b.classList.toggle("is-on", document.documentElement.getAttribute("data-width") === "full"); }
218
236
 
@@ -255,7 +273,7 @@
255
273
  { label: "Retries", path: "<%= retries_path %>", icon: "↻" },
256
274
  { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
257
275
  { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
258
- { label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },
276
+ <% if defined?(Sidekiq::Capsule) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
259
277
  { label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },
260
278
  { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
261
279
  { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
@@ -303,6 +321,18 @@
303
321
  if (e.target && e.target.id === "rh-palette") { palClose(); return; }
304
322
  if (e.target.closest && e.target.closest("#rh-palette-open")) { e.preventDefault(); palOpen(); }
305
323
  });
324
+ // Throughput interval picker — persist the choice; changing it re-buckets
325
+ // from here on, so reset the in-progress bucket and redraw.
326
+ function restoreChartInterval() {
327
+ var s = document.getElementById("rh-chart-interval"); if (!s) return;
328
+ try { var v = localStorage.getItem("rh-chart-iv"); if (v) s.value = v; } catch (_) {}
329
+ }
330
+ document.addEventListener("change", function (e) {
331
+ if (!e.target || e.target.id !== "rh-chart-interval") return;
332
+ try { localStorage.setItem("rh-chart-iv", e.target.value); } catch (_) {}
333
+ samples = []; bucketStart = null; draw();
334
+ });
335
+
306
336
  document.addEventListener("keydown", function (e) {
307
337
  if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { e.preventDefault(); var p = palEl(); (p && p.hidden) ? palOpen() : palClose(); return; }
308
338
  var p = palEl(); if (!p || p.hidden) return;
@@ -312,8 +342,8 @@
312
342
  else if (e.key === "Enter") { e.preventDefault(); if (palFiltered[palSel]) palRun(palFiltered[palSel]); }
313
343
  });
314
344
 
315
- document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); draw(); });
316
- document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); });
345
+ document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); draw(); });
346
+ document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); });
317
347
  document.addEventListener("visibilitychange", function () { if (!document.hidden) poll(); });
318
348
  })();
319
349
  </script>
@@ -343,7 +373,10 @@
343
373
  </nav>
344
374
  <nav class="rh-grp">
345
375
  <div class="rh-grp-label">Infrastructure</div>
346
- <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
376
+ <%# Capsules are a Sidekiq 7+ concept — hide the nav entirely on 6.x. %>
377
+ <% if defined?(Sidekiq::Capsule) %>
378
+ <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
379
+ <% end %>
347
380
  <%= nav_link "Redis", redis_info_path, icon: "◈" %>
348
381
  <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
349
382
  <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
@@ -27,7 +27,7 @@
27
27
  <div class="rh-card">
28
28
  <div class="k">Processed</div>
29
29
  <div class="v num" data-stat="processed"><%= number_with_delimiter @stats.processed %></div>
30
- <div class="d up">▲ <span class="num" data-stat="rate">—</span> / min</div>
30
+ <div class="d"><span class="num" data-stat="rate">—</span> / min</div>
31
31
  </div>
32
32
  <div class="rh-card">
33
33
  <div class="k">Failed · total</div>
@@ -42,7 +42,7 @@
42
42
  </div>
43
43
 
44
44
  <div class="rh-chart-wrap">
45
- <div class="top"><h3>Throughput</h3><span class="rh-sub">jobs / sec · live</span><span class="now"><span id="rh-chart-now">—</span>/s</span></div>
45
+ <div class="top"><h3>Throughput</h3><span class="rh-sub">jobs / sec · live</span><span class="now"><span id="rh-chart-now">—</span>/s</span><select id="rh-chart-interval" aria-label="Throughput interval" style="margin-left:12px;background:var(--panel-2);color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font:12px var(--sans)"><option value="10">per 10s</option><option value="30" selected>per 30s</option><option value="60">per 1m</option><option value="300">per 5m</option></select></div>
46
46
  <canvas id="rh-chart" width="1100" height="180"></canvas>
47
47
  </div>
48
48
 
@@ -13,7 +13,7 @@
13
13
  <% else %>
14
14
  <% @groups.each do |g| %>
15
15
  <tr>
16
- <td><%= g[:klass] %><br><span class="rh-err"><%= g[:error] %></span></td>
16
+ <td><%= g[:klass] %> <%= error_trace_link(klass: g[:klass], error: g[:error]) %><br><span class="rh-err"><%= g[:error] %></span></td>
17
17
  <td class="r"><%= number_with_delimiter g[:count] %></td>
18
18
  <td><span class="rh-sub"><%= g[:sources].join(", ") %></span></td>
19
19
  <td><span class="rh-sub"><%= g[:queues].join(", ") %></span></td>
@@ -4,7 +4,7 @@
4
4
  <div class="rh-toolbar"><%= link_to "+ Enqueue job", new_job_path, class: "rh-btn rh-btn-primary" %></div>
5
5
  <% end %>
6
6
 
7
- <% unless @fetch_installed %>
7
+ <% if RoundhouseUi.pause_enabled && !@fetch_installed %>
8
8
  <div class="rh-warn">
9
9
  Pausing is <strong>recorded but not enforced</strong> — no Roundhouse fetcher has reported in.
10
10
  Add it to your Sidekiq config: <code>config[:fetch_class] = RoundhouseUi::Fetch</code>
@@ -26,10 +26,12 @@
26
26
  <td class="r"><%= number_with_delimiter q.size %></td>
27
27
  <td class="r <%= "rh-lat-warn" if q.latency > 60 %>"><%= q.latency.round(2) %>s</td>
28
28
  <td class="r">
29
- <% if paused %>
30
- <%= button_to "Resume", resume_queue_path(q.name), method: :post, class: "rh-btn", form_class: "rh-inline" %>
31
- <% else %>
32
- <%= button_to "Pause", pause_queue_path(q.name), method: :post, class: "rh-btn", form_class: "rh-inline" %>
29
+ <% if RoundhouseUi.pause_enabled %>
30
+ <% if paused %>
31
+ <%= button_to "Resume", resume_queue_path(q.name), method: :post, class: "rh-btn", form_class: "rh-inline" %>
32
+ <% else %>
33
+ <%= button_to "Pause", pause_queue_path(q.name), method: :post, class: "rh-btn", form_class: "rh-inline" %>
34
+ <% end %>
33
35
  <% end %>
34
36
  <%= button_to "Snapshot", snapshot_queue_path(q.name), method: :post, class: "rh-btn", form_class: "rh-inline" %>
35
37
  <%= button_to "Purge", purge_queue_path(q.name),
@@ -1,15 +1,17 @@
1
1
  <% content_for :title, "Workers" %>
2
2
 
3
3
  <h2 class="rh-h2">Workers · <%= @processes.size %> <%= "process".pluralize(@processes.size) %></h2>
4
- <p class="rh-sub" style="margin:-4px 0 16px">
5
- Fetch strategy:
6
- <% if @fetch_active %>
7
- <span class="rh-st rh-st-ok">RoundhouseUi::Fetch — pause-aware</span>
8
- <% else %>
9
- <span class="rh-st rh-st-paused">default — pause not enforced</span>
10
- <% end %>
11
- <span class="rh-sub" style="margin-left:6px">crash-reliable fetch (super_fetch) is Sidekiq Pro</span>
12
- </p>
4
+ <% if RoundhouseUi.pause_enabled %>
5
+ <p class="rh-sub" style="margin:-4px 0 16px">
6
+ Fetch strategy:
7
+ <% if @fetch_active %>
8
+ <span class="rh-st rh-st-ok">RoundhouseUi::Fetch — pause-aware</span>
9
+ <% else %>
10
+ <span class="rh-st rh-st-paused">default — pause not enforced</span>
11
+ <% end %>
12
+ <span class="rh-sub" style="margin-left:6px">crash-reliable fetch (super_fetch) is Sidekiq Pro</span>
13
+ </p>
14
+ <% end %>
13
15
  <table class="rh-table">
14
16
  <thead><tr><th>Process</th><th class="r">Threads</th><th>Queues</th><th class="r">Started</th><th class="r">Heartbeat</th><th class="r">Actions</th></tr></thead>
15
17
  <tbody>
@@ -14,6 +14,7 @@ module RoundhouseUi
14
14
  def label = "trace"
15
15
  def job_url(**) = nil
16
16
  def queue_url(_name) = nil
17
+ def error_url(**) = nil
17
18
  end
18
19
 
19
20
  class DatadogAdapter
@@ -36,6 +37,16 @@ module RoundhouseUi
36
37
  traces_url([ "@sidekiq.queue:#{name}" ])
37
38
  end
38
39
 
40
+ # Grouped Errors rows have no single JID, so link to a class-wide search.
41
+ # The exact facet depends on your Datadog tagging; tune via `extra_query`
42
+ # if `resource_name` isn't how your Sidekiq spans are tagged.
43
+ def error_url(klass:, error: nil)
44
+ terms = [ "resource_name:#{klass}" ]
45
+ terms << "service:#{@service}" if @service
46
+ terms << @extra_query if @extra_query
47
+ traces_url(terms)
48
+ end
49
+
39
50
  private
40
51
 
41
52
  def traces_url(terms)
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -57,6 +57,18 @@ module RoundhouseUi
57
57
  # Sidekiq's retry/dead sets, so this is the only way to surface them here.
58
58
  attr_accessor :show_sidekiq_failures
59
59
 
60
+ # Seconds between dashboard stat polls. Lower = livelier, but each poll also
61
+ # re-runs the host's auth/routing on the mount, so a busy console can add DB
62
+ # load. Default 5s; raise it if polling shows up in your traces.
63
+ attr_accessor :poll_interval
64
+
65
+ # Queue pause/resume is only enforced when RoundhouseUi::Fetch is installed
66
+ # as the server's fetch strategy. If you run reliable fetch (Sidekiq
67
+ # Pro/Enterprise super_fetch) you can't also run our fetcher, so pause can't
68
+ # be enforced — set this to false to hide the pause controls and the
69
+ # "not enforced" warning entirely. Default: true.
70
+ attr_accessor :pause_enabled
71
+
60
72
  # Configure in an initializer:
61
73
  #
62
74
  # RoundhouseUi.configure do |c|
@@ -77,4 +89,6 @@ module RoundhouseUi
77
89
  self.allow_job_editing = false
78
90
  self.redact_args = []
79
91
  self.show_sidekiq_failures = false
92
+ self.pause_enabled = true
93
+ self.poll_interval = 5
80
94
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roundhouse_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - R.J. Robinson