roundhouse_ui 0.9.0 → 0.10.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -19
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
  4. data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
  5. data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
  6. data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
  7. data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
  8. data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
  9. data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
  10. data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
  11. data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
  12. data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
  13. data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
  14. data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
  15. data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
  16. data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
  17. data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
  18. data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
  19. data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
  20. data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
  21. data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
  22. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  23. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
  24. data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
  25. data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
  26. data/lib/roundhouse_ui/cancellation.rb +43 -0
  27. data/lib/roundhouse_ui/duration_collector.rb +11 -4
  28. data/lib/roundhouse_ui/error_groups.rb +12 -1
  29. data/lib/roundhouse_ui/pause.rb +38 -3
  30. data/lib/roundhouse_ui/tags.rb +115 -0
  31. data/lib/roundhouse_ui/version.rb +1 -1
  32. data/lib/roundhouse_ui.rb +35 -0
  33. metadata +5 -2
@@ -88,6 +88,39 @@
88
88
  .rh-st-paused { background:var(--panel-3); color:var(--muted); }
89
89
  .rh-pill { font-size:11px; padding:2px 8px; border-radius:999px; font-family:var(--mono); background:var(--panel-2); color:var(--muted); }
90
90
  .rh-pill-warn { background:rgba(226,165,63,.16); color:var(--warn); }
91
+ .rh-pill-tag { background:var(--panel-2); color:var(--text); border:1px solid var(--line); max-width:22ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle; }
92
+ /* jid sits inline after the class now; dim it so it reads as a copy target
93
+ rather than competing with the job name. Still fully selectable. */
94
+ /* --muted, not --faint: the jid is secondary but still has to be readable
95
+ at a glance when you're comparing it against a log line. */
96
+ .rh-jid { color:var(--muted); opacity:1; font-size:11.5px; letter-spacing:.02em; margin-top:3px; }
97
+
98
+ /* Bulk actions. Two scopes — ticked rows vs every match — kept visually
99
+ separate, because confusing them destroys the wrong jobs. */
100
+ .rh-actionbar { display:flex; flex-wrap:wrap; align-items:stretch; gap:10px; margin-bottom:14px; }
101
+ .rh-actiongroup { display:flex; align-items:center; gap:7px; padding:7px 12px; border:1px solid var(--line-soft);
102
+ border-radius:11px; background:var(--panel); }
103
+ .rh-actionlabel { font-size:11px; letter-spacing:.05em; text-transform:uppercase; color:var(--faint);
104
+ font-weight:600; margin-right:3px; max-width:34ch; overflow:hidden; text-overflow:ellipsis;
105
+ white-space:nowrap; }
106
+ .rh-actiongroup form { display:inline; margin:0; }
107
+ /* Icon-only observability link for table rows. Sized like a button so it
108
+ sits on the Actions baseline without stretching the row. */
109
+ .rh-trace-ico { display:inline-block; width:24px; text-align:center; padding:5px 0; border-radius:7px;
110
+ border:1px solid transparent; text-decoration:none; }
111
+ .rh-trace-ico:hover { border-color:var(--accent); background:var(--panel-2); }
112
+ .rh-pill-link { text-decoration:none; border:1px solid transparent; }
113
+ .rh-pill-link:hover { border-color:var(--accent); color:var(--text); }
114
+ .rh-pill-link.is-on { border-color:var(--accent); color:var(--text); background:var(--panel-3); }
115
+
116
+ /* Quick filters: counts by tag, from the scan that already ran. */
117
+ .rh-quickfilters { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-bottom:14px; }
118
+ .rh-qf { display:inline-flex; align-items:center; gap:7px; font-size:12px; text-decoration:none;
119
+ background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:4px 10px; color:var(--muted); }
120
+ .rh-qf:hover { border-color:var(--accent); color:var(--text); }
121
+ .rh-qf.is-on { border-color:var(--accent); color:var(--text); background:var(--panel-2); }
122
+ .rh-qf-n { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:11px; color:var(--faint); }
123
+ .rh-qf.is-on .rh-qf-n { color:var(--accent); }
91
124
 
92
125
  .rh-btn { font:inherit; font-size:12px; background:var(--panel-2); color:var(--text); border:1px solid var(--line); padding:5px 11px; border-radius:7px; cursor:pointer; text-decoration:none; display:inline-block; }
93
126
  .rh-btn:hover { border-color:var(--accent); }
