magick-feature-flags 0.9.26 → 0.9.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52c961b4ce8e667ce8e728098f63a210fb82aaee03d63d573ef87d692c007875
4
- data.tar.gz: 6e33c899e93f039bf5d318a0ac1492efa45329b99bbe4e700f17b37d4240818e
3
+ metadata.gz: d975b9e4104eaeb39aad4a8814897971e399bce74e61797b0929cad1a6f5b9bb
4
+ data.tar.gz: 23f2fddf2fd1dce20c0508d79e75e1ba3723df9230b5c98bb7241073ea32fa2b
5
5
  SHA512:
6
- metadata.gz: f854aa3d288dd2bf137fb56e69c58c2092ff1b5d76a8ce2c429f416416b758ab5d99e680efd3c704f084d08dc7b2a4bc2f161bed9431446609de1ebddec40f22
7
- data.tar.gz: 98e43b058ed905b6bed109911e6649b8644c401681e269a6e406f41e49689992bed804488ab81f0ccafe4db7dcbc68ab45a88686a93f0ee10213f35b8a8f1c36
6
+ metadata.gz: bd8a642773a7d81cda3630c8479e877ba34b8fa4713711a4b9bc08a303b1a487b350c371360717fd14ec89d4f70aea12ca624b8347496cd053fd76879fc3bc0e
7
+ data.tar.gz: c8551a3a54cd555eb1ff161aab47d5a53c2058ab4df52f9dcb50075ecd24e599fd899acf544d494197c0b877a9a4f9e4300aadd402da70803ae0022c66071d1b
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module AdminUI
5
+ class FeaturesController < ActionController::Base
6
+ # Include route helpers so views can use magick_admin_ui.* helpers
7
+ include Magick::AdminUI::Engine.routes.url_helpers
8
+ layout 'application'
9
+ before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting]
10
+
11
+ # Make route helpers available in views via magick_admin_ui helper
12
+ helper_method :magick_admin_ui, :available_roles, :partially_enabled?
13
+
14
+ def magick_admin_ui
15
+ Magick::AdminUI::Engine.routes.url_helpers
16
+ end
17
+
18
+ def available_roles
19
+ Magick::AdminUI.config.available_roles || []
20
+ end
21
+
22
+ def partially_enabled?(feature)
23
+ targeting = feature.instance_variable_get(:@targeting) || {}
24
+ targeting.any? && !targeting.empty?
25
+ end
26
+
27
+ def index
28
+ @features = Magick.features.values
29
+ end
30
+
31
+ def show
32
+ end
33
+
34
+ def edit
35
+ end
36
+
37
+ def update
38
+ if @feature.type == :boolean
39
+ # For boolean features, checkbox sends 'true' when checked, nothing when unchecked
40
+ # Rails form helpers handle this - if checkbox is unchecked, params[:value] will be nil
41
+ value = params[:value] == 'true'
42
+ @feature.set_value(value)
43
+ elsif params[:value].present?
44
+ # For string/number features, convert to appropriate type
45
+ value = params[:value]
46
+ if @feature.type == :number
47
+ value = value.include?('.') ? value.to_f : value.to_i
48
+ end
49
+ @feature.set_value(value)
50
+ end
51
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Feature updated successfully'
52
+ end
53
+
54
+ def enable
55
+ @feature.enable
56
+ redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
57
+ end
58
+
59
+ def disable
60
+ @feature.disable
61
+ redirect_to magick_admin_ui.features_path, notice: 'Feature disabled'
62
+ end
63
+
64
+ def enable_for_user
65
+ @feature.enable_for_user(params[:user_id])
66
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Feature enabled for user'
67
+ end
68
+
69
+ def enable_for_role
70
+ role = params[:role]
71
+ if role.present?
72
+ @feature.enable_for_role(role)
73
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: "Feature enabled for role: #{role}"
74
+ else
75
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Role is required'
76
+ end
77
+ end
78
+
79
+ def disable_for_role
80
+ role = params[:role]
81
+ if role.present?
82
+ @feature.disable_for_role(role)
83
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: "Feature disabled for role: #{role}"
84
+ else
85
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Role is required'
86
+ end
87
+ end
88
+
89
+ def update_targeting
90
+ # Handle targeting updates from form
91
+ targeting_params = params[:targeting] || {}
92
+
93
+ # Ensure we're using the registered feature instance
94
+ feature_name = @feature.name.to_s
95
+ @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
96
+
97
+ current_targeting = @feature.instance_variable_get(:@targeting) || {}
98
+
99
+ # Handle roles - always clear existing and set new ones
100
+ # Rails checkboxes don't send unchecked values, so we need to check what was sent
101
+ current_roles = current_targeting[:role].is_a?(Array) ? current_targeting[:role] : (current_targeting[:role] ? [current_targeting[:role]] : [])
102
+ selected_roles = Array(targeting_params[:roles]).reject(&:blank?)
103
+
104
+ # Disable roles that are no longer selected
105
+ (current_roles - selected_roles).each do |role|
106
+ @feature.disable_for_role(role) if role.present?
107
+ end
108
+
109
+ # Enable newly selected roles
110
+ (selected_roles - current_roles).each do |role|
111
+ @feature.enable_for_role(role) if role.present?
112
+ end
113
+
114
+ # Handle user IDs - replace existing user targeting
115
+ if targeting_params[:user_ids].present?
116
+ user_ids = targeting_params[:user_ids].split(',').map(&:strip).reject(&:blank?)
117
+ current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
118
+
119
+ # Disable users that are no longer in the list
120
+ (current_user_ids - user_ids).each do |user_id|
121
+ @feature.disable_for_user(user_id) if user_id.present?
122
+ end
123
+
124
+ # Enable new users
125
+ (user_ids - current_user_ids).each do |user_id|
126
+ @feature.enable_for_user(user_id) if user_id.present?
127
+ end
128
+ elsif targeting_params.key?(:user_ids) && targeting_params[:user_ids].blank?
129
+ # Clear all user targeting if field was cleared
130
+ current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
131
+ current_user_ids.each do |user_id|
132
+ @feature.disable_for_user(user_id) if user_id.present?
133
+ end
134
+ end
135
+
136
+ # Handle percentage of users
137
+ percentage_users_value = targeting_params[:percentage_users]
138
+ if percentage_users_value.present? && percentage_users_value.to_s.strip != ''
139
+ percentage = percentage_users_value.to_f
140
+ if percentage > 0 && percentage <= 100
141
+ result = @feature.enable_percentage_of_users(percentage)
142
+ Rails.logger.debug "Magick: Enabled percentage_users #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
143
+ else
144
+ # Value is 0 or invalid - disable
145
+ @feature.disable_percentage_of_users
146
+ end
147
+ else
148
+ # Field is empty - disable if it was previously set
149
+ @feature.disable_percentage_of_users if current_targeting[:percentage_users]
150
+ end
151
+
152
+ # Handle percentage of requests
153
+ percentage_requests_value = targeting_params[:percentage_requests]
154
+ if percentage_requests_value.present? && percentage_requests_value.to_s.strip != ''
155
+ percentage = percentage_requests_value.to_f
156
+ if percentage > 0 && percentage <= 100
157
+ result = @feature.enable_percentage_of_requests(percentage)
158
+ Rails.logger.debug "Magick: Enabled percentage_requests #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
159
+ else
160
+ # Value is 0 or invalid - disable
161
+ @feature.disable_percentage_of_requests
162
+ end
163
+ else
164
+ # Field is empty - disable if it was previously set
165
+ @feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
166
+ end
167
+
168
+ # After all targeting updates, ensure we're using the registered feature instance
169
+ # and reload it to get the latest state from adapter
170
+ feature_name = @feature.name.to_s
171
+ if Magick.features.key?(feature_name)
172
+ @feature = Magick.features[feature_name]
173
+ @feature.reload
174
+ else
175
+ @feature.reload
176
+ end
177
+
178
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
179
+ rescue StandardError => e
180
+ Rails.logger.error "Magick: Error updating targeting: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
181
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating targeting: #{e.message}"
182
+ end
183
+
184
+ private
185
+
186
+ def set_feature
187
+ feature_name = params[:id].to_s
188
+ @feature = Magick.features[feature_name] || Magick[feature_name]
189
+ redirect_to magick_admin_ui.features_path, alert: 'Feature not found' unless @feature
190
+
191
+ # Ensure we're working with the registered feature instance to keep state in sync
192
+ @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module AdminUI
5
+ class StatsController < ActionController::Base
6
+ include Magick::AdminUI::Engine.routes.url_helpers
7
+ layout 'application'
8
+
9
+ helper_method :magick_admin_ui
10
+
11
+ def magick_admin_ui
12
+ Magick::AdminUI::Engine.routes.url_helpers
13
+ end
14
+
15
+ def show
16
+ @feature = Magick.features[params[:id].to_s] || Magick[params[:id]]
17
+ @stats = Magick.feature_stats(params[:id].to_sym) || {} if @feature
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,533 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Magick Feature Flags</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ * {
8
+ margin: 0;
9
+ padding: 0;
10
+ box-sizing: border-box;
11
+ }
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
14
+ background: #f5f5f5;
15
+ color: #333;
16
+ line-height: 1.6;
17
+ }
18
+ .container {
19
+ max-width: 1200px;
20
+ margin: 0 auto;
21
+ padding: 20px;
22
+ }
23
+ header {
24
+ background: #fff;
25
+ border-bottom: 1px solid #e0e0e0;
26
+ padding: 20px 0;
27
+ margin-bottom: 30px;
28
+ }
29
+ header h1 {
30
+ font-size: 24px;
31
+ font-weight: 600;
32
+ color: #2c3e50;
33
+ }
34
+ header h1 a {
35
+ text-decoration: none;
36
+ color: #2c3e50;
37
+ transition: color 0.2s;
38
+ }
39
+ header h1 a:hover {
40
+ color: #3498db;
41
+ }
42
+ .card {
43
+ background: #fff;
44
+ border-radius: 8px;
45
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
46
+ padding: 24px;
47
+ margin-bottom: 20px;
48
+ }
49
+ .card-header {
50
+ display: flex;
51
+ justify-content: space-between;
52
+ align-items: center;
53
+ margin-bottom: 20px;
54
+ padding-bottom: 16px;
55
+ border-bottom: 1px solid #e0e0e0;
56
+ }
57
+ .card-header h2 {
58
+ font-size: 20px;
59
+ font-weight: 600;
60
+ color: #2c3e50;
61
+ }
62
+ table {
63
+ width: 100%;
64
+ border-collapse: collapse;
65
+ }
66
+ th, td {
67
+ padding: 12px;
68
+ text-align: left;
69
+ border-bottom: 1px solid #e0e0e0;
70
+ }
71
+ th {
72
+ font-weight: 600;
73
+ color: #666;
74
+ font-size: 14px;
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.5px;
77
+ }
78
+ tr:hover {
79
+ background: #f9f9f9;
80
+ }
81
+ .btn {
82
+ display: inline-block;
83
+ padding: 8px 16px;
84
+ border-radius: 4px;
85
+ text-decoration: none;
86
+ font-size: 14px;
87
+ font-weight: 500;
88
+ cursor: pointer;
89
+ border: none;
90
+ transition: all 0.2s;
91
+ }
92
+ .btn-primary {
93
+ background: #3498db;
94
+ color: #fff;
95
+ }
96
+ .btn-primary:hover {
97
+ background: #2980b9;
98
+ }
99
+ .btn-success {
100
+ background: #27ae60;
101
+ color: #fff;
102
+ }
103
+ .btn-success:hover {
104
+ background: #229954;
105
+ }
106
+ .btn-danger {
107
+ background: #e74c3c;
108
+ color: #fff;
109
+ }
110
+ .btn-danger:hover {
111
+ background: #c0392b;
112
+ }
113
+ .btn-secondary {
114
+ background: #95a5a6;
115
+ color: #fff;
116
+ }
117
+ .btn-secondary:hover {
118
+ background: #7f8c8d;
119
+ }
120
+ .btn-sm {
121
+ padding: 8px 16px;
122
+ font-size: 14px;
123
+ min-width: 80px;
124
+ text-align: center;
125
+ }
126
+ .btn-group {
127
+ display: flex;
128
+ gap: 8px;
129
+ flex-wrap: wrap;
130
+ }
131
+ .btn-group .btn {
132
+ flex: 0 0 auto;
133
+ }
134
+ form.button_to {
135
+ display: inline-block;
136
+ margin: 0;
137
+ }
138
+ form.button_to input[type="submit"] {
139
+ padding: 8px 16px;
140
+ font-size: 14px;
141
+ min-width: 80px;
142
+ text-align: center;
143
+ }
144
+ .badge {
145
+ display: inline-block;
146
+ padding: 4px 8px;
147
+ border-radius: 4px;
148
+ font-size: 12px;
149
+ font-weight: 500;
150
+ }
151
+ .badge-success {
152
+ background: #d4edda;
153
+ color: #155724;
154
+ }
155
+ .badge-warning {
156
+ background: #fff3cd;
157
+ color: #856404;
158
+ }
159
+ .badge-danger {
160
+ background: #f8d7da;
161
+ color: #721c24;
162
+ }
163
+ .badge-info {
164
+ background: #d1ecf1;
165
+ color: #0c5460;
166
+ }
167
+ .form-group {
168
+ margin-bottom: 20px;
169
+ }
170
+ label {
171
+ display: block;
172
+ margin-bottom: 8px;
173
+ font-weight: 500;
174
+ color: #333;
175
+ }
176
+ input[type="text"],
177
+ input[type="number"],
178
+ select {
179
+ width: 100%;
180
+ padding: 10px;
181
+ border: 1px solid #ddd;
182
+ border-radius: 4px;
183
+ font-size: 14px;
184
+ }
185
+ input[type="checkbox"] {
186
+ width: auto;
187
+ }
188
+ .alert {
189
+ padding: 12px 16px;
190
+ border-radius: 4px;
191
+ margin-bottom: 20px;
192
+ }
193
+ .alert-success {
194
+ background: #d4edda;
195
+ color: #155724;
196
+ border: 1px solid #c3e6cb;
197
+ }
198
+ .alert-danger {
199
+ background: #f8d7da;
200
+ color: #721c24;
201
+ border: 1px solid #f5c6cb;
202
+ }
203
+ .alert-info {
204
+ background: #d1ecf1;
205
+ color: #0c5460;
206
+ border: 1px solid #bee5eb;
207
+ }
208
+ .feature-details {
209
+ display: grid;
210
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
211
+ gap: 20px;
212
+ margin-bottom: 20px;
213
+ }
214
+ .detail-item {
215
+ padding: 16px;
216
+ background: #f9f9f9;
217
+ border-radius: 4px;
218
+ }
219
+ .detail-label {
220
+ font-size: 12px;
221
+ color: #666;
222
+ text-transform: uppercase;
223
+ letter-spacing: 0.5px;
224
+ margin-bottom: 4px;
225
+ }
226
+ .detail-value {
227
+ font-size: 16px;
228
+ font-weight: 500;
229
+ color: #333;
230
+ }
231
+ .targeting-rules {
232
+ margin-top: 20px;
233
+ }
234
+ .targeting-rules ul {
235
+ list-style: none;
236
+ padding: 0;
237
+ }
238
+ .targeting-rules li {
239
+ padding: 8px 12px;
240
+ background: #f9f9f9;
241
+ margin-bottom: 8px;
242
+ border-radius: 4px;
243
+ border-left: 3px solid #3498db;
244
+ }
245
+ .stats-grid {
246
+ display: grid;
247
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
248
+ gap: 20px;
249
+ margin-top: 20px;
250
+ }
251
+ .stat-card {
252
+ background: #f9f9f9;
253
+ padding: 20px;
254
+ border-radius: 4px;
255
+ text-align: center;
256
+ }
257
+ .stat-value {
258
+ font-size: 32px;
259
+ font-weight: 600;
260
+ color: #3498db;
261
+ margin-bottom: 8px;
262
+ }
263
+ .stat-label {
264
+ font-size: 14px;
265
+ color: #666;
266
+ text-transform: uppercase;
267
+ letter-spacing: 0.5px;
268
+ }
269
+ .empty-state {
270
+ text-align: center;
271
+ padding: 60px 20px;
272
+ color: #999;
273
+ }
274
+ .empty-state h3 {
275
+ margin-bottom: 10px;
276
+ color: #666;
277
+ }
278
+
279
+ /* User IDs Tag Input */
280
+ .user-ids-input-container {
281
+ border: 1px solid #ddd;
282
+ border-radius: 4px;
283
+ padding: 8px;
284
+ background: #fff;
285
+ min-height: 44px;
286
+ display: flex;
287
+ flex-wrap: wrap;
288
+ align-items: center;
289
+ gap: 6px;
290
+ }
291
+ .user-ids-tags {
292
+ display: flex;
293
+ flex-wrap: wrap;
294
+ gap: 6px;
295
+ flex: 1;
296
+ }
297
+ .user-id-tag {
298
+ display: inline-flex;
299
+ align-items: center;
300
+ gap: 6px;
301
+ background: #3498db;
302
+ color: #fff;
303
+ padding: 4px 8px;
304
+ border-radius: 4px;
305
+ font-size: 13px;
306
+ font-weight: 500;
307
+ }
308
+ .tag-remove {
309
+ cursor: pointer;
310
+ font-size: 18px;
311
+ line-height: 1;
312
+ font-weight: bold;
313
+ opacity: 0.8;
314
+ transition: opacity 0.2s;
315
+ }
316
+ .tag-remove:hover {
317
+ opacity: 1;
318
+ }
319
+ .user-ids-input {
320
+ flex: 1;
321
+ min-width: 150px;
322
+ border: none;
323
+ outline: none;
324
+ padding: 6px 8px;
325
+ font-size: 14px;
326
+ }
327
+ .user-ids-input:focus {
328
+ outline: none;
329
+ }
330
+ .user-ids-input-container:focus-within {
331
+ border-color: #3498db;
332
+ box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1);
333
+ }
334
+
335
+ /* Mobile Responsive */
336
+ @media (max-width: 768px) {
337
+ .container {
338
+ padding: 10px;
339
+ }
340
+ header {
341
+ padding: 15px 0;
342
+ }
343
+ header h1 {
344
+ font-size: 20px;
345
+ }
346
+ .card {
347
+ padding: 16px;
348
+ }
349
+ .card-header {
350
+ flex-direction: column;
351
+ align-items: flex-start;
352
+ gap: 12px;
353
+ }
354
+ .card-header h2 {
355
+ font-size: 18px;
356
+ }
357
+ table {
358
+ display: block;
359
+ overflow-x: auto;
360
+ -webkit-overflow-scrolling: touch;
361
+ }
362
+ thead {
363
+ display: none;
364
+ }
365
+ tr {
366
+ display: block;
367
+ margin-bottom: 16px;
368
+ border: 1px solid #e0e0e0;
369
+ border-radius: 8px;
370
+ padding: 12px;
371
+ background: #fff;
372
+ }
373
+ td {
374
+ display: block;
375
+ padding: 8px 0;
376
+ border: none;
377
+ text-align: left;
378
+ }
379
+ td:before {
380
+ content: attr(data-label);
381
+ font-weight: 600;
382
+ color: #666;
383
+ display: block;
384
+ margin-bottom: 4px;
385
+ font-size: 12px;
386
+ text-transform: uppercase;
387
+ letter-spacing: 0.5px;
388
+ }
389
+ td:last-child {
390
+ border-top: 1px solid #e0e0e0;
391
+ margin-top: 8px;
392
+ padding-top: 12px;
393
+ }
394
+ .btn-group {
395
+ flex-direction: column;
396
+ width: 100%;
397
+ }
398
+ .btn-group .btn {
399
+ width: 100%;
400
+ }
401
+ .feature-details {
402
+ grid-template-columns: 1fr;
403
+ }
404
+ .stats-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+ .card-header > div {
408
+ width: 100%;
409
+ }
410
+ .card-header > div .btn {
411
+ width: 100%;
412
+ margin-bottom: 8px;
413
+ }
414
+ .user-ids-input-container {
415
+ min-height: 40px;
416
+ padding: 6px;
417
+ }
418
+ .user-ids-input {
419
+ min-width: 100px;
420
+ font-size: 16px; /* Prevents zoom on iOS */
421
+ }
422
+ }
423
+
424
+ /* Tablet */
425
+ @media (min-width: 769px) and (max-width: 1024px) {
426
+ .container {
427
+ padding: 15px;
428
+ }
429
+ table {
430
+ font-size: 14px;
431
+ }
432
+ th, td {
433
+ padding: 10px 8px;
434
+ }
435
+ }
436
+ </style>
437
+ </head>
438
+ <body>
439
+ <header>
440
+ <div class="container">
441
+ <h1><a href="<%= magick_admin_ui.root_path %>">🎩 Magick Feature Flags</a></h1>
442
+ </div>
443
+ </header>
444
+
445
+ <div class="container">
446
+ <% if notice %>
447
+ <div class="alert alert-success"><%= notice %></div>
448
+ <% end %>
449
+ <% if alert %>
450
+ <div class="alert alert-danger"><%= alert %></div>
451
+ <% end %>
452
+
453
+ <%= yield %>
454
+ </div>
455
+
456
+ <script>
457
+ // User IDs Tag Input Handler
458
+ (function() {
459
+ const input = document.getElementById('user_ids_input');
460
+ const tagsContainer = document.getElementById('user_ids_tags');
461
+ const hiddenInput = document.getElementById('user_ids_hidden');
462
+
463
+ if (!input || !tagsContainer || !hiddenInput) return;
464
+
465
+ function updateHiddenInput() {
466
+ const tags = Array.from(tagsContainer.querySelectorAll('.user-id-tag'));
467
+ const userIds = tags.map(tag => tag.textContent.trim().replace('×', '').trim()).filter(id => id);
468
+ hiddenInput.value = userIds.join(',');
469
+ }
470
+
471
+ function addTag(userId) {
472
+ userId = userId.trim();
473
+ if (!userId) return;
474
+
475
+ // Check if already exists
476
+ const existing = Array.from(tagsContainer.querySelectorAll('.user-id-tag'))
477
+ .some(tag => tag.textContent.trim().replace('×', '').trim() === userId);
478
+ if (existing) return;
479
+
480
+ const tag = document.createElement('span');
481
+ tag.className = 'user-id-tag';
482
+ tag.innerHTML = userId + ' <span class="tag-remove" data-user-id="' + userId + '">×</span>';
483
+
484
+ const removeBtn = tag.querySelector('.tag-remove');
485
+ removeBtn.addEventListener('click', function() {
486
+ tag.remove();
487
+ updateHiddenInput();
488
+ });
489
+
490
+ tagsContainer.appendChild(tag);
491
+ updateHiddenInput();
492
+ }
493
+
494
+ input.addEventListener('keydown', function(e) {
495
+ if (e.key === 'Enter' || e.keyCode === 13) {
496
+ e.preventDefault();
497
+ const value = input.value.trim();
498
+ if (value) {
499
+ // Support comma-separated values
500
+ value.split(',').forEach(id => {
501
+ const trimmed = id.trim();
502
+ if (trimmed) addTag(trimmed);
503
+ });
504
+ input.value = '';
505
+ }
506
+ }
507
+ });
508
+
509
+ // Handle paste with comma-separated values
510
+ input.addEventListener('paste', function(e) {
511
+ setTimeout(function() {
512
+ const value = input.value.trim();
513
+ if (value.includes(',')) {
514
+ value.split(',').forEach(id => {
515
+ const trimmed = id.trim();
516
+ if (trimmed) addTag(trimmed);
517
+ });
518
+ input.value = '';
519
+ }
520
+ }, 10);
521
+ });
522
+
523
+ // Remove existing tags when clicked
524
+ tagsContainer.querySelectorAll('.tag-remove').forEach(btn => {
525
+ btn.addEventListener('click', function() {
526
+ this.parentElement.remove();
527
+ updateHiddenInput();
528
+ });
529
+ });
530
+ })();
531
+ </script>
532
+ </body>
533
+ </html>
@@ -0,0 +1,124 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h2>Edit Feature: <%= @feature.display_name || @feature.name %></h2>
4
+ <div class="btn-group">
5
+ <a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary btn-sm">← Back</a>
6
+ </div>
7
+ </div>
8
+
9
+ <%= form_with url: magick_admin_ui.feature_path(@feature.name), method: :put, local: true do |f| %>
10
+ <div class="form-group">
11
+ <label>Feature Name</label>
12
+ <input type="text" value="<%= @feature.name %>" disabled>
13
+ <small style="color: #999;">Feature name cannot be changed</small>
14
+ </div>
15
+
16
+ <div class="form-group">
17
+ <label>Type</label>
18
+ <input type="text" value="<%= @feature.type.to_s.capitalize %>" disabled>
19
+ </div>
20
+
21
+ <div class="form-group">
22
+ <label>Current Value</label>
23
+ <% if @feature.type == :boolean %>
24
+ <div>
25
+ <label style="display: inline-flex; align-items: center; cursor: pointer;">
26
+ <%= check_box_tag 'value', 'true', @feature.enabled?, id: 'feature_value' %>
27
+ <span style="margin-left: 8px;">Enabled</span>
28
+ </label>
29
+ </div>
30
+ <%= hidden_field_tag 'value', 'false' %>
31
+ <small style="color: #999;">Check to enable, uncheck to disable</small>
32
+ <% elsif @feature.type == :string %>
33
+ <%= text_field_tag 'value', @feature.get_value, class: 'form-control' %>
34
+ <% elsif @feature.type == :number %>
35
+ <%= number_field_tag 'value', @feature.get_value, class: 'form-control' %>
36
+ <% end %>
37
+ </div>
38
+
39
+ <div class="form-group">
40
+ <div class="btn-group">
41
+ <%= submit_tag 'Update Feature', class: 'btn btn-primary btn-sm' %>
42
+ <a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary btn-sm">Cancel</a>
43
+ </div>
44
+ </div>
45
+ <% end %>
46
+
47
+ <% if @feature.type == :boolean %>
48
+ <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0;">
49
+ <h3 style="margin-bottom: 16px;">Quick Actions</h3>
50
+ <div class="btn-group">
51
+ <%= button_to 'Enable Globally', magick_admin_ui.enable_feature_path(@feature.name), method: :put, class: 'btn btn-success btn-sm' %>
52
+ <%= button_to 'Disable Globally', magick_admin_ui.disable_feature_path(@feature.name), method: :put, class: 'btn btn-danger btn-sm' %>
53
+ </div>
54
+ </div>
55
+ <% end %>
56
+
57
+ <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0;">
58
+ <h3 style="margin-bottom: 16px;">Targeting Rules</h3>
59
+ <%= form_with url: magick_admin_ui.update_targeting_feature_path(@feature.name), method: :put, local: true do |f| %>
60
+ <% targeting = @feature.instance_variable_get(:@targeting) || {} %>
61
+ <% current_roles = targeting[:role].is_a?(Array) ? targeting[:role] : (targeting[:role] ? [targeting[:role]] : []) %>
62
+ <% roles_list = available_roles %>
63
+
64
+ <% if roles_list.any? %>
65
+ <div class="form-group">
66
+ <label>Enable for Roles</label>
67
+ <div style="display: flex; flex-direction: column; gap: 8px;">
68
+ <% roles_list.each do |role| %>
69
+ <label style="display: inline-flex; align-items: center; cursor: pointer;">
70
+ <%= check_box_tag 'targeting[roles][]', role, current_roles.include?(role.to_s), id: "role_#{role}" %>
71
+ <span style="margin-left: 8px;"><%= role.to_s.humanize %></span>
72
+ </label>
73
+ <% end %>
74
+ </div>
75
+ <small style="color: #999;">Select roles that should have access to this feature</small>
76
+ </div>
77
+ <% else %>
78
+ <div class="alert alert-info">
79
+ <p>No roles configured. Add roles via DSL:</p>
80
+ <code>Magick::AdminUI.configure { |config| config.available_roles = ['admin', 'user', 'manager'] }</code>
81
+ </div>
82
+ <% end %>
83
+
84
+ <div class="form-group">
85
+ <label>Enable for User IDs</label>
86
+ <div class="user-ids-input-container">
87
+ <div class="user-ids-tags" id="user_ids_tags">
88
+ <% current_user_ids = targeting[:user].is_a?(Array) ? targeting[:user] : (targeting[:user] ? [targeting[:user]] : []) %>
89
+ <% current_user_ids.each do |user_id| %>
90
+ <span class="user-id-tag">
91
+ <%= user_id %>
92
+ <span class="tag-remove" data-user-id="<%= user_id %>">×</span>
93
+ </span>
94
+ <% end %>
95
+ </div>
96
+ <input type="text" id="user_ids_input" class="form-control user-ids-input" placeholder="Type user ID and press Enter" autocomplete="off">
97
+ <%= hidden_field_tag 'targeting[user_ids]', current_user_ids.join(','), id: 'user_ids_hidden' %>
98
+ </div>
99
+ <small style="color: #999;">Type a user ID and press Enter to add. Click × to remove.</small>
100
+ </div>
101
+
102
+ <div class="form-group">
103
+ <label>Percentage of Users</label>
104
+ <% current_percentage_users = targeting[:percentage_users] || '' %>
105
+ <%= number_field_tag 'targeting[percentage_users]', current_percentage_users, min: 0, max: 100, step: 0.1, placeholder: '0-100', class: 'form-control' %>
106
+ <small style="color: #999;">Enable for a consistent percentage of users (0-100). Leave empty to disable.</small>
107
+ </div>
108
+
109
+ <div class="form-group">
110
+ <label>Percentage of Requests</label>
111
+ <% current_percentage_requests = targeting[:percentage_requests] || '' %>
112
+ <%= number_field_tag 'targeting[percentage_requests]', current_percentage_requests, min: 0, max: 100, step: 0.1, placeholder: '0-100', class: 'form-control' %>
113
+ <small style="color: #999;">Enable for a random percentage of requests (0-100). Leave empty to disable.</small>
114
+ </div>
115
+
116
+ <div class="form-group">
117
+ <div class="btn-group">
118
+ <%= submit_tag 'Update Targeting', class: 'btn btn-primary btn-sm' %>
119
+ <a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary btn-sm">Cancel</a>
120
+ </div>
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+ </div>
@@ -0,0 +1,79 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h2>Feature Flags</h2>
4
+ <div>
5
+ <span class="badge badge-info"><%= @features.size %> features</span>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @features.empty? %>
10
+ <div class="empty-state">
11
+ <h3>No features found</h3>
12
+ <p>Register features in your application to see them here.</p>
13
+ </div>
14
+ <% else %>
15
+ <table>
16
+ <thead>
17
+ <tr>
18
+ <th>Name</th>
19
+ <th>Type</th>
20
+ <th>Status</th>
21
+ <th>Value</th>
22
+ <th>Description</th>
23
+ <th>Actions</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% @features.each do |feature| %>
28
+ <tr>
29
+ <td data-label="Name">
30
+ <strong><%= feature.display_name || feature.name %></strong>
31
+ <br>
32
+ <small style="color: #999;"><%= feature.name %></small>
33
+ </td>
34
+ <td data-label="Type">
35
+ <span class="badge badge-info">
36
+ <%= feature.type.to_s.capitalize %>
37
+ </span>
38
+ </td>
39
+ <td data-label="Status">
40
+ <% case feature.status.to_sym %>
41
+ <% when :active %>
42
+ <span class="badge badge-success">Active</span>
43
+ <% when :deprecated %>
44
+ <span class="badge badge-warning">Deprecated</span>
45
+ <% when :inactive %>
46
+ <span class="badge badge-danger">Inactive</span>
47
+ <% else %>
48
+ <span class="badge"><%= feature.status.to_s.capitalize %></span>
49
+ <% end %>
50
+ </td>
51
+ <td data-label="Value">
52
+ <% if feature.type == :boolean %>
53
+ <% targeting = feature.instance_variable_get(:@targeting) || {} %>
54
+ <% if targeting.any? %>
55
+ <span class="badge badge-warning">Partially Enabled</span>
56
+ <% elsif feature.enabled? %>
57
+ <span class="badge badge-success">Enabled</span>
58
+ <% else %>
59
+ <span class="badge badge-danger">Disabled</span>
60
+ <% end %>
61
+ <% else %>
62
+ <code><%= feature.get_value.inspect %></code>
63
+ <% end %>
64
+ </td>
65
+ <td data-label="Description">
66
+ <%= feature.description || '<em>No description</em>'.html_safe %>
67
+ </td>
68
+ <td data-label="Actions">
69
+ <div class="btn-group">
70
+ <a href="<%= magick_admin_ui.feature_path(feature.name) %>" class="btn btn-primary btn-sm">View</a>
71
+ <a href="<%= magick_admin_ui.edit_feature_path(feature.name) %>" class="btn btn-secondary btn-sm">Edit</a>
72
+ </div>
73
+ </td>
74
+ </tr>
75
+ <% end %>
76
+ </tbody>
77
+ </table>
78
+ <% end %>
79
+ </div>
@@ -0,0 +1,118 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h2><%= @feature.display_name || @feature.name %></h2>
4
+ <div class="btn-group">
5
+ <a href="<%= magick_admin_ui.features_path %>" class="btn btn-secondary btn-sm">← Back to List</a>
6
+ <a href="<%= magick_admin_ui.edit_feature_path(@feature.name) %>" class="btn btn-primary btn-sm">Edit</a>
7
+ </div>
8
+ </div>
9
+
10
+ <div class="feature-details">
11
+ <div class="detail-item">
12
+ <div class="detail-label">Name</div>
13
+ <div class="detail-value"><%= @feature.name %></div>
14
+ </div>
15
+ <div class="detail-item">
16
+ <div class="detail-label">Type</div>
17
+ <div class="detail-value">
18
+ <span class="badge badge-info"><%= @feature.type.to_s.capitalize %></span>
19
+ </div>
20
+ </div>
21
+ <div class="detail-item">
22
+ <div class="detail-label">Status</div>
23
+ <div class="detail-value">
24
+ <% case @feature.status.to_sym %>
25
+ <% when :active %>
26
+ <span class="badge badge-success">Active</span>
27
+ <% when :deprecated %>
28
+ <span class="badge badge-warning">Deprecated</span>
29
+ <% when :inactive %>
30
+ <span class="badge badge-danger">Inactive</span>
31
+ <% else %>
32
+ <span class="badge"><%= @feature.status.to_s.capitalize %></span>
33
+ <% end %>
34
+ </div>
35
+ </div>
36
+ <div class="detail-item">
37
+ <div class="detail-label">Current Value</div>
38
+ <div class="detail-value">
39
+ <% if @feature.type == :boolean %>
40
+ <% targeting = @feature.instance_variable_get(:@targeting) || {} %>
41
+ <% if targeting.any? %>
42
+ <span class="badge badge-warning">Partially Enabled</span>
43
+ <small style="display: block; color: #999; margin-top: 4px;">Targeting rules are active</small>
44
+ <% elsif @feature.enabled? %>
45
+ <span class="badge badge-success">Enabled</span>
46
+ <% else %>
47
+ <span class="badge badge-danger">Disabled</span>
48
+ <% end %>
49
+ <% else %>
50
+ <code><%= @feature.get_value.inspect %></code>
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+ <div class="detail-item">
55
+ <div class="detail-label">Default Value</div>
56
+ <div class="detail-value"><code><%= @feature.default_value.inspect %></code></div>
57
+ </div>
58
+ </div>
59
+
60
+ <% if @feature.description %>
61
+ <div class="card" style="margin-top: 20px;">
62
+ <h3 style="margin-bottom: 10px;">Description</h3>
63
+ <p><%= @feature.description %></p>
64
+ </div>
65
+ <% end %>
66
+
67
+ <% targeting = @feature.instance_variable_get(:@targeting) || {} %>
68
+ <div class="card targeting-rules" style="margin-top: 20px;">
69
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px;">
70
+ <h3 style="margin: 0;">Targeting Rules</h3>
71
+ <a href="<%= magick_admin_ui.edit_feature_path(@feature.name) %>" class="btn btn-secondary btn-sm">Manage Targeting</a>
72
+ </div>
73
+ <% if targeting.any? %>
74
+ <ul>
75
+ <% targeting.each do |key, value| %>
76
+ <li>
77
+ <strong><%= key.to_s.humanize.gsub('Percentage ', '') %>:</strong>
78
+ <% if key.to_s == 'percentage_users' || key.to_s == 'percentage_requests' %>
79
+ <span class="badge badge-info" style="margin-left: 4px;"><%= value.to_f %>%</span>
80
+ <% elsif value.is_a?(Array) %>
81
+ <% value.each do |v| %>
82
+ <span class="badge badge-info" style="margin-left: 4px;"><%= v %></span>
83
+ <% end %>
84
+ <% else %>
85
+ <span class="badge badge-info" style="margin-left: 4px;"><%= value %></span>
86
+ <% end %>
87
+ </li>
88
+ <% end %>
89
+ </ul>
90
+ <% else %>
91
+ <p style="color: #999; font-style: italic;">No targeting rules set. Feature is enabled/disabled globally.</p>
92
+ <% end %>
93
+ </div>
94
+
95
+ <% dependencies = @feature.dependencies %>
96
+ <% if dependencies.any? %>
97
+ <div class="card" style="margin-top: 20px;">
98
+ <h3 style="margin-bottom: 10px;">Dependencies</h3>
99
+ <ul>
100
+ <% dependencies.each do |dep| %>
101
+ <li><code><%= dep %></code></li>
102
+ <% end %>
103
+ </ul>
104
+ </div>
105
+ <% end %>
106
+
107
+ <div class="btn-group" style="margin-top: 20px;">
108
+ <% if @feature.type == :boolean %>
109
+ <% if @feature.enabled? %>
110
+ <%= button_to 'Disable', magick_admin_ui.disable_feature_path(@feature.name), method: :put, class: 'btn btn-danger btn-sm' %>
111
+ <% else %>
112
+ <%= button_to 'Enable', magick_admin_ui.enable_feature_path(@feature.name), method: :put, class: 'btn btn-success btn-sm' %>
113
+ <% end %>
114
+ <% end %>
115
+ <a href="<%= magick_admin_ui.edit_feature_path(@feature.name) %>" class="btn btn-primary btn-sm">Edit</a>
116
+ <a href="<%= magick_admin_ui.stat_path(@feature.name) %>" class="btn btn-secondary btn-sm">View Statistics</a>
117
+ </div>
118
+ </div>
@@ -0,0 +1,67 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h2>Statistics: <%= @feature.display_name || @feature.name %></h2>
4
+ <div class="btn-group">
5
+ <a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary btn-sm">← Back to Feature</a>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @stats && @stats.any? %>
10
+ <div class="stats-grid">
11
+ <div class="stat-card">
12
+ <div class="stat-value"><%= @stats[:usage_count] || 0 %></div>
13
+ <div class="stat-label">Total Usage</div>
14
+ </div>
15
+ <div class="stat-card">
16
+ <div class="stat-value"><%= sprintf('%.3f', @stats[:average_duration] || 0) %>ms</div>
17
+ <div class="stat-label">Avg Duration</div>
18
+ </div>
19
+ <% if @stats[:average_duration_by_operation] %>
20
+ <% @stats[:average_duration_by_operation].each do |operation, duration| %>
21
+ <div class="stat-card">
22
+ <div class="stat-value"><%= sprintf('%.3f', duration || 0) %>ms</div>
23
+ <div class="stat-label"><%= operation.to_s.humanize %></div>
24
+ </div>
25
+ <% end %>
26
+ <% end %>
27
+ </div>
28
+
29
+ <div class="card" style="margin-top: 20px;">
30
+ <h3 style="margin-bottom: 16px;">Performance Metrics</h3>
31
+ <table>
32
+ <thead>
33
+ <tr>
34
+ <th>Metric</th>
35
+ <th>Value</th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ <tr>
40
+ <td>Usage Count</td>
41
+ <td><strong><%= @stats[:usage_count] || 0 %></strong></td>
42
+ </tr>
43
+ <tr>
44
+ <td>Average Duration</td>
45
+ <td><strong><%= sprintf('%.3f', @stats[:average_duration] || 0) %>ms</strong></td>
46
+ </tr>
47
+ <% if @stats[:average_duration_by_operation] %>
48
+ <% @stats[:average_duration_by_operation].each do |operation, duration| %>
49
+ <tr>
50
+ <td>Average Duration (<%= operation.to_s.humanize %>)</td>
51
+ <td><strong><%= sprintf('%.3f', duration || 0) %>ms</strong></td>
52
+ </tr>
53
+ <% end %>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <% else %>
59
+ <div class="empty-state">
60
+ <h3>No statistics available</h3>
61
+ <p>Performance metrics are not enabled or no data has been collected yet.</p>
62
+ <p style="margin-top: 10px; font-size: 14px; color: #999;">
63
+ Enable performance metrics in your Magick configuration to start collecting statistics.
64
+ </p>
65
+ </div>
66
+ <% end %>
67
+ </div>
@@ -15,6 +15,8 @@ module Magick
15
15
  @primary = primary || :memory # :memory, :redis, or :active_record
