magick-feature-flags 0.9.26 → 0.9.27

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: 2d9899b591d5ec1be511af30b937a34c676afae3b82cb79bfdc902aadadf9064
4
+ data.tar.gz: 2a0bdd7d7edac034d652a709dafa7e46d24f2674f0e2335bd50fc44755ab0238
5
5
  SHA512:
6
- metadata.gz: f854aa3d288dd2bf137fb56e69c58c2092ff1b5d76a8ce2c429f416416b758ab5d99e680efd3c704f084d08dc7b2a4bc2f161bed9431446609de1ebddec40f22
7
- data.tar.gz: 98e43b058ed905b6bed109911e6649b8644c401681e269a6e406f41e49689992bed804488ab81f0ccafe4db7dcbc68ab45a88686a93f0ee10213f35b8a8f1c36
6
+ metadata.gz: 401bfd7caa17aca83411668f7ac024a734131dbd130f24628ee5ef0f34f6b45ac06ca118c0aa903d53ee09f045f081e272bcd09f21820e9c07c16edbdc9f81c3
7
+ data.tar.gz: '08ad203ed9b79dcb39c5e6e6a79fa497332e52bd84482c629275ce43e38d77894ad31b2b0480feb4a27975a4317f16172182c60e73a5169888bf3ae716b6f140'
@@ -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>
@@ -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)
@@ -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.27'
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.27
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