rollout-ui 0.9.3 → 0.10.1

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.
@@ -6,8 +6,9 @@
6
6
  <h2 class="font-semibold text-xl text-gray-500 pt-12"><%= h(@feature.name) %></h2>
7
7
 
8
8
  <div class="w-8 h-1 bg-gray-300 my-10"></div>
9
- <main class="p-6 bg-gray-100 max-w-lg w-full text-sm rounded-sm">
9
+ <main class="p-6 bg-gray-100 max-w-3xl w-full text-sm rounded-sm">
10
10
  <form id="updateFormSubmit" action="<%= h(feature_path(@feature.name)) %>" method="POST">
11
+ <input type="hidden" name="feature_version" value="<%= h(feature_version(@feature)) %>">
11
12
  <input type="hidden" name="last_updated_at" value="<%= h(@feature.data['updated_at']) %>">
12
13
  <div class="mb-5">
13
14
  <label class="block text-gray-500 mb-2" for="description">Description</label>
@@ -29,14 +30,7 @@
29
30
  <input class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" name="percentage" id="percentage" value="<%= h(@feature.percentage) %>" type="number" step="0.1">
30
31
  </div>
31
32
 
32
- <div class="mb-5">
33
- <label class="block text-gray-500 mb-2">Users</label>
34
- <% if @feature.users.count > 150 %>
35
- <div class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-gray-100"><%= h(@feature.users.count) %></div>
36
- <% else %>
37
- <textarea class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" name="users" id="users" value="<%= h(@feature.users.join(', ')) %>" rows="2"><%= h(@feature.users.join(', ')) %></textarea>
38
- <% end %>
39
- </div>
33
+ <%= erb :"features/partials/enrolled_users", layout: false %>
40
34
  </form>
41
35
 
42
36
  <div class="flex items-center justify-end">
@@ -47,6 +41,4 @@
47
41
  </div>
48
42
  </main>
49
43
 
50
- <% history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : [] %>
51
-
52
- <%= erb :"features/partials/event_log", layout: false, locals: { events: history_events } %>
44
+ <%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events } %>
@@ -14,7 +14,7 @@ module Rollout::UI
14
14
  NOT_FOUND = ->(env) { [404, { 'content-type' => 'text/plain', 'x-cascade' => 'pass' }, ['Not Found']] }
15
15
 
16
16
  def initialize
17
- @static = Rack::Static.new(NOT_FOUND, urls: ['/css'], root: PUBLIC_PATH)
17
+ @static = Rack::Static.new(NOT_FOUND, urls: ['/css', '/js'], root: PUBLIC_PATH)
18
18
  end
19
19
 
20
20
  def call(env)
@@ -30,11 +30,23 @@ module Rollout::UI
30
30
  class Dispatcher
31
31
  include Helpers
32
32
 
33
+ class StaleFeature < StandardError
34
+ end
35
+
36
+ class InvalidEnrollment < StandardError
37
+ end
38
+
39
+ STALE_FEATURE_MESSAGE = 'This rollout changed since you opened it. Your update was not saved. Review the latest state and try again.'
40
+
33
41
  VIEWS_PATH = File.expand_path('views', __dir__)
34
42
  TEMPLATE_CACHE = {}
43
+ HISTORY_USER_LOOKUP_LIMIT = 150
35
44
 
36
45
  ROUTES = [
37
46
  [:GET, %r{\A/\z}, :index],
47
+ [:GET, %r{\A/users/search\z}, :search_users],
48
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)/users/search\z}, :search_feature_users],
49
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)/users\z}, :enrolled_users],
38
50
  [:GET, %r{\A/features/new\z}, :new_feature],
39
51
  [:POST, %r{\A/features/new\z}, :create_feature],
40
52
  [:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
@@ -86,6 +98,8 @@ module Rollout::UI
86
98
  end
87
99
  )
88
100
  else
101
+ @history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : []
102
+ load_history_user_nicknames(@history_events)
89
103
  render_view(:'features/index')
90
104
  end
91
105
  end
@@ -99,37 +113,272 @@ module Rollout::UI
99
113
  end
100
114
 
