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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +3 -2
- data/.github/workflows/test.yml +2 -3
- data/README.md +151 -30
- data/lib/rollout/ui/config.rb +7 -1
- data/lib/rollout/ui/helpers.rb +69 -5
- 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 +174 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +1 -3
- 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/user_selector.erb +15 -0
- data/lib/rollout/ui/views/features/show.erb +4 -12
- data/lib/rollout/ui/web.rb +258 -9
- metadata +7 -1
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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 } %>
|
data/lib/rollout/ui/web.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|