sidekiq_queue_manager 1.0.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.
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqQueueManager
4
+ # Core service class for managing ANY Sidekiq queues
5
+ #
6
+ # Automatically discovers and provides management for all Sidekiq queues
7
+ # in the application. Works with any queue names, job types, and configurations.
8
+ #
9
+ # Features:
10
+ # - Queue pause/resume operations for any queue
11
+ # - Real-time statistics and monitoring
12
+ # - Bulk operations across multiple queues
13
+ # - Configurable queue priorities and protection
14
+ # - Health monitoring and diagnostics
15
+ #
16
+ # All operations return structured responses with success/failure status
17
+ # and detailed error messages for proper error handling.
18
+ #
19
+ class QueueService
20
+ # Default queue priority for unknown queues (higher number = higher priority)
21
+ DEFAULT_QUEUE_PRIORITY = 1
22
+
23
+ class << self
24
+ # ========================================
25
+ # Queue Control Operations
26
+ # ========================================
27
+
28
+ # Pause a specific queue
29
+ # @param queue_name [String] the name of the queue to pause
30
+ # @return [Hash] response with success status and message
31
+ def pause_queue(queue_name)
32
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
33
+ return failure_response('Cannot pause critical queue') if critical_queue?(queue_name)
34
+
35
+ queue = Sidekiq::Queue[queue_name]
36
+ result = queue.pause
37
+
38
+ if operation_successful?(result)
39
+ update_queue_stats(queue_name, 'paused')
40
+ log_operation("Queue '#{queue_name}' paused - result: #{result}")
41
+ success_response("Queue '#{queue_name}' paused successfully")
42
+ else
43
+ log_error("Failed to pause queue '#{queue_name}': unexpected result '#{result}'")
44
+ failure_response("Failed to pause queue '#{queue_name}'")
45
+ end
46
+ rescue StandardError => e
47
+ handle_service_error(e, "pause queue '#{queue_name}'")
48
+ end
49
+
50
+ # Resume a specific queue
51
+ # @param queue_name [String] the name of the queue to resume
52
+ # @return [Hash] response with success status and message
53
+ def resume_queue(queue_name)
54
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
55
+
56
+ queue = Sidekiq::Queue[queue_name]
57
+ result = queue.unpause
58
+
59
+ if operation_successful?(result)
60
+ update_queue_stats(queue_name, 'active')
61
+ status_msg = result.zero? ? '(already active)' : ''
62
+ log_operation("Queue '#{queue_name}' resumed - result: #{result} #{status_msg}")
63
+ success_response("Queue '#{queue_name}' resumed successfully")
64
+ else
65
+ log_error("Failed to resume queue '#{queue_name}': unexpected result '#{result}'")
66
+ failure_response("Failed to resume queue '#{queue_name}'")
67
+ end
68
+ rescue StandardError => e
69
+ handle_service_error(e, "resume queue '#{queue_name}'")
70
+ end
71
+
72
+ # Pause all non-critical queues using Ruby's functional approach
73
+ # @return [Hash] response with count of paused queues
74
+ def pause_all_queues
75
+ results = available_queues.filter_map do |queue_name|
76
+ next :skipped if critical_queue?(queue_name)
77
+
78
+ pause_queue(queue_name)[:success] ? :paused : queue_name
79
+ end
80
+
81
+ paused_count = results.count(:paused)
82
+ skipped_count = results.count(:skipped)
83
+ failed_queues = results - %i[paused skipped]
84
+
85
+ message = build_bulk_operation_message('pause', paused_count, skipped_count, failed_queues)
86
+ log_operation(message)
87
+
88
+ success_response(message,
89
+ paused: paused_count,
90
+ skipped: skipped_count,
91
+ failed: failed_queues)
92
+ rescue StandardError => e
93
+ handle_service_error(e, 'bulk pause operation')
94
+ end
95
+
96
+ # Resume all queues using Ruby's functional approach
97
+ # @return [Hash] response with count of resumed queues
98
+ def resume_all_queues
99
+ results = available_queues.filter_map do |queue_name|
100
+ next :skipped if critical_queue?(queue_name)
101
+
102
+ resume_queue(queue_name)[:success] ? :resumed : queue_name
103
+ end
104
+
105
+ resumed_count = results.count(:resumed)
106
+ skipped_count = results.count(:skipped)
107
+ failed_queues = results - %i[resumed skipped]
108
+
109
+ message = build_bulk_operation_message('resume', resumed_count, skipped_count, failed_queues)
110
+ log_operation(message)
111
+
112
+ success_response(message,
113
+ resumed: resumed_count,
114
+ skipped: skipped_count,
115
+ failed: failed_queues)
116
+ rescue StandardError => e
117
+ handle_service_error(e, 'bulk resume operation')
118
+ end
119
+
120
+ # ========================================
121
+ # Statistics and Monitoring
122
+ # ========================================
123
+
124
+ # Get comprehensive queue metrics using Ruby's expressive data transformation
125
+ # @return [Hash] complete queue statistics and global metrics
126
+ def queue_metrics
127
+ sidekiq_stats = Sidekiq::Stats.new
128
+ queue_stats = available_queues.index_with { |name| build_queue_metrics(name) }
129
+
130
+ {
131
+ global_stats: extract_global_stats(sidekiq_stats),
132
+ queues: queue_stats,
133
+ timestamp: Time.current.iso8601,
134
+ cache_buster: Time.current.to_f
135
+ }
136
+ rescue StandardError => e
137
+ handle_service_error(e, 'get queue metrics')
138
+ end
139
+
140
+ # Get status information for a specific queue or all queues
141
+ # @param queue_name [String, nil] specific queue name or nil for all queues
142
+ # @return [Hash] queue status information
143
+ def queue_status(queue_name = nil)
144
+ return single_queue_status(queue_name) if queue_name
145
+
146
+ available_queues.map { |name| single_queue_status(name) }
147
+ rescue StandardError => e
148
+ handle_service_error(e, 'get queue status')
149
+ end
150
+
151
+ # ========================================
152
+ # Advanced Queue Operations
153
+ # ========================================
154
+
155
+ def view_queue_jobs(queue_name, page: 1, per_page: 10)
156
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
157
+
158
+ # Ruby idiom: use clamp for bounds checking
159
+ page = page.to_i.clamp(1, Float::INFINITY)
160
+ per_page = per_page.to_i.clamp(1, 100) # Max 100 per page for performance
161
+
162
+ queue = Sidekiq::Queue[queue_name]
163
+ total_jobs = queue.size
164
+
165
+ # Use Ruby's slice and map for elegant data transformation
166
+ offset = (page - 1) * per_page
167
+ jobs = queue.to_a.slice(offset, per_page)&.map&.with_index do |job, index|
168
+ format_job_data(job, offset + index + 1)
169
+ end || []
170
+
171
+ success_response('Queue jobs retrieved successfully',
172
+ queue_name: queue_name,
173
+ size: total_jobs,
174
+ latency: queue.latency.round(2),
175
+ jobs: jobs,
176
+ pagination: build_pagination_data(page, per_page, total_jobs))
177
+ rescue StandardError => e
178
+ handle_service_error(e, "get jobs for queue '#{queue_name}'")
179
+ end
180
+
181
+ def delete_job(queue_name, job_id)
182
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
183
+ return failure_response('Invalid job ID') if job_id.blank?
184
+
185
+ queue = Sidekiq::Queue[queue_name]
186
+ job = queue.find_job(job_id)
187
+
188
+ return failure_response('Job not found') unless job
189
+
190
+ job.delete
191
+ log_operation("Job #{job_id} deleted from queue '#{queue_name}'")
192
+ success_response("Job #{job_id} deleted successfully")
193
+ rescue StandardError => e
194
+ handle_service_error(e, "delete job #{job_id} from queue '#{queue_name}'")
195
+ end
196
+
197
+ # Enhanced queue management methods with Ruby idioms
198
+ %w[set_queue_limit remove_queue_limit set_process_limit remove_process_limit
199
+ block_queue unblock_queue].each do |method_name|
200
+ define_method method_name do |queue_name, *args|
201
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
202
+
203
+ queue = Sidekiq::Queue[queue_name]
204
+ operation = method_name.split('_')[0] # 'set', 'remove', 'block', 'unblock'
205
+ attribute = method_name.match(/_(queue_|process_)?(.+)$/)[2] # 'limit', 'process_limit'
206
+
207
+ perform_queue_operation(queue, queue_name, operation, attribute, *args)
208
+ rescue StandardError => e
209
+ handle_service_error(e, "#{method_name.humanize.downcase} for queue '#{queue_name}'")
210
+ end
211
+ end
212
+
213
+ def clear_queue(queue_name)
214
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
215
+ return failure_response('Cannot clear critical queue') if critical_queue?(queue_name)
216
+
217
+ queue = Sidekiq::Queue[queue_name]
218
+ jobs_cleared = queue.size
219
+ queue.clear
220
+
221
+ log_operation("Queue '#{queue_name}' cleared - #{jobs_cleared} jobs removed")
222
+ success_response("Queue '#{queue_name}' cleared successfully", jobs_cleared: jobs_cleared)
223
+ rescue StandardError => e
224
+ handle_service_error(e, "clear queue '#{queue_name}'")
225
+ end
226
+
227
+ private
228
+
229
+ # ========================================
230
+ # Queue Information Helpers
231
+ # ========================================
232
+
233
+ def available_queues
234
+ @available_queues ||= discover_queues
235
+ end
236
+
237
+ # Ruby's functional approach to queue discovery
238
+ def discover_queues
239
+ [
240
+ # Method 1: Sidekiq's built-in discovery
241
+ -> { Sidekiq::Queue.all.map(&:name) },
242
+ # Method 2: Redis queues set
243
+ -> { Sidekiq.redis { |conn| conn.smembers('queues') } },
244
+ # Method 3: Redis queue keys
245
+ -> { discover_redis_queue_keys }
246
+ ].flat_map(&:call).uniq.sort
247
+ end
248
+
249
+ def discover_redis_queue_keys
250
+ Sidekiq.redis do |conn|
251
+ conn.keys('queue:*').filter_map do |key|
252
+ key.delete_prefix('queue:').presence
253
+ end
254
+ end
255
+ end
256
+
257
+ # Ruby predicate methods for cleaner conditionals
258
+ def valid_queue?(queue_name) = queue_name.is_a?(String) && available_queues.include?(queue_name)
259
+ def critical_queue?(queue_name) = SidekiqQueueManager.critical_queue?(queue_name)
260
+ def queue_paused?(queue_name) = Sidekiq::Queue[queue_name].paused?
261
+ def queue_priority(queue_name) = SidekiqQueueManager.queue_priority(queue_name)
262
+
263
+ # ========================================
264
+ # Statistics Builders
265
+ # ========================================
266
+
267
+ def build_queue_metrics(queue_name)
268
+ queue = Sidekiq::Queue[queue_name]
269
+
270
+ {
271
+ name: queue_name,
272
+ size: queue.size,
273
+ latency: queue.latency.round(2),
274
+ paused: queue_paused?(queue_name),
275
+ critical: critical_queue?(queue_name),
276
+ priority: queue_priority(queue_name),
277
+ busy: busy_workers_for_queue(queue_name),
278
+ # sidekiq-limit_fetch integration with safe attribute access
279
+ limit: safe_queue_attribute(queue, :limit),
280
+ process_limit: safe_queue_attribute(queue, :process_limit),
281
+ blocked: safe_queue_attribute(queue, :blocking?, false)
282
+ }
283
+ end
284
+
285
+ def extract_global_stats(sidekiq_stats)
286
+ {
287
+ processed: sidekiq_stats.processed,
288
+ failed: sidekiq_stats.failed,
289
+ busy: total_busy_workers,
290
+ enqueued: total_enqueued_jobs,
291
+ processes: sidekiq_stats.processes_size,
292
+ workers: sidekiq_stats.workers_size,
293
+ retry_size: sidekiq_stats.retry_size,
294
+ dead_size: sidekiq_stats.dead_size,
295
+ scheduled_size: sidekiq_stats.scheduled_size
296
+ }
297
+ end
298
+
299
+ # ========================================
300
+ # Helper Methods
301
+ # ========================================
302
+
303
+ # Ruby idiom: intention-revealing method names
304
+ def operation_successful?(result) = ['OK', 1, 0].include?(result)
305
+ def positive_integer?(value) = value.is_a?(Integer) && value.positive?
306
+
307
+ def single_queue_status(queue_name)
308
+ return failure_response('Invalid queue name') unless valid_queue?(queue_name)
309
+
310
+ queue = Sidekiq::Queue[queue_name]
311
+ {
312
+ name: queue_name,
313
+ size: queue.size,
314
+ latency: queue.latency.round(2),
315
+ paused: queue_paused?(queue_name),
316
+ critical: critical_queue?(queue_name),
317
+ priority: queue_priority(queue_name)
318
+ }
319
+ end
320
+
321
+ def format_job_data(job, position)
322
+ {
323
+ position: position,
324
+ jid: job.jid,
325
+ class: job.klass,
326
+ args: job.args,
327
+ created_at: job.created_at&.strftime('%Y-%m-%d %H:%M:%S'),
328
+ enqueued_at: job.enqueued_at&.strftime('%Y-%m-%d %H:%M:%S'),
329
+ retry_count: job['retry_count'] || 0,
330
+ queue: job.queue
331
+ }
332
+ end
333
+
334
+ def build_bulk_operation_message(operation, success_count, skipped_count, failed_queues)
335
+ message = "Bulk #{operation} completed. #{operation.capitalize}d: #{success_count}, Skipped: #{skipped_count}"
336
+ message += ", Failed: #{failed_queues.join(', ')}" if failed_queues.any?
337
+ message
338
+ end
339
+
340
+ def perform_queue_operation(queue, queue_name, operation, attribute, *args)
341
+ case operation
342
+ when 'set'
343
+ limit = args.first&.to_i
344
+ return failure_response('Invalid limit') unless positive_integer?(limit)
345
+
346
+ queue.public_send("#{attribute}=", limit)
347
+ log_operation("Queue '#{queue_name}' #{attribute} set to #{limit}")
348
+ success_response("Queue '#{queue_name}' #{attribute} set to #{limit}")
349
+ when 'remove'
350
+ queue.public_send("#{attribute}=", nil)
351
+ log_operation("Queue '#{queue_name}' #{attribute} removed")
352
+ success_response("Queue '#{queue_name}' #{attribute} removed")
353
+ when 'block'
354
+ queue.block
355
+ log_operation("Queue '#{queue_name}' blocked")
356
+ success_response("Queue '#{queue_name}' blocked successfully")
357
+ when 'unblock'
358
+ queue.unblock
359
+ log_operation("Queue '#{queue_name}' unblocked")
360
+ success_response("Queue '#{queue_name}' unblocked successfully")
361
+ end
362
+ end
363
+
364
+ # Safe attribute access with Ruby's method handling
365
+ def safe_queue_attribute(queue, attribute, default = nil)
366
+ queue.public_send(attribute)
367
+ rescue StandardError
368
+ default
369
+ end
370
+
371
+ def busy_workers_for_queue(queue_name)
372
+ Sidekiq::Workers.new.count { |_, _, work| work['queue'] == queue_name }
373
+ rescue StandardError => e
374
+ log_error("Failed to get busy workers for queue '#{queue_name}': #{e.message}")
375
+ 0
376
+ end
377
+
378
+ def total_busy_workers
379
+ Sidekiq::Workers.new.size
380
+ rescue StandardError => e
381
+ log_error("Failed to get total busy workers: #{e.message}")
382
+ 0
383
+ end
384
+
385
+ def total_enqueued_jobs
386
+ available_queues.sum { |queue_name| Sidekiq::Queue[queue_name].size }
387
+ rescue StandardError => e
388
+ log_error("Failed to get total enqueued jobs: #{e.message}")
389
+ 0
390
+ end
391
+
392
+ # ========================================
393
+ # Response Builders (Ruby's expressive approach)
394
+ # ========================================
395
+
396
+ def success_response(message, **data)
397
+ build_response(true, message, **data)
398
+ end
399
+
400
+ def failure_response(message, **data)
401
+ build_response(false, message, **data)
402
+ end
403
+
404
+ def build_response(success, message, **data)
405
+ {
406
+ success: success,
407
+ message: message,
408
+ timestamp: Time.current.iso8601
409
+ }.merge(data)
410
+ end
411
+
412
+ # ========================================
413
+ # Error Handling (Ruby's approach to exceptions)
414
+ # ========================================
415
+
416
+ def handle_service_error(error, operation)
417
+ log_error("Failed to #{operation}: #{error.message}")
418
+ failure_response("Failed to #{operation}: #{error.message}")
419
+ end
420
+
421
+ # ========================================
422
+ # Logging Helpers
423
+ # ========================================
424
+
425
+ def log_operation(message)
426
+ return unless SidekiqQueueManager.enable_logging?
427
+
428
+ Rails.logger.public_send(
429
+ SidekiqQueueManager.configuration.log_level,
430
+ "[SidekiqQueueManager] #{message}"
431
+ )
432
+ end
433
+
434
+ def log_error(message)
435
+ return unless SidekiqQueueManager.enable_logging?
436
+
437
+ Rails.logger.error("[SidekiqQueueManager] #{message}")
438
+ end
439
+
440
+ # ========================================
441
+ # Data Management
442
+ # ========================================
443
+
444
+ def update_queue_stats(queue_name, status)
445
+ return unless SidekiqQueueManager.enable_caching?
446
+
447
+ stats_key = "#{redis_key_prefix}:#{queue_name}"
448
+
449
+ Sidekiq.redis do |conn|
450
+ conn.hset(stats_key, 'status', status.to_s)
451
+ conn.hset(stats_key, 'updated_at', Time.current.to_i.to_s)
452
+ conn.expire(stats_key, SidekiqQueueManager.configuration.cache_ttl)
453
+ end
454
+ rescue StandardError => e
455
+ log_error("Failed to update queue stats for '#{queue_name}': #{e.message}")
456
+ end
457
+
458
+ def redis_key_prefix = SidekiqQueueManager.configuration.redis_key_prefix
459
+
460
+ def build_pagination_data(page, per_page, total_items)
461
+ total_pages = (total_items.to_f / per_page).ceil
462
+
463
+ {
464
+ current_page: page,
465
+ per_page: per_page,
466
+ total_pages: total_pages,
467
+ total_jobs: total_items,
468
+ has_previous: page > 1,
469
+ has_next: page < total_pages
470
+ }
471
+ end
472
+ end
473
+ end
474
+ end
475
+
@@ -0,0 +1,132 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="sidekiq-queue-manager">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+
8
+ <title>Sidekiq Queue Manager</title>
9
+ <meta name="description" content="Professional Sidekiq queue monitoring and management interface">
10
+
11
+ <!-- Security Headers -->
12
+ <meta name="robots" content="noindex, nofollow">
13
+
14
+ <!-- Color Scheme Support -->
15
+ <meta name="color-scheme" content="light dark">
16
+ <meta name="theme-color" content="#6366f1" media="(prefers-color-scheme: light)">
17
+ <meta name="theme-color" content="#4f46e5" media="(prefers-color-scheme: dark)">
18
+
19
+ <!-- Favicon -->
20
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%236366f1'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M3.75 6.75h16.5M3.75 12h16.5M12 17.25h8.25M3.75 17.25h.01v.01h-.01v-.01ZM7.5 17.25h.01v.01H7.5v-.01ZM11.25 17.25h.01v.01h-.01v-.01Z' /%3e%3c/svg%3e">
21
+
22
+ <!-- Asset Loading (Compatible with asset pipeline and direct serving) -->
23
+ <% asset_info = asset_serving_info %>
24
+
25
+ <!-- Preload Critical Resources -->
26
+ <link rel="preload" href="<%= asset_info[:css_path] %>" as="style">
27
+ <link rel="preload" href="<%= asset_info[:modals_css_path] %>" as="style">
28
+ <link rel="preload" href="<%= asset_info[:js_path] %>" as="script">
29
+
30
+ <!-- Stylesheets -->
31
+ <% if asset_info[:use_asset_pipeline] %>
32
+ <%= stylesheet_link_tag 'sidekiq_queue_manager/application', 'data-turbo-track': 'reload' %>
33
+ <%= stylesheet_link_tag 'sidekiq_queue_manager/modals', 'data-turbo-track': 'reload' %>
34
+ <% else %>
35
+ <link rel="stylesheet" href="<%= asset_info[:css_path] %>" data-turbo-track="reload">
36
+ <link rel="stylesheet" href="<%= asset_info[:modals_css_path] %>" data-turbo-track="reload">
37
+ <% end %>
38
+
39
+ <!-- Theme Initialization Script (Prevent FOUC) -->
40
+ <script>
41
+ (function() {
42
+ const storedTheme = localStorage.getItem('sqm-theme');
43
+ const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
44
+
45
+ if (storedTheme === 'dark' || (!storedTheme && systemDark)) {
46
+ document.documentElement.setAttribute('data-theme', 'dark');
47
+ } else if (storedTheme === 'light') {
48
+ document.documentElement.setAttribute('data-theme', 'light');
49
+ }
50
+ // For 'auto' or no preference, let CSS handle it
51
+ })();
52
+ </script>
53
+
54
+ <!-- Configuration for JavaScript -->
55
+ <script type="text/javascript">
56
+ window.SidekiqQueueManagerConfig = {
57
+ mountPath: '<%= sidekiq_dashboard.root_path %>',
58
+ refreshInterval: <%= SidekiqQueueManager.configuration.refresh_interval %>,
59
+ theme: '<%= SidekiqQueueManager.configuration.theme %>',
60
+ criticalQueues: <%= SidekiqQueueManager.configuration.critical_queues.to_json.html_safe %>,
61
+ enableLogging: <%= SidekiqQueueManager.configuration.enable_logging %>
62
+ };
63
+ </script>
64
+ </head>
65
+
66
+ <body class="sidekiq-queue-manager">
67
+ <!-- Skip Link for Accessibility -->
68
+ <a href="#main-content" class="skip-link sqm-btn sqm-btn-primary"
69
+ style="position: absolute; top: -3rem; left: 50%; transform: translateX(-50%); z-index: 10001; transition: top 0.2s ease-out;">
70
+ Skip to main content
71
+ </a>
72
+
73
+ <!-- Main Application Container -->
74
+ <div class="sqm-container">
75
+ <!-- Header -->
76
+ <header class="sqm-header" role="banner">
77
+ <div>
78
+ <h1 class="sqm-title">
79
+ 📊 Sidekiq Queue Manager
80
+ </h1>
81
+ <p class="sqm-subtitle">
82
+ Professional queue monitoring and management interface
83
+ </p>
84
+ </div>
85
+
86
+ <div class="sqm-header-info">
87
+ <div style="font-size: 0.6875rem; opacity: 0.8;">Version <%= SidekiqQueueManager::VERSION %></div>
88
+ <div id="sqm-last-updated" style="margin-top: 0.25rem;">
89
+ Last updated: <span id="sqm-timestamp">Loading...</span>
90
+ </div>
91
+ </div>
92
+ </header>
93
+
94
+ <!-- Main Content Area -->
95
+ <main id="main-content" role="main">
96
+ <%= yield %>
97
+ </main>
98
+
99
+ <!-- Footer -->
100
+ <footer class="sqm-footer"
101
+ style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--sqm-border); text-align: center; color: var(--sqm-muted-foreground); font-size: 0.75rem;">
102
+ <p>
103
+ Powered by
104
+ <a href="https://github.com/jamalawd/sidekiq_queue_manager"
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ style="color: var(--sqm-primary); text-decoration: none; font-weight: 500;">
108
+ Sidekiq Queue Manager
109
+ </a>
110
+ v<%= SidekiqQueueManager::VERSION %>
111
+ </p>
112
+ <p style="margin-top: 0.5rem; font-size: 0.6875rem; opacity: 0.8;">
113
+ Ruby <%= RUBY_VERSION %> • Rails <%= Rails.version %> • Sidekiq <%= Sidekiq::VERSION %>
114
+ </p>
115
+ </footer>
116
+ </div>
117
+
118
+ <!-- JavaScript -->
119
+ <% if asset_info[:use_asset_pipeline] %>
120
+ <%= javascript_include_tag 'sidekiq_queue_manager/application', 'data-turbo-track': 'reload' %>
121
+ <% else %>
122
+ <script src="<%= asset_info[:js_path] %>" data-turbo-track="reload"></script>
123
+ <% end %>
124
+
125
+ <!-- Skip Link Focus Styles -->
126
+ <style>
127
+ .skip-link:focus {
128
+ top: 1rem;
129
+ }
130
+ </style>
131
+ </body>
132
+ </html>