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 +4 -4
- data/README.md +12 -0
- data/lib/rollout/ui/config.rb +0 -1
- data/lib/rollout/ui/helpers.rb +30 -1
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +1 -1
- data/lib/rollout/ui/views/features/partials/event_log.erb +3 -19
- data/lib/rollout/ui/views/layout.erb +71 -4
- data/lib/rollout/ui/web.rb +206 -63
- data/lib/rollout/ui.rb +0 -1
- data/rollout-ui.gemspec +2 -2
- metadata +9 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4af968fe0271d80faec7b339779f81a5b8eb9bd06d23b984c7fb5436384b0593
|
|
4
|
+
data.tar.gz: 4d4ef32df14c4282b72ea2c5f6a3fee00266dd137ea612b211bcab1e9bc0786f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/rollout/ui/config.rb
CHANGED
data/lib/rollout/ui/helpers.rb
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require "
|
|
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
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<% if updated_at.nil? %>
|
|
50
50
|
<span class="text-gray-400">Never</span>
|
|
51
51
|
<% else %>
|
|
52
|
-
|
|
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
|
|
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 %>
|
|
55
39
|
</td>
|
|
56
40
|
|
|
57
41
|
<td class="py-2 text-right whitespace-nowrap pl-3" style="min-width: 160px;">
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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>
|
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,228 @@ require "rollout/ui/config"
|
|
|
7
8
|
require "rollout/ui/helpers"
|
|
8
9
|
|
|
9
10
|
module Rollout::UI
|
|
10
|
-
class Web
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
31
|
-
|
|
16
|
+
def initialize
|
|
17
|
+
@static = Rack::Static.new(NOT_FOUND, urls: ['/css'], 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
|
+
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
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.9.
|
|
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:
|
|
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
|