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.
- checksums.yaml +4 -4
- data/README.md +620 -46
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
- data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
- data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
- data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
- data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
- data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
- data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
- data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
- data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
- data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
- data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
- data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
- data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
- data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
- data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
- data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
- data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
- data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
- data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
- data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
- data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
- data/config/routes.rb +9 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
- data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
- data/lib/roundhouse_ui/demo.rb +130 -0
- data/lib/roundhouse_ui/error_groups.rb +25 -4
- data/lib/roundhouse_ui/filter_query.rb +504 -0
- data/lib/roundhouse_ui/health.rb +43 -4
- data/lib/roundhouse_ui/history.rb +54 -0
- data/lib/roundhouse_ui/icons.rb +104 -0
- data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
- data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
- data/lib/roundhouse_ui/observability.rb +55 -7
- data/lib/roundhouse_ui/pause.rb +6 -11
- data/lib/roundhouse_ui/queue_summary.rb +11 -0
- data/lib/roundhouse_ui/recurring.rb +137 -0
- data/lib/roundhouse_ui/runbooks.rb +75 -0
- data/lib/roundhouse_ui/snapshots.rb +13 -2
- data/lib/roundhouse_ui/tags.rb +112 -0
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +235 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- 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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
270
|
-
document.querySelectorAll('[data-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 %>",
|
|
358
|
-
{ label: "Metrics", path: "<%= metrics_path %>",
|
|
359
|
-
{ label: "Busy", path: "<%= busy_path %>",
|
|
360
|
-
<% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>",
|
|
361
|
-
{ label: "Queues", path: "<%= queues_path %>",
|
|
362
|
-
{ label: "Scheduled", path: "<%= scheduled_path %>",
|
|
363
|
-
<% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>",
|
|
364
|
-
{ label: "Errors", path: "<%= errors_path %>",
|
|
365
|
-
{ label: "Dead", path: "<%= dead_set_path %>",
|
|
366
|
-
<% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>",
|
|
367
|
-
<% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>",
|
|
368
|
-
{ label: "
|
|
369
|
-
{ label: "
|
|
370
|
-
<% if RoundhouseUi.
|
|
371
|
-
{ label: "
|
|
372
|
-
{ label: "
|
|
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
|
-
|
|
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";
|
|
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:
|
|
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: "
|
|
458
|
-
<%= nav_link "Scheduled", scheduled_path, icon: "
|
|
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:
|
|
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:
|
|
464
|
-
<% if
|
|
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
|
-
|
|
478
|
-
|
|
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"
|
|
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 `<svg viewBox="…` 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="
|
|
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>
|