101
115
  def show
116
+ response['cache-control'] = 'no-store'
102
117
  @rollout = config.get(:instance)
103
118
  @feature = @rollout.get(params[:feature_name])
104
119
 
105
120
  if json_request?
106
121
  json(feature_to_hash(@feature))
107
122
  else
123
+ load_selected_users
124
+ @history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : []
125
+ load_history_user_nicknames(@history_events)
108
126
  render_view(:'features/show')
109
127
  end
110
128
  end
111
129
 
130
+ def search_users
131
+ return not_found unless user_selector_enabled?
132
+
133
+ response['cache-control'] = 'no-store'
134
+ query = params[:q]
135
+ unless query.nil? || query.is_a?(String)
136
+ response.status = 400
137
+ return json(error: 'Nickname must be a string.')
138
+ end
139
+ query = query.to_s.strip
140
+ if query.length > USER_SEARCH_MAX_LENGTH
141
+ response.status = 400
142
+ return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
143
+ end
144
+ return json(users: []) if query.length < user_search_min_length
145
+
146
+ begin
147
+ users = config.get(:user_search, query, user_search_limit)
148
+ json(users: users.take(user_search_limit).map { |user| user_to_hash(user) })
149
+ rescue StandardError
150
+ response.status = 503
151
+ json(error: 'Nickname search is temporarily unavailable. Please try again.')
152
+ end
153
+ end
154
+
155
+ def search_feature_users
156
+ return not_found unless user_selector_enabled?
157
+
158
+ response['cache-control'] = 'no-store'
159
+ @rollout = config.get(:instance)
160
+ begin
161
+ @feature = @rollout.get(params[:feature_name])
162
+ rescue StandardError
163
+ return not_found
164
+ end
165
+
166
+ query = params[:q]
167
+ unless query.nil? || query.is_a?(String)
168
+ response.status = 400
169
+ return json(error: 'Nickname must be a string.')
170
+ end
171
+ query = query.to_s.strip
172
+ if query.length > USER_SEARCH_MAX_LENGTH
173
+ response.status = 400
174
+ return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
175
+ end
176
+ return json(users: [], feature_version: feature_version(@feature)) if query.length < user_search_min_length
177
+
178
+ enrolled_ids = @feature.users.map(&:to_s)
179
+ begin
180
+ users = config.get(:user_search, query, user_search_limit)
181
+ json(
182
+ users: users.take(user_search_limit).map do |user|
183
+ hashed = user_to_hash(user)
184
+ hashed.merge(enrolled: enrolled_ids.include?(hashed[:id]))
185
+ end,
186
+ feature_version: feature_version(@feature),
187
+ )
188
+ rescue StandardError
189
+ response.status = 503
190
+ json(error: 'Nickname search is temporarily unavailable. Please try again.')
191
+ end
192
+ end
193
+
194
+ def enrolled_users
195
+ response['cache-control'] = 'no-store'
196
+ @rollout ||= config.get(:instance)
197
+ begin
198
+ @feature = @rollout.get(params[:feature_name])
199
+ rescue StandardError
200
+ return not_found
201
+ end
202
+ load_selected_users
203
+
204
+ json(
205
+ users: @selected_users,
206
+ total: @users_total_count,
207
+ page: @user_page,
208
+ per_page: @users_per_page,
209
+ total_pages: @users_total_pages,
210
+ feature_version: feature_version(@feature),
211
+ lookup_failed: @selected_user_lookup_failed || false,
212
+ )
213
+ end
214
+
215
+ def load_selected_users
216
+ @users_per_page = USER_PAGE_SIZE
217
+ all_ids = @feature.users.map { |id| id.to_s }.uniq.sort
218
+ @users_total_count = all_ids.size
219
+ @users_total_pages = [ (all_ids.size.to_f / @users_per_page).ceil, 1 ].max
220
+ @user_page = parse_users_page(params[:page], @users_total_pages)
221
+ offset = (@user_page - 1) * @users_per_page
222
+ page_ids = all_ids[offset, @users_per_page] || []
223
+ if config.defined?(:user_lookup)
224
+ lookup_user_nicknames(page_ids)
225
+ end
226
+ @selected_users = page_ids.map { |id| { id: id, nickname: user_label(id, include_id: false) } }
227
+ @selected_user_lookup_failed = @user_lookup_failed == true
228
+ end
229
+
230
+ def parse_users_page(value, total_pages)
231
+ candidate = 1
232
+ unless value.is_a?(Array) || value.is_a?(Hash)
233
+ raw = value.to_s.strip
234
+ candidate = raw.to_i if raw.match?(/\A\d+\z/)
235
+ end
236
+ candidate = 1 if candidate < 1
237
+ candidate = total_pages if candidate > total_pages
238
+ candidate = 1 if candidate < 1
239
+ candidate
240
+ end
241
+
242
+ def load_history_user_nicknames(events)
243
+ return unless config.defined?(:user_lookup)
244
+
245
+ ids = {}
246
+ events.each do |event|
247
+ next unless event.name.to_s == 'update'
248
+
249
+ [:before, :after].each do |side|
250
+ changes = event.data.fetch(side)
251
+ Array(changes[:users] || changes['users']).each do |id|
252
+ id = id.to_s
253
+ next if @user_lookup_attempted&.key?(id)
254
+
255
+ ids[id] = true
256
+ return lookup_user_nicknames(ids.keys) if ids.size >= HISTORY_USER_LOOKUP_LIMIT
257
+ end
258
+ end
259
+ end
260
+ lookup_user_nicknames(ids.keys)
261
+ end
262
+
263
+ def lookup_user_nicknames(ids)
264
+ return unless config.defined?(:user_lookup)
265
+
266
+ @user_nicknames ||= {}
267
+ @user_lookup_attempted ||= {}
268
+ missing_ids = ids.uniq.reject { |id| @user_lookup_attempted.key?(id) }
269
+ return if missing_ids.empty?
270
+
271
+ missing_ids.each { |id| @user_lookup_attempted[id] = true }
272
+ config.get(:user_lookup, missing_ids).each do |user|
273
+ user = user_to_hash(user)
274
+ @user_nicknames[user[:id]] = user[:nickname]
275
+ end
276
+ rescue StandardError
277
+ @user_lookup_failed = true
278
+ end
279
+
280
+ def user_to_hash(user)
281
+ # Only expose the fields needed by the selector, even if a callback
282
+ # returns additional application-specific attributes.
283
+ id = user[:id] || user['id']
284
+ nickname = user[:nickname] || user['nickname']
285
+ raise ArgumentError, 'Users must have an id and nickname' if id.nil? || nickname.to_s.empty?
286
+
287
+ { id: id.to_s, nickname: nickname.to_s }
288
+ end
289
+
112
290
  def update
