job_harbor 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +136 -16
  3. data/app/assets/stylesheets/job_harbor/application.css +1 -1
  4. data/app/assets/stylesheets/job_harbor/application.tailwind.css +8 -0
  5. data/app/components/job_harbor/badge_component.rb +5 -3
  6. data/app/components/job_harbor/chart_component.rb +2 -4
  7. data/app/components/job_harbor/failure_rates_component.rb +19 -7
  8. data/app/components/job_harbor/job_filters_component.rb +7 -2
  9. data/app/components/job_harbor/job_row_component.rb +9 -4
  10. data/app/components/job_harbor/per_page_selector_component.rb +1 -3
  11. data/app/components/job_harbor/queue_card_component.rb +3 -2
  12. data/app/components/job_harbor/refresh_selector_component.rb +9 -4
  13. data/app/components/job_harbor/theme_toggle_component.rb +1 -2
  14. data/app/components/job_harbor/worker_card_component.rb +19 -12
  15. data/app/controllers/job_harbor/application_controller.rb +7 -3
  16. data/app/controllers/job_harbor/dashboard_controller.rb +1 -1
  17. data/app/controllers/job_harbor/jobs_controller.rb +73 -41
  18. data/app/controllers/job_harbor/queues_controller.rb +4 -5
  19. data/app/controllers/job_harbor/recurring_tasks_controller.rb +36 -8
  20. data/app/controllers/job_harbor/workers_controller.rb +2 -1
  21. data/app/models/job_harbor/chart_data.rb +22 -63
  22. data/app/models/job_harbor/dashboard_stats.rb +3 -20
  23. data/app/models/job_harbor/failure_stats.rb +8 -44
  24. data/app/models/job_harbor/job_presenter.rb +83 -133
  25. data/app/models/job_harbor/page_info.rb +9 -0
  26. data/app/models/job_harbor/processes.rb +25 -0
  27. data/app/models/job_harbor/queue_stats.rb +15 -12
  28. data/app/models/job_harbor/recurring_task_arguments.rb +29 -0
  29. data/app/models/job_harbor/sensitive_value_filter.rb +68 -0
  30. data/app/views/job_harbor/jobs/index.html.erb +12 -8
  31. data/app/views/job_harbor/jobs/search.html.erb +3 -3
  32. data/app/views/job_harbor/jobs/show.html.erb +15 -5
  33. data/app/views/job_harbor/queues/show.html.erb +9 -12
  34. data/app/views/job_harbor/recurring_tasks/show.html.erb +6 -6
  35. data/app/views/job_harbor/workers/index.html.erb +4 -2
  36. data/app/views/layouts/job_harbor/application.html.erb +58 -27
  37. data/lib/job_harbor/configuration.rb +30 -3
  38. data/lib/job_harbor/engine.rb +4 -11
  39. data/lib/job_harbor/version.rb +1 -1
  40. metadata +19 -12
  41. data/lib/tasks/solidqueue_dashboard_tasks.rake +0 -4
  42. data/lib/tasks/tailwind.rake +0 -129
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7a889d8b92626cb522dc5f9b5b946461a14f7d506dc0ee26ab6bbf59e1c7f90
4
- data.tar.gz: 9c216cfbfe737a3663f6fb92272e9de3f35377d56c3a6016d7ea854f061b0668
3
+ metadata.gz: 61fd4075abe3acb8265885f2ff9e5d007bede4df800c2c466b98e11cbc1810b2
4
+ data.tar.gz: c7b3836df141e4711ccb3f4828eb926da10f6f5d9901a996338e7977cb011641
5
5
  SHA512:
6
- metadata.gz: d5061556d98ad9b8e8ca86cb3f999e80edb396511ed440f6c3cbb63c6306f9ca39006166f995ff523978025a9e44762ddc150b2787aafafd99921942e1b7f10e
7
- data.tar.gz: 8ca29376a73274e9b226b0ff2f4d5f2866df1a4712c3947752feb9905d3a0a82835a6e88bd53aac43327a4d444fa34e8ccdfadee86c53e47b8bb6439110a2818
6
+ metadata.gz: fca96f13699a78acf5e848527c86df9f98e53ca6f1ce7ab7bd4b7d0ba923e737e8badadc86d990627bed83ffe8c81d07b1c324e0654f4f0704a5790bc223bf26
7
+ data.tar.gz: f3b64f11f8a06145c40830703b2d48f7ced097e01a173b3c63a9c645bb880701bf4e13174c2c3ecc9a570d7bbec9e4a2bd422888e9751c645138429720bfb412
data/README.md CHANGED
@@ -100,7 +100,8 @@ Create an initializer at `config/initializers/job_harbor.rb`:
100
100
 
101
101
  ```ruby
102
102
  JobHarbor.configure do |config|
103
- # Authorization - must return true to allow access (default: open)
103
+ # Authorization - must return true to allow access.
104
+ # Default: open in development and test, denied in every other environment until you set this.
104
105
  config.authorize_with = -> { current_user&.admin? }
105
106
 
106
107
  # Theme: :dark or :light (default: :dark)
@@ -109,12 +110,13 @@ JobHarbor.configure do |config|
109
110
  # Jobs per page (default: 25)
110
111
  config.jobs_per_page = 25
111
112
 
112
- # Auto-refresh polling interval in seconds (default: 5)
113
- config.poll_interval = 5
113
+ # Auto-refresh polling interval in seconds (default: 30)
114
+ # The selector offers Off, 15, 30, 60 and 300; any other value is added as its own option
115
+ config.poll_interval = 30
114
116
 
115
117
  # Toggle features on/off
116
118
  config.enable_recurring_tasks = true # default: true
117
- config.enable_real_time_updates = true # default: true
119
+ config.enable_real_time_updates = true # default: true (false removes auto-refresh entirely)
118
120
  config.enable_failure_stats = true # default: true
119
121
  config.enable_charts = true # default: true
120
122
 
@@ -127,33 +129,151 @@ JobHarbor.configure do |config|
127
129
 
128
130
  # Also accepts a proc for dynamic paths:
129
131
  # config.return_to_app_path = -> { main_app.root_path }
132
+
133
+ # Sensitive values (default: nil, use the app's config.filter_parameters)
134
+ # An array replaces that list; false shows raw values and brings argument search back.
135
+ config.filter_parameters = nil
130
136
  end
131
137
  ```
132
138
 
