roundhouse_ui 0.9.1 → 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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +61 -0
  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/error_groups.rb +12 -1
  25. data/lib/roundhouse_ui/tags.rb +115 -0
  26. data/lib/roundhouse_ui/version.rb +1 -1
  27. data/lib/roundhouse_ui.rb +35 -0
  28. 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,6 +14,7 @@ module RoundhouseUi
14
14
  def initialize(query: nil, limit: DEFAULT_SCAN_LIMIT)
15
15
  @query = query.to_s.strip
16
16
  @limit = limit
17
+ @tag_cache = {}
17
18
  end
18
19
 
19
20
  def call
@@ -34,12 +35,22 @@ module RoundhouseUi
34
35
  end
35
36
 
36
37
  list = groups.values.sort_by { |g| -g[:count] }
37
- list = list.select { |g| "#{g[:klass]} #{g[:error]}".downcase.include?(@query.downcase) } if @query.present?
38
+ list = list.select { |g| matches?(g) } if @query.present?
38
39
  Result.new(groups: list, scanned: scanned, truncated: truncated)
39
40
  end
40
41
 
41
42
  private
42
43
 
44
+ # Tag values are part of the haystack here for the same reason they are on
45
+ # the job sets: typing a squad name should find that squad's failures. A
46
+ # group's entries all share a class, so the tag is constant for the group
47
+ # and resolves once per group rather than per scanned entry.
48
+ def matches?(group)
49
+ haystack = [ group[:klass], group[:error] ]
50
+ haystack.concat(Tags.for(klass: group[:klass], item: {}, cache: @tag_cache).values) if RoundhouseUi.job_tags
51
+ haystack.join(" ").downcase.include?(@query.downcase)
52
+ end
53
+
43
54
  # Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
44
55
  # and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
45
56
  def sources