roundhouse_ui 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
@@ -12,6 +12,19 @@
12
12
  --accent:#6E8BFF; --accent-2:#9B8CFC; --good:#44C58C; --warn:#E2A53F; --crit:#E66A60;
13
13
  --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
14
14
  --sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
15
+
16
+ /* Control scale (#65). Two heights and nothing between them: a row
17
+ affordance and a page action. Every dimension on every control comes
18
+ from here, and control_scale_test.rb fails on a raw pixel in a control
19
+ rule — three hand-matched button pairs in one day is how we got here.
20
+ Deliberately not in Theme::TOKENS: colour is the host's to override,
21
+ structure is not. */
22
+ --ctl-h-sm:20px; --ctl-h-md:30px; --ctl-h-lg:34px;
23
+ --ctl-px-sm:7px; --ctl-px-md:11px;
24
+ --ctl-fs-sm:11px; --ctl-fs-md:12px;
25
+ --ctl-r:6px; --ctl-r-lg:8px; --ctl-r-pill:999px;
26
+ --ctl-icon-sm:12px; --ctl-icon-md:14px;
27
+ --ctl-gap:5px;
15
28
  }
16
29
  :root[data-theme="light"]{
17
30
  --bg:#F5F6F9; --panel:#FFFFFF; --panel-2:#EEF1F6; --panel-3:#E6EAF2;
@@ -40,7 +53,6 @@
40
53
  .rh-nav.is-active { background:var(--panel-2); color:var(--text); border-color:var(--line); }
41
54
  .rh-nav .rh-ico { width:18px; text-align:center; font-size:14px; opacity:.85; }
42
55
  .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
56
  .rh-badge:empty { display:none; }
45
57
  .rh-badge.warn { background:rgba(226,165,63,.16); color:var(--warn); }
46
58
  .rh-badge.crit { background:rgba(230,106,96,.16); color:var(--crit); }
@@ -57,9 +69,7 @@
57
69
  .rh-dot::after { content:""; position:absolute; inset:0; border-radius:50%; background:var(--good); animation:rh-ping 2.4s ease-out infinite; }
58
70
  @keyframes rh-ping { 0%{transform:scale(1);opacity:.7} 70%{transform:scale(3.4);opacity:0} 100%{opacity:0} }
59
71
  @media (prefers-reduced-motion:reduce){ .rh-dot::after{animation:none} }
60
- .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; }
61
72
  .rh-iconbtn:hover { color:var(--text); border-color:var(--accent); }
62
- .rh-ro { font-size:11px; padding:3px 9px; border-radius:7px; background:rgba(226,165,63,.16); color:var(--warn); font-weight:600; }
63
73
 
64
74
  .rh-flash { padding:10px 14px; font-size:13px; border-radius:10px; margin-bottom:16px; }
65
75
  .rh-flash-ok { background:rgba(68,197,140,.12); color:var(--good); border:1px solid rgba(68,197,140,.3); }
@@ -70,15 +80,25 @@
70
80
  .rh-panel { background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; overflow:hidden; }
71
81
 
72
82
  table.rh-table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line-soft); border-radius:12px; overflow:hidden; }
73
- .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); }
83
+ .rh-table th { text-align:left; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--faint); font-weight:600; padding:8px 14px; border-bottom:1px solid var(--line-soft); }
74
84
  .rh-table th.r, .rh-table td.r { text-align:right; }
75
- .rh-table td { padding:12px 16px; border-bottom:1px solid var(--line-soft); }
85
+
86
+ /* The Actions cell holds up to five controls, and text-align:right alone lets
87
+ it break between them — Delete dropped onto a second line the moment the
88
+ "find more like this" glass and Edit both appeared. The column takes the
89
+ width it needs instead; auto table layout gives it back to the error
90
+ message column, which has it to spare. */
91
+ .rh-table td.rh-cell-actions { white-space:nowrap; }
92
+ .rh-table td.rh-cell-actions > * { vertical-align:middle; }
93
+ .rh-table td { padding:8px 14px; border-bottom:1px solid var(--line-soft); }
76
94
  .rh-table tr:last-child td { border-bottom:none; }
77
95
  .rh-mono { font-family:var(--mono); font-variant-numeric:tabular-nums; }
78
96
  .rh-sub { color:var(--faint); font-size:12px; }
79
97
  .rh-err { color:var(--crit); font-family:var(--mono); font-size:12px; }
80
98
  .rh-empty { color:var(--faint); text-align:center; padding:30px 16px; }
81
- .rh-trace { color:var(--accent); text-decoration:none; font-size:11px; }
99
+ .rh-trace { color:var(--accent); text-decoration:none; font-size:11.5px;
100
+ display:inline-flex; align-items:center; gap:4px; vertical-align:middle; }
101
+ .rh-trace .rh-ico { width:14px; height:14px; flex:0 0 14px; }
82
102
  .rh-trace:hover { text-decoration:underline; }
83
103
 
84
104
  .rh-st { display:inline-flex; align-items:center; font-size:11px; font-weight:600; padding:2px 9px; border-radius:6px; }
@@ -86,17 +106,445 @@
86
106
  .rh-st-warn { background:rgba(226,165,63,.16); color:var(--warn); }
87
107
  .rh-st-crit { background:rgba(230,106,96,.16); color:var(--crit); }
88
108
  .rh-st-paused { background:var(--panel-3); color:var(--muted); }
89
- .rh-pill { font-size:11px; padding:2px 8px; border-radius:999px; font-family:var(--mono); background:var(--panel-2); color:var(--muted); }
90
109
  .rh-pill-warn { background:rgba(226,165,63,.16); color:var(--warn); }