291
+ enrollment = parse_enrollment!
113
292
  rollout = config.get(:instance)
114
293
  actor = config.get(:actor, scope: self)
115
- feature_data = rollout.get(params[:feature_name]).data
116
- if feature_data['updated_at'] && params[:last_updated_at].to_s != feature_data['updated_at'].to_s
117
- return redirect_to("#{feature_path(params[:feature_name])}?error=Rollout version outdated. Review changes below and try again.")
118
- end
119
294
 
120
295
  with_rollout_context(rollout, actor: actor) do
121
296
  rollout.with_feature(params[:feature_name]) do |feature|
297
+ validate_feature_version!(feature, enrollment)
122
298
  feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
123
299
  feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
124
- if params[:users]
125
- feature.users = params[:users].split(',').map(&:strip).uniq.sort
126
- end
300
+ apply_enrollment!(feature, enrollment)
127
301
  feature.data["description"] = params[:description]
128
302
  feature.data["updated_at"] = Time.now.to_i
129
303
  end
130
304
  end
131
305
 
132
- redirect_to feature_path(params[:feature_name])
306
+ redirect_location = feature_path(params[:feature_name])
307
+ if params.key?(:page)
308
+ after_feature = config.get(:instance).get(params[:feature_name])
309
+ total_pages = [ (after_feature.users.map(&:to_s).uniq.size.to_f / USER_PAGE_SIZE).ceil, 1 ].max
310
+ page = parse_users_page(params[:page], total_pages)
311
+ redirect_location = "#{feature_path(params[:feature_name])}?#{Rack::Utils.build_query('page' => page)}"
312
+ end
313
+ redirect_to redirect_location
314
+ rescue StaleFeature => error
315
+ query = Rack::Utils.build_query(error: error.message)
316
+ redirect_to("#{feature_path(params[:feature_name])}?#{query}")
317
+ rescue InvalidEnrollment
318
+ bad_request(StandardError.new('invalid enrollment update'))
319
+ end
320
+
321
+ # Delta mode is only the scalar marker "1" plus a real fingerprint.
322
+ # Any other present marker is rejected so it cannot fall through to a
323
+ # legacy full-list replacement. Legacy replacement runs only when the
324
+ # marker is absent, and only for a scalar string.
325
+ def parse_enrollment!
326
+ if params.key?(:user_changes)
327
+ raise InvalidEnrollment unless params[:user_changes] == '1'
328
+ raise InvalidEnrollment unless params[:feature_version].is_a?(String) && !params[:feature_version].empty?
329
+ additions = parse_delta_ids(params[:users_to_add])
330
+ removals = parse_delta_ids(params[:users_to_remove])
331
+ raise InvalidEnrollment if additions.nil? || removals.nil?
332
+ return [:delta, additions, removals]
333
+ end
334
+
335
+ return nil unless params.key?(:users)
336
+
337
+ users = params[:users]
338
+ return nil if users.nil?
339
+ raise InvalidEnrollment unless users.is_a?(String)
340
+ [:legacy, users]
341
+ end
342
+
343
+ def apply_enrollment!(feature, enrollment)
344
+ return if enrollment.nil?
345
+
346
+ kind, *payload = enrollment
347
+ if kind == :delta
348
+ additions, removals = payload
349
+ current = feature.users.map { |id| id.to_s }.uniq
350
+ feature.users = (current + additions - removals).uniq.sort
351
+ else
352
+ feature.users = payload.fetch(0).split(',').map(&:strip).reject(&:empty?).uniq.sort
353
+ end
354
+ end
355
+
356
+ def parse_delta_ids(value)
357
+ return [] if value.nil?
358
+ return nil unless value.is_a?(String) || value.is_a?(Array)
359
+ list = value.is_a?(Array) ? value : value.to_s.split(',')
360
+ return nil unless list.all? { |entry| entry.is_a?(String) }
361
+ list.map(&:strip).reject(&:empty?)
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ def validate_feature_version!(feature, enrollment = nil)
367
+ if enrollment&.first == :delta
368
+ version = params[:feature_version]
369
+ raise InvalidEnrollment unless version.is_a?(String) && !version.empty?
370
+ if version != feature_version(feature)
371
+ raise StaleFeature, STALE_FEATURE_MESSAGE
372
+ end
373
+ return
374
+ end
375
+
376
+ stale = if params.key?(:feature_version)
377
+ params[:feature_version].to_s != feature_version(feature)
378
+ else
379
+ feature.data['updated_at'] && params[:last_updated_at].to_s != feature.data['updated_at'].to_s
380
+ end
381
+ raise StaleFeature, STALE_FEATURE_MESSAGE if stale
133
382
  end
134
383
 
135
384
  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.9.3
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - FetLife
@@ -227,11 +227,17 @@ 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
233
+ - lib/rollout/ui/public/js/user-selector.js
231
234
  - lib/rollout/ui/version.rb
232
235
  - lib/rollout/ui/views/features/index.erb
233
236
  - lib/rollout/ui/views/features/new.erb
237
+ - lib/rollout/ui/views/features/partials/enrolled_users.erb
234
238
  - lib/rollout/ui/views/features/partials/event_log.erb
239
+ - lib/rollout/ui/views/features/partials/user_pagination.erb
240
+ - lib/rollout/ui/views/features/partials/user_selector.erb
235
241
  - lib/rollout/ui/views/features/show.erb
236
242
  - lib/rollout/ui/views/layout.erb
237
243
  - lib/rollout/ui/web.rb