16
16
  @subscriber_thread = nil
17
17
  @subscriber = nil
18
+ @last_reload_times = {} # Track last reload time per feature for debouncing
19
+ @reload_mutex = Mutex.new
18
20
  # Only start Pub/Sub subscriber if Redis is available
19
21
  # In memory-only mode, each process has isolated cache (no cross-process invalidation)
20
22
  start_cache_invalidation_subscriber if redis_adapter
@@ -187,6 +189,21 @@ module Magick
187
189
  on.message do |_channel, feature_name|
188
190
  feature_name_str = feature_name.to_s
189
191
 
192
+ # Debounce: only reload if we haven't reloaded this feature in the last 100ms
193
+ # This prevents duplicate reloads from multiple invalidation messages
194
+ should_reload = @reload_mutex.synchronize do
195
+ last_reload = @last_reload_times[feature_name_str]
196
+ now = Time.now.to_f
197
+ if last_reload.nil? || (now - last_reload) > 0.1 # 100ms debounce
198
+ @last_reload_times[feature_name_str] = now
199
+ true
200
+ else
201
+ false
202
+ end
203
+ end
204
+
205
+ next unless should_reload
206
+
190
207
  # Invalidate memory cache for this feature
191
208
  memory_adapter.delete(feature_name_str) if memory_adapter