@@ -277,6 +310,7 @@
277
310
  .then(function (d) {
278
311
  setConn(true);
279
312
  apply(d);
313
+ if (d.queue_names) QUEUE_NAMES = d.queue_names; // feeds the ⌘K palette
280
314
  var now = Date.now();
281
315
  var backlog = d.enqueued + d.scheduled + d.retries;
282
316
  if (lastProcessed != null) {
@@ -352,7 +386,63 @@
352
386
  try { localStorage.setItem("rh-width", next); } catch (_) {}
353
387
  syncWidth();
354
388
  });
355
- // command palette (⌘K)
389
+ // command palette (⌘K). Two halves: static navigation, and search actions
390
+ // built from whatever you type — paste a JID, a queue, or a squad and the
391
+ // palette offers the searches that will actually find it, rather than
392
+ // making you pick a page first and search again once you're there.
393
+ // Capability-gated exactly like the sidebar: a backend without a retry
394
+ // set must not be offered a search into one.
395
+ var SETS = [
396
+ { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
397
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", icon: "↻" },<% end %>
398
+ { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
399
+ { label: "Errors", path: "<%= errors_path %>", icon: "⚠" }
400
+ ];
401
+ // Declared tag vocabulary and live queue names, so the palette can tell a
402
+ // squad from a queue from a free-text term.
403
+ var TAG_VOCAB = <%== (RoundhouseUi::Tags.filters || {}).to_json %>;
404
+ // Filled by the stats poll, which already loads queues for its count, so
405
+ // knowing the queue names costs no extra Redis work.
406
+ var QUEUE_NAMES = [];
407
+
408
+ function palQuery(q) {
409
+ var out = [], seen = {};
410
+ // A 12+ char hex string is a Sidekiq JID — offer the sets directly.
411
+ if (/^[0-9a-f]{12,}$/i.test(q)) {
412
+ SETS.forEach(function (s) {
413
+ out.push({ label: "Find job " + q + " in " + s.label, icon: s.icon,
414
+ path: s.path + "?q=" + encodeURIComponent(q) });
415
+ });
416
+ return out;
417
+ }
418
+ // Exact squad / tag value → the structured filter, which is stricter
419
+ // and safer than a substring match.
420
+ Object.keys(TAG_VOCAB).forEach(function (key) {
421
+ TAG_VOCAB[key].forEach(function (value) {
422
+ if (value.toLowerCase().indexOf(q) !== 0) return;
423
+ seen[value] = true;
424
+ SETS.forEach(function (s) {
425
+ out.push({ label: key + ": " + value + " — " + s.label, icon: "◆",
426
+ path: s.path + "?tag=" + encodeURIComponent(key + ":" + value) });
427
+ });
428
+ });
429
+ });
430
+ // Known queue name → filter by queue.
431
+ QUEUE_NAMES.forEach(function (name) {
432
+ if (name.toLowerCase().indexOf(q) !== 0) return;
433
+ SETS.slice(0, 3).forEach(function (s) {
434
+ out.push({ label: "Queue " + name + " — " + s.label, icon: "≣",
435
+ path: s.path + "?queue=" + encodeURIComponent(name) });
436
+ });
437
+ });
438
+ // Always offer plain text search as a fallback.
439
+ SETS.forEach(function (s) {
440
+ out.push({ label: "Search " + s.label + " for “" + q + "”", icon: s.icon,
441
+ path: s.path + "?q=" + encodeURIComponent(q) });
442
+ });
443
+ return out;
444
+ }
445
+
356
446
  var CMDS = [
357
447
  { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
358
448
  { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
@@ -383,8 +473,11 @@
383
473
  }
384
474
  function palRender() {
385
475
  var input = palInputEl(), list = palListEl(); if (!input || !list) return;
386
- var q = input.value.toLowerCase();
387
- palFiltered = CMDS.filter(function (c) { return c.label.toLowerCase().indexOf(q) !== -1; });
476
+ var q = input.value.trim().toLowerCase();
477
+ var nav = CMDS.filter(function (c) { return c.label.toLowerCase().indexOf(q) !== -1; });
478
+ // Search actions lead once you've typed: with a query, finding the job
479
+ // is the intent; jumping to a page is the fallback.
480
+ palFiltered = q ? palQuery(q).concat(nav) : nav;
388
481
  if (palSel >= palFiltered.length) palSel = Math.max(0, palFiltered.length - 1);
389
482
  list.innerHTML = "";
390
483
  palFiltered.forEach(function (c, i) {
@@ -433,6 +526,17 @@
433
526
  });
434
527
 
435
528
  document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); wireChart(); draw(); });
529
+
530
+ // Dead set "select all". Delegated from document rather than bound to the
531
+ // checkbox, because a Turbo visit (searching, paging, arriving via the
532
+ // nav) replaces <body> — the old element and its listener are discarded,
533
+ // and a re-inserted body script carries a nonce the original page's CSP
534
+ // does not accept. document survives both, so this binds once and keeps
535
+ // working. See issue #26.
536
+ document.addEventListener("change", function (e) {
537
+ if (!e.target || e.target.id !== "rh-all") return;
538
+ document.querySelectorAll(".rh-check").forEach(function (cb) { cb.checked = e.target.checked; });
539
+ });
436
540
  document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); wireChart(); });
437
541
  document.addEventListener("visibilitychange", function () { if (!document.hidden) poll(); });
438
542
  })();
@@ -500,7 +604,7 @@
500
604
 
501
605
  <div id="rh-palette" class="rh-palette" data-turbo-permanent hidden>
502
606
  <div class="rh-palette-box">
