super_settings 1.0.2 → 2.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +128 -26
- data/VERSION +1 -1
- data/app/helpers/super_settings/settings_helper.rb +13 -3
- data/app/views/layouts/super_settings/settings.html.erb +1 -1
- data/config/routes.rb +1 -1
- data/db/migrate/20210414004553_create_super_settings.rb +1 -7
- data/lib/super_settings/application/api.js +4 -1
- data/lib/super_settings/application/helper.rb +56 -17
- data/lib/super_settings/application/images/arrow-down-short.svg +3 -0
- data/lib/super_settings/application/images/arrow-up-short.svg +3 -0
- data/lib/super_settings/application/images/info-circle.svg +4 -0
- data/lib/super_settings/application/images/pencil-square.svg +4 -0
- data/lib/super_settings/application/images/plus.svg +3 -1
- data/lib/super_settings/application/images/trash3.svg +3 -0
- data/lib/super_settings/application/images/x-circle.svg +4 -0
- data/lib/super_settings/application/index.html.erb +54 -37
- data/lib/super_settings/application/layout.html.erb +5 -2
- data/lib/super_settings/application/layout_styles.css +7 -151
- data/lib/super_settings/application/layout_vars.css.erb +21 -0
- data/lib/super_settings/application/scripts.js +162 -37
- data/lib/super_settings/application/style_vars.css.erb +62 -0
- data/lib/super_settings/application/styles.css +183 -14
- data/lib/super_settings/application.rb +18 -11
- data/lib/super_settings/attributes.rb +1 -8
- data/lib/super_settings/configuration.rb +9 -0
- data/lib/super_settings/controller_actions.rb +2 -2
- data/lib/super_settings/engine.rb +1 -1
- data/lib/super_settings/history_item.rb +1 -1
- data/lib/super_settings/http_client.rb +165 -0
- data/lib/super_settings/rack_application.rb +3 -3
- data/lib/super_settings/rest_api.rb +5 -4
- data/lib/super_settings/setting.rb +13 -2
- data/lib/super_settings/storage/active_record_storage.rb +7 -0
- data/lib/super_settings/storage/history_attributes.rb +31 -0
- data/lib/super_settings/storage/http_storage.rb +60 -184
- data/lib/super_settings/storage/json_storage.rb +201 -0
- data/lib/super_settings/storage/mongodb_storage.rb +238 -0
- data/lib/super_settings/storage/redis_storage.rb +49 -111
- data/lib/super_settings/storage/s3_storage.rb +165 -0
- data/lib/super_settings/storage/storage_attributes.rb +64 -0
- data/lib/super_settings/storage/test_storage.rb +3 -5
- data/lib/super_settings/storage/transaction.rb +67 -0
- data/lib/super_settings/storage.rb +13 -6
- data/lib/super_settings/time_precision.rb +36 -0
- data/lib/super_settings.rb +11 -0
- data/super_settings.gemspec +4 -2
- metadata +22 -9
- data/lib/super_settings/application/images/edit.svg +0 -1
- data/lib/super_settings/application/images/info.svg +0 -1
- data/lib/super_settings/application/images/slash.svg +0 -1
- data/lib/super_settings/application/images/trash.svg +0 -1
@@ -10,31 +10,38 @@ module SuperSettings
|
|
10
10
|
# @param layout [String, Symbol] path to an ERB template to use as the layout around the application UI. You can
|
11
11
|
# pass the symbol +:default+ to use the default layout that ships with the gem.
|
12
12
|
# @param add_to_head [String] HTML code to add to the <head> element on the page.
|
13
|
-
|
13
|
+
# @param api_base_url [String] the base URL for the REST API.
|
14
|
+
# @param color_scheme [Symbol] whether to use dark mode for the application UI. If +nil+, the user's system
|
15
|
+
# preference will be used.
|
16
|
+
def initialize(layout: nil, add_to_head: nil, api_base_url: nil, color_scheme: nil)
|
14
17
|
if layout
|
15
18
|
layout = File.expand_path(File.join("application", "layout.html.erb"), __dir__) if layout == :default
|
16
|
-
@layout = ERB.new(File.read(layout))
|
19
|
+
@layout = ERB.new(File.read(layout)) if layout
|
17
20
|
@add_to_head = add_to_head
|
21
|
+
else
|
22
|
+
@layout = nil
|
23
|
+
@add_to_head = nil
|
18
24
|
end
|
25
|
+
|
26
|
+
@api_base_url = api_base_url
|
27
|
+
@color_scheme = color_scheme&.to_sym
|
19
28
|
end
|
20
29
|
|
21
|
-
# Render the
|
30
|
+
# Render the web UI application HTML.
|
22
31
|
#
|
23
32
|
# @return [void]
|
24
|
-
def render
|
25
|
-
template = ERB.new(File.read(File.expand_path(File.join("application",
|
33
|
+
def render
|
34
|
+
template = ERB.new(File.read(File.expand_path(File.join("application", "index.html.erb"), __dir__)))
|
26
35
|
html = template.result(binding)
|
27
|
-
if @layout
|
28
|
-
|
29
|
-
|
30
|
-
html
|
31
|
-
end
|
36
|
+
html = render_layout { html } if @layout
|
37
|
+
html = html.html_safe if html.respond_to?(:html_safe)
|
38
|
+
html
|
32
39
|
end
|
33
40
|
|
34
41
|
private
|
35
42
|
|
36
43
|
def render_layout
|
37
|
-
@layout
|
44
|
+
@layout&.result(binding)
|
38
45
|
end
|
39
46
|
end
|
40
47
|
end
|
@@ -4,20 +4,13 @@ module SuperSettings
|
|
4
4
|
# Interface to expose mass setting attributes on an object. Setting attributes with a
|
5
5
|
# hash will simply call the attribute writers for each key in the hash.
|
6
6
|
module Attributes
|
7
|
-
class UnknownAttributeError < StandardError
|
8
|
-
end
|
9
|
-
|
10
7
|
def initialize(attributes = nil)
|
11
8
|
self.attributes = attributes if attributes
|
12
9
|
end
|
13
10
|
|
14
11
|
def attributes=(values)
|
15
12
|
values.each do |name, value|
|
16
|
-
if respond_to?(:"#{name}=", true)
|
17
|
-
send(:"#{name}=", value)
|
18
|
-
else
|
19
|
-
raise UnknownAttributeError.new("unknown attribute #{name.to_s.inspect} for #{self.class}")
|
20
|
-
end
|
13
|
+
send(:"#{name}=", value) if respond_to?(:"#{name}=", true)
|
21
14
|
end
|
22
15
|
end
|
23
16
|
end
|
@@ -25,6 +25,7 @@ module SuperSettings
|
|
25
25
|
def initialize
|
26
26
|
@superclass = nil
|
27
27
|
@web_ui_enabled = true
|
28
|
+
@color_scheme = false
|
28
29
|
@changed_by_block = nil
|
29
30
|
end
|
30
31
|
|
@@ -63,6 +64,14 @@ module SuperSettings
|
|
63
64
|
!!@web_ui_enabled
|
64
65
|
end
|
65
66
|
|
67
|
+
# Set dark mode for the web UI. Possible values are :light, :dark, or :system.
|
68
|
+
# The default value is :light.
|
69
|
+
attr_writer :color_scheme
|
70
|
+
|
71
|
+
def color_scheme
|
72
|
+
(@color_scheme ||= :light).to_sym
|
73
|
+
end
|
74
|
+
|
66
75
|
# Enhance the controller. You can define methods or call controller class methods like
|
67
76
|
# +before_action+, etc. in the block. These will be applied to the engine controller.
|
68
77
|
# This is essentially the same a monkeypatching the controller class.
|
@@ -19,7 +19,7 @@ module SuperSettings
|
|
19
19
|
|
20
20
|
# Render the HTML application for managing settings.
|
21
21
|
def root
|
22
|
-
html = SuperSettings::Application.new.render
|
22
|
+
html = SuperSettings::Application.new.render
|
23
23
|
render html: html.html_safe, layout: true
|
24
24
|
end
|
25
25
|
|
@@ -40,7 +40,7 @@ module SuperSettings
|
|
40
40
|
|
41
41
|
# API endpoint for updating settings. See SuperSettings::RestAPI for details.
|
42
42
|
def update
|
43
|
-
changed_by =
|
43
|
+
changed_by = SuperSettings.configuration.controller.changed_by(self)
|
44
44
|
result = SuperSettings::RestAPI.update(params[:settings], changed_by)
|
45
45
|
if result[:success]
|
46
46
|
render json: result
|
@@ -27,7 +27,7 @@ module SuperSettings
|
|
27
27
|
|
28
28
|
config.after_initialize do
|
29
29
|
# Call the deferred initialization block.
|
30
|
-
configuration =
|
30
|
+
configuration = SuperSettings.configuration
|
31
31
|
configuration.call
|
32
32
|
|
33
33
|
SuperSettings.refresh_interval = configuration.refresh_interval unless configuration.refresh_interval.nil?
|
@@ -27,7 +27,7 @@ module SuperSettings
|
|
27
27
|
def changed_by_display
|
28
28
|
return changed_by if changed_by.nil?
|
29
29
|
|
30
|
-
display_proc =
|
30
|
+
display_proc = SuperSettings.configuration.model.changed_by_display
|
31
31
|
if display_proc && !changed_by.nil?
|
32
32
|
display_proc.call(changed_by) || changed_by
|
33
33
|
else
|
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "uri"
|
5
|
+
|
6
|
+
module SuperSettings
|
7
|
+
# This is a simple HTTP client that is used to communicate with the REST API. It
|
8
|
+
# will keep the connection alive and reuse it on subsequent requests.
|
9
|
+
class HttpClient
|
10
|
+
DEFAULT_HEADERS = {"Accept" => "application/json"}.freeze
|
11
|
+
DEFAULT_TIMEOUT = 5.0
|
12
|
+
KEEP_ALIVE_TIMEOUT = 60
|
13
|
+
|
14
|
+
class Error < StandardError
|
15
|
+
end
|
16
|
+
|
17
|
+
class NotFoundError < Error
|
18
|
+
end
|
19
|
+
|
20
|
+
class InvalidRecordError < Error
|
21
|
+
attr_reader :errors
|
22
|
+
|
23
|
+
def initialize(message, errors:)
|
24
|
+
super(message)
|
25
|
+
@errors = errors
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(base_url, headers: nil, params: nil, timeout: nil, user: nil, password: nil)
|
30
|
+
base_url = "#{base_url}/" unless base_url.end_with?("/")
|
31
|
+
@base_uri = URI(base_url)
|
32
|
+
@base_uri.query = query_string(params) if params
|
33
|
+
@headers = headers ? DEFAULT_HEADERS.merge(headers) : DEFAULT_HEADERS
|
34
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
35
|
+
@user = user
|
36
|
+
@password = password
|
37
|
+
@mutex = Mutex.new
|
38
|
+
@connections = []
|
39
|
+
end
|
40
|
+
|
41
|
+
def get(path, params = nil)
|
42
|
+
request = Net::HTTP::Get.new(request_uri(path, params))
|
43
|
+
send_request(request)
|
44
|
+
end
|
45
|
+
|
46
|
+
def post(path, params = nil)
|
47
|
+
request = Net::HTTP::Post.new(request_uri(path))
|
48
|
+
request.body = JSON.dump(params) if params
|
49
|
+
send_request(request)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def send_request(request)
|
55
|
+
set_headers(request)
|
56
|
+
response_payload = nil
|
57
|
+
attempts = 0
|
58
|
+
|
59
|
+
with_connection do |http|
|
60
|
+
http.start unless http.started?
|
61
|
+
response = http.request(request)
|
62
|
+
|
63
|
+
begin
|
64
|
+
response.value # raises exception unless response is a success
|
65
|
+
response_payload = JSON.parse(response.body)
|
66
|
+
rescue Net::ProtocolError
|
67
|
+
if [404, 410].include?(response.code.to_i)
|
68
|
+
raise NotFoundError.new("#{response.code} #{response.message}")
|
69
|
+
elsif response.code.to_i == 422
|
70
|
+
raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
|
71
|
+
else
|
72
|
+
raise Error.new("#{response.code} #{response.message}")
|
73
|
+
end
|
74
|
+
rescue JSON::JSONError => e
|
75
|
+
raise Error.new(e.message)
|
76
|
+
end
|
77
|
+
rescue IOError, Errno::ECONNRESET => connection_error
|
78
|
+
attempts += 1
|
79
|
+
retry if attempts <= 1
|
80
|
+
raise connection_error
|
81
|
+
end
|
82
|
+
|
83
|
+
response_payload
|
84
|
+
end
|
85
|
+
|
86
|
+
def with_connection(&block)
|
87
|
+
http = pop_connection
|
88
|
+
begin
|
89
|
+
response = yield(http)
|
90
|
+
return_connection(http)
|
91
|
+
response
|
92
|
+
rescue => e
|
93
|
+
begin
|
94
|
+
http.finish if http.started?
|
95
|
+
rescue IOError
|
96
|
+
end
|
97
|
+
raise e
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def pop_connection
|
102
|
+
http = nil
|
103
|
+
@mutex.synchronize do
|
104
|
+
http = @connections.pop
|
105
|
+
end
|
106
|
+
http = nil unless http&.started?
|
107
|
+
http ||= new_connection
|
108
|
+
http
|
109
|
+
end
|
110
|
+
|
111
|
+
def return_connection(http)
|
112
|
+
@mutex.synchronize do
|
113
|
+
if @connections.empty?
|
114
|
+
@connections.push(http)
|
115
|
+
http = nil
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
if http
|
120
|
+
begin
|
121
|
+
http.finish if http.started?
|
122
|
+
rescue IOError
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def new_connection
|
128
|
+
http = Net::HTTP.new(@base_uri.host, @base_uri.port || @base_uri.inferred_port)
|
129
|
+
http.use_ssl = @base_uri.scheme == "https"
|
130
|
+
http.open_timeout = @timeout
|
131
|
+
http.read_timeout = @timeout
|
132
|
+
http.write_timeout = @timeout
|
133
|
+
http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
|
134
|
+
http
|
135
|
+
end
|
136
|
+
|
137
|
+
def set_headers(request)
|
138
|
+
@headers.each do |name, value|
|
139
|
+
name = name.to_s
|
140
|
+
values = Array(value)
|
141
|
+
request[name] = values[0].to_s
|
142
|
+
values[1, values.length].each do |val|
|
143
|
+
request.add_field(name, val.to_s)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def request_uri(path, params = nil)
|
149
|
+
uri = URI.join(@base_uri, path.delete_prefix("/"))
|
150
|
+
if (params && !params.empty?) || (@base_uri.query && !@base_uri.query.empty?)
|
151
|
+
uri.query = [uri.query, query_string(params)].join("&")
|
152
|
+
end
|
153
|
+
uri
|
154
|
+
end
|
155
|
+
|
156
|
+
def query_string(params)
|
157
|
+
q = []
|
158
|
+
q << @base_uri.query unless @base_uri.query.to_s.empty?
|
159
|
+
params&.each do |name, value|
|
160
|
+
q << "#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
|
161
|
+
end
|
162
|
+
q.join("&")
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -122,7 +122,7 @@ module SuperSettings
|
|
122
122
|
#
|
123
123
|
# @return [Boolean]
|
124
124
|
def web_ui_enabled?
|
125
|
-
|
125
|
+
SuperSettings.configuration.controller.web_ui_enabled?
|
126
126
|
end
|
127
127
|
|
128
128
|
private
|
@@ -159,7 +159,7 @@ module SuperSettings
|
|
159
159
|
|
160
160
|
def handle_root_request(request)
|
161
161
|
response = check_authorization(request, write_required: true) do |user|
|
162
|
-
[200, {"content-type" => "text/html; charset=utf-8", "cache-control" => "no-cache"}, [Application.new(:default, add_to_head(request)
|
162
|
+
[200, {"content-type" => "text/html; charset=utf-8", "cache-control" => "no-cache"}, [Application.new(layout: :default, add_to_head: add_to_head(request), color_scheme: SuperSettings.configuration.controller.color_scheme).render]]
|
163
163
|
end
|
164
164
|
|
165
165
|
if [401, 403].include?(response.first)
|
@@ -236,7 +236,7 @@ module SuperSettings
|
|
236
236
|
|
237
237
|
def post_params(request)
|
238
238
|
if request.content_type.to_s.match?(/\Aapplication\/json/i) && request.body
|
239
|
-
request.params.merge(JSON.parse(request.body.
|
239
|
+
request.params.merge(JSON.parse(request.body.read))
|
240
240
|
else
|
241
241
|
request.params
|
242
242
|
end
|
@@ -23,7 +23,7 @@ module SuperSettings
|
|
23
23
|
# ...
|
24
24
|
# ]
|
25
25
|
def index
|
26
|
-
settings = Setting.active.sort_by(&:key)
|
26
|
+
settings = Setting.active.reject(&:deleted?).sort_by(&:key)
|
27
27
|
{settings: settings.collect(&:as_json)}
|
28
28
|
end
|
29
29
|
|
@@ -46,7 +46,8 @@ module SuperSettings
|
|
46
46
|
# updated_at: iso8601 string
|
47
47
|
# }
|
48
48
|
def show(key)
|
49
|
-
Setting.find_by_key(key)
|
49
|
+
setting = Setting.find_by_key(key)
|
50
|
+
setting.as_json if setting && !setting.deleted?
|
50
51
|
end
|
51
52
|
|
52
53
|
# The update operation uses a transaction to atomically update all settings.
|
@@ -156,7 +157,7 @@ module SuperSettings
|
|
156
157
|
#
|
157
158
|
# @example
|
158
159
|
# GET /last_updated_at
|
159
|
-
#
|
160
|
+
#
|
160
161
|
# The response payload is:
|
161
162
|
# {
|
162
163
|
# last_updated_at: iso8601 string
|
@@ -188,7 +189,7 @@ module SuperSettings
|
|
188
189
|
# ]
|
189
190
|
def updated_since(time)
|
190
191
|
time = Coerce.time(time)
|
191
|
-
settings = Setting.updated_since(time)
|
192
|
+
settings = Setting.updated_since(time).reject(&:deleted?)
|
192
193
|
{settings: settings.collect(&:as_json)}
|
193
194
|
end
|
194
195
|
end
|
@@ -109,6 +109,7 @@ module SuperSettings
|
|
109
109
|
# @param time [Time]
|
110
110
|
# @return [Array<Setting>]
|
111
111
|
def updated_since(time)
|
112
|
+
time = SuperSettings::Coerce.time(time)
|
112
113
|
storage.with_connection do
|
113
114
|
storage.updated_since(time).collect { |record| new(record) }
|
114
115
|
end
|
@@ -219,6 +220,16 @@ module SuperSettings
|
|
219
220
|
next if Coerce.blank?(setting_params["key"])
|
220
221
|
next if ["value_type", "value", "description", "deleted"].all? { |name| Coerce.blank?(setting_params[name]) }
|
221
222
|
|
223
|
+
key_was = setting_params["key_was"]
|
224
|
+
if key_was && !changed.include?(key_was)
|
225
|
+
old_setting = Setting.find_by_key(key_was)
|
226
|
+
if old_setting
|
227
|
+
old_setting.deleted = true
|
228
|
+
old_setting.changed_by = changed_by
|
229
|
+
changed[old_setting.key] = old_setting
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
222
233
|
key = setting_params["key"]
|
223
234
|
setting = changed[key] || Setting.find_by_key(key)
|
224
235
|
unless setting
|
@@ -372,7 +383,7 @@ module SuperSettings
|
|
372
383
|
#
|
373
384
|
# @param val [Time, DateTime]
|
374
385
|
def created_at=(val)
|
375
|
-
val =
|
386
|
+
val = TimePrecision.new(val).time
|
376
387
|
will_change!(:created_at, val) unless created_at == val
|
377
388
|
@record.created_at = val
|
378
389
|
end
|
@@ -388,7 +399,7 @@ module SuperSettings
|
|
388
399
|
#
|
389
400
|
# @param val [Time, DateTime]
|
390
401
|
def updated_at=(val)
|
391
|
-
val =
|
402
|
+
val = TimePrecision.new(val).time
|
392
403
|
will_change!(:updated_at, val) unless updated_at == val
|
393
404
|
@record.updated_at = val
|
394
405
|
end
|
@@ -60,6 +60,13 @@ module SuperSettings
|
|
60
60
|
Model.transaction(&block)
|
61
61
|
end
|
62
62
|
|
63
|
+
def destroy_all
|
64
|
+
ApplicationRecord.transaction do
|
65
|
+
Model.delete_all
|
66
|
+
HistoryModel.delete_all
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
63
70
|
protected
|
64
71
|
|
65
72
|
# Only load settings asynchronously if there is an extra database connection left in the
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SuperSettings
|
4
|
+
module Storage
|
5
|
+
# Generic class that can be extended to represent a history record for a setting in memory.
|
6
|
+
class HistoryAttributes
|
7
|
+
include SuperSettings::Attributes
|
8
|
+
|
9
|
+
attr_accessor :key, :value, :changed_by
|
10
|
+
attr_writer :deleted
|
11
|
+
attr_reader :created_at
|
12
|
+
|
13
|
+
def initialize(*)
|
14
|
+
@key = nil
|
15
|
+
@value = nil
|
16
|
+
@changed_by = nil
|
17
|
+
@created_at = nil
|
18
|
+
@deleted = false
|
19
|
+
super
|
20
|
+
end
|
21
|
+
|
22
|
+
def created_at=(val)
|
23
|
+
@created_at = TimePrecision.new(val).time
|
24
|
+
end
|
25
|
+
|
26
|
+
def deleted?
|
27
|
+
!!@deleted
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|