192
209
 
@@ -196,7 +213,7 @@ module Magick
196
213
  feature = Magick.features[feature_name_str]
197
214
  if feature.respond_to?(:reload)
198
215
  feature.reload
199
- # Log for debugging (only in development)
216
+ # Log for debugging (only in development, and only once per debounce period)
200
217
  if defined?(Rails) && Rails.env.development?
201
218
  Rails.logger.debug "Magick: Reloaded feature '#{feature_name_str}' after cache invalidation"
202
219
  end
@@ -28,17 +28,19 @@ module Magick
28
28
  # Explicitly require controllers early to ensure they're loaded when gem is from RubyGems
29
29
  # This initializer runs before routes are drawn
30
30
  initializer 'magick.admin_ui.require_controllers', before: :load_config_initializers do
31
- controller_path = root.join('app', 'controllers', 'magick', 'adminui', 'features_controller.rb')
31
+ engine_root = Magick::AdminUI::Engine.root
32
+ controller_path = engine_root.join('app', 'controllers', 'magick', 'adminui', 'features_controller.rb')
32
33
  require controller_path.to_s if controller_path.exist?
33
34
 
34
- stats_controller_path = root.join('app', 'controllers', 'magick', 'adminui', 'stats_controller.rb')
35
+ stats_controller_path = engine_root.join('app', 'controllers', 'magick', 'adminui', 'stats_controller.rb')
35
36
  require stats_controller_path.to_s if stats_controller_path.exist?