503
- <input id="rh-palette-input" type="text" placeholder="Jump to… (type to filter, ↑↓ to move, ↵ to go)" autocomplete="off">
607
+ <input id="rh-palette-input" type="text" placeholder="Search a job ID, queue, squad, or class — or jump to a page" autocomplete="off">
504
608
  <div id="rh-palette-list" class="rh-palette-list"></div>
505
609
  </div>
506
610
  </div>
@@ -57,7 +57,7 @@
57
57
  <% else %>
58
58
  <% @top_errors.each do |g| %>
59
59
  <div class="rh-insight-row">
60
- <div class="rh-insight-main"><%= link_to g[:klass], errors_path(q: g[:klass]), class: "rh-joblink" %> <%= error_trace_link(klass: g[:klass], error: g[:error]) %><br><span class="rh-err"><%= g[:error] %></span></div>
60
+ <div class="rh-insight-main"><%= link_to g[:klass], errors_path(q: g[:klass]), class: "rh-joblink" %> <%= tag_badges(tags_for_class(g[:klass])) %> <%= error_trace_link(klass: g[:klass], error: g[:error]) %><br><span class="rh-err"><%= g[:error] %></span></div>
61
61
  <span class="rh-pill"><%= number_with_delimiter g[:count] %></span>
62
62
  </div>
63
63
  <% end %>
@@ -2,39 +2,60 @@
2
2
 
3
3
  <form method="get" action="<%= dead_set_path %>" class="rh-search">
4
4
  <input type="search" name="q" value="<%= @query %>" placeholder="search class, jid, error, or arg value…">
5
+ <% if params[:tag].present? %><input type="hidden" name="tag" value="<%= params[:tag] %>"><% end %>
6
+ <% if params[:queue].present? %><input type="hidden" name="queue" value="<%= params[:queue] %>"><% end %>
5
7
  </form>
6
8
 
7
- <h2 class="rh-h2">Dead set · <%= number_with_delimiter @total %> jobs</h2>
9
+ <%= render "roundhouse_ui/shared/tag_filter" %>
8
10
 
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 %>
11
+ <%= set_heading("Dead set", showing: @jobs.size, total: @total, query: @query, tag: @tag) %>
16
12
 
17
- <%= form_with url: bulk_dead_path, method: :post, data: { turbo: false } do %>
18
- <div class="rh-bulkbar">
19
- <button type="submit" name="op" value="retry" class="rh-btn">↻ Retry selected</button>
20
- <button type="submit" name="op" value="delete" class="rh-btn rh-btn-danger">✕ Delete selected</button>
21
- <span class="rh-sub">tick rows, then choose an action</span>
13
+ <%# One toolbar, two scopes. They used to be stacked bars whose captions were the
14
+ only thing distinguishing "the rows you ticked" from "every job that matches",
15
+ which is a dangerous distinction to bury. The selected-rows buttons live here
16
+ but submit the form wrapping the table, via the HTML form= attribute. %>
17
+ <div class="rh-actionbar">
18
+ <div class="rh-actiongroup">
19
+ <span class="rh-actionlabel">Selected rows</span>
20
+ <button type="submit" form="rh-bulk-dead" name="op" value="retry" class="rh-btn">↻ Retry</button>
21
+ <button type="submit" form="rh-bulk-dead" name="op" value="delete" class="rh-btn rh-btn-danger">✕ Delete</button>
22
22
  </div>
23
+
24
+ <% if any_filter?(@query, @tag) %>
25
+ <div class="rh-actiongroup">
26
+ <span class="rh-actionlabel" title="<%= filter_description(@query, @tag) %>">
27
+ All <%= number_with_delimiter @total %> <%= filter_description(@query, @tag) %>
28
+ </span>
29
+ <%= button_to "↻ Retry", bulk_all_dead_path, method: :post, params: { op: "retry", q: @query, tag: params[:tag], queue: params[:queue] }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Retry every dead job #{filter_description(@query, @tag)} (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
30
+ <%= button_to "✕ Delete", bulk_all_dead_path, method: :post, params: { op: "delete", q: @query, tag: params[:tag], queue: params[:queue] }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every dead job #{filter_description(@query, @tag)} (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+
35
+ <%= form_with url: bulk_dead_path, method: :post, id: "rh-bulk-dead", data: { turbo: false } do %>
23
36
  <table class="rh-table">
24
- <thead><tr><th style="width:28px"><input type="checkbox" id="rh-all" aria-label="select all"></th><th>Job</th><th>Last error</th><th class="r">Died</th></tr></thead>
37
+ <thead><tr><th style="width:28px"><input type="checkbox" id="rh-all" aria-label="select all"></th><th>Job</th><% if tag_column? %><th><%= tag_column_label %></th><% end %><th>Queue</th><th>Last error</th><th class="r">Died</th><th class="r">Actions</th></tr></thead>
25
38
  <tbody>
26
39
  <% if @jobs.empty? %>