110
+ .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; }
111
+ /* jid sits inline after the class now; dim it so it reads as a copy target
112
+ rather than competing with the job name. Still fully selectable. */
113
+ /* --muted, not --faint: the jid is secondary but still has to be readable
114
+ at a glance when you're comparing it against a log line. */
115
+ .rh-jid { color:var(--muted); opacity:1; font-size:11.5px; letter-spacing:.02em; margin-top:3px; }
116
+
117
+ /* Bulk actions. Two scopes — ticked rows vs every match — kept visually
118
+ separate, because confusing them destroys the wrong jobs. */
119
+ .rh-actionbar { display:flex; flex-wrap:wrap; align-items:stretch; gap:10px; margin-bottom:14px; }
120
+ .rh-actiongroup { display:flex; align-items:center; gap:7px; padding:7px 12px; border:1px solid var(--line-soft);
121
+ border-radius:11px; background:var(--panel); }
122
+ .rh-actionlabel { font-size:11px; letter-spacing:.05em; text-transform:uppercase; color:var(--faint);
123
+ font-weight:600; margin-right:3px; max-width:34ch; overflow:hidden; text-overflow:ellipsis;
124
+ white-space:nowrap; }
125
+ .rh-actiongroup form { display:inline; margin:0; }
126
+ /* A vendor's own mark, where the adapter supplies one. No border and no
127
+ background: a logo encased in a box breaks most brand guidelines,
128
+ Datadog's included, so this affordance is deliberately the one control in
129
+ an Actions column without a border. Opacity carries the hover instead.
130
+ Height is set here and width follows the artwork's own aspect ratio, so
131
+ nothing is ever stretched. */
132
+ /* The row affordance in a dense list. It carries .rh-btn (or .rh-runbook when
133
+ inline in a text cell), so the box, border, radius, padding and font come
134
+ from the control it sits beside and cannot drift from it. Everything here
135
+ is icon centring and the horizontal trim an icon needs that a word does
136
+ not. Never holds a vendor mark: boxing a logo is against most brand
137
+ guidelines, Datadog's included. */
138
+ .rh-trace-btn:hover { border-color:var(--accent); text-decoration:none; }
139
+ /* Said once above a table instead of on every row. */
140
+ .rh-trace-legend { display:flex; align-items:center; gap:7px; font-size:11.5px; color:var(--faint);
141
+ margin:0 0 9px 1px; }
142
+ .rh-trace-mark { text-decoration:none; opacity:.9; transition:opacity .12s ease; }
143
+ .rh-trace-mark:hover { opacity:1; text-decoration:none; }
144
+ /* Scoped to the mark itself, not to a parent affordance class. These rules
145
+ once hung off .rh-trace-mark, so the legend — which renders the same mark
146
+ inside .rh-trace-legend — matched none of them and drew both variants at
147
+ full size. A vendor mark has to size and resolve wherever it is placed. */
148
+ .rh-mark-dd { height:13px; width:auto; }
149
+ .rh-trace-legend .rh-mark-dd { height:14px; }
150
+ /* Datadog's white lockup is different artwork, not the purple one recoloured
151
+ — different viewBox, different fill-rule mix — so both ship and CSS picks
152
+ one. Recolouring the purple paths white renders Bits as a solid tile with
153
+ the dog knocked out of him.
154
+
155
+ Light is the default: a viewer on the system setting has stamped no theme
156
+ at all, so there is nothing to branch on server-side. Dark is written
157
+ twice — once for prefers-color-scheme, once for the explicit stamp — so the
158
+ toggle wins in both directions. Every display rule is a bare class so a
159
+ container rule can change the size without silently unhiding a variant. */
160
+ .rh-mark-on-light { display:block; }
161
+ .rh-mark-on-dark { display:none; }
162
+ @media (prefers-color-scheme: dark) {
163
+ :root:not([data-theme="light"]) .rh-mark-on-light { display:none; }
164
+ :root:not([data-theme="light"]) .rh-mark-on-dark { display:block; }
165
+ }
166
+ :root[data-theme="dark"] .rh-mark-on-light { display:none; }
167
+ :root[data-theme="dark"] .rh-mark-on-dark { display:block; }
168
+ :root[data-theme="light"] .rh-mark-on-light { display:block; }
169
+ :root[data-theme="light"] .rh-mark-on-dark { display:none; }
170
+ /* Active structured filters, dismissible. They cannot live in the search box
171
+ (see shared/_active_filters), so they live beside it. */
172
+ .rh-active-filters { display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin:0 0 10px 1px; }
173
+ .rh-filter-chip { display:inline-flex; align-items:center; gap:var(--ctl-gap);
174
+ height:var(--ctl-h-sm); padding:0 var(--ctl-px-sm);
175
+ font-size:var(--ctl-fs-sm); border:1px solid var(--line);
176
+ border-radius:var(--ctl-r); background:var(--panel-2); color:var(--text); }
177
+ .rh-filter-chip .k { color:var(--faint); font-family:var(--mono); }
178
+ .rh-filter-chip .v { font-family:var(--mono); max-width:34ch; overflow:hidden;
179
+ text-overflow:ellipsis; white-space:nowrap; }
180
+ /* A real hit target, not a 13px glyph at --faint. The chip is the only way
181
+ back out of a filter you cannot see in the search box, so the way out has to
182
+ look like one. */
183
+ .rh-filter-chip .x { display:inline-flex; align-items:center; justify-content:center;
184
+ width:16px; height:16px; margin-right:-3px; border-radius:4px;
185
+ color:var(--muted); text-decoration:none; font-size:14px;
186
+ line-height:1; }
187
+ .rh-filter-chip .x:hover { color:var(--crit); background:color-mix(in srgb, var(--crit) 16%, transparent); }
188
+ .rh-filter-chip:hover { border-color:var(--muted); }
189
+ .rh-active-filters .clear-all { font-size:var(--ctl-fs-sm); color:var(--muted); text-decoration:none; }
190
+ .rh-active-filters .clear-all:hover { color:var(--crit); text-decoration:underline; }
191
+
192
+ .rh-pill-link { text-decoration:none; border:1px solid transparent; }
193
+ .rh-pill-link:hover { border-color:var(--accent); color:var(--text); }
194
+ .rh-pill-link.is-on { border-color:var(--accent); color:var(--text); background:var(--panel-3); }
195
+
196
+ /* Quick filters: counts by tag, from the scan that already ran. */
197
+ /* Motion. Every effect here is meant to carry information rather than
198
+ decorate: a value that changed, a queue that will not drain, time until
199
+ the next refresh. All of it is dropped for prefers-reduced-motion. */
200
+ .rh-ico { display:inline-grid; place-items:center; width:16px; height:16px; flex:0 0 16px; }
201
+ .rh-ico svg { display:block; }
202
+
203
+ /* A polled number that actually changed. The only effect on this page that
204
+ tells you something you could not otherwise see. */
205
+ @keyframes rh-flash { from { background:color-mix(in srgb, var(--accent) 32%, transparent); } to { background:transparent; } }
206
+ .rh-changed { animation:rh-flash 1.4s ease-out; border-radius:4px; }
207
+
208
+ /* No entrance animation on rows, deliberately. Navigation is the frequent
209
+ action in an ops console — Queues to Errors to Dead and back, all day —
210
+ so choreographing it turns every click into a small wait. Motion here
211
+ signals that the DATA changed, never that the page did. */
212
+
213
+ /* The one row that will not fix itself. Slow on purpose — faster reads as an
214
+ alarm, and an alarm you see all day is one you stop seeing. */
215
+ @keyframes rh-pulse { 0%, 100% { opacity:1; } 50% { opacity:.58; } }
216
+ .rh-fc-growing { animation:rh-pulse 2.6s ease-in-out infinite; }
217
+
218
+ .rh-btn, .rh-iconbtn { transition:transform .09s ease, border-color .12s ease, background .12s ease; }
219
+ .rh-btn:active, .rh-iconbtn:active { transform:scale(.95); }
220
+
221
+ @keyframes rh-toastin { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
222
+ .rh-flash { animation:rh-toastin .3s cubic-bezier(.2,.8,.3,1); }
223
+
224
+ .rh-nav { transition:background .16s ease, color .16s ease, border-color .16s ease; }
225
+
226
+ @media (prefers-reduced-motion: reduce) {
227
+ .rh-fc-growing, .rh-flash, .rh-changed { animation:none !important; }
228
+ .rh-btn, .rh-iconbtn, .rh-nav { transition:none !important; }
229
+ .rh-btn:active, .rh-iconbtn:active { transform:none; }
230
+ }
231
+
232
+ /* Retry attempt as a ladder. A count tells you a number; a ladder tells you
233
+ how close this job is to giving up, without reading. */
234
+ .rh-ladder { display:inline-flex; gap:2px; vertical-align:middle; }
235
+ .rh-ladder i { width:4px; height:10px; border-radius:1px; background:var(--panel-3); }
236
+ .rh-ladder i.is-on { background:var(--warn); }
237
+ .rh-ladder i.is-hot { background:var(--crit); }
238
+ .rh-ladder-n { margin-left:6px; font-variant-numeric:tabular-nums; }
239
+
240
+ /* Next-attempt ring. The label is fixed-width so every ring lands on the
241
+ same x — right-aligning the pair leaves them ragged, because "in 6h" and
242
+ "in 40s" are different widths. */
243
+ .rh-next { display:inline-flex; align-items:center; gap:6px; }
244
+ .rh-next .rh-sub { font-variant-numeric:tabular-nums; white-space:nowrap; }
245
+ .rh-ring { width:15px; height:15px; border-radius:50%; flex:0 0 15px;
246
+ background:conic-gradient(var(--accent) var(--rh-turn,0turn), var(--panel-2) 0); }
247
+
248
+ /* A ranked table wants lengths, not a column of figures: the point of
249
+ "slowest classes" is proportion, and proportion is a length. */
250
+ .rh-dbar { display:flex; align-items:center; gap:8px; }
251
+ .rh-dbar .t { flex:1; min-width:70px; height:6px; border-radius:3px; background:var(--panel-2); overflow:hidden; }
252
+ .rh-dbar .t i { display:block; height:100%; background:linear-gradient(90deg,var(--accent),var(--accent-2)); }
253
+ .rh-dbar .n { min-width:3.6rem; text-align:right; font-family:var(--mono); font-size:11.5px;
254
+ font-variant-numeric:tabular-nums; }
255
+
256
+ /* A count is also a magnitude. 340 and 2 should not look the same. */
257
+ .rh-count { position:relative; height:16px; min-width:52px; border-radius:3px;
258
+ background:var(--panel-2); overflow:hidden; display:inline-block; width:100%; }
259
+ .rh-count i { position:absolute; inset:0 auto 0 0; background:var(--accent); opacity:.34; }
260
+ .rh-count[data-rh-sev="crit"] i { background:var(--crit); opacity:.42; }
261
+ .rh-count[data-rh-sev="warn"] i { background:var(--warn); opacity:.38; }
262
+ .rh-count span { position:relative; display:block; text-align:right; padding:0 5px;
263
+ font:500 11px var(--mono); line-height:16px; font-variant-numeric:tabular-nums; }
264
+
265
+ /* Elapsed against what is typical for that class. "4m 12s" means nothing on
266
+ its own — fine for a nightly rollup, a hang for a webhook. */
267
+ .rh-elapsed { display:flex; align-items:center; gap:7px; }
268
+ .rh-elapsed .t { flex:1; min-width:56px; height:4px; border-radius:2px; background:var(--panel-2); overflow:hidden; }
269
+ .rh-elapsed .t i { display:block; height:100%; background:var(--good); transition:width .6s linear; }
270
+ .rh-elapsed .t[data-rh-sev="warn"] i { background:var(--warn); }
271
+ .rh-elapsed .t[data-rh-sev="crit"] i { background:var(--crit); }
272
+ .rh-elapsed .n { min-width:3.4rem; text-align:right; }
273
+
274
+ /* Health verdict. The one thing someone opening this page needs in the first
275
+ second, so it gets the full width and a rank rather than a middle column
276
+ and a disclosure. */
277
+ /* One vertical, down the whole component. The rail used to stop at the top
278
+ row while each tile drew its own narrower stripe at the same x, so the
279
+ left edge stepped from 4px to 2px — two red lines competing to mean the
280
+ same thing. */
281
+ .rh-health { background:var(--panel); border:1px solid var(--line-soft);
282
+ border-left:4px solid var(--faint); border-radius:12px;
283
+ overflow:hidden; margin-bottom:18px; }
284
+ .rh-health.is-ok { border-left-color:var(--good); }
285
+ .rh-health.is-warn { border-left-color:var(--warn); }
286
+ .rh-health.is-crit { border-left-color:var(--crit); }
287
+ .rh-health-top { display:flex; align-items:center; gap:14px; padding:13px 16px; }
288
+ .rh-health-verdict { font-weight:700; font-size:15px; letter-spacing:-.01em; }
289
+ .rh-health.is-ok .rh-health-verdict { color:var(--good); }
290
+ .rh-health.is-warn .rh-health-verdict { color:var(--warn); }
291
+ .rh-health.is-crit .rh-health-verdict { color:var(--crit); }
292
+ .rh-health-reason { font-size:13.5px; color:var(--text); }
293
+ .rh-health-meta { margin-left:auto; font:500 11px var(--mono); color:var(--faint); white-space:nowrap; }
294
+
295
+ /* One tile per check, filling the width. Label above value, so pairing them
296
+ is a glance rather than a scan across a gap. */
297
+ .rh-health-signals { display:grid; grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));
298
+ gap:1px; background:var(--line-soft); border-top:1px solid var(--line-soft); }
299
+ /* Per-tile severity is carried by the icon and a wash, not a second stripe.
300
+ Both washes are present so warn is distinguishable from ok, which a stripe
301
+ was doing before. */
302
+ .rh-sig { background:var(--panel); padding:10px 14px; display:flex; flex-direction:column; gap:3px;
303
+ min-width:0; }
304
+ .rh-sig[data-rh-sev="warn"] { background:color-mix(in srgb, var(--warn) 6%, var(--panel)); }
305
+ .rh-sig[data-rh-sev="crit"] { background:color-mix(in srgb, var(--crit) 6%, var(--panel)); }
306
+ .rh-sig-name { display:flex; align-items:center; gap:7px; font:600 12px var(--sans); color:var(--text); }
307
+ .rh-sig-name .rh-ico { width:13px; height:13px; flex:0 0 13px; }
308
+ .rh-sig[data-rh-sev="ok"] .rh-sig-name .rh-ico { color:var(--good); }
309
+ .rh-sig[data-rh-sev="warn"] .rh-sig-name .rh-ico { color:var(--warn); }
310
+ .rh-sig[data-rh-sev="crit"] .rh-sig-name .rh-ico { color:var(--crit); }
311
+ .rh-sig-detail { font-size:12px; color:var(--muted); overflow-wrap:anywhere; }
312
+ .rh-sig[data-rh-sev="crit"] .rh-sig-detail { color:var(--text); }
313
+
314
+ /* Trend on a stat card: "8,420,118" becomes "8.4M and climbing" without a
315
+ second number to read. */
316
+ .rh-card canvas { display:block; width:100%; height:26px; margin-top:6px; }
317
+
318
+ /* Daily history (#61). Two series, two units — a count and a rate — so each
319
+ gets its own vertical scale and the rate is the one drawn on top. */
320
+ .rh-hist-ranges { margin-left:auto; display:flex; gap:5px; }
321
+ canvas#rh-history { display:block; width:100%; height:150px; }
322
+ .rh-hist-legend { display:flex; align-items:center; gap:14px; margin-top:8px; font-size:11.5px; color:var(--muted); }
323
+ .rh-hist-legend span { display:inline-flex; align-items:center; gap:5px; }
324
+ .rh-swatch { width:9px; height:9px; border-radius:2px; display:inline-block; }
325
+ .rh-swatch.is-proc { background:var(--accent); }
326
+ .rh-swatch.is-rate { background:var(--crit); }
327
+ .rh-hist-hover { margin-left:auto; font-family:var(--mono); font-variant-numeric:tabular-nums; }
328
+
329
+ /* Per-queue trend. A table of numbers tells you values; a column of shapes
330
+ tells you what the system is doing. Fed from the depths the poll already
331
+ reads, held in the browser for the session — no storage, no retention. */
332
+ .rh-trend, .rh-trend-h { width:100px; }
333
+ .rh-trend canvas { display:block; width:88px; height:22px; }
334
+ .rh-trend-empty { display:inline-block; width:88px; height:22px; border-radius:3px;
335
+ background:linear-gradient(90deg,var(--panel-2) 30%,var(--panel-3) 50%,var(--panel-2) 70%);
336
+ background-size:220% 100%; animation:rh-shimmer 2.6s linear infinite; }
337
+ /* Setting `display` on these beats the user-agent's `[hidden] { display:none }`,
338
+ so without this the placeholder and the drawn sparkline both render, stacked.
339
+ An attribute selector outranks the bare class, so no !important is needed. */
340
+ .rh-trend canvas[hidden], .rh-trend-empty[hidden] { display:none; }
341
+ /* A placeholder that moves reads as "working"; the word "measuring" reads as
342
+ "broken". Slow, because this sits in a column of eight and anything quick
343
+ enough to notice individually is exhausting in aggregate. */
344
+ @keyframes rh-shimmer { from { background-position:220% 0; } to { background-position:-220% 0; } }
345
+
346
+ /* Severity as form, not only colour: you find the bad row before reading it.
347
+ Also the one cue that survives a red-green colour deficiency. */
348
+ .rh-table tbody tr > td:first-child { border-left:3px solid transparent; }
349
+ .rh-table tbody tr[data-rh-sev="crit"] > td:first-child { border-left-color:var(--crit); }
350
+ .rh-table tbody tr[data-rh-sev="warn"] > td:first-child { border-left-color:var(--warn); }
351
+ .rh-table tbody tr[data-rh-sev="ok"] > td:first-child { border-left-color:var(--good); }
352
+
353
+ @media (prefers-reduced-motion: reduce) { .rh-trend-empty { animation:none; } }
354
+
355
+ /* Drain forecast (#35). "Growing" is the state that matters during an
356
+ incident, so it is the only one that gets a colour that demands attention. */
357
+ .rh-sort { cursor:pointer; user-select:none; white-space:nowrap; }
358
+ .rh-sort:hover { color:var(--muted); }
359
+ .rh-sort::after { content:"↕"; opacity:.28; margin-left:5px; font-size:10px; }
360
+ .rh-sort.is-asc::after { content:"↑"; opacity:1; color:var(--accent); }
361
+ .rh-sort.is-desc::after { content:"↓"; opacity:1; color:var(--accent); }
362
+
363
+ .rh-runbook:hover { border-color:var(--accent); background:var(--panel-2); }
364
+
365
+
366
+ .rh-fc { font-size:12.5px; font-variant-numeric:tabular-nums; white-space:nowrap; }
367
+ .rh-fc-wait { color:var(--faint); }
368
+ .rh-fc-draining { color:var(--good); }
369
+ .rh-fc-stalled { color:var(--warn); }
370
+ .rh-fc-growing { color:var(--crit); font-weight:600; }
371
+
372
+ .rh-settings { padding:4px 0; }
373
+ .rh-setting { display:flex; align-items:center; gap:24px; padding:16px 18px; border-bottom:1px solid var(--line-soft); }
374
+ .rh-setting:last-child { border-bottom:none; }
375
+ .rh-setting-label { flex:1; display:flex; flex-direction:column; gap:3px; }
376
+ .rh-setting-label b { font-size:13px; font-weight:600; }
377
+ .rh-setting-control { flex:0 0 auto; }
378
+ .rh-setting-control select { min-width:190px; }
379
+
380
+ .rh-quickfilters { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-bottom:14px; }
381
+ .rh-qf { display:inline-flex; align-items:center; gap:7px; font-size:12px; text-decoration:none;
382
+ background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:4px 10px; color:var(--muted); }
383
+ .rh-qf:hover { border-color:var(--accent); color:var(--text); }
384
+ .rh-qf.is-on { border-color:var(--accent); color:var(--text); background:var(--panel-2); }
385
+ .rh-qf-n { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:11px; color:var(--faint); }
386
+ .rh-qf.is-on .rh-qf-n { color:var(--accent); }
387
+
388
+ /* ── Controls ────────────────────────────────────────────────────────────
389
+ One box, eight names. Every control is this rule plus a role, and the only
390
+ dimensions anywhere are the --ctl-* tokens — so two controls cannot
391
+ disagree by a pixel of padding, which is exactly how .rh-trace-btn ended
392
+ up 2px taller than .rh-runbook while sitting beside it.
393
+
394
+ Sized by HEIGHT, never by line-height plus padding. That is the whole
395
+ trick: it makes an icon-only control and a text control the same height by
396
+ construction rather than by coincidence, and the flexbox centres both, so
397
+ a 12px glyph and an 11px word share a line without either knowing about
398
+ the other. Matching paddings can never achieve that across different
399
+ content, which is why hand-fixing pairs kept not holding. */
400
+ .rh-btn, .rh-runbook, .rh-pill, .rh-badge, .rh-kbd, .rh-iconbtn, .rh-ro, .rh-trace-btn {
401
+ display:inline-flex; align-items:center; justify-content:center; gap:var(--ctl-gap);
402
+ height:var(--ctl-h-md); padding:0 var(--ctl-px-md);
403
+ font-family:inherit; font-size:var(--ctl-fs-md); font-weight:500; line-height:1;
404
+ border:1px solid var(--line); border-radius:var(--ctl-r);
405
+ background:var(--panel-2); color:var(--text);
406
+ text-decoration:none; white-space:nowrap; vertical-align:middle;
407
+ }
408
+ /* Interactive ones only. A badge is not a button and should not claim to be. */
409
+ .rh-btn, .rh-kbd, .rh-iconbtn, .rh-runbook, .rh-trace-btn { cursor:pointer; }
410
+
411
+ /* The row scale: anything that lives inside a table cell. */
412
+ .rh-btn--sm, .rh-runbook, .rh-pill, .rh-badge, .rh-ro, .rh-cap-pick {
413
+ height:var(--ctl-h-sm); padding:0 var(--ctl-px-sm); font-size:var(--ctl-fs-sm);
414
+ }
415
+
416
+ /* Icon-only: square, so the width IS the height. */
417
+ .rh-btn--icon, .rh-trace-btn { padding:0; width:var(--ctl-h-md); }
418
+ .rh-btn--icon.rh-btn--sm, .rh-trace-btn.rh-btn--sm { width:var(--ctl-h-sm); }
419
+
420
+ /* Header controls at one size, so ⌘K and the two icon buttons read as
421
+ siblings. They were 25px and 34px before, which nobody had reported yet. */
422
+ .rh-kbd, .rh-iconbtn { height:var(--ctl-h-lg); border-radius:var(--ctl-r-lg);
423
+ color:var(--muted); background:var(--panel); }
424
+ .rh-iconbtn { width:var(--ctl-h-lg); padding:0; }
425
+ .rh-kbd { font-family:var(--mono); }
426
+ .rh-cap-pick { margin-left:var(--ctl-gap); }
427
+
428
+ /* Roles. */
429
+ .rh-runbook, .rh-trace-btn { color:var(--accent); }
430
+ .rh-pill, .rh-badge { border-radius:var(--ctl-r-pill); border-color:transparent;
431
+ font-family:var(--mono); font-variant-numeric:tabular-nums;
432
+ color:var(--muted); }
433
+ .rh-ro { color:var(--warn); background:rgba(226,165,63,.16); border-color:transparent;
434
+ font-weight:600; }
435
+
436
+ /* Icons are sized on the glyph, not on a wrapper. Every shipped mark carries
437
+ its own width attribute, and nothing overrode it — so a 15px svg sat in a
438
+ 12px slot and read as oversized, which no amount of padding could fix. */
439
+ .rh-btn > .rh-ico, .rh-iconbtn > .rh-ico, .rh-runbook > .rh-ico, .rh-trace-btn > .rh-ico {
440
+ width:var(--ctl-icon-md); height:var(--ctl-icon-md); flex:0 0 var(--ctl-icon-md);
441
+ }
442
+ .rh-btn--sm > .rh-ico, .rh-trace-btn.rh-btn--sm > .rh-ico {
443
+ width:var(--ctl-icon-sm); height:var(--ctl-icon-sm); flex-basis:var(--ctl-icon-sm);
444
+ }
445
+ .rh-btn > .rh-ico > svg, .rh-iconbtn > .rh-ico > svg,
446
+ .rh-runbook > .rh-ico > svg, .rh-trace-btn > .rh-ico > svg { width:100%; height:100%; display:block; }
91
447
 
