solid_objects 0.12.1 → 0.13.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 +60 -0
- data/README.md +69 -13
- data/benchmark/idle_polling.rb +98 -0
- data/benchmark/support.rb +2 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/adr/0011-wake-up-strategy.md +6 -0
- data/docs/architecture.md +5 -4
- data/docs/authorization.md +11 -4
- data/docs/benchmarks.md +38 -0
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +1 -0
- data/docs/operations.md +24 -0
- data/docs/realtime.md +22 -14
- data/docs/roadmap.md +32 -11
- data/docs/security.md +7 -7
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- data/lib/solid_objects/broadcast_executor.rb +35 -4
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/effect_executor.rb +34 -3
- data/lib/solid_objects/polling_backoff.rb +45 -0
- data/lib/solid_objects/process_registry.rb +51 -0
- data/lib/solid_objects/reminder_scheduler.rb +35 -4
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +36 -4
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/lib/solid_objects/worker.rb +39 -3
- data/lib/solid_objects.rb +2 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
- data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/lib/solid_objects/worker.rbs +7 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- metadata +58 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module SolidObjects
|
|
8
|
+
class Web
|
|
9
|
+
# A state changing request must carry the token of the session that asked
|
|
10
|
+
# for the form. The token a form receives is masked with a fresh one-time
|
|
11
|
+
# pad on every request, so the bytes on the wire differ each time and a
|
|
12
|
+
# compression side channel cannot recover the session token.
|
|
13
|
+
class CsrfProtection
|
|
14
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
15
|
+
TOKEN_BYTES = 32
|
|
16
|
+
|
|
17
|
+
MISSING_SESSION = <<~MESSAGE
|
|
18
|
+
SolidObjects::Web needs a Rack session for CSRF protection.
|
|
19
|
+
|
|
20
|
+
Mount it inside the application routes so the Rails session middleware runs first:
|
|
21
|
+
|
|
22
|
+
Rails.application.routes.draw do
|
|
23
|
+
mount SolidObjects::Web => "/solid_objects"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
In a bare Rack application, run a session middleware before it:
|
|
27
|
+
|
|
28
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
|
|
29
|
+
run SolidObjects::Web
|
|
30
|
+
MESSAGE
|
|
31
|
+
|
|
32
|
+
# @rbs (untyped) -> void
|
|
33
|
+
def initialize(app)
|
|
34
|
+
@app = app
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
38
|
+
def call(env)
|
|
39
|
+
return forbidden unless accept?(env)
|
|
40
|
+
|
|
41
|
+
session = session!(env)
|
|
42
|
+
session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
|
|
43
|
+
env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
|
|
44
|
+
@app.call(env)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @rbs (Hash[String, untyped]) -> bool
|
|
50
|
+
def accept?(env)
|
|
51
|
+
return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])
|
|
52
|
+
|
|
53
|
+
valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs (Hash[String, untyped], String?) -> bool
|
|
57
|
+
def valid?(env, given)
|
|
58
|
+
return false if given.nil? || given.empty?
|
|
59
|
+
|
|
60
|
+
session = session!(env)
|
|
61
|
+
stored = session[:csrf]
|
|
62
|
+
return false if stored.nil?
|
|
63
|
+
|
|
64
|
+
token = decode(given)
|
|
65
|
+
return false unless token
|
|
66
|
+
|
|
67
|
+
# The secret is not rotated here. A page renders one Retry form per
|
|
68
|
+
# dead letter, and a browser keeps pages open in other tabs, so
|
|
69
|
+
# spending the secret on the first submission would answer 403 to
|
|
70
|
+
# every other form already rendered. Single use is not what a CSRF
|
|
71
|
+
# token provides: it proves the request came from a page this session
|
|
72
|
+
# was served, and the per-request mask below is what keeps the value
|
|
73
|
+
# on the wire from repeating.
|
|
74
|
+
matches?(token, stored)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @rbs (String, String) -> bool
|
|
78
|
+
def matches?(token, stored)
|
|
79
|
+
candidate = case token.bytesize
|
|
80
|
+
when TOKEN_BYTES then token
|
|
81
|
+
when TOKEN_BYTES * 2 then unmask(token)
|
|
82
|
+
else return false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @rbs (String) -> String
|
|
89
|
+
def mask(token)
|
|
90
|
+
decoded = decode(token).to_s
|
|
91
|
+
pad = SecureRandom.random_bytes(decoded.bytesize)
|
|
92
|
+
encode(pad + exclusive_or(pad, decoded))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs (String) -> String
|
|
96
|
+
def unmask(masked)
|
|
97
|
+
half = masked.bytesize / 2
|
|
98
|
+
exclusive_or(masked[0, half].to_s, masked[half..].to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @rbs (String, String) -> String
|
|
102
|
+
def exclusive_or(left, right)
|
|
103
|
+
left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs (String) -> String
|
|
107
|
+
def encode(token)
|
|
108
|
+
[ token ].pack("m0").tr("+/", "-_")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @rbs (String) -> String?
|
|
112
|
+
def decode(token)
|
|
113
|
+
decoded = token.tr("-_", "+/").unpack1("m0")
|
|
114
|
+
decoded.is_a?(String) ? decoded : nil
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
|
|
120
|
+
def session!(env)
|
|
121
|
+
env["rack.session"] || raise(MISSING_SESSION)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @rbs () -> Array[untyped]
|
|
125
|
+
def forbidden
|
|
126
|
+
[ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module SolidObjects
|
|
7
|
+
class Web
|
|
8
|
+
# The methods a view may call. Everything a template prints goes through
|
|
9
|
+
# `h`, because an actor id, an operation name, and an exception message are
|
|
10
|
+
# all application supplied strings that reach this page unchanged.
|
|
11
|
+
module Helpers
|
|
12
|
+
# Only these survive a page link. A filter an operator set stays set when
|
|
13
|
+
# they turn the page; anything else the query string carries does not
|
|
14
|
+
# come back.
|
|
15
|
+
FORWARDED_PARAMS = %w[actor_type actor_id status per_page].freeze
|
|
16
|
+
TRUNCATION_LIMIT = 2_000
|
|
17
|
+
|
|
18
|
+
# @rbs (untyped) -> String
|
|
19
|
+
def h(text)
|
|
20
|
+
::Rack::Utils.escape_html(text.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> String
|
|
24
|
+
def root_path
|
|
25
|
+
env["SCRIPT_NAME"].to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs (String) -> String
|
|
29
|
+
def path_to(path)
|
|
30
|
+
"#{root_path}#{path}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @rbs () -> String
|
|
34
|
+
def current_path
|
|
35
|
+
request.path_info
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs (String) -> bool
|
|
39
|
+
def current_tab?(path)
|
|
40
|
+
return current_path == "/" if path == "/"
|
|
41
|
+
|
|
42
|
+
current_path.start_with?(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs () -> Hash[String, String]
|
|
46
|
+
def tabs
|
|
47
|
+
Web.tabs
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @rbs () -> String?
|
|
51
|
+
def csp_nonce
|
|
52
|
+
env[Web::NONCE_KEY]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @rbs () -> String
|
|
56
|
+
def csrf_tag
|
|
57
|
+
%(<input type="hidden" name="authenticity_token" value="#{h(env[Web::CSRF_TOKEN_KEY])}" />)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @rbs (String) -> String
|
|
61
|
+
def form_to(path)
|
|
62
|
+
%(<form method="post" action="#{h(path_to(path))}">#{csrf_tag})
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @rbs (untyped) -> String
|
|
66
|
+
def relative_time(time)
|
|
67
|
+
return "—" unless time
|
|
68
|
+
|
|
69
|
+
stamp = time.getutc.iso8601
|
|
70
|
+
%(<time datetime="#{stamp}" title="#{stamp}">#{h(stamp)}</time>)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @rbs (untyped) -> String
|
|
74
|
+
def number(value)
|
|
75
|
+
h(value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @rbs (Numeric?) -> String
|
|
79
|
+
def duration(seconds)
|
|
80
|
+
return "—" unless seconds
|
|
81
|
+
|
|
82
|
+
return "#{h(format("%.3f", seconds))} s" if seconds < 60
|
|
83
|
+
|
|
84
|
+
h("#{(seconds / 60).floor} min #{(seconds % 60).round} s")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @rbs (untyped, ?Integer) -> String
|
|
88
|
+
def json_block(value)
|
|
89
|
+
return "—" if value.nil?
|
|
90
|
+
|
|
91
|
+
%(<pre class="payload">#{h(truncate(JSON.pretty_generate(value)))}</pre>)
|
|
92
|
+
rescue JSON::GeneratorError, TypeError
|
|
93
|
+
%(<pre class="payload">#{h(truncate(value.inspect))}</pre>)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @rbs (String, ?Integer) -> String
|
|
97
|
+
def truncate(text, limit = TRUNCATION_LIMIT)
|
|
98
|
+
return text if text.length <= limit
|
|
99
|
+
|
|
100
|
+
"#{text[0, limit]}…"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @rbs (String?) -> String
|
|
104
|
+
def status_label(status)
|
|
105
|
+
%(<span class="status status-#{h(status)}">#{h(status)}</span>)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @rbs (untyped) -> String
|
|
109
|
+
def actor_label(record)
|
|
110
|
+
"#{h(record.actor_type)} / #{h(record.actor_id)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @rbs (untyped) -> String
|
|
114
|
+
def instance_link(instance)
|
|
115
|
+
%(<a href="#{h(path_to("/instances/#{instance.id}"))}">#{actor_label(instance)}</a>)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @rbs (?Hash[String, untyped]) -> String
|
|
119
|
+
def query_string(overrides = {})
|
|
120
|
+
merged = FORWARDED_PARAMS
|
|
121
|
+
.to_h { |name| [ name, url_params(name) ] }
|
|
122
|
+
.merge(overrides.transform_keys(&:to_s))
|
|
123
|
+
.reject { |_name, value| value.nil? || value.to_s.empty? }
|
|
124
|
+
return "" if merged.empty?
|
|
125
|
+
|
|
126
|
+
"?#{merged.map { |name, value| "#{::Rack::Utils.escape(name)}=#{::Rack::Utils.escape(value.to_s)}" }.join("&")}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @rbs (?Hash[String, untyped]) -> String
|
|
130
|
+
def page_link(overrides = {})
|
|
131
|
+
h("#{path_to(current_path)}#{query_string(overrides)}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @rbs (Instance) -> String
|
|
135
|
+
def lease_state(instance)
|
|
136
|
+
return "paused" if instance.paused_at
|
|
137
|
+
return "idle" unless instance.activation_owner_id
|
|
138
|
+
return "idle" unless instance.activation_expires_at
|
|
139
|
+
|
|
140
|
+
(instance.activation_expires_at > statistics.now) ? "activated" : "expired"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @rbs () -> Statistics
|
|
144
|
+
def statistics
|
|
145
|
+
@statistics ||= Statistics.new
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs (untyped) -> Paginator
|
|
149
|
+
def paginate(relation)
|
|
150
|
+
Paginator.new(relation:, page: url_params("page"), per_page: url_params("per_page"))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# An unrecognized filter falls back to the default rather than returning
|
|
154
|
+
# nothing, so a hand edited query string cannot make a page look empty.
|
|
155
|
+
# @rbs (Array[String], ?default: String?) -> String?
|
|
156
|
+
def filter_value(allowed, default: nil)
|
|
157
|
+
value = url_params("status")
|
|
158
|
+
allowed.include?(value) ? value : default
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @rbs () -> Instance
|
|
162
|
+
def find_instance
|
|
163
|
+
instance = Instance.find_by(id: route_params(:id))
|
|
164
|
+
halt(404) unless instance
|
|
165
|
+
|
|
166
|
+
instance
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @rbs () -> untyped
|
|
170
|
+
def filtered_instances
|
|
171
|
+
relation = Instance.order(updated_at: :desc, id: :desc)
|
|
172
|
+
actor_type = url_params("actor_type")
|
|
173
|
+
relation = relation.where(actor_type:) unless actor_type.to_s.empty?
|
|
174
|
+
actor_id = url_params("actor_id")
|
|
175
|
+
return relation if actor_id.to_s.empty?
|
|
176
|
+
|
|
177
|
+
relation.where(
|
|
178
|
+
Instance.arel_table[:actor_id].matches("%#{Instance.sanitize_sql_like(actor_id)}%")
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @rbs (String) -> untyped
|
|
183
|
+
def mailbox_messages(membership)
|
|
184
|
+
membership_model = (membership == "claimed") ? ClaimedMessage : ReadyMessage
|
|
185
|
+
Message
|
|
186
|
+
.where(id: membership_model.select(:message_id))
|
|
187
|
+
.order(available_at: :asc, id: :asc)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Chart data travels in an attribute rather than an inline script block,
|
|
191
|
+
# so the page needs no script-src exception and an actor type cannot
|
|
192
|
+
# close the attribute and open a tag.
|
|
193
|
+
#
|
|
194
|
+
# The container is not decoration. Chart.js measures a responsive canvas
|
|
195
|
+
# against its parent, so the parent has to have a height of its own; a
|
|
196
|
+
# panel that sizes to its children would grow a little on every redraw.
|
|
197
|
+
# @rbs (String, untyped) -> String
|
|
198
|
+
def chart(name, values)
|
|
199
|
+
return "" unless Web.charts?
|
|
200
|
+
|
|
201
|
+
%(<div class="chart-frame"><canvas data-chart="#{h(name)}" ) +
|
|
202
|
+
%(data-chart-values='#{h(JSON.generate(values))}'></canvas></div>)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A vendored copy is a path below the mount; a CDN copy is an absolute
|
|
206
|
+
# URL and is left alone.
|
|
207
|
+
# @rbs () -> String
|
|
208
|
+
def chart_library_source
|
|
209
|
+
url = Web.chart_library_url.to_s
|
|
210
|
+
url.include?("//") ? url : path_to(url)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @rbs () -> String
|
|
214
|
+
def chart_library_integrity_attributes
|
|
215
|
+
integrity = Web.chart_library_integrity
|
|
216
|
+
return "" unless integrity
|
|
217
|
+
|
|
218
|
+
%( integrity="#{h(integrity)}" crossorigin="anonymous" referrerpolicy="no-referrer")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @rbs () -> String
|
|
222
|
+
def environment_name
|
|
223
|
+
ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# Counts and slices one relation. The page size is clamped because the page
|
|
6
|
+
# number and the page size both arrive from the query string, and an
|
|
7
|
+
# operator page that accepts an unbounded limit is a denial of service
|
|
8
|
+
# against the database the actors run on.
|
|
9
|
+
class Paginator
|
|
10
|
+
DEFAULT_PER_PAGE = 25
|
|
11
|
+
MAXIMUM_PER_PAGE = 200
|
|
12
|
+
|
|
13
|
+
# @rbs @page: Integer
|
|
14
|
+
# @rbs @per_page: Integer
|
|
15
|
+
# @rbs @total: Integer
|
|
16
|
+
# @rbs @records: Array[untyped]
|
|
17
|
+
|
|
18
|
+
attr_reader :page, :per_page, :total, :records
|
|
19
|
+
|
|
20
|
+
# @rbs (relation: untyped, ?page: String?, ?per_page: String?) -> void
|
|
21
|
+
def initialize(relation:, page: nil, per_page: nil)
|
|
22
|
+
@per_page = bounded(per_page, default: DEFAULT_PER_PAGE, maximum: MAXIMUM_PER_PAGE)
|
|
23
|
+
@total = relation.count
|
|
24
|
+
@page = bounded(page, default: 1, maximum: last_page)
|
|
25
|
+
@records = relation.offset((@page - 1) * @per_page).limit(@per_page).to_a
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs () -> Integer
|
|
29
|
+
def last_page
|
|
30
|
+
[ (total.to_f / per_page).ceil, 1 ].max
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @rbs () -> Integer?
|
|
34
|
+
def previous_page
|
|
35
|
+
(page > 1) ? page - 1 : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> Integer?
|
|
39
|
+
def next_page
|
|
40
|
+
(page < last_page) ? page + 1 : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Integer
|
|
44
|
+
def first_record
|
|
45
|
+
total.zero? ? 0 : ((page - 1) * per_page) + 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs () -> Integer
|
|
49
|
+
def last_record
|
|
50
|
+
[ page * per_page, total ].min
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @rbs (String?, default: Integer, maximum: Integer) -> Integer
|
|
56
|
+
def bounded(value, default:, maximum:)
|
|
57
|
+
requested = Integer(value.to_s, 10, exception: false)
|
|
58
|
+
return default unless requested&.positive?
|
|
59
|
+
|
|
60
|
+
[ requested, maximum ].min
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
class Route
|
|
6
|
+
# A named segment stops at the next slash, so `/instances/:id` never
|
|
7
|
+
# swallows `/instances/1/pause` and route order cannot hide a page.
|
|
8
|
+
NAMED_SEGMENT = %r{/([^/]*):([^.:$/]+)}
|
|
9
|
+
SEGMENT_CAPTURE = '/\1(?<\2>[^$/]+)'
|
|
10
|
+
|
|
11
|
+
# @rbs @matcher: String | Regexp
|
|
12
|
+
# @rbs @request_method: String
|
|
13
|
+
# @rbs @pattern: String
|
|
14
|
+
# @rbs @policy: Hash[Symbol, String]
|
|
15
|
+
# @rbs @handler: Proc
|
|
16
|
+
|
|
17
|
+
attr_reader :request_method, :pattern, :policy, :handler
|
|
18
|
+
|
|
19
|
+
# @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void
|
|
20
|
+
def initialize(request_method:, pattern:, policy:, handler:)
|
|
21
|
+
@request_method = request_method
|
|
22
|
+
@pattern = pattern
|
|
23
|
+
@policy = policy
|
|
24
|
+
@handler = handler
|
|
25
|
+
@matcher = compile(pattern)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs (String) -> bool
|
|
29
|
+
def match?(path)
|
|
30
|
+
return @matcher == path if @matcher.is_a?(String)
|
|
31
|
+
|
|
32
|
+
@matcher.match?(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs (String) -> Hash[Symbol, String?]
|
|
36
|
+
def capture(path)
|
|
37
|
+
return {} if @matcher.is_a?(String)
|
|
38
|
+
|
|
39
|
+
match = @matcher.match(path)
|
|
40
|
+
return {} unless match
|
|
41
|
+
|
|
42
|
+
match.named_captures.transform_keys(&:to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @rbs (String) -> (String | Regexp)
|
|
48
|
+
def compile(pattern)
|
|
49
|
+
return pattern unless pattern.match?(NAMED_SEGMENT)
|
|
50
|
+
|
|
51
|
+
Regexp.new("\\A#{pattern.gsub(NAMED_SEGMENT, SEGMENT_CAPTURE)}\\z")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# Declares the pages of the dashboard. Every route carries the
|
|
6
|
+
# administration policy it needs, and a route declared without one raises
|
|
7
|
+
# at load time. A new page therefore cannot reach the database before an
|
|
8
|
+
# application has said who may read it.
|
|
9
|
+
module Router
|
|
10
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
11
|
+
def head(path, policy: nil, &handler)
|
|
12
|
+
route("HEAD", path, policy:, &handler)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
16
|
+
def get(path, policy: nil, &handler)
|
|
17
|
+
route("GET", path, policy:, &handler)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
21
|
+
def post(path, policy: nil, &handler)
|
|
22
|
+
route("POST", path, policy:, &handler)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
|
|
26
|
+
def route(request_method, path, policy: nil, &handler)
|
|
27
|
+
raise ArgumentError, "route #{path} requires an authorization policy" unless policy
|
|
28
|
+
raise ArgumentError, "route #{path} requires a handler" unless handler
|
|
29
|
+
raise ArgumentError, "route #{path} requires a policy action" unless policy[:action]
|
|
30
|
+
raise ArgumentError, "route #{path} requires a policy resource" unless policy[:resource]
|
|
31
|
+
|
|
32
|
+
routes[request_method] << Route.new(request_method:, pattern: path, policy:, handler:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs () -> Hash[String, Array[Route]]
|
|
36
|
+
def routes
|
|
37
|
+
@routes ||= Hash.new { |store, request_method| store[request_method] = [] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @rbs (String, String) -> Route?
|
|
41
|
+
def match(request_method, path)
|
|
42
|
+
routes[request_method].find { |route| route.match?(path) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# The counts behind the dashboard and behind `GET /stats`. Both read the
|
|
6
|
+
# same object, so the polled JSON and the rendered page can never disagree
|
|
7
|
+
# about what a number means.
|
|
8
|
+
class Statistics
|
|
9
|
+
EFFECT_STATUSES = %w[pending processing completed dead].freeze
|
|
10
|
+
BROADCAST_STATUSES = %w[pending processing delivered dead].freeze
|
|
11
|
+
REMINDER_STATUSES = %w[scheduled paused completed].freeze
|
|
12
|
+
PROCESS_STATES = %w[running draining stopped].freeze
|
|
13
|
+
|
|
14
|
+
# @rbs @now: Time
|
|
15
|
+
|
|
16
|
+
attr_reader :now
|
|
17
|
+
|
|
18
|
+
# @rbs (?now: Time) -> void
|
|
19
|
+
def initialize(now: SolidObjects.database_adapter.database_now)
|
|
20
|
+
@now = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
instances:,
|
|
27
|
+
mailbox:,
|
|
28
|
+
effects: grouped(Effect, :status, EFFECT_STATUSES),
|
|
29
|
+
broadcasts: grouped(Broadcast, :status, BROADCAST_STATUSES),
|
|
30
|
+
reminders:,
|
|
31
|
+
dead_letters: { total: DeadLetter.count },
|
|
32
|
+
processes: grouped(Process, :shutdown_state, PROCESS_STATES),
|
|
33
|
+
server_time: now.utc.iso8601
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs () -> Hash[Symbol, Integer]
|
|
38
|
+
def instances
|
|
39
|
+
{
|
|
40
|
+
total: Instance.count,
|
|
41
|
+
paused: Instance.where.not(paused_at: nil).count,
|
|
42
|
+
activated: Instance.where(activation_expires_at: now..).count
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The oldest ready message that is already due is the queue latency of
|
|
47
|
+
# this runtime: how far behind the workers are, in seconds.
|
|
48
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
49
|
+
def mailbox
|
|
50
|
+
due = ReadyMessage.where(available_at: ..now)
|
|
51
|
+
oldest = due.minimum(:available_at)
|
|
52
|
+
{
|
|
53
|
+
ready: ReadyMessage.count,
|
|
54
|
+
due: due.count,
|
|
55
|
+
claimed: ClaimedMessage.count,
|
|
56
|
+
latency: oldest ? (now - oldest).round(3) : 0.0
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @rbs () -> Hash[Symbol, Integer]
|
|
61
|
+
def reminders
|
|
62
|
+
grouped(Reminder, :status, REMINDER_STATUSES).merge(
|
|
63
|
+
due: Reminder.where(status: "scheduled", next_run_at: ..now).count
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# A status the schema allows but the table does not currently hold still
|
|
70
|
+
# reports zero, so a row of counts keeps the same shape between polls.
|
|
71
|
+
# @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer]
|
|
72
|
+
def grouped(model, column, statuses)
|
|
73
|
+
counts = model.group(column).count
|
|
74
|
+
statuses.to_h { |status| [ status.to_sym, counts.fetch(status, 0) ] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|