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 +4 -4
- data/README.md +96 -26
- data/lib/rollout/ui/config.rb +7 -2
- data/lib/rollout/ui/helpers.rb +64 -3
- data/lib/rollout/ui/public/js/user-selector.js +172 -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/event_log.erb +2 -18
- data/lib/rollout/ui/views/features/partials/user_selector.erb +15 -0
- data/lib/rollout/ui/views/features/show.erb +7 -6
- data/lib/rollout/ui/web.rb +291 -63
- data/lib/rollout/ui.rb +0 -1
- data/rollout-ui.gemspec +2 -2
- metadata +11 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
|
|
4
|
+
data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
|
|
7
|
+
data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
|
data/README.md
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# Rollout::UI
|
|
2
2
|
|
|
3
|
-
|
|
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
|

|
|
7
6
|
<!--  -->
|
|
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
|
|
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
|
|
21
|
+
Mount the Rack app:
|
|
23
22
|
|
|
24
23
|
```ruby
|
|
25
24
|
Rails.application.routes.draw do
|
|
26
|
-
mount Rollout::UI::Web.new =>
|
|
25
|
+
mount Rollout::UI::Web.new => "/admin/rollout"
|
|
27
26
|
|
|
28
27
|
# ...
|
|
29
28
|
end
|
|
30
29
|
```
|
|
31
30
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
55
|
+
mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
|
|
60
56
|
|
|
61
57
|
# ...
|
|
62
58
|
end
|
|
63
59
|
```
|
|
64
60
|
|
|
65
|
-
##
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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&.
|
|
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
|
-
|
|
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
|
data/lib/rollout/ui/config.rb
CHANGED
|
@@ -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|
|
data/lib/rollout/ui/helpers.rb
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
require "
|
|
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
|
-
|
|
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
|
+
})();
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -70,6 +70,4 @@
|
|
|
70
70
|
</table>
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
-
|
|
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
|
|
35
|
-
|
|
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 >
|
|
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"
|
|
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
|
-
|
|
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 } %>
|
data/lib/rollout/ui/web.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
require "
|
|
2
|
-
require "
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
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 '
|
|
27
|
-
spec.add_dependency '
|
|
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.
|
|
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:
|
|
33
|
+
name: rack
|
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
|
35
35
|
requirements:
|
|
36
36
|
- - ">="
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: '
|
|
38
|
+
version: '3.1'
|
|
39
39
|
- - "<"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: '
|
|
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: '
|
|
48
|
+
version: '3.1'
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
|
-
version: '
|
|
51
|
+
version: '4.0'
|
|
52
52
|
- !ruby/object:Gem::Dependency
|
|
53
|
-
name:
|
|
53
|
+
name: erb
|
|
54
54
|
requirement: !ruby/object:Gem::Requirement
|
|
55
55
|
requirements:
|
|
56
56
|
- - ">="
|
|
57
57
|
- !ruby/object:Gem::Version
|
|
58
|
-
version: '
|
|
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: '
|
|
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
|