92
- .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
448
  .rh-btn:hover { border-color:var(--accent); }
94
- .rh-btn-danger:hover { border-color:var(--crit); color:var(--crit); }
449
+ .rh-btn-danger { color:var(--crit); border-color:color-mix(in srgb, var(--crit) 38%, var(--line)); }
450
+ .rh-btn-danger:hover { border-color:var(--crit); background:color-mix(in srgb, var(--crit) 10%, var(--panel-2)); }
95
451
  .rh-inline { display:inline; margin:0; }
96
452
 
97
- .rh-search { margin-bottom:16px; }
98
- .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); }
99
- .rh-search input:focus { outline:none; border-color:var(--accent); }
453
+ .rh-search { margin-bottom:16px; position:relative; display:flex; align-items:flex-start; gap:8px; }
454
+
455
+ /* Input group, the old Bootstrap way: the bar and its help button are ONE
456
+ control, sharing a border, with the meeting corners squared and a single
457
+ divider between them. align-items:stretch is what makes it hold up — the bar
458
+ grows taller as pills wrap, and the button has to grow with it or the group
459
+ comes apart at exactly the moment it has the most in it. */
460
+ .rh-group { flex:1; min-width:0; display:flex; align-items:stretch; }
461
+ .rh-group .rh-bar { flex:1; min-width:0; border-radius:var(--ctl-r-lg) 0 0 var(--ctl-r-lg); border-right:0; }
462
+ .rh-group .rh-help { display:flex; }
463
+ .rh-group .rh-help > button {
464
+ height:auto; width:auto; align-self:stretch;
465
+ padding:0 var(--ctl-px-md); border-radius:0 var(--ctl-r-lg) var(--ctl-r-lg) 0;
466
+ }
467
+ /* The whole group lights up together. Highlighting only the half that holds the
468
+ caret is what makes two adjacent controls read as two controls. */
469
+ .rh-group:focus-within .rh-bar,
470
+ .rh-group:focus-within .rh-help > button { border-color:var(--accent); }
471
+ .rh-group .rh-help > button:hover { background:var(--panel-2); }
472
+ /* Plain box, still used where q is a substring (Queues index, Errors). */
473
+ .rh-search > input[type="search"] { flex:1; min-width:0; background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:10px 14px; color:var(--text); font:13px var(--mono); }
474
+ .rh-search > input[type="search"]:focus { outline:none; border-color:var(--accent); }
475
+
476
+ /* The filter bar: pills and the input are one control, because they are one
477
+ value. The border lives here, not on the input. */
478
+ .rh-bar {
479
+ flex:1; min-width:0; display:flex; align-items:center; flex-wrap:wrap; gap:6px;
480
+ background:var(--panel); border:1px solid var(--line); border-radius:var(--ctl-r-lg);
481
+ padding:6px 10px; min-height:42px; cursor:text; position:relative;
482
+ }
483
+ .rh-bar:focus-within { border-color:var(--accent); }
484
+ .rh-bar > svg { flex:0 0 auto; width:var(--ctl-icon-md); height:var(--ctl-icon-md); color:var(--faint); }
485
+ .rh-bar input[type="search"] {
486
+ flex:1 1 9rem; min-width:7rem; background:none; border:0; outline:none;
487
+ color:var(--text); font:13px var(--mono); padding:5px 2px;
488
+ -webkit-appearance:none; appearance:none;
489
+ }
490
+ .rh-bar input[type="search"]::-webkit-search-cancel-button { display:none; }
491
+ .rh-bar input::placeholder { color:var(--faint); }
492
+ .rh-bar-clear { flex:0 0 auto; font:var(--ctl-fs-sm)/1 var(--sans); color:var(--muted); text-decoration:none; padding:0 4px; }
493
+ .rh-bar-clear:hover { color:var(--crit); text-decoration:underline; }
494
+
495
+ /* A facet pill. Height from the control scale, like every other control. */
496
+ .rh-pillf {
497
+ display:inline-flex; align-items:center; height:var(--ctl-h-sm);
498
+ border:1px solid color-mix(in srgb, var(--accent) 40%, var(--line));
499
+ border-radius:var(--ctl-r); background:color-mix(in srgb, var(--accent) 10%, transparent);
500
+ font:var(--ctl-fs-sm)/1 var(--mono); white-space:nowrap; overflow:hidden; max-width:26rem;
501
+ }
502
+ .rh-pillf .k { padding:0 5px; height:100%; display:inline-flex; align-items:center; color:var(--accent); background:color-mix(in srgb, var(--accent) 14%, transparent); }
503
+ .rh-pillf .v { padding:0 6px; overflow:hidden; text-overflow:ellipsis; color:var(--text); }
504
+ .rh-pillf .x { flex:0 0 auto; width:16px; height:100%; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); text-decoration:none; }
505
+ .rh-pillf .x:hover { color:var(--crit); background:color-mix(in srgb, var(--crit) 16%, transparent); }
506
+
507
+ /* Typeahead. Positioned against .rh-bar, which is the positioned ancestor. */
508
+ .rh-ac {
509
+ position:absolute; left:8px; top:calc(100% + 5px); z-index:38; min-width:17rem; max-width:min(34rem,90vw);
510
+ background:var(--panel); border:1px solid var(--line); border-radius:9px;
511
+ box-shadow:0 14px 40px rgba(0,0,0,.45); padding:4px; max-height:17rem; overflow-y:auto;
512
+ }
513
+ .rh-ac[hidden] { display:none; }
514
+ .rh-ac-row { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:6px 9px; border-radius:6px; font:var(--ctl-fs-md)/1.3 var(--mono); color:var(--text); cursor:pointer; }
515
+ .rh-ac-row[aria-selected="true"], .rh-ac-row:hover { background:var(--panel-3); }
516
+ .rh-ac-row .rh-ac-why { font:var(--ctl-fs-sm)/1 var(--sans); color:var(--faint); }
517
+ .rh-ac-grp { padding:6px 9px 3px; font:600 10px var(--sans); letter-spacing:.09em; text-transform:uppercase; color:var(--faint); }
518
+
519
+ /* Search help. Revealed on hover, as asked — and on :focus-within too, so it is
520
+ reachable by keyboard and on touch, where there is no hover at all. Pure CSS:
521
+ a popover that needs JS to open is a popover that does not open under a strict
522
+ CSP if the nonce ever slips. */
523
+ .rh-help { position:relative; flex:0 0 auto; }
524
+ .rh-help > button {
525
+ height:var(--ctl-h-md); width:var(--ctl-h-md); padding:0;
526
+ display:inline-flex; align-items:center; justify-content:center;
527
+ background:var(--panel); border:1px solid var(--line); border-radius:var(--ctl-r);
528
+ color:var(--muted); font:600 var(--ctl-fs-md)/1 var(--sans); cursor:help;
529
+ }
530
+ .rh-help > button:hover, .rh-help:focus-within > button { color:var(--text); border-color:var(--accent); }
531
+ .rh-help-panel {
532
+ position:absolute; right:0; top:calc(100% + 6px); z-index:40; width:min(30rem, 88vw);
533
+ background:var(--panel); border:1px solid var(--line); border-radius:10px;
534
+ padding:14px 16px; box-shadow:0 16px 44px rgba(0,0,0,.45);
535
+ opacity:0; visibility:hidden; transform:translateY(-3px);
536
+ transition:opacity .12s ease, transform .12s ease, visibility .12s;
537
+ }
538
+ .rh-help:hover .rh-help-panel, .rh-help:focus-within .rh-help-panel {
539
+ opacity:1; visibility:visible; transform:none;
540
+ }
541
+ @media (prefers-reduced-motion: reduce) { .rh-help-panel { transition:none; } }
542
+ .rh-help-panel h4 { margin:0 0 8px; font:600 12px var(--sans); color:var(--text); }
543
+ .rh-help-panel dl { margin:0; display:grid; grid-template-columns:auto 1fr; gap:5px 12px; align-items:baseline; }
544
+ .rh-help-panel dt { font:12px var(--mono); color:var(--accent); white-space:nowrap; }
545
+ .rh-help-panel dd { margin:0; font-size:12px; color:var(--muted); }
546
+ .rh-help-panel .rh-help-note { margin:11px 0 0; padding-top:10px; border-top:1px solid var(--line-soft); font-size:11.5px; color:var(--faint); }
547
+ .rh-help-panel code { font:11.5px var(--mono); color:var(--text); background:var(--panel-2); padding:1px 4px; border-radius:3px; }
100
548
  .rh-bulkbar { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
101
549
  .rh-note { color:var(--faint); font-size:12px; margin-top:14px; }
102
550
  .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; }