36
37
  end
37
38
 
38
39
  # Explicitly add app/views to view paths
39
40
  # Rails engines should do this automatically, but we ensure it's configured
40
41
  initializer 'magick.admin_ui.append_view_paths', after: :add_view_paths do |app|
41
- app.paths['app/views'] << root.join('app', 'views').to_s if root.join('app', 'views').exist?
42
+ engine_root = Magick::AdminUI::Engine.root
43
+ app.paths['app/views'] << engine_root.join('app', 'views').to_s if engine_root.join('app', 'views').exist?
42
44
  end
43
45
 
44
46
  # Also ensure view paths are added when ActionController loads
@@ -54,14 +56,15 @@ module Magick
54
56
  config.to_prepare do
55
57
  # Explicitly require controllers first to ensure they're loaded
56
58
  # This is necessary when the gem is loaded from RubyGems
57
- controller_path = root.join('app', 'controllers', 'magick', 'adminui', 'features_controller.rb')
59
+ engine_root = Magick::AdminUI::Engine.root
60
+ controller_path = engine_root.join('app', 'controllers', 'magick', 'adminui', 'features_controller.rb')
58
61
  require controller_path.to_s if controller_path.exist?
59
62
 
60
- stats_controller_path = root.join('app', 'controllers', 'magick', 'adminui', 'stats_controller.rb')
63
+ stats_controller_path = engine_root.join('app', 'controllers', 'magick', 'adminui', 'stats_controller.rb')
61
64
  require stats_controller_path.to_s if stats_controller_path.exist?
