rollout-ui 0.9.2 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5230ac819ea0e86c7dc2606f9b308c429b6b92b50e8fb402ac3b4f5519b8207
4
- data.tar.gz: 67f902bcffbc98e99189b16c577c66952bde58b0dfff550318cadcb0779f9bc3
3
+ metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
4
+ data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
5
5
  SHA512:
6
- metadata.gz: af0154118022337d0963bb63013498f2292fecfe29dfd318729335fd724c4e4569e2c7a3f6662c19ce0c9ba729fd9a0149005963cbc531c3ae800b55077a8fa8
7
- data.tar.gz: 85d005cfbf60f08c96032e3a4b9c607aca322f013050845489974c21a19a88259d68a98d011774e75c4c7d2e6f692969c3ee7daadffb6542a31bc2d144f49409
6
+ metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
7
+ data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
data/README.md CHANGED
@@ -1,14 +1,12 @@
1
1
  # Rollout::UI
2
2
 
3
- Minimalist UI for the [rollout](https://github.com/fetlife/rollout) gem that
4
- you can just mount as a Rack app and it will just work.
3
+ A Rack-mountable UI for the [rollout](https://github.com/fetlife/rollout) gem.
5
4
 
6
5
  ![Index Page](./screenshot_index.png)
7
6
  <!-- ![Feature Page](./screenshot_show.png) -->
8
7
 
9
8
  ## Usage with Rails
10
9
 
11
-
12
10
  Add it to your application's Gemfile:
13
11
 
14
12
  ```ruby
@@ -17,20 +15,20 @@ gem "rollout-redis-adapter", "~> 0.1"
17
15
  gem "rollout-ui"
18
16
  ```
19
17
 
20
- `rollout-ui` 0.9+ requires Rollout 3.1 and does not support Rollout 2.x. The UI is backend-neutral: applications using Redis should add `rollout-redis-adapter` and pass a configured Rollout instance into the UI.
18
+ `rollout-ui` 0.9+ requires Rollout 3.1 or later within 3.x. It works with any
19
+ Rollout adapter; Redis applications also need `rollout-redis-adapter`.
21
20
 
22
- Mount it
21
+ Mount the Rack app:
23
22
 
24
23
  ```ruby
25
24
  Rails.application.routes.draw do
26
- mount Rollout::UI::Web.new => '/admin/rollout'
25
+ mount Rollout::UI::Web.new => "/admin/rollout"
27
26
 
28
27
  # ...
29
28
  end
30
29
  ```
31
30
 
32
- And to configure it with your `Rollout` instance, you can put your configuration
33
- in `routes.rb` or in a standalone initializer.
31
+ Configure your Rollout instance in `routes.rb` or an initializer:
34
32
 
35
33
  ```ruby
36
34
  Rollout::UI.configure do
@@ -40,12 +38,10 @@ end
40
38
 
41
39
  ## Authentication
42
40
 
43
- If you are using Rails, you can put `constraints` on your mount.
44
-
45
- So in case of usafe with Devise, your constraints might look like:
41
+ Protect the Rails mount with a constraint. For example, with Devise:
46
42
 
47
43
  ```ruby
48
- module Constraint::Admin
44
+ class AdminConstraint
49
45
  def self.matches?(request)
50
46
  id = request.session["warden.user.user.key"].try(:[], 0).try(:[], 0)
51
47
  return false if id.blank?
@@ -56,25 +52,68 @@ module Constraint::Admin
56
52
  end
57
53
 
58
54
  Rails.application.routes.draw do
59
- mount Rollout::UI::Web.new => '/admin/rollout', constraints: Constraints::Admin
55
+ mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
60
56
 
61
57
  # ...
62
58
  end
63
59
  ```
64
60
 
65
- ## API Endpoints
61
+ ## Nickname user selector
62
+
63
+ Configure **both** callbacks below to replace the user ID field with a searchable
64
+ multi-select. Your application supplies the queries; the UI displays nicknames
65
+ and Rollout stores user IDs. This example uses Rails and PostgreSQL:
66
+
67
+ ```ruby
68
+ Rollout::UI.configure do
69
+ instance { $rollout }
70
+
71
+ user_search do |query, limit|
72
+ prefix = User.sanitize_sql_like(query.downcase) + "%"
73
+ User.where("lower(nickname) LIKE ?", prefix)
74
+ .limit(limit)
75
+ .pluck(:id, :nickname)
76
+ .map { |id, nickname| { id: id.to_s, nickname: nickname } }
77
+ end
78
+
79
+ user_lookup do |ids|
80
+ User.where(id: ids)
81
+ .pluck(:id, :nickname)
82
+ .map { |id, nickname| { id: id.to_s, nickname: nickname } }
83
+ end
84
+ end
85
+ ```
86
+
87
+ Both callbacks return arrays of hashes with `id` and `nickname` keys (symbols or
88
+ strings). `user_search` receives a trimmed nickname query and a result limit.
89
+ `user_lookup` receives a batch of selected or historical user IDs as strings;
90
+ omit missing accounts. Apply your account visibility rules in both callbacks.
66
91
 
67
- The index and show routes can also respond with JSON data instead of HTML when the request's `Accept` header is
68
- `application/json`
92
+ Search defaults to **3 characters** and **20 results**, with a 300 ms debounce.
93
+ Override these settings inside the same configuration block:
69
94
 
70
- The index route also accepts query parameters to filter by user or group:
71
- `/admin/rollout?user=someone`
72
- `/admin/rollout?group=developers`
95
+ ```ruby
96
+ user_search_min_length { 3 }
97
+ user_search_limit { 20 }
98
+ ```
99
+
100
+ For large directories, use **indexed nickname-only prefix matching** and apply
101
+ the limit in the database. Escape wildcards as above; avoid loading all users or
102
+ running unindexed `%contains%` queries. A matching index for this example is:
103
+
104
+ ```sql
105
+ CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
106
+ ON users (lower(nickname) text_pattern_ops);
107
+ ```
108
+
109
+ Check the query plan against your schema and collation. The selector supports
110
+ keyboard navigation and preserves selections if a lookup fails. Without
111
+ JavaScript, the ID field remains usable. Features with more than 150 selected
112
+ users show only a count.
73
113
 
74
114
  ## Logging
75
115
 
76
- To get the most out of **rollout-ui**, we recommend you to turn on logging
77
- on your rollout instance to see history of changes in the UI.
116
+ Enable logging on your Rollout instance to display change history:
78
117
 
79
118
  ```ruby
80
119
  require "redis"
@@ -88,18 +127,49 @@ $rollout = Rollout.new(
88
127
  )
89
128
  ```
90
129
 
91
- To also see who updated states of your rollouts, you can configure `actor` and
92
- `actor_url`. So if you are using Rails with Devise, your configuration might
93
- look like:
130
+ Configure `actor` and `actor_url` to attribute changes. For example, with Devise:
94
131
 
95
132
  ```ruby
96
133
  Rollout::UI.configure do
97
134
  instance { $rollout }
98
- actor { current_user&.username }
135
+ actor { current_user&.nickname }
99
136
  actor_url { |actor| "/#{actor}" }
100
137
  end
101
138
  ```
102
139
 
140
+ When using a Rollout version and adapter that support event-aware deletion,
141
+ deletions appear in the overview history with the configured actor. This
142
+ requires global logging (`global: true`); adapters without event-aware deletion
143
+ retain the previous behavior and do not add a deletion event.
144
+
145
+ With `user_lookup` configured, feature and overview history display current
146
+ nicknames while audit events retain IDs. History resolves up to 150 additional
147
+ distinct IDs per page; missing accounts, failed lookups, and IDs beyond that limit
148
+ use ID-based labels.
149
+
150
+ ## API Endpoints
151
+
152
+ Send `Accept: application/json` to the index or feature route for JSON responses.
153
+ The index also accepts filters such as `/admin/rollout?user=123` and
154
+ `/admin/rollout?group=developers`.
155
+
156
+ When both nickname callbacks are configured, `GET /users/search?q=alice`
157
+ (relative to the mount) returns `{"users":[{"id":"123","nickname":"alice"}]}`.
158
+ It uses the mount's authentication, caps results server-side, and sends
159
+ `Cache-Control: no-store`. Queries below the configured minimum return an empty
160
+ list without calling `user_search`. Queries are limited to 100 characters;
161
+ configured minimum lengths and result limits are bounded to 1–100.
162
+
163
+ ## Host Header Validation
164
+
165
+ When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
166
+ performs no additional Host header validation.
167
+
168
+ For standalone use (e.g. `rackup`), configure authentication and host validation
169
+ through middleware such as
170
+ [`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api)
171
+ or a reverse proxy that only forwards trusted hosts.
172
+
103
173
  ## Contributing
104
174
 
105
175
  Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
@@ -129,7 +199,7 @@ bundle exec rerun rackup
129
199
 
130
200
  And visit [http://localhost:9292/](http://localhost:9292/).
131
201
 
132
- Alternatively, you can also configure which Redis with:
202
+ To use a different Redis connection:
133
203
 
134
204
  ```sh
135
205
  REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=10 bundle exec rerun rackup
@@ -1,4 +1,3 @@
1
- require "sinatra"
2
1
  require "rollout"
3
2
 
4
3
  require "rollout/ui/version"
@@ -10,10 +9,16 @@ module Rollout::UI
10
9
  actor
11
10
  actor_url
12
11
  timestamp_format
12
+ user_search
13
+ user_lookup
14
+ user_search_min_length
15
+ user_search_limit
13
16
  ].freeze
14
17
 
15
18
  DEFAULT_VALUES = {
16
- timestamp_format: '%Y-%m-%d %H:%M %Z'
19
+ timestamp_format: '%Y-%m-%d %H:%M %Z',
20
+ user_search_min_length: 3,
21
+ user_search_limit: 20
17
22
  }.freeze
18
23
 
19
24
  KEYS.each do |key|
@@ -1,14 +1,44 @@
1
- require "sinatra"
1
+ require "rack"
2
2
  require "rollout"
3
3
 
4
4
  require "rollout/ui/version"
5
5
 
6
6
  module Rollout::UI
7
7
  module Helpers
8
+ USER_EDIT_LIMIT = 150
9
+ USER_SEARCH_MAX_LENGTH = 100
10
+
8
11
  def stylesheet_path(name)
9
12
  "#{request.script_name}/css/#{name}.css"
10
13
  end
11
14
 
15
+ def javascript_path(name)
16
+ "#{request.script_name}/js/#{name}.js"
17
+ end
18
+
19
+ def user_search_path
20
+ "#{request.script_name}/users/search"
21
+ end
22
+
23
+ def user_selector_enabled?
24
+ config.defined?(:user_search) && config.defined?(:user_lookup)
25
+ end
26
+
27
+ def user_search_min_length
28
+ config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
29
+ end
30
+
31
+ def user_search_limit
32
+ config.get(:user_search_limit).to_i.clamp(1, 100)
33
+ end
34
+
35
+ def user_label(id)
36
+ id = id.to_s
37
+ return @user_nicknames[id] if @user_nicknames&.key?(id)
38
+
39
+ @user_lookup_attempted&.key?(id) ? "Unknown user (##{id})" : "User (##{id})"
40
+ end
41
+
12
42
  def index_path
13
43
  "#{request.script_name}/"
14
44
  end
@@ -84,10 +114,15 @@ module Rollout::UI
84
114
  key.to_s.gsub('data.', '')
85
115
  end
86
116
 
87
- def format_change_value(value)
117
+ def format_change_value(value, key: nil)
88
118
  case value
89
119
  when Array
90
- "[#{value.join(', ')}]"
120
+ values = if key.to_s == 'users' && config.defined?(:user_lookup)
121
+ value.map { |id| user_label(id) }
122
+ else
123
+ value
124
+ end
125
+ "[#{values.join(', ')}]"
91
126
  when String, nil
92
127
  "'#{value}'"
93
128
  else
@@ -95,6 +130,32 @@ module Rollout::UI
95
130
  end
96
131
  end
97
132
 
133
+ def event_actor(event)
134
+ actor = event.context && event.context[:actor]
135
+ return "unidentified user" unless actor
136
+ return h(actor) unless config.defined?(:actor_url)
137
+
138
+ %(<a class="underline" href="#{h(config.get(:actor_url, actor))}" target="_blank">#{h(actor)}</a>)
139
+ end
140
+
141
+ def event_summary(event)
142
+ return "deleted this feature" if event.name.to_s == "delete"
143
+
144
+ changes = event.data.fetch(:before).keys.filter_map do |key|
145
+ next if key.to_s == 'data.updated_at'
146
+
147
+ before_value = event.data.fetch(:before).fetch(key)
148
+ after_value = event.data.fetch(:after).fetch(key)
149
+ next if before_value == after_value
150
+ next if key.to_s == 'data.description' && before_value.to_s == after_value.to_s
151
+
152
+ before = format_change_value(before_value, key: key)
153
+ after = format_change_value(after_value, key: key)
154
+ "#{format_change_key(key)} from #{before} to #{after}"
155
+ end
156
+ "changed #{changes.empty? ? 'nothing!' : changes.join(', ')}"
157
+ end
158
+
98
159
  def json_request?
99
160
  request.env['HTTP_ACCEPT'] == 'application/json'
100
161
  end
@@ -0,0 +1,172 @@
1
+ (function() {
2
+ const picker = document.getElementById('user-selector');
3
+ if (!picker) return;
4
+
5
+ const field = document.getElementById('users');
6
+ const input = document.getElementById('user-nickname');
7
+ const chips = document.getElementById('selected-users');
8
+ const list = document.getElementById('user-search-results');
9
+ const status = document.getElementById('user-search-status');
10
+ const minLength = Number(picker.dataset.minLength);
11
+ const limit = Number(picker.dataset.limit);
12
+ const selected = new Map();
13
+ chips.querySelectorAll('[data-user-id]').forEach(function(chip) {
14
+ selected.set(chip.dataset.userId, chip.dataset.nickname);
15
+ });
16
+
17
+ let timer;
18
+ let controller;
19
+ let revision = 0;
20
+ let results = [];
21
+ let activeIndex = -1;
22
+ const prompt = 'Type at least ' + minLength + ' characters to search.';
23
+
24
+ function closeList() {
25
+ list.hidden = true;
26
+ input.setAttribute('aria-expanded', 'false');
27
+ input.removeAttribute('aria-activedescendant');
28
+ activeIndex = -1;
29
+ }
30
+
31
+ function cancelSearch() {
32
+ clearTimeout(timer);
33
+ if (controller) controller.abort();
34
+ revision += 1;
35
+ closeList();
36
+ }
37
+
38
+ function renderChips() {
39
+ chips.replaceChildren();
40
+ selected.forEach(function(nickname, id) {
41
+ const chip = document.createElement('span');
42
+ chip.className = 'inline-flex items-center bg-gray-200 rounded-sm px-2 py-1 mr-2 mb-2';
43
+ const label = document.createElement('span');
44
+ label.textContent = nickname;
45
+ const remove = document.createElement('button');
46
+ remove.type = 'button';
47
+ remove.className = 'ml-2 text-gray-600';
48
+ remove.textContent = '×';
49
+ remove.setAttribute('aria-label', 'Remove ' + nickname);
50
+ remove.addEventListener('click', function() {
51
+ selected.delete(id);
52
+ updateSelection(nickname + ' removed.');
53
+ });
54
+ chip.append(label, remove);
55
+ chips.append(chip);
56
+ });
57
+ }
58
+
59
+ function choose(user) {
60
+ selected.set(user.id, user.nickname);
61
+ updateSelection(user.nickname + ' selected.');
62
+ }
63
+
64
+ function updateSelection(message) {
65
+ field.value = Array.from(selected.keys()).join(', ');
66
+ renderChips();
67
+ cancelSearch();
68
+ input.value = '';
69
+ input.focus();
70
+ status.textContent = message + ' ' + prompt;
71
+ }
72
+
73
+ function activate(index) {
74
+ activeIndex = index;
75
+ Array.from(list.children).forEach(function(option, i) {
76
+ option.setAttribute('aria-selected', String(i === index));
77
+ option.classList.toggle('bg-gray-200', i === index);
78
+ });
79
+ const option = list.children[index];
80
+ if (option) {
81
+ input.setAttribute('aria-activedescendant', option.id);
82
+ option.scrollIntoView({ block: 'nearest' });
83
+ }
84
+ }
85
+
86
+ async function search(query, searchRevision) {
87
+ controller = new AbortController();
88
+ status.textContent = 'Searching nicknames…';
89
+ try {
90
+ const url = new URL(picker.dataset.searchUrl, window.location.href);
91
+ url.searchParams.set('q', query);
92
+ const response = await fetch(url, {
93
+ signal: controller.signal,
94
+ headers: { Accept: 'application/json' },
95
+ credentials: 'same-origin',
96
+ cache: 'no-store'
97
+ });
98
+ if (!response.ok) throw new Error('Search failed');
99
+ const data = await response.json();
100
+ if (searchRevision !== revision) return;
101
+ renderResults(data.users);
102
+ } catch (error) {
103
+ if (searchRevision !== revision || error.name === 'AbortError') return;
104
+ closeList();
105
+ status.textContent = 'Nickname search is unavailable. Try typing again. Your selections are preserved.';
106
+ }
107
+ }
108
+
109
+ function renderResults(users) {
110
+ results = users.filter(function(user) { return !selected.has(user.id); });
111
+ list.replaceChildren();
112
+ results.forEach(function(user, index) {
113
+ const option = document.createElement('div');
114
+ option.id = 'user-search-option-' + index;
115
+ option.className = 'px-4 py-2 cursor-pointer hover:bg-gray-200';
116
+ option.setAttribute('role', 'option');
117
+ option.setAttribute('aria-selected', 'false');
118
+ option.textContent = user.nickname;
119
+ option.addEventListener('pointerdown', function(event) { event.preventDefault(); });
120
+ option.addEventListener('click', function() { choose(user); });
121
+ list.append(option);
122
+ });
123
+ list.hidden = results.length === 0;
124
+ input.setAttribute('aria-expanded', String(results.length > 0));
125
+ if (users.length === 0) {
126
+ status.textContent = 'No matching nicknames.';
127
+ } else if (results.length === 0) {
128
+ status.textContent = 'Matching users are already selected.';
129
+ } else {
130
+ status.textContent = results.length + (results.length === 1 ? ' match.' : ' matches.') + ' Use ↑/↓ and Enter to select.';
131
+ }
132
+ if (users.length >= limit) status.textContent += ' Refine your nickname to narrow the results.';
133
+ }
134
+
135
+ function scheduleSearch() {
136
+ cancelSearch();
137
+ const query = input.value.trim();
138
+ if (Array.from(query).length < minLength) {
139
+ status.textContent = prompt;
140
+ return;
141
+ }
142
+ status.textContent = 'Waiting to search…';
143
+ const searchRevision = revision;
144
+ timer = setTimeout(function() { search(query, searchRevision); }, 300);
145
+ }
146
+
147
+ input.addEventListener('input', scheduleSearch);
148
+ input.addEventListener('focus', scheduleSearch);
149
+
150
+ input.addEventListener('keydown', function(event) {
151
+ if (event.key === 'Enter') {
152
+ event.preventDefault();
153
+ if (!list.hidden && activeIndex >= 0) choose(results[activeIndex]);
154
+ } else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && !list.hidden) {
155
+ event.preventDefault();
156
+ const next = event.key === 'ArrowDown' ? activeIndex + 1 :
157
+ activeIndex < 0 ? results.length - 1 : activeIndex - 1;
158
+ activate((next + results.length) % results.length);
159
+ } else if (event.key === 'Escape') {
160
+ cancelSearch();
161
+ status.textContent = prompt;
162
+ } else if (event.key === 'Tab') {
163
+ cancelSearch();
164
+ }
165
+ });
166
+
167
+ input.addEventListener('blur', function() { cancelSearch(); });
168
+ renderChips();
169
+ document.querySelector('label[for="users"]').htmlFor = input.id;
170
+ field.hidden = true;
171
+ picker.hidden = false;
172
+ })();
@@ -1,5 +1,5 @@
1
1
  class Rollout
2
2
  module UI
3
- VERSION = "0.9.2"
3
+ VERSION = "0.10.0"
4
4
  end
5
5
  end
@@ -70,6 +70,4 @@
70
70
  </table>
71
71
  </div>
72
72
 
73
- <% global_history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : [] %>
74
-
75
- <%= erb :"features/partials/event_log", layout: false, locals: { events: global_history_events, show_feature: true, show_header: true, existing_features: @features } %>
73
+ <%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events, show_feature: true, show_header: true, existing_features: @features } %>
@@ -31,24 +31,8 @@
31
31
  </td>
32
32
  <% end %>
33
33
  <td class="py-2 pr-3 break-words w-full">
34
- <% if event.name == "update" %>
35
- <% if event.context && event.context[:actor] %>
36
- <% if config.defined?(:actor_url) %>
37
- <a class="underline" href="<%= h(config.get(:actor_url, event.context[:actor])) %>" target="_blank"><%= h(event.context[:actor]) %></a>
38
- <% else %>
39
- <%= h(event.context[:actor]) %>
40
- <% end %>
41
- <% else %>
42
- unidentified user
43
- <% end %>
44
- <%
45
- changes = event.data.fetch(:before).keys.reject { |key| key.to_s == 'data.updated_at' }.map do |key|
46
- before = format_change_value(event.data.fetch(:before).fetch(key))
47
- after = format_change_value(event.data.fetch(:after).fetch(key))
48
- "#{format_change_key(key)} from #{before} to #{after}"
49
- end
50
- %>
51
- changed <%= h(changes.empty? ? 'nothing!' : changes.join(', ')) %>
34
+ <% if ["update", "delete"].include?(event.name.to_s) %>
35
+ <%= event_actor(event) %> <%= h(event_summary(event)) %>
52
36
  <% else %>
53
37
  <%= h(event.data) %>
54
38
  <% end %>
@@ -0,0 +1,15 @@
1
+ <div id="user-selector" data-search-url="<%= h(user_search_path) %>" data-min-length="<%= h(user_search_min_length) %>" data-limit="<%= h(user_search_limit) %>" hidden>
2
+ <div id="selected-users" class="flex flex-wrap mb-2" aria-label="Selected users">
3
+ <% @selected_users.each do |user| %>
4
+ <span data-user-id="<%= h(user[:id]) %>" data-nickname="<%= h(user[:nickname]) %>"></span>
5
+ <% end %>
6
+ </div>
7
+ <label class="block text-gray-500 mb-2" for="user-nickname">Search by nickname</label>
8
+ <input id="user-nickname" class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="user-search-results" aria-describedby="user-search-status" autocomplete="off" spellcheck="false" maxlength="<%= USER_SEARCH_MAX_LENGTH %>" placeholder="Type a nickname…">
9
+ <div id="user-search-results" class="border rounded-sm bg-white mt-1" role="listbox" aria-label="Matching nicknames" style="max-height: 16rem; overflow-y: auto" hidden></div>
10
+ <p id="user-search-status" class="text-gray-500 mt-2" role="status" aria-live="polite">Type at least <%= h(user_search_min_length) %> characters to search.</p>
11
+ <% if @selected_user_lookup_failed %>
12
+ <p class="text-gray-500 mt-2" role="status">Selected nicknames could not be loaded. Their user IDs are preserved.</p>
13
+ <% end %>
14
+ </div>
15
+ <script src="<%= h(javascript_path('user-selector')) %>" defer></script>
@@ -30,11 +30,14 @@
30
30
  </div>
31
31
 
32
32
  <div class="mb-5">
33
- <label class="block text-gray-500 mb-2">Users</label>
34
- <% if @feature.users.count > 150 %>
33
+ <label class="block text-gray-500 mb-2" for="users">Users</label>
34
+ <% if @feature.users.count > USER_EDIT_LIMIT %>
35
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
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>
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" rows="2"><%= h(@feature.users.join(', ')) %></textarea>
38
+ <% if user_selector_enabled? %>
39
+ <%= erb :"features/partials/user_selector", layout: false %>
40
+ <% end %>
38
41
  <% end %>
39
42
  </div>
40
43
  </form>
@@ -47,6 +50,4 @@
47
50
  </div>
48
51
  </main>
49
52
 
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 } %>
53
+ <%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events } %>
@@ -1,5 +1,6 @@
1
- require "sinatra"
2
- require "sinatra/json"
1
+ require "erb"
2
+ require "json"
3
+ require "rack"
3
4
  require "rollout"
4
5
 
5
6
  require "rollout/ui/version"
@@ -7,86 +8,313 @@ require "rollout/ui/config"
7
8
  require "rollout/ui/helpers"
8
9
 
9
10
  module Rollout::UI
10
- class Web < Sinatra::Base
11
- set :static, true
12
- set :public_folder, File.dirname(__FILE__) + '/public'
13
-
14
- helpers Helpers
15
-
16
- get '/' do
17
- @rollout = config.get(:instance)
18
- @features = @rollout.features.sort_by(&:downcase)
19
- if json_request?
20
- json(
21
- filtered_features(@rollout, @features).map do |feature|
22
- feature_to_hash(@rollout.get(feature))
23
- end
24
- )
25
- else
26
- erb :'features/index'
27
- end
28
- end
11
+ class Web
12
+ PUBLIC_PATH = File.expand_path('public', __dir__)
29
13
 
30
- get '/features/new' do
31
- erb :'features/new'
14
+ NOT_FOUND = ->(env) { [404, { 'content-type' => 'text/plain', 'x-cascade' => 'pass' }, ['Not Found']] }
15
+
16
+ def initialize
17
+ @static = Rack::Static.new(NOT_FOUND, urls: ['/css', '/js'], root: PUBLIC_PATH)
32
18
  end
33
19
 
34
- post '/features/new' do
35
- redirect feature_path(params[:name])
20
+ def call(env)
21
+ status, headers, body = @static.call(env)
22
+ return [status, headers, body] unless status == 404
23
+
24
+ Dispatcher.new.call(env)
36
25
  end
37
26
 
38
- get '/features/:feature_name' do
39
- @rollout = config.get(:instance)
40
- @feature = @rollout.get(params[:feature_name])
27
+ # Handles a single request. A fresh instance is created per request (see
28
+ # Web#call above) so the instance variables set by actions and read by
29
+ # views (@rollout, @feature, etc) can't leak across concurrent requests.
30
+ class Dispatcher
31
+ include Helpers
32
+
33
+ VIEWS_PATH = File.expand_path('views', __dir__)
34
+ TEMPLATE_CACHE = {}
35
+ HISTORY_USER_LOOKUP_LIMIT = 150
36
+
37
+ ROUTES = [
38
+ [:GET, %r{\A/\z}, :index],
39
+ [:GET, %r{\A/users/search\z}, :search_users],
40
+ [:GET, %r{\A/features/new\z}, :new_feature],
41
+ [:POST, %r{\A/features/new\z}, :create_feature],
42
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
43
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)/delete\z}, :delete_feature],
44
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)\z}, :show],
45
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)\z}, :update],
46
+ ].freeze
47
+
48
+ attr_reader :request, :response, :params
49
+
50
+ def call(env)
51
+ @request = Rack::Request.new(env)
52
+ @response = Rack::Response.new
53
+ response['x-content-type-options'] = 'nosniff'
54
+ response['x-frame-options'] = 'SAMEORIGIN'
55
+ response['x-xss-protection'] = '1; mode=block'
56
+
57
+ # Rack::URLMap strips the mount prefix from PATH_INFO, leaving "" (not
58
+ # "/") when a mounted app is requested at its exact mount point.
59
+ path_info = request.path_info
60
+ path_info = '/' if path_info.empty?
61
+
62
+ method = request.request_method.to_sym
63
+ lookup_method = method == :HEAD ? :GET : method
64
+ route = ROUTES.find { |verb, pattern, _| verb == lookup_method && pattern.match?(path_info) }
65
+ return not_found unless route
41
66
 
42
- if json_request?
43
- json(feature_to_hash(@feature))
44
- else
45
- erb :'features/show'
67
+ _, pattern, action = route
68
+ begin
69
+ @params = build_params(pattern.match(path_info).named_captures)
70
+ rescue Rack::BadRequest => e
71
+ return bad_request(e)
72
+ end
73
+
74
+ send(action)
75
+ response.body = [] if method == :HEAD
76
+ response.finish
77
+ end
78
+
79
+ private
80
+
81
+ def index
82
+ @rollout = config.get(:instance)
83
+ @features = @rollout.features.sort_by(&:downcase)
84
+ if json_request?
85
+ json(
86
+ filtered_features(@rollout, @features).map do |feature|
87
+ feature_to_hash(@rollout.get(feature))
88
+ end
89
+ )
90
+ else
91
+ @history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : []
92
+ load_history_user_nicknames(@history_events)
93
+ render_view(:'features/index')
94
+ end
46
95
  end
47
- end
48
96
 
49
- post '/features/:feature_name' do
50
- rollout = config.get(:instance)
51
- actor = config.get(:actor, scope: self)
52
- feature_data = rollout.get(params[:feature_name]).data
53
- if feature_data['updated_at'] && params[:last_updated_at].to_s != feature_data['updated_at'].to_s
54
- redirect "#{feature_path(params[:feature_name])}?error=Rollout version outdated. Review changes below and try again."
55
- end
56
- with_rollout_context(rollout, actor: actor) do
57
- rollout.with_feature(params[:feature_name]) do |feature|
58
- feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
59
- feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
60
- if params[:users]
61
- feature.users = params[:users].split(',').map(&:strip).uniq.sort
97
+ def new_feature
98
+ render_view(:'features/new')
99
+ end
100
+
101
+ def create_feature
102
+ redirect_to feature_path(params[:name])
103
+ end
104
+
105
+ def show
106
+ @rollout = config.get(:instance)
107
+ @feature = @rollout.get(params[:feature_name])
108
+
109
+ if json_request?
110
+ json(feature_to_hash(@feature))
111
+ else
112
+ load_selected_users if user_selector_enabled? && @feature.users.count <= USER_EDIT_LIMIT
113
+ @history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : []
114
+ load_history_user_nicknames(@history_events)
115
+ render_view(:'features/show')
116
+ end
117
+ end
118
+
119
+ def search_users
120
+ return not_found unless user_selector_enabled?
121
+
122
+ response['cache-control'] = 'no-store'
123
+ query = params[:q]
124
+ unless query.nil? || query.is_a?(String)
125
+ response.status = 400
126
+ return json(error: 'Nickname must be a string.')
127
+ end
128
+ query = query.to_s.strip
129
+ if query.length > USER_SEARCH_MAX_LENGTH
130
+ response.status = 400
131
+ return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
132
+ end
133
+ return json(users: []) if query.length < user_search_min_length
134
+
135
+ begin
136
+ users = config.get(:user_search, query, user_search_limit)
137
+ json(users: users.take(user_search_limit).map { |user| user_to_hash(user) })
138
+ rescue StandardError
139
+ response.status = 503
140
+ json(error: 'Nickname search is temporarily unavailable. Please try again.')
141
+ end
142
+ end
143
+
144
+ 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
149
+ end
150
+
151
+ def load_history_user_nicknames(events)
152
+ return unless config.defined?(:user_lookup)
153
+
154
+ ids = {}
155
+ events.each do |event|
156
+ next unless event.name.to_s == 'update'
157
+
158
+ [:before, :after].each do |side|
159
+ changes = event.data.fetch(side)
160
+ Array(changes[:users] || changes['users']).each do |id|
161
+ id = id.to_s
162
+ next if @user_lookup_attempted&.key?(id)
163
+
164
+ ids[id] = true
165
+ return lookup_user_nicknames(ids.keys) if ids.size >= HISTORY_USER_LOOKUP_LIMIT
166
+ end
62
167
  end
63
- feature.data["description"] = params[:description]
64
- feature.data["updated_at"] = Time.now.to_i
65
168
  end
169
+ lookup_user_nicknames(ids.keys)
66
170
  end
67
171
 
68
- redirect feature_path(params[:feature_name])
69
- end
172
+ def lookup_user_nicknames(ids)
173
+ @user_nicknames ||= {}
174
+ @user_lookup_attempted ||= {}
175
+ missing_ids = ids.uniq.reject { |id| @user_lookup_attempted.key?(id) }
176
+ return if missing_ids.empty?
70
177
 
71
- post '/features/:feature_name/activate-percentage' do
72
- rollout = config.get(:instance)
73
- actor = config.get(:actor, scope: self)
178
+ missing_ids.each { |id| @user_lookup_attempted[id] = true }
179
+ config.get(:user_lookup, missing_ids).each do |user|
180
+ user = user_to_hash(user)
181
+ @user_nicknames[user[:id]] = user[:nickname]
182
+ end
183
+ rescue StandardError
184
+ @user_lookup_failed = true
185
+ end
74
186
 
75
- with_rollout_context(rollout, actor: actor) do
76
- rollout.with_feature(params[:feature_name]) do |feature|
77
- feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
78
- feature.data["updated_at"] = Time.now.to_i
187
+ def user_to_hash(user)
188
+ # Only expose the fields needed by the selector, even if a callback
189
+ # returns additional application-specific attributes.
190
+ id = user[:id] || user['id']
191
+ nickname = user[:nickname] || user['nickname']
192
+ raise ArgumentError, 'Users must have an id and nickname' if id.nil? || nickname.to_s.empty?
193
+
194
+ { id: id.to_s, nickname: nickname.to_s }
195
+ end
196
+
197
+ def update
198
+ rollout = config.get(:instance)
199
+ 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
+
205
+ with_rollout_context(rollout, actor: actor) do
206
+ rollout.with_feature(params[:feature_name]) do |feature|
207
+ feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
208
+ 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
212
+ feature.data["description"] = params[:description]
213
+ feature.data["updated_at"] = Time.now.to_i
214
+ end
79
215
  end
216
+
217
+ redirect_to feature_path(params[:feature_name])
80
218
  end
81
219
 
82
- redirect index_path
83
- end
220
+ def activate_percentage
221
+ rollout = config.get(:instance)
222
+ actor = config.get(:actor, scope: self)
223
+
224
+ with_rollout_context(rollout, actor: actor) do
225
+ rollout.with_feature(params[:feature_name]) do |feature|
226
+ feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
227
+ feature.data["updated_at"] = Time.now.to_i
228
+ end
229
+ end
230
+
231
+ redirect_to index_path
232
+ end
233
+
234
+ def delete_feature
235
+ rollout = config.get(:instance)
236
+ actor = config.get(:actor, scope: self)
237
+
238
+ with_rollout_context(rollout, actor: actor) do
239
+ rollout.delete(params[:feature_name])
240
+ end
241
+
242
+ redirect_to index_path
243
+ end
244
+
245
+ def not_found
246
+ response.status = 404
247
+ response['content-type'] = 'text/plain'
248
+ response['x-cascade'] = 'pass'
249
+ response.write('Not Found')
250
+ response.finish
251
+ end
84
252
 
85
- post '/features/:feature_name/delete' do
86
- @rollout = config.get(:instance)
87
- @rollout.delete(params[:feature_name])
253
+ def bad_request(error)
254
+ response.status = 400
255
+ response['content-type'] = 'text/plain'
256
+ response.write("Bad Request: #{error.message}")
257
+ response.finish
258
+ end
259
+
260
+ def build_params(route_params)
261
+ route_params = route_params.transform_values { |value| Rack::Utils.unescape_path(value) }
262
+
263
+ request.params.merge(route_params).each_with_object({}) do |(key, value), hash|
264
+ hash[key.to_s] = value
265
+ hash[key.to_sym] = value
266
+ end
267
+ end
268
+
269
+ # Mirrors Sinatra's own `redirect`: browsers replaying a 302 after a
270
+ # non-GET request may re-issue the original method, so redirects away
271
+ # from a POST use 303 See Other instead.
272
+ def redirect_to(location)
273
+ http_version = request.env['SERVER_PROTOCOL'] || request.env['HTTP_VERSION']
274
+ status = (http_version == 'HTTP/1.1' && request.request_method != 'GET') ? 303 : 302
275
+ response.redirect(location, status)
276
+ end
277
+
278
+ def json(data)
279
+ response.headers.delete('x-frame-options')
280
+ response.headers.delete('x-xss-protection')
281
+ response['content-type'] = 'application/json'
282
+ response.write(data.to_json)
283
+ end
284
+
285
+ # Exposed so `Rollout::UI.configure { actor { env[...] } }` /
286
+ # `actor { session[...] }` blocks keep working when instance_eval'd
287
+ # against this dispatcher, matching Sinatra's own request-scoped
288
+ # `env`/`session` helpers.
289
+ def env
290
+ request.env
291
+ end
292
+
293
+ def session
294
+ request.session
295
+ end
296
+
297
+ def render_view(name)
298
+ response['content-type'] = 'text/html;charset=utf-8'
299
+ response.write(erb(name))
300
+ end
88
301
 
89
- redirect index_path
302
+ # Called from within views to render a partial, e.g.
303
+ # `<%= erb :"features/partials/event_log", layout: false, locals: { events: events } %>`
304
+ def erb(name, layout: true, locals: {})
305
+ return render_template(name, locals) unless layout
306
+
307
+ render_template(:layout, locals) { render_template(name, locals) }
308
+ end
309
+
310
+ def render_template(name, locals = {}, &block)
311
+ path = File.join(VIEWS_PATH, "#{name}.erb")
312
+ template_source = (TEMPLATE_CACHE[name] ||= ERB.new(File.read(path), trim_mode: '-').src)
313
+
314
+ template_binding = binding
315
+ locals.each { |key, value| template_binding.local_variable_set(key, value) }
316
+ template_binding.eval(template_source, path)
317
+ end
90
318
  end
91
319
  end
92
320
  end
data/lib/rollout/ui.rb CHANGED
@@ -1,4 +1,3 @@
1
- require "sinatra"
2
1
  require "rollout"
3
2
 
4
3
  require "rollout/ui/version"
data/rollout-ui.gemspec CHANGED
@@ -23,8 +23,8 @@ Gem::Specification.new do |spec|
23
23
  spec.require_paths = ['lib']
24
24
 
25
25
  spec.add_dependency 'rollout', '>= 3.1', '< 4'
26
- spec.add_dependency 'sinatra', ['>= 2.0', '< 5.0']
27
- spec.add_dependency 'sinatra-contrib', ['>= 2.0', '< 5.0']
26
+ spec.add_dependency 'rack', ['>= 3.1', '< 4.0']
27
+ spec.add_dependency 'erb'
28
28
 
29
29
  spec.add_development_dependency 'rollout-redis-adapter', '~> 0.1'
30
30
  spec.add_development_dependency 'redis-namespace'
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.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - FetLife
@@ -30,45 +30,39 @@ dependencies:
30
30
  - !ruby/object:Gem::Version
31
31
  version: '4'
32
32
  - !ruby/object:Gem::Dependency
33
- name: sinatra
33
+ name: rack
34
34
  requirement: !ruby/object:Gem::Requirement
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '2.0'
38
+ version: '3.1'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: '5.0'
41
+ version: '4.0'
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '2.0'
48
+ version: '3.1'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: '5.0'
51
+ version: '4.0'
52
52
  - !ruby/object:Gem::Dependency
53
- name: sinatra-contrib
53
+ name: erb
54
54
  requirement: !ruby/object:Gem::Requirement
55
55
  requirements:
56
56
  - - ">="
57
57
  - !ruby/object:Gem::Version
58
- version: '2.0'
59
- - - "<"
60
- - !ruby/object:Gem::Version
61
- version: '5.0'
58
+ version: '0'
62
59
  type: :runtime
63
60
  prerelease: false
64
61
  version_requirements: !ruby/object:Gem::Requirement
65
62
  requirements:
66
63
  - - ">="
67
64
  - !ruby/object:Gem::Version
68
- version: '2.0'
69
- - - "<"
70
- - !ruby/object:Gem::Version
71
- version: '5.0'
65
+ version: '0'
72
66
  - !ruby/object:Gem::Dependency
73
67
  name: rollout-redis-adapter
74
68
  requirement: !ruby/object:Gem::Requirement
@@ -234,10 +228,12 @@ files:
234
228
  - lib/rollout/ui/config.rb
235
229
  - lib/rollout/ui/helpers.rb
236
230
  - lib/rollout/ui/public/css/tailwind.min.css
231
+ - lib/rollout/ui/public/js/user-selector.js
237
232
  - lib/rollout/ui/version.rb
238
233
  - lib/rollout/ui/views/features/index.erb
239
234
  - lib/rollout/ui/views/features/new.erb
240
235
  - lib/rollout/ui/views/features/partials/event_log.erb
236
+ - lib/rollout/ui/views/features/partials/user_selector.erb
241
237
  - lib/rollout/ui/views/features/show.erb
242
238
  - lib/rollout/ui/views/layout.erb
243
239
  - lib/rollout/ui/web.rb