27
- <tr><td colspan="4" class="rh-empty"><%= @query.present? ? "No dead jobs match “#{@query}”." : "Dead set is empty 🎉" %></td></tr>
40
+ <tr><td colspan="<%= tag_column? ? 7 : 6 %>" class="rh-empty"><%= any_filter?(@query, @tag) ? "No dead jobs #{filter_description(@query, @tag)}." : "Dead set is empty 🎉" %></td></tr>
28
41
  <% else %>
29
42
  <% @jobs.each do |job| %>
30
43
  <tr>
31
44
  <td><input type="checkbox" name="jids[]" value="<%= job.jid %>" class="rh-check" aria-label="select <%= job.jid %>"></td>
32
- <td><%= link_to job.klass, job_path(set: "dead", jid: job.jid), class: "rh-joblink" %><br><span class="rh-sub"><%= job.jid %></span> <%= trace_link(klass: job.klass, jid: job.jid, queue: job.queue) %><% if RoundhouseUi.allow_job_editing %> <%= link_to "edit", edit_job_path(set: "dead", jid: job.jid), class: "rh-trace" %><% end %></td>
45
+ <td><%= job_identity(job.klass, job.jid, job_path(set: "dead", jid: job.jid)) %></td>
46
+ <% if tag_column? %><td><%= tag_cell(tags_for(job)) %></td><% end %>
47
+ <td><%= queue_pill(job.queue, link: true) %></td>
33
48
  <td>
34
49
  <span class="rh-err"><%= job.item["error_class"] %></span>
35
- <% if job.item["error_message"].present? %><br><span class="rh-sub"><%= truncate(job.item["error_message"], length: 90) %></span><% end %>
50
+ <% if job.item["error_message"].present? %><br><span class="rh-sub" title="<%= job.item["error_message"] %>"><%= truncate(job.item["error_message"], length: 90) %></span><% end %>
51
+ </td>
52
+ <td class="r"><%= job_time(job.at, overdue: "#{time_ago_in_words(job.at)} ago") %></td>
53
+ <td class="r">
54
+ <%= trace_icon(klass: job.klass, jid: job.jid, queue: job.queue) %>
55
+ <% if RoundhouseUi.allow_job_editing %><%= link_to "Edit", edit_job_path(set: "dead", jid: job.jid), class: "rh-btn" %><% end %>
56
+ <%= button_to "Retry", retry_dead_job_path(job.jid), method: :post, class: "rh-btn", form_class: "rh-inline" %>
57
+ <%= button_to "Delete", delete_dead_job_path(job.jid), method: :post, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Delete #{job.jid}?" } %>
36
58
  </td>
37
- <td class="r"><%= time_ago_in_words(job.at) %> ago</td>
38
59
  </tr>
39
60
  <% end %>
40
61
  <% end %>
@@ -43,12 +64,5 @@
43
64
  <% end %>
44
65
  <%= render "roundhouse_ui/shared/pager" %>
45
66
 
46
- <script nonce="<%= content_nonce %>">
47
- (function () {
48
- var all = document.getElementById("rh-all");
49
- if (!all) return;
50
- all.addEventListener("change", function () {
51
- document.querySelectorAll(".rh-check").forEach(function (cb) { cb.checked = all.checked; });
52
- });
53
- })();
54
- </script>
67
+ <%# The select-all handler lives in the layout head, delegated from document, so
68
+ it survives Turbo body swaps. Don't reintroduce a body script here. #26 %>
@@ -1,23 +1,44 @@
1
1
  <% content_for :title, "Errors" %>
2
2
 
3
3
  <form method="get" action="<%= errors_path %>" class="rh-search">
4
- <input type="search" name="q" value="<%= @query %>" placeholder="filter by job class or error…">
4
+ <input type="search" name="q" value="<%= @query %>" placeholder="filter by job class, error, or squad…">
5
+ <% if params[:tag].present? %><input type="hidden" name="tag" value="<%= params[:tag] %>"><% end %>
5
6
  </form>
6
7
 
7
- <h2 class="rh-h2">Errors · <%= @groups.size %> issues</h2>
8
+ <%# Counts come from the same scan that built the groups, so this costs no extra
9
+ Redis work. Each chip narrows the list below rather than sending you elsewhere. %>
10
+ <%= render "roundhouse_ui/shared/tag_filter", counts: @tag_counts %>
11
+
12
+ <h2 class="rh-h2">
13
+ Errors · <%= @groups.size %> issues
14
+ <% if @tag %><span class="hint">filtered to <%= @tag[0] %>: <%= @tag[1] %></span><% end %>
15
+ </h2>
16
+
8
17
  <table class="rh-table">
9
- <thead><tr><th>Issue</th><th class="r">Occurrences</th><th>In</th><th>Queues</th><th class="r">Last seen</th></tr></thead>
18
+ <thead>
19
+ <tr>
20
+ <th>Issue</th>
21
+ <% if tag_column? %><th><%= tag_column_label %></th><% end %>
22
+ <th class="r">Occurrences</th>
23
+ <th>In</th>
24
+ <th>Queues</th>
25
+ <th class="r">Last seen</th>
26
+ </tr>
27
+ </thead>
10
28
  <tbody>
