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.
- checksums.yaml +4 -4
- data/README.md +124 -19
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
- data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
- data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
- data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
- data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
- data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
- data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
- data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
- data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
- data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
- data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
- data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
- data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
- data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
- data/lib/roundhouse_ui/cancellation.rb +43 -0
- data/lib/roundhouse_ui/duration_collector.rb +11 -4
- data/lib/roundhouse_ui/error_groups.rb +12 -1
- data/lib/roundhouse_ui/pause.rb +38 -3
- data/lib/roundhouse_ui/tags.rb +115 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +35 -0
- 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
|
-
|
|
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="
|
|
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
|
-
|
|
9
|
+
<%= render "roundhouse_ui/shared/tag_filter" %>
|
|
8
10
|
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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="
|
|
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><%=
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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="
|
|
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] %> <%=
|
|
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
|
|
20
|
-
<td class="r"><%= g[:last_at]
|
|
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"
|
|
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
|
-
|
|
9
|
+
<%= render "roundhouse_ui/shared/tag_filter" %>
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<
|
|
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="
|
|
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><%=
|
|
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"
|
|
31
|
-
<td class="r"
|
|
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
|
-
|
|
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
|
|
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
|
|
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><%=
|
|
17
|
-
|
|
18
|
-
<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
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|