@@ -126,44 +574,51 @@
126
574
  canvas#rh-chart { display:block; width:100%; height:90px; }
127
575
  .rh-cb { width:15px; height:15px; }
128
576
 
129
- /* composite health banner */
130
- .rh-health { background:var(--panel); border:1px solid var(--line-soft); border-left:3px solid var(--faint); border-radius:12px; margin-bottom:18px; }
131
- .rh-health-ok { border-left-color:var(--good); }
132
- .rh-health-warn { border-left-color:var(--warn); }
133
- .rh-health-crit { border-left-color:var(--crit); }
134
- .rh-health summary { display:flex; align-items:center; gap:12px; padding:14px 16px; cursor:pointer; list-style:none; }
135
- .rh-health summary::-webkit-details-marker { display:none; }
136
- .rh-health-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--faint); }
137
- .rh-health-ok .rh-health-dot { background:var(--good); }
138
- .rh-health-warn .rh-health-dot { background:var(--warn); }
139
- .rh-health-crit .rh-health-dot { background:var(--crit); }
140
- .rh-health-verdict { font-weight:650; font-size:15px; }
141
- .rh-health-ok .rh-health-verdict { color:var(--good); }
142
- .rh-health-warn .rh-health-verdict { color:var(--warn); }
143
- .rh-health-crit .rh-health-verdict { color:var(--crit); }
144
- .rh-health-reason { color:var(--muted); font-size:13px; }
145
- .rh-health-cta { margin-left:auto; font:11px var(--mono); color:var(--faint); }
146
- .rh-health[open] .rh-health-cta { color:var(--accent); }
147
- .rh-health-signals { padding:2px 16px 14px; display:flex; flex-direction:column; gap:8px; border-top:1px solid var(--line-soft); margin-top:2px; padding-top:12px; }
148
- .rh-sig { display:flex; align-items:center; gap:10px; font-size:13px; }
149
- .rh-sig b { min-width:170px; font-weight:600; }
150
-
151
577
  /* insight panels */
152
578
  .rh-insights { display:grid; gap:14px; margin-bottom:24px; }
153
579
  @media(min-width:760px){ .rh-insights { grid-template-columns:1fr 1fr; } }
154
580
  .rh-insight { padding:6px 0; }
155
581
  .rh-insight-h { display:flex; align-items:baseline; justify-content:space-between; gap:10px; font-size:13px; font-weight:600; padding:13px 16px 10px; }
156
- .rh-insight-row { display:flex; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid var(--line-soft); }
582
+ /* One divider you can actually see, and room to breathe. --line-soft is for
583
+ separating a cell from its neighbour inside a table; between two entries in
584
+ a list it disappears and the rows read as one block of text. */
585
+ .rh-insight-row { display:flex; align-items:center; gap:12px; padding:12px 16px; border-top:1px solid var(--line); }
586
+ .rh-insight-row:first-of-type { border-top:1px solid var(--line-soft); }
587
+ /* The error is subordinate to the class, not a second heading. */
588
+ .rh-insight-row .rh-err { display:block; margin-top:3px; }
157
589
  .rh-insight-row .rh-insight-main { flex:1; min-width:0; font-size:13px; }
158
590
  .rh-insight-row .rh-pill { font-variant-numeric:tabular-nums; }
159
591
  .rh-insight .rh-empty { padding:18px 16px; }
592
+ .rh-insight-legend .rh-trace-legend { margin:0 16px 8px; }
593
+ .rh-insight-legend:empty { display:none; }
160
594
 
161
595
  /* collapsible disclosure (large args, full backtrace) */
162
- .rh-disclose summary { cursor:pointer; font:12px var(--mono); color:var(--accent); padding:6px 0; list-style:none; }
596
+ .rh-disclose summary { cursor:pointer; font:12.5px var(--sans); color:var(--accent); padding:6px 0; list-style:none; }
163
597
  .rh-disclose summary::-webkit-details-marker { display:none; }
164
598
  .rh-disclose summary::before { content:"▸ "; }
165
599
  .rh-disclose[open] summary::before { content:"▾ "; }
166
600
 
601
+ /* Time until the next refresh. The numbers on every page are a few seconds
602
+ old at best, and with the interval configurable (2–300s) there was no way
603
+ to tell a fresh number from a stale one. A depleting arc reads as time
604
+ remaining rather than progress toward something. */
605
+ .rh-tick { position:relative; width:12px; height:12px; border-radius:50%; flex:0 0 auto;
606
+ background:var(--panel-2); display:inline-block; }
607
+ .rh-tick-fill { position:absolute; inset:0; border-radius:50%;
608
+ background:conic-gradient(var(--good) var(--rh-tick-turn, 1turn), transparent 0);
609
+ transition:none; }
610
+ /* The arc is driven by one linear transition per poll rather than a rAF loop
611
+ or an animation whose duration has to be kept in sync with the timer. */
612
+ .rh-tick-fill.is-running { transition:--rh-tick-turn linear; }
613
+ .rh-live.is-stale .rh-tick { background:var(--crit); }
614
+ .rh-live.is-stale .rh-tick-fill { background:none; }
615
+ @property --rh-tick-turn { syntax: "<angle>"; inherits: false; initial-value: 1turn; }
616
+ /* No smoothly sweeping arc for anyone who asked for less motion — it snaps
617
+ full on each refresh instead, which still says "just updated". */
618
+ @media (prefers-reduced-motion: reduce) {
619
+ .rh-tick-fill, .rh-tick-fill.is-running { transition:none !important; }
620
+ }
621
+
167
622
  /* connection state — poll failures surface here instead of silently going stale */
168
623
  .rh-live.is-stale .rh-dot { background:var(--crit); }
169
624
  .rh-live.is-stale .rh-dot::after { animation:none; }
@@ -198,7 +653,6 @@
198
653
  .rh-sec { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint); font-weight:600; margin:22px 0 8px; }
199
654
  .rh-joblink { color:inherit; text-decoration:none; }
200
655
  .rh-joblink:hover { text-decoration:underline; }
201
- .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; }
202
656
  .rh-kbd:hover { color:var(--text); border-color:var(--accent); }
203
657
  .rh-palette[hidden] { display:none; }
204
658
  .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); }
@@ -208,10 +662,14 @@
208
662
  .rh-palette-item { display:flex; align-items:center; gap:12px; padding:9px 12px; border-radius:9px; cursor:pointer; font-size:13px; }
209
663
  .rh-palette-item.sel { background:var(--panel-2); }
210
664
  .rh-palette-ico { width:20px; text-align:center; color:var(--muted); }
665
+ <%# Host colour overrides, emitted last so they win without !important. Only
666
+ known tokens, values shape-checked — see lib/roundhouse_ui/theme.rb %>
667
+ <%= RoundhouseUi::Theme.css&.html_safe %>
668
+ <%= RoundhouseUi::Theme.selectable_css&.html_safe %>
211
669
  </style>
212
670
  <%# Apply the saved theme before first paint so it persists across navigations with no flash. %>
213
671
  <script nonce="<%= content_nonce %>">
214
- (function () { try { var t = localStorage.getItem("rh-theme"); if (t) document.documentElement.setAttribute("data-theme", t); var w = localStorage.getItem("rh-width"); if (w) document.documentElement.setAttribute("data-width", w); } catch (e) {} })();
672
+ (function () { try { var t = localStorage.getItem("rh-theme"); if (t) document.documentElement.setAttribute("data-theme", t); var w = localStorage.getItem("rh-width"); if (w) document.documentElement.setAttribute("data-width", w); var p = localStorage.getItem("rh-palette"); if (p) document.documentElement.setAttribute("data-rh-palette", p); } catch (e) {} })();
215
673
  </script>
216
674
  <%# Turbo Drive: link navigation without full-page reloads (kills the flicker). %>
217
675
  <script src="<%= turbo_js_path %>"></script>
@@ -219,10 +677,74 @@
219
677
  (function () {
220
678
  var fmt = function (n) { return Number(n).toLocaleString(); };
221
679
  var started = false, lastProcessed = null, lastFailed = null, lastBacklog = null, lastT = null;
222
- var POLL_MS = <%= (RoundhouseUi.poll_interval.to_f * 1000).round %>;
680
+ var HOST_POLL_MS = <%= (RoundhouseUi.poll_interval.to_f * 1000).round %>;
681
+ function storedPollMs() {
682
+ try {
683
+ var v = parseFloat(localStorage.getItem("rh-poll"));
684
+ // Clamped: this comes from the browser, and 0 would hammer the mount.
685
+ if (!isNaN(v) && v > 0) return Math.min(Math.max(v, 2), 300) * 1000;
686
+ } catch (_) {}
687
+ return HOST_POLL_MS;
688
+ }
689
+ var POLL_MS = storedPollMs();
223
690
  var samples = [], buckets = [], bucketStart = null; // samples = current bucket; buckets = finalized per-interval averages
224
691
  var lastPts = []; // points last drawn, for the hover tooltip to read
225
692
  function setText(id, t) { var el = document.getElementById(id); if (el) el.textContent = t; }
693
+ // Drain forecast (#35). The question during a backlog is not "how long if
694
+ // arrivals stopped" — it is "will this clear at all", and that needs *net*
695
+ // velocity, not throughput. Three answers, because a signed number is the
696
+ // whole point: draining, stalled, or growing.
697
+ //
698
+ // Depth is an integer and dt is a few seconds, so a single sample's
699
+ // velocity is quantised and noisy — one unlucky poll would flip the label.
700
+ // Smoothed with an EWMA so the label reflects a trend rather than a tick.
701
+ var lastDepths = null, smoothVel = {}, lastCapacity = null;
702
+ var STALLED_PER_MIN = 1; // below one job a minute either way, nothing is happening
703
+
704
+ function smooth(key, v) {
705
+ var prev = smoothVel[key];
706
+ return (smoothVel[key] = prev == null ? v : (prev * 0.7) + (v * 0.3));
707
+ }
708
+
709
+ // vel is jobs/second, signed: negative drains, positive grows.
710
+ function forecast(depth, vel) {
711
+ if (depth === 0) return { text: "clear", cls: "" };
712
+ if (vel == null) return { text: "measuring…", cls: "rh-fc-wait" };
713
+ if (Math.abs(vel) * 60 < STALLED_PER_MIN) return { text: "stalled", cls: "rh-fc-stalled" };
714
+ if (vel > 0) return { text: "growing " + rateLabel(vel) + " — will not drain", cls: "rh-fc-growing" };
715
+ return { text: "clears in " + humanizeEta(depth / -vel), cls: "rh-fc-draining" };
716
+ }
717
+
718
+ // One urgency scale for the Forecast column, low = healthy, so an ascending
719
+ // sort reads best-first like every other column. Bands are kept far apart
720
+ // rather than blended, because "draining in 3 days" and "growing slowly"
721
+ // are different kinds of problem and should not interleave.
722
+ //
723
+ // clear -1
724
+ // draining seconds to clear (sooner is better)
725
+ // stalled 1e12
726
+ // growing 1e13 + jobs/sec (faster is worse)
727
+ //
728
+ // An unmeasured queue returns "", which the sorter puts last in both
729
+ // directions — see sortRows.
730
+ function forecastSort(depth, vel) {
731
+ if (depth === 0) return -1;
732
+ if (vel == null) return "";
733
+ if (Math.abs(vel) * 60 < STALLED_PER_MIN) return 1e12;
734
+ return vel > 0 ? 1e13 + vel : depth / -vel;
735
+ }
736
+
737
+ function rateLabel(vel) {
738
+ var v = Math.abs(vel);
739
+ return v >= 1 ? v.toFixed(0) + "/s" : Math.round(v * 60) + "/min";
740
+ }
741
+
742
+ function paintForecast(el, depth, vel) {
743
+ var f = forecast(depth, vel);
744
+ el.textContent = f.text;
745
+ el.className = "rh-fc " + f.cls;
746
+ }
747
+
226
748
  function humanizeEta(s) {
227
749
  if (s < 60) return "~" + Math.round(s) + "s";
228
750
  if (s < 3600) return "~" + Math.round(s / 60) + "m";
@@ -246,6 +768,9 @@
246
768
  var peak = Math.max.apply(null, pts);
247
769
  var max = peak * 1.25 || 1;
248
770
  var x = function (i) { return i / (n - 1) * w; }, y = function (v) { return h - pad - v / max * (h - pad * 2); };
771
+ // Faint horizontal rules. Reading a value off a bare sparkline is guesswork.
772
+ ctx.strokeStyle = "rgba(255,255,255,.05)"; ctx.lineWidth = 1;
773
+ for (var gi = 1; gi < 4; gi++) { var gy = h / 4 * gi; ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); }
249
774
  var g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, "rgba(110,139,255,.30)"); g.addColorStop(1, "rgba(110,139,255,0)");
250
775
  ctx.beginPath(); ctx.moveTo(0, h); pts.forEach(function (v, i) { ctx.lineTo(x(i), y(v)); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = g; ctx.fill();
251
776
  ctx.beginPath(); pts.forEach(function (v, i) { i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v)); }); ctx.strokeStyle = "#6E8BFF"; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
@@ -264,10 +789,18 @@
264
789
  tip.style.left = (e.clientX - rect.left) + "px";
265
790
  tip.hidden = false;
266
791
  }
