wurk 0.0.5 → 1.0.0
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 +4 -0
- data/app/controllers/wurk/api/serializers.rb +48 -2
- data/app/controllers/wurk/api_controller.rb +216 -1
- data/app/controllers/wurk/dashboard_controller.rb +20 -2
- data/app/controllers/wurk/extensions_controller.rb +56 -0
- data/app/controllers/wurk/profiles_controller.rb +68 -0
- data/config/routes.rb +54 -1
- data/exe/sidekiqswarm +8 -0
- data/exe/wurkswarm +23 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
- data/lib/generators/wurk/install/templates/wurk.rb +14 -3
- data/lib/sidekiq/api.rb +4 -0
- data/lib/sidekiq/cli.rb +9 -0
- data/lib/sidekiq/client.rb +4 -0
- data/lib/sidekiq/job.rb +4 -0
- data/lib/sidekiq/launcher.rb +4 -0
- data/lib/sidekiq/middleware/chain.rb +4 -0
- data/lib/sidekiq/middleware/server/statsd.rb +12 -0
- data/lib/sidekiq/rails.rb +10 -0
- data/lib/sidekiq/redis_connection.rb +4 -0
- data/lib/sidekiq/scheduled.rb +4 -0
- data/lib/sidekiq/testing.rb +4 -0
- data/lib/sidekiq/version.rb +4 -0
- data/lib/sidekiq/web.rb +4 -0
- data/lib/sidekiq/worker.rb +4 -0
- data/lib/sidekiq.rb +16 -0
- data/lib/wurk/batch/callbacks.rb +103 -13
- data/lib/wurk/batch/death_handler.rb +5 -2
- data/lib/wurk/batch/server_middleware.rb +35 -3
- data/lib/wurk/batch/status.rb +9 -0
- data/lib/wurk/batch.rb +23 -1
- data/lib/wurk/capsule.rb +20 -1
- data/lib/wurk/cli.rb +84 -1
- data/lib/wurk/client.rb +20 -17
- data/lib/wurk/compat.rb +44 -2
- data/lib/wurk/component.rb +5 -4
- data/lib/wurk/configuration.rb +120 -3
- data/lib/wurk/cron.rb +51 -9
- data/lib/wurk/dead_set.rb +8 -3
- data/lib/wurk/deploy.rb +8 -4
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/reaper.rb +78 -11
- data/lib/wurk/fetcher/reliable.rb +14 -4
- data/lib/wurk/heartbeat.rb +45 -0
- data/lib/wurk/history.rb +174 -0
- data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
- data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
- data/lib/wurk/iterable_job.rb +41 -0
- data/lib/wurk/iterable_job_query.rb +75 -0
- data/lib/wurk/job.rb +8 -0
- data/lib/wurk/job_record.rb +16 -1
- data/lib/wurk/job_set.rb +4 -4
- data/lib/wurk/job_util.rb +15 -6
- data/lib/wurk/keys.rb +10 -0
- data/lib/wurk/launcher.rb +35 -1
- data/lib/wurk/leader.rb +15 -6
- data/lib/wurk/limiter/bucket.rb +14 -3
- data/lib/wurk/limiter/concurrent.rb +1 -1
- data/lib/wurk/limiter/window.rb +2 -1
- data/lib/wurk/limiter.rb +12 -0
- data/lib/wurk/lua/loader.rb +10 -0
- data/lib/wurk/lua.rb +106 -14
- data/lib/wurk/metrics/history.rb +5 -0
- data/lib/wurk/metrics/query.rb +39 -0
- data/lib/wurk/metrics/queue_rollup.rb +151 -0
- data/lib/wurk/metrics/statsd.rb +11 -0
- data/lib/wurk/middleware/current_attributes.rb +29 -6
- data/lib/wurk/middleware/interrupt_handler.rb +5 -0
- data/lib/wurk/middleware/poison_pill.rb +35 -5
- data/lib/wurk/processor.rb +17 -8
- data/lib/wurk/profile_set.rb +65 -0
- data/lib/wurk/profiler.rb +127 -0
- data/lib/wurk/railtie.rb +19 -5
- data/lib/wurk/redis_client_adapter.rb +72 -0
- data/lib/wurk/redis_connection.rb +30 -0
- data/lib/wurk/redis_pool.rb +5 -1
- data/lib/wurk/scheduled.rb +42 -0
- data/lib/wurk/sorted_entry.rb +13 -11
- data/lib/wurk/stats.rb +11 -4
- data/lib/wurk/swarm/child_boot.rb +26 -4
- data/lib/wurk/swarm.rb +1 -1
- data/lib/wurk/transaction_aware_client.rb +69 -0
- data/lib/wurk/unique.rb +49 -7
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/batch_status.rb +42 -0
- data/lib/wurk/web/config.rb +219 -17
- data/lib/wurk/web/enterprise.rb +14 -0
- data/lib/wurk/web/extension.rb +348 -0
- data/lib/wurk/web/rack_app.rb +77 -0
- data/lib/wurk/web.rb +2 -0
- data/lib/wurk/worker/setter.rb +5 -1
- data/lib/wurk/worker.rb +17 -6
- data/lib/wurk.rb +44 -0
- data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
- data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
- data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
- data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
- data/vendor/assets/dashboard/favicon.png +0 -0
- data/vendor/assets/dashboard/index.html +10 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +42 -3
- data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
- data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
data/lib/wurk/web/config.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'rack/builder'
|
|
5
|
+
|
|
3
6
|
module Wurk
|
|
4
7
|
class Web
|
|
5
8
|
# Web UI configuration. Holds the authorization callback documented in
|
|
@@ -22,25 +25,87 @@ module Wurk
|
|
|
22
25
|
# When no block is registered, every request is authorized (matches
|
|
23
26
|
# Sidekiq's default — no auth until the user opts in).
|
|
24
27
|
class Config
|
|
28
|
+
extend Forwardable
|
|
29
|
+
|
|
25
30
|
# String forms that mean "off" — so `config.web.read_only = ENV[...]`
|
|
26
31
|
# doesn't flip on when the env var is "0"/"false"/empty.
|
|
27
32
|
FALSEY_STRINGS = ['', '0', 'false', 'no', 'off'].freeze
|
|
28
33
|
|
|
34
|
+
# Firefox-profiler endpoints for the Profiles pane (spec §25.2). The
|
|
35
|
+
# dashboard uploads a stored profile to `profile_store_url` and redirects
|
|
36
|
+
# the operator to `profile_view_url % <returned-hash>`. Overridable for
|
|
37
|
+
# self-hosted profiler instances.
|
|
38
|
+
PROFILE_VIEW_URL = 'https://profiler.firefox.com/public/%s'
|
|
39
|
+
PROFILE_STORE_URL = 'https://api.profiler.firefox.com/compressed-store'
|
|
40
|
+
|
|
41
|
+
# Hash-style settings, same surface as Sidekiq::Web::Config (#204):
|
|
42
|
+
# gems and apps write `Sidekiq::Web.configure { |c| c[:csrf] = false }`.
|
|
43
|
+
# Seeded like upstream's OPTIONS; unknown keys are stored verbatim so a
|
|
44
|
+
# setting wurk doesn't consume still round-trips (e.g. :csrf — wurk's
|
|
45
|
+
# extension POST guard is the Sec-Fetch-Site check in
|
|
46
|
+
# ExtensionsController, spec §25.1, not a token).
|
|
47
|
+
OPTIONS = {
|
|
48
|
+
profile_view_url: PROFILE_VIEW_URL,
|
|
49
|
+
profile_store_url: PROFILE_STORE_URL
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Sidekiq's built-in dashboard tabs (spec §25.3). The `tabs` hash starts
|
|
53
|
+
# as a copy of this; extensions add to it via `register_extension` or by
|
|
54
|
+
# mutating `tabs` directly — the same surface third-party gems use.
|
|
55
|
+
DEFAULT_TABS = {
|
|
56
|
+
'Dashboard' => '', 'Busy' => 'busy', 'Queues' => 'queues',
|
|
57
|
+
'Retries' => 'retries', 'Scheduled' => 'scheduled', 'Dead' => 'morgue',
|
|
58
|
+
'Metrics' => 'metrics', 'Profiles' => 'profiles'
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Tab paths the SPA already renders natively (Sidekiq DEFAULT_TABS plus
|
|
62
|
+
# wurk's Pro/Ent extras). A gem re-registering one of these — e.g.
|
|
63
|
+
# sidekiq-cron's "cron" — must not produce a duplicate nav item, so
|
|
64
|
+
# `custom_tabs` filters them out.
|
|
65
|
+
NATIVE_TAB_PATHS = %w[
|
|
66
|
+
busy queues retries scheduled dead morgue metrics profiles
|
|
67
|
+
batches limiters cron search
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
29
70
|
# Host-app Rack middleware stacked in front of the dashboard, newest
|
|
30
|
-
# last. Each entry is `[middleware, args, block]`.
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
end
|
|
71
|
+
# last. Each entry is `[middleware, args, block]`. The LIVE array, like
|
|
72
|
+
# Sidekiq::Web::Config#middlewares — callers mutate it directly
|
|
73
|
+
# (sidekiq-cron's tests do `c.middlewares.clear`), so `#rack_app`
|
|
74
|
+
# detects drift instead of this returning a frozen copy.
|
|
75
|
+
attr_reader :middlewares
|
|
36
76
|
|
|
37
77
|
def initialize
|
|
78
|
+
@options = OPTIONS.dup
|
|
38
79
|
@authorization = nil
|
|
39
80
|
@read_only = env_read_only?
|
|
81
|
+
@read_only_message = nil
|
|
40
82
|
@middlewares = []
|
|
41
83
|
@rack_app = nil
|
|
84
|
+
init_extensions!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
|
|
88
|
+
|
|
89
|
+
# Firefox-profiler URLs — named views over the same @options keys the
|
|
90
|
+
# bracket surface exposes, so `c[:profile_view_url] = …` and
|
|
91
|
+
# `c.profile_view_url = …` can't drift apart.
|
|
92
|
+
def profile_view_url = @options[:profile_view_url]
|
|
93
|
+
def profile_store_url = @options[:profile_store_url]
|
|
94
|
+
|
|
95
|
+
def profile_view_url=(value)
|
|
96
|
+
@options[:profile_view_url] = value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def profile_store_url=(value)
|
|
100
|
+
@options[:profile_store_url] = value
|
|
42
101
|
end
|
|
43
102
|
|
|
103
|
+
# Optional banner copy shown by the dashboard in read-only mode. Nil →
|
|
104
|
+
# the SPA falls back to its localized default ("Read-only mode"). Lets a
|
|
105
|
+
# host explain *why* it's read-only — e.g. the public demo sets
|
|
106
|
+
# "This is a public demo — actions are disabled."
|
|
107
|
+
attr_accessor :read_only_message
|
|
108
|
+
|
|
44
109
|
# Registers a `(env, method, path) -> truthy/falsey` block. Re-calling
|
|
45
110
|
# overwrites; the spec exposes a single hook, not a chain.
|
|
46
111
|
def authorization(&block)
|
|
@@ -48,6 +113,77 @@ module Wurk
|
|
|
48
113
|
@authorization
|
|
49
114
|
end
|
|
50
115
|
|
|
116
|
+
# Web-extension surface (spec §25.2). Third-party gems (sidekiq-cron,
|
|
117
|
+
# sidekiq-unique-jobs, sidekiq-status, …) register dashboard tabs at load
|
|
118
|
+
# time. `tabs` is a mutable name→path hash seeded from DEFAULT_TABS;
|
|
119
|
+
# `custom_job_info_rows` collects callables that add rows to the job
|
|
120
|
+
# detail view; `app_url` / `assets_path` mirror Sidekiq's accessors.
|
|
121
|
+
# `locales` is the mutable locale-directory list extensions append to
|
|
122
|
+
# (`Sidekiq::Web.configure.locales << dir`) — the Extension renderer's
|
|
123
|
+
# `t()` reads en.yml from every listed dir. Wurk's own SPA i18n is
|
|
124
|
+
# separate, so it starts empty.
|
|
125
|
+
attr_reader :tabs, :extensions, :locales
|
|
126
|
+
attr_accessor :custom_job_info_rows, :app_url, :assets_path
|
|
127
|
+
|
|
128
|
+
# Matches Sidekiq::Web::Config#register_extension (aliased `register`,
|
|
129
|
+
# spec §25.2): `tab` is the label(s), `index` the path(s), `name` the
|
|
130
|
+
# asset namespace. `tab`/`index` are zipped into the `tabs` hash
|
|
131
|
+
# (label => path) so the tab surfaces in the SPA nav via /api/meta, and
|
|
132
|
+
# the extension's `registered(app)` routes/ERB views are served by
|
|
133
|
+
# Wurk::Web::Extension::Renderer under `ext/:name/*` (#187) — the SPA's
|
|
134
|
+
# Extension page embeds the rendered HTML. Yields self to an optional
|
|
135
|
+
# block for further config, and returns self.
|
|
136
|
+
# rubocop:disable Metrics/ParameterLists -- signature matches Sidekiq::Web::Config#register_extension (spec §25.2)
|
|
137
|
+
def register_extension(extension, name:, tab:, index:, root_dir: nil,
|
|
138
|
+
cache_for: 86_400, asset_paths: nil)
|
|
139
|
+
Array(tab).zip(Array(index)).each { |label, path| @tabs[label] = path if label }
|
|
140
|
+
# Upstream registers root_dir/locales automatically; extensions
|
|
141
|
+
# without a root_dir append their dir to `locales` themselves.
|
|
142
|
+
@locales << ::File.join(root_dir, 'locales') if root_dir
|
|
143
|
+
@extensions << {
|
|
144
|
+
extension: extension, name: name, tab: tab, index: index,
|
|
145
|
+
root_dir: root_dir, cache_for: cache_for, asset_paths: asset_paths
|
|
146
|
+
}
|
|
147
|
+
yield self if block_given?
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
# rubocop:enable Metrics/ParameterLists
|
|
151
|
+
alias register register_extension
|
|
152
|
+
|
|
153
|
+
# Tabs the SPA should render in the nav: everything registered beyond the
|
|
154
|
+
# Sidekiq defaults and wurk's own native pages, as `{ name:, path:,
|
|
155
|
+
# ext_name: }`. `ext_name` ties the tab back to a registered extension so
|
|
156
|
+
# the Extension page can fetch its server-rendered view from
|
|
157
|
+
# `ext/:ext_name/*`; nil for tabs added by bare `tabs[]=` mutation (the
|
|
158
|
+
# SPA falls back to the iframe embed for those).
|
|
159
|
+
def custom_tabs
|
|
160
|
+
@tabs.filter_map do |name, path|
|
|
161
|
+
# Same trailing-slash normalization as extension_for_index, so a
|
|
162
|
+
# native path registered as "cron/" doesn't duplicate the native tab.
|
|
163
|
+
next if DEFAULT_TABS.key?(name) || NATIVE_TAB_PATHS.include?(path.to_s.delete_suffix('/'))
|
|
164
|
+
|
|
165
|
+
{ name: name, path: path.to_s, ext_name: extension_for_index(path)&.dig(:name)&.to_s }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The registered extension whose `index` covers `path` ("locks/" and
|
|
170
|
+
# "locks" are the same index).
|
|
171
|
+
def extension_for_index(path)
|
|
172
|
+
norm = path.to_s.delete_suffix('/')
|
|
173
|
+
@extensions.find { |e| Array(e[:index]).any? { |i| i.to_s.delete_suffix('/') == norm } }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Evaluate the registered `custom_job_info_rows` against a job (spec
|
|
177
|
+
# §25.2), returning `[[label, value], …]` for the SPA's job-detail modal.
|
|
178
|
+
# Each row is a callable (`call(job)`) or a Sidekiq-style `add_pair(job)`
|
|
179
|
+
# object; a row that raises or returns a non-pair is skipped so one bad
|
|
180
|
+
# extension can't break the job views.
|
|
181
|
+
def job_info_pairs(job)
|
|
182
|
+
return [] if @custom_job_info_rows.empty?
|
|
183
|
+
|
|
184
|
+
@custom_job_info_rows.filter_map { |row| job_info_pair(row, job) }
|
|
185
|
+
end
|
|
186
|
+
|
|
51
187
|
# Sidekiq-compatible (`Sidekiq::Web.use`). Registers a Rack middleware
|
|
52
188
|
# that wraps the dashboard, in front of the authorization hook, so a
|
|
53
189
|
# host app can gate the UI with Devise/Warden/Sorcery/Rack::Auth::Basic
|
|
@@ -59,17 +195,21 @@ module Wurk
|
|
|
59
195
|
@rack_app = nil
|
|
60
196
|
end
|
|
61
197
|
|
|
62
|
-
# Builds
|
|
63
|
-
#
|
|
64
|
-
#
|
|
198
|
+
# Builds the host-middleware chain wrapping `inner`, memoized against
|
|
199
|
+
# the middleware list it was built from — `middlewares` is the live
|
|
200
|
+
# array (upstream surface), so direct mutation after the first request
|
|
201
|
+
# triggers a rebuild instead of silently serving the stale chain.
|
|
202
|
+
# Production builds exactly once at boot; the per-request comparison is
|
|
203
|
+
# an == over a handful of entries.
|
|
65
204
|
def rack_app(inner)
|
|
66
|
-
@rack_app
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
205
|
+
return @rack_app if @rack_app && @rack_app_stack == @middlewares
|
|
206
|
+
|
|
207
|
+
@rack_app_stack = @middlewares.dup
|
|
208
|
+
stack = @middlewares
|
|
209
|
+
@rack_app = ::Rack::Builder.new do
|
|
210
|
+
stack.each { |middleware, args, block| use(middleware, *args, &block) }
|
|
211
|
+
run inner
|
|
212
|
+
end.to_app
|
|
73
213
|
end
|
|
74
214
|
|
|
75
215
|
# Read-only mode. When on, the Authorization middleware blocks every
|
|
@@ -86,10 +226,13 @@ module Wurk
|
|
|
86
226
|
end
|
|
87
227
|
|
|
88
228
|
def reset!
|
|
229
|
+
@options = OPTIONS.dup
|
|
89
230
|
@authorization = nil
|
|
90
231
|
@read_only = env_read_only?
|
|
232
|
+
@read_only_message = nil
|
|
91
233
|
@middlewares = []
|
|
92
234
|
@rack_app = nil
|
|
235
|
+
init_extensions!
|
|
93
236
|
end
|
|
94
237
|
|
|
95
238
|
# Returns true when no block is registered, otherwise the block's
|
|
@@ -107,6 +250,30 @@ module Wurk
|
|
|
107
250
|
def env_read_only?
|
|
108
251
|
ENV['WURK_WEB_READ_ONLY'] == '1'
|
|
109
252
|
end
|
|
253
|
+
|
|
254
|
+
def init_extensions!
|
|
255
|
+
@tabs = DEFAULT_TABS.dup
|
|
256
|
+
@extensions = []
|
|
257
|
+
@locales = []
|
|
258
|
+
@custom_job_info_rows = []
|
|
259
|
+
@app_url = nil
|
|
260
|
+
@assets_path = nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def job_info_pair(row, job)
|
|
264
|
+
pair = job_info_row_value(row, job)
|
|
265
|
+
return unless pair.is_a?(::Array) && pair.size == 2
|
|
266
|
+
|
|
267
|
+
[pair[0].to_s, pair[1].to_s]
|
|
268
|
+
rescue ::StandardError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def job_info_row_value(row, job)
|
|
273
|
+
return row.call(job) if row.respond_to?(:call)
|
|
274
|
+
|
|
275
|
+
row.add_pair(job) if row.respond_to?(:add_pair)
|
|
276
|
+
end
|
|
110
277
|
end
|
|
111
278
|
|
|
112
279
|
class << self
|
|
@@ -114,8 +281,11 @@ module Wurk
|
|
|
114
281
|
@config ||= Config.new
|
|
115
282
|
end
|
|
116
283
|
|
|
284
|
+
# With a block: yields the config (the documented configure form).
|
|
285
|
+
# Without: returns it — upstream's blockless-getter form, which gems
|
|
286
|
+
# use as `Sidekiq::Web.configure.tabs` (#204).
|
|
117
287
|
def configure
|
|
118
|
-
yield config
|
|
288
|
+
block_given? ? yield(config) : config
|
|
119
289
|
end
|
|
120
290
|
|
|
121
291
|
# Class-level shorthand for `config.use` — mirrors `Sidekiq::Web.use`.
|
|
@@ -123,6 +293,38 @@ module Wurk
|
|
|
123
293
|
config.use(...)
|
|
124
294
|
end
|
|
125
295
|
|
|
296
|
+
# Class-level extension surface — gems call these straight off
|
|
297
|
+
# `Sidekiq::Web` (e.g. `Sidekiq::Web.register(Ext, name:, tab:)` or
|
|
298
|
+
# `Sidekiq::Web.tabs["Locks"] = "locks"`), not only inside `configure`.
|
|
299
|
+
def register(extension, **, &)
|
|
300
|
+
config.register_extension(extension, **, &)
|
|
301
|
+
end
|
|
302
|
+
alias register_extension register
|
|
303
|
+
|
|
304
|
+
def tabs
|
|
305
|
+
config.tabs
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def custom_job_info_rows
|
|
309
|
+
config.custom_job_info_rows
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def app_url
|
|
313
|
+
config.app_url
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def app_url=(value)
|
|
317
|
+
config.app_url = value
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def assets_path
|
|
321
|
+
config.assets_path
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def assets_path=(value)
|
|
325
|
+
config.assets_path = value
|
|
326
|
+
end
|
|
327
|
+
|
|
126
328
|
# Test helper — exposed for parity with `Wurk::Limiter.reset_config!`.
|
|
127
329
|
# Production callers should not need to drop the auth block at runtime.
|
|
128
330
|
def reset_config!
|
data/lib/wurk/web/enterprise.rb
CHANGED
|
@@ -132,6 +132,20 @@ module Wurk
|
|
|
132
132
|
def history(bucket, window:, now: ::Time.now)
|
|
133
133
|
Wurk::Metrics::Query.history(bucket, window, now: now)
|
|
134
134
|
end
|
|
135
|
+
|
|
136
|
+
# Per-queue size/latency gauge time-series for the Historical tab.
|
|
137
|
+
# `bucket` is '1m'/'5m'/'1h'; `window` is in seconds (clamped to the
|
|
138
|
+
# bucket's retention). `queues:` narrows to one queue (else every live
|
|
139
|
+
# queue). Returns `[{name:, points: [{at:, size:, latency:}, …]}, …]`.
|
|
140
|
+
def queue_history(bucket, window:, queues: nil, now: ::Time.now)
|
|
141
|
+
Wurk::Metrics::Query.queue_history(bucket, window, queues: queues, now: now)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Ent §5.3 Historical snapshots from the `history:metrics` stream,
|
|
145
|
+
# oldest→newest. Returns `[{at:, processed:, failures:, …}, …]`.
|
|
146
|
+
def snapshots(limit: Wurk::History::STREAM_DEFAULT_LIMIT)
|
|
147
|
+
Wurk::History.recent(limit: limit)
|
|
148
|
+
end
|
|
135
149
|
end
|
|
136
150
|
end
|
|
137
151
|
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
class Web
|
|
9
|
+
# Server-side renderer for third-party Web extensions (spec §25, #187).
|
|
10
|
+
#
|
|
11
|
+
# Sidekiq extensions register Sinatra-style routes + ERB views via
|
|
12
|
+
# `Sidekiq::Web.register(Ext, name:, tab:, index:, root_dir:, asset_paths:)`,
|
|
13
|
+
# where `Ext.registered(app)` calls `app.get "/path" do … end` and
|
|
14
|
+
# `app.helpers SomeModule`. wurk's dashboard is a React SPA with no Sinatra
|
|
15
|
+
# render path, so this module provides a minimal, Sidekiq-Web-compatible
|
|
16
|
+
# renderer: it captures the routes, runs the matched block in an `Action`
|
|
17
|
+
# context (a `WebHelpers` subset + the ext's own helpers + `erb`), and
|
|
18
|
+
# returns the rendered HTML for the SPA's Extension page to embed.
|
|
19
|
+
#
|
|
20
|
+
# It does NOT depend on the `sidekiq` gem — extensions registered through
|
|
21
|
+
# wurk's own `Sidekiq::Web.register` alias render unchanged. (Running a
|
|
22
|
+
# real third-party gem unmodified additionally needs the shim in #204.)
|
|
23
|
+
module Extension
|
|
24
|
+
# Captures the routes + helpers an extension declares in `registered`.
|
|
25
|
+
# Quacks like the slice of `Sidekiq::Web::Application` extensions touch.
|
|
26
|
+
class App
|
|
27
|
+
attr_reader :routes, :helper_modules, :helper_blocks
|
|
28
|
+
|
|
29
|
+
HTTP_METHODS = %i[get post put patch delete head].freeze
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@routes = [] # [[method_string, Route, block], …]
|
|
33
|
+
@helper_modules = []
|
|
34
|
+
@helper_blocks = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
HTTP_METHODS.each do |verb|
|
|
38
|
+
define_method(verb) do |path, &block|
|
|
39
|
+
@routes << [verb.to_s.upcase, Route.new(path), block]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sidekiq extensions add helpers via `app.helpers(Mod)` and/or a block.
|
|
44
|
+
def helpers(*mods, &block)
|
|
45
|
+
@helper_modules.concat(mods)
|
|
46
|
+
@helper_blocks << block if block
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Sinatra settings calls some extensions make — accepted no-op.
|
|
51
|
+
def set(*); end
|
|
52
|
+
def settings = self
|
|
53
|
+
def configure(*) = (yield self if block_given?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compiles a Sinatra-style path ("/locks/:digest") into a matcher that
|
|
57
|
+
# extracts Symbol-keyed route params. A trailing "*" matches the rest.
|
|
58
|
+
class Route
|
|
59
|
+
NAMED = %r{/([^/]*):([^./:$]+)}
|
|
60
|
+
|
|
61
|
+
attr_reader :path
|
|
62
|
+
|
|
63
|
+
def initialize(path)
|
|
64
|
+
@path = path.to_s
|
|
65
|
+
@keys = []
|
|
66
|
+
@regex = compile(@path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns a `{key => value}` Hash of route params, or nil if no match.
|
|
70
|
+
def match(path)
|
|
71
|
+
m = @regex.match(path)
|
|
72
|
+
return nil unless m
|
|
73
|
+
|
|
74
|
+
@keys.each_with_index.to_h { |k, i| [k, m[i + 1] && CGI.unescape(m[i + 1])] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compile(path)
|
|
80
|
+
return /\A#{::Regexp.escape(path[0..-2])}.*\z/ if path.end_with?('*')
|
|
81
|
+
|
|
82
|
+
%r{\A#{escaped_pattern(path)}/?\z}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Escape every literal fragment so route text like ".json" or "+"
|
|
86
|
+
# matches itself instead of acting as a regex metacharacter.
|
|
87
|
+
def escaped_pattern(path)
|
|
88
|
+
pattern = +''
|
|
89
|
+
pos = 0
|
|
90
|
+
path.scan(NAMED) { pos = append_param(pattern, path, pos, ::Regexp.last_match) }
|
|
91
|
+
pattern << ::Regexp.escape(path[pos..])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def append_param(pattern, path, pos, match)
|
|
95
|
+
@keys << match[2].to_sym
|
|
96
|
+
pattern << ::Regexp.escape(path[pos...match.begin(0)]) << "/#{::Regexp.escape(match[1])}([^/?#]+)"
|
|
97
|
+
match.end(0)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The `WebHelpers` subset real extension views/routes call. Mixed into
|
|
102
|
+
# every `Action`. Anything an exotic gem needs beyond this can be added
|
|
103
|
+
# incrementally; the common surface (escaping, paths, time, i18n) is here.
|
|
104
|
+
module Helpers
|
|
105
|
+
def h(text) = ::CGI.escapeHTML(text.to_s)
|
|
106
|
+
|
|
107
|
+
# Truncate long text (display safety) — mirrors Sidekiq's default cap.
|
|
108
|
+
def truncate(text, max = 2000)
|
|
109
|
+
str = text.to_s
|
|
110
|
+
str.size > max ? "#{str[0, max]}…" : str
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Best-effort arg display, matching Sidekiq's `to_display`.
|
|
114
|
+
def to_display(arg)
|
|
115
|
+
str = arg.inspect
|
|
116
|
+
str.size > 100 ? "#{str[0, 100]}…" : str
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# i18n: look up the extension's own locale strings if present, else the
|
|
120
|
+
# humanized key (so a missing translation degrades, never raises).
|
|
121
|
+
def t(key, options = {})
|
|
122
|
+
val = (@ext_strings || {}).dig(*key.to_s.split('.'))
|
|
123
|
+
str = val.is_a?(::String) ? val : key.to_s.tr('_', ' ').capitalize
|
|
124
|
+
options.each { |k, v| str = str.gsub("%{#{k}}", v.to_s) }
|
|
125
|
+
str
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Base path for this extension, trailing slash. Embedded in the
|
|
129
|
+
# engine, ext links must land back on the embed endpoint
|
|
130
|
+
# (`/wurk/ext/<name>/…`); standalone (`run Sidekiq::Web`, #204) the
|
|
131
|
+
# ext's routes ARE the URL space, so it's the app root — upstream's
|
|
132
|
+
# `"#{env['SCRIPT_NAME']}/"` semantics.
|
|
133
|
+
def root_path = @embed ? "#{@mount}/ext/#{@ext_name}/" : "#{@mount}/"
|
|
134
|
+
def current_path = @subpath.to_s.sub(%r{\A/}, '')
|
|
135
|
+
|
|
136
|
+
def asset_path(file)
|
|
137
|
+
@embed ? "#{@mount}/ext-assets/#{@ext_name}/#{file}" : "#{@mount}/#{@ext_name}/#{file}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# GET-form embeds don't need CSRF; return an empty, benign tag.
|
|
141
|
+
def csrf_tag = ''
|
|
142
|
+
def csp_nonce = (@csp_nonce ||= ::SecureRandom.hex(8))
|
|
143
|
+
|
|
144
|
+
# `<time>` element like Sidekiq's relative_time; JS upgrades it client-side.
|
|
145
|
+
def relative_time(time)
|
|
146
|
+
t = time.is_a?(::Time) ? time : ::Time.at(time.to_f)
|
|
147
|
+
iso = t.utc.iso8601
|
|
148
|
+
%(<time class="ltr" dir="ltr" title="#{iso}" datetime="#{iso}">#{iso}</time>)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def redis(&) = ::Wurk.redis(&)
|
|
152
|
+
def product_version = ::Wurk::VERSION
|
|
153
|
+
def number_with_delimiter(num) = num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Per-request render context: instance-evals a matched route block, then
|
|
157
|
+
# renders ERB with the block's ivars + helpers in scope.
|
|
158
|
+
class Action
|
|
159
|
+
include Helpers
|
|
160
|
+
|
|
161
|
+
# Internal-redirect signal carried out of a route block via throw/catch.
|
|
162
|
+
Redirect = ::Struct.new(:location)
|
|
163
|
+
|
|
164
|
+
def initialize(env:, route_params:, ext:, mount:, embed: true)
|
|
165
|
+
@env = env
|
|
166
|
+
@request = ::Rack::Request.new(env)
|
|
167
|
+
@route_params = route_params
|
|
168
|
+
@ext_name = ext[:name].to_s
|
|
169
|
+
@root_dir = ext[:root_dir]
|
|
170
|
+
@ext_strings = ext[:strings]
|
|
171
|
+
@mount = mount.to_s
|
|
172
|
+
@embed = embed
|
|
173
|
+
@subpath = env['wurk.ext.subpath']
|
|
174
|
+
extend_helpers(ext[:helpers])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
attr_reader :env, :request
|
|
178
|
+
|
|
179
|
+
def params = @params ||= symbolize(@request.params).merge(@route_params)
|
|
180
|
+
def url_params(key) = @request.params[key.to_s]
|
|
181
|
+
def route_params(key = nil) = key ? @route_params[key.to_sym] : @route_params
|
|
182
|
+
def session = (@env['rack.session'] ||= {})
|
|
183
|
+
def logger = ::Wurk.logger
|
|
184
|
+
def redirect(location) = throw(:wurk_ext_halt, Redirect.new(location))
|
|
185
|
+
|
|
186
|
+
# Render an ERB template. `content` is either the template String (the
|
|
187
|
+
# common path — the ext's own helper reads the file) or a Symbol naming
|
|
188
|
+
# a `*.erb` under the ext's root_dir/views.
|
|
189
|
+
def erb(content, _options = {})
|
|
190
|
+
template = content.is_a?(::Symbol) ? read_view(content) : content.to_s
|
|
191
|
+
::ERB.new(template, trim_mode: '-').result(binding)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Run the route block in this context, capturing redirects. Returns the
|
|
195
|
+
# rendered HTML String, or a Redirect.
|
|
196
|
+
def run(block)
|
|
197
|
+
catch(:wurk_ext_halt) { instance_exec(&block) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def read_view(name)
|
|
203
|
+
raise ::ArgumentError, "extension #{@ext_name} has no root_dir" unless @root_dir
|
|
204
|
+
|
|
205
|
+
::File.read(::File.join(@root_dir, 'views', "#{name}.erb"))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def extend_helpers(helpers)
|
|
209
|
+
Array(helpers && helpers[:modules]).each { |mod| extend(mod) }
|
|
210
|
+
Array(helpers && helpers[:blocks]).each { |blk| instance_eval(&blk) if blk }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def symbolize(hash) = hash.to_h.transform_keys(&:to_sym)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Ties it together: finds a registered extension by name, captures its
|
|
217
|
+
# routes once (`registered(app)`), matches the request, and renders.
|
|
218
|
+
class Renderer
|
|
219
|
+
class << self
|
|
220
|
+
# @return [Array(Integer, Hash, String)] Rack-ish [status, headers,
|
|
221
|
+
# body] — 200 HTML, 302 redirect, or 404 — or nil when no extension
|
|
222
|
+
# with `name` is registered (so the engine can fall through).
|
|
223
|
+
# `embed: true` (the engine's ext/:name/* endpoint) rewrites links
|
|
224
|
+
# and redirects into the embed URL space; `embed: false` (the
|
|
225
|
+
# standalone `run Sidekiq::Web` Rack app, #204) leaves the
|
|
226
|
+
# extension's own route paths as the URL space, like upstream.
|
|
227
|
+
# rubocop:disable Metrics/ParameterLists -- request facts (name/verb/path/env) + URL-space (mount/embed); bundling them would just rename the list
|
|
228
|
+
def call(name:, method:, subpath:, env:, mount:, embed: true)
|
|
229
|
+
ext = registered_extension(name)
|
|
230
|
+
return nil unless ext
|
|
231
|
+
|
|
232
|
+
verb = method.to_s.upcase
|
|
233
|
+
route, block, route_params = match_route(ext, verb, subpath)
|
|
234
|
+
return [404, html_headers, "No #{verb} route #{subpath} in extension #{name}"] unless route
|
|
235
|
+
|
|
236
|
+
env['wurk.ext.subpath'] = subpath
|
|
237
|
+
render(ext, route_params, block, env, { mount: mount, embed: embed })
|
|
238
|
+
end
|
|
239
|
+
# rubocop:enable Metrics/ParameterLists
|
|
240
|
+
|
|
241
|
+
# `[absolute_path, cache_for_seconds]` of an asset under the
|
|
242
|
+
# extension's asset_paths, or nil if the extension/file isn't found.
|
|
243
|
+
# The expand_path prefix check rejects `../` traversal out of the dir.
|
|
244
|
+
def asset_file(name, file)
|
|
245
|
+
ext = registered_extension(name)
|
|
246
|
+
return nil unless ext
|
|
247
|
+
|
|
248
|
+
Array(ext[:asset_paths]).each do |dir|
|
|
249
|
+
path = ::File.expand_path(::File.join(dir, file.to_s))
|
|
250
|
+
next unless path.start_with?("#{::File.expand_path(dir)}/") && ::File.file?(path)
|
|
251
|
+
|
|
252
|
+
return [path, ext[:cache_for] || 86_400]
|
|
253
|
+
end
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def registered_extension(name)
|
|
260
|
+
::Wurk::Web.config.extensions.find { |e| e[:name].to_s == name.to_s }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# `ctx` is `{ mount:, embed: }` — the URL-space the response renders
|
|
264
|
+
# into (engine embed vs standalone root).
|
|
265
|
+
def render(ext, route_params, block, env, ctx)
|
|
266
|
+
result = action_for(ext, route_params, env, ctx).run(block)
|
|
267
|
+
if result.is_a?(Action::Redirect)
|
|
268
|
+
[302, { 'Location' => redirect_target(result.location, ext, ctx) }, '']
|
|
269
|
+
else
|
|
270
|
+
[200, html_headers, result.to_s]
|
|
271
|
+
end
|
|
272
|
+
rescue ::StandardError => e
|
|
273
|
+
::Wurk.configuration.handle_exception(e, context: 'web-extension-render')
|
|
274
|
+
[500, html_headers, "Extension render error: #{::CGI.escapeHTML(e.message)}"]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def action_for(ext, route_params, env, ctx)
|
|
278
|
+
app = captured_app(ext)
|
|
279
|
+
Action.new(
|
|
280
|
+
env: env, route_params: route_params, mount: ctx[:mount], embed: ctx[:embed],
|
|
281
|
+
ext: ext.merge(helpers: { modules: app.helper_modules, blocks: app.helper_blocks },
|
|
282
|
+
strings: strings_for(ext))
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def match_route(ext, verb, subpath)
|
|
287
|
+
captured_app(ext).routes.each do |meth, route, block|
|
|
288
|
+
next unless meth == verb
|
|
289
|
+
|
|
290
|
+
params = route.match(subpath)
|
|
291
|
+
return [route, block, params] if params
|
|
292
|
+
end
|
|
293
|
+
[nil, nil, nil]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Capture the ext's routes/helpers once; memoize on the config entry.
|
|
297
|
+
def captured_app(ext)
|
|
298
|
+
ext[:_app] ||= App.new.tap do |app|
|
|
299
|
+
ext[:extension].registered(app) if ext[:extension].respond_to?(:registered)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# An ext's redirect target is relative to its mount ("locks" → the
|
|
304
|
+
# embed endpoint; standalone → the app root, where root_path-built
|
|
305
|
+
# targets already carry the mount). Absolute URLs pass through.
|
|
306
|
+
def redirect_target(location, ext, ctx)
|
|
307
|
+
loc = location.to_s
|
|
308
|
+
return loc if loc.match?(%r{\A[a-z]+://}i)
|
|
309
|
+
return "#{ctx[:mount]}/ext/#{ext[:name]}/#{loc.sub(%r{\A/}, '')}" if ctx[:embed]
|
|
310
|
+
|
|
311
|
+
loc.start_with?('/') ? loc : "#{ctx[:mount]}/#{loc}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The ext's own root_dir/locales plus every dir appended to
|
|
315
|
+
# `config.locales` (the upstream protocol for extensions without a
|
|
316
|
+
# root_dir — sidekiq-cron does `Sidekiq::Web.configure.locales <<
|
|
317
|
+
# dir` inside `registered`, which `captured_app` has already run by
|
|
318
|
+
# the time we land here).
|
|
319
|
+
def strings_for(ext)
|
|
320
|
+
ext.fetch(:_strings) do
|
|
321
|
+
ext[:_strings] = locale_dirs(ext).each_with_object({}) do |dir, acc|
|
|
322
|
+
file = ::File.join(dir.to_s, 'en.yml')
|
|
323
|
+
acc.merge!(load_yaml(file)) if ::File.exist?(file)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def locale_dirs(ext)
|
|
329
|
+
dirs = []
|
|
330
|
+
dirs << ::File.join(ext[:root_dir], 'locales') if ext[:root_dir]
|
|
331
|
+
dirs.concat(::Wurk::Web.config.locales)
|
|
332
|
+
dirs.uniq
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def load_yaml(file)
|
|
336
|
+
require 'yaml'
|
|
337
|
+
data = ::YAML.safe_load_file(file)
|
|
338
|
+
data.is_a?(::Hash) ? (data.values.first || data) : {}
|
|
339
|
+
rescue ::StandardError
|
|
340
|
+
{}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def html_headers = { 'Content-Type' => 'text/html; charset=utf-8' }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|