139
+ ### Sensitive values
140
+
141
+ Job arguments, the full payload and recurring task arguments are filtered with your app's
142
+ `config.filter_parameters`, read each time a page renders. Values under keys like `password` or
143
+ `token` show as `[FILTERED]`. Active Job's own `_aj_*` markers are left as they are.
144
+
145
+ While filtering is on, search matches job class, job ID and Active Job ID, but not argument
146
+ text: matching hidden values would let a search reveal them. Set `config.filter_parameters = false`
147
+ to show raw values and search arguments again.
148
+
149
+ Key filters don't redact free text or arguments passed positionally (not in a hash), because
150
+ those have no key. Error messages and backtraces are also shown as written. A Proc in
151
+ `filter_parameters` can scrub all of these by pattern; the dashboard runs it over positional
152
+ strings, error messages and backtraces too:
153
+
154
+ ```ruby
155
+ Rails.application.config.filter_parameters << ->(_key, value) {
156
+ value.gsub!(/\b(tok|sk)_live_\w+/, "[FILTERED]") if value.is_a?(String)
157
+ }
158
+ ```
159
+
160
+ If a filter raises, the whole value shows as `[FILTERED]`.
161
+
162
+ `config.filter_parameters` accepts only `nil` (use the app's list), `false` (raw values) or an
163
+ Array of Strings, Symbols, Regexps and Procs (replaces the list). Anything else, including `true` or `[true]`, raises `ArgumentError` at boot so a
164
+ misconfiguration can't quietly show everything. An empty Array `[]` means "mask nothing": values
165
+ show raw and argument search comes back, the same as `false`.
166
+
167
+ Search queries you type appear in your request logs as the `q` parameter. Add `:q` to your app's
168
+ `filter_parameters` if searches may contain sensitive values.
169
+
133
170
  ## Authorization
134
171
 
135
- The dashboard uses a configurable authorization callback. Return `true` to allow access:
172
+ **Production installs must configure `authorize_with`.** Until you do, the dashboard
173
+ denies every request in every environment except development and test (production,
174
+ staging, and so on) and logs a warning at boot. In development and test it stays open
175
+ so you can try it out without setup.
176
+
177
+ `authorize_with` is a lambda evaluated in the context of the dashboard controller
178
+ (`instance_exec`), so it can call your host app's helpers such as `current_user`. Return
179
+ a truthy value to allow the request. A rule you set explicitly is used as-is in every
180
+ environment, including `-> { true }` if you really want the dashboard open.
181
+
182
+ ```ruby
183
+ # config/initializers/job_harbor.rb
184
+ JobHarbor.configure do |config|
185
+ # Require an admin
186
+ config.authorize_with = -> { current_user&.admin? }
187
+ end
188
+ ```
189
+
190
+ ### HTTP basic auth
191
+
192
+ ```ruby
193
+ config.authorize_with = -> {
194
+ authenticate_or_request_with_http_basic("Job Harbor") do |user, password|
195
+ ActiveSupport::SecurityUtils.secure_compare(user, Rails.application.credentials.job_harbor_user!) &
196
+ ActiveSupport::SecurityUtils.secure_compare(password, Rails.application.credentials.job_harbor_password!)
197
+ end
198
+ }
199
+ ```
200
+
201
+ The bang readers raise `KeyError` when a credential is missing, so an unset credential
202
+ can never be matched by empty basic-auth credentials.
203
+
204
+ ### Devise
205
+
206
+ ```ruby
207
+ config.authorize_with = -> {
208
+ authenticate_user!
209
+ current_user.admin?
210
+ }
211
+ ```
212
+
213
+ ### Rails 8 authentication generator
214
+
215
+ The dashboard inherits your `ApplicationController`, so the generated `Authentication`
216
+ concern already requires a session before this rule runs:
217
+
218
+ ```ruby
219
+ config.authorize_with = -> { Current.user&.admin? }
220
+ ```
221
+
222
+ The dashboard is an isolated engine, so the generated concern's bare
223
+ `redirect_to new_session_path` resolves against the engine's routes and raises
224
+ `ActionController::UrlGenerationError` for visitors without a session. Point it at the
225
+ host's routes instead, in your `Authentication` concern:
136
226
 
137
227
  ```ruby
138
- # Allow all (default - not recommended for production)
139
- config.authorize_with = -> { true }
228
+ def request_authentication
229
+ session[:return_to_after_authenticating] = request.url
230
+ redirect_to main_app.new_session_path
231
+ end
232
+ ```
233
+
234
+ Or leave the concern alone and override it for the dashboard only:
140
235
 
141
- # Require authentication
142
- config.authorize_with = -> { current_user.present? }
236
+ ```ruby
237
+ # config/initializers/job_harbor.rb
238
+ Rails.application.config.to_prepare do
239
+ JobHarbor::ApplicationController.class_eval do
240
+ private
241
+
242
+ def request_authentication
243
+ session[:return_to_after_authenticating] = request.url
244
+ redirect_to main_app.new_session_path
245
+ end
246
+ end
247
+ end
248
+ ```
143
249
 
144
- # Require admin role
145
- config.authorize_with = -> { current_user&.admin? }
250
+ `main_app.new_session_path` from inside the engine is covered by the gem's tests; the
251
+ generated concern and the `to_prepare` override themselves are not exercised against a
252
+ real Rails 8 generated app.
146
253
 
147
- # Use Pundit or similar
254
+ ### Pundit or similar
255
+
256
+ ```ruby
148
257
  config.authorize_with = -> { authorize(:job_harbor, :manage?) }
149
258
  ```
150
259
 
260
+ ### When access is denied
261
+
262
+ If the rule redirects or halts on its own (Devise's `authenticate_user!` throws to
263
+ Warden, whose failure app redirects), that response is used as-is. Pundit's `authorize` raises `Pundit::NotAuthorizedError` instead
264
+ of returning false, so handle it with a `rescue_from` in your `ApplicationController`.
265
+ Without one it surfaces as a 500.
266
+
267
+ When the rule returns false, browser requests are redirected to your app's root path with an "Admin access required."
268
+ alert. If your app has no root route, or the request is not HTML, the response is
269
+ `403 Forbidden`.
270
+
151
271
  ## Requirements
152
272
 
153
- - Ruby >= 3.2
154
- - Rails >= 7.1
155
- - Solid Queue >= 0.3
156
- - ViewComponent >= 3.0
273
+ - Ruby >= 3.3
274
+ - Rails >= 8.1
275
+ - Solid Queue >= 1.0, < 2
276
+ - ViewComponent >= 4.1
157
277
 
158
278
  ## License
159
279
 
@@ -1,2 +1,2 @@
1
1
  /*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-sky-500:oklch(68.5% .169 237.323);--spacing:.25rem;--breakpoint-2xl:96rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{--background:0 0% 98%;--foreground:240 10% 3.9%;--card:0 0% 100%;--card-foreground:240 10% 3.9%;--primary:240 5.9% 10%;--primary-foreground:0 0% 98%;--secondary:240 4.8% 95.9%;--secondary-foreground:240 5.9% 10%;--muted:240 4.8% 95.9%;--muted-foreground:240 3.8% 46.1%;--accent:240 4.8% 93.8%;--accent-foreground:240 5.9% 10%;--destructive:0 84.2% 60.2%;--destructive-foreground:0 0% 98%;--border:240 5.9% 90%;--input:240 5.9% 90%;--ring:240 5.9% 10%;--radius:.65rem;--chart-1:12 76% 61%;--chart-2:173 58% 39%;--chart-3:197 37% 24%;--chart-4:43 74% 66%;--chart-5:27 87% 67%}.dark{--background:240 10% 5.4%;--foreground:0 0% 92%;--card:240 10% 3.9%;--card-foreground:0 0% 92%;--primary:0 0% 95%;--primary-foreground:240 5.9% 10%;--secondary:240 3.7% 15.9%;--secondary-foreground:0 0% 92%;--muted:240 3.7% 15.9%;--muted-foreground:240 5% 64.9%;--accent:240 3.7% 15.9%;--accent-foreground:0 0% 92%;--destructive:0 62.8% 30.6%;--destructive-foreground:0 0% 92%;--border:240 3.7% 15.9%;--input:240 3.7% 15.9%;--ring:240 4.9% 83.9%;--chart-1:220 70% 50%;--chart-2:160 60% 45%;--chart-3:30 80% 55%;--chart-4:280 65% 60%;--chart-5:340 75% 55%}*,:before,:after{box-sizing:border-box}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji}}@layer components{.navbar{border-bottom:1px solid hsl(var(--border));background-color:hsl(var(--card));align-items:center;gap:1rem;height:3.5rem;padding:0 1.5rem;display:flex}.navbar-section{align-items:center;gap:.375rem;display:flex}.navbar-item{color:hsl(var(--muted-foreground));border-radius:calc(var(--radius) - 2px);white-space:nowrap;align-items:center;gap:.375rem;padding:.375rem .75rem;font-size:.875rem;font-weight:500;text-decoration:none;transition:color .15s,background-color .15s;display:inline-flex}.navbar-item:hover{color:hsl(var(--foreground));background-color:hsl(var(--accent))}.navbar-item-current{color:hsl(var(--primary-foreground));background-color:hsl(var(--primary))}.navbar-item-current:hover{color:hsl(var(--primary-foreground));background-color:hsl(var(--primary));opacity:.9}.navbar-badge{border-radius:calc(var(--radius) - 4px);background-color:hsl(var(--muted));color:hsl(var(--muted-foreground));padding:.125rem .375rem;font-size:.75rem;font-weight:600}.navbar-item-current .navbar-badge{background-color:hsl(var(--primary-foreground)/.2);color:hsl(var(--primary-foreground))}.card{border-radius:var(--radius);border:1px solid hsl(var(--border));background-color:hsl(var(--card));color:hsl(var(--card-foreground));box-shadow:0 1px 2px #0000000d}.card-header{flex-direction:column;gap:.375rem;padding:1.5rem;display:flex}.card-title{letter-spacing:-.025em;margin:0;font-size:1rem;font-weight:600;line-height:1}.card-description{color:hsl(var(--muted-foreground));font-size:.875rem}.card-content{padding:1.5rem;padding-top:0}.card-footer{align-items:center;padding:0 1.5rem 1.5rem;display:flex}.badge{border-radius:calc(var(--radius) - 2px);white-space:nowrap;align-items:center;gap:.375rem;padding:.125rem .625rem;font-size:.75rem;font-weight:500;transition:color .15s,background-color .15s;display:inline-flex}.badge-primary{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground))}.badge-secondary{background-color:hsl(var(--secondary));color:hsl(var(--secondary-foreground))}.badge-destructive{background-color:hsl(var(--destructive));color:hsl(var(--destructive-foreground))}.badge-outline{border:1px solid hsl(var(--border));color:hsl(var(--foreground));background-color:#0000}.badge-red{color:#ef4444;background-color:#ef44441a}.dark .badge-red{color:#fca5a5;background-color:#ef444426}.badge-amber{color:#d97706;background-color:#fbbf241a}.dark .badge-amber{color:#fcd34d;background-color:#fbbf2426}.badge-yellow{color:#a16207;background-color:#facc151a}.dark .badge-yellow{color:#fde047;background-color:#facc1526}.badge-green{color:#16a34a;background-color:#22c55e1a}.dark .badge-green{color:#86efac;background-color:#22c55e26}.badge-sky{color:#0284c7;background-color:#0ea5e91a}.dark .badge-sky{color:#7dd3fc;background-color:#0ea5e926}.badge-zinc{color:#52525b;background-color:#52525b1a}.dark .badge-zinc{color:#a1a1aa;background-color:#52525b33}.circle{border-radius:9999px;flex-shrink:0;width:.5rem;height:.5rem;display:inline-block}.circle-red{background-color:#ef4444}.circle-amber{background-color:#fbbf24}.circle-green{background-color:#22c55e}.circle-yellow{background-color:#facc15}.circle-sky{background-color:#0ea5e9}.circle-zinc{background-color:#52525b}.btn{white-space:nowrap;border-radius:calc(var(--radius) - 2px);cursor:pointer;border:none;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;font-weight:500;line-height:1;text-decoration:none;transition:color .15s,background-color .15s,opacity .15s;display:inline-flex}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-default{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));height:2.5rem;padding:.5rem 1rem}.btn-default:hover{opacity:.9}.btn-secondary{background-color:hsl(var(--secondary));color:hsl(var(--secondary-foreground));border:1px solid hsl(var(--border));height:2.5rem;padding:.5rem 1rem}.btn-secondary:hover{background-color:hsl(var(--accent))}.btn-destructive{background-color:hsl(var(--destructive));color:hsl(var(--destructive-foreground));height:2.5rem;padding:.5rem 1rem}.btn-destructive:hover{opacity:.9}.btn-outline{border:1px solid hsl(var(--border));color:hsl(var(--foreground));background-color:#0000;height:2.5rem;padding:.5rem 1rem}.btn-outline:hover{background-color:hsl(var(--accent))}.btn-ghost{color:hsl(var(--foreground));background-color:#0000;height:2.5rem;padding:.5rem 1rem}.btn-ghost:hover{background-color:hsl(var(--accent))}.btn-icon{width:2.5rem;height:2.5rem;color:hsl(var(--foreground));border:1px solid hsl(var(--border));background-color:#0000;padding:0}.btn-icon:hover{background-color:hsl(var(--accent))}.btn-sm{height:1.75rem;padding:.25rem .5rem;font-size:.75rem}.btn-xs{height:1.5rem;padding:.125rem .5rem;font-size:.75rem}.select{border-radius:calc(var(--radius) - 2px);border:1px solid hsl(var(--input));background-color:hsl(var(--card));width:12rem;height:2.25rem;color:hsl(var(--foreground));cursor:pointer;align-items:center;padding:.375rem .75rem;font-size:.875rem;display:inline-flex}.select:focus{outline:2px solid hsl(var(--ring));outline-offset:2px}.select option{background-color:hsl(var(--card));color:hsl(var(--foreground))}.table-wrapper{overflow-x:auto}table.sqd-table{border-collapse:collapse;caption-side:bottom;width:100%;font-size:.875rem}table.sqd-table thead tr{border-bottom:1px solid hsl(var(--border))}table.sqd-table th{text-align:left;vertical-align:middle;text-transform:uppercase;letter-spacing:.05em;height:2.5rem;color:hsl(var(--muted-foreground));white-space:nowrap;padding:0 1rem;font-size:.75rem;font-weight:500}table.sqd-table tbody tr{border-bottom:1px solid hsl(var(--border));transition:background-color .15s}table.sqd-table tbody tr:hover{background-color:hsl(var(--muted)/.5)}table.sqd-table td{vertical-align:middle;padding:.75rem 1rem}.info-line{align-items:center;gap:1rem;padding:.5rem 0;display:flex}.info-line-label{color:hsl(var(--muted-foreground));white-space:nowrap;flex-shrink:0;font-size:.875rem}.info-line-separator{background-color:hsl(var(--border));flex:1;height:1px}.info-line-value{color:hsl(var(--foreground));text-align:right;white-space:nowrap;font-size:.875rem;font-weight:500}.alert{border-radius:var(--radius);border:1px solid;align-items:center;gap:.75rem;padding:1rem;font-size:.875rem;display:flex;position:relative}.alert-success{color:#16a34a;background-color:#22c55e0d;border-color:#22c55e4d}.dark .alert-success{color:#86efac;background-color:#22c55e1a}.alert-error{color:#dc2626;background-color:#ef44440d;border-color:#ef44444d}.dark .alert-error{color:#fca5a5;background-color:#ef44441a}.alert-warning{color:#d97706;background-color:#fbbf240d;border-color:#fbbf244d}.dark .alert-warning{color:#fcd34d;background-color:#fbbf241a}.alert-info{color:#0284c7;background-color:#0ea5e90d;border-color:#0ea5e94d}.dark .alert-info{color:#7dd3fc;background-color:#0ea5e91a}.pagination-nav{justify-content:center;align-items:center;gap:.25rem;display:flex}.pagination-link{min-width:2rem;height:2rem;color:hsl(var(--muted-foreground));border-radius:calc(var(--radius) - 2px);justify-content:center;align-items:center;padding:0 .5rem;font-size:.875rem;font-weight:500;text-decoration:none;transition:background-color .15s;display:inline-flex}.pagination-link:hover{background-color:hsl(var(--accent));color:hsl(var(--foreground))}.pagination-active{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));border:1px solid hsl(var(--primary))}.pagination-disabled{opacity:.5;cursor:not-allowed}.link{color:hsl(var(--foreground));text-underline-offset:4px;text-decoration:underline}.link:hover{opacity:.75}.sqd-chart{width:100%;height:200px;position:relative}.sqd-chart canvas{width:100%!important;height:100%!important}.code-block{background-color:hsl(var(--muted));border-radius:calc(var(--radius) - 2px);white-space:pre-wrap;word-break:break-word;padding:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.8125rem;overflow-x:auto}.stat-card{border-radius:var(--radius);border:1px solid hsl(var(--border));background-color:hsl(var(--card));flex-direction:column;gap:.25rem;padding:1.5rem;text-decoration:none;transition:border-color .15s;display:flex}a.stat-card:hover{border-color:hsl(var(--foreground)/.2)}.stat-card-label{color:hsl(var(--muted-foreground));align-items:center;gap:.5rem;font-size:.875rem;font-weight:500;display:flex}.stat-card-value{color:hsl(var(--foreground));font-size:2.25rem;font-weight:700;line-height:1}.sqd-rate-low{color:#16a34a;background-color:#22c55e1a}.dark .sqd-rate-low{color:#86efac;background-color:#22c55e26}.sqd-rate-medium{color:#d97706;background-color:#fbbf241a}.dark .sqd-rate-medium{color:#fcd34d;background-color:#fbbf2426}.sqd-rate-high{color:#dc2626;background-color:#ef44441a}.dark .sqd-rate-high{color:#fca5a5;background-color:#ef444426}.sqd-retry-badge{border-radius:calc(var(--radius) - 4px);color:#d97706;background-color:#fbbf2426;padding:.125rem .375rem;font-size:.6875rem;font-weight:600}.dark .sqd-retry-badge{color:#fcd34d}html:not(.dark) .sqd-theme-icon-sun{display:none}html:not(.dark) .sqd-theme-icon-moon,html.dark .sqd-theme-icon-sun{display:block}html.dark .sqd-theme-icon-moon{display:none}}@layer utilities{.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-6{height:calc(var(--spacing)*6)}.h-12{height:calc(var(--spacing)*12)}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-6{width:calc(var(--spacing)*6)}.w-12{width:calc(var(--spacing)*12)}.w-full{width:100%}.max-w-screen-2xl{max-width:var(--breakpoint-2xl)}.flex-shrink-0{flex-shrink:0}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.border{border-style:var(--tw-border-style);border-width:1px}.px-6{padding-inline:calc(var(--spacing)*6)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.pl-1{padding-left:calc(var(--spacing)*1)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-sky-500{color:var(--color-sky-500)}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.opacity-75{opacity:.75}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@media (min-width:40rem){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(63.7% .237 25.331);--color-sky-500:oklch(68.5% .169 237.323);--spacing:.25rem;--breakpoint-2xl:96rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{--background:0 0% 98%;--foreground:240 10% 3.9%;--card:0 0% 100%;--card-foreground:240 10% 3.9%;--primary:240 5.9% 10%;--primary-foreground:0 0% 98%;--secondary:240 4.8% 95.9%;--secondary-foreground:240 5.9% 10%;--muted:240 4.8% 95.9%;--muted-foreground:240 3.8% 46.1%;--accent:240 4.8% 93.8%;--accent-foreground:240 5.9% 10%;--destructive:0 84.2% 60.2%;--destructive-foreground:0 0% 98%;--border:240 5.9% 90%;--input:240 5.9% 90%;--ring:240 5.9% 10%;--radius:.65rem;--chart-1:12 76% 61%;--chart-2:173 58% 39%;--chart-3:197 37% 24%;--chart-4:43 74% 66%;--chart-5:27 87% 67%}.dark{--background:240 10% 5.4%;--foreground:0 0% 92%;--card:240 10% 3.9%;--card-foreground:0 0% 92%;--primary:0 0% 95%;--primary-foreground:240 5.9% 10%;--secondary:240 3.7% 15.9%;--secondary-foreground:0 0% 92%;--muted:240 3.7% 15.9%;--muted-foreground:240 5% 64.9%;--accent:240 3.7% 15.9%;--accent-foreground:0 0% 92%;--destructive:0 62.8% 30.6%;--destructive-foreground:0 0% 92%;--border:240 3.7% 15.9%;--input:240 3.7% 15.9%;--ring:240 4.9% 83.9%;--chart-1:220 70% 50%;--chart-2:160 60% 45%;--chart-3:30 80% 55%;--chart-4:280 65% 60%;--chart-5:340 75% 55%}*,:before,:after{box-sizing:border-box}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji}}@layer components{.navbar{border-bottom:1px solid hsl(var(--border));background-color:hsl(var(--card));align-items:center;gap:1rem;height:3.5rem;padding:0 1.5rem;display:flex}.navbar-section{align-items:center;gap:.375rem;display:flex}@media not all and (min-width:40rem){.navbar{flex-wrap:wrap;gap:.5rem;height:auto;min-height:3.5rem;padding:.5rem 1rem}.navbar-section{flex-wrap:wrap;flex-basis:100%;order:3}}.navbar-item{color:hsl(var(--muted-foreground));border-radius:calc(var(--radius) - 2px);white-space:nowrap;align-items:center;gap:.375rem;padding:.375rem .75rem;font-size:.875rem;font-weight:500;text-decoration:none;transition:color .15s,background-color .15s;display:inline-flex}.navbar-item:hover{color:hsl(var(--foreground));background-color:hsl(var(--accent))}.navbar-item-current{color:hsl(var(--primary-foreground));background-color:hsl(var(--primary))}.navbar-item-current:hover{color:hsl(var(--primary-foreground));background-color:hsl(var(--primary));opacity:.9}.navbar-badge{border-radius:calc(var(--radius) - 4px);background-color:hsl(var(--muted));color:hsl(var(--muted-foreground));padding:.125rem .375rem;font-size:.75rem;font-weight:600}.navbar-item-current .navbar-badge{background-color:hsl(var(--primary-foreground)/.2);color:hsl(var(--primary-foreground))}.card{border-radius:var(--radius);border:1px solid hsl(var(--border));background-color:hsl(var(--card));color:hsl(var(--card-foreground));box-shadow:0 1px 2px #0000000d}.card-header{flex-direction:column;gap:.375rem;padding:1.5rem;display:flex}.card-title{letter-spacing:-.025em;margin:0;font-size:1rem;font-weight:600;line-height:1}.card-description{color:hsl(var(--muted-foreground));font-size:.875rem}.card-content{padding:1.5rem;padding-top:0}.card-footer{align-items:center;padding:0 1.5rem 1.5rem;display:flex}.badge{border-radius:calc(var(--radius) - 2px);white-space:nowrap;align-items:center;gap:.375rem;padding:.125rem .625rem;font-size:.75rem;font-weight:500;transition:color .15s,background-color .15s;display:inline-flex}.badge-primary{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground))}.badge-secondary{background-color:hsl(var(--secondary));color:hsl(var(--secondary-foreground))}.badge-destructive{background-color:hsl(var(--destructive));color:hsl(var(--destructive-foreground))}.badge-outline{border:1px solid hsl(var(--border));color:hsl(var(--foreground));background-color:#0000}.badge-red{color:#ef4444;background-color:#ef44441a}.dark .badge-red{color:#fca5a5;background-color:#ef444426}.badge-amber{color:#d97706;background-color:#fbbf241a}.dark .badge-amber{color:#fcd34d;background-color:#fbbf2426}.badge-yellow{color:#a16207;background-color:#facc151a}.dark .badge-yellow{color:#fde047;background-color:#facc1526}.badge-green{color:#16a34a;background-color:#22c55e1a}.dark .badge-green{color:#86efac;background-color:#22c55e26}.badge-sky{color:#0284c7;background-color:#0ea5e91a}.dark .badge-sky{color:#7dd3fc;background-color:#0ea5e926}.badge-zinc{color:#52525b;background-color:#52525b1a}.dark .badge-zinc{color:#a1a1aa;background-color:#52525b33}.circle{border-radius:9999px;flex-shrink:0;width:.5rem;height:.5rem;display:inline-block}.circle-red{background-color:#ef4444}.circle-amber{background-color:#fbbf24}.circle-green{background-color:#22c55e}.circle-yellow{background-color:#facc15}.circle-sky{background-color:#0ea5e9}.circle-zinc{background-color:#52525b}.btn{white-space:nowrap;border-radius:calc(var(--radius) - 2px);cursor:pointer;border:none;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;font-weight:500;line-height:1;text-decoration:none;transition:color .15s,background-color .15s,opacity .15s;display:inline-flex}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-default{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));height:2.5rem;padding:.5rem 1rem}.btn-default:hover{opacity:.9}.btn-secondary{background-color:hsl(var(--secondary));color:hsl(var(--secondary-foreground));border:1px solid hsl(var(--border));height:2.5rem;padding:.5rem 1rem}.btn-secondary:hover{background-color:hsl(var(--accent))}.btn-destructive{background-color:hsl(var(--destructive));color:hsl(var(--destructive-foreground));height:2.5rem;padding:.5rem 1rem}.btn-destructive:hover{opacity:.9}.btn-outline{border:1px solid hsl(var(--border));color:hsl(var(--foreground));background-color:#0000;height:2.5rem;padding:.5rem 1rem}.btn-outline:hover{background-color:hsl(var(--accent))}.btn-ghost{color:hsl(var(--foreground));background-color:#0000;height:2.5rem;padding:.5rem 1rem}.btn-ghost:hover{background-color:hsl(var(--accent))}.btn-icon{width:2.5rem;height:2.5rem;color:hsl(var(--foreground));border:1px solid hsl(var(--border));background-color:#0000;padding:0}.btn-icon:hover{background-color:hsl(var(--accent))}.btn-sm{height:1.75rem;padding:.25rem .5rem;font-size:.75rem}.btn-xs{height:1.5rem;padding:.125rem .5rem;font-size:.75rem}.select{border-radius:calc(var(--radius) - 2px);border:1px solid hsl(var(--input));background-color:hsl(var(--card));width:12rem;height:2.25rem;color:hsl(var(--foreground));cursor:pointer;align-items:center;padding:.375rem .75rem;font-size:.875rem;display:inline-flex}.select:focus{outline:2px solid hsl(var(--ring));outline-offset:2px}.select option{background-color:hsl(var(--card));color:hsl(var(--foreground))}.table-wrapper{overflow-x:auto}table.sqd-table{border-collapse:collapse;caption-side:bottom;width:100%;font-size:.875rem}table.sqd-table thead tr{border-bottom:1px solid hsl(var(--border))}table.sqd-table th{text-align:left;vertical-align:middle;text-transform:uppercase;letter-spacing:.05em;height:2.5rem;color:hsl(var(--muted-foreground));white-space:nowrap;padding:0 1rem;font-size:.75rem;font-weight:500}table.sqd-table tbody tr{border-bottom:1px solid hsl(var(--border));transition:background-color .15s}table.sqd-table tbody tr:hover{background-color:hsl(var(--muted)/.5)}table.sqd-table td{vertical-align:middle;padding:.75rem 1rem}.info-line{align-items:center;gap:1rem;padding:.5rem 0;display:flex}.info-line-label{color:hsl(var(--muted-foreground));white-space:nowrap;flex-shrink:0;font-size:.875rem}.info-line-separator{background-color:hsl(var(--border));flex:1;height:1px}.info-line-value{color:hsl(var(--foreground));text-align:right;white-space:nowrap;font-size:.875rem;font-weight:500}@media not all and (min-width:40rem){.info-line-value{white-space:normal;overflow-wrap:anywhere;min-width:0}}.alert{border-radius:var(--radius);border:1px solid;align-items:center;gap:.75rem;padding:1rem;font-size:.875rem;display:flex;position:relative}.alert-success{color:#16a34a;background-color:#22c55e0d;border-color:#22c55e4d}.dark .alert-success{color:#86efac;background-color:#22c55e1a}.alert-error{color:#dc2626;background-color:#ef44440d;border-color:#ef44444d}.dark .alert-error{color:#fca5a5;background-color:#ef44441a}.alert-warning{color:#d97706;background-color:#fbbf240d;border-color:#fbbf244d}.dark .alert-warning{color:#fcd34d;background-color:#fbbf241a}.alert-info{color:#0284c7;background-color:#0ea5e90d;border-color:#0ea5e94d}.dark .alert-info{color:#7dd3fc;background-color:#0ea5e91a}.pagination-nav{justify-content:center;align-items:center;gap:.25rem;display:flex}.pagination-link{min-width:2rem;height:2rem;color:hsl(var(--muted-foreground));border-radius:calc(var(--radius) - 2px);justify-content:center;align-items:center;padding:0 .5rem;font-size:.875rem;font-weight:500;text-decoration:none;transition:background-color .15s;display:inline-flex}.pagination-link:hover{background-color:hsl(var(--accent));color:hsl(var(--foreground))}.pagination-active{background-color:hsl(var(--primary));color:hsl(var(--primary-foreground));border:1px solid hsl(var(--primary))}.pagination-disabled{opacity:.5;cursor:not-allowed}.link{color:hsl(var(--foreground));text-underline-offset:4px;text-decoration:underline}.link:hover{opacity:.75}.sqd-chart{width:100%;height:200px;position:relative}.sqd-chart canvas{width:100%!important;height:100%!important}.code-block{background-color:hsl(var(--muted));border-radius:calc(var(--radius) - 2px);white-space:pre-wrap;word-break:break-word;padding:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.8125rem;overflow-x:auto}.stat-card{border-radius:var(--radius);border:1px solid hsl(var(--border));background-color:hsl(var(--card));flex-direction:column;gap:.25rem;padding:1.5rem;text-decoration:none;transition:border-color .15s;display:flex}a.stat-card:hover{border-color:hsl(var(--foreground)/.2)}.stat-card-label{color:hsl(var(--muted-foreground));align-items:center;gap:.5rem;font-size:.875rem;font-weight:500;display:flex}.stat-card-value{color:hsl(var(--foreground));font-size:2.25rem;font-weight:700;line-height:1}.sqd-rate-low{color:#16a34a;background-color:#22c55e1a}.dark .sqd-rate-low{color:#86efac;background-color:#22c55e26}.sqd-rate-medium{color:#d97706;background-color:#fbbf241a}.dark .sqd-rate-medium{color:#fcd34d;background-color:#fbbf2426}.sqd-rate-high{color:#dc2626;background-color:#ef44441a}.dark .sqd-rate-high{color:#fca5a5;background-color:#ef444426}.sqd-retry-badge{border-radius:calc(var(--radius) - 4px);color:#d97706;background-color:#fbbf2426;padding:.125rem .375rem;font-size:.6875rem;font-weight:600}.dark .sqd-retry-badge{color:#fcd34d}html:not(.dark) .sqd-theme-icon-sun{display:none}html:not(.dark) .sqd-theme-icon-moon,html.dark .sqd-theme-icon-sun{display:block}html.dark .sqd-theme-icon-moon{display:none}}@layer utilities{.collapse{visibility:collapse}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-6{height:calc(var(--spacing)*6)}.h-12{height:calc(var(--spacing)*12)}.max-h-100{max-height:calc(var(--spacing)*100)}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-6{width:calc(var(--spacing)*6)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-full{width:100%}.max-w-screen-2xl{max-width:var(--breakpoint-2xl)}.flex-shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t-4{border-top-style:var(--tw-border-style);border-top-width:4px}.border-t-red-500{border-top-color:var(--color-red-500)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.pl-1{padding-left:calc(var(--spacing)*1)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-red-500{color:var(--color-red-500)}.text-sky-500{color:var(--color-sky-500)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.opacity-75{opacity:.75}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@media (min-width:40rem){.sm\:ml-4{margin-left:calc(var(--spacing)*4)}.sm\:inline{display:inline}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing)*6)}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
@@ -77,6 +77,11 @@
77
77
  background-color: hsl(var(--card));
78
78
  }
79
79
  .navbar-section { display: flex; align-items: center; gap: 0.375rem; }
80
+ /* Small screens: the links drop to their own full-width row and wrap if they must */
81
+ @media (width < 40rem) {
82
+ .navbar { flex-wrap: wrap; height: auto; min-height: 3.5rem; padding: 0.5rem 1rem; gap: 0.5rem; }
83
+ .navbar-section { order: 3; flex-basis: 100%; flex-wrap: wrap; }
84
+ }
80
85
  .navbar-item {
81
86
  display: inline-flex;
82
87
  align-items: center;
@@ -311,6 +316,9 @@
311
316
  text-align: right;
312
317
  white-space: nowrap;
313
318
  }
319
+ @media (width < 40rem) {
320
+ .info-line-value { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
321
+ }
314
322
 
315
323
  /* Alerts / Flash */
316
324
  .alert {
@@ -2,7 +2,7 @@
2
2
 
3
3
  module JobHarbor
4
4
  class BadgeComponent < ApplicationComponent
5
- VALID_STATUSES = %w[pending scheduled in_progress failed finished blocked active paused].freeze
5
+ VALID_STATUSES = %w[pending scheduled in_progress failed finished blocked active paused stale].freeze
6
6
 
7
7
  STATUS_COLORS = {
8
8
  "pending" => "badge-sky",
@@ -12,7 +12,8 @@ module JobHarbor
12
12
  "finished" => "badge-green",
13
13
  "blocked" => "badge-zinc",
14
14
  "active" => "badge-green",
15
- "paused" => "badge-amber"
15
+ "paused" => "badge-amber",
16
+ "stale" => "badge-zinc"
16
17
  }.freeze
17
18
 
18
19
  STATUS_CIRCLES = {
@@ -23,7 +24,8 @@ module JobHarbor
23
24
  "finished" => "circle-green",
24
25
  "blocked" => "circle-zinc",
25
26
  "active" => "circle-green",
26
- "paused" => "circle-amber"
27
+ "paused" => "circle-amber",
28
+ "stale" => "circle-zinc"
27
29
  }.freeze
28
30
 
29
31
  def initialize(status:)
@@ -20,7 +20,7 @@ module JobHarbor
20
20
 
21
21
  def header
22
22
  content_tag(:div, class: "card-header") do
23
- content_tag(:div, class: "flex items-center justify-between") do
23
+ content_tag(:div, class: "flex flex-wrap items-center justify-between gap-2") do
24
24
  safe_join([
25
25
  content_tag(:h3, "Job Activity", class: "card-title"),
26
26
  range_selector
@@ -39,9 +39,7 @@ module JobHarbor
39
39
  active = range[:value] == @current_range
40
40
  css_class = active ? "btn btn-default btn-xs" : "btn btn-secondary btn-xs"
41
41
 
42
- link_to range[:label],
43
- "#{root_path}?chart_range=#{range[:value]}",
44
- class: css_class
42
+ link_to range[:label], root_path(chart_range: range[:value]), class: css_class
45
43
  end
46
44
 
47
45
  def body
@@ -2,6 +2,9 @@
2
2
 
3
3
  module JobHarbor
4
4
  class FailureRatesComponent < ApplicationComponent
5
+ LOW_THRESHOLD = 5
6
+ MEDIUM_THRESHOLD = 20
7
+
5
8
  def initialize(stats:)
6
9
  @stats = stats
7
10
  end
@@ -38,11 +41,13 @@ module JobHarbor
38
41
  end
39
42
 
40
43
  def stats_table
41
- content_tag(:table, class: "sqd-table sqd-failure-table") do
42
- safe_join([
43
- table_header,
44
- table_body
45
- ])
44
+ content_tag(:div, class: "table-wrapper") do
45
+ content_tag(:table, class: "sqd-table sqd-failure-table") do
46
+ safe_join([
47
+ table_header,
48
+ table_body
49
+ ])
50
+ end
46
51
  end
47
52
  end
48
53
 
@@ -77,8 +82,15 @@ module JobHarbor
77
82
  end
78
83
 
79
84
  def rate_badge(rate)
80
- css_class = FailureStats.rate_badge_class(rate)
81
- content_tag(:span, "#{rate}%", class: "badge #{css_class}")
85
+ content_tag(:span, "#{rate}%", class: "badge #{rate_badge_class(rate)}")
86
+ end
87
+
88
+ def rate_badge_class(rate)
89
+ case rate
90
+ when 0...LOW_THRESHOLD then "sqd-rate-low"
91
+ when LOW_THRESHOLD...MEDIUM_THRESHOLD then "sqd-rate-medium"
92
+ else "sqd-rate-high"
93
+ end
82
94
  end
83
95
  end
84
96
  end
@@ -3,8 +3,8 @@
3
3
  module JobHarbor
4
4
  class JobFiltersComponent < ApplicationComponent
5
5
  def initialize(class_names:, queue_names:, current_class:, current_queue:, current_path:, params: {})
6
- @class_names = class_names
7
- @queue_names = queue_names
6
+ @class_names = with_selected(class_names, current_class)
7
+ @queue_names = with_selected(queue_names, current_queue)
8
8
  @current_class = current_class
9
9
  @current_queue = current_queue
10
10
  @current_path = current_path
@@ -22,6 +22,11 @@ module JobHarbor
22
22
 
23
23
  private
24
24
 
25
+ # The active filter stays selectable even after its last job is gone.
26
+ def with_selected(names, current)
27
+ current.present? ? (names | [ current ]).sort : names
28
+ end
29
+
25
30
  def class_filter
26
31
  content_tag(:div, class: "flex items-center gap-1.5") do
27
32
  safe_join([
@@ -2,8 +2,9 @@
2
2
 
3
3
  module JobHarbor
4
4
  class JobRowComponent < ApplicationComponent
5
- def initialize(job:)
5
+ def initialize(job:, return_to: nil)
6
6
  @job = job
7
+ @return_to = return_to
7
8
  end
8
9
 
9
10
  def call
@@ -92,17 +93,21 @@ module JobHarbor
92
93
  end
93
94
  end
94
95
 
96
+ def return_to
97
+ @return_to || request.fullpath
98
+ end
99
+
95
100
  def retry_button
96
101
  return unless @job.can_retry?
97
102
 
98
- button_to "Retry", retry_job_path(@job), method: :post, class: "btn btn-secondary btn-xs"
103
+ button_to "Retry", retry_job_path(@job), method: :post, params: { return_to: return_to }, class: "btn btn-secondary btn-xs"
99
104
  end
100
105
 
101
106
  def discard_button
102
107
  return unless @job.can_discard?
103
108
 
104
- button_to "Discard", discard_job_path(@job), method: :delete, class: "btn btn-destructive btn-xs",
105
- data: { confirm: "Are you sure you want to discard this job?" }
109
+ button_to "Discard", discard_job_path(@job), method: :delete, params: { return_to: return_to }, class: "btn btn-destructive btn-xs",
110
+ form: { data: { confirm: "Are you sure you want to discard this job?" } }
106
111
  end
107
112
  end
108
113
  end
@@ -15,9 +15,7 @@ module JobHarbor
15
15
  safe_join([
16
16
  content_tag(:span, "Show", class: "text-sm text-muted-foreground"),
17
17
  content_tag(:select,
18
- class: "sqd-per-page-select select",
19
- style: "width: 5rem;",
20
- data: { action: "change->per-page#change" }
18
+ class: "sqd-per-page-select select w-20"
21
19
  ) do
22
20
  safe_join(PAGE_SIZES.map { |size| option_tag(size) })
23
21
  end
@@ -30,11 +30,12 @@ module JobHarbor
30
30
  private
31
31
 
32
32
  def stats
33
- content_tag(:div, class: "grid grid-cols-3 gap-4 mb-4") do
33
+ content_tag(:div, class: "grid grid-cols-4 gap-4 mb-4") do
34
34
  safe_join([
35
35
  stat("Pending", @queue.pending_count),
36
36
  stat("Scheduled", @queue.scheduled_count),
37
- stat("In Progress", @queue.in_progress_count)
37
+ stat("In Progress", @queue.in_progress_count),
38
+ stat("Blocked", @queue.blocked_count)
38
39
  ])
39
40
  end
40
41
  end
@@ -41,15 +41,20 @@ module JobHarbor
41
41
 
42
42
  def interval_select
43
43
  content_tag(:select,
44
- class: "sqd-refresh-select select",
45
- style: "width: 5rem;",
46
- data: { action: "change->refresh-selector#change" },
44
+ class: "sqd-refresh-select select w-20",
47
45
  "aria-label": "Auto-refresh interval"
48
46
  ) do
49
- safe_join(INTERVALS.map { |interval| option_tag(interval) })
47
+ safe_join(intervals.map { |interval| option_tag(interval) })
50
48
  end
51
49
  end
52
50
 
51
+ # A configured poll_interval outside the standard choices gets its own option, so the select never shows a blank.
52
+ def intervals
53
+ return INTERVALS if INTERVALS.any? { |interval| interval[:value] == @default_interval }
54
+
55
+ (INTERVALS + [ { label: "#{@default_interval}s", value: @default_interval } ]).sort_by { |interval| interval[:value] }
56
+ end
57
+
53
58
  def option_tag(interval)
54
59
  selected = interval[:value] == @default_interval
55
60
  content_tag(:option, interval[:label], value: interval[:value], selected: selected)
@@ -9,8 +9,7 @@ module JobHarbor
9
9
  content_tag(:button,
10
10
  class: "sqd-theme-toggle btn btn-icon",
11
11
  type: "button",
12
- aria: { label: "Toggle theme" },
13
- data: { action: "click->theme-toggle#toggle" }
12
+ aria: { label: "Toggle theme" }
14
13
  ) do
15
14
  safe_join([
16
15
  sun_icon,
@@ -2,8 +2,6 @@
2
2
 
3
3
  module JobHarbor
4
4
  class WorkerCardComponent < ApplicationComponent
5
- STALE_THRESHOLD = 5.minutes
6
-
7
5
  def initialize(worker:)
8
6
  @worker = worker
9
7
  end
@@ -14,8 +12,13 @@ module JobHarbor
14
12
  content_tag(:div, class: "card-header") do
15
13
  content_tag(:div, class: "flex items-center justify-between") do
16
14
  safe_join([
17
- content_tag(:span, worker_name, class: "card-title"),
18
- render(BadgeComponent.new(status: heartbeat_status))
15
+ content_tag(:div, class: "flex items-center gap-2") do
16
+ safe_join([
17
+ content_tag(:span, worker_name, class: "card-title"),
18
+ content_tag(:span, kind_label, class: "badge badge-secondary", data: { kind: @worker.kind })
19
+ ])
20
+ end,
21
+ content_tag(:span, render(BadgeComponent.new(status: heartbeat_status)), data: { heartbeat: true })
19
22
  ])
20
23
  end
21
24
  end,
@@ -35,14 +38,13 @@ module JobHarbor
35
38
  @worker.name.presence || "Worker ##{@worker.id}"
36
39
  end
37
40
 
38
- def heartbeat_status
39
- stale? ? :blocked : :active
41
+ # Solid Queue 1.7 reports supervisors as "Supervisor(fork)" or "Supervisor(async)".
42
+ def kind_label
43
+ @worker.kind.sub(/\(.*\)\z/, "")
40
44
  end
41
45
 
42
- def stale?
43
- return true unless @worker.last_heartbeat_at
44
-
45
- @worker.last_heartbeat_at < STALE_THRESHOLD.ago
46
+ def heartbeat_status
47
+ Processes.alive?(@worker) ? :active : :stale
46
48
  end
47
49
 
48
50
  def details
@@ -71,13 +73,18 @@ module JobHarbor
71
73
  time_ago_in_words(@worker.last_heartbeat_at) + " ago"
72
74
  end
73
75
 
76
+ # Workers record the queues they poll as a comma-joined string in metadata.
77
+ def queues
78
+ @queues ||= @worker.metadata["queues"].to_s.split(",").map(&:strip).reject(&:empty?)
79
+ end
80
+
74
81
  def queues_list
75
- return "".html_safe unless @worker.respond_to?(:queues) && @worker.queues.present?
82
+ return "".html_safe if queues.empty?
76
83
 
77
84
  content_tag(:div, class: "mt-3 flex items-center gap-1 flex-wrap") do
78
85
  safe_join([
79
86
  content_tag(:span, "Queues:", class: "text-xs text-muted-foreground"),
80
- @worker.queues.map { |q| content_tag(:code, q, class: "badge badge-secondary text-xs font-mono") }
87
+ queues.map { |q| content_tag(:code, q, class: "badge badge-secondary text-xs font-mono") }
81
88
  ].flatten)
82
89
  end
83
90
  end
@@ -13,9 +13,13 @@ module JobHarbor
13
13
  private
14
14
 
15
15
  def authorize_access
16
- return if JobHarbor.configuration.authorize(self)
16
+ return if JobHarbor.configuration.authorize(self) || performed?
17
17
 
18
- redirect_to main_app.root_path, alert: "Admin access required."
18
+ if request.format.html? && main_app.respond_to?(:root_path)
19
+ redirect_to main_app.root_path, alert: "Admin access required."
20
+ else
21
+ head :forbidden
22
+ end
19
23
  end
20
24
 
21
25
  def sq_config
@@ -28,7 +32,7 @@ module JobHarbor
28
32
 
29
33
  def nav_counts
30
34
  @nav_counts ||= {
31
- workers: SolidQueue::Process.where(kind: "Worker").count,
35
+ workers: Processes.live_workers.count,
32
36
  recurring_tasks: recurring_task_count
33
37
  }
34
38
  end