792
+ function setValue(el, text) {
793
+ if (el.textContent === text) return; // unchanged: no flash, or the page blinks constantly
794
+ el.textContent = text;
795
+ el.classList.remove("rh-changed");
796
+ void el.offsetWidth;
797
+ el.classList.add("rh-changed");
798
+ }
267
799
  function apply(d) {
268
800
  Object.keys(d).forEach(function (k) {
269
- document.querySelectorAll('[data-stat="' + k + '"]').forEach(function (el) { el.textContent = fmt(d[k]); });
270
- document.querySelectorAll('[data-nav="' + k + '"]').forEach(function (el) { el.textContent = fmt(d[k]); });
801
+ var text = fmt(d[k]);
802
+ document.querySelectorAll('[data-stat="' + k + '"]').forEach(function (el) { setValue(el, text); });
803
+ document.querySelectorAll('[data-nav="' + k + '"]').forEach(function (el) { setValue(el, text); });
271
804
  });
272
805
  }
273
806
  function poll() {
@@ -276,14 +809,23 @@
276
809
  .then(function (r) { return r.json(); })
277
810
  .then(function (d) {
278
811
  setConn(true);
812
+ restartTick();
279
813
  apply(d);
814
+ paintCards(d);
815
+ // Feeds the ⌘K palette. Derived from the depth keys rather than sent
816
+ // twice — see the stats endpoint.
817
+ if (d.queue_depths) QUEUE_NAMES = Object.keys(d.queue_depths).sort();
280
818
  var now = Date.now();
281
819
  var backlog = d.enqueued + d.scheduled + d.retries;
282
820
  if (lastProcessed != null) {
283
821
  var dt = (now - lastT) / 1000;
284
822
  var dp = d.processed - lastProcessed;
285
823
  var rate = dt > 0 ? Math.max(0, dp / dt) : 0;
286
- var re = document.querySelector('[data-stat="rate"]'); if (re) re.textContent = fmt(Math.round(rate * 60));
824
+ // querySelectorAll: the header and the Processed card both carry this
825
+ // attribute, and querySelector only ever matched the header — so the
826
+ // card read "— / min" forever.
827
+ var perMin = fmt(Math.round(rate * 60));
828
+ document.querySelectorAll('[data-stat="rate"]').forEach(function (el) { el.textContent = perMin; });
287
829
  var ne = document.getElementById("rh-chart-now"); if (ne) ne.textContent = Math.round(rate);
288
830
  // Accumulate into the current bucket; finalize it (one averaged point)
289
831
  // once the chosen interval has elapsed. Live "/s" still updates each poll.
@@ -303,19 +845,358 @@
303
845
  var vel = (backlog - lastBacklog) / dt;
304
846
  setText("rh-m-velocity", (vel >= 0 ? "+" : "") + vel.toFixed(1) + "/s");
305
847
  }
306
- setText("rh-m-eta", backlog === 0 ? "clear" : (rate > 0 ? humanizeEta(backlog / rate) : "—"));
848
+ // Was backlog/throughput, which answers "how long if nothing else
849
+ // arrives" — a number that stays reassuring while the backlog grows.
850
+ var netVel = lastBacklog != null && dt > 0 ? smooth("__total", (backlog - lastBacklog) / dt) : null;
851
+ setText("rh-m-eta", forecast(backlog, netVel).text);
852
+
853
+ var pick = document.getElementById("rh-m-deadline");
854
+ if (pick) {
855
+ var c = capacity(backlog, rate, netVel, d.concurrency, parseInt(pick.value, 10));
856
+ setText("rh-m-capacity", c.text);
857
+ setText("rh-m-capacity-note", c.note);
858
+ // Remember the last inputs so changing the deadline answers
859
+ // immediately rather than waiting for the next poll.
860
+ lastCapacity = { backlog: backlog, rate: rate, vel: netVel, concurrency: d.concurrency };
861
+ }
862
+ }
863
+ // Per-queue forecast. Rendered wherever a cell asks for one, so the
864
+ // Queues table, a queue page and the dashboard all read the same
865
+ // number rather than three approximations of it.
866
+ if (d.queue_depths) {
867
+ var dtq = lastT ? (now - lastT) / 1000 : 0;
868
+ document.querySelectorAll("[data-rh-forecast]").forEach(function (el) {
869
+ var name = el.getAttribute("data-rh-forecast");
870
+ var depth = d.queue_depths[name];
871
+ if (depth == null) return;
872
+ var vel = null;
873
+ if (lastDepths && lastDepths[name] != null && dtq > 0) {
874
+ vel = smooth(name, (depth - lastDepths[name]) / dtq);
875
+ }
876
+ paintForecast(el, depth, vel);
877
+ var cell = el.closest("td");
878
+ if (cell) cell.setAttribute("data-sort-value", forecastSort(depth, vel));
879
+
880
+ var hist = depthHistory[name] || (depthHistory[name] = []);
881
+ hist.push(depth);
882
+ if (hist.length > SPARK_SAMPLES) hist.shift();
883
+
884
+ var row = el.closest("tr");
885
+ var sev = severity(depth, vel);
886
+ if (row && sev) row.setAttribute("data-rh-sev", sev);
887
+
888
+ var spark = row && row.querySelector("[data-rh-spark]");
889
+ if (spark && hist.length >= 2) {
890
+ var wait = row.querySelector("[data-rh-spark-wait]");
891
+ if (wait) wait.hidden = true;
892
+ spark.hidden = false;
893
+ drawSpark(spark, hist, token(SEV_COLOUR[sev] || "--accent"));
894
+ }
895
+ });
896
+ // Drop state for queues that no longer exist. A queue that goes
897
+ // away and comes back must start measuring afresh, or its old
898
+ // smoothed velocity is blended into the first new sample.
899
+ Object.keys(smoothVel).forEach(function (k) {
900
+ if (k !== "__total" && !(k in d.queue_depths)) delete smoothVel[k];
901
+ });
902
+ Object.keys(depthHistory).forEach(function (k) {
903
+ if (!(k in d.queue_depths)) delete depthHistory[k];
904
+ });
905
+ lastDepths = d.queue_depths;
307
906
  }
308
907
  lastProcessed = d.processed; lastFailed = d.failed; lastBacklog = backlog; lastT = now;
309
908
  })
310
909
  .catch(function () { setConn(false); });
311
910
  }
