Skip to content

About

Minimalist UI for Rollout

Resources

Stars

77 stars

Watchers

13 watching

Forks

Repository files navigation

Rollout::UI

A Rack-mountable UI for the rollout gem.

Index Page

Usage with Rails

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"

  # ...
end

Configure your Rollout instance in routes.rb or an initializer:

Rollout::UI.configure do
  instance { $rollout }
end

Authentication

Protect 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

  # ...
end

Nickname user selector

Configure 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
end

Both 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.

Paginated enrollment editing

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.

Concurrent edits

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.

Logging

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}" }
end

When 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.

API Endpoints

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.

Host Header Validation

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.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.

Development Setup

This project uses mise for managing development tools.

Install mise if you haven't already:

curl https://mise.run | sh

Then install the required tools and dependencies:

mise install
bundle install

To run this project for development in isolation:

bundle exec rerun rackup

And visit http://localhost:9292/.

To use a different Redis connection:

REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=10 bundle exec rerun rackup

Releasing

  1. Bump version: rake version:patch (or minor/major)
  2. Commit and tag: git commit -am "Bump version" && git tag v0.7.3
  3. Push: git push origin master --tags

The GitHub Actions workflow will automatically publish to RubyGems when tags are pushed.

License

The gem is available as open source under the terms of the MIT License.

About

Minimalist UI for Rollout

Resources

Stars

77 stars

Watchers

13 watching

Forks

Releases

Packages

Contributors

Languages