11
29
  <% if @groups.empty? %>
12
- <tr><td colspan="5" class="rh-empty"><%= @query.present? ? "No issues match “#{@query}”." : "No failing jobs 🎉" %></td></tr>
30
+ <tr><td colspan="<%= tag_column? ? 6 : 5 %>" class="rh-empty">
31
+ <%= @query.present? || @tag ? "No issues match the current filter." : "No failing jobs 🎉" %>
32
+ </td></tr>
13
33
  <% else %>
14
34
  <% @groups.each do |g| %>
15
35
  <tr>
16
- <td><%= g[:klass] %> <%= error_trace_link(klass: g[:klass], error: g[:error]) %><br><span class="rh-err"><%= g[:error] %></span></td>
36
+ <td><%= g[:klass] %> <%= error_trace_icon(klass: g[:klass], error: g[:error]) %><br><span class="rh-err"><%= g[:error] %></span></td>
37
+ <% if tag_column? %><td><%= tag_cell(@group_tags[g[:klass]]) %></td><% end %>
17
38
  <td class="r"><%= number_with_delimiter g[:count] %></td>
18
39
  <td><span class="rh-sub"><%= g[:sources].join(", ") %></span></td>
19
- <td><span class="rh-sub"><%= g[:queues].join(", ") %></span></td>
20
- <td class="r"><%= g[:last_at] ? "#{time_ago_in_words(g[:last_at])} ago" : "—" %></td>
40
+ <td><%= safe_join(g[:queues].map { |q| queue_pill(q) }, " ") %></td>
41
+ <td class="r"><%= job_time(g[:last_at], overdue: "#{time_ago_in_words(g[:last_at])} ago" ) if g[:last_at] %><%= content_tag(:span, "—", class: "rh-sub") unless g[:last_at] %></td>
21
42
  </tr>
22
43
  <% end %>
23
44
  <% end %>
@@ -5,6 +5,7 @@
5
5
  <span class="rh-tag"><%= @set %></span>
6
6
  <span class="rh-tag">queue <%= @entry.queue %></span>
7
7
  <% if item["retry_count"] %><span class="rh-tag">retry <%= item["retry_count"] %></span><% end %>
8
+ <%= tag_badges(tags_for(@entry)) %>
8
9
  <span class="rh-tag rh-mono"><%= @entry.jid %></span>
9
10
  </div>
10
11
 
@@ -1,5 +1,9 @@
1
1
  <% content_for :title, "Queues" %>
2
2
 
3
+ <form method="get" action="<%= queues_path %>" class="rh-search">
4
+ <input type="search" name="q" value="<%= @query %>" placeholder="filter queues by name…">
5
+ </form>
6
+
3
7
  <% if RoundhouseUi.allow_job_editing %>
4
8
  <div class="rh-toolbar"><%= link_to "+ Enqueue job", new_job_path, class: "rh-btn rh-btn-primary" %></div>
5
9
  <% end %>
@@ -16,12 +20,12 @@
16
20
  <thead><tr><th>Queue</th><th>State</th><th class="r">Size</th><th class="r">Latency</th><th class="r">Actions</th></tr></thead>
17
21
  <tbody>
18
22
  <% if @queues.empty? %>
19
- <tr><td colspan="5" class="rh-empty">No active queues</td></tr>
23
+ <tr><td colspan="5" class="rh-empty"><%= @query.present? ? "No queues match “#{@query}”." : "No active queues" %></td></tr>
20
24
  <% else %>
21
25
  <% @queues.each do |q| %>
22
26
  <% paused = @paused.include?(q.name) %>
23
27
  <tr>
24
- <td><%= q.name %></td>
28
+ <td><%= queue_pill(q.name) %></td>
25
29
  <td><% if paused %><span class="rh-pill rh-pill-warn">paused</span><% else %><span class="rh-sub">active</span><% end %></td>
26
30
  <td class="r"><%= number_with_delimiter q.size %></td>
27
31
  <td class="r <%= "rh-lat-warn" if q.latency > 60 %>"><%= q.latency.round(2) %>s</td>
@@ -2,34 +2,45 @@
2
2
 
3
3
  <form method="get" action="<%= retries_path %>" class="rh-search">
4
4
  <input type="search" name="q" value="<%= @query %>" placeholder="search class, jid, error, or arg value…">
5
+ <% if params[:tag].present? %><input type="hidden" name="tag" value="<%= params[:tag] %>"><% end %>
6
+ <% if params[:queue].present? %><input type="hidden" name="queue" value="<%= params[:queue] %>"><% end %>
5
7
  </form>
6
8
 
