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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqQueueManager
4
+ # Base application controller for the SidekiqQueueManager engine
5
+ #
6
+ # Provides common functionality, security measures, and configuration
7
+ # for all controllers within the gem.
8
+ #
9
+ # Inherits from the main application's ApplicationController to ensure
10
+ # access to authentication methods and other application-specific functionality.
11
+ #
12
+ class ApplicationController < (defined?(::ApplicationController) ? ::ApplicationController : ActionController::Base)
13
+ # Engine-specific layout
14
+ layout 'sidekiq_queue_manager/application'
15
+
16
+ # Authentication and security using Ruby's declarative approach
17
+ before_action :authenticate_access
18
+ before_action :set_security_headers
19
+
20
+ protected
21
+
22
+ # Handle authentication - explicit configuration following professional standards
23
+ def authenticate_access
24
+ return custom_authentication if custom_authentication_configured?
25
+
26
+ basic_authentication if basic_authentication_enabled?
27
+
28
+ # If both disabled, access is allowed (explicit choice for development)
29
+ end
30
+
31
+ private
32
+
33
+ # Ruby's case-based authentication routing
34
+ def custom_authentication
35
+ return unless respond_to?(authentication_method, true)
36
+
37
+ send(authentication_method)
38
+ end
39
+
40
+ def basic_authentication
41
+ validate_basic_auth_configuration!
42
+ perform_basic_authentication
43
+ end
44
+
45
+ # Predicate methods for cleaner conditionals (Ruby convention)
46
+ def custom_authentication_configured?
47
+ SidekiqQueueManager.configuration.custom_authentication?
48
+ end
49
+
50
+ def basic_authentication_enabled?
51
+ SidekiqQueueManager.basic_auth_enabled?
52
+ end
53
+
54
+ def authentication_method
55
+ SidekiqQueueManager.configuration.authentication_method
56
+ end
57
+
58
+ # Configuration validation with descriptive error handling
59
+ def validate_basic_auth_configuration!
60
+ return if basic_auth_password.present?
61
+
62
+ render_authentication_configuration_error
63
+ end
64
+
65
+ def basic_auth_password
66
+ SidekiqQueueManager.configuration.basic_auth_password
67
+ end
68
+
69
+ def basic_auth_username
70
+ SidekiqQueueManager.configuration.basic_auth_username
71
+ end
72
+
73
+ # Ruby's multiline string with here-doc for readable error messages
74
+ def render_authentication_configuration_error
75
+ Rails.logger.error '[SidekiqQueueManager] basic_auth_password not configured - authentication will fail'
76
+
77
+ error_message = <<~ERROR
78
+ Sidekiq Queue Manager: Authentication Not Configured
79
+
80
+ Basic authentication is enabled but no password is set.
81
+
82
+ To fix this, add the following to config/initializers/sidekiq_queue_manager.rb:
83
+
84
+ SidekiqQueueManager.configure do |config|
85
+ config.basic_auth_password = 'your-secure-password-here'
86
+ end
87
+
88
+ Or disable authentication (NOT recommended for production):
89
+
90
+ SidekiqQueueManager.configure do |config|
91
+ config.basic_auth_enabled = false
92
+ end
93
+ ERROR
94
+
95
+ render plain: error_message, status: :internal_server_error
96
+ end
97
+
98
+ # HTTP Basic Authentication implementation with Ruby's secure comparison
99
+ def perform_basic_authentication
100
+ authenticate_or_request_with_http_basic('Sidekiq Queue Manager') do |username, password|
101
+ # Use secure comparison to prevent timing attacks
102
+ credentials_valid?(username, password)
103
+ end
104
+ end
105
+
106
+ def credentials_valid?(username, password)
107
+ username_match = ActiveSupport::SecurityUtils.secure_compare(username, basic_auth_username)
108
+ password_match = ActiveSupport::SecurityUtils.secure_compare(password, basic_auth_password)
109
+
110
+ username_match && password_match
111
+ end
112
+
113
+ # Set security headers for all responses using Ruby's functional approach
114
+ def set_security_headers
115
+ apply_standard_security_headers
116
+ apply_csp_header if SidekiqQueueManager.configuration.enable_csp?
117
+ end
118
+
119
+ def apply_standard_security_headers
120
+ response.headers.merge!(
121
+ 'X-Frame-Options' => 'SAMEORIGIN',
122
+ 'X-Content-Type-Options' => 'nosniff',
123
+ 'X-XSS-Protection' => '1; mode=block',
124
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin'
125
+ )
126
+ end
127
+
128
+ def apply_csp_header
129
+ response.headers['Content-Security-Policy'] = content_security_policy
130
+ end
131
+
132
+ # Build Content Security Policy header using Ruby's array joining
133
+ def content_security_policy
134
+ [
135
+ "default-src 'self'",
136
+ "script-src 'self' 'unsafe-inline'",
137
+ "style-src 'self' 'unsafe-inline'",
138
+ "img-src 'self' data:",
139
+ "font-src 'self'",
140
+ "connect-src 'self'",
141
+ "frame-ancestors 'none'"
142
+ ].join('; ')
143
+ end
144
+
145
+ # Helper to determine asset serving strategy using Ruby's expressive error handling
146
+ def asset_serving_info
147
+ @asset_serving_info ||= detect_asset_serving_strategy
148
+ end
149
+ helper_method :asset_serving_info
150
+
151
+ def detect_asset_serving_strategy
152
+ asset_pipeline_strategy
153
+ rescue StandardError
154
+ direct_serving_strategy
155
+ end
156
+
157
+ def asset_pipeline_strategy
158
+ {
159
+ css_path: asset_path('sidekiq_queue_manager/application.css'),
160
+ js_path: asset_path('sidekiq_queue_manager/application.js'),
161
+ modals_css_path: asset_path('sidekiq_queue_manager/modals.css'),
162
+ use_asset_pipeline: true
163
+ }
164
+ end
165
+
166
+ def direct_serving_strategy
167
+ mount_path = sidekiq_dashboard.root_path.chomp('/')
168
+
169
+ {
170
+ css_path: "#{mount_path}/assets/sidekiq_queue_manager/application.css",
171
+ js_path: "#{mount_path}/assets/sidekiq_queue_manager/application.js",
172
+ modals_css_path: "#{mount_path}/assets/sidekiq_queue_manager/modals.css",
173
+ use_asset_pipeline: false
174
+ }
175
+ end
176
+
177
+ # Common helper for accessing main application methods
178
+ def main_app
179
+ @main_app ||= Rails.application.routes.url_helpers
180
+ end
181
+
182
+ # Logging helpers with gem prefix using Ruby's method delegation
183
+ %w[info error].each do |level|
184
+ define_method "log_#{level}" do |message|
185
+ Rails.logger.public_send(level, "[SidekiqQueueManager] #{message}")
186
+ end
187
+ end
188
+ end
189
+ end
190
+
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqQueueManager
4
+ # Controller to serve assets when the asset pipeline is not available
5
+ # This ensures CSS and JavaScript work in API-only Rails apps
6
+ class AssetsController < ApplicationController
7
+ # Skip CSRF protection for asset requests
8
+ skip_before_action :verify_authenticity_token
9
+
10
+ # Serve CSS file
11
+ def css
12
+ css_content = load_asset_file('application.css')
13
+
14
+ respond_to do |format|
15
+ format.css do
16
+ render plain: css_content, content_type: 'text/css'
17
+ end
18
+ format.all do
19
+ render plain: css_content, content_type: 'text/css'
20
+ end
21
+ end
22
+ rescue StandardError => e
23
+ Rails.logger.error "[SidekiqQueueManager] Error serving CSS: #{e.message}"
24
+ render plain: "/* CSS loading error: #{e.message} */", content_type: 'text/css'
25
+ end
26
+
27
+ # Serve JavaScript file
28
+ def js
29
+ js_content = load_asset_file('application.js')
30
+
31
+ respond_to do |format|
32
+ format.js do
33
+ render plain: js_content, content_type: 'application/javascript'
34
+ end
35
+ format.all do
36
+ render plain: js_content, content_type: 'application/javascript'
37
+ end
38
+ end
39
+ rescue StandardError => e
40
+ Rails.logger.error "[SidekiqQueueManager] Error serving JS: #{e.message}"
41
+ render plain: "/* JavaScript loading error: #{e.message} */", content_type: 'application/javascript'
42
+ end
43
+
44
+ # Serve modals CSS file
45
+ def modals_css
46
+ css_content = load_asset_file('modals.css')
47
+
48
+ respond_to do |format|
49
+ format.css do
50
+ render plain: css_content, content_type: 'text/css'
51
+ end
52
+ format.all do
53
+ render plain: css_content, content_type: 'text/css'
54
+ end
55
+ end
56
+ rescue StandardError => e
57
+ Rails.logger.error "[SidekiqQueueManager] Error serving modals CSS: #{e.message}"
58
+ render plain: "/* Modals CSS loading error: #{e.message} */", content_type: 'text/css'
59
+ end
60
+
61
+ private
62
+
63
+ # Load asset file from the gem's asset directory
64
+ def load_asset_file(filename)
65
+ asset_path = case filename
66
+ when 'application.css'
67
+ File.join(gem_assets_path, 'stylesheets', 'sidekiq_queue_manager', 'application.css')
68
+ when 'modals.css'
69
+ File.join(gem_assets_path, 'stylesheets', 'sidekiq_queue_manager', 'modals.css')
70
+ when 'application.js'
71
+ File.join(gem_assets_path, 'javascripts', 'sidekiq_queue_manager', 'application.js')
72
+ else
73
+ raise "Unknown asset: #{filename}"
74
+ end
75
+
76
+ raise "Asset file not found: #{asset_path}" unless File.exist?(asset_path)
77
+
78
+ File.read(asset_path)
79
+ end
80
+
81
+ # Get the path to the gem's assets directory
82
+ def gem_assets_path
83
+ @gem_assets_path ||= File.expand_path('../../assets', __dir__)
84
+ end
85
+ end
86
+ end
87
+
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqQueueManager
4
+ # Main dashboard controller providing the web interface for queue management
5
+ #
6
+ # Handles both JSON API responses and HTML views for the queue manager interface.
7
+ # Includes comprehensive error handling, authentication, and real-time updates.
8
+ #
9
+ class DashboardController < ApplicationController
10
+ include ActionController::Live
11
+
12
+ # Prevent CSRF token issues with JSON API requests
13
+ protect_from_forgery with: :null_session, if: -> { request.format.json? }
14
+
15
+ # Apply authentication if configured
16
+ before_action :authenticate_user!, if: -> { authentication_required? }
17
+ before_action :set_queue_name, only: %i[
18
+ queue_status pause_queue resume_queue jobs delete_job
19
+ set_limit remove_limit set_process_limit remove_process_limit
20
+ block unblock clear
21
+ ]
22
+
23
+ # Comprehensive error handling for all controller actions
24
+ rescue_from StandardError, with: :handle_api_error
25
+ rescue_from SidekiqQueueManager::ServiceError, with: :handle_service_error
26
+ rescue_from ActionController::ParameterMissing, with: :handle_parameter_error
27
+
28
+ # ========================================
29
+ # Main Dashboard Actions
30
+ # ========================================
31
+
32
+ # Main dashboard interface - serves both HTML and JSON
33
+ # GET /
34
+ def index
35
+ respond_to do |format|
36
+ format.html { render_dashboard_html }
37
+ format.json { render_dashboard_json }
38
+ end
39
+ end
40
+
41
+ # Real-time metrics endpoint for AJAX updates
42
+ # GET /metrics
43
+ def metrics
44
+ set_no_cache_headers
45
+ render json: api_response(data: QueueService.queue_metrics)
46
+ end
47
+
48
+ # Server-Sent Events stream for real-time updates
49
+ # GET /live
50
+ def live_stream
51
+ response.headers['Content-Type'] = 'text/event-stream'
52
+ response.headers['Cache-Control'] = 'no-cache'
53
+ response.headers['X-Accel-Buffering'] = 'no'
54
+
55
+ begin
56
+ # Send initial data
57
+ send_sse_event('metrics', QueueService.queue_metrics)
58
+
59
+ # Stream updates every refresh interval
60
+ loop do
61
+ sleep(refresh_interval_seconds)
62
+
63
+ metrics = QueueService.queue_metrics
64
+ send_sse_event('metrics', metrics)
65
+ end
66
+ rescue ActionController::Live::ClientDisconnected
67
+ logger.info('Client disconnected from live stream')
68
+ rescue StandardError => e
69
+ logger.error("Live stream error: #{e.message}")
70
+ send_sse_event('error', { message: 'Stream error occurred' })
71
+ ensure
72
+ response.stream.close
73
+ end
74
+ end
75
+
76
+ # ========================================
77
+ # Queue Control Actions (JSON API)
78
+ # ========================================
79
+
80
+ # Pause a specific queue
81
+ # POST /queues/:name/pause
82
+ def pause_queue
83
+ result = QueueService.pause_queue(@queue_name)
84
+ render json: api_response(
85
+ success: result[:success],
86
+ message: result[:message],
87
+ data: result
88
+ ), status: result[:success] ? :ok : :unprocessable_entity
89
+ end
90
+
91
+ # Resume a specific queue
92
+ # POST /queues/:name/resume
93
+ def resume_queue
94
+ result = QueueService.resume_queue(@queue_name)
95
+ render json: api_response(
96
+ success: result[:success],
97
+ message: result[:message],
98
+ data: result
99
+ ), status: result[:success] ? :ok : :unprocessable_entity
100
+ end
101
+
102
+ # Get status for a specific queue
103
+ # GET /queues/:name/status
104
+ def queue_status
105
+ result = QueueService.queue_status(@queue_name)
106
+ render json: api_response(data: result)
107
+ end
108
+
109
+ # Bulk pause all non-critical queues
110
+ # POST /queues/pause_all
111
+ def pause_all
112
+ result = QueueService.pause_all_queues
113
+ render json: api_response(
114
+ success: result[:success],
115
+ message: result[:message],
116
+ data: result
117
+ ), status: result[:success] ? :ok : :unprocessable_entity
118
+ end
119
+
120
+ # Bulk resume all queues
121
+ # POST /queues/resume_all
122
+ def resume_all
123
+ result = QueueService.resume_all_queues
124
+ render json: api_response(
125
+ success: result[:success],
126
+ message: result[:message],
127
+ data: result
128
+ ), status: result[:success] ? :ok : :unprocessable_entity
129
+ end
130
+
131
+ # Global summary statistics
132
+ # GET /queues/summary
133
+ def summary
134
+ set_no_cache_headers
135
+
136
+ metrics = QueueService.queue_metrics
137
+ summary_data = {
138
+ total_queues: metrics[:queues].size,
139
+ total_enqueued: metrics[:global_stats][:enqueued],
140
+ total_busy: metrics[:global_stats][:busy],
141
+ paused_queues: metrics[:queues].count { |_, queue| queue[:paused] },
142
+ critical_queues: metrics[:queues].count { |_, queue| queue[:critical] }
143
+ }
144
+
145
+ render json: api_response(data: summary_data)
146
+ end
147
+
148
+ # ========================================
149
+ # Advanced Queue Operations
150
+ # ========================================
151
+
152
+ # View jobs in a specific queue with pagination
153
+ # GET /queues/:name/jobs
154
+ def jobs
155
+ page = params[:page] || 1
156
+ per_page = params[:per_page] || 10
157
+
158
+ result = QueueService.view_queue_jobs(@queue_name, page: page, per_page: per_page)
159
+ render json: api_response(
160
+ success: result[:success],
161
+ message: result[:message],
162
+ data: result
163
+ ), status: result[:success] ? :ok : :unprocessable_entity
164
+ end
165
+
166
+ # Delete a specific job from a queue
167
+ # DELETE /queues/:name/delete_job
168
+ def delete_job
169
+ job_id = params.require(:job_id)
170
+
171
+ result = QueueService.delete_job(@queue_name, job_id)
172
+ render json: api_response(
173
+ success: result[:success],
174
+ message: result[:message]
175
+ ), status: result[:success] ? :ok : :unprocessable_entity
176
+ end
177
+
178
+ # Set queue limit
179
+ # POST /queues/:name/set_limit
180
+ def set_limit
181
+ limit = params.require(:limit).to_i
182
+
183
+ result = QueueService.set_queue_limit(@queue_name, limit)
184
+ render json: api_response(
185
+ success: result[:success],
186
+ message: result[:message]
187
+ ), status: result[:success] ? :ok : :unprocessable_entity
188
+ end
189
+
190
+ # Remove queue limit
191
+ # DELETE /queues/:name/remove_limit
192
+ def remove_limit
193
+ result = QueueService.remove_queue_limit(@queue_name)
194
+ render json: api_response(
195
+ success: result[:success],
196
+ message: result[:message]
197
+ ), status: result[:success] ? :ok : :unprocessable_entity
198
+ end
199
+
200
+ # Set process limit
201
+ # POST /queues/:name/set_process_limit
202
+ def set_process_limit
203
+ limit = params.require(:limit).to_i
204
+
205
+ result = QueueService.set_process_limit(@queue_name, limit)
206
+ render json: api_response(
207
+ success: result[:success],
208
+ message: result[:message]
209
+ ), status: result[:success] ? :ok : :unprocessable_entity
210
+ end
211
+
212
+ # Remove process limit
213
+ # DELETE /queues/:name/remove_process_limit
214
+ def remove_process_limit
215
+ result = QueueService.remove_process_limit(@queue_name)
216
+ render json: api_response(
217
+ success: result[:success],
218
+ message: result[:message]
219
+ ), status: result[:success] ? :ok : :unprocessable_entity
220
+ end
221
+
222
+ # Block a queue
223
+ # POST /queues/:name/block
224
+ def block
225
+ result = QueueService.block_queue(@queue_name)
226
+ render json: api_response(
227
+ success: result[:success],
228
+ message: result[:message]
229
+ ), status: result[:success] ? :ok : :unprocessable_entity
230
+ end
231
+
232
+ # Unblock a queue
233
+ # POST /queues/:name/unblock
234
+ def unblock
235
+ result = QueueService.unblock_queue(@queue_name)
236
+ render json: api_response(
237
+ success: result[:success],
238
+ message: result[:message]
239
+ ), status: result[:success] ? :ok : :unprocessable_entity
240
+ end
241
+
242
+ # Clear all jobs from a queue
243
+ # POST /queues/:name/clear
244
+ def clear
245
+ result = QueueService.clear_queue(@queue_name)
246
+ render json: api_response(
247
+ success: result[:success],
248
+ message: result[:message],
249
+ data: result[:data]
250
+ ), status: result[:success] ? :ok : :unprocessable_entity
251
+ end
252
+
253
+ private
254
+
255
+ # ========================================
256
+ # Helper Methods
257
+ # ========================================
258
+
259
+ def render_dashboard_html
260
+ @initial_metrics = QueueService.queue_metrics
261
+ @configuration = SidekiqQueueManager.configuration.to_h
262
+ render template: 'sidekiq_queue_manager/dashboard/index'
263
+ end
264
+
265
+ def render_dashboard_json
266
+ set_no_cache_headers
267
+ render json: api_response(data: QueueService.queue_metrics)
268
+ end
269
+
270
+ def set_queue_name
271
+ @queue_name = params[:name] || params[:queue_name]
272
+
273
+ if @queue_name.blank?
274
+ render json: api_error_response('Queue name is required'), status: :bad_request
275
+ return
276
+ end
277
+
278
+ # Validate queue name exists
279
+ return if available_queue_names.include?(@queue_name)
280
+
281
+ render json: api_error_response("Invalid queue name: #{@queue_name}"), status: :not_found
282
+ nil
283
+ end
284
+
285
+ def available_queue_names
286
+ @available_queue_names ||= SidekiqQueueManager::QueueService.send(:available_queues)
287
+ end
288
+
289
+ def authentication_required?
290
+ SidekiqQueueManager.configuration.authentication_method.present?
291
+ end
292
+
293
+ def authenticate_user!
294
+ method_name = SidekiqQueueManager.configuration.authentication_method
295
+ send(method_name) if respond_to?(method_name, true)
296
+ end
297
+
298
+ def refresh_interval_seconds
299
+ SidekiqQueueManager.configuration.refresh_interval / 1000.0
300
+ end
301
+
302
+ # ========================================
303
+ # Server-Sent Events Helpers
304
+ # ========================================
305
+
306
+ def send_sse_event(event_type, data)
307
+ response.stream.write("event: #{event_type}\n")
308
+ response.stream.write("data: #{data.to_json}\n\n")
309
+ rescue StandardError => e
310
+ logger.error("Failed to send SSE event: #{e.message}")
311
+ end
312
+
313
+ # ========================================
314
+ # Response Formatters
315
+ # ========================================
316
+
317
+ def api_response(success: true, message: nil, data: nil, **extra)
318
+ response_hash = {
319
+ success: success,
320
+ timestamp: Time.current.iso8601
321
+ }
322
+
323
+ response_hash[:message] = message if message.present?
324
+ response_hash[:data] = data if data.present?
325
+ response_hash.merge!(extra) if extra.any?
326
+
327
+ response_hash
328
+ end
329
+
330
+ def api_error_response(message, **extra)
331
+ api_response(success: false, message: message, **extra)
332
+ end
333
+
334
+ # ========================================
335
+ # HTTP Headers
336
+ # ========================================
337
+
338
+ def set_no_cache_headers
339
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
340
+ response.headers['Pragma'] = 'no-cache'
341
+ response.headers['Expires'] = '0'
342
+ end
343
+
344
+ # ========================================
345
+ # Error Handlers
346
+ # ========================================
347
+
348
+ def handle_api_error(exception)
349
+ logger.error("API Error: #{exception.class} - #{exception.message}")
350
+ logger.error(exception.backtrace.join("\n")) if Rails.env.development?
351
+
352
+ error_message = if Rails.env.production?
353
+ 'An unexpected error occurred'
354
+ else
355
+ "#{exception.class}: #{exception.message}"
356
+ end
357
+
358
+ render json: api_error_response(error_message), status: :internal_server_error
359
+ end
360
+
361
+ def handle_service_error(exception)
362
+ logger.error("Service Error: #{exception.message}")
363
+ render json: api_error_response(exception.message), status: :unprocessable_entity
364
+ end
365
+
366
+ def handle_parameter_error(exception)
367
+ logger.warn("Parameter Error: #{exception.message}")
368
+ render json: api_error_response("Missing required parameter: #{exception.param}"),
369
+ status: :bad_request
370
+ end
371
+ end
372
+ end
373
+