rollout-ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +2 -2
- data/.github/workflows/test.yml +1 -3
- data/.mise.toml +1 -1
- data/.ruby-version +1 -1
- data/README.md +93 -5
- data/lib/rollout/ui/config.rb +1 -0
- data/lib/rollout/ui/helpers.rb +29 -3
- data/lib/rollout/ui/public/css/enrolled-users.css +191 -0
- data/lib/rollout/ui/public/js/enrolled-users.js +377 -0
- data/lib/rollout/ui/public/js/user-selector.js +2 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +16 -1
- data/lib/rollout/ui/views/features/partials/enrolled_users.erb +66 -0
- data/lib/rollout/ui/views/features/partials/user_pagination.erb +7 -0
- data/lib/rollout/ui/views/features/partials/warning_icon.erb +3 -0
- data/lib/rollout/ui/views/features/show.erb +12 -12
- data/lib/rollout/ui/views/layout.erb +7 -0
- data/lib/rollout/ui/web.rb +191 -13
- metadata +6 -1
data/lib/rollout/ui/web.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "erb"
|
|
|
2
2
|
require "json"
|
|
3
3
|
require "rack"
|
|
4
4
|
require "rollout"
|
|
5
|
+
require "set"
|
|
5
6
|
|
|
6
7
|
require "rollout/ui/version"
|
|
7
8
|
require "rollout/ui/config"
|
|
@@ -30,6 +31,14 @@ module Rollout::UI
|
|
|
30
31
|
class Dispatcher
|
|
31
32
|
include Helpers
|
|
32
33
|
|
|
34
|
+
class StaleFeature < StandardError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class InvalidEnrollment < StandardError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
STALE_FEATURE_MESSAGE = 'This rollout changed since you opened it. Your update was not saved. Review the latest state and try again.'
|
|
41
|
+
|
|
33
42
|
VIEWS_PATH = File.expand_path('views', __dir__)
|
|
34
43
|
TEMPLATE_CACHE = {}
|
|
35
44
|
HISTORY_USER_LOOKUP_LIMIT = 150
|
|
@@ -37,6 +46,8 @@ module Rollout::UI
|
|
|
37
46
|
ROUTES = [
|
|
38
47
|
[:GET, %r{\A/\z}, :index],
|
|
39
48
|
[:GET, %r{\A/users/search\z}, :search_users],
|
|
49
|
+
[:GET, %r{\A/features/(?<feature_name>[^/]+)/users/search\z}, :search_feature_users],
|
|
50
|
+
[:GET, %r{\A/features/(?<feature_name>[^/]+)/users\z}, :enrolled_users],
|
|
40
51
|
[:GET, %r{\A/features/new\z}, :new_feature],
|
|
41
52
|
[:POST, %r{\A/features/new\z}, :create_feature],
|
|
42
53
|
[:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
|
|
@@ -88,6 +99,7 @@ module Rollout::UI
|
|
|
88
99
|
end
|
|
89
100
|
)
|
|
90
101
|
else
|
|
102
|
+
load_defined_features
|
|
91
103
|
@history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : []
|
|
92
104
|
load_history_user_nicknames(@history_events)
|
|
93
105
|
render_view(:'features/index')
|
|
@@ -103,19 +115,32 @@ module Rollout::UI
|
|
|
103
115
|
end
|
|
104
116
|
|
|
105
117
|
def show
|
|
118
|
+
response['cache-control'] = 'no-store'
|
|
106
119
|
@rollout = config.get(:instance)
|
|
107
120
|
@feature = @rollout.get(params[:feature_name])
|
|
108
121
|
|
|
109
122
|
if json_request?
|
|
110
123
|
json(feature_to_hash(@feature))
|
|
111
124
|
else
|
|
112
|
-
|
|
125
|
+
load_defined_features
|
|
126
|
+
load_selected_users
|
|
113
127
|
@history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : []
|
|
114
128
|
load_history_user_nicknames(@history_events)
|
|
115
129
|
render_view(:'features/show')
|
|
116
130
|
end
|
|
117
131
|
end
|
|
118
132
|
|
|
133
|
+
def load_defined_features
|
|
134
|
+
names = if config.defined?(:defined_features)
|
|
135
|
+
config.get(:defined_features)
|
|
136
|
+
elsif Rollout.respond_to?(:definitions)
|
|
137
|
+
path = Rollout.definitions_path
|
|
138
|
+
Rollout.definitions.keys if path && File.directory?(path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@defined_features = names.map(&:to_s).to_set unless names.nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
119
144
|
def search_users
|
|
120
145
|
return not_found unless user_selector_enabled?
|
|
121
146
|
|
|
@@ -141,11 +166,91 @@ module Rollout::UI
|
|
|
141
166
|
end
|
|
142
167
|
end
|
|
143
168
|
|
|
169
|
+
def search_feature_users
|
|
170
|
+
return not_found unless user_selector_enabled?
|
|
171
|
+
|
|
172
|
+
response['cache-control'] = 'no-store'
|
|
173
|
+
@rollout = config.get(:instance)
|
|
174
|
+
begin
|
|
175
|
+
@feature = @rollout.get(params[:feature_name])
|
|
176
|
+
rescue StandardError
|
|
177
|
+
return not_found
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
query = params[:q]
|
|
181
|
+
unless query.nil? || query.is_a?(String)
|
|
182
|
+
response.status = 400
|
|
183
|
+
return json(error: 'Nickname must be a string.')
|
|
184
|
+
end
|
|
185
|
+
query = query.to_s.strip
|
|
186
|
+
if query.length > USER_SEARCH_MAX_LENGTH
|
|
187
|
+
response.status = 400
|
|
188
|
+
return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
|
|
189
|
+
end
|
|
190
|
+
return json(users: [], feature_version: feature_version(@feature)) if query.length < user_search_min_length
|
|
191
|
+
|
|
192
|
+
enrolled_ids = @feature.users.map(&:to_s)
|
|
193
|
+
begin
|
|
194
|
+
users = config.get(:user_search, query, user_search_limit)
|
|
195
|
+
json(
|
|
196
|
+
users: users.take(user_search_limit).map do |user|
|
|
197
|
+
hashed = user_to_hash(user)
|
|
198
|
+
hashed.merge(enrolled: enrolled_ids.include?(hashed[:id]))
|
|
199
|
+
end,
|
|
200
|
+
feature_version: feature_version(@feature),
|
|
201
|
+
)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
response.status = 503
|
|
204
|
+
json(error: 'Nickname search is temporarily unavailable. Please try again.')
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def enrolled_users
|
|
209
|
+
response['cache-control'] = 'no-store'
|
|
210
|
+
@rollout ||= config.get(:instance)
|
|
211
|
+
begin
|
|
212
|
+
@feature = @rollout.get(params[:feature_name])
|
|
213
|
+
rescue StandardError
|
|
214
|
+
return not_found
|
|
215
|
+
end
|
|
216
|
+
load_selected_users
|
|
217
|
+
|
|
218
|
+
json(
|
|
219
|
+
users: @selected_users,
|
|
220
|
+
total: @users_total_count,
|
|
221
|
+
page: @user_page,
|
|
222
|
+
per_page: @users_per_page,
|
|
223
|
+
total_pages: @users_total_pages,
|
|
224
|
+
feature_version: feature_version(@feature),
|
|
225
|
+
lookup_failed: @selected_user_lookup_failed || false,
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
144
229
|
def load_selected_users
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@
|
|
148
|
-
@
|
|
230
|
+
@users_per_page = USER_PAGE_SIZE
|
|
231
|
+
all_ids = @feature.users.map { |id| id.to_s }.uniq.sort
|
|
232
|
+
@users_total_count = all_ids.size
|
|
233
|
+
@users_total_pages = [ (all_ids.size.to_f / @users_per_page).ceil, 1 ].max
|
|
234
|
+
@user_page = parse_users_page(params[:page], @users_total_pages)
|
|
235
|
+
offset = (@user_page - 1) * @users_per_page
|
|
236
|
+
page_ids = all_ids[offset, @users_per_page] || []
|
|
237
|
+
if config.defined?(:user_lookup)
|
|
238
|
+
lookup_user_nicknames(page_ids)
|
|
239
|
+
end
|
|
240
|
+
@selected_users = page_ids.map { |id| { id: id, nickname: user_label(id, include_id: false) } }
|
|
241
|
+
@selected_user_lookup_failed = @user_lookup_failed == true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_users_page(value, total_pages)
|
|
245
|
+
candidate = 1
|
|
246
|
+
unless value.is_a?(Array) || value.is_a?(Hash)
|
|
247
|
+
raw = value.to_s.strip
|
|
248
|
+
candidate = raw.to_i if raw.match?(/\A\d+\z/)
|
|
249
|
+
end
|
|
250
|
+
candidate = 1 if candidate < 1
|
|
251
|
+
candidate = total_pages if candidate > total_pages
|
|
252
|
+
candidate = 1 if candidate < 1
|
|
253
|
+
candidate
|
|
149
254
|
end
|
|
150
255
|
|
|
151
256
|
def load_history_user_nicknames(events)
|
|
@@ -170,6 +275,8 @@ module Rollout::UI
|
|
|
170
275
|
end
|
|
171
276
|
|
|
172
277
|
def lookup_user_nicknames(ids)
|
|
278
|
+
return unless config.defined?(:user_lookup)
|
|
279
|
+
|
|
173
280
|
@user_nicknames ||= {}
|
|
174
281
|
@user_lookup_attempted ||= {}
|
|
175
282
|
missing_ids = ids.uniq.reject { |id| @user_lookup_attempted.key?(id) }
|
|
@@ -195,26 +302,97 @@ module Rollout::UI
|
|
|
195
302
|
end
|
|
196
303
|
|
|
197
304
|
def update
|
|
305
|
+
enrollment = parse_enrollment!
|
|
198
306
|
rollout = config.get(:instance)
|
|
199
307
|
actor = config.get(:actor, scope: self)
|
|
200
|
-
feature_data = rollout.get(params[:feature_name]).data
|
|
201
|
-
if feature_data['updated_at'] && params[:last_updated_at].to_s != feature_data['updated_at'].to_s
|
|
202
|
-
return redirect_to("#{feature_path(params[:feature_name])}?error=Rollout version outdated. Review changes below and try again.")
|
|
203
|
-
end
|
|
204
308
|
|
|
205
309
|
with_rollout_context(rollout, actor: actor) do
|
|
206
310
|
rollout.with_feature(params[:feature_name]) do |feature|
|
|
311
|
+
validate_feature_version!(feature, enrollment)
|
|
207
312
|
feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
|
|
208
313
|
feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
|
|
209
|
-
|
|
210
|
-
feature.users = params[:users].split(',').map(&:strip).reject(&:empty?).uniq.sort
|
|
211
|
-
end
|
|
314
|
+
apply_enrollment!(feature, enrollment)
|
|
212
315
|
feature.data["description"] = params[:description]
|
|
213
316
|
feature.data["updated_at"] = Time.now.to_i
|
|
214
317
|
end
|
|
215
318
|
end
|
|
216
319
|
|
|
217
|
-
|
|
320
|
+
redirect_location = feature_path(params[:feature_name])
|
|
321
|
+
if params.key?(:page)
|
|
322
|
+
after_feature = config.get(:instance).get(params[:feature_name])
|
|
323
|
+
total_pages = [ (after_feature.users.map(&:to_s).uniq.size.to_f / USER_PAGE_SIZE).ceil, 1 ].max
|
|
324
|
+
page = parse_users_page(params[:page], total_pages)
|
|
325
|
+
redirect_location = "#{feature_path(params[:feature_name])}?#{Rack::Utils.build_query('page' => page)}"
|
|
326
|
+
end
|
|
327
|
+
redirect_to redirect_location
|
|
328
|
+
rescue StaleFeature => error
|
|
329
|
+
query = Rack::Utils.build_query(error: error.message)
|
|
330
|
+
redirect_to("#{feature_path(params[:feature_name])}?#{query}")
|
|
331
|
+
rescue InvalidEnrollment
|
|
332
|
+
bad_request(StandardError.new('invalid enrollment update'))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Delta mode is only the scalar marker "1" plus a real fingerprint.
|
|
336
|
+
# Any other present marker is rejected so it cannot fall through to a
|
|
337
|
+
# legacy full-list replacement. Legacy replacement runs only when the
|
|
338
|
+
# marker is absent, and only for a scalar string.
|
|
339
|
+
def parse_enrollment!
|
|
340
|
+
if params.key?(:user_changes)
|
|
341
|
+
raise InvalidEnrollment unless params[:user_changes] == '1'
|
|
342
|
+
raise InvalidEnrollment unless params[:feature_version].is_a?(String) && !params[:feature_version].empty?
|
|
343
|
+
additions = parse_delta_ids(params[:users_to_add])
|
|
344
|
+
removals = parse_delta_ids(params[:users_to_remove])
|
|
345
|
+
raise InvalidEnrollment if additions.nil? || removals.nil?
|
|
346
|
+
return [:delta, additions, removals]
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
return nil unless params.key?(:users)
|
|
350
|
+
|
|
351
|
+
users = params[:users]
|
|
352
|
+
return nil if users.nil?
|
|
353
|
+
raise InvalidEnrollment unless users.is_a?(String)
|
|
354
|
+
[:legacy, users]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def apply_enrollment!(feature, enrollment)
|
|
358
|
+
return if enrollment.nil?
|
|
359
|
+
|
|
360
|
+
kind, *payload = enrollment
|
|
361
|
+
if kind == :delta
|
|
362
|
+
additions, removals = payload
|
|
363
|
+
current = feature.users.map { |id| id.to_s }.uniq
|
|
364
|
+
feature.users = (current + additions - removals).uniq.sort
|
|
365
|
+
else
|
|
366
|
+
feature.users = payload.fetch(0).split(',').map(&:strip).reject(&:empty?).uniq.sort
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def parse_delta_ids(value)
|
|
371
|
+
return [] if value.nil?
|
|
372
|
+
return nil unless value.is_a?(String) || value.is_a?(Array)
|
|
373
|
+
list = value.is_a?(Array) ? value : value.to_s.split(',')
|
|
374
|
+
return nil unless list.all? { |entry| entry.is_a?(String) }
|
|
375
|
+
list.map(&:strip).reject(&:empty?)
|
|
376
|
+
rescue StandardError
|
|
377
|
+
nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def validate_feature_version!(feature, enrollment = nil)
|
|
381
|
+
if enrollment&.first == :delta
|
|
382
|
+
version = params[:feature_version]
|
|
383
|
+
raise InvalidEnrollment unless version.is_a?(String) && !version.empty?
|
|
384
|
+
if version != feature_version(feature)
|
|
385
|
+
raise StaleFeature, STALE_FEATURE_MESSAGE
|
|
386
|
+
end
|
|
387
|
+
return
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
stale = if params.key?(:feature_version)
|
|
391
|
+
params[:feature_version].to_s != feature_version(feature)
|
|
392
|
+
else
|
|
393
|
+
feature.data['updated_at'] && params[:last_updated_at].to_s != feature.data['updated_at'].to_s
|
|
394
|
+
end
|
|
395
|
+
raise StaleFeature, STALE_FEATURE_MESSAGE if stale
|
|
218
396
|
end
|
|
219
397
|
|
|
220
398
|
def activate_percentage
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rollout-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- FetLife
|
|
@@ -227,13 +227,18 @@ files:
|
|
|
227
227
|
- lib/rollout/ui.rb
|
|
228
228
|
- lib/rollout/ui/config.rb
|
|
229
229
|
- lib/rollout/ui/helpers.rb
|
|
230
|
+
- lib/rollout/ui/public/css/enrolled-users.css
|
|
230
231
|
- lib/rollout/ui/public/css/tailwind.min.css
|
|
232
|
+
- lib/rollout/ui/public/js/enrolled-users.js
|
|
231
233
|
- lib/rollout/ui/public/js/user-selector.js
|
|
232
234
|
- lib/rollout/ui/version.rb
|
|
233
235
|
- lib/rollout/ui/views/features/index.erb
|
|
234
236
|
- lib/rollout/ui/views/features/new.erb
|
|
237
|
+
- lib/rollout/ui/views/features/partials/enrolled_users.erb
|
|
235
238
|
- lib/rollout/ui/views/features/partials/event_log.erb
|
|
239
|
+
- lib/rollout/ui/views/features/partials/user_pagination.erb
|
|
236
240
|
- lib/rollout/ui/views/features/partials/user_selector.erb
|
|
241
|
+
- lib/rollout/ui/views/features/partials/warning_icon.erb
|
|
237
242
|
- lib/rollout/ui/views/features/show.erb
|
|
238
243
|
- lib/rollout/ui/views/layout.erb
|
|
239
244
|
- lib/rollout/ui/web.rb
|