7
- <h2 class="rh-h2">Retries · <%= number_with_delimiter @total %> jobs</h2>
9
+ <%= render "roundhouse_ui/shared/tag_filter" %>
8
10
 
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>
11
+ <%= set_heading("Retries", showing: @jobs.size, total: @total, query: @query, tag: @tag) %>
12
+
13
+ <% if any_filter?(@query, @tag) %>
14
+ <div class="rh-actionbar">
15
+ <div class="rh-actiongroup">
16
+ <span class="rh-actionlabel" title="<%= filter_description(@query, @tag) %>">
17
+ All <%= number_with_delimiter @total %> <%= filter_description(@query, @tag) %>
18
+ </span>
19
+ <%= button_to "↻ Run now", bulk_all_retries_path, method: :post, params: { op: "retry", q: @query, tag: params[:tag], queue: params[:queue] }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Run every retry #{filter_description(@query, @tag)} now (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
20
+ <%= button_to "✕ Delete", bulk_all_retries_path, method: :post, params: { op: "delete", q: @query, tag: params[:tag], queue: params[:queue] }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every retry #{filter_description(@query, @tag)} (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
21
+ </div>
14
22
  </div>
15
23
  <% end %>
16
24
 
17
25
  <table class="rh-table">
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>
26
+ <thead><tr><th>Job</th><% if tag_column? %><th><%= tag_column_label %></th><% end %><th>Queue</th><th>Last error</th><th class="r">Attempt</th><th class="r">Next try</th><th class="r">Actions</th></tr></thead>
19
27
  <tbody>
20
28
  <% if @jobs.empty? %>
21
- <tr><td colspan="5" class="rh-empty"><%= @query.present? ? "No retries match “#{@query}”." : "Nothing waiting to retry 🎉" %></td></tr>
29
+ <tr><td colspan="<%= tag_column? ? 7 : 6 %>" class="rh-empty"><%= any_filter?(@query, @tag) ? "No retries #{filter_description(@query, @tag)}." : "Nothing waiting to retry 🎉" %></td></tr>
22
30
  <% else %>
23
31
  <% @jobs.each do |job| %>
24
32
  <tr>
25
- <td><%= link_to job.klass, job_path(set: "retry", jid: job.jid), class: "rh-joblink" %><br><span class="rh-sub"><%= job.jid %></span> <%= trace_link(klass: job.klass, jid: job.jid, queue: job.queue) %></td>
33
+ <td><%= job_identity(job.klass, job.jid, job_path(set: "retry", jid: job.jid)) %></td>
34
+ <% if tag_column? %><td><%= tag_cell(tags_for(job)) %></td><% end %>
35
+ <td><%= queue_pill(job.queue, link: true) %></td>
26
36
  <td>
27
37
  <span class="rh-err"><%= job.item["error_class"] %></span>
28
- <% if job.item["error_message"].present? %><br><span class="rh-sub"><%= truncate(job.item["error_message"], length: 90) %></span><% end %>
38
+ <% if job.item["error_message"].present? %><br><span class="rh-sub" title="<%= job.item["error_message"] %>"><%= truncate(job.item["error_message"], length: 90) %></span><% end %>
29
39
  </td>
30
- <td class="r">#<%= job.item["retry_count"].to_i + 1 %></td>
31
- <td class="r"><span class="rh-sub"><%= job.at && job.at > Time.now ? "in #{distance_of_time_in_words(Time.now, job.at)}" : "now" %></span></td>
40
+ <td class="r"><%= job.item["retry_count"].to_i + 1 %></td>
41
+ <td class="r"><%= job_time(job.at, overdue: "now") %></td>
32
42
  <td class="r">
43
+ <%= trace_icon(klass: job.klass, jid: job.jid, queue: job.queue) %>
33
44
  <% if RoundhouseUi.allow_job_editing %><%= link_to "Edit", edit_job_path(set: "retry", jid: job.jid), class: "rh-btn" %><% end %>
34
45
  <%= button_to "Run now", run_retry_path(job.jid), method: :post, class: "rh-btn", form_class: "rh-inline" %>
35
46
  <%= button_to "Delete", delete_retry_path(job.jid), method: :post, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Delete #{job.jid}?" } %>
@@ -2,21 +2,27 @@
2
2
 
3
3
  <form method="get" action="<%= scheduled_path %>" class="rh-search">
4
4
  <input type="search" name="q" value="<%= @query %>" placeholder="search class, jid, or arg value…">
5
+ <% if params[:tag].present? %><input type="hidden" name="tag" value="<%= params[:tag] %>"><% end %>
6
+ <% if params[:queue].present? %><input type="hidden" name="queue" value="<%= params[:queue] %>"><% end %>
5
7
  </form>
6
8
 
7
- <h2 class="rh-h2">Scheduled · <%= number_with_delimiter @total %> jobs</h2>
9
+ <%= render "roundhouse_ui/shared/tag_filter" %>
10
+
11
+ <%= set_heading("Scheduled", showing: @jobs.size, total: @total, query: @query, tag: @tag) %>
8
12
  <table class="rh-table">
9
- <thead><tr><th>Job</th><th>Queue</th><th class="r">Runs</th><th class="r">Actions</th></tr></thead>
13
+ <thead><tr><th>Job</th><% if tag_column? %><th><%= tag_column_label %></th><% end %><th>Queue</th><th class="r">Runs</th><th class="r">Actions</th></tr></thead>
10
14
  <tbody>
