super_settings 1.0.2 → 2.0.1
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/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
|