911
+ // Restart the countdown arc. One transition per poll, so there is no rAF
912
+ // loop and nothing to keep in sync with the timer — and because it is
913
+ // driven off the same POLL_MS the timer uses, changing the interval in
914
+ // Settings is reflected immediately rather than on the next page load.
915
+ function restartTick() {
916
+ var el = document.getElementById("rh-tick");
917
+ if (!el) return;
918
+ el.classList.remove("is-running");
919
+ el.style.transitionDuration = "0s";
920
+ el.style.setProperty("--rh-tick-turn", "1turn");
921
+ void el.offsetWidth; // flush, or the reset coalesces with the sweep below
922
+ el.classList.add("is-running");
923
+ el.style.transitionDuration = POLL_MS + "ms";
924
+ el.style.setProperty("--rh-tick-turn", "0turn");
925
+ }
926
+
927
+ // Capacity (#36). The forecast says whether a backlog clears; this says what
928
+ // to do about it. Whole-fleet only — Sidekiq counts processed jobs
929
+ // globally, so claiming a per-queue thread count would be inventing the
930
+ // split.
931
+ //
932
+ // arrivals = throughput + net velocity (what is coming in)
933
+ // need = backlog / deadline + arrivals (rate to clear in time)
934
+ // perThread = throughput / concurrency
935
+ //
936
+ // Arrivals are included on purpose: sizing to backlog alone under-provisions
937
+ // exactly when it matters, because work keeps arriving while you drain.
938
+ function capacity(backlog, rate, vel, concurrency, deadlineSecs) {
939
+ if (!concurrency) return { text: "—", note: "no workers reporting in" };
940
+ if (backlog === 0) return { text: "0", note: "nothing waiting" };
941
+ if (!rate) return { text: "—", note: "no throughput to measure from" };
942
+
943
+ var perThread = rate / concurrency;
944
+ var arrivals = Math.max(0, rate + (vel || 0));
945
+ var need = Math.ceil((backlog / deadlineSecs + arrivals) / perThread);
946
+ var extra = need - concurrency;
947
+ if (extra <= 0) {
948
+ return { text: concurrency + " ✓", note: "current fleet clears it in time" };
949
+ }
950
+ return { text: "+" + extra,
951
+ note: "on top of " + concurrency + " — " + need + " threads to clear in time" };
952
+ }
953
+
954
+ // Sortable tables (#queues). Client-side on purpose: the Forecast column
955
+ // has no server-side value at all — it is computed from two polls — so a
956
+ // server sort could not order by it. Doing all columns the same way keeps
957
+ // one behaviour rather than two.
958
+ //
959
+ // Comparison reads data-sort-value, never the cell text: latency renders
960
+ // as "3h 19m" and the forecast as a sentence, and parsing those back would
961
+ // be re-deriving what the server already knew.
962
+ function sortRows(table, col, dir) {
963
+ var tbody = table.tBodies[0];
964
+ if (!tbody) return;
965
+ var rows = Array.prototype.slice.call(tbody.rows).filter(function (r) {
966
+ return !r.querySelector(".rh-empty");
967
+ });
968
+ var type = table.tHead.rows[0].cells[col].getAttribute("data-sort-type");
969
+ rows.sort(function (a, b) {
970
+ var x = cellValue(a, col), y = cellValue(b, col);
971
+ // Rows with no value yet ("measuring…") sort last in both directions
972
+ // rather than clustering at whichever end zero happens to fall.
973
+ if (x === "" && y === "") return 0;
974
+ if (x === "") return 1;
975
+ if (y === "") return -1;
976
+ var cmp = type === "num" ? parseFloat(x) - parseFloat(y) : String(x).localeCompare(String(y));
977
+ return dir === "asc" ? cmp : -cmp;
978
+ });
979
+ rows.forEach(function (r) { tbody.appendChild(r); });
980
+ }
981
+
982
+ function cellValue(row, col) {
983
+ var cell = row.cells[col];
984
+ if (!cell) return "";
985
+ var v = cell.getAttribute("data-sort-value");
986
+ return v == null ? cell.textContent.trim() : v;
987
+ }
988
+
989
+ document.addEventListener("click", function (e) {
990
+ var th = e.target.closest && e.target.closest("th.rh-sort");
991
+ if (!th) return;
992
+ var table = th.closest("table");
993
+ var wasAsc = th.classList.contains("is-asc");
994
+ table.querySelectorAll("th.rh-sort").forEach(function (o) {
995
+ o.classList.remove("is-asc", "is-desc");
996
+ });
997
+ // Numbers open descending: on this page the interesting end of Size and
998
+ // Latency is the big one, which is the same reason the default order is
999
+ // worst-first.
1000
+ var dir = wasAsc ? "desc" : (th.classList.contains("is-desc") ? "asc"
1001
+ : (th.getAttribute("data-sort-type") === "num" ? "desc" : "asc"));
1002
+ th.classList.add(dir === "asc" ? "is-asc" : "is-desc");
1003
+ sortRows(table, parseInt(th.getAttribute("data-sort-col"), 10), dir);
1004
+ });
1005
+
1006
+ // Per-queue depth history. The poll already reads every queue's depth and
1007
+ // was keeping only the previous value; keeping a window instead costs one
1008
+ // array per queue and turns the Size column into a trend.
1009
+ //
1010
+ // Session-local on purpose. Closing the tab restarts the trend, which is
1011
+ // the honest behaviour for something computed in a browser — persisting it
1012
+ // is the retention question in #34, not this.
1013
+ var SPARK_SAMPLES = 40;
1014
+ var depthHistory = {};
1015
+ // Stat-card trends. Same idea as the queue trends and the same window:
1016
+ // the poll already returns these numbers, so a card can show direction
1017
+ // rather than only a value.
1018
+ var CARD_SERIES = { processed: "processed", failed: "failed", busy: "busy", backlog: null };
1019
+ // Which of those are lifetime counters. Their trend is the RATE; plotting the
1020
+ // counter itself draws a line that can only ever go up, whose slope carries
1021
+ // the whole signal and whose level is what the eye actually reads. On Failed
1022
+ // it is worse than useless: auto-scaled around 131,000, a few hundred new
1023
+ // failures render as a flat line with a step at the start.
1024
+ //
1025
+ // Busy threads and Backlog are levels already — they rise and fall on their
1026
+ // own, and differencing them would throw away the thing worth seeing.
1027
+ var CARD_CUMULATIVE = { processed: true, failed: true };
1028
+ var cardHistory = {};
1029
+ var cardPrev = {};
1030
+ var SEV_COLOUR = { crit: "--crit", warn: "--warn", ok: "--good" };
1031
+
1032
+ function token(name) {
1033
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#6E8BFF";
1034
+ }
1035
+
1036
+ // Urgency from the same rule the Forecast column uses, so a stripe and the
1037
+ // sentence beside it can never disagree.
1038
+ function severity(depth, vel) {
1039
+ if (depth === 0) return "ok";
1040
+ if (vel == null) return null;
1041
+ if (vel > 0 && Math.abs(vel) * 60 >= STALLED_PER_MIN) return "crit";
1042
+ if (Math.abs(vel) * 60 < STALLED_PER_MIN) return "warn";
1043
+ return "ok";
1044
+ }
1045
+
1046
+ function drawSpark(cv, hist, colour) {
1047
+ var ctx = cv.getContext("2d");
1048
+ var w = cv.width, h = cv.height, pad = 4;
1049
+ ctx.clearRect(0, 0, w, h);
1050
+ if (hist.length < 2) return;
1051
+
1052
+ var lo = Math.min.apply(null, hist), hi = Math.max.apply(null, hist);
1053
+ // A queue that has not moved has span 0, and (v - lo) / 1 puts every
1054
+ // point on the floor — a flat line at the bottom looks like an axis, or
1055
+ // like zero. Pad the range so an unchanging series sits mid-height.
1056
+ var span = hi - lo;
1057
+ if (span <= 0) { lo -= 1; span = 2; }
1058
+ var x = function (i) { return pad + i / (hist.length - 1) * (w - pad * 2); };
1059
+ var y = function (v) { return h - pad - (v - lo) / span * (h - pad * 2); };
1060
+
1061
+ // Area first: a filled shape reads as volume, where a bare line reads as
1062
+ // noise at this size.
1063
+ var g = ctx.createLinearGradient(0, 0, 0, h);
1064
+ g.addColorStop(0, colour + "66"); g.addColorStop(1, colour + "00");
1065
+ ctx.beginPath(); ctx.moveTo(x(0), h);
1066
+ hist.forEach(function (v, i) { ctx.lineTo(x(i), y(v)); });
1067
+ ctx.lineTo(x(hist.length - 1), h); ctx.closePath();
1068
+ ctx.fillStyle = g; ctx.fill();
1069
+
1070
+ ctx.beginPath();
1071
+ hist.forEach(function (v, i) { i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v)); });
1072
+ ctx.strokeStyle = colour; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
1073
+
1074
+ // Emphasised endpoint — "now" is the only point anyone acts on.
1075
+ ctx.beginPath();
1076
+ ctx.arc(x(hist.length - 1), y(hist[hist.length - 1]), 2.6, 0, Math.PI * 2);
1077
+ ctx.fillStyle = colour; ctx.fill();
1078
+ }
1079
+
1080
+ // Backlog is derived rather than sent, so it is computed here to keep the
1081
+ // card and the number above it from disagreeing.
1082
+ function cardValue(key, d) {
1083
+ if (key === "backlog") return d.enqueued + d.scheduled + d.retries;
1084
+ return d[CARD_SERIES[key]];
1085
+ }
1086
+
1087
+ // The point a sparkline should plot for one poll. Pure, so test/js can lift it
1088
+ // out of this file rather than keep a copy that drifts.
1089
+ // - a level plots as itself
1090
+ // - a counter plots what arrived since the last sample
1091
+ // - a counter's first sample plots nothing: there is no delta yet
1092
+ // Clamped at zero because Sidekiq's counters can be reset underneath us, and
1093
+ // a negative arrival rate is not a thing.
1094
+ function cardPoint(key, value, previous) {
1095
+ if (!CARD_CUMULATIVE[key]) return value;
1096
+ if (previous == null || value == null) return null;
1097
+ return Math.max(0, value - previous);
1098
+ }
1099
+
1100
+ var CARD_COLOUR = { processed: "--accent", failed: "--crit", busy: "--good", backlog: "--warn" };
1101
+
1102
+ function paintCards(d) {
1103
+ document.querySelectorAll("[data-rh-card]").forEach(function (cv) {
1104
+ var key = cv.getAttribute("data-rh-card");
1105
+ var raw = cardValue(key, d);
1106
+ if (raw == null) return;
1107
+ var point = cardPoint(key, raw, cardPrev[key]);
1108
+ cardPrev[key] = raw;
1109
+ if (point == null) return;
1110
+ var hist = cardHistory[key] || (cardHistory[key] = []);
1111
+ hist.push(point);
1112
+ if (hist.length > SPARK_SAMPLES) hist.shift();
1113
+ if (hist.length >= 2) drawSpark(cv, hist, token(CARD_COLOUR[key] || "--accent"));
1114
+ });
1115
+ }
1116
+
1117
+ // Daily history. Counts and a failure rate share one canvas but not one
1118
+ // scale: plotted together on a single axis the rate would be a flat line
1119
+ // at zero, because 0.4% and 900 jobs are not comparable numbers.
1120
+ //
1121
+ // Drawn once on load, not on the poll — a six-month window does not change
1122
+ // between two five-second ticks.
1123
+ function drawHistory() {
1124
+ var cv = document.getElementById("rh-history");
1125
+ if (!cv) return;
1126
+ var raw = cv.getAttribute("data-rh-history");
1127
+ if (!raw) return;
1128
+ var days;
1129
+ try { days = JSON.parse(raw); } catch (_) { return; }
1130
+ if (!days.length) return;
1131
+
1132
+ var dpr = window.devicePixelRatio || 1;
1133
+ var w = cv.clientWidth, h = 150;
1134
+ if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
1135
+ var ctx = cv.getContext("2d");
1136
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1137
+ ctx.clearRect(0, 0, w, h);
1138
+
1139
+ var pad = 8;
1140
+ var maxP = Math.max.apply(null, days.map(function (d) { return d.p; })) || 1;
1141
+ var maxR = Math.max.apply(null, days.map(function (d) { return d.r; })) || 0.01;
1142
+ var x = function (i) { return days.length === 1 ? w / 2 : i / (days.length - 1) * (w - pad * 2) + pad; };
1143
+ var yP = function (v) { return h - pad - v / (maxP * 1.15) * (h - pad * 2); };
1144
+ var yR = function (v) { return h - pad - v / (maxR * 1.35) * (h - pad * 2); };
1145
+
1146
+ ctx.strokeStyle = "rgba(128,128,128,.13)"; ctx.lineWidth = 1;
1147
+ for (var g = 1; g < 4; g++) { var gy = h / 4 * g; ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); }
1148
+
1149
+ // Processed as filled area: it is context, not the point.
1150
+ var accent = token("--accent");
1151
+ var grad = ctx.createLinearGradient(0, 0, 0, h);
1152
+ grad.addColorStop(0, accent + "44"); grad.addColorStop(1, accent + "00");
1153
+ ctx.beginPath(); ctx.moveTo(x(0), h);
1154
+ days.forEach(function (d, i) { ctx.lineTo(x(i), yP(d.p)); });
1155
+ ctx.lineTo(x(days.length - 1), h); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
1156
+ ctx.beginPath();
1157
+ days.forEach(function (d, i) { i ? ctx.lineTo(x(i), yP(d.p)) : ctx.moveTo(x(i), yP(d.p)); });
1158
+ ctx.strokeStyle = accent; ctx.lineWidth = 1.6; ctx.stroke();
1159
+
1160
+ // The typical rate as a dashed baseline, so today has something to be
1161
+ // compared against rather than just a number.
1162
+ var typical = parseFloat(cv.getAttribute("data-rh-typical"));
1163
+ if (!isNaN(typical) && typical > 0) {
1164
+ ctx.save(); ctx.setLineDash([ 3, 3 ]);
1165
+ ctx.beginPath(); ctx.moveTo(pad, yR(typical)); ctx.lineTo(w - pad, yR(typical));
1166
+ ctx.strokeStyle = token("--faint"); ctx.lineWidth = 1; ctx.stroke(); ctx.restore();
1167
+ }
1168
+
1169
+ // Failure rate on top, because it is the line worth reading.
1170
+ ctx.beginPath();
1171
+ days.forEach(function (d, i) { i ? ctx.lineTo(x(i), yR(d.r)) : ctx.moveTo(x(i), yR(d.r)); });
1172
+ ctx.strokeStyle = token("--crit"); ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
1173
+
1174
+ cv.onmousemove = function (e) {
1175
+ var rect = cv.getBoundingClientRect();
1176
+ var i = Math.round((e.clientX - rect.left - pad) / ((w - pad * 2) || 1) * (days.length - 1));
1177
+ var d = days[Math.max(0, Math.min(days.length - 1, i))];
1178
+ if (d) setText("rh-hist-read", d.d + " · " + Number(d.p).toLocaleString() + " processed · " +
1179
+ (d.r * 100).toFixed(2) + "% failed");
1180
+ };
1181
+ cv.onmouseleave = function () { setText("rh-hist-read", ""); };
1182
+ }
1183
+
312
1184
  // Surface a failed poll instead of letting the numbers silently go stale.
313
1185
  function setConn(ok) {
314
1186
  var el = document.getElementById("rh-live"), lbl = document.getElementById("rh-live-label");
315
1187
  if (el) el.classList.toggle("is-stale", !ok);
316
1188
  if (lbl) lbl.textContent = ok ? "live" : "reconnecting…";
317
1189
  }
318
- function startOnce() { if (started) return; started = true; poll(); setInterval(poll, POLL_MS); }
1190
+ // The timer handle is kept so a viewer changing the refresh interval in
1191
+ // Settings takes effect immediately, rather than on the next full page load.
1192
+ var pollHandle = null;
1193
+ function startOnce() { if (started) return; started = true; poll(); restartPoll(); }
1194
+ function restartPoll() {
1195
+ if (pollHandle) clearInterval(pollHandle);
1196
+ POLL_MS = storedPollMs();
1197
+ pollHandle = setInterval(poll, POLL_MS);
1198
+ restartTick();
1199
+ }
319
1200
  function wireChart() {
320
1201
  var cv = document.getElementById("rh-chart"); if (!cv) return;
321
1202
  cv.onmousemove = chartHover;
@@ -342,7 +1223,7 @@
342
1223
  var next = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
343
1224
  document.documentElement.setAttribute("data-theme", next);
344
1225
  try { localStorage.setItem("rh-theme", next); } catch (_) {}
345
- syncTheme();
1226
+ syncTheme(); syncSettingsForm();
346
1227
  });
347
1228
  // width toggle (compact 1180px ⟷ full) — same persistence pattern as theme
348
1229
  document.addEventListener("click", function (e) {
@@ -350,28 +1231,95 @@
350
1231
  var next = document.documentElement.getAttribute("data-width") === "full" ? "compact" : "full";
351
1232
  document.documentElement.setAttribute("data-width", next);
352
1233
  try { localStorage.setItem("rh-width", next); } catch (_) {}
353
- syncWidth();
1234
+ syncWidth(); syncSettingsForm();
354
1235
  });
