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.
@@ -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
- load_selected_users if user_selector_enabled? && @feature.users.count <= USER_EDIT_LIMIT
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
- ids = @feature.users.map(&:to_s)
146
- lookup_user_nicknames(ids)
147
- @selected_users = ids.map { |id| { id: id, nickname: user_label(id) } }
148
- @selected_user_lookup_failed = @user_lookup_failed
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
- if params[:users]
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
- redirect_to feature_path(params[:feature_name])
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.10.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