roundhouse_ui 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +166 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/javascripts/roundhouse_ui/turbo.min.js +35 -0
  6. data/app/assets/stylesheets/roundhouse_ui/application.css +15 -0
  7. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +41 -0
  8. data/app/controllers/roundhouse_ui/application_controller.rb +75 -0
  9. data/app/controllers/roundhouse_ui/assets_controller.rb +16 -0
  10. data/app/controllers/roundhouse_ui/audit_controller.rb +7 -0
  11. data/app/controllers/roundhouse_ui/busy_controller.rb +29 -0
  12. data/app/controllers/roundhouse_ui/capsules_controller.rb +27 -0
  13. data/app/controllers/roundhouse_ui/dashboard_controller.rb +26 -0
  14. data/app/controllers/roundhouse_ui/dead_controller.rb +46 -0
  15. data/app/controllers/roundhouse_ui/errors_controller.rb +50 -0
  16. data/app/controllers/roundhouse_ui/jobs_controller.rb +94 -0
  17. data/app/controllers/roundhouse_ui/metrics_controller.rb +8 -0
  18. data/app/controllers/roundhouse_ui/queues_controller.rb +40 -0
  19. data/app/controllers/roundhouse_ui/redis_controller.rb +21 -0
  20. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -0
  21. data/app/controllers/roundhouse_ui/scheduled_controller.rb +34 -0
  22. data/app/controllers/roundhouse_ui/snapshots_controller.rb +26 -0
  23. data/app/controllers/roundhouse_ui/workers_controller.rb +33 -0
  24. data/app/helpers/roundhouse_ui/application_helper.rb +4 -0
  25. data/app/helpers/roundhouse_ui/nav_helper.rb +24 -0
  26. data/app/helpers/roundhouse_ui/observability_helper.rb +13 -0
  27. data/app/views/layouts/roundhouse_ui/application.html.erb +365 -0
  28. data/app/views/roundhouse_ui/audit/index.html.erb +21 -0
  29. data/app/views/roundhouse_ui/busy/index.html.erb +23 -0
  30. data/app/views/roundhouse_ui/capsules/index.html.erb +22 -0
  31. data/app/views/roundhouse_ui/dashboard/show.html.erb +68 -0
  32. data/app/views/roundhouse_ui/dead/index.html.erb +46 -0
  33. data/app/views/roundhouse_ui/errors/index.html.erb +28 -0
  34. data/app/views/roundhouse_ui/jobs/_form.html.erb +24 -0
  35. data/app/views/roundhouse_ui/jobs/edit.html.erb +2 -0
  36. data/app/views/roundhouse_ui/jobs/new.html.erb +2 -0
  37. data/app/views/roundhouse_ui/jobs/show.html.erb +33 -0
  38. data/app/views/roundhouse_ui/metrics/show.html.erb +49 -0
  39. data/app/views/roundhouse_ui/queues/index.html.erb +45 -0
  40. data/app/views/roundhouse_ui/redis/show.html.erb +59 -0
  41. data/app/views/roundhouse_ui/retries/index.html.erb +33 -0
  42. data/app/views/roundhouse_ui/scheduled/index.html.erb +29 -0
  43. data/app/views/roundhouse_ui/shared/_pager.html.erb +15 -0
  44. data/app/views/roundhouse_ui/snapshots/index.html.erb +25 -0
  45. data/app/views/roundhouse_ui/workers/index.html.erb +39 -0
  46. data/config/routes.rb +54 -0
  47. data/lib/roundhouse_ui/audit.rb +25 -0
  48. data/lib/roundhouse_ui/cancel_middleware.rb +19 -0
  49. data/lib/roundhouse_ui/cancellation.rb +37 -0
  50. data/lib/roundhouse_ui/engine.rb +5 -0
  51. data/lib/roundhouse_ui/fetch.rb +36 -0
  52. data/lib/roundhouse_ui/metrics.rb +51 -0
  53. data/lib/roundhouse_ui/observability.rb +46 -0
  54. data/lib/roundhouse_ui/pause.rb +59 -0
  55. data/lib/roundhouse_ui/redaction.rb +33 -0
  56. data/lib/roundhouse_ui/snapshots.rb +90 -0
  57. data/lib/roundhouse_ui/version.rb +3 -0
  58. data/lib/roundhouse_ui.rb +73 -0
  59. data/lib/tasks/roundhouse_ui_tasks.rake +4 -0
  60. metadata +131 -0
