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 +4 -4
- data/app/controllers/magick/adminui/features_controller.rb +196 -0
- data/app/controllers/magick/adminui/stats_controller.rb +21 -0
- data/app/views/layouts/application.html.erb +533 -0
- data/app/views/magick/adminui/features/edit.html.erb +124 -0
- data/app/views/magick/adminui/features/index.html.erb +79 -0
- data/app/views/magick/adminui/features/show.html.erb +118 -0
- data/app/views/magick/adminui/stats/show.html.erb +67 -0
- data/lib/magick/admin_ui/engine.rb +9 -6
- data/lib/magick/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d9899b591d5ec1be511af30b937a34c676afae3b82cb79bfdc902aadadf9064
|
|
4
|
+
data.tar.gz: 2a0bdd7d7edac034d652a709dafa7e46d24f2674f0e2335bd50fc44755ab0238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
data/lib/magick/version.rb
CHANGED
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.
|
|
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
|