11
15
  <% if @jobs.empty? %>
12
- <tr><td colspan="4" class="rh-empty"><%= @query.present? ? "No scheduled jobs match “#{@query}”." : "Nothing scheduled" %></td></tr>
16
+ <tr><td colspan="<%= tag_column? ? 5 : 4 %>" class="rh-empty"><%= any_filter?(@query, @tag) ? "No scheduled jobs #{filter_description(@query, @tag)}." : "Nothing scheduled" %></td></tr>
13
17
  <% else %>
14
18
  <% @jobs.each do |job| %>
15
19
  <tr>
16
- <td><%= link_to job.klass, job_path(set: "scheduled", jid: job.jid), class: "rh-joblink" %><br><span class="rh-sub"><%= job.jid %></span></td>
17
- <td><span class="rh-sub"><%= job.queue %></span></td>
18
- <td class="r"><span class="rh-sub"><%= job.at && job.at > Time.now ? "in #{distance_of_time_in_words(Time.now, job.at)}" : "now (overdue)" %></span></td>
20
+ <td><%= job_identity(job.klass, job.jid, job_path(set: "scheduled", jid: job.jid)) %></td>
21
+ <% if tag_column? %><td><%= tag_cell(tags_for(job)) %></td><% end %>
22
+ <td><%= queue_pill(job.queue, link: true) %></td>
23
+ <td class="r"><%= job_time(job.at) %></td>
19
24
  <td class="r">
25
+ <%= trace_icon(klass: job.klass, jid: job.jid, queue: job.queue) %>
20
26
  <% if RoundhouseUi.allow_job_editing %><%= link_to "Edit", edit_job_path(set: "scheduled", jid: job.jid), class: "rh-btn" %><% end %>
21
27
  <%= button_to "Enqueue now", enqueue_scheduled_path(job.jid), method: :post, class: "rh-btn", form_class: "rh-inline" %>
22
28
  <%= button_to "Delete", delete_scheduled_path(job.jid), method: :post, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Delete #{job.jid}?" } %>
@@ -1,13 +1,13 @@
1
1
  <% if @page > 1 || @has_next %>
2
2
  <div class="rh-pager">
3
3
  <% if @page > 1 %>
4
- <%= link_to "← Prev", url_for(page: @page - 1, q: @query.presence), class: "rh-btn" %>
4
+ <%= link_to "← Prev", url_for(page: @page - 1, q: @query.presence, tag: params[:tag].presence, queue: params[:queue].presence), class: "rh-btn" %>
5
5
  <% else %>
6
6
  <span class="rh-btn" style="opacity:.4; pointer-events:none">← Prev</span>
7
7
  <% end %>
8
8
  <span class="rh-sub">Page <%= @page %></span>
9
9
  <% if @has_next %>
10
- <%= link_to "Next →", url_for(page: @page + 1, q: @query.presence), class: "rh-btn" %>
10
+ <%= link_to "Next →", url_for(page: @page + 1, q: @query.presence, tag: params[:tag].presence, queue: params[:queue].presence), class: "rh-btn" %>
11
11
  <% else %>
12
12
  <span class="rh-btn" style="opacity:.4; pointer-events:none">Next →</span>
13
13
  <% end %>
@@ -0,0 +1,30 @@
1
+ <%# Tag filter chips (ADR 0002 step 4), shared by the job sets and Errors.
2
+ Errors passes `counts` because grouping already scanned every entry; the job
3
+ sets don't, since browse deliberately reads only enough to fill one page and
4
+ counting would turn a 25-row render into a full-set scan. %>
5
+ <%# .presence matters: an empty counts hash is truthy, so treating it as "we have
6
+ counts" while falling back to the uncounted vocabulary made the two branches
7
+ disagree and blew up sorting a Hash-of-Arrays as if it were Hash-of-counts. %>
8
+ <% counts = local_assigns[:counts].presence %>
9
+ <% if RoundhouseUi.job_tags %>
10
+ <% vocabulary = counts || tag_vocabulary %>
11
+ <% if vocabulary.present? %>
12
+ <div class="rh-quickfilters">
13
+ <% vocabulary.each do |key, values| %>
14
+ <% values = values.sort_by { |_, count| -count } if counts %>
15
+ <% values.each do |value, count| %>
16
+ <% active = @tag == [ key.to_s, value.to_s ] %>
17
+ <%= link_to url_for(only_path: true, page: nil, q: @query.presence,
18
+ tag: (active ? nil : "#{key}:#{value}"),
19
+ queue: params[:queue].presence),
20
+ class: "rh-qf#{' is-on' if active}", title: "#{key}: #{value}" do %>
21
+ <%= value %><% if count %><span class="rh-qf-n"><%= number_with_delimiter count %></span><% end %>
22
+ <% end %>
23
+ <% end %>
24
+ <% end %>
25
+ <% if @tag %>
26
+ <%= link_to "clear", url_for(only_path: true, tag: nil, page: nil, q: @query.presence, queue: params[:queue].presence), class: "rh-sub" %>
27
+ <% end %>
28
+ </div>
29
+ <% end %>
30
+ <% end %>
@@ -14,10 +14,17 @@ module RoundhouseUi
14
14
  def name = "Sidekiq"