62
65
 
63
66
  # Then add view paths
64
- view_path = root.join('app', 'views').to_s
67
+ view_path = engine_root.join('app', 'views').to_s
65
68
  if File.directory?(view_path)
66
69
  if defined?(Magick::AdminUI::FeaturesController)
67
70
  Magick::AdminUI::FeaturesController.append_view_path(view_path)
@@ -563,6 +563,7 @@ module Magick
563
563
 
564
564
  def save_targeting
565
565
  # Save targeting to adapter (this updates memory synchronously, then Redis/AR)
566
+ # The set method already publishes cache invalidation to other processes via Pub/Sub
566
567
  adapter_registry.set(name, 'targeting', targeting)
567
568
 
568
569
  # Update the feature in Magick.features if it's registered
@@ -575,14 +576,10 @@ module Magick
575
576
  # Update local targeting empty cache for performance
576
577
  @_targeting_empty = targeting.empty?
577
578
 
578
- # Explicitly publish cache invalidation to other processes via Pub/Sub
579
- # This ensures other Rails app instances/consoles invalidate their cache and reload
580
- # Note: We don't invalidate local cache here because we just updated it above
581
- # The set method publishes cache invalidation, but we also publish here to ensure
582
- # it happens even if Redis update fails or is async
583
- if adapter_registry.respond_to?(:publish_cache_invalidation)
584
- adapter_registry.publish_cache_invalidation(name)
585
- end
579
+ # Note: We don't need to explicitly publish cache invalidation here because:
580
+ # 1. adapter_registry.set already publishes cache invalidation (synchronously for async Redis updates)
581
+ # 2. Publishing twice causes duplicate reloads in other processes
582
+ # 3. The set method handles both sync and async Redis updates correctly
586
583
  end
587
584
 
588
585
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.9.26'
4
+ VERSION = '0.9.28'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.26
4
+ version: 0.9.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -95,6 +95,13 @@ extra_rdoc_files: []
95
95
  files:
96
96
  - LICENSE
97
97
  - README.md
98
+ - app/controllers/magick/adminui/features_controller.rb
99
+ - app/controllers/magick/adminui/stats_controller.rb
100
+ - app/views/layouts/application.html.erb
101
+ - app/views/magick/adminui/features/edit.html.erb
102
+ - app/views/magick/adminui/features/index.html.erb
103
+ - app/views/magick/adminui/features/show.html.erb
104
+ - app/views/magick/adminui/stats/show.html.erb
98
105
  - lib/generators/magick/active_record/active_record_generator.rb
99
106
  - lib/generators/magick/active_record/templates/create_magick_features.rb
100
107
  - lib/generators/magick/install/install_generator.rb