@@ -0,0 +1,365 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Roundhouse</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <%= yield :head %>
8
+ <style nonce="<%= content_nonce %>">
9
+ :root {
10
+ --bg:#0B0E14; --panel:#10141D; --panel-2:#151A24; --panel-3:#1A2030;
11
+ --line:#1E2533; --line-soft:#171C26; --text:#EAEDF3; --muted:#939DAF; --faint:#5A6475;
12
+ --accent:#6E8BFF; --accent-2:#9B8CFC; --good:#44C58C; --warn:#E2A53F; --crit:#E66A60;
13
+ --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
14
+ --sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
15
+ }
16
+ :root[data-theme="light"]{
17
+ --bg:#F5F6F9; --panel:#FFFFFF; --panel-2:#EEF1F6; --panel-3:#E6EAF2;
18
+ --line:#DCE1EA; --line-soft:#E7EBF1; --text:#1A1F2B; --muted:#5C6677; --faint:#97A0B1;
19
+ --accent:#4F68E8; --accent-2:#7E6CF0; --good:#1FA46B; --warn:#B97D17; --crit:#D24A40;
20
+ }
21
+ /* dark bg on <html> kills the white flash between full-page navigations */
22
+ html { background:#0B0E14; color-scheme:dark; }
23
+ :root[data-theme="light"], html:has(:root[data-theme="light"]) { color-scheme:light; }
24
+ * { box-sizing:border-box; }
25
+ body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 var(--sans); -webkit-font-smoothing:antialiased; }
26
+ .num { font-family:var(--mono); font-variant-numeric:tabular-nums; }
27
+ a { color:inherit; }
28
+
29
+ .rh-app { display:grid; grid-template-columns:236px 1fr; min-height:100vh; }
30
+ .rh-rail { background:#090C11; border-right:1px solid var(--line-soft); padding:20px 14px; display:flex; flex-direction:column; gap:22px; position:sticky; top:0; height:100vh; overflow-y:auto; }
31
+ :root[data-theme="light"] .rh-rail { background:#FBFCFE; }
32
+ .rh-brand { display:flex; align-items:center; gap:11px; padding:2px 6px; }
33
+ .rh-brand .glyph { width:30px; height:30px; border-radius:8px; background:linear-gradient(140deg,var(--accent),var(--accent-2)); display:grid; place-items:center; color:#0B0E14; font-size:16px; font-weight:800; }
34
+ .rh-brand b { font-size:15px; letter-spacing:-.01em; display:block; }
35
+ .rh-brand small { font-size:11px; color:var(--faint); }
36
+ .rh-grp { display:flex; flex-direction:column; gap:2px; }
37
+ .rh-grp-label { font-size:10px; letter-spacing:.13em; text-transform:uppercase; color:var(--faint); padding:0 8px 6px; font-weight:600; }
38
+ .rh-nav { display:flex; align-items:center; gap:11px; padding:7px 10px; border-radius:9px; color:var(--muted); text-decoration:none; border:1px solid transparent; }
39
+ .rh-nav:hover { background:var(--panel); color:var(--text); }
40
+ .rh-nav.is-active { background:var(--panel-2); color:var(--text); border-color:var(--line); }
41
+ .rh-nav .rh-ico { width:18px; text-align:center; font-size:14px; opacity:.85; }
42
+ .rh-nav .rh-lbl { flex:1; }
43
+ .rh-badge { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:11px; padding:1px 7px; border-radius:999px; background:var(--panel-2); color:var(--muted); min-width:8px; text-align:center; }
44
+ .rh-badge:empty { display:none; }
45
+ .rh-badge.warn { background:rgba(226,165,63,.16); color:var(--warn); }
46
+ .rh-badge.crit { background:rgba(230,106,96,.16); color:var(--crit); }
47
+
48
+ .rh-main { padding:22px 30px 70px; max-width:1180px; }
49
+ .rh-top { display:flex; align-items:center; gap:14px; margin-bottom:22px; }
50
+ .rh-top h1 { font-size:21px; font-weight:650; letter-spacing:-.02em; margin:0; }
51
+ .rh-crumb { color:var(--faint); font-size:13px; }
52
+ .rh-spacer { flex:1; }
53
+ .rh-live { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
54
+ .rh-dot { width:7px; height:7px; border-radius:50%; background:var(--good); position:relative; }
55
+ .rh-dot::after { content:""; position:absolute; inset:0; border-radius:50%; background:var(--good); animation:rh-ping 2.4s ease-out infinite; }
56
+ @keyframes rh-ping { 0%{transform:scale(1);opacity:.7} 70%{transform:scale(3.4);opacity:0} 100%{opacity:0} }
57
+ @media (prefers-reduced-motion:reduce){ .rh-dot::after{animation:none} }
58
+ .rh-iconbtn { width:34px; height:34px; border-radius:9px; cursor:pointer; background:var(--panel); border:1px solid var(--line); color:var(--muted); font-size:15px; }
59
+ .rh-iconbtn:hover { color:var(--text); border-color:var(--accent); }
60
+ .rh-ro { font-size:11px; padding:3px 9px; border-radius:7px; background:rgba(226,165,63,.16); color:var(--warn); font-weight:600; }
61
+
62
+ .rh-flash { padding:10px 14px; font-size:13px; border-radius:10px; margin-bottom:16px; }
63
+ .rh-flash-ok { background:rgba(68,197,140,.12); color:var(--good); border:1px solid rgba(68,197,140,.3); }
64
+ .rh-flash-bad { background:rgba(226,165,63,.12); color:var(--warn); border:1px solid rgba(226,165,63,.35); }
65
+
66
+ .rh-h2 { font-size:13px; font-weight:600; margin:0 0 12px; display:flex; align-items:baseline; gap:10px; }
67
+ .rh-h2 .hint { font-size:12px; color:var(--faint); font-weight:400; }
68
+ .rh-panel { background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; overflow:hidden; }
69
+
70
+ table.rh-table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; overflow:hidden; }
71
+ .rh-table th { text-align:left; font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); font-weight:600; padding:11px 16px; border-bottom:1px solid var(--line-soft); }
72
+ .rh-table th.r, .rh-table td.r { text-align:right; }
73
+ .rh-table td { padding:12px 16px; border-bottom:1px solid var(--line-soft); }
74
+ .rh-table tr:last-child td { border-bottom:none; }
75
+ .rh-mono { font-family:var(--mono); font-variant-numeric:tabular-nums; }
76
+ .rh-sub { color:var(--faint); font-size:12px; }
77
+ .rh-err { color:var(--crit); font-family:var(--mono); font-size:12px; }
78
+ .rh-empty { color:var(--faint); text-align:center; padding:30px 16px; }
79
+ .rh-trace { color:var(--accent); text-decoration:none; font-size:11px; }
80
+ .rh-trace:hover { text-decoration:underline; }
81
+
82
+ .rh-st { display:inline-flex; align-items:center; font-size:11px; font-weight:600; padding:2px 9px; border-radius:6px; }
83
+ .rh-st-ok { background:rgba(68,197,140,.14); color:var(--good); }
84
+ .rh-st-warn { background:rgba(226,165,63,.16); color:var(--warn); }
85
+ .rh-st-crit { background:rgba(230,106,96,.16); color:var(--crit); }
86
+ .rh-st-paused { background:var(--panel-3); color:var(--muted); }
87
+ .rh-pill { font-size:11px; padding:2px 8px; border-radius:999px; font-family:var(--mono); background:var(--panel-2); color:var(--muted); }
88
+ .rh-pill-warn { background:rgba(226,165,63,.16); color:var(--warn); }
89
+
90
+ .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; }
91
+ .rh-btn:hover { border-color:var(--accent); }
92
+ .rh-btn-danger:hover { border-color:var(--crit); color:var(--crit); }
93
+ .rh-inline { display:inline; margin:0; }
94
+
95
+ .rh-search { margin-bottom:16px; }
96
+ .rh-search input { width:100%; background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:10px 14px; color:var(--text); font:13px var(--mono); }
97
+ .rh-search input:focus { outline:none; border-color:var(--accent); }
98
+ .rh-bulkbar { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
99
+ .rh-note { color:var(--faint); font-size:12px; margin-top:14px; }
100
+ .rh-warn { background:rgba(226,165,63,.12); border:1px solid rgba(226,165,63,.4); color:var(--warn); border-radius:10px; padding:11px 14px; margin-bottom:18px; font-size:13px; }
101
+ .rh-warn code { background:rgba(0,0,0,.25); padding:1px 6px; border-radius:5px; font-size:12px; }
102
+ .rh-lat-warn { color:var(--warn); }
103
+
104
+ /* dashboard */
105
+ .rh-alerts { display:flex; flex-direction:column; gap:8px; margin-bottom:22px; }
106
+ .rh-alert { display:flex; align-items:center; gap:12px; background:var(--panel); border:1px solid var(--line-soft); border-left:3px solid var(--crit); border-radius:10px; padding:11px 14px; font-size:13px; }
107
+ .rh-alert.warn { border-left-color:var(--warn); }
108
+ .rh-alert .msg { flex:1; } .rh-alert .msg b { font-family:var(--mono); }
109
+ .rh-cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:16px; }
110
+ .rh-card { background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; padding:16px 17px; }
111
+ .rh-card .k { font-size:12px; color:var(--muted); margin-bottom:9px; }
112
+ .rh-card .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:26px; letter-spacing:-.02em; line-height:1; }
113
+ .rh-card .v small { font-size:14px; color:var(--faint); }
114
+ .rh-card .d { font-size:12px; margin-top:8px; color:var(--faint); }
115
+ .rh-card .d.up { color:var(--good); } .rh-card .d.bad { color:var(--crit); }
116
+ .rh-card .pill { display:inline-flex; align-items:center; gap:6px; font-size:13px; font-weight:600; padding:4px 10px; border-radius:7px; margin-bottom:9px; background:rgba(226,165,63,.14); color:var(--warn); }
117
+ .rh-card .pill.ok { background:rgba(68,197,140,.14); color:var(--good); }
118
+ .rh-chart-wrap { background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; padding:18px 20px 10px; margin-bottom:24px; }
119
+ .rh-chart-wrap .top { display:flex; align-items:baseline; gap:12px; margin-bottom:6px; }
120
+ .rh-chart-wrap h3 { font-size:13px; font-weight:600; margin:0; }
121
+ .rh-chart-wrap .now { margin-left:auto; font-family:var(--mono); font-size:13px; color:var(--accent); }
122
+ canvas#rh-chart { display:block; width:100%; height:90px; }
123
+ .rh-cb { width:15px; height:15px; }
124
+ .rh-field { margin-bottom:16px; max-width:640px; }
125
+ .rh-field label { display:block; font-size:12px; color:var(--muted); margin-bottom:6px; }
126
+ .rh-field input, .rh-field textarea { width:100%; background:var(--panel); border:1px solid var(--line); border-radius:9px; padding:9px 12px; color:var(--text); font:13px var(--mono); }
127
+ .rh-field input:focus, .rh-field textarea:focus { outline:none; border-color:var(--accent); }
128
+ .rh-btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; }
129
+ .rh-btn-primary:hover { filter:brightness(1.08); border-color:var(--accent); }
130
+ .rh-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
131
+ .rh-pager { display:flex; align-items:center; gap:14px; margin-top:16px; }
132
+ .rh-pre { font:12px/1.6 var(--mono); color:var(--muted); background:var(--bg); border:1px solid var(--line-soft); border-radius:10px; padding:12px 14px; overflow-x:auto; white-space:pre; }
133
+ .rh-tags { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:18px; }
134
+ .rh-tag { font-size:12px; padding:3px 9px; border-radius:6px; background:var(--panel-2); color:var(--muted); }
135
+ .rh-sec { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint); font-weight:600; margin:22px 0 8px; }
136
+ .rh-joblink { color:inherit; text-decoration:none; }
137
+ .rh-joblink:hover { text-decoration:underline; }
138
+ .rh-kbd { font:11px var(--mono); color:var(--faint); background:var(--panel); border:1px solid var(--line); border-radius:7px; padding:6px 9px; cursor:pointer; }
139
+ .rh-kbd:hover { color:var(--text); border-color:var(--accent); }
140
+ .rh-palette[hidden] { display:none; }
141
+ .rh-palette { position:fixed; inset:0; background:rgba(5,7,11,.55); z-index:80; display:flex; align-items:flex-start; justify-content:center; padding-top:14vh; backdrop-filter:blur(2px); }
142
+ .rh-palette-box { width:560px; max-width:92vw; background:var(--panel); border:1px solid var(--line); border-radius:14px; box-shadow:0 18px 55px rgba(0,0,0,.55); overflow:hidden; }
143
+ .rh-palette-box input { width:100%; background:none; border:none; border-bottom:1px solid var(--line-soft); padding:15px 18px; color:var(--text); font:15px var(--sans); outline:none; }
144
+ .rh-palette-list { max-height:340px; overflow-y:auto; padding:6px; }
145
+ .rh-palette-item { display:flex; align-items:center; gap:12px; padding:9px 12px; border-radius:9px; cursor:pointer; font-size:13px; }
146
+ .rh-palette-item.sel { background:var(--panel-2); }
147
+ .rh-palette-ico { width:20px; text-align:center; color:var(--muted); }
148
+ </style>
149
+ <%# Apply the saved theme before first paint so it persists across navigations with no flash. %>
150
+ <script nonce="<%= content_nonce %>">
151
+ (function () { try { var t = localStorage.getItem("rh-theme"); if (t) document.documentElement.setAttribute("data-theme", t); } catch (e) {} })();
152
+ </script>
153
+ <%# Turbo Drive: link navigation without full-page reloads (kills the flicker). %>
154
+ <script src="<%= turbo_js_path %>"></script>
155
+ <script nonce="<%= content_nonce %>">
156
+ (function () {
157
+ var fmt = function (n) { return Number(n).toLocaleString(); };
158
+ var started = false, lastProcessed = null, lastFailed = null, lastBacklog = null, lastT = null, series = [];
159
+ function setText(id, t) { var el = document.getElementById(id); if (el) el.textContent = t; }
160
+ function humanizeEta(s) {
161
+ if (s < 60) return "~" + Math.round(s) + "s";
162
+ if (s < 3600) return "~" + Math.round(s / 60) + "m";
163
+ if (s < 86400) return "~" + (s / 3600).toFixed(1) + "h";
164
+ return "~" + (s / 86400).toFixed(1) + "d";
165
+ }
166
+
167
+ function draw() {
168
+ var cv = document.getElementById("rh-chart"); if (!cv) return;
169
+ var ctx = cv.getContext("2d"), w = cv.width, h = cv.height, pad = 6, n = series.length;
170
+ ctx.clearRect(0, 0, w, h); if (n < 2) return;
171
+ var max = Math.max.apply(null, series) * 1.25 || 1;
172
+ var x = function (i) { return i / (n - 1) * w; }, y = function (v) { return h - pad - v / max * (h - pad * 2); };
173
+ var g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, "rgba(110,139,255,.30)"); g.addColorStop(1, "rgba(110,139,255,0)");
174
+ ctx.beginPath(); ctx.moveTo(0, h); series.forEach(function (v, i) { ctx.lineTo(x(i), y(v)); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = g; ctx.fill();
175
+ ctx.beginPath(); series.forEach(function (v, i) { i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v)); }); ctx.strokeStyle = "#6E8BFF"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
176
+ }
177
+ function apply(d) {
178
+ Object.keys(d).forEach(function (k) {
179
+ document.querySelectorAll('[data-stat="' + k + '"]').forEach(function (el) { el.textContent = fmt(d[k]); });
180
+ document.querySelectorAll('[data-nav="' + k + '"]').forEach(function (el) { el.textContent = fmt(d[k]); });
181
+ });
182
+ }
183
+ function poll() {
184
+ if (document.hidden) return;
185
+ fetch("<%= dashboard_stats_path %>", { headers: { Accept: "application/json" } })
186
+ .then(function (r) { return r.json(); })
187
+ .then(function (d) {
188
+ apply(d);
189
+ var now = Date.now();
190
+ var backlog = d.enqueued + d.scheduled + d.retries;
191
+ if (lastProcessed != null) {
192
+ var dt = (now - lastT) / 1000;
193
+ var dp = d.processed - lastProcessed;
194
+ var rate = dt > 0 ? Math.max(0, dp / dt) : 0;
195
+ var re = document.querySelector('[data-stat="rate"]'); if (re) re.textContent = fmt(Math.round(rate * 60));
196
+ var ne = document.getElementById("rh-chart-now"); if (ne) ne.textContent = Math.round(rate);
197
+ series.push(rate); if (series.length > 60) series.shift(); draw();
198
+
199
+ // Metrics tab live rates (these elements exist only on that page).
200
+ setText("rh-m-throughput", Math.round(rate) + "/s");
201
+ var fr = dp > 0 ? Math.max(0, (d.failed - lastFailed) / dp) : 0;
202
+ setText("rh-m-failrate", (fr * 100).toFixed(1) + "%");
203
+ if (lastBacklog != null && dt > 0) {
204
+ var vel = (backlog - lastBacklog) / dt;
205
+ setText("rh-m-velocity", (vel >= 0 ? "+" : "") + vel.toFixed(1) + "/s");
206
+ }
207
+ setText("rh-m-eta", backlog === 0 ? "clear" : (rate > 0 ? humanizeEta(backlog / rate) : "—"));
208
+ }
209
+ lastProcessed = d.processed; lastFailed = d.failed; lastBacklog = backlog; lastT = now;
210
+ })
211
+ .catch(function () {});
212
+ }
213
+ function startOnce() { if (started) return; started = true; poll(); setInterval(poll, 2500); }
214
+ function syncTheme() { var b = document.getElementById("rh-theme"); if (b) b.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀" : "☾"; }
215
+
216
+ // The sidebar is turbo-permanent (so its badges don't flicker), so we move
217
+ // the active-nav highlight ourselves on each navigation.
218
+ var ROOT = "<%= root_path %>".replace(/\/$/, "");
219
+ function setActiveNav() {
220
+ var path = location.pathname.replace(/\/$/, "");
221
+ document.querySelectorAll("#rh-rail .rh-nav").forEach(function (a) {
222
+ var href = (a.getAttribute("href") || "").replace(/\/$/, "");
223
+ var active = href === path || (href && href !== ROOT && path.indexOf(href) === 0);
224
+ a.classList.toggle("is-active", !!active);
225
+ });
226
+ }
227
+
228
+ // theme toggle via delegation so it survives Turbo body swaps
229
+ document.addEventListener("click", function (e) {
230
+ var b = e.target.closest && e.target.closest("#rh-theme"); if (!b) return;
231
+ var next = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
232
+ document.documentElement.setAttribute("data-theme", next);
233
+ try { localStorage.setItem("rh-theme", next); } catch (_) {}
234
+ syncTheme();
235
+ });
236
+ // command palette (⌘K)
237
+ var CMDS = [
238
+ { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
239
+ { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
240
+ { label: "Busy", path: "<%= busy_path %>", icon: "◐" },
241
+ { label: "Workers", path: "<%= workers_path %>", icon: "◷" },
242
+ { label: "Queues", path: "<%= queues_path %>", icon: "≡" },
243
+ { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
244
+ { label: "Retries", path: "<%= retries_path %>", icon: "↻" },
245
+ { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
246
+ { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
247
+ { label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },
248
+ { label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },
249
+ { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
250
+ { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
251
+ <% if RoundhouseUi.allow_job_editing %>{ label: "Enqueue job", path: "<%= new_job_path %>", icon: "+" },<% end %>
252
+ { label: "Toggle theme", action: "theme", icon: "◐" }
253
+ ];
254
+ var palSel = 0, palFiltered = [];
255
+ function palEl() { return document.getElementById("rh-palette"); }
256
+ function palInputEl() { return document.getElementById("rh-palette-input"); }
257
+ function palListEl() { return document.getElementById("rh-palette-list"); }
258
+ function palMark() {
259
+ var l = palListEl(); if (!l) return;
260
+ var it = l.children;
261
+ for (var i = 0; i < it.length; i++) it[i].className = "rh-palette-item" + (i === palSel ? " sel" : "");
262
+ if (it[palSel]) it[palSel].scrollIntoView({ block: "nearest" });
263
+ }
264
+ function palRender() {
265
+ var input = palInputEl(), list = palListEl(); if (!input || !list) return;
266
+ var q = input.value.toLowerCase();
267
+ palFiltered = CMDS.filter(function (c) { return c.label.toLowerCase().indexOf(q) !== -1; });
268
+ if (palSel >= palFiltered.length) palSel = Math.max(0, palFiltered.length - 1);
269
+ list.innerHTML = "";
270
+ palFiltered.forEach(function (c, i) {
271
+ var d = document.createElement("div");
272
+ d.className = "rh-palette-item" + (i === palSel ? " sel" : "");
273
+ var s = document.createElement("span"); s.className = "rh-palette-ico"; s.textContent = c.icon;
274
+ d.appendChild(s); d.appendChild(document.createTextNode(c.label));
275
+ d.addEventListener("click", function () { palRun(c); });
276
+ d.addEventListener("mousemove", function () { palSel = i; palMark(); });
277
+ list.appendChild(d);
278
+ });
279
+ }
280
+ function palRun(c) {
281
+ palClose();
282
+ if (c.action === "theme") { var b = document.getElementById("rh-theme"); if (b) b.click(); return; }
283
+ if (c.path) { window.Turbo ? Turbo.visit(c.path) : (location.href = c.path); }
284
+ }
285
+ function palOpen() { var p = palEl(), i = palInputEl(); if (!p || !i) return; p.hidden = false; i.value = ""; palSel = 0; palRender(); i.focus(); }
286
+ function palClose() { var p = palEl(); if (p) p.hidden = true; }
287
+
288
+ document.addEventListener("input", function (e) { if (e.target && e.target.id === "rh-palette-input") { palSel = 0; palRender(); } });
289
+ document.addEventListener("click", function (e) {
290
+ if (e.target && e.target.id === "rh-palette") { palClose(); return; }
291
+ if (e.target.closest && e.target.closest("#rh-palette-open")) { e.preventDefault(); palOpen(); }
292
+ });
293
+ document.addEventListener("keydown", function (e) {
294
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { e.preventDefault(); var p = palEl(); (p && p.hidden) ? palOpen() : palClose(); return; }
295
+ var p = palEl(); if (!p || p.hidden) return;
296
+ if (e.key === "Escape") palClose();
297
+ else if (e.key === "ArrowDown") { e.preventDefault(); palSel = Math.min(palSel + 1, palFiltered.length - 1); palMark(); }
298
+ else if (e.key === "ArrowUp") { e.preventDefault(); palSel = Math.max(palSel - 1, 0); palMark(); }
299
+ else if (e.key === "Enter") { e.preventDefault(); if (palFiltered[palSel]) palRun(palFiltered[palSel]); }
300
+ });
301
+
302
+ document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); setActiveNav(); draw(); });
303
+ document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); setActiveNav(); });
304
+ document.addEventListener("visibilitychange", function () { if (!document.hidden) poll(); });
305
+ })();
306
+ </script>
307
+ </head>
308
+ <body class="rh-body">
309
+ <div class="rh-app">
310
+ <aside class="rh-rail" id="rh-rail" data-turbo-permanent>
311
+ <div class="rh-brand"><div class="glyph">⎈</div><div><b>Roundhouse</b><small>sidekiq console</small></div></div>
312
+
313
+ <nav class="rh-grp">
314
+ <div class="rh-grp-label">Overview</div>
315
+ <%= nav_link "Dashboard", root_path, icon: "▦" %>
316
+ <%= nav_link "Metrics", metrics_path, icon: "▤" %>
317
+ <%= nav_link "Busy", busy_path, icon: "◐", badge: "busy" %>
318
+ <%= nav_link "Workers", workers_path, icon: "◷" %>
319
+ </nav>
320
+ <nav class="rh-grp">
321
+ <div class="rh-grp-label">Work</div>
322
+ <%= nav_link "Queues", queues_path, icon: "≡", badge: "queues" %>
323
+ <%= nav_link "Scheduled", scheduled_path, icon: "◔", badge: "scheduled" %>
324
+ <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
325
+ <%= nav_link "Errors", errors_path, icon: "⚠" %>
326
+ <%= nav_link "Dead", dead_set_path, icon: "✕", badge: "dead", badge_class: "crit" %>
327
+ <% if RoundhouseUi.allow_job_editing %>
328
+ <%= nav_link "Enqueue job", new_job_path, icon: "+" %>
329
+ <% end %>
330
+ </nav>
331
+ <nav class="rh-grp">
332
+ <div class="rh-grp-label">Infrastructure</div>
333
+ <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
334
+ <%= nav_link "Redis", redis_info_path, icon: "◈" %>
335
+ <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
336
+ <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
337
+ </nav>
338
+ </aside>
339
+
340
+ <main class="rh-main">
341
+ <header class="rh-top">
342
+ <h1><%= yield :title %></h1>
343
+ <span class="rh-crumb"><%= yield :crumb %></span>
344
+ <span class="rh-spacer"></span>
345
+ <span class="rh-live"><span class="rh-dot"></span> live · <span class="num" data-stat="rate">—</span>/min</span>
346
+ <% if RoundhouseUi.read_only %><span class="rh-ro">read-only</span><% end %>
347
+ <button class="rh-kbd" id="rh-palette-open" type="button" title="Command palette (⌘K)">⌘K</button>
348
+ <button class="rh-iconbtn" id="rh-theme" type="button" title="Toggle theme" aria-label="Toggle theme">☾</button>
349
+ </header>
350
+
351
+ <% if flash[:notice] %><div class="rh-flash rh-flash-ok"><%= flash[:notice] %></div><% end %>
352
+ <% if flash[:alert] %><div class="rh-flash rh-flash-bad"><%= flash[:alert] %></div><% end %>
353
+
354
+ <%= yield %>
355
+ </main>
356
+ </div>
357
+
358
+ <div id="rh-palette" class="rh-palette" data-turbo-permanent hidden>
359
+ <div class="rh-palette-box">
360
+ <input id="rh-palette-input" type="text" placeholder="Jump to… (type to filter, ↑↓ to move, ↵ to go)" autocomplete="off">
361
+ <div id="rh-palette-list" class="rh-palette-list"></div>
362
+ </div>
363
+ </div>
364
+ </body>
365
+ </html>
@@ -0,0 +1,21 @@
1
+ <% content_for :title, "Audit log" %>
2
+
3
+ <h2 class="rh-h2">Audit log <span class="hint">recent state-changing actions, newest first</span></h2>
4
+ <table class="rh-table">
5
+ <thead><tr><th>Actor</th><th>Action</th><th>Target</th><th class="r">When</th></tr></thead>
6
+ <tbody>
7
+ <% if @entries.empty? %>
8
+ <tr><td colspan="4" class="rh-empty">No actions recorded yet.</td></tr>
9
+ <% else %>
10
+ <% @entries.each do |e| %>
11
+ <tr>
12
+ <td class="rh-mono" style="color:var(--accent)"><%= e["actor"] %></td>
13
+ <td><%= e["action"] %></td>
14
+ <td class="rh-mono"><%= e["target"] %></td>
15
+ <td class="r rh-sub"><%= e["at"] ? "#{time_ago_in_words(Time.at(e["at"]))} ago" : "—" %></td>
16
+ </tr>
17
+ <% end %>
18
+ <% end %>
19
+ </tbody>
20
+ </table>
21
+ <p class="rh-note">Set <code>RoundhouseUi.actor_resolver</code> to attribute actions to the signed-in user instead of “anonymous”.</p>
@@ -0,0 +1,23 @@
1
+ <% content_for :title, "Busy" %>
2
+
3
+ <h2 class="rh-h2">Busy <span class="hint"><%= @work.size %> <%= "job".pluralize(@work.size) %> running now</span></h2>
4
+ <table class="rh-table">
5
+ <thead><tr><th>Job</th><th>Queue</th><th>Worker</th><th class="r">Running for</th><th class="r">Actions</th></tr></thead>
6
+ <tbody>
7
+ <% if @work.empty? %>
8
+ <tr><td colspan="5" class="rh-empty">No jobs running right now.</td></tr>
9
+ <% else %>
10
+ <% @work.each do |w| %>
11
+ <% elapsed = (Time.now - w[:run_at]).to_i %>
12
+ <% long = elapsed >= @threshold %>
13
+ <tr>
14
+ <td><%= w[:job].klass %><br><span class="rh-sub"><%= w[:job].jid %></span></td>
15
+ <td><span class="rh-sub"><%= w[:queue] %></span></td>
16
+ <td><span class="rh-sub"><%= w[:process] %></span></td>
17
+ <td class="r rh-mono <%= "rh-lat-warn" if long %>"><%= elapsed < 60 ? "#{elapsed}s" : distance_of_time_in_words(0, elapsed) %><%= " ⚠" if long %></td>
18
+ <td class="r"><%= button_to "Cancel", cancel_job_path(w[:job].jid), method: :post, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Request cancellation of #{w[:job].jid}? Cooperative jobs abort; others are skipped on their next attempt." } %></td>
19
+ </tr>
20
+ <% end %>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
@@ -0,0 +1,22 @@
1
+ <% content_for :title, "Capsules" %>
2
+
3
+ <h2 class="rh-h2">Capsules <span class="hint">Sidekiq 7+ isolated concurrency pools</span></h2>
4
+ <table class="rh-table">
5
+ <thead><tr><th>Capsule</th><th class="r">Processes</th><th class="r">Concurrency</th><th>Queues (weighted)</th></tr></thead>
6
+ <tbody>
7
+ <% if @capsules.empty? %>
8
+ <tr><td colspan="4" class="rh-empty">No capsule data — no running processes (or workers predate Sidekiq 8.0.8).</td></tr>
9
+ <% else %>
10
+ <% @capsules.each do |c| %>
11
+ <tr>
12
+ <td class="rh-mono"><%= c[:name] %></td>
13
+ <td class="r rh-mono"><%= c[:processes] %></td>
14
+ <td class="r rh-mono"><%= c[:concurrency] %></td>
15
+ <td>
16
+ <% c[:queues].each do |queue, weight| %><span class="rh-tag"><%= queue %><% if weight.to_i != 1 %> ×<%= weight %><% end %></span> <% end %>
17
+ </td>
18
+ </tr>
19
+ <% end %>
20
+ <% end %>
21
+ </tbody>
22
+ </table>
@@ -0,0 +1,68 @@
1
+ <% content_for :title, "Dashboard" %>
2
+ <% content_for :crumb, Rails.env %>
3
+
4
+ <% stuck = @queues.select { |q| q.latency > 60 } %>
5
+
6
+ <% if stuck.any? %>
7
+ <div class="rh-alerts">
8
+ <% stuck.each do |q| %>
9
+ <div class="rh-alert <%= "warn" if q.latency <= 600 %>">
10
+ <span class="msg">Queue <b><%= q.name %></b> is <%= q.latency > 600 ? "stuck" : "over budget" %> — oldest job <%= distance_of_time_in_words(0, q.latency) %>, <%= number_with_delimiter q.size %> waiting</span>
11
+ <%= link_to "Manage →", queues_path, class: "rh-btn" %>
12
+ </div>
13
+ <% end %>
14
+ </div>
15
+ <% end %>
16
+
17
+ <div class="rh-cards">
18
+ <div class="rh-card">
19
+ <% if stuck.any? %>
20
+ <span class="pill">⚠ Degraded</span>
21
+ <div class="k"><b style="color:var(--warn)"><%= stuck.first.name %></b> queue over budget</div>
22
+ <% else %>
23
+ <span class="pill ok">✓ Healthy</span>
24
+ <div class="k">all queues within budget</div>
25
+ <% end %>
26
+ </div>
27
+ <div class="rh-card">
28
+ <div class="k">Processed</div>
29
+ <div class="v num" data-stat="processed"><%= number_with_delimiter @stats.processed %></div>
30
+ <div class="d up">▲ <span class="num" data-stat="rate">—</span> / min</div>
31
+ </div>
32
+ <div class="rh-card">
33
+ <div class="k">Failed · total</div>
34
+ <div class="v num" data-stat="failed"><%= number_with_delimiter @stats.failed %></div>
35
+ <div class="d bad"><span class="num" data-stat="dead"><%= number_with_delimiter @stats.dead_size %></span> dead</div>
36
+ </div>
37
+ <div class="rh-card">
38
+ <div class="k">Busy threads</div>
39
+ <div class="v num" data-stat="busy"><%= @stats.workers_size %></div>
40
+ <div class="d">enqueued <span class="num" data-stat="enqueued"><%= number_with_delimiter @stats.enqueued %></span></div>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="rh-chart-wrap">
45
+ <div class="top"><h3>Throughput</h3><span class="rh-sub">jobs / sec · live</span><span class="now"><span id="rh-chart-now">—</span>/s</span></div>
46
+ <canvas id="rh-chart" width="1100" height="180"></canvas>
47
+ </div>
48
+
49
+ <h2 class="rh-h2">Queues <span class="hint">live · click a queue to manage</span></h2>
50
+ <table class="rh-table">
51
+ <thead><tr><th>Queue</th><th>State</th><th class="r">Depth</th><th class="r">Oldest job</th><th></th></tr></thead>
52
+ <tbody>
53
+ <% if @queues.empty? %>
54
+ <tr><td colspan="5" class="rh-empty">No active queues</td></tr>
55
+ <% else %>
56
+ <% @queues.sort_by { |q| -q.latency }.each do |q| %>
57
+ <% label, css = queue_state(q.latency) %>
58
+ <tr>
59
+ <td class="rh-mono"><%= q.name %></td>
60
+ <td><span class="rh-st <%= css %>"><%= label %></span></td>
61
+ <td class="r rh-mono"><%= number_with_delimiter q.size %></td>
62
+ <td class="r rh-mono <%= "rh-lat-warn" if q.latency > 60 %>"><%= q.latency < 60 ? "#{q.latency.round(1)}s" : "#{distance_of_time_in_words(0, q.latency)}" %></td>
63
+ <td class="r"><%= link_to "Manage →", queues_path, class: "rh-btn" %></td>
64
+ </tr>
65
+ <% end %>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
@@ -0,0 +1,46 @@
1
+ <% content_for :title, "Dead set" %>
2
+
3
+ <form method="get" action="<%= dead_set_path %>" class="rh-search">
4
+ <input type="search" name="q" value="<%= @query %>" placeholder="search class, jid, error, or arg value…">
5
+ </form>
6
+
7
+ <h2 class="rh-h2">Dead set · <%= number_with_delimiter @total %> jobs</h2>
8
+
9
+ <%= form_with url: bulk_dead_path, method: :post, data: { turbo: false } do %>
10
+ <div class="rh-bulkbar">
11
+ <button type="submit" name="op" value="retry" class="rh-btn">↻ Retry selected</button>
12
+ <button type="submit" name="op" value="delete" class="rh-btn rh-btn-danger">✕ Delete selected</button>
13
+ <span class="rh-sub">tick rows, then choose an action</span>
14
+ </div>
15
+ <table class="rh-table">
16
+ <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>
17
+ <tbody>
18
+ <% if @jobs.empty? %>
19
+ <tr><td colspan="4" class="rh-empty"><%= @query.present? ? "No dead jobs match “#{@query}”." : "Dead set is empty 🎉" %></td></tr>
20
+ <% else %>
21
+ <% @jobs.each do |job| %>
22
+ <tr>
23
+ <td><input type="checkbox" name="jids[]" value="<%= job.jid %>" class="rh-check" aria-label="select <%= job.jid %>"></td>
24
+ <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>
25
+ <td>
26
+ <span class="rh-err"><%= job.item["error_class"] %></span>
27
+ <% if job.item["error_message"].present? %><br><span class="rh-sub"><%= truncate(job.item["error_message"], length: 90) %></span><% end %>
28
+ </td>
29
+ <td class="r"><%= time_ago_in_words(job.at) %> ago</td>
30
+ </tr>
31
+ <% end %>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <% end %>
36
+ <%= render "roundhouse_ui/shared/pager" %>
37
+
38
+ <script nonce="<%= content_nonce %>">
39
+ (function () {
40
+ var all = document.getElementById("rh-all");
41
+ if (!all) return;
42
+ all.addEventListener("change", function () {
43
+ document.querySelectorAll(".rh-check").forEach(function (cb) { cb.checked = all.checked; });
44
+ });
45
+ })();
46
+ </script>
@@ -0,0 +1,28 @@
1
+ <% content_for :title, "Errors" %>
2
+
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…">
5
+ </form>
6
+
7
+ <h2 class="rh-h2">Errors · <%= @groups.size %> issues</h2>
8
+ <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>
10
+ <tbody>
11
+ <% if @groups.empty? %>
12
+ <tr><td colspan="5" class="rh-empty"><%= @query.present? ? "No issues match “#{@query}”." : "No failing jobs 🎉" %></td></tr>
13
+ <% else %>
14
+ <% @groups.each do |g| %>
15
+ <tr>
16
+ <td><%= g[:klass] %><br><span class="rh-err"><%= g[:error] %></span></td>
17
+ <td class="r"><%= number_with_delimiter g[:count] %></td>
18
+ <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>
21
+ </tr>
22
+ <% end %>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ <% if @truncated %>
27
+ <p class="rh-note">Grouped the most recent <%= number_with_delimiter @scan_limit %> failures — older entries not yet scanned.</p>
28
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <% if @mode == :new %>
2
+ <h2 class="rh-h2">Enqueue a job <span class="hint">pushes a new job onto a queue</span></h2>
3
+ <% else %>
4
+ <h2 class="rh-h2">Edit &amp; re-enqueue <span class="hint">deletes the original, pushes the edited job</span></h2>
5
+ <% end %>
6
+
7
+ <%= form_with url: @action_path, method: :post do %>
8
+ <div class="rh-field">
9
+ <label>Job class</label>
10
+ <input type="text" name="job_class" value="<%= @job["class"] %>" placeholder="MyApp::SomeJob" autocomplete="off">
11
+ </div>
12
+ <div class="rh-field">
13
+ <label>Queue</label>
14
+ <input type="text" name="queue" value="<%= @job["queue"] %>">
15
+ </div>
16
+ <div class="rh-field">
17
+ <label>Arguments <span class="rh-sub">— JSON array</span></label>
18
+ <textarea name="args" rows="8" spellcheck="false"><%= @job["args"] %></textarea>
19
+ </div>
20
+ <div class="rh-field">
21
+ <button type="submit" class="rh-btn rh-btn-primary"><%= @mode == :new ? "Enqueue job" : "Save & re-enqueue" %></button>
22
+ <%= link_to "Cancel", (@mode == :new ? queues_path : root_path), class: "rh-btn" %>
23
+ </div>
24
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <% content_for :title, "Edit job" %>
2
+ <%= render "form" %>
@@ -0,0 +1,2 @@
1
+ <% content_for :title, "Enqueue job" %>
2
+ <%= render "form" %>