15
15
 
16
16
  # Capabilities let the UI hide what a backend can't do. Sidekiq supports
17
- # all the sets/views; pause is NOT native (needs the fetcher), so it does
18
- # not advertise :native_pause — the "not enforced" warning still applies.
17
+ # all the sets/views. On OSS Sidekiq pause is NOT native (it needs our
18
+ # fetcher), so :native_pause is withheld and the "not enforced" warning
19
+ # applies — but Sidekiq Pro ships its own enforced pause, so there it is
20
+ # advertised and the warning drops away.
19
21
  CAPABILITIES = %i[retries dead scheduled busy workers redis capsules].freeze
20
- def supports?(capability) = CAPABILITIES.include?(capability)
22
+
23
+ def supports?(capability)
24
+ return RoundhouseUi::Pause.native? if capability == :native_pause
25
+
26
+ CAPABILITIES.include?(capability)
27
+ end
21
28
 
22
29
  def stats = ::Sidekiq::Stats.new
23
30
  def queues = ::Sidekiq::Queue.all
@@ -7,9 +7,15 @@ module RoundhouseUi
7
7
  # Sidekiq.configure_server do |config|
8
8
  # config.server_middleware { |chain| chain.add RoundhouseUi::CancelMiddleware }
9
9
  # end
10
+ #
11
+ # Hot-path cost: near zero. Cancellation.pending? answers "anything cancelled
12
+ # at all?" from a process-local gate (one EXISTS per CHECK_EVERY seconds, not
13
+ # per job); the exact per-job SISMEMBER only runs while cancellations are
14
+ # actually pending. State lives on the Cancellation module because Sidekiq
15
+ # builds a fresh middleware instance per job — ivars here wouldn't survive.
10
16
  class CancelMiddleware
11
17
  def call(_worker, job, _queue)
12
- if RoundhouseUi::Cancellation.cancelled?(job["jid"])
18
+ if RoundhouseUi::Cancellation.pending? && RoundhouseUi::Cancellation.cancelled?(job["jid"])
13
19
  RoundhouseUi::Cancellation.clear!(job["jid"])
14
20
  return # acknowledge without running
15
21
  end
@@ -12,6 +12,9 @@ module RoundhouseUi
12
12
  module Cancellation
13
13
  KEY = "roundhouse:cancelled"
14
14
  TTL = 86_400 # seconds
15
+ CHECK_EVERY = 2.0 # seconds — max staleness of the "nothing is cancelled" gate
16
+
17
+ @gate = Mutex.new # guards @pending / @checked_at (see .pending?)
15
18
 
16
19
  module_function
17
20
 
@@ -20,6 +23,12 @@ module RoundhouseUi
20
23
  conn.call("SADD", KEY, jid.to_s)
21
24
  conn.call("EXPIRE", KEY, TTL)
22
25
  end
26
+ # Bust the local gate: the cancelling process sees its own cancel
27
+ # immediately; other processes converge within CHECK_EVERY.
28
+ @gate.synchronize do
29
+ @pending = true
30
+ @checked_at = monotonic_now
31
+ end
23
32
  end
24
33
 
25
34
  def cancelled?(jid)
@@ -33,5 +42,39 @@ module RoundhouseUi
33
42
  def cancelled_jids
34
43
  Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) }
35
44
  end
45
+
46
+ # The middleware's hot-path gate: "is anything cancelled at all?" — almost
47
+ # always no, so answering it with one EXISTS per process per CHECK_EVERY
48
+ # (instead of a SISMEMBER per job) takes the common case to zero Redis
49
+ # round-trips. While cancellations are pending, the middleware still does
50
+ # the exact per-job SISMEMBER. A cancel issued by *another* process is
51
+ # invisible here for up to CHECK_EVERY — acceptable because cancellation
52
+ # is cooperative and racy by design (a job may already be running when the
53
+ # flag lands), and cancel! busts the local gate so the cancelling process
54
+ # itself is always exact.
55
+ def pending?
56
+ now = monotonic_now
57
+ @gate.synchronize do
58
+ if @checked_at.nil? || now - @checked_at >= CHECK_EVERY
59
+ @pending = any?
60
+ @checked_at = now
61
+ end
62
+ @pending
63
+ end
64
+ end
65
+
66
+ def any?
67
+ Sidekiq.redis { |conn| conn.call("EXISTS", KEY) } == 1
68
+ end
69
+
70
+ # Forget the cached gate state (tests; or after flushing Redis by hand).
71
+ def reset_gate!
72
+ @gate.synchronize do
73
+ @pending = nil
74
+ @checked_at = nil
75
+ end
76
+ end
77
+
78
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
79
  end
37
80
  end