A Rack-mountable UI for the rollout gem.
Add it to your application's Gemfile:
gem "rollout", "~> 3.1"
gem "rollout-redis-adapter", "~> 0.1"
gem "rollout-ui"rollout-ui 0.9+ requires Rollout 3.1 or later within 3.x. It works with any
Rollout adapter; Redis applications also need rollout-redis-adapter.
Mount the Rack app:
Rails.application.routes.draw do
mount Rollout::UI::Web.new => "/admin/rollout"
# ...
endConfigure your Rollout instance in routes.rb or an initializer:
Rollout::UI.configure do
instance { $rollout }
endProtect the Rails mount with a constraint. For example, with Devise:
class AdminConstraint
def self.matches?(request)
id = request.session["warden.user.user.key"].try(:[], 0).try(:[], 0)
return false if id.blank?
user = User.find_by(id: id)
user&.admin?
end
end
Rails.application.routes.draw do
mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
# ...
endConfigure both callbacks below to replace the user ID field with a searchable nickname picker alongside the enrolled-user list. Your application supplies the queries; the UI displays nicknames and Rollout stores user IDs. This example uses Rails and PostgreSQL:
Rollout::UI.configure do
instance { $rollout }
user_search do |query, limit|
prefix = User.sanitize_sql_like(query.downcase) + "%"
User.where("lower(nickname) LIKE ?", prefix)
.limit(limit)
.pluck(:id, :nickname)
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
end
user_lookup do |ids|
User.where(id: ids)
.pluck(:id, :nickname)
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
end
endBoth callbacks return arrays of hashes with id and nickname keys (symbols or
strings). user_search receives a trimmed nickname query and a result limit.
user_lookup receives a batch of selected or historical user IDs as strings;
omit missing accounts. Apply your account visibility rules in both callbacks.
Search defaults to 3 characters and 20 results, with a 300 ms debounce. Override these settings inside the same configuration block:
user_search_min_length { 3 }
user_search_limit { 20 }For large directories, use indexed nickname-only prefix matching and apply
the limit in the database. Escape wildcards as above; avoid loading all users or
running unindexed %contains% queries. A matching index for this example is:
CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
ON users (lower(nickname) text_pattern_ops);Check the query plan against your schema and collation. The selector supports
keyboard navigation and preserves pending changes if a lookup fails. The
enrolled count includes unresolved accounts, shown as Unknown user with their
ID in a separate column, but excludes users enabled through groups or percentage.
Every feature shows a paginated enrollment list with 25 users per page. Rows are ordered by user ID. Nicknames are looked up only for the visible page, independently of the bounded history lookup. There is no longer a 150-user enrollment editing cutoff.
Remove/Undo and nickname additions are staged until Update. With JavaScript, switching enrollment pages leaves pending changes and unsaved description, groups, and percentage fields intact. A pending-changes callout below the table opens a Review popover listing additions and removals across all pages. Search marks already-enrolled users even when they are on another page. The heading counts stored enrollments; pending changes are reported separately until saved.
The form submits additions and removals, not a replacement list from the visible
page. Delta requests use user_changes=1 and require a full-state
feature_version; malformed markers and missing versions are rejected. After
version validation, these deltas are applied to the full membership
inside with_feature. Duplicate additions are idempotent; removals take
precedence if an ID appears in both sets. Legacy full-list users submissions
remain supported.
Without JavaScript or nickname callbacks, add users using comma-separated IDs and check Remove for visible rows. Update before following page links to avoid discarding unsaved form changes. Enrollment pagination remains available when nickname lookup fails; unknown IDs are never silently dropped.
Feature forms include a feature_version fingerprint of the editable state:
percentage, groups, enrolled IDs, and description. Ordering of IDs and groups,
timestamps, and unrelated metadata do not change the fingerprint. Distinct edits
in the same second and changes made outside the UI are detected.
Updates validate the submitted fingerprint inside with_feature, before changing
state. A stale submission is rejected without saving state or history, and the
UI redirects to the latest feature so the administrator can review and retry.
Older non-delta forms without feature_version retain the last_updated_at
timestamp check; that legacy check cannot distinguish same-second edits.
Atomic conflict detection depends on the adapter's mutation guarantees. The
ActiveRecord adapter locks existing feature rows during with_feature. The
Redis adapter does not provide the equivalent lock or compare-and-set guarantee.
Use uncached feature reads for the Rollout instance supplied to the admin UI.
Process-local adapter caches can otherwise display an older enrolled list and
form version after another worker saves. Normal feature evaluation can use a
separate cached instance with the same group definitions. Feature responses send
Cache-Control: no-store; this does not bypass an adapter's server-side cache.
Enable logging on your Rollout instance to display change history:
require "redis"
require "rollout"
require "rollout/adapters/redis"
$redis = Redis.new
$rollout = Rollout.new(
adapter: Rollout::Adapters::Redis.new($redis),
logging: { history_length: 100, global: true },
)Configure actor and actor_url to attribute changes. For example, with Devise:
Rollout::UI.configure do
instance { $rollout }
actor { current_user&.nickname }
actor_url { |actor| "/#{actor}" }
endWhen using a Rollout version and adapter that support event-aware deletion,
deletions appear in the overview history with the configured actor. This
requires global logging (global: true); adapters without event-aware deletion
retain the previous behavior and do not add a deletion event.
With user_lookup configured, feature and overview history display current
nicknames while audit events retain IDs. History resolves up to 150 additional
distinct IDs per page; missing accounts, failed lookups, and IDs beyond that limit
use ID-based labels.
Send Accept: application/json to the index or feature route for JSON responses.
The index also accepts filters such as /admin/rollout?user=123 and
/admin/rollout?group=developers.
When both nickname callbacks are configured, GET /users/search?q=alice
(relative to the mount) returns {"users":[{"id":"123","nickname":"alice"}]}.
It uses the mount's authentication, caps results server-side, and sends
Cache-Control: no-store. Queries below the configured minimum return an empty
list without calling user_search. Queries are limited to 100 characters;
configured minimum lengths and result limits are bounded to 1–100.
GET /features/:name/users?page=2 returns the visible enrollment
page, total enrollment count, page metadata, full-state feature_version, and
lookup_failed. Pages contain at most 25 users and page numbers are clamped to
the available range. The response never contains the entire
membership list and sends Cache-Control: no-store.
GET /features/:name/users/search?q=alice uses the same nickname callbacks and
limits, adds an enrolled flag to each match based on the full membership, and
includes feature_version. The unscoped /users/search response is unchanged.
Paginated reads never replace the form's original version token; if the rollout
changes while editing, the administrator must review the newer state.
When mounted in Rails, requests pass through Rails' config.hosts checks. The gem
performs no additional Host header validation.
For standalone use (e.g. rackup), configure authentication and host validation
through middleware such as
Rack::Protection::HostAuthorization
or a reverse proxy that only forwards trusted hosts.
Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
This project uses mise for managing development tools.
Install mise if you haven't already:
curl https://mise.run | shThen install the required tools and dependencies:
mise install
bundle installTo run this project for development in isolation:
bundle exec rerun rackupAnd visit http://localhost:9292/.
To use a different Redis connection:
REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=10 bundle exec rerun rackup- Bump version:
rake version:patch(orminor/major) - Commit and tag:
git commit -am "Bump version" && git tag v0.7.3 - Push:
git push origin master --tags
The GitHub Actions workflow will automatically publish to RubyGems when tags are pushed.
The gem is available as open source under the terms of the MIT License.