355
- // command palette (⌘K)
1236
+ // command palette (⌘K). Two halves: static navigation, and search actions
1237
+ // built from whatever you type — paste a JID, a queue, or a squad and the
1238
+ // palette offers the searches that will actually find it, rather than
1239
+ // making you pick a page first and search again once you're there.
1240
+ // Capability-gated exactly like the sidebar: a backend without a retry
1241
+ // set must not be offered a search into one.
1242
+ var SETS = [
1243
+ { label: "Dead", path: "<%= dead_set_path %>", ico: "dead" },
1244
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", ico: "retries" },<% end %>
1245
+ { label: "Scheduled", path: "<%= scheduled_path %>", ico: "scheduled" },
1246
+ { label: "Errors", path: "<%= errors_path %>", ico: "errors" }
1247
+ ];
1248
+ // Declared tag vocabulary and live queue names, so the palette can tell a
1249
+ // squad from a queue from a free-text term.
1250
+ var TAG_VOCAB = <%== (RoundhouseUi::Tags.filters || {}).to_json %>;
1251
+ // Filled by the stats poll, which already loads queues for its count, so
1252
+ // knowing the queue names costs no extra Redis work.
1253
+ var QUEUE_NAMES = [];
1254
+
1255
+ function palQuery(q) {
1256
+ var out = [], seen = {};
1257
+ // A 12+ char hex string is a Sidekiq JID — offer the sets directly.
1258
+ if (/^[0-9a-f]{12,}$/i.test(q)) {
1259
+ SETS.forEach(function (s) {
1260
+ out.push({ label: "Find job " + q + " in " + s.label, icon: s.icon,
1261
+ path: s.path + "?q=" + encodeURIComponent(q) });
1262
+ });
1263
+ return out;
1264
+ }
1265
+ // Exact squad / tag value → the structured filter, which is stricter
1266
+ // and safer than a substring match.
1267
+ Object.keys(TAG_VOCAB).forEach(function (key) {
1268
+ TAG_VOCAB[key].forEach(function (value) {
1269
+ if (value.toLowerCase().indexOf(q) !== 0) return;
1270
+ seen[value] = true;
1271
+ SETS.forEach(function (s) {
1272
+ out.push({ label: key + ": " + value + " — " + s.label, ico: "dashboard",
1273
+ path: s.path + "?tag=" + encodeURIComponent(key + ":" + value) });
1274
+ });
1275
+ });
1276
+ });
1277
+ // Known queue name → filter by queue.
1278
+ QUEUE_NAMES.forEach(function (name) {
1279
+ if (name.toLowerCase().indexOf(q) !== 0) return;
1280
+ SETS.slice(0, 3).forEach(function (s) {
1281
+ out.push({ label: "Queue " + name + " — " + s.label, ico: "queues",
1282
+ path: s.path + "?queue=" + encodeURIComponent(name) });
1283
+ });
1284
+ });
1285
+ // Always offer plain text search as a fallback.
1286
+ SETS.forEach(function (s) {
1287
+ out.push({ label: "Search " + s.label + " for “" + q + "”", icon: s.icon,
1288
+ path: s.path + "?q=" + encodeURIComponent(q) });
1289
+ });
1290
+ return out;
1291
+ }
1292
+
356
1293
  var CMDS = [
357
- { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
358
- { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
359
- { label: "Busy", path: "<%= busy_path %>", icon: "◐" },
360
- <% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>", icon: "◷" },<% end %>
361
- { label: "Queues", path: "<%= queues_path %>", icon: "≡" },
362
- { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
363
- <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", icon: "↻" },<% end %>
364
- { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
365
- { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
366
- <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
367
- <% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },<% end %>
368
- { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
369
- { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
370
- <% if RoundhouseUi.allow_job_editing %>{ label: "Enqueue job", path: "<%= new_job_path %>", icon: "+" },<% end %>
371
- { label: "Toggle theme", action: "theme", icon: "◐" },
372
- { label: "Toggle full width", action: "width", icon: "⟷" }
1294
+ { label: "Dashboard", path: "<%= root_path %>", ico: "dashboard" },
1295
+ { label: "Metrics", path: "<%= metrics_path %>", ico: "metrics" },
1296
+ { label: "Busy", path: "<%= busy_path %>", ico: "busy" },
1297
+ <% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>", ico: "workers" },<% end %>
1298
+ { label: "Queues", path: "<%= queues_path %>", ico: "queues" },
1299
+ { label: "Scheduled", path: "<%= scheduled_path %>", ico: "scheduled" },
1300
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", ico: "retries" },<% end %>
1301
+ { label: "Errors", path: "<%= errors_path %>", ico: "errors" },
1302
+ { label: "Dead", path: "<%= dead_set_path %>", ico: "dead" },
1303
+ <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>", ico: "capsules" },<% end %>
1304
+ <% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>", ico: "redis" },<% end %>
1305
+ <% if RoundhouseUi::Recurring.any? %>{ label: "Recurring", path: "<%= recurring_path %>", ico: "scheduled" },<% end %>
1306
+ <% if RoundhouseUi.backend.supports?(:snapshots) %>{ label: "Snapshots", path: "<%= snapshots_path %>", ico: "snapshots" },<% end %>
1307
+ <% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Audit log", path: "<%= audit_log_path %>", ico: "audit" },<% end %>
1308
+ { label: "Settings", path: "<%= settings_path %>", ico: "settings" },
1309
+ <% if job_editing? %>{ label: "Enqueue job", path: "<%= new_job_path %>", ico: "enqueue" },<% end %>
1310
+ { label: "Toggle theme", action: "theme", ico: "busy" },
1311
+ { label: "Toggle full width", action: "width", ico: "width" }
373
1312
  ];
374
1313
  var palSel = 0, palFiltered = [];
1314
+ // A cloned <svg> node, by name, from the server-rendered store. Never a
1315
+ // markup string — a string is exactly what put escaped SVG source in every row.
1316
+ function icoNode(name) {
1317
+ if (!name) return null;
1318
+ var store = document.getElementById("rh-ico-store");
1319
+ if (!store || !store.content) return null;
1320
+ var holder = store.content.querySelector('[data-ico="' + name + '"]');
1321
+ return holder && holder.firstElementChild ? holder.firstElementChild.cloneNode(true) : null;
1322
+ }
375
1323
  function palEl() { return document.getElementById("rh-palette"); }
376
1324
  function palInputEl() { return document.getElementById("rh-palette-input"); }
377
1325
  function palListEl() { return document.getElementById("rh-palette-list"); }
@@ -383,14 +1331,18 @@
383
1331
  }
384
1332
  function palRender() {
385
1333
  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; });
1334
+ var q = input.value.trim().toLowerCase();
1335
+ var nav = CMDS.filter(function (c) { return c.label.toLowerCase().indexOf(q) !== -1; });
1336
+ // Search actions lead once you've typed: with a query, finding the job
1337
+ // is the intent; jumping to a page is the fallback.
1338
+ palFiltered = q ? palQuery(q).concat(nav) : nav;
388
1339
  if (palSel >= palFiltered.length) palSel = Math.max(0, palFiltered.length - 1);
389
1340
  list.innerHTML = "";
390
1341
  palFiltered.forEach(function (c, i) {
391
1342
  var d = document.createElement("div");
392
1343
  d.className = "rh-palette-item" + (i === palSel ? " sel" : "");
393
- var s = document.createElement("span"); s.className = "rh-palette-ico"; s.textContent = c.icon;
1344
+ var s = document.createElement("span"); s.className = "rh-palette-ico";
1345
+ var glyph = icoNode(c.ico); if (glyph) s.appendChild(glyph);
394
1346
  d.appendChild(s); d.appendChild(document.createTextNode(c.label));
395
1347
  d.addEventListener("click", function () { palRun(c); });
396
1348
  d.addEventListener("mousemove", function () { palSel = i; palMark(); });
@@ -411,6 +1363,43 @@
411
1363
  if (e.target && e.target.id === "rh-palette") { palClose(); return; }
412
1364
  if (e.target.closest && e.target.closest("#rh-palette-open")) { e.preventDefault(); palOpen(); }
413
1365
  });
1366
+ // Settings page. Delegated from document so it survives Turbo body swaps,
1367
+ // the same reason select-all is (#26). Every control writes local storage
1368
+ // and applies immediately — there is no Save button because there is
1369
+ // nothing to submit.
1370
+ function prefRead(key) { try { return localStorage.getItem(key) || ""; } catch (_) { return ""; } }
1371
+ function prefWrite(key, value) {
1372
+ try { value === "" ? localStorage.removeItem(key) : localStorage.setItem(key, value); } catch (_) {}
1373
+ }
1374
+ function syncSettingsForm() {
1375
+ var t = document.getElementById("rh-set-theme"); if (t) t.value = prefRead("rh-theme") || "dark";
1376
+ var p = document.getElementById("rh-set-palette"); if (p) p.value = prefRead("rh-palette");
1377
+ var w = document.getElementById("rh-set-width"); if (w) w.value = prefRead("rh-width") || "compact";
1378
+ var i = document.getElementById("rh-set-poll"); if (i) i.value = prefRead("rh-poll");
1379
+ }
1380
+ function applyAttr(name, value) {
1381
+ value ? document.documentElement.setAttribute(name, value)
1382
+ : document.documentElement.removeAttribute(name);
1383
+ }
1384
+ document.addEventListener("change", function (e) {
1385
+ var el = e.target; if (!el || !el.id) return;
1386
+ if (el.id === "rh-m-deadline" && lastCapacity) {
1387
+ var c = capacity(lastCapacity.backlog, lastCapacity.rate, lastCapacity.vel,
1388
+ lastCapacity.concurrency, parseInt(el.value, 10));
1389
+ setText("rh-m-capacity", c.text); setText("rh-m-capacity-note", c.note);
1390
+ }
1391
+ if (el.id === "rh-set-theme") { prefWrite("rh-theme", el.value); applyAttr("data-theme", el.value); syncTheme(); }
1392
+ if (el.id === "rh-set-palette") { prefWrite("rh-palette", el.value); applyAttr("data-rh-palette", el.value); }
1393
+ if (el.id === "rh-set-width") { prefWrite("rh-width", el.value); applyAttr("data-width", el.value); syncWidth(); }
1394
+ if (el.id === "rh-set-poll") { prefWrite("rh-poll", el.value); restartPoll(); }
1395
+ });
1396
+ document.addEventListener("click", function (e) {
1397
+ if (!e.target.closest || !e.target.closest("#rh-set-reset")) return;
1398
+ [ "rh-theme", "rh-palette", "rh-width", "rh-poll" ].forEach(function (k) { prefWrite(k, ""); });
1399
+ [ "data-theme", "data-rh-palette", "data-width" ].forEach(function (a) { applyAttr(a, null); });
1400
+ syncTheme(); syncWidth(); restartPoll(); syncSettingsForm();
1401
+ });
1402
+
414
1403
  // Throughput interval picker — persist the choice; changing it re-buckets
415
1404
  // from here on, so reset the in-progress bucket and redraw.
416
1405
  function restoreChartInterval() {
@@ -432,7 +1421,198 @@
432
1421
  else if (e.key === "Enter") { e.preventDefault(); if (palFiltered[palSel]) palRun(palFiltered[palSel]); }
433
1422
  });
434
1423
 
435
- document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); wireChart(); draw(); });
1424
+ document.addEventListener("turbo:load", function () { startOnce(); syncTheme(); syncWidth(); syncSettingsForm(); setActiveNav(); restoreChartInterval(); wireChart(); draw(); drawHistory(); });
1425
+
1426
+ // Dead set "select all". Delegated from document rather than bound to the
1427
+ // checkbox, because a Turbo visit (searching, paging, arriving via the
1428
+ // nav) replaces <body> — the old element and its listener are discarded,
1429
+ // and a re-inserted body script carries a nonce the original page's CSP
1430
+ // does not accept. document survives both, so this binds once and keeps
1431
+ // working. See issue #26.
1432
+ document.addEventListener("change", function (e) {
1433
+ if (!e.target || e.target.id !== "rh-all") return;
1434
+ document.querySelectorAll(".rh-check").forEach(function (cb) { cb.checked = e.target.checked; });
1435
+ });
1436
+ // ── Filter bar: Tab completes, Enter applies ──────────────────────────
1437
+ //
1438
+ // Tab completes a key or a value WITHOUT submitting. Enter submits, and the
1439
+ // pill appears because the server rendered it from the filter that is now in
1440
+ // force. A pill therefore exists if and only if its filter is applied.
1441
+ //
1442
+ // That ordering is the point. A pill drawn on Tab, before the request, would
1443
+ // show a filter that is not applied — the invisible-filter bug inverted, and
1444
+ // this bar sits directly above "delete all matching". Nothing here holds
1445
+ // filter state: the box is a string, the URL is the truth.
1446
+ //
1447
+ // Delegated from document for the same reason as the select-all above: a
1448
+ // Turbo visit replaces <body>, discarding elements and their listeners, and a
1449
+ // re-inserted body script would carry a nonce this page's CSP rejects.
1450
+ var acItems = [], acSel = 0;
1451
+
1452
+ function acBox() { return document.querySelector(".rh-bar input[type=search]"); }
1453
+ function acMenu(make) {
1454
+ var bar = document.querySelector(".rh-bar");
1455
+ if (!bar) return null;
1456
+ var m = bar.querySelector(".rh-ac");
1457
+ if (!m && make) {
1458
+ m = document.createElement("div");
1459
+ m.className = "rh-ac";
1460
+ m.setAttribute("role", "listbox");
1461
+ m.hidden = true;
1462
+ bar.appendChild(m);
1463
+ }
1464
+ return m;
1465
+ }
1466
+ function acHide() {
1467
+ var m = acMenu(false);
1468
+ if (m) { m.hidden = true; m.innerHTML = ""; }
1469
+ acItems = []; acSel = 0;
1470
+ }
1471
+ function acData(box, attr, fallback) {
1472
+ try { return JSON.parse(box.getAttribute(attr) || fallback); } catch (e) { return JSON.parse(fallback); }
1473
+ }
1474
+ // The typed text splits into "everything already settled" and the last token,
1475
+ // which is the only thing being completed.
1476
+ function acSplit(value) {
1477
+ var caretless = value.replace(/^\s+/, "");
1478
+ var cut = caretless.lastIndexOf(" ");
1479
+ return { head: cut === -1 ? "" : caretless.slice(0, cut + 1), token: cut === -1 ? caretless : caretless.slice(cut + 1) };
1480
+ }
1481
+ function acSuggest(box, token) {
1482
+ var keys = (box.getAttribute("data-rh-filter-keys") || "").split(",").filter(Boolean);
1483
+ var values = acData(box, "data-rh-filter-values", "{}");
1484
+ var eq = token.indexOf("=");
1485
+ if (eq === -1) {
1486
+ if (!token) return [];
1487
+ return keys.filter(function (k) { return k.indexOf(token) === 0 && k !== token; })
1488
+ .map(function (k) { return { insert: k + "=", label: k + "=", why: "filter" }; });
1489
+ }
1490
+ var key = token.slice(0, eq), typed = token.slice(eq + 1).toLowerCase();
1491
+ return (values[key] || [])
1492
+ .filter(function (v) { return v.toLowerCase().indexOf(typed) === 0 && v !== token.slice(eq + 1); })
1493
+ .slice(0, 12)
1494
+ .map(function (v) { return { insert: key + "=" + (/\s/.test(v) ? '"' + v + '"' : v), label: v, why: key }; });
1495
+ }
1496
+ function acRender(list) {
1497
+ var m = acMenu(true);
1498
+ if (!m) return;
1499
+ acItems = list; acSel = 0;
1500
+ if (!list.length) { acHide(); return; }
1501
+ m.innerHTML = "";
1502
+ list.forEach(function (s, i) {
1503
+ var row = document.createElement("div");
1504
+ row.className = "rh-ac-row";
1505
+ row.setAttribute("role", "option");
1506
+ row.setAttribute("aria-selected", i === 0 ? "true" : "false");
1507
+ row.setAttribute("data-rh-ac-i", String(i));
1508
+ var label = document.createElement("span");
1509
+ label.textContent = s.label;
1510
+ var why = document.createElement("span");
1511
+ why.className = "rh-ac-why";
1512
+ why.textContent = s.why;
1513
+ row.appendChild(label); row.appendChild(why);
1514
+ m.appendChild(row);
1515
+ });
1516
+ m.hidden = false;
1517
+ }
1518
+ function acMark() {
1519
+ var m = acMenu(false);
1520
+ if (!m) return;
1521
+ Array.prototype.forEach.call(m.children, function (c, i) {
1522
+ c.setAttribute("aria-selected", i === acSel ? "true" : "false");
1523
+ });
1524
+ }
1525
+ function acRefresh() {
1526
+ var box = acBox();
1527
+ if (!box) return;
1528
+ acRender(acSuggest(box, acSplit(box.value).token));
1529
+ }
1530
+ // Completing leaves a trailing space, so the next keystroke starts a new token
1531
+ // rather than extending a finished facet. Then re-suggest: after `class=` that
1532
+ // offers values, and after a complete value it offers nothing, which is what
1533
+ // lets Enter fall through to submitting instead of re-completing forever.
1534
+ function acAccept(box, s) {
1535
+ box.value = acSplit(box.value).head + s.insert + (s.insert.slice(-1) === "=" ? "" : " ");
1536
+ acHide();
1537
+ acRefresh();
1538
+ }
1539
+
1540
+ document.addEventListener("input", function (e) {
1541
+ if (!e.target.matches || !e.target.matches(".rh-bar input[type=search]")) return;
1542
+ acRefresh();
1543
+ });
1544
+ // Focusing an EMPTY box offers the keys straight away. Reported as "not
1545
+ // completing the tag, nor the typeahead": with nothing typed there was nothing
1546
+ // to suggest, so Tab had no target and fell through to the next tab stop — the
1547
+ // help button — which reads as the bar rejecting you. Opening on focus makes
1548
+ // the vocabulary discoverable without hovering help, and means Tab always has
1549
+ // something to do the moment the caret is in the box.
1550
+ document.addEventListener("focusin", function (e) {
1551
+ if (!e.target.matches || !e.target.matches(".rh-bar input[type=search]")) return;
1552
+ if (e.target.value === "") {
1553
+ var keys = (e.target.getAttribute("data-rh-filter-keys") || "").split(",").filter(Boolean);
1554
+ acRender(keys.filter(function (k) { return k !== "text"; }).map(function (k) {
1555
+ return { insert: k + "=", label: k + "=", why: "filter" };
1556
+ }));
1557
+ return;
1558
+ }
1559
+ acRefresh();
1560
+ });
1561
+ document.addEventListener("keydown", function (e) {
1562
+ var box = e.target;
1563
+ if (!box.matches || !box.matches(".rh-bar input[type=search]")) return;
1564
+
1565
+ if (e.key === "Escape" && acItems.length) { e.preventDefault(); acHide(); return; }
1566
+ if (e.key === "ArrowDown" && acItems.length) { e.preventDefault(); acSel = (acSel + 1) % acItems.length; acMark(); return; }
1567
+ if (e.key === "ArrowUp" && acItems.length) { e.preventDefault(); acSel = (acSel - 1 + acItems.length) % acItems.length; acMark(); return; }
1568
+
1569
+ // Tab completes and stays put. Only when there is something to complete —
1570
+ // otherwise it must move focus, or the bar becomes a keyboard trap.
1571
+ if (e.key === "Tab" && !e.shiftKey && acItems.length) {
1572
+ e.preventDefault();
1573
+ acAccept(box, acItems[acSel]);
1574
+ return;
1575
+ }
1576
+ if (e.key === "Enter") {
1577
+ // A highlighted KEY completion is mid-facet: finish it and wait. Anything
1578
+ // else submits, and the server decides what the pills say.
1579
+ if (acItems.length && acItems[acSel].insert.slice(-1) === "=") {
1580
+ e.preventDefault();
1581
+ acAccept(box, acItems[acSel]);
1582
+ return;
1583
+ }
1584
+ if (acItems.length) { acAccept(box, acItems[acSel]); }
1585
+ acHide();
1586
+ return; // let the form submit
1587
+ }
1588
+ // Backspace on an empty box removes the last pill by NAVIGATING to the
1589
+ // without-that-facet URL the server already rendered. No client-side filter
1590
+ // state to fall out of step with the server's.
1591
+ if (e.key === "Backspace" && box.value === "") {
1592
+ var xs = document.querySelectorAll(".rh-pillf .x");
1593
+ if (xs.length) { e.preventDefault(); xs[xs.length - 1].click(); }
1594
+ }
1595
+ });
1596
+ document.addEventListener("mousedown", function (e) {
1597
+ var row = e.target.closest && e.target.closest(".rh-ac-row");
1598
+ var box = acBox();
1599
+ if (row && box) {
1600
+ e.preventDefault();
1601
+ acSel = parseInt(row.getAttribute("data-rh-ac-i"), 10) || 0;
1602
+ acAccept(box, acItems[acSel]);
1603
+ box.focus();
1604
+ return;
1605
+ }
1606
+ if (!e.target.closest || !e.target.closest(".rh-bar")) acHide();
1607
+ });
1608
+ // Clicking the bar's padding should focus the box, the way a single control does.
1609
+ document.addEventListener("click", function (e) {
1610
+ if (e.target.classList && e.target.classList.contains("rh-bar")) {
1611
+ var box = acBox();
1612
+ if (box) box.focus();
1613
+ }
1614
+ });
1615
+
436
1616
  document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); wireChart(); });
