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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 774727bf58b55972fbc4ace533c90803233ad6e4a42d6e8b8cec0f464972bae3
|
|
4
|
+
data.tar.gz: e06ddec0e9138bb50be07a50184d46b500affe8f9212d5aaa290be75b35a73da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0f7924f14f94fc5f4143be95389f461c988d2772cb05d4502a77d9959431420d4392a5dc7d9d390038fa372da0b492b4f0d095bb566d6abc0fc4a6bf3fa5605
|
|
7
|
+
data.tar.gz: 8cc09f2445d8e75f40cdc06ac84597748de5e2a7cbfd99ec9ad062e88ca5df03f7be7e117c221533c796304a6b90e6e47555f26d307a04a44b550b570449ceeb
|
data/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<div align="center">
|
|
12
12
|
|
|
13
|
+
[](https://wurk.demo.developerz.ai/wurk)
|
|
13
14
|
[](https://rubygems.org/gems/wurk)
|
|
14
15
|
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
15
16
|
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
@@ -54,6 +55,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
|
|
|
54
55
|
|
|
55
56
|
## Documentation
|
|
56
57
|
|
|
58
|
+
- **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki / full docs](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and the complete guide.
|
|
57
59
|
- **[Getting started & architecture](https://github.com/developerz-ai/wurk/blob/main/docs/idea/01-overview.md)** — how the swarm, manager, fetcher, and processor fit together.
|
|
58
60
|
- **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
|
|
59
61
|
- **API reference (parity specs):** [Sidekiq OSS](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-free.md) · [Pro](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-pro.md) · [Enterprise](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-ent.md) — the authoritative surface Wurk matches exactly.
|
|
@@ -139,6 +141,8 @@ Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode o
|
|
|
139
141
|
|
|
140
142
|
`bundle install && restart`. Wurk reads and writes the same Redis schema, so a rolling deploy can run Sidekiq and Wurk against the same Redis during the cutover. Third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled, …) are exercised by running their own upstream suites against Wurk in the [`ecosystem` CI job](https://github.com/developerz-ai/wurk/blob/main/.github/workflows/ecosystem.yml) (see [`test/ecosystem/`](https://github.com/developerz-ai/wurk/tree/main/test/ecosystem)).
|
|
141
143
|
|
|
144
|
+
Full walkthrough — config side-by-side, the Redis key/`sidekiq_options` mapping, known incompatibilities, and a one-page cutover checklist: **[docs/migrate-from-sidekiq.md](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md)**.
|
|
145
|
+
|
|
142
146
|
## Contributing
|
|
143
147
|
|
|
144
148
|
Issues and pull requests are welcome — see **[CONTRIBUTING.md](https://github.com/developerz-ai/wurk/blob/main/CONTRIBUTING.md)** for the dev setup, test layers, and conventions, and **[SECURITY.md](https://github.com/developerz-ai/wurk/blob/main/SECURITY.md)** to report a vulnerability.
|
|
@@ -34,8 +34,13 @@ module Wurk
|
|
|
34
34
|
{ name: summary.name, size: summary.size, latency: summary.latency, paused: summary.paused? }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Host-registered custom job-info rows (spec §25.2) ride along as
|
|
38
|
+
# `custom_rows` for the SPA's job-detail modal (see Config#job_info_pairs,
|
|
39
|
+
# which gates on registration so the common no-extension case is free).
|
|
40
|
+
# Divergence: wurk evaluates these during job-list serialization — the SPA
|
|
41
|
+
# renders job detail client-side — not in a dedicated server detail view.
|
|
37
42
|
def job_record(record)
|
|
38
|
-
{
|
|
43
|
+
base = {
|
|
39
44
|
jid: record.jid,
|
|
40
45
|
klass: record.display_class,
|
|
41
46
|
args: record.display_args,
|
|
@@ -43,6 +48,8 @@ module Wurk
|
|
|
43
48
|
enqueued_at: record.enqueued_at&.to_f,
|
|
44
49
|
created_at: record.created_at&.to_f
|
|
45
50
|
}
|
|
51
|
+
rows = ::Wurk::Web.config.job_info_pairs(record)
|
|
52
|
+
rows.empty? ? base : base.merge(custom_rows: rows)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def sorted_entry(entry)
|
|
@@ -51,7 +58,8 @@ module Wurk
|
|
|
51
58
|
at: entry.at.to_f,
|
|
52
59
|
error_class: entry['error_class'],
|
|
53
60
|
error_message: entry['error_message'],
|
|
54
|
-
retry_count: entry['retry_count']
|
|
61
|
+
retry_count: entry['retry_count'],
|
|
62
|
+
error_backtrace: entry.error_backtrace
|
|
55
63
|
)
|
|
56
64
|
end
|
|
57
65
|
|
|
@@ -67,6 +75,10 @@ module Wurk
|
|
|
67
75
|
quiet: process.stopping?,
|
|
68
76
|
rss: process['rss'],
|
|
69
77
|
rtt_us: process['rtt_us'],
|
|
78
|
+
started_at: process['started_at'],
|
|
79
|
+
cpu_model: process['cpu_model'],
|
|
80
|
+
cores: process['cores'],
|
|
81
|
+
memory_total_kb: process['memory_total_kb'],
|
|
70
82
|
labels: process.labels,
|
|
71
83
|
queues: process.queues,
|
|
72
84
|
version: process.version,
|
|
@@ -74,6 +86,20 @@ module Wurk
|
|
|
74
86
|
}
|
|
75
87
|
end
|
|
76
88
|
|
|
89
|
+
# One in-flight job (WorkSet row) for the Busy page's process detail.
|
|
90
|
+
def work_row(process_id, thread_id, work)
|
|
91
|
+
record = work.job
|
|
92
|
+
{
|
|
93
|
+
process_id: process_id,
|
|
94
|
+
thread_id: thread_id,
|
|
95
|
+
queue: work.queue,
|
|
96
|
+
klass: record.display_class,
|
|
97
|
+
args: record.display_args,
|
|
98
|
+
jid: record.jid,
|
|
99
|
+
run_at: work.run_at.to_f
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
77
103
|
def limiter_row(name, meta)
|
|
78
104
|
{
|
|
79
105
|
name: name,
|
|
@@ -119,6 +145,13 @@ module Wurk
|
|
|
119
145
|
{ at: row[:at], processed: row[:p], failed: row[:f], runtime_ms: row[:ms] }
|
|
120
146
|
end
|
|
121
147
|
|
|
148
|
+
# One queue's size/latency gauge series (Wurk::Metrics::Query.queue_history).
|
|
149
|
+
# `points` are oldest→newest; `at` is epoch seconds at the bucket start,
|
|
150
|
+
# `size` is queue depth, `latency` is head-of-line wait in seconds.
|
|
151
|
+
def queue_history_series(row)
|
|
152
|
+
{ name: row[:name], points: row[:points].map { |p| { at: p[:at], size: p[:size], latency: p[:latency] } } }
|
|
153
|
+
end
|
|
154
|
+
|
|
122
155
|
def parse_options(raw)
|
|
123
156
|
return {} if raw.nil? || raw.to_s.empty?
|
|
124
157
|
|
|
@@ -126,6 +159,19 @@ module Wurk
|
|
|
126
159
|
rescue ::JSON::ParserError
|
|
127
160
|
{}
|
|
128
161
|
end
|
|
162
|
+
|
|
163
|
+
# One profile row for the Profiles pane. `key` drives the view/data links.
|
|
164
|
+
def profile_record(rec)
|
|
165
|
+
{
|
|
166
|
+
key: rec.key,
|
|
167
|
+
jid: rec.jid,
|
|
168
|
+
token: rec.token,
|
|
169
|
+
type: rec.type,
|
|
170
|
+
size: rec.size,
|
|
171
|
+
elapsed: rec.elapsed,
|
|
172
|
+
started_at: rec.started_at&.to_i
|
|
173
|
+
}
|
|
174
|
+
end
|
|
129
175
|
end
|
|
130
176
|
end
|
|
131
177
|
end
|
|
@@ -24,13 +24,30 @@ module Wurk
|
|
|
24
24
|
|
|
25
25
|
skip_forgery_protection only: %i[
|
|
26
26
|
stream reset_limiter pause_cron unpause_cron enqueue_cron
|
|
27
|
+
clear_queue delete_queue_job pause_queue unpause_queue
|
|
28
|
+
retries_bulk retries_all retry_job
|
|
29
|
+
scheduled_bulk scheduled_all scheduled_job
|
|
30
|
+
dead_bulk dead_all dead_job
|
|
31
|
+
quiet_process stop_process
|
|
27
32
|
]
|
|
28
33
|
|
|
34
|
+
# Per-set action whitelists. Maps the SPA's action name to the
|
|
35
|
+
# SortedEntry/JobSet method. Anything not listed 400s — keeps the bulk/
|
|
36
|
+
# single dispatchers from reaching arbitrary methods off a request param.
|
|
37
|
+
RETRY_ACTIONS = { 'retry' => :retry, 'delete' => :delete, 'kill' => :kill }.freeze
|
|
38
|
+
SCHEDULED_ACTIONS = { 'delete' => :delete, 'add_to_queue' => :add_to_queue }.freeze
|
|
39
|
+
DEAD_ACTIONS = { 'retry' => :retry, 'delete' => :delete }.freeze
|
|
40
|
+
|
|
29
41
|
# Boot-time flags the SPA reads once to shape the UI (e.g. hide destructive
|
|
30
42
|
# actions and show the read-only banner). Always a GET, so it stays
|
|
31
43
|
# reachable while read-only mode blocks mutations.
|
|
32
44
|
def meta
|
|
33
|
-
|
|
45
|
+
config = ::Wurk::Web.config
|
|
46
|
+
render json: {
|
|
47
|
+
read_only: config.read_only?,
|
|
48
|
+
read_only_message: config.read_only_message,
|
|
49
|
+
custom_tabs: config.custom_tabs
|
|
50
|
+
}
|
|
34
51
|
end
|
|
35
52
|
|
|
36
53
|
def stats
|
|
@@ -51,14 +68,97 @@ module Wurk
|
|
|
51
68
|
}
|
|
52
69
|
end
|
|
53
70
|
|
|
71
|
+
# Empties one queue (UNLINK list + drop from the `queues` set).
|
|
72
|
+
def clear_queue
|
|
73
|
+
::Wurk::Queue.new(params[:name].to_s).clear
|
|
74
|
+
render json: { ok: true }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Removes a single job from a queue by jid. LREM matches exact bytes, so we
|
|
78
|
+
# locate the record (Queue#find_job) and let it delete its own value.
|
|
79
|
+
def delete_queue_job
|
|
80
|
+
record = ::Wurk::Queue.new(params[:name].to_s).find_job(params[:jid].to_s)
|
|
81
|
+
return render(json: { error: 'unknown job' }, status: :not_found) unless record
|
|
82
|
+
|
|
83
|
+
render json: { ok: true, deleted: record.delete }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Pause/unpause a queue (Pro §6, §10.1). Idempotent; returns the resulting
|
|
87
|
+
# state so the SPA can update its toggle without a refetch round-trip.
|
|
88
|
+
def pause_queue
|
|
89
|
+
::Wurk::Queue.new(params[:name].to_s).pause!
|
|
90
|
+
render json: { ok: true, paused: true }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unpause_queue
|
|
94
|
+
::Wurk::Queue.new(params[:name].to_s).unpause!
|
|
95
|
+
render json: { ok: true, paused: false }
|
|
96
|
+
end
|
|
97
|
+
|
|
54
98
|
def retries = render_sorted_set(::Wurk::RetrySet.new)
|
|
55
99
|
def scheduled = render_sorted_set(::Wurk::ScheduledSet.new)
|
|
56
100
|
def dead = render_sorted_set(::Wurk::DeadSet.new)
|
|
57
101
|
|
|
102
|
+
# --- Retry set mutations -------------------------------------------------
|
|
103
|
+
def retry_job = single_entry_action(::Wurk::RetrySet.new, RETRY_ACTIONS, params[:cmd])
|
|
104
|
+
def retries_bulk = bulk_entry_action(::Wurk::RetrySet.new, RETRY_ACTIONS)
|
|
105
|
+
|
|
106
|
+
def retries_all
|
|
107
|
+
set = ::Wurk::RetrySet.new
|
|
108
|
+
count = case params[:cmd].to_s
|
|
109
|
+
when 'retry' then set.retry_all
|
|
110
|
+
when 'kill' then set.kill_all
|
|
111
|
+
when 'delete' then clear_set(set)
|
|
112
|
+
else return render(json: { error: 'unknown action' }, status: :bad_request)
|
|
113
|
+
end
|
|
114
|
+
render json: { ok: true, count: count }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- Scheduled set mutations ---------------------------------------------
|
|
118
|
+
def scheduled_job = single_entry_action(::Wurk::ScheduledSet.new, SCHEDULED_ACTIONS, params[:cmd])
|
|
119
|
+
def scheduled_bulk = bulk_entry_action(::Wurk::ScheduledSet.new, SCHEDULED_ACTIONS)
|
|
120
|
+
|
|
121
|
+
def scheduled_all
|
|
122
|
+
set = ::Wurk::ScheduledSet.new
|
|
123
|
+
count = case params[:cmd].to_s
|
|
124
|
+
when 'delete' then clear_set(set)
|
|
125
|
+
when 'add_to_queue' then drain_set(set, :add_to_queue)
|
|
126
|
+
else return render(json: { error: 'unknown action' }, status: :bad_request)
|
|
127
|
+
end
|
|
128
|
+
render json: { ok: true, count: count }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Dead set mutations --------------------------------------------------
|
|
132
|
+
def dead_job = single_entry_action(::Wurk::DeadSet.new, DEAD_ACTIONS, params[:cmd])
|
|
133
|
+
def dead_bulk = bulk_entry_action(::Wurk::DeadSet.new, DEAD_ACTIONS)
|
|
134
|
+
|
|
135
|
+
def dead_all
|
|
136
|
+
set = ::Wurk::DeadSet.new
|
|
137
|
+
count = case params[:cmd].to_s
|
|
138
|
+
when 'retry' then set.retry_all
|
|
139
|
+
when 'delete' then clear_set(set)
|
|
140
|
+
else return render(json: { error: 'unknown action' }, status: :bad_request)
|
|
141
|
+
end
|
|
142
|
+
render json: { ok: true, count: count }
|
|
143
|
+
end
|
|
144
|
+
|
|
58
145
|
def processes
|
|
59
146
|
render json: ::Wurk::ProcessSet.new.map { |p| ::Wurk::Api::Serializers.process_row(p) }
|
|
60
147
|
end
|
|
61
148
|
|
|
149
|
+
# Currently-executing jobs across the cluster (WorkSet), oldest first.
|
|
150
|
+
# The Busy page's process-detail modal filters client-side by process_id.
|
|
151
|
+
def workers
|
|
152
|
+
render json: ::Wurk::WorkSet.new.map { |pid, tid, work| ::Wurk::Api::Serializers.work_row(pid, tid, work) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Busy-page controls: SIGTSTP (quiet — drop fetch, drain in-flight) and
|
|
156
|
+
# SIGTERM (stop — graceful shutdown). Both are async; the target notices on
|
|
157
|
+
# its next heartbeat (≤10s). `identity` absent or "all" signals every live
|
|
158
|
+
# process.
|
|
159
|
+
def quiet_process = signal_processes(:quiet!)
|
|
160
|
+
def stop_process = signal_processes(:stop!)
|
|
161
|
+
|
|
62
162
|
def batches
|
|
63
163
|
set = ::Wurk::BatchSet.new
|
|
64
164
|
page = ::Wurk::Api::Pagination.window(params)
|
|
@@ -134,6 +234,34 @@ module Wurk
|
|
|
134
234
|
render json: { error: e.message }, status: :bad_request
|
|
135
235
|
end
|
|
136
236
|
|
|
237
|
+
# Ent §5.3 Historical snapshots from the capped `history:metrics` stream.
|
|
238
|
+
# `?limit=N` (default 1000) most-recent points, oldest→newest, each
|
|
239
|
+
# `{at:, processed:, failures:, …}`. Fields are read generically so a
|
|
240
|
+
# migrated Sidekiq Ent stream renders as-is.
|
|
241
|
+
def history_snapshots
|
|
242
|
+
limit = ::Wurk::Api::Pagination.clamp_int(
|
|
243
|
+
params[:limit], 1, ::Wurk::History::STREAM_CAP, ::Wurk::History::STREAM_DEFAULT_LIMIT
|
|
244
|
+
)
|
|
245
|
+
render json: { snapshots: ::Wurk::Web::Enterprise::Historical.snapshots(limit: limit) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Per-queue size/latency gauge time-series for the Metrics/Historical tab.
|
|
249
|
+
# `:bucket` is 1m/5m/1h; `?window=24h` (s/m/h/d) is clamped to the bucket's
|
|
250
|
+
# retention; optional `?queue=<name>` narrows to one queue. Each queue's
|
|
251
|
+
# `points` are Recharts-ready.
|
|
252
|
+
def queue_history
|
|
253
|
+
window = parse_window(params[:window])
|
|
254
|
+
queues = params[:queue].present? ? [params[:queue].to_s] : nil
|
|
255
|
+
series = ::Wurk::Web::Enterprise::Historical.queue_history(params[:bucket].to_s, window: window, queues: queues)
|
|
256
|
+
render json: {
|
|
257
|
+
bucket: params[:bucket].to_s,
|
|
258
|
+
window: window,
|
|
259
|
+
queues: series.map { |row| ::Wurk::Api::Serializers.queue_history_series(row) }
|
|
260
|
+
}
|
|
261
|
+
rescue ::ArgumentError => e
|
|
262
|
+
render json: { error: e.message }, status: :bad_request
|
|
263
|
+
end
|
|
264
|
+
|
|
137
265
|
def search
|
|
138
266
|
substr = params[:substr].to_s
|
|
139
267
|
return render(json: { substr: substr, total: 0, hits: [] }) if substr.empty?
|
|
@@ -142,6 +270,13 @@ module Wurk
|
|
|
142
270
|
render json: { substr: substr, total: hits.size, hits: hits }
|
|
143
271
|
end
|
|
144
272
|
|
|
273
|
+
# Profiles list (v8.0+). The SPA links each row to /profiles/:key (view)
|
|
274
|
+
# and /profiles/:key/data (raw blob). Newest first.
|
|
275
|
+
def profiles
|
|
276
|
+
records = ::Wurk::ProfileSet.new.map { |rec| ::Wurk::Api::Serializers.profile_record(rec) }
|
|
277
|
+
render json: records.sort_by { |r| -(r[:started_at] || 0) }
|
|
278
|
+
end
|
|
279
|
+
|
|
145
280
|
# SSE: one `event: stats` per tick with a fresh Stats snapshot. Caps at
|
|
146
281
|
# `STREAM_MAX_DURATION` so a stale browser tab can't tie a Rails worker
|
|
147
282
|
# forever — the client reconnects automatically when the stream closes.
|
|
@@ -159,6 +294,86 @@ module Wurk
|
|
|
159
294
|
|
|
160
295
|
private
|
|
161
296
|
|
|
297
|
+
# Resolves a single entry by "<score>|<jid>" key and applies a whitelisted
|
|
298
|
+
# action. 400 on an unknown action, 404 when the key matches nothing (e.g.
|
|
299
|
+
# the entry was already retried/deleted from another tab).
|
|
300
|
+
def single_entry_action(set, actions, cmd)
|
|
301
|
+
method = actions[cmd.to_s]
|
|
302
|
+
return render(json: { error: 'unknown action' }, status: :bad_request) unless method
|
|
303
|
+
|
|
304
|
+
entries = entries_for(set, [params[:key]])
|
|
305
|
+
return render(json: { error: 'unknown job' }, status: :not_found) if entries.empty?
|
|
306
|
+
|
|
307
|
+
entries.each { |entry| entry.public_send(method) }
|
|
308
|
+
render json: { ok: true, count: entries.size }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Bulk variant: `keys[]` + a single `cmd` applied to every resolved entry.
|
|
312
|
+
def bulk_entry_action(set, actions)
|
|
313
|
+
method = actions[params[:cmd].to_s]
|
|
314
|
+
return render(json: { error: 'unknown action' }, status: :bad_request) unless method
|
|
315
|
+
|
|
316
|
+
count = 0
|
|
317
|
+
keys = Array(params[:keys]).map(&:to_s).uniq
|
|
318
|
+
entries_for(set, keys).each do |entry|
|
|
319
|
+
entry.public_send(method)
|
|
320
|
+
count += 1
|
|
321
|
+
end
|
|
322
|
+
render json: { ok: true, count: count }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Sends `method` (:quiet! / :stop!) to one process by identity, or to every
|
|
326
|
+
# live process when identity is blank/"all". Embedded processes are skipped
|
|
327
|
+
# (they raise on quiet!/stop! — there's no separate process to signal). 404s
|
|
328
|
+
# when a named identity isn't in the live set.
|
|
329
|
+
def signal_processes(method)
|
|
330
|
+
identity = params[:identity].to_s
|
|
331
|
+
if identity.empty? || identity == 'all'
|
|
332
|
+
count = ::Wurk::ProcessSet.new.reject(&:embedded?).each { |p| p.public_send(method) }.size
|
|
333
|
+
return render(json: { ok: true, count: count })
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
process = ::Wurk::ProcessSet[identity]
|
|
337
|
+
return render(json: { error: 'unknown process' }, status: :not_found) unless process
|
|
338
|
+
|
|
339
|
+
process.public_send(method) unless process.embedded?
|
|
340
|
+
render json: { ok: true, count: process.embedded? ? 0 : 1 }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Maps "<score>|<jid>" keys to live SortedEntry objects via score-bracketed
|
|
344
|
+
# fetch (exact float match, narrowed by jid) — avoids depending on float→
|
|
345
|
+
# string round-tripping between JS and Ruby. Skips malformed/empty keys.
|
|
346
|
+
def entries_for(set, keys)
|
|
347
|
+
keys.flat_map do |key|
|
|
348
|
+
score, jid = key.to_s.split('|', 2)
|
|
349
|
+
next [] if jid.nil? || jid.empty?
|
|
350
|
+
|
|
351
|
+
set.fetch(score.to_f, jid)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# UNLINKs the whole set, returning the count removed (read before clearing
|
|
356
|
+
# so the response reports what was deleted).
|
|
357
|
+
def clear_set(set)
|
|
358
|
+
total = set.size
|
|
359
|
+
set.clear
|
|
360
|
+
total
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Drains a set by applying `method` to every entry until empty. Used for
|
|
364
|
+
# scheduled "add to queue all", where each call removes the entry and would
|
|
365
|
+
# otherwise shift the paged iterator's indices mid-scan.
|
|
366
|
+
def drain_set(set, method)
|
|
367
|
+
count = 0
|
|
368
|
+
until set.size.zero?
|
|
369
|
+
set.each do |entry|
|
|
370
|
+
entry.public_send(method)
|
|
371
|
+
count += 1
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
count
|
|
375
|
+
end
|
|
376
|
+
|
|
162
377
|
def render_sorted_set(set)
|
|
163
378
|
page = ::Wurk::Api::Pagination.window(params)
|
|
164
379
|
total = set.size
|
|
@@ -15,7 +15,14 @@ module Wurk
|
|
|
15
15
|
# Vite dev server (default :5173) so contributors get HMR without
|
|
16
16
|
# rebuilding the bundle on every change.
|
|
17
17
|
class DashboardController < ApplicationController
|
|
18
|
-
|
|
18
|
+
# Vite serves the dev shell under its `base` (vite.config.ts), which is the
|
|
19
|
+
# same path the engine mounts assets at — so derive these from
|
|
20
|
+
# AssetMount::PREFIX to keep the Ruby side in lockstep. Fetching the server
|
|
21
|
+
# root instead returns Vite's "did you mean /wurk-assets/" hint page, not the
|
|
22
|
+
# shell (issue #181).
|
|
23
|
+
VITE_DEV_HOST = 'http://localhost:5173'
|
|
24
|
+
VITE_ASSET_BASE = "#{::Wurk::Engine::AssetMount::PREFIX}/".freeze # "/wurk-assets/"
|
|
25
|
+
VITE_DEV_URL = "#{VITE_DEV_HOST}#{VITE_ASSET_BASE}".freeze
|
|
19
26
|
INDEX_REL_PATH = ['vendor', 'assets', 'dashboard', 'index.html'].freeze
|
|
20
27
|
|
|
21
28
|
def index
|
|
@@ -30,12 +37,23 @@ module Wurk
|
|
|
30
37
|
|
|
31
38
|
def fetch_vite_dev_shell
|
|
32
39
|
uri = ::URI.parse(VITE_DEV_URL)
|
|
33
|
-
::Net::HTTP.get(uri)
|
|
40
|
+
rewrite_dev_asset_urls(::Net::HTTP.get(uri))
|
|
34
41
|
rescue ::StandardError => e
|
|
35
42
|
raise "Wurk dashboard: cannot reach Vite dev server at #{VITE_DEV_URL} " \
|
|
36
43
|
"(#{e.class}: #{e.message}). Run `bin/rake frontend:dev` from the gem root."
|
|
37
44
|
end
|
|
38
45
|
|
|
46
|
+
# The shell's entry URLs (`src="/wurk-assets/@vite/client"`,
|
|
47
|
+
# `src="/wurk-assets/src/main.tsx"`) are base-absolute, but the page is
|
|
48
|
+
# served from the host origin (e.g. :3000), where that path only maps to the
|
|
49
|
+
# built bundle. Point them back at the Vite dev server so the browser loads
|
|
50
|
+
# the modules + HMR client straight from Vite (CORS-enabled in dev). Vite's
|
|
51
|
+
# `server.origin` covers runtime-generated asset URLs but not these entry
|
|
52
|
+
# tags, so rewrite them here.
|
|
53
|
+
def rewrite_dev_asset_urls(html)
|
|
54
|
+
html.gsub(/(["'])#{::Regexp.escape(VITE_ASSET_BASE)}/, "\\1#{VITE_DEV_URL}")
|
|
55
|
+
end
|
|
56
|
+
|
|
39
57
|
def read_built_index
|
|
40
58
|
path = ::Wurk::Engine.root.join(*INDEX_REL_PATH)
|
|
41
59
|
unless path.exist?
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'wurk/web/extension'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
# Serves third-party Web extensions (#187): `ext/:name/*subpath` runs the
|
|
7
|
+
# registered extension's matched route and returns its rendered HTML for the
|
|
8
|
+
# SPA's Extension page to embed; `ext-assets/:name/*file` serves files from
|
|
9
|
+
# the extension's `asset_paths`.
|
|
10
|
+
#
|
|
11
|
+
# Inherits DashboardController so an /ext/* URL that is actually the SPA's
|
|
12
|
+
# client-side route (no extension registered under that name — e.g. a browser
|
|
13
|
+
# refresh on /wurk/ext/<tab>/) falls through to the SPA shell instead of 404.
|
|
14
|
+
class ExtensionsController < DashboardController
|
|
15
|
+
# Extension forms carry no Rails CSRF token. Parity with Sidekiq's own
|
|
16
|
+
# CSRF model instead (spec §25.1): unsafe methods must be same-origin per
|
|
17
|
+
# Sec-Fetch-Site; cross-site requests are denied. GETs are unaffected.
|
|
18
|
+
skip_forgery_protection
|
|
19
|
+
before_action :verify_same_origin!, unless: -> { request.get? || request.head? }
|
|
20
|
+
|
|
21
|
+
def show
|
|
22
|
+
result = Web::Extension::Renderer.call(
|
|
23
|
+
name: params[:name], method: request.request_method,
|
|
24
|
+
subpath: "/#{params[:subpath]}", env: request.env, mount: request.script_name
|
|
25
|
+
)
|
|
26
|
+
return index unless result # not an extension → SPA client route; let React handle it
|
|
27
|
+
|
|
28
|
+
respond_with(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def asset
|
|
32
|
+
file, cache_for = Web::Extension::Renderer.asset_file(params[:name], params[:file])
|
|
33
|
+
return head :not_found unless file
|
|
34
|
+
|
|
35
|
+
response.headers['Cache-Control'] = "public, max-age=#{cache_for}"
|
|
36
|
+
send_file file, disposition: 'inline'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def respond_with((status, headers, body))
|
|
42
|
+
return redirect_to(headers['Location'], allow_other_host: false) if status == 302
|
|
43
|
+
|
|
44
|
+
# Extension output is host-registered server code, same trust model as
|
|
45
|
+
# Sidekiq::Web rendering its extensions — not user input.
|
|
46
|
+
render html: body.html_safe, layout: false, status: status
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Spec §25.1: unsafe methods require `Sec-Fetch-Site: same-origin` — a
|
|
50
|
+
# missing header is denied too, like stock Sidekiq (a non-browser client
|
|
51
|
+
# must spoof the header deliberately; a cookie-carrying browser can't).
|
|
52
|
+
def verify_same_origin!
|
|
53
|
+
head :forbidden unless request.headers['Sec-Fetch-Site'] == 'same-origin'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
# Profiles pane non-JSON endpoints (spec §25.4):
|
|
8
|
+
#
|
|
9
|
+
# GET /profiles/:key/data → the stored gzipped gecko JSON, streamed with a
|
|
10
|
+
# gzip Content-Encoding (Firefox profiler pulls
|
|
11
|
+
# this when given a `from-url` source).
|
|
12
|
+
# GET /profiles/:key → upload the profile to the Firefox profiler
|
|
13
|
+
# store and 302 to its public view URL.
|
|
14
|
+
#
|
|
15
|
+
# `:key` is "<token>-<jid>". The JSON list lives at /api/profiles.
|
|
16
|
+
class ProfilesController < ApplicationController
|
|
17
|
+
# CSRF protection is for state-changing form posts; these are GET reads.
|
|
18
|
+
skip_forgery_protection
|
|
19
|
+
|
|
20
|
+
def data
|
|
21
|
+
blob = profile_blob(params[:key])
|
|
22
|
+
return head(:not_found) unless blob
|
|
23
|
+
|
|
24
|
+
response.headers['Content-Encoding'] = 'gzip'
|
|
25
|
+
send_data blob, type: 'application/json', disposition: 'inline'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show
|
|
29
|
+
blob = profile_blob(params[:key])
|
|
30
|
+
return head(:not_found) unless blob
|
|
31
|
+
|
|
32
|
+
hash = upload_to_profiler(blob)
|
|
33
|
+
return head(:bad_gateway) unless hash
|
|
34
|
+
|
|
35
|
+
redirect_to(format(::Wurk::Web.config.profile_view_url, hash), allow_other_host: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def profile_blob(key)
|
|
41
|
+
Wurk.redis { |conn| conn.call('HGET', key, 'data') }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POSTs the gzipped profile to the Firefox profiler's compressed-store.
|
|
45
|
+
# Returns the public hash (used to build the view URL) or nil on failure.
|
|
46
|
+
def upload_to_profiler(gzipped)
|
|
47
|
+
uri = URI.parse(::Wurk::Web.config.profile_store_url)
|
|
48
|
+
res = post_gzip(uri, gzipped)
|
|
49
|
+
res.is_a?(Net::HTTPSuccess) ? res.body.to_s.strip : nil
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Wurk.configuration.handle_exception(e, context: 'Wurk::ProfilesController#upload')
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def post_gzip(uri, body)
|
|
56
|
+
req = Net::HTTP::Post.new(uri)
|
|
57
|
+
req['Content-Encoding'] = 'gzip'
|
|
58
|
+
req['Content-Type'] = 'application/json'
|
|
59
|
+
req.body = body
|
|
60
|
+
# Explicit timeouts so a slow/unreachable profiler can't tie up the Rails
|
|
61
|
+
# request thread for Ruby's ~60s defaults.
|
|
62
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
63
|
+
open_timeout: 5, read_timeout: 15) do |http|
|
|
64
|
+
http.request(req)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -8,11 +8,43 @@ Wurk::Engine.routes.draw do
|
|
|
8
8
|
get 'meta', to: 'api#meta'
|
|
9
9
|
get 'stats', to: 'api#stats'
|
|
10
10
|
get 'queues', to: 'api#queues'
|
|
11
|
-
get 'queues/:name', to: 'api#queue', as: :api_queue
|
|
11
|
+
get 'queues/:name', to: 'api#queue', as: :api_queue, constraints: { name: %r{[^/]+} }
|
|
12
|
+
post 'queues/:name/clear', to: 'api#clear_queue', as: :api_clear_queue, constraints: { name: %r{[^/]+} }
|
|
13
|
+
post 'queues/:name/delete', to: 'api#delete_queue_job', as: :api_delete_queue_job, constraints: { name: %r{[^/]+} }
|
|
14
|
+
# Per-queue Pause/Unpause (Pro §6, §10.1): toggles membership of the `paused`
|
|
15
|
+
# SET that fetchers consult. Read-only mode 403s these via Authorization.
|
|
16
|
+
post 'queues/:name/pause', to: 'api#pause_queue', as: :api_pause_queue, constraints: { name: %r{[^/]+} }
|
|
17
|
+
post 'queues/:name/unpause', to: 'api#unpause_queue', as: :api_unpause_queue, constraints: { name: %r{[^/]+} }
|
|
18
|
+
|
|
19
|
+
# Job-set mutations (retry/delete/kill/requeue/clear). The SPA posts to
|
|
20
|
+
# these; the Authorization middleware 403s every non-GET in read-only mode,
|
|
21
|
+
# so they need no extra guard. `:key` is "<score>|<jid>" (Wurk::SortedEntry#id);
|
|
22
|
+
# `all/:cmd` operates over the whole set; the bare collection POST is a bulk
|
|
23
|
+
# action over `keys[]`. Spec: docs/target/sidekiq-free.md §25.4.
|
|
24
|
+
# `all/:cmd` is intentionally unconstrained: the controller validates `:cmd`
|
|
25
|
+
# against the per-set whitelist and returns a JSON 400 for an unknown action,
|
|
26
|
+
# matching the single/bulk contract. A route-level constraint would turn that
|
|
27
|
+
# into a 404 route miss instead.
|
|
12
28
|
get 'retries', to: 'api#retries'
|
|
29
|
+
post 'retries', to: 'api#retries_bulk', as: :api_retries_bulk
|
|
30
|
+
post 'retries/all/:cmd', to: 'api#retries_all', as: :api_retries_all
|
|
31
|
+
post 'retries/:key', to: 'api#retry_job', as: :api_retry_job, constraints: { key: %r{[^/]+} }
|
|
13
32
|
get 'scheduled', to: 'api#scheduled'
|
|
33
|
+
post 'scheduled', to: 'api#scheduled_bulk', as: :api_scheduled_bulk
|
|
34
|
+
post 'scheduled/all/:cmd', to: 'api#scheduled_all', as: :api_scheduled_all
|
|
35
|
+
post 'scheduled/:key', to: 'api#scheduled_job', as: :api_scheduled_job, constraints: { key: %r{[^/]+} }
|
|
14
36
|
get 'dead', to: 'api#dead'
|
|
37
|
+
post 'dead', to: 'api#dead_bulk', as: :api_dead_bulk
|
|
38
|
+
post 'dead/all/:cmd', to: 'api#dead_all', as: :api_dead_all
|
|
39
|
+
post 'dead/:key', to: 'api#dead_job', as: :api_dead_job, constraints: { key: %r{[^/]+} }
|
|
15
40
|
get 'processes', to: 'api#processes'
|
|
41
|
+
get 'workers', to: 'api#workers'
|
|
42
|
+
# Busy-page process control (quiet/stop). `identity` rides in the body (it
|
|
43
|
+
# contains ':' and '.', awkward as a path segment); absent or "all" signals
|
|
44
|
+
# every live process. Read-only mode 403s these via the Authorization
|
|
45
|
+
# middleware. Spec: docs/target/sidekiq-free.md §25.4 (POST /busy).
|
|
46
|
+
post 'busy/quiet', to: 'api#quiet_process', as: :api_quiet_process
|
|
47
|
+
post 'busy/stop', to: 'api#stop_process', as: :api_stop_process
|
|
16
48
|
get 'batches', to: 'api#batches'
|
|
17
49
|
get 'batches/:bid', to: 'api#batch', as: :api_batch
|
|
18
50
|
get 'limiters', to: 'api#limiters'
|
|
@@ -24,11 +56,32 @@ Wurk::Engine.routes.draw do
|
|
|
24
56
|
get 'cron/:lid/history', to: 'api#cron_history', as: :api_cron_history
|
|
25
57
|
get 'metrics', to: 'api#metrics'
|
|
26
58
|
get 'metrics/:klass', to: 'api#metrics_for_job', as: :api_metrics_for_job, constraints: { klass: %r{[^/]+} }
|
|
59
|
+
get 'history/snapshots', to: 'api#history_snapshots', as: :api_history_snapshots
|
|
27
60
|
get 'history/:bucket', to: 'api#history', as: :api_history
|
|
61
|
+
get 'queue-history/:bucket', to: 'api#queue_history', as: :api_queue_history
|
|
28
62
|
get 'search', to: 'api#search'
|
|
63
|
+
get 'profiles', to: 'api#profiles'
|
|
29
64
|
get 'stream', to: 'api#stream' # SSE
|
|
30
65
|
end
|
|
31
66
|
|
|
67
|
+
# Profiles (v8.0+) — not under /api: `:key/data` streams the gzipped gecko
|
|
68
|
+
# blob with a gzip Content-Encoding, and `:key` POST-uploads the profile to
|
|
69
|
+
# the Firefox profiler then 302s to its public view. `:key` is "<token>-<jid>".
|
|
70
|
+
# The `/data` route is declared first so it wins over the bare `:key` match.
|
|
71
|
+
# Spec: docs/target/sidekiq-free.md §25.4.
|
|
72
|
+
get 'profiles/:key/data', to: 'profiles#data', as: :profile_data, constraints: { key: %r{[^/]+} }
|
|
73
|
+
get 'profiles/:key', to: 'profiles#show', as: :profile, constraints: { key: %r{[^/]+} }
|
|
74
|
+
|
|
75
|
+
# Third-party Web extensions (#187): server-rendered route + assets. The SPA's
|
|
76
|
+
# Extension page fetches `ext/:name/<subpath>` and embeds the returned HTML;
|
|
77
|
+
# `format: false` keeps dots in subpaths/filenames out of Rails' format logic.
|
|
78
|
+
# An /ext URL with no matching registered extension falls through to the SPA
|
|
79
|
+
# shell inside the controller (it's the SPA's own /ext/:tab client route).
|
|
80
|
+
match 'ext/:name(/*subpath)', to: 'extensions#show', via: %i[get post put patch delete], as: :extension,
|
|
81
|
+
format: false, defaults: { subpath: '' }, constraints: { name: %r{[^/]+} }
|
|
82
|
+
get 'ext-assets/:name/*file', to: 'extensions#asset', as: :extension_asset,
|
|
83
|
+
format: false, constraints: { name: %r{[^/]+} }
|
|
84
|
+
|
|
32
85
|
# SPA catch-all — let React Router handle the rest.
|
|
33
86
|
get '*path', to: 'dashboard#index', constraints: ->(req) { req.format == :html }
|
|
34
87
|
end
|
data/exe/sidekiqswarm
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Drop-in alias for `wurkswarm`. Sidekiq Enterprise apps invoke `sidekiqswarm`
|
|
5
|
+
# in their Procfile / systemd unit; this keeps that exact command working after
|
|
6
|
+
# a one-line gem swap. Same behavior — forks the worker swarm from a preloaded
|
|
7
|
+
# parent. Spec: docs/target/sidekiq-ent.md §7.
|
|
8
|
+
load File.expand_path('wurkswarm', __dir__)
|