roundhouse_ui 0.10.0 → 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 +563 -50
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +190 -23
- data/app/controllers/roundhouse_ui/application_controller.rb +73 -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 +23 -4
- data/app/controllers/roundhouse_ui/dead_controller.rb +26 -12
- data/app/controllers/roundhouse_ui/errors_controller.rb +30 -8
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +56 -7
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +33 -12
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -8
- 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 +253 -10
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +113 -27
- data/app/helpers/roundhouse_ui/tags_helper.rb +30 -3
- data/app/views/layouts/roundhouse_ui/application.html.erb +1181 -92
- 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 +23 -15
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -9
- data/app/views/roundhouse_ui/jobs/show.html.erb +7 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +53 -13
- 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 +17 -15
- data/app/views/roundhouse_ui/scheduled/index.html.erb +10 -10
- 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 +2 -4
- 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 +13 -3
- 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 +8 -11
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +200 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- metadata +29 -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,7 +106,6 @@
|
|
|
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); }
|
|
91
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; }
|
|
92
111
|
/* jid sits inline after the class now; dim it so it reads as a copy target
|
|
@@ -104,16 +123,260 @@
|
|
|
104
123
|
font-weight:600; margin-right:3px; max-width:34ch; overflow:hidden; text-overflow:ellipsis;
|
|
105
124
|
white-space:nowrap; }
|
|
106
125
|
.rh-actiongroup form { display:inline; margin:0; }
|
|
107
|
-
/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
|
|
112
192
|
.rh-pill-link { text-decoration:none; border:1px solid transparent; }
|
|
113
193
|
.rh-pill-link:hover { border-color:var(--accent); color:var(--text); }
|
|
114
194
|
.rh-pill-link.is-on { border-color:var(--accent); color:var(--text); background:var(--panel-3); }
|
|
115
195
|
|
|
116
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
|
+
|
|
117
380
|
.rh-quickfilters { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-bottom:14px; }
|
|
118
381
|
.rh-qf { display:inline-flex; align-items:center; gap:7px; font-size:12px; text-decoration:none;
|
|
119
382
|
background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:4px 10px; color:var(--muted); }
|
|
@@ -122,14 +385,166 @@
|
|
|
122
385
|
.rh-qf-n { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:11px; color:var(--faint); }
|
|
123
386
|
.rh-qf.is-on .rh-qf-n { color:var(--accent); }
|
|
124
387
|
|
|
125
|
-
|
|
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; }
|
|
447
|
+
|
|
126
448
|
.rh-btn:hover { border-color:var(--accent); }
|
|
127
|
-
.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)); }
|
|
128
451
|
.rh-inline { display:inline; margin:0; }
|
|
129
452
|
|
|
130
|
-
.rh-search { margin-bottom:16px; }
|
|
131
|
-
|
|
132
|
-
|
|
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; }
|
|
133
548
|
.rh-bulkbar { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
|
|
134
549
|
.rh-note { color:var(--faint); font-size:12px; margin-top:14px; }
|
|
135
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; }
|
|
@@ -159,44 +574,51 @@
|
|
|
159
574
|
canvas#rh-chart { display:block; width:100%; height:90px; }
|
|
160
575
|
.rh-cb { width:15px; height:15px; }
|
|
161
576
|
|
|
162
|
-
/* composite health banner */
|
|
163
|
-
.rh-health { background:var(--panel); border:1px solid var(--line-soft); border-left:3px solid var(--faint); border-radius:12px; margin-bottom:18px; }
|
|
164
|
-
.rh-health-ok { border-left-color:var(--good); }
|
|
165
|
-
.rh-health-warn { border-left-color:var(--warn); }
|
|
166
|
-
.rh-health-crit { border-left-color:var(--crit); }
|
|
167
|
-
.rh-health summary { display:flex; align-items:center; gap:12px; padding:14px 16px; cursor:pointer; list-style:none; }
|
|
168
|
-
.rh-health summary::-webkit-details-marker { display:none; }
|
|
169
|
-
.rh-health-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--faint); }
|
|
170
|
-
.rh-health-ok .rh-health-dot { background:var(--good); }
|
|
171
|
-
.rh-health-warn .rh-health-dot { background:var(--warn); }
|
|
172
|
-
.rh-health-crit .rh-health-dot { background:var(--crit); }
|
|
173
|
-
.rh-health-verdict { font-weight:650; font-size:15px; }
|
|
174
|
-
.rh-health-ok .rh-health-verdict { color:var(--good); }
|
|
175
|
-
.rh-health-warn .rh-health-verdict { color:var(--warn); }
|
|
176
|
-
.rh-health-crit .rh-health-verdict { color:var(--crit); }
|
|
177
|
-
.rh-health-reason { color:var(--muted); font-size:13px; }
|
|
178
|
-
.rh-health-cta { margin-left:auto; font:11px var(--mono); color:var(--faint); }
|
|
179
|
-
.rh-health[open] .rh-health-cta { color:var(--accent); }
|
|
180
|
-
.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; }
|
|
181
|
-
.rh-sig { display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
182
|
-
.rh-sig b { min-width:170px; font-weight:600; }
|
|
183
|
-
|
|
184
577
|
/* insight panels */
|
|
185
578
|
.rh-insights { display:grid; gap:14px; margin-bottom:24px; }
|
|
186
579
|
@media(min-width:760px){ .rh-insights { grid-template-columns:1fr 1fr; } }
|
|
187
580
|
.rh-insight { padding:6px 0; }
|
|
188
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; }
|
|
189
|
-
|
|
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; }
|
|
190
589
|
.rh-insight-row .rh-insight-main { flex:1; min-width:0; font-size:13px; }
|
|
191
590
|
.rh-insight-row .rh-pill { font-variant-numeric:tabular-nums; }
|
|
192
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; }
|
|
193
594
|
|
|
194
595
|
/* collapsible disclosure (large args, full backtrace) */
|
|
195
|
-
.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; }
|
|
196
597
|
.rh-disclose summary::-webkit-details-marker { display:none; }
|
|
197
598
|
.rh-disclose summary::before { content:"▸ "; }
|
|
198
599
|
.rh-disclose[open] summary::before { content:"▾ "; }
|
|
199
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
|
+
|
|
200
622
|
/* connection state — poll failures surface here instead of silently going stale */
|
|
201
623
|
.rh-live.is-stale .rh-dot { background:var(--crit); }
|
|
202
624
|
.rh-live.is-stale .rh-dot::after { animation:none; }
|
|
@@ -231,7 +653,6 @@
|
|
|
231
653
|
.rh-sec { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint); font-weight:600; margin:22px 0 8px; }
|
|
232
654
|
.rh-joblink { color:inherit; text-decoration:none; }
|
|
233
655
|
.rh-joblink:hover { text-decoration:underline; }
|
|
234
|
-
.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; }
|
|
235
656
|
.rh-kbd:hover { color:var(--text); border-color:var(--accent); }
|
|
236
657
|
.rh-palette[hidden] { display:none; }
|
|
237
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); }
|
|
@@ -241,10 +662,14 @@
|
|
|
241
662
|
.rh-palette-item { display:flex; align-items:center; gap:12px; padding:9px 12px; border-radius:9px; cursor:pointer; font-size:13px; }
|
|
242
663
|
.rh-palette-item.sel { background:var(--panel-2); }
|
|
243
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 %>
|
|
244
669
|
</style>
|
|
245
670
|
<%# Apply the saved theme before first paint so it persists across navigations with no flash. %>
|
|
246
671
|
<script nonce="<%= content_nonce %>">
|
|
247
|
-
(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) {} })();
|
|
248
673
|
</script>
|
|
249
674
|
<%# Turbo Drive: link navigation without full-page reloads (kills the flicker). %>
|
|
250
675
|
<script src="<%= turbo_js_path %>"></script>
|
|
@@ -252,10 +677,74 @@
|
|
|
252
677
|
(function () {
|
|
253
678
|
var fmt = function (n) { return Number(n).toLocaleString(); };
|
|
254
679
|
var started = false, lastProcessed = null, lastFailed = null, lastBacklog = null, lastT = null;
|
|
255
|
-
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();
|
|
256
690
|
var samples = [], buckets = [], bucketStart = null; // samples = current bucket; buckets = finalized per-interval averages
|
|
257
691
|
var lastPts = []; // points last drawn, for the hover tooltip to read
|
|
258
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
|
+
|
|
259
748
|
function humanizeEta(s) {
|
|
260
749
|
if (s < 60) return "~" + Math.round(s) + "s";
|
|
261
750
|
if (s < 3600) return "~" + Math.round(s / 60) + "m";
|
|
@@ -279,6 +768,9 @@
|
|
|
279
768
|
var peak = Math.max.apply(null, pts);
|
|
280
769
|
var max = peak * 1.25 || 1;
|
|
281
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(); }
|
|
282
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)");
|
|
283
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();
|
|
284
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();
|
|
@@ -297,10 +789,18 @@
|
|
|
297
789
|
tip.style.left = (e.clientX - rect.left) + "px";
|
|
298
790
|
tip.hidden = false;
|
|
299
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
|
+
}
|
|
300
799
|
function apply(d) {
|
|
301
800
|
Object.keys(d).forEach(function (k) {
|
|
302
|
-
|
|
303
|
-
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); });
|
|
304
804
|
});
|
|
305
805
|
}
|
|
306
806
|
function poll() {
|
|
@@ -309,15 +809,23 @@
|
|
|
309
809
|
.then(function (r) { return r.json(); })
|
|
310
810
|
.then(function (d) {
|
|
311
811
|
setConn(true);
|
|
812
|
+
restartTick();
|
|
312
813
|
apply(d);
|
|
313
|
-
|
|
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();
|
|
314
818
|
var now = Date.now();
|
|
315
819
|
var backlog = d.enqueued + d.scheduled + d.retries;
|
|
316
820
|
if (lastProcessed != null) {
|
|
317
821
|
var dt = (now - lastT) / 1000;
|
|
318
822
|
var dp = d.processed - lastProcessed;
|
|
319
823
|
var rate = dt > 0 ? Math.max(0, dp / dt) : 0;
|
|
320
|
-
|
|
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; });
|
|
321
829
|
var ne = document.getElementById("rh-chart-now"); if (ne) ne.textContent = Math.round(rate);
|
|
322
830
|
// Accumulate into the current bucket; finalize it (one averaged point)
|
|
323
831
|
// once the chosen interval has elapsed. Live "/s" still updates each poll.
|
|
@@ -337,19 +845,358 @@
|
|
|
337
845
|
var vel = (backlog - lastBacklog) / dt;
|
|
338
846
|
setText("rh-m-velocity", (vel >= 0 ? "+" : "") + vel.toFixed(1) + "/s");
|
|
339
847
|
}
|
|
340
|
-
|
|
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;
|
|
341
906
|
}
|
|
342
907
|
lastProcessed = d.processed; lastFailed = d.failed; lastBacklog = backlog; lastT = now;
|
|
343
908
|
})
|
|
344
909
|
.catch(function () { setConn(false); });
|
|
345
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
|
+
|
|
346
1184
|
// Surface a failed poll instead of letting the numbers silently go stale.
|
|
347
1185
|
function setConn(ok) {
|
|
348
1186
|
var el = document.getElementById("rh-live"), lbl = document.getElementById("rh-live-label");
|
|
349
1187
|
if (el) el.classList.toggle("is-stale", !ok);
|
|
350
1188
|
if (lbl) lbl.textContent = ok ? "live" : "reconnecting…";
|
|
351
1189
|
}
|
|
352
|
-
|
|
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
|
+
}
|
|
353
1200
|
function wireChart() {
|
|
354
1201
|
var cv = document.getElementById("rh-chart"); if (!cv) return;
|
|
355
1202
|
cv.onmousemove = chartHover;
|
|
@@ -376,7 +1223,7 @@
|
|
|
376
1223
|
var next = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
|
|
377
1224
|
document.documentElement.setAttribute("data-theme", next);
|
|
378
1225
|
try { localStorage.setItem("rh-theme", next); } catch (_) {}
|
|
379
|
-
syncTheme();
|
|
1226
|
+
syncTheme(); syncSettingsForm();
|
|
380
1227
|
});
|
|
381
1228
|
// width toggle (compact 1180px ⟷ full) — same persistence pattern as theme
|
|
382
1229
|
document.addEventListener("click", function (e) {
|
|
@@ -384,7 +1231,7 @@
|
|
|
384
1231
|
var next = document.documentElement.getAttribute("data-width") === "full" ? "compact" : "full";
|
|
385
1232
|
document.documentElement.setAttribute("data-width", next);
|
|
386
1233
|
try { localStorage.setItem("rh-width", next); } catch (_) {}
|
|
387
|
-
syncWidth();
|
|
1234
|
+
syncWidth(); syncSettingsForm();
|
|
388
1235
|
});
|
|
389
1236
|
// command palette (⌘K). Two halves: static navigation, and search actions
|
|
390
1237
|
// built from whatever you type — paste a JID, a queue, or a squad and the
|
|
@@ -393,10 +1240,10 @@
|
|
|
393
1240
|
// Capability-gated exactly like the sidebar: a backend without a retry
|
|
394
1241
|
// set must not be offered a search into one.
|
|
395
1242
|
var SETS = [
|
|
396
|
-
{ label: "Dead", path: "<%= dead_set_path %>",
|
|
397
|
-
<% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>",
|
|
398
|
-
{ label: "Scheduled", path: "<%= scheduled_path %>",
|
|
399
|
-
{ label: "Errors", path: "<%= errors_path %>",
|
|
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" }
|
|
400
1247
|
];
|
|
401
1248
|
// Declared tag vocabulary and live queue names, so the palette can tell a
|
|
402
1249
|
// squad from a queue from a free-text term.
|
|
@@ -422,7 +1269,7 @@
|
|
|
422
1269
|
if (value.toLowerCase().indexOf(q) !== 0) return;
|
|
423
1270
|
seen[value] = true;
|
|
424
1271
|
SETS.forEach(function (s) {
|
|
425
|
-
out.push({ label: key + ": " + value + " — " + s.label,
|
|
1272
|
+
out.push({ label: key + ": " + value + " — " + s.label, ico: "dashboard",
|
|
426
1273
|
path: s.path + "?tag=" + encodeURIComponent(key + ":" + value) });
|
|
427
1274
|
});
|
|
428
1275
|
});
|
|
@@ -431,7 +1278,7 @@
|
|
|
431
1278
|
QUEUE_NAMES.forEach(function (name) {
|
|
432
1279
|
if (name.toLowerCase().indexOf(q) !== 0) return;
|
|
433
1280
|
SETS.slice(0, 3).forEach(function (s) {
|
|
434
|
-
out.push({ label: "Queue " + name + " — " + s.label,
|
|
1281
|
+
out.push({ label: "Queue " + name + " — " + s.label, ico: "queues",
|
|
435
1282
|
path: s.path + "?queue=" + encodeURIComponent(name) });
|
|
436
1283
|
});
|
|
437
1284
|
});
|
|
@@ -444,24 +1291,35 @@
|
|
|
444
1291
|
}
|
|
445
1292
|
|
|
446
1293
|
var CMDS = [
|
|
447
|
-
{ label: "Dashboard", path: "<%= root_path %>",
|
|
448
|
-
{ label: "Metrics", path: "<%= metrics_path %>",
|
|
449
|
-
{ label: "Busy", path: "<%= busy_path %>",
|
|
450
|
-
<% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>",
|
|
451
|
-
{ label: "Queues", path: "<%= queues_path %>",
|
|
452
|
-
{ label: "Scheduled", path: "<%= scheduled_path %>",
|
|
453
|
-
<% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>",
|
|
454
|
-
{ label: "Errors", path: "<%= errors_path %>",
|
|
455
|
-
{ label: "Dead", path: "<%= dead_set_path %>",
|
|
456
|
-
<% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>",
|
|
457
|
-
<% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>",
|
|
458
|
-
{ label: "
|
|
459
|
-
{ label: "
|
|
460
|
-
<% if RoundhouseUi.
|
|
461
|
-
{ label: "
|
|
462
|
-
{ 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" }
|
|
463
1312
|
];
|
|
464
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
|
+
}
|
|
465
1323
|
function palEl() { return document.getElementById("rh-palette"); }
|
|
466
1324
|
function palInputEl() { return document.getElementById("rh-palette-input"); }
|
|
467
1325
|
function palListEl() { return document.getElementById("rh-palette-list"); }
|
|
@@ -483,7 +1341,8 @@
|
|
|
483
1341
|
palFiltered.forEach(function (c, i) {
|
|
484
1342
|
var d = document.createElement("div");
|
|
485
1343
|
d.className = "rh-palette-item" + (i === palSel ? " sel" : "");
|
|
486
|
-
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);
|
|
487
1346
|
d.appendChild(s); d.appendChild(document.createTextNode(c.label));
|
|
488
1347
|
d.addEventListener("click", function () { palRun(c); });
|
|
489
1348
|
d.addEventListener("mousemove", function () { palSel = i; palMark(); });
|
|
@@ -504,6 +1363,43 @@
|
|
|
504
1363
|
if (e.target && e.target.id === "rh-palette") { palClose(); return; }
|
|
505
1364
|
if (e.target.closest && e.target.closest("#rh-palette-open")) { e.preventDefault(); palOpen(); }
|
|
506
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
|
+
|
|
507
1403
|
// Throughput interval picker — persist the choice; changing it re-buckets
|
|
508
1404
|
// from here on, so reset the in-progress bucket and redraw.
|
|
509
1405
|
function restoreChartInterval() {
|
|
@@ -525,7 +1421,7 @@
|
|
|
525
1421
|
else if (e.key === "Enter") { e.preventDefault(); if (palFiltered[palSel]) palRun(palFiltered[palSel]); }
|
|
526
1422
|
});
|
|
527
1423
|
|
|
528
|
-
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(); });
|
|
529
1425
|
|
|
530
1426
|
// Dead set "select all". Delegated from document rather than bound to the
|
|
531
1427
|
// checkbox, because a Turbo visit (searching, paging, arriving via the
|
|
@@ -537,6 +1433,186 @@
|
|
|
537
1433
|
if (!e.target || e.target.id !== "rh-all") return;
|
|
538
1434
|
document.querySelectorAll(".rh-check").forEach(function (cb) { cb.checked = e.target.checked; });
|
|
539
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
|
+
|
|
540
1616
|
document.addEventListener("DOMContentLoaded", function () { startOnce(); syncTheme(); syncWidth(); setActiveNav(); restoreChartInterval(); wireChart(); });
|
|
541
1617
|
document.addEventListener("visibilitychange", function () { if (!document.hidden) poll(); });
|
|
542
1618
|
})();
|
|
@@ -549,37 +1625,39 @@
|
|
|
549
1625
|
|
|
550
1626
|
<nav class="rh-grp">
|
|
551
1627
|
<div class="rh-grp-label">Overview</div>
|
|
552
|
-
<%= nav_link "Dashboard", root_path, icon:
|
|
553
|
-
<%= nav_link "Metrics", metrics_path, icon:
|
|
554
|
-
<%= 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" %>
|
|
555
1631
|
<% if RoundhouseUi.backend.supports?(:workers) %>
|
|
556
|
-
<%= nav_link "Workers", workers_path, icon:
|
|
1632
|
+
<%= nav_link "Workers", workers_path, icon: :workers %>
|
|
557
1633
|
<% end %>
|
|
558
1634
|
</nav>
|
|
559
1635
|
<nav class="rh-grp">
|
|
560
1636
|
<div class="rh-grp-label">Work</div>
|
|
561
|
-
<%= nav_link "Queues", queues_path, icon: "
|
|
562
|
-
<%= 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" %>
|
|
563
1639
|
<% if RoundhouseUi.backend.supports?(:retries) %>
|
|
564
|
-
<%= 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" %>
|
|
565
1641
|
<% end %>
|
|
566
|
-
<%= nav_link "Errors", errors_path, icon:
|
|
567
|
-
<%= nav_link "Dead", dead_set_path, icon:
|
|
568
|
-
<% if
|
|
569
|
-
<%= 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 %>
|
|
570
1646
|
<% end %>
|
|
571
1647
|
</nav>
|
|
572
1648
|
<nav class="rh-grp">
|
|
573
1649
|
<div class="rh-grp-label">Infrastructure</div>
|
|
574
1650
|
<%# Capsules are a Sidekiq 7+ concept — hide on 6.x and on non-Sidekiq backends. %>
|
|
575
1651
|
<% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>
|
|
576
|
-
<%= nav_link "Capsules", capsules_path, icon:
|
|
1652
|
+
<%= nav_link "Capsules", capsules_path, icon: :capsules %>
|
|
577
1653
|
<% end %>
|
|
578
1654
|
<% if RoundhouseUi.backend.supports?(:redis) %>
|
|
579
|
-
<%= nav_link "Redis", redis_info_path, icon:
|
|
1655
|
+
<%= nav_link "Redis", redis_info_path, icon: :redis %>
|
|
580
1656
|
<% end %>
|
|
581
|
-
|
|
582
|
-
|
|
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 %>
|
|
583
1661
|
</nav>
|
|
584
1662
|
</aside>
|
|
585
1663
|
|
|
@@ -588,7 +1666,9 @@
|
|
|
588
1666
|
<h1><%= yield :title %></h1>
|
|
589
1667
|
<span class="rh-crumb"><%= yield :crumb %></span>
|
|
590
1668
|
<span class="rh-spacer"></span>
|
|
591
|
-
<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>
|
|
592
1672
|
<% if RoundhouseUi.read_only %><span class="rh-ro">read-only</span><% end %>
|
|
593
1673
|
<button class="rh-kbd" id="rh-palette-open" type="button" title="Command palette (⌘K)">⌘K</button>
|
|
594
1674
|
<button class="rh-iconbtn" id="rh-width" type="button" title="Toggle full width" aria-label="Toggle full width">⟷</button>
|
|
@@ -602,6 +1682,15 @@
|
|
|
602
1682
|
</main>
|
|
603
1683
|
</div>
|
|
604
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
|
+
|
|
605
1694
|
<div id="rh-palette" class="rh-palette" data-turbo-permanent hidden>
|
|
606
1695
|
<div class="rh-palette-box">
|
|
607
1696
|
<input id="rh-palette-input" type="text" placeholder="Search a job ID, queue, squad, or class — or jump to a page" autocomplete="off">
|