437
1617
  document.addEventListener("visibilitychange", function () { if (!document.hidden) poll(); });
438
1618
  })();
@@ -445,37 +1625,39 @@
445
1625
 
446
1626
  <nav class="rh-grp">
447
1627
  <div class="rh-grp-label">Overview</div>
448
- <%= nav_link "Dashboard", root_path, icon: "▦" %>
449
- <%= nav_link "Metrics", metrics_path, icon: "▤" %>
450
- <%= nav_link "Busy", busy_path, icon: "◐", badge: "busy" %>
1628
+ <%= nav_link "Dashboard", root_path, icon: :dashboard %>
1629
+ <%= nav_link "Metrics", metrics_path, icon: :metrics %>
1630
+ <%= nav_link "Busy", busy_path, icon: :busy, badge: "busy", badge_title: "Jobs running right now" %>
451
1631
  <% if RoundhouseUi.backend.supports?(:workers) %>
452
- <%= nav_link "Workers", workers_path, icon: "◷" %>
1632
+ <%= nav_link "Workers", workers_path, icon: :workers %>
453
1633
  <% end %>
454
1634
  </nav>
455
1635
  <nav class="rh-grp">
456
1636
  <div class="rh-grp-label">Work</div>
457
- <%= nav_link "Queues", queues_path, icon: "≡", badge: "queues" %>
458
- <%= nav_link "Scheduled", scheduled_path, icon: "◔", badge: "scheduled" %>
1637
+ <%= nav_link "Queues", queues_path, icon: :queues, badge: "enqueued", badge_title: "Jobs enqueued across all queues" %>
1638
+ <%= nav_link "Scheduled", scheduled_path, icon: :scheduled, badge: "scheduled", badge_title: "Jobs scheduled for later" %>
459
1639
  <% if RoundhouseUi.backend.supports?(:retries) %>
460
- <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
1640
+ <%= nav_link "Retries", retries_path, icon: :retries, badge: "retries", badge_class: "warn", badge_title: "Jobs waiting to retry" %>
461
1641
  <% end %>
462
- <%= nav_link "Errors", errors_path, icon: "⚠" %>
463
- <%= nav_link "Dead", dead_set_path, icon: "✕", badge: "dead", badge_class: "crit" %>
464
- <% if RoundhouseUi.allow_job_editing %>
465
- <%= nav_link "Enqueue job", new_job_path, icon: "+" %>
1642
+ <%= nav_link "Errors", errors_path, icon: :errors %>
1643
+ <%= nav_link "Dead", dead_set_path, icon: :dead, badge: "dead", badge_class: "crit", badge_title: "Jobs that exhausted their retries" %>
1644
+ <% if job_editing? %>
1645
+ <%= nav_link "Enqueue job", new_job_path, icon: :enqueue %>
466
1646
  <% end %>
467
1647
  </nav>
468
1648
  <nav class="rh-grp">
469
1649
  <div class="rh-grp-label">Infrastructure</div>
470
1650
  <%# Capsules are a Sidekiq 7+ concept — hide on 6.x and on non-Sidekiq backends. %>
471
1651
  <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>
472
- <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
1652
+ <%= nav_link "Capsules", capsules_path, icon: :capsules %>
473
1653
  <% end %>
474
1654
  <% if RoundhouseUi.backend.supports?(:redis) %>
475
- <%= nav_link "Redis", redis_info_path, icon: "◈" %>
1655
+ <%= nav_link "Redis", redis_info_path, icon: :redis %>
476
1656
  <% end %>
477
- <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
478
- <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
1657
+ <% if RoundhouseUi.backend.supports?(:snapshots) %><%= nav_link "Snapshots", snapshots_path, icon: :snapshots %><% end %>
1658
+ <% if RoundhouseUi::Recurring.any? %><%= nav_link "Recurring", recurring_path, icon: :scheduled %><% end %>
1659
+ <% if RoundhouseUi.backend.supports?(:redis) %><%= nav_link "Audit log", audit_log_path, icon: :audit %><% end %>
1660
+ <%= nav_link "Settings", settings_path, icon: :settings %>
479
1661
  </nav>
480
1662
  </aside>
481
1663
 
@@ -484,7 +1666,9 @@
484
1666
  <h1><%= yield :title %></h1>
485
1667
  <span class="rh-crumb"><%= yield :crumb %></span>
486
1668
  <span class="rh-spacer"></span>
487
- <span class="rh-live" id="rh-live"><span class="rh-dot"></span> <span id="rh-live-label">live</span> · <span class="num" data-stat="rate">—</span>/min</span>
1669
+ <span class="rh-live" id="rh-live" title="Refreshes automatically — change the interval in Settings">
1670
+ <span class="rh-tick"><span class="rh-tick-fill" id="rh-tick"></span></span>
1671
+ <span id="rh-live-label">live</span> · <span class="num" data-stat="rate">—</span>/min</span>
488
1672
  <% if RoundhouseUi.read_only %><span class="rh-ro">read-only</span><% end %>
489
1673
  <button class="rh-kbd" id="rh-palette-open" type="button" title="Command palette (⌘K)">⌘K</button>
490
1674
  <button class="rh-iconbtn" id="rh-width" type="button" title="Toggle full width" aria-label="Toggle full width">⟷</button>
@@ -498,9 +1682,18 @@
498
1682
  </main>
499
1683
  </div>
500
1684
 
1685
+ <%# Icons for the palette, rendered once by the same `icon` helper the rest of
1686
+ the UI uses. The palette used to interpolate the icon markup straight into a
1687
+ JS string, and that markup is NOT html_safe — so ERB escaped it, and every row
1688
+ rendered a wall of `&lt;svg viewBox=&quot;…` as literal text, because the
1689
+ palette assigns it with textContent. It broke silently when icons stopped
1690
+ being single Unicode glyphs. No markup travels through JS now; JS clones a
1691
+ node instead. %>
1692
+ <template id="rh-ico-store"><% RoundhouseUi::Icons::PATHS.each_key do |ico_name| %><span data-ico="<%= ico_name %>"><%= icon(ico_name) %></span><% end %></template>
1693
+
501
1694
  <div id="rh-palette" class="rh-palette" data-turbo-permanent hidden>
502
1695
  <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">
1696
+ <input id="rh-palette-input" type="text" placeholder="Search a job ID, queue, squad, or class — or jump to a page" autocomplete="off">
504
1697
  <div id="rh-palette-list" class="rh-palette-list"></div>
505
1698
  </div>
506
1699
  </div>