rollout-ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +2 -2
- data/.github/workflows/test.yml +1 -3
- data/.mise.toml +1 -1
- data/.ruby-version +1 -1
- data/README.md +93 -5
- data/lib/rollout/ui/config.rb +1 -0
- data/lib/rollout/ui/helpers.rb +29 -3
- 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 +2 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +16 -1
- 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/warning_icon.erb +3 -0
- data/lib/rollout/ui/views/features/show.erb +12 -12
- data/lib/rollout/ui/views/layout.erb +7 -0
- data/lib/rollout/ui/web.rb +191 -13
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30014dd8f03ca67cb9bd7152adb431746e9d1ec399201e2a51abb4f3dedab1a4
|
|
4
|
+
data.tar.gz: bfa5ade0416b04978bc47f461513dcb857e88a2aa8838a140564a12fe9101a8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf00bea7e987f4569fff37e268e48dccaf5b30c9cf62e545d8276ab66f426da578fc454e5ab1fd2b1db065a58284400ea72e4a0b8cd3eac088715c0f4d50a041
|
|
7
|
+
data.tar.gz: cea39dbe21f1a32be247c019e0fe7ca3073344ab918c4deb9ca7fedc80230b852b0d4e5ac745e1b4b776d41d5352030f25f41c98e70fd7a07479de4d034d67ca
|
|
@@ -7,7 +7,7 @@ on:
|
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
release:
|
|
10
|
-
runs-on: ubuntu-
|
|
10
|
+
runs-on: ubuntu-26.04
|
|
11
11
|
permissions:
|
|
12
12
|
contents: write
|
|
13
13
|
id-token: write
|
|
@@ -37,7 +37,7 @@ jobs:
|
|
|
37
37
|
|
|
38
38
|
github-release:
|
|
39
39
|
needs: release
|
|
40
|
-
runs-on: ubuntu-
|
|
40
|
+
runs-on: ubuntu-26.04
|
|
41
41
|
permissions:
|
|
42
42
|
contents: write
|
|
43
43
|
steps:
|
data/.github/workflows/test.yml
CHANGED
data/.mise.toml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
[tools]
|
|
2
|
-
ruby = "3.4.
|
|
2
|
+
ruby = "3.4.11"
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.11
|
data/README.md
CHANGED
|
@@ -61,8 +61,9 @@ end
|
|
|
61
61
|
## Nickname user selector
|
|
62
62
|
|
|
63
63
|
Configure **both** callbacks below to replace the user ID field with a searchable
|
|
64
|
-
|
|
65
|
-
and Rollout stores user IDs. This example
|
|
64
|
+
nickname picker alongside the enrolled-user list. Your application supplies the
|
|
65
|
+
queries; the UI displays nicknames and Rollout stores user IDs. This example
|
|
66
|
+
uses Rails and PostgreSQL:
|
|
66
67
|
|
|
67
68
|
```ruby
|
|
68
69
|
Rollout::UI.configure do
|
|
@@ -107,9 +108,84 @@ CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
|
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
Check the query plan against your schema and collation. The selector supports
|
|
110
|
-
keyboard navigation and preserves
|
|
111
|
-
|
|
112
|
-
users
|
|
111
|
+
keyboard navigation and preserves pending changes if a lookup fails. The
|
|
112
|
+
enrolled count includes unresolved accounts, shown as `Unknown user` with their
|
|
113
|
+
ID in a separate column, but excludes users enabled through groups or percentage.
|
|
114
|
+
|
|
115
|
+
### Paginated enrollment editing
|
|
116
|
+
|
|
117
|
+
Every feature shows a paginated enrollment list with **25 users per page**.
|
|
118
|
+
Rows are ordered by user ID. Nicknames are
|
|
119
|
+
looked up only for the visible page, independently of the bounded history lookup.
|
|
120
|
+
There is no longer a 150-user enrollment editing cutoff.
|
|
121
|
+
|
|
122
|
+
Remove/Undo and nickname additions are staged until **Update**. With JavaScript,
|
|
123
|
+
switching enrollment pages leaves pending changes and unsaved description,
|
|
124
|
+
groups, and percentage fields intact. A pending-changes callout below the table
|
|
125
|
+
opens a **Review** popover listing additions and removals across all pages. Search marks
|
|
126
|
+
already-enrolled users even when they are on another page. The heading counts
|
|
127
|
+
stored enrollments; pending changes are reported separately until saved.
|
|
128
|
+
|
|
129
|
+
The form submits additions and removals, not a replacement list from the visible
|
|
130
|
+
page. Delta requests use `user_changes=1` and require a full-state
|
|
131
|
+
`feature_version`; malformed markers and missing versions are rejected. After
|
|
132
|
+
version validation, these deltas are applied to the full membership
|
|
133
|
+
inside `with_feature`. Duplicate additions are idempotent; removals take
|
|
134
|
+
precedence if an ID appears in both sets. Legacy full-list `users` submissions
|
|
135
|
+
remain supported.
|
|
136
|
+
|
|
137
|
+
Without JavaScript or nickname callbacks, add users using comma-separated IDs
|
|
138
|
+
and check Remove for visible rows. **Update before following page links** to
|
|
139
|
+
avoid discarding unsaved form changes. Enrollment pagination remains available
|
|
140
|
+
when nickname lookup fails; unknown IDs are never silently dropped.
|
|
141
|
+
|
|
142
|
+
## Missing definitions
|
|
143
|
+
|
|
144
|
+
The overview and feature page mark stored features that have no definition in
|
|
145
|
+
the current application as **Missing definition**. This is informational: it
|
|
146
|
+
does not change targeting, editing, or deletion, and a missing definition is not
|
|
147
|
+
proof that a feature is unused.
|
|
148
|
+
|
|
149
|
+
With Rollout's `definitions` API, the UI checks `Rollout.definitions` when the
|
|
150
|
+
definitions directory exists. Older Rollout versions, a missing directory, or
|
|
151
|
+
`Rollout.definitions_path = nil` leave checking disabled.
|
|
152
|
+
|
|
153
|
+
Applications that also register features in Ruby can supply their complete list
|
|
154
|
+
of defined names instead:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
Rollout::UI.configure do
|
|
158
|
+
defined_features { MyApplication.defined_feature_names }
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The callback returns string or symbol names. Returning `nil` disables checking;
|
|
163
|
+
an empty list marks every stored feature as missing. Definitions are read once
|
|
164
|
+
per HTML request, so changes appear on the next request. Loading errors are not
|
|
165
|
+
treated as missing definitions. JSON responses are unchanged.
|
|
166
|
+
|
|
167
|
+
## Concurrent edits
|
|
168
|
+
|
|
169
|
+
Feature forms include a `feature_version` fingerprint of the editable state:
|
|
170
|
+
percentage, groups, enrolled IDs, and description. Ordering of IDs and groups,
|
|
171
|
+
timestamps, and unrelated metadata do not change the fingerprint. Distinct edits
|
|
172
|
+
in the same second and changes made outside the UI are detected.
|
|
173
|
+
|
|
174
|
+
Updates validate the submitted fingerprint inside `with_feature`, before changing
|
|
175
|
+
state. A stale submission is rejected without saving state or history, and the
|
|
176
|
+
UI redirects to the latest feature so the administrator can review and retry.
|
|
177
|
+
Older non-delta forms without `feature_version` retain the `last_updated_at`
|
|
178
|
+
timestamp check; that legacy check cannot distinguish same-second edits.
|
|
179
|
+
|
|
180
|
+
Atomic conflict detection depends on the adapter's mutation guarantees. The
|
|
181
|
+
ActiveRecord adapter locks existing feature rows during `with_feature`. The
|
|
182
|
+
Redis adapter does not provide the equivalent lock or compare-and-set guarantee.
|
|
183
|
+
|
|
184
|
+
Use uncached feature reads for the Rollout instance supplied to the admin UI.
|
|
185
|
+
Process-local adapter caches can otherwise display an older enrolled list and
|
|
186
|
+
form version after another worker saves. Normal feature evaluation can use a
|
|
187
|
+
separate cached instance with the same group definitions. Feature responses send
|
|
188
|
+
`Cache-Control: no-store`; this does not bypass an adapter's server-side cache.
|
|
113
189
|
|
|
114
190
|
## Logging
|
|
115
191
|
|
|
@@ -160,6 +236,18 @@ It uses the mount's authentication, caps results server-side, and sends
|
|
|
160
236
|
list without calling `user_search`. Queries are limited to 100 characters;
|
|
161
237
|
configured minimum lengths and result limits are bounded to 1–100.
|
|
162
238
|
|
|
239
|
+
`GET /features/:name/users?page=2` returns the visible enrollment
|
|
240
|
+
page, total enrollment count, page metadata, full-state `feature_version`, and
|
|
241
|
+
`lookup_failed`. Pages contain at most 25 users and page numbers are clamped to
|
|
242
|
+
the available range. The response never contains the entire
|
|
243
|
+
membership list and sends `Cache-Control: no-store`.
|
|
244
|
+
|
|
245
|
+
`GET /features/:name/users/search?q=alice` uses the same nickname callbacks and
|
|
246
|
+
limits, adds an `enrolled` flag to each match based on the full membership, and
|
|
247
|
+
includes `feature_version`. The unscoped `/users/search` response is unchanged.
|
|
248
|
+
Paginated reads never replace the form's original version token; if the rollout
|
|
249
|
+
changes while editing, the administrator must review the newer state.
|
|
250
|
+
|
|
163
251
|
## Host Header Validation
|
|
164
252
|
|
|
165
253
|
When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
|
data/lib/rollout/ui/config.rb
CHANGED
data/lib/rollout/ui/helpers.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "digest/sha2"
|
|
2
|
+
require "json"
|
|
1
3
|
require "rack"
|
|
2
4
|
require "rollout"
|
|
3
5
|
|
|
@@ -5,7 +7,7 @@ require "rollout/ui/version"
|
|
|
5
7
|
|
|
6
8
|
module Rollout::UI
|
|
7
9
|
module Helpers
|
|
8
|
-
|
|
10
|
+
USER_PAGE_SIZE = 25
|
|
9
11
|
USER_SEARCH_MAX_LENGTH = 100
|
|
10
12
|
|
|
11
13
|
def stylesheet_path(name)
|
|
@@ -20,10 +22,22 @@ module Rollout::UI
|
|
|
20
22
|
"#{request.script_name}/users/search"
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
def enrolled_users_path(feature_name)
|
|
26
|
+
"#{request.script_name}/features/#{feature_name}/users"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def feature_user_search_path(feature_name)
|
|
30
|
+
"#{request.script_name}/features/#{feature_name}/users/search"
|
|
31
|
+
end
|
|
32
|
+
|
|
23
33
|
def user_selector_enabled?
|
|
24
34
|
config.defined?(:user_search) && config.defined?(:user_lookup)
|
|
25
35
|
end
|
|
26
36
|
|
|
37
|
+
def missing_definition?(feature_name)
|
|
38
|
+
@defined_features && !@defined_features.include?(feature_name.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
27
41
|
def user_search_min_length
|
|
28
42
|
config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
|
|
29
43
|
end
|
|
@@ -32,11 +46,23 @@ module Rollout::UI
|
|
|
32
46
|
config.get(:user_search_limit).to_i.clamp(1, 100)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
|
-
def user_label(id)
|
|
49
|
+
def user_label(id, include_id: true)
|
|
36
50
|
id = id.to_s
|
|
37
51
|
return @user_nicknames[id] if @user_nicknames&.key?(id)
|
|
38
52
|
|
|
39
|
-
@user_lookup_attempted&.key?(id) ? "Unknown user
|
|
53
|
+
label = @user_lookup_attempted&.key?(id) ? "Unknown user" : "User"
|
|
54
|
+
include_id ? "#{label} (##{id})" : label
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def feature_version(feature)
|
|
58
|
+
state = [
|
|
59
|
+
feature.name.to_s,
|
|
60
|
+
feature.percentage.to_f,
|
|
61
|
+
feature.groups.map(&:to_s).uniq.sort,
|
|
62
|
+
feature.users.map(&:to_s).uniq.sort,
|
|
63
|
+
feature.data["description"].to_s,
|
|
64
|
+
]
|
|
65
|
+
Digest::SHA256.hexdigest(state.to_json)
|
|
40
66
|
end
|
|
41
67
|
|
|
42
68
|
def index_path
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#enrolled-users .enrolled-user-search-field,
|
|
2
|
+
#enrolled-users #pending-user-slot {
|
|
3
|
+
position: relative;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
#enrolled-users .text-gray-500,
|
|
7
|
+
#enrolled-users .text-gray-600,
|
|
8
|
+
#enrolled-users .text-gray-400 {
|
|
9
|
+
color: #4a5568;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.dark #enrolled-users .text-gray-500,
|
|
13
|
+
.dark #enrolled-users .text-gray-600,
|
|
14
|
+
.dark #enrolled-users .text-gray-400 {
|
|
15
|
+
color: #b8b8b8;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#enrolled-users input::placeholder {
|
|
19
|
+
color: #4a5568;
|
|
20
|
+
opacity: 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.dark #enrolled-users input::placeholder {
|
|
24
|
+
color: #b8b8b8;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#enrolled-users #user-search-dropdown {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: calc(100% + 0.25rem);
|
|
30
|
+
left: 0;
|
|
31
|
+
right: 0;
|
|
32
|
+
z-index: 20;
|
|
33
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#enrolled-users #user-search-results {
|
|
37
|
+
max-height: min(16rem, 40vh);
|
|
38
|
+
overflow-y: auto;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#enrolled-users #user-search-result-status {
|
|
42
|
+
padding: 0.5rem 0.75rem;
|
|
43
|
+
line-height: 1rem;
|
|
44
|
+
max-height: min(8rem, 30vh);
|
|
45
|
+
overflow-y: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#enrolled-users #user-search-results [role="option"] > span:first-child {
|
|
49
|
+
min-width: 0;
|
|
50
|
+
overflow-wrap: anywhere;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
|
|
54
|
+
background-color: #edf2f7;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.dark #enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
|
|
58
|
+
background-color: #3a3a3a;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#enrolled-users #user-search-results [role="option"][aria-selected="true"] {
|
|
62
|
+
background-color: #2563eb;
|
|
63
|
+
color: #ffffff;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#enrolled-users #user-search-results [role="option"][aria-selected="true"] .text-gray-500 {
|
|
67
|
+
color: inherit;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#enrolled-users #user-search-results [role="option"][aria-disabled="true"] {
|
|
71
|
+
cursor: default;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#enrolled-users #user-search-status {
|
|
75
|
+
height: 1.5rem;
|
|
76
|
+
line-height: 1.5rem;
|
|
77
|
+
margin-top: 0.25rem;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
text-overflow: ellipsis;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#enrolled-users #pending-user-slot {
|
|
84
|
+
margin-top: 0.75rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#enrolled-users #pending-user-changes:not([hidden]) {
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
90
|
+
align-items: center;
|
|
91
|
+
column-gap: 0.5rem;
|
|
92
|
+
row-gap: 0.5rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#enrolled-users #pending-user-summary {
|
|
96
|
+
min-width: 0;
|
|
97
|
+
overflow-wrap: anywhere;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#enrolled-users #pending-user-actions {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 0.5rem;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#enrolled-users #pending-user-actions > button {
|
|
107
|
+
display: inline-flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
min-height: 2.5rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#enrolled-users #review-user-changes {
|
|
114
|
+
border-color: currentColor;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#enrolled-users #pending-user-review {
|
|
118
|
+
position: absolute;
|
|
119
|
+
bottom: calc(100% + 0.5rem);
|
|
120
|
+
right: 0;
|
|
121
|
+
width: min(100%, 28rem);
|
|
122
|
+
z-index: 20;
|
|
123
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#enrolled-users #pending-user-details {
|
|
127
|
+
max-height: min(16rem, 50vh);
|
|
128
|
+
overflow-y: auto;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#enrolled-users #pending-user-details li {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: baseline;
|
|
134
|
+
justify-content: space-between;
|
|
135
|
+
gap: 0.75rem;
|
|
136
|
+
margin-bottom: 0.25rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#enrolled-users #pending-user-details li span {
|
|
140
|
+
min-width: 0;
|
|
141
|
+
overflow-wrap: anywhere;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#enrolled-users button {
|
|
145
|
+
min-height: 24px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#enrolled-users .enrolled-user-pagination {
|
|
149
|
+
margin-bottom: 0.5rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#enrolled-users .enrolled-user-pagination [aria-disabled="true"] {
|
|
153
|
+
opacity: 0.5;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@media (max-width: 480px) {
|
|
157
|
+
#enrolled-users #pending-user-changes:not([hidden]) {
|
|
158
|
+
grid-template-columns: minmax(0, 1fr);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#enrolled-users #pending-user-actions {
|
|
162
|
+
justify-self: center;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#enrolled-users #enrolled-users-table {
|
|
166
|
+
table-layout: fixed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#enrolled-users #enrolled-users-table th,
|
|
170
|
+
#enrolled-users #enrolled-users-table td {
|
|
171
|
+
padding-left: 0.375rem;
|
|
172
|
+
padding-right: 0.375rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#enrolled-users #enrolled-users-table th:nth-child(2) {
|
|
176
|
+
width: 35%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#enrolled-users #enrolled-users-table th:last-child {
|
|
180
|
+
width: 4rem;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#enrolled-users #enrolled-users-table td:nth-child(2) {
|
|
184
|
+
overflow-wrap: anywhere;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#enrolled-users .enrolled-user-pagination a {
|
|
188
|
+
padding-left: 0.5rem;
|
|
189
|
+
padding-right: 0.5rem;
|
|
190
|
+
}
|
|
191
|
+
}
|