rollout-ui 0.9.1 → 0.9.3

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: e63cbca0b53def021d093e24434d1c3857515164a82f92878f0b832c09b7319a
4
- data.tar.gz: 05a822fb2f9ed0e8032681e255fc981dfa70d23f2e061bec5bf59bad6c54f722
3
+ metadata.gz: 4af968fe0271d80faec7b339779f81a5b8eb9bd06d23b984c7fb5436384b0593
4
+ data.tar.gz: 4d4ef32df14c4282b72ea2c5f6a3fee00266dd137ea612b211bcab1e9bc0786f
5
5
  SHA512:
6
- metadata.gz: 3472089c2fe218f5c1bc35911a15239ed79e3c243d91903d2c04f53d09e816893dc2f841ad7d287d7debece8b53d77d39a3b725db27f0f92039cea8959a01db7
7
- data.tar.gz: 3844e1d841186b55edb5757f92e3cb48c517ba94beb26c06dcc97ea4ebbe1af44665aed0e882dc61584113dd52ca44aeeef28118a9e49e7c943adbc5a8e5f89a
6
+ metadata.gz: 6af36b891a09fcf1f5f22abe858c10c266db3c9c48444d58e3a72f38daa1dfa3e444b7e443857d77261d041540c0533501e9548f1ad6e7582902773b0c399f85
7
+ data.tar.gz: cbeb563460654cb4405a3f2eaaf515426d92311f0ae3eb32dd8eaeea1455262b86e80acb2379c41556459ab579f8cb779cb8923522bdb0dc42c4ff19d67fb369
data/README.md CHANGED
@@ -62,6 +62,13 @@ Rails.application.routes.draw do
62
62
  end
63
63
  ```
64
64
 
65
+ ## Host Header Validation
66
+
67
+ This gem performs no Host header validation of its own. When mounted inside a Rails app (as shown above), requests already pass through Rails' own `config.hosts` checks before reaching this mount point.
68
+
69
+ If you run this as a standalone app (e.g. via `rackup`), you're responsible for host header validation in the same way you're responsible for authentication, for example with
70
+ [`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api) in front of it, or a reverse proxy that only forwards trusted hosts.
71
+
65
72
  ## API Endpoints
66
73
 
67
74
  The index and show routes can also respond with JSON data instead of HTML when the request's `Accept` header is
@@ -100,6 +107,11 @@ Rollout::UI.configure do
100
107
  end
101
108
  ```
102
109
 
110
+ When using a Rollout version and adapter that support event-aware deletion,
111
+ deletions appear in the overview history with the configured actor. This
112
+ requires global logging (`global: true`); adapters without event-aware deletion
113
+ retain the previous behavior and do not add a deletion event.
114
+
103
115
  ## Contributing
104
116
 
105
117
  Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
@@ -1,4 +1,3 @@
1
- require "sinatra"
2
1
  require "rollout"
3
2
 
4
3
  require "rollout/ui/version"
@@ -1,4 +1,4 @@
1
- require "sinatra"
1
+ require "rack"
2
2
  require "rollout"
3
3
 
4
4
  require "rollout/ui/version"
@@ -70,6 +70,16 @@ module Rollout::UI
70
70
  end
71
71
  end
72
72
 
73
+ def formatted_timestamp(time)
74
+ time.strftime(config.get(:timestamp_format))
75
+ end
76
+
77
+ def relative_timestamp_tag(time)
78
+ return "" unless time
79
+
80
+ %(<span data-timestamp="#{h(time.iso8601)}" title="#{h(formatted_timestamp(time))}">#{h(time_ago(time))}</span>)
81
+ end
82
+
73
83
  def format_change_key(key)
74
84
  key.to_s.gsub('data.', '')
75
85
  end
@@ -85,6 +95,25 @@ module Rollout::UI
85
95
  end
86
96
  end
87
97
 
98
+ def event_actor(event)
99
+ actor = event.context && event.context[:actor]
100
+ return "unidentified user" unless actor
101
+ return h(actor) unless config.defined?(:actor_url)
102
+
103
+ %(<a class="underline" href="#{h(config.get(:actor_url, actor))}" target="_blank">#{h(actor)}</a>)
104
+ end
105
+
106
+ def event_summary(event)
107
+ return "deleted this feature" if event.name.to_s == "delete"
108
+
109
+ changes = event.data.fetch(:before).keys.reject { |key| key.to_s == 'data.updated_at' }.map do |key|
110
+ before = format_change_value(event.data.fetch(:before).fetch(key))
111
+ after = format_change_value(event.data.fetch(:after).fetch(key))
112
+ "#{format_change_key(key)} from #{before} to #{after}"
113
+ end
114
+ "changed #{changes.empty? ? 'nothing!' : changes.join(', ')}"
115
+ end
116
+
88
117
  def json_request?
89
118
  request.env['HTTP_ACCEPT'] == 'application/json'
90
119
  end
@@ -1,5 +1,5 @@
1
1
  class Rollout
2
2
  module UI
3
- VERSION = "0.9.1"
3
+ VERSION = "0.9.3"
4
4
  end
5
5
  end
@@ -49,7 +49,7 @@
49
49
  <% if updated_at.nil? %>
50
50
  <span class="text-gray-400">Never</span>
51
51
  <% else %>
52
- <span title="<%= h(updated_at.strftime(Rollout::UI.config.timestamp_format)) %>"><%= h(time_ago(updated_at)) %></span>
52
+ <%= relative_timestamp_tag(updated_at) %>
53
53
  <% end %>
54
54
  </td>
55
55
  <% end %>
@@ -31,31 +31,15 @@
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 %>
55
39
  </td>
56
40
 
57
41
  <td class="py-2 text-right whitespace-nowrap pl-3" style="min-width: 160px;">
58
- <span title="<%= h(event.created_at.strftime(Rollout::UI.config.timestamp_format)) %>"><%= h(time_ago(event.created_at)) %></span>
42
+ <%= relative_timestamp_tag(event.created_at) %>
59
43
  </td>
60
44
  </tr>
61
45
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html data-timestamp-format="<%= h(config.get(:timestamp_format)) %>">
3
3
  <head>
4
4
  <title>Rollout UI</title>
5
5
  <meta name="viewport" content="width=device-width initial-scale=1">
@@ -42,19 +42,85 @@
42
42
  }
43
43
  }
44
44
  applyTheme(theme);
45
+ const themeLabels = { system: 'Theme: System', light: 'Theme: Light', dark: 'Theme: Dark' };
46
+ const themeTitles = {
47
+ system: "Follows your device's light or dark appearance setting.",
48
+ light: 'Always use light appearance.',
49
+ dark: 'Always use dark appearance.'
50
+ };
45
51
  window.addEventListener('DOMContentLoaded', function() {
46
52
  const btn = document.getElementById('theme-toggle');
47
- btn.textContent = theme;
53
+ function applyThemeLabel(t) {
54
+ btn.textContent = themeLabels[t];
55
+ btn.title = themeTitles[t];
56
+ }
57
+ applyThemeLabel(theme);
48
58
  btn.addEventListener('click', function() {
49
59
  const current = localStorage.getItem('theme') || 'system';
50
60
  const next = current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light';
51
61
  localStorage.setItem('theme', next);
52
62
  applyTheme(next);
53
- btn.textContent = next;
63
+ applyThemeLabel(next);
54
64
  });
55
65
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
56
66
  if (localStorage.getItem('theme') === 'system') applyTheme('system');
57
67
  });
68
+
69
+ const timezoneKey = 'timezone';
70
+ const timezoneBtn = document.getElementById('timezone-toggle');
71
+ function timezoneMode() {
72
+ return localStorage.getItem(timezoneKey) === 'utc' ? 'utc' : 'local';
73
+ }
74
+ function formatTimestamp(iso, mode) {
75
+ const date = new Date(iso);
76
+ if (Number.isNaN(date.getTime())) return null;
77
+ const format = document.documentElement.getAttribute('data-timestamp-format') || '%Y-%m-%d %H:%M %Z';
78
+ const parts = {};
79
+ new Intl.DateTimeFormat('en-US', {
80
+ year: 'numeric',
81
+ month: '2-digit',
82
+ day: '2-digit',
83
+ hour: '2-digit',
84
+ minute: '2-digit',
85
+ second: '2-digit',
86
+ hourCycle: 'h23',
87
+ timeZone: mode === 'utc' ? 'UTC' : undefined,
88
+ timeZoneName: 'short'
89
+ }).formatToParts(date).forEach(function(part) {
90
+ if (part.type !== 'literal') parts[part.type] = part.value;
91
+ });
92
+ const tokens = {
93
+ '%Y': parts.year,
94
+ '%y': parts.year.slice(-2),
95
+ '%m': parts.month,
96
+ '%d': parts.day,
97
+ '%H': parts.hour,
98
+ '%M': parts.minute,
99
+ '%S': parts.second,
100
+ '%Z': parts.timeZoneName,
101
+ '%%': '%'
102
+ };
103
+ return format.replace(/%[%YymdHMSZ]/g, function(token) {
104
+ return Object.prototype.hasOwnProperty.call(tokens, token) ? tokens[token] : token;
105
+ });
106
+ }
107
+ function applyTimezone(mode) {
108
+ const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local';
109
+ timezoneBtn.textContent = mode === 'utc' ? 'Time: UTC' : 'Time: Local';
110
+ timezoneBtn.title = mode === 'utc'
111
+ ? 'Uses Coordinated Universal Time.'
112
+ : "Uses your browser's timezone: " + localZone;
113
+ document.querySelectorAll('[data-timestamp]').forEach(function(el) {
114
+ const formatted = formatTimestamp(el.getAttribute('data-timestamp'), mode);
115
+ if (formatted) el.setAttribute('title', formatted);
116
+ });
117
+ }
118
+ applyTimezone(timezoneMode());
119
+ timezoneBtn.addEventListener('click', function() {
120
+ const next = timezoneMode() === 'local' ? 'utc' : 'local';
121
+ localStorage.setItem(timezoneKey, next);
122
+ applyTimezone(next);
123
+ });
58
124
  });
59
125
  })();
60
126
  </script>
@@ -63,7 +129,8 @@
63
129
  <h1 class="font-light text-4xl text-gray-600 flex-auto">
64
130
  <a href="<%= h(index_path) %>" class="hover:text-blue-700">Rollout </a>
65
131
  </h1>
66
- <button id="theme-toggle" class="text-sm text-gray-600 p-2 bg-gray-100 rounded-sm transition-colors duration-150 hover:bg-gray-200">system</button>
132
+ <button type="button" id="timezone-toggle" class="text-sm text-gray-600 p-2 bg-gray-100 rounded-sm transition-colors duration-150 hover:bg-gray-200 mr-2" title="Uses your browser's timezone">Time: Local</button>
133
+ <button type="button" id="theme-toggle" class="text-sm text-gray-600 p-2 bg-gray-100 rounded-sm transition-colors duration-150 hover:bg-gray-200" title="Follows your device's light or dark appearance setting.">Theme: System</button>
67
134
  </div>
68
135
  <%= yield %>
69
136
  </div>
@@ -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,228 @@ 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__)
13
+
14
+ NOT_FOUND = ->(env) { [404, { 'content-type' => 'text/plain', 'x-cascade' => 'pass' }, ['Not Found']] }
29
15
 
30
- get '/features/new' do
31
- erb :'features/new'
16
+ def initialize
17
+ @static = Rack::Static.new(NOT_FOUND, urls: ['/css'], 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
+
36
+ ROUTES = [
37
+ [:GET, %r{\A/\z}, :index],
38
+ [:GET, %r{\A/features/new\z}, :new_feature],
39
+ [:POST, %r{\A/features/new\z}, :create_feature],
40
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
41
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)/delete\z}, :delete_feature],
42
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)\z}, :show],
43
+ [:POST, %r{\A/features/(?<feature_name>[^/]+)\z}, :update],
44
+ ].freeze
45
+
46
+ attr_reader :request, :response, :params
41
47
 
42
- if json_request?
43
- json(feature_to_hash(@feature))
44
- else
45
- erb :'features/show'
48
+ def call(env)
49
+ @request = Rack::Request.new(env)
50
+ @response = Rack::Response.new
51
+ response['x-content-type-options'] = 'nosniff'
52
+ response['x-frame-options'] = 'SAMEORIGIN'
53
+ response['x-xss-protection'] = '1; mode=block'
54
+
55
+ # Rack::URLMap strips the mount prefix from PATH_INFO, leaving "" (not
56
+ # "/") when a mounted app is requested at its exact mount point.
57
+ path_info = request.path_info
58
+ path_info = '/' if path_info.empty?
59
+
60
+ method = request.request_method.to_sym
61
+ lookup_method = method == :HEAD ? :GET : method
62
+ route = ROUTES.find { |verb, pattern, _| verb == lookup_method && pattern.match?(path_info) }
63
+ return not_found unless route
64
+
65
+ _, pattern, action = route
66
+ begin
67
+ @params = build_params(pattern.match(path_info).named_captures)
68
+ rescue Rack::BadRequest => e
69
+ return bad_request(e)
70
+ end
71
+
72
+ send(action)
73
+ response.body = [] if method == :HEAD
74
+ response.finish
75
+ end
76
+
77
+ private
78
+
79
+ def index
80
+ @rollout = config.get(:instance)
81
+ @features = @rollout.features.sort_by(&:downcase)
82
+ if json_request?
83
+ json(
84
+ filtered_features(@rollout, @features).map do |feature|
85
+ feature_to_hash(@rollout.get(feature))
86
+ end
87
+ )
88
+ else
89
+ render_view(:'features/index')
90
+ end
91
+ end
92
+
93
+ def new_feature
94
+ render_view(:'features/new')
95
+ end
96
+
97
+ def create_feature
98
+ redirect_to feature_path(params[:name])
99
+ end
100
+
101
+ def show
102
+ @rollout = config.get(:instance)
103
+ @feature = @rollout.get(params[:feature_name])
104
+
105
+ if json_request?
106
+ json(feature_to_hash(@feature))
107
+ else
108
+ render_view(:'features/show')
109
+ end
110
+ end
111
+
112
+ def update
113
+ rollout = config.get(:instance)
114
+ actor = config.get(:actor, scope: self)
115
+ feature_data = rollout.get(params[:feature_name]).data
116
+ if feature_data['updated_at'] && params[:last_updated_at].to_s != feature_data['updated_at'].to_s
117
+ return redirect_to("#{feature_path(params[:feature_name])}?error=Rollout version outdated. Review changes below and try again.")
118
+ end
119
+
120
+ with_rollout_context(rollout, actor: actor) do
121
+ rollout.with_feature(params[:feature_name]) do |feature|
122
+ feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
123
+ feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
124
+ if params[:users]
125
+ feature.users = params[:users].split(',').map(&:strip).uniq.sort
126
+ end
127
+ feature.data["description"] = params[:description]
128
+ feature.data["updated_at"] = Time.now.to_i
129
+ end
130
+ end
131
+
132
+ redirect_to feature_path(params[:feature_name])
46
133
  end
47
- end
48
134
 
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
135
+ def activate_percentage
136
+ rollout = config.get(:instance)
137
+ actor = config.get(:actor, scope: self)
138
+
139
+ with_rollout_context(rollout, actor: actor) do
140
+ rollout.with_feature(params[:feature_name]) do |feature|
141
+ feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
142
+ feature.data["updated_at"] = Time.now.to_i
62
143
  end
63
- feature.data["description"] = params[:description]
64
- feature.data["updated_at"] = Time.now.to_i
65
144
  end
145
+
146
+ redirect_to index_path
66
147
  end
67
148
 
68
- redirect feature_path(params[:feature_name])
69
- end
149
+ def delete_feature
150
+ rollout = config.get(:instance)
151
+ actor = config.get(:actor, scope: self)
152
+
153
+ with_rollout_context(rollout, actor: actor) do
154
+ rollout.delete(params[:feature_name])
155
+ end
156
+
157
+ redirect_to index_path
158
+ end
159
+
160
+ def not_found
161
+ response.status = 404
162
+ response['content-type'] = 'text/plain'
163
+ response['x-cascade'] = 'pass'
164
+ response.write('Not Found')
165
+ response.finish
166
+ end
167
+
168
+ def bad_request(error)
169
+ response.status = 400
170
+ response['content-type'] = 'text/plain'
171
+ response.write("Bad Request: #{error.message}")
172
+ response.finish
173
+ end
70
174
 
71
- post '/features/:feature_name/activate-percentage' do
72
- rollout = config.get(:instance)
73
- actor = config.get(:actor, scope: self)
175
+ def build_params(route_params)
176
+ route_params = route_params.transform_values { |value| Rack::Utils.unescape_path(value) }
74
177
 
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
178
+ request.params.merge(route_params).each_with_object({}) do |(key, value), hash|
179
+ hash[key.to_s] = value
180
+ hash[key.to_sym] = value
79
181
  end
80
182
  end
81
183
 
82
- redirect index_path
83
- end
184
+ # Mirrors Sinatra's own `redirect`: browsers replaying a 302 after a
185
+ # non-GET request may re-issue the original method, so redirects away
186
+ # from a POST use 303 See Other instead.
187
+ def redirect_to(location)
188
+ http_version = request.env['SERVER_PROTOCOL'] || request.env['HTTP_VERSION']
189
+ status = (http_version == 'HTTP/1.1' && request.request_method != 'GET') ? 303 : 302
190
+ response.redirect(location, status)
191
+ end
192
+
193
+ def json(data)
194
+ response.headers.delete('x-frame-options')
195
+ response.headers.delete('x-xss-protection')
196
+ response['content-type'] = 'application/json'
197
+ response.write(data.to_json)
198
+ end
199
+
200
+ # Exposed so `Rollout::UI.configure { actor { env[...] } }` /
201
+ # `actor { session[...] }` blocks keep working when instance_eval'd
202
+ # against this dispatcher, matching Sinatra's own request-scoped
203
+ # `env`/`session` helpers.
204
+ def env
205
+ request.env
206
+ end
207
+
208
+ def session
209
+ request.session
210
+ end
211
+
212
+ def render_view(name)
213
+ response['content-type'] = 'text/html;charset=utf-8'
214
+ response.write(erb(name))
215
+ end
84
216
 
85
- post '/features/:feature_name/delete' do
86
- @rollout = config.get(:instance)
87
- @rollout.delete(params[:feature_name])
217
+ # Called from within views to render a partial, e.g.
218
+ # `<%= erb :"features/partials/event_log", layout: false, locals: { events: events } %>`
219
+ def erb(name, layout: true, locals: {})
220
+ return render_template(name, locals) unless layout
88
221
 
89
- redirect index_path
222
+ render_template(:layout, locals) { render_template(name, locals) }
223
+ end
224
+
225
+ def render_template(name, locals = {}, &block)
226
+ path = File.join(VIEWS_PATH, "#{name}.erb")
227
+ template_source = (TEMPLATE_CACHE[name] ||= ERB.new(File.read(path), trim_mode: '-').src)
228
+
229
+ template_binding = binding
230
+ locals.each { |key, value| template_binding.local_variable_set(key, value) }
231
+ template_binding.eval(template_source, path)
232
+ end
90
233
  end
91
234
  end
92
235
  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.1
4
+ version: 0.9.3
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