cogworker 0.1.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 +7 -0
- data/exe/cogworker +6 -0
- data/exe/cogworkerswarm +6 -0
- data/lib/cogworker/basic_fetch.rb +27 -0
- data/lib/cogworker/cli.rb +64 -0
- data/lib/cogworker/client.rb +61 -0
- data/lib/cogworker/component.rb +19 -0
- data/lib/cogworker/config.rb +84 -0
- data/lib/cogworker/config_loader.rb +22 -0
- data/lib/cogworker/heartbeat.rb +150 -0
- data/lib/cogworker/history/middleware.rb +18 -0
- data/lib/cogworker/history/storage.rb +81 -0
- data/lib/cogworker/history.rb +33 -0
- data/lib/cogworker/job.rb +75 -0
- data/lib/cogworker/job_record.rb +24 -0
- data/lib/cogworker/job_util.rb +60 -0
- data/lib/cogworker/launcher.rb +93 -0
- data/lib/cogworker/logging.rb +18 -0
- data/lib/cogworker/manager.rb +71 -0
- data/lib/cogworker/middleware/chain.rb +64 -0
- data/lib/cogworker/periodic/claim.lua +27 -0
- data/lib/cogworker/periodic/entry.rb +24 -0
- data/lib/cogworker/periodic/manager.rb +28 -0
- data/lib/cogworker/periodic/release_middleware.rb +27 -0
- data/lib/cogworker/periodic/ticker.rb +123 -0
- data/lib/cogworker/process.rb +36 -0
- data/lib/cogworker/process_set.rb +29 -0
- data/lib/cogworker/processor.rb +129 -0
- data/lib/cogworker/prometheus/exporter.rb +62 -0
- data/lib/cogworker/queue.rb +60 -0
- data/lib/cogworker/redis_connection.rb +37 -0
- data/lib/cogworker/redis_keys.rb +32 -0
- data/lib/cogworker/scheduled.rb +67 -0
- data/lib/cogworker/signals.rb +15 -0
- data/lib/cogworker/stats.rb +47 -0
- data/lib/cogworker/status/client_middleware.rb +19 -0
- data/lib/cogworker/status/server_middleware.rb +31 -0
- data/lib/cogworker/status/storage.rb +30 -0
- data/lib/cogworker/status/worker.rb +27 -0
- data/lib/cogworker/status.rb +40 -0
- data/lib/cogworker/swarm.rb +169 -0
- data/lib/cogworker/testing.rb +109 -0
- data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
- data/lib/cogworker/unique_jobs.rb +32 -0
- data/lib/cogworker/version.rb +5 -0
- data/lib/cogworker/web/action.rb +63 -0
- data/lib/cogworker/web/application.rb +62 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
- data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
- data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
- data/lib/cogworker/web/assets/htmx.min.js +1 -0
- data/lib/cogworker/web/assets/tailwind.css +1 -0
- data/lib/cogworker/web/layout.rb +352 -0
- data/lib/cogworker/web/router.rb +27 -0
- data/lib/cogworker/web/routes/busy.rb +99 -0
- data/lib/cogworker/web/routes/dead.rb +93 -0
- data/lib/cogworker/web/routes/history.rb +226 -0
- data/lib/cogworker/web/routes/periodic.rb +67 -0
- data/lib/cogworker/web/routes/queues.rb +101 -0
- data/lib/cogworker/web/routes/retries.rb +89 -0
- data/lib/cogworker/web/routes/save_session.rb +21 -0
- data/lib/cogworker/web/routes/scheduled.rb +49 -0
- data/lib/cogworker/web/routes/stats.rb +298 -0
- data/lib/cogworker/web/views.rb +25 -0
- data/lib/cogworker/web.rb +257 -0
- data/lib/cogworker/work.rb +19 -0
- data/lib/cogworker/work_set.rb +23 -0
- data/lib/cogworker/worker.rb +5 -0
- data/lib/cogworker/workers.rb +8 -0
- data/lib/cogworker.rb +114 -0
- metadata +300 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
module Routes
|
|
8
|
+
# The 6 job-counter cards, the "Runs per day" Chart.js graph, and a
|
|
9
|
+
# Redis INFO summary.
|
|
10
|
+
module Stats
|
|
11
|
+
COUNTERS_CONTENT_ID = 'stats-counters-content'
|
|
12
|
+
REDIS_CONTENT_ID = 'stats-redis-content'
|
|
13
|
+
# The 6 job-counter colors are the same ones `Layout.stat_chip` uses
|
|
14
|
+
# for the compact bar shown on every page — one shared mapping
|
|
15
|
+
# (`Layout::JOB_STAT_ACCENTS`) so the two never drift apart.
|
|
16
|
+
CARD_ACCENTS = Layout::JOB_STAT_ACCENTS.merge(
|
|
17
|
+
'Version' => 'text-gray-700 dark:text-gray-300', 'Uptime (days)' => 'text-gray-700 dark:text-gray-300',
|
|
18
|
+
'Connections' => 'text-gray-700 dark:text-gray-300', 'Memory Usage' => 'text-gray-700 dark:text-gray-300',
|
|
19
|
+
'Peak Memory Usage' => 'text-gray-700 dark:text-gray-300'
|
|
20
|
+
).freeze
|
|
21
|
+
# `INFO` field name => card label. Pulled from the flat Hash
|
|
22
|
+
# `Cogworker::Stats#redis_info` returns (same field names the
|
|
23
|
+
# `redis` gem always uses, regardless of Redis version) — a field
|
|
24
|
+
# missing from a given server/deployment renders as "n/a" rather
|
|
25
|
+
# than raising.
|
|
26
|
+
REDIS_INFO_FIELDS = {
|
|
27
|
+
'redis_version' => 'Version', 'uptime_in_days' => 'Uptime (days)',
|
|
28
|
+
'connected_clients' => 'Connections', 'used_memory_human' => 'Memory Usage',
|
|
29
|
+
'used_memory_peak_human' => 'Peak Memory Usage'
|
|
30
|
+
}.freeze
|
|
31
|
+
# Period switcher options for the "Runs per day" chart —
|
|
32
|
+
# `params['period']` (a plain query string, read fresh on every
|
|
33
|
+
# request; see `registered` below) selects one of these by key.
|
|
34
|
+
# Ordered as displayed, shortest first.
|
|
35
|
+
PERIODS = {
|
|
36
|
+
'week' => { 'label' => 'Week', 'days' => 7 },
|
|
37
|
+
'month' => { 'label' => 'Month', 'days' => 30 },
|
|
38
|
+
'3months' => { 'label' => '3 Months', 'days' => 90 },
|
|
39
|
+
'6months' => { 'label' => '6 Months', 'days' => 182 }
|
|
40
|
+
}.freeze
|
|
41
|
+
DEFAULT_PERIOD = 'month'
|
|
42
|
+
CHART_SUCCESS_COLOR = '#16a34a'
|
|
43
|
+
CHART_FAILED_COLOR = '#dc2626'
|
|
44
|
+
CHART_CANVAS_ID = 'runs-chart-canvas'
|
|
45
|
+
CHART_CONTAINER_HEIGHT_PX = 220
|
|
46
|
+
# Vendored under assets/ (see CLAUDE.md's "Fully offline" section —
|
|
47
|
+
# served locally, not fetched from a CDN), the same way AG_GRID_ASSETS
|
|
48
|
+
# is in `routes/history.rb`.
|
|
49
|
+
CHART_ASSET = 'assets/chart.umd.min.js'
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
def registered(app)
|
|
54
|
+
renderer = lambda do
|
|
55
|
+
# A plain query param, not htmx state — the period switcher
|
|
56
|
+
# below is a normal `<a href>` (full page reload), exactly like
|
|
57
|
+
# `Routes::History`'s status filter links.
|
|
58
|
+
period = Stats.resolve_period(params['period'])
|
|
59
|
+
body = Stats.page_body(period, request.script_name)
|
|
60
|
+
if hx_request?
|
|
61
|
+
body
|
|
62
|
+
else
|
|
63
|
+
Layout.wrap('Stats', body, script_name: request.script_name, show_stats_bar: false,
|
|
64
|
+
extra_head: Stats.chart_head(request.script_name))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
app.get('/', &renderer)
|
|
69
|
+
app.get('/stats', &renderer)
|
|
70
|
+
|
|
71
|
+
# Polled by `Layout.stats_bar` (the global counter strip shown
|
|
72
|
+
# under the header on every page, not just here) — always just
|
|
73
|
+
# this small fragment, never a full page.
|
|
74
|
+
app.get('/stats/bar') { Layout.stats_bar_content }
|
|
75
|
+
|
|
76
|
+
# The job-counter and Redis grids each poll their own small
|
|
77
|
+
# fragment independently (see `page_body`) — neither depends on
|
|
78
|
+
# `period`, so unlike the chart there's no query string to carry.
|
|
79
|
+
app.get('/stats/counters') { Stats.counters_grid }
|
|
80
|
+
app.get('/stats/redis') { Stats.redis_grid(Cogworker::Stats.new.redis_info) }
|
|
81
|
+
|
|
82
|
+
# Polled directly by the chart's own inline script (`chart`,
|
|
83
|
+
# below) via `fetch` — NOT an htmx target, and deliberately not a
|
|
84
|
+
# full HTML fragment: returning just the plotted numbers lets the
|
|
85
|
+
# chart patch its existing Chart.js instance's data in place
|
|
86
|
+
# (`chart.update()`) instead of tearing down and recreating the
|
|
87
|
+
# `<canvas>`/instance on every tick, which is what an htmx
|
|
88
|
+
# innerHTML-swapped fragment would force. Same idea as
|
|
89
|
+
# `Routes::History`'s `/history/data` for its AG Grid.
|
|
90
|
+
app.get('/stats/chart_data') do
|
|
91
|
+
period = Stats.resolve_period(params['period'])
|
|
92
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate(Stats.chart_data_payload(period))]]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_period(raw)
|
|
97
|
+
PERIODS.key?(raw) ? raw : DEFAULT_PERIOD
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def chart_head(script_name)
|
|
101
|
+
%(<script src="#{Layout.path(script_name, CHART_ASSET)}"></script>)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The counters and Redis grids are each their own independently
|
|
105
|
+
# htmx-polled fragment (`Layout.poll_div`) — plain, stateless HTML,
|
|
106
|
+
# cheap to fully replace every tick. The "Runs per day" section in
|
|
107
|
+
# between is a normal, *unpolled* part of the page: its own chart
|
|
108
|
+
# keeps itself live via `/stats/chart_data` instead (see
|
|
109
|
+
# `registered` above and `chart` below), so re-rendering this whole
|
|
110
|
+
# method's output on a poll would rebuild widgets that don't need
|
|
111
|
+
# rebuilding — the counters/Redis grids are the only pieces actually
|
|
112
|
+
# meant to be swapped wholesale on every tick.
|
|
113
|
+
def page_body(period, script_name)
|
|
114
|
+
stats = Cogworker::Stats.new
|
|
115
|
+
counters_poll = Layout.poll_div(COUNTERS_CONTENT_ID, script_name, 'stats/counters', counters_grid(stats))
|
|
116
|
+
runs_chart = Layout.section('Runs per day', runs_per_day_section(period, script_name))
|
|
117
|
+
redis_poll = Layout.section('Redis', Layout.poll_div(REDIS_CONTENT_ID, script_name, 'stats/redis',
|
|
118
|
+
redis_grid(stats.redis_info)))
|
|
119
|
+
counters_poll + runs_chart + redis_poll
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def counters_grid(stats = Cogworker::Stats.new)
|
|
123
|
+
values = {
|
|
124
|
+
'Enqueued' => stats.enqueued, 'Processed' => stats.processed, 'Failed' => stats.failed,
|
|
125
|
+
'Retries' => stats.retry_size, 'Scheduled' => stats.scheduled_size, 'Dead' => stats.dead_size
|
|
126
|
+
}
|
|
127
|
+
cards = values.map { |label, value| card(label, value) }.join
|
|
128
|
+
%(<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">#{cards}</div>)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def redis_grid(info)
|
|
132
|
+
cards = REDIS_INFO_FIELDS.map { |field, label| card(label, info[field] || 'n/a') }.join
|
|
133
|
+
%(<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">#{cards}</div>)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def runs_per_day_section(period, script_name)
|
|
137
|
+
period_switcher(script_name, period) + chart_bubble(period, script_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def period_switcher(script_name, current_period)
|
|
141
|
+
links = PERIODS.map do |key, opts|
|
|
142
|
+
period_link(script_name, key, opts['label'], active: key == current_period)
|
|
143
|
+
end.join
|
|
144
|
+
%(<div class="mb-4 flex gap-2">#{links}</div>)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def period_link(script_name, key, label, active:)
|
|
148
|
+
classes = if active
|
|
149
|
+
'bg-indigo-600 text-white'
|
|
150
|
+
else
|
|
151
|
+
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
152
|
+
end
|
|
153
|
+
href = Layout.path(script_name, "stats?period=#{key}")
|
|
154
|
+
%(<a href="#{href}" class="px-3 py-1 rounded-md text-sm font-medium #{classes}">#{Layout.h(label)}</a>)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Wraps the chart in the same "bubble" card look as the job-counter/
|
|
158
|
+
# Redis stat cards (`card`, below).
|
|
159
|
+
def chart_bubble(period, script_name)
|
|
160
|
+
%(<div class="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-4">#{chart(
|
|
161
|
+
period, script_name
|
|
162
|
+
)}</div>)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Success/failed counts for `period`, keyed exactly as Chart.js
|
|
166
|
+
# wants them — `labels`/`fullDates` line up index-for-index with
|
|
167
|
+
# `success`/`failed`. Shared between the initial render (`chart`,
|
|
168
|
+
# baked into the page) and `/stats/chart_data` (what the chart's own
|
|
169
|
+
# poll re-fetches from then on), so the two can never drift apart.
|
|
170
|
+
def chart_data_payload(period)
|
|
171
|
+
days_count = PERIODS.fetch(period, PERIODS[DEFAULT_PERIOD])['days']
|
|
172
|
+
# Fully qualified: a bare `History::Storage` from inside
|
|
173
|
+
# `Routes::Stats` would resolve, via lexical nesting, to
|
|
174
|
+
# `Cogworker::Web::Routes::History` first (this module's sibling
|
|
175
|
+
# route file) — the same gotcha `routes/history.rb` itself
|
|
176
|
+
# documents — not to the top-level `Cogworker::History`.
|
|
177
|
+
counts = Cogworker::History::Storage.daily_counts(days_count)
|
|
178
|
+
days = day_labels(days_count)
|
|
179
|
+
{
|
|
180
|
+
'labels' => days.map { |d| d[5..] },
|
|
181
|
+
'fullDates' => days,
|
|
182
|
+
'success' => days.map { |d| (counts[d] || {})['success'] || 0 },
|
|
183
|
+
'failed' => days.map { |d| (counts[d] || {})['failed'] || 0 }
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A small two-line chart (success/failed per day) rendered by
|
|
188
|
+
# Chart.js (vendored, loaded via `chart_head` — see CLAUDE.md's
|
|
189
|
+
# "Fully offline" section, not fetched from a CDN), not a hand-rolled
|
|
190
|
+
# SVG: a first attempt at a DIY SVG chart used
|
|
191
|
+
# `preserveAspectRatio="none"` to stretch full width, which visibly
|
|
192
|
+
# distorted the plotted lines/points on any card wider than its
|
|
193
|
+
# aspect ratio, and its styling looked noticeably rougher than a
|
|
194
|
+
# maintained charting library's own defaults (real user report on
|
|
195
|
+
# both counts). `responsive: true` + `maintainAspectRatio: false`
|
|
196
|
+
# fills the fixed-height wrapper div at its full width, at any
|
|
197
|
+
# screen size, without distortion — Chart.js sizes its own
|
|
198
|
+
# `<canvas>` (including devicePixelRatio) via `ResizeObserver`.
|
|
199
|
+
#
|
|
200
|
+
# This section is NOT inside any `Layout.poll_div` (see `page_body`
|
|
201
|
+
# above) — the `<canvas>`/`new Chart(...)` below render exactly once
|
|
202
|
+
# per page load. An earlier version instead re-rendered this whole
|
|
203
|
+
# chart (canvas included) on every htmx poll, which meant creating a
|
|
204
|
+
# brand-new Chart.js instance every tick; skipping `.destroy()` on
|
|
205
|
+
# the previous one (easy to miss, since the *canvas* really was
|
|
206
|
+
# gone) leaked one more zombie instance per poll — real, observed
|
|
207
|
+
# root cause of the chart eventually breaking under live updates.
|
|
208
|
+
# Rather than track and destroy instances across swaps, the fix here
|
|
209
|
+
# is to not recreate the widget at all: the chart keeps itself
|
|
210
|
+
# current by polling `/stats/chart_data` on its own (`refreshChart`
|
|
211
|
+
# below, gated by the same `window.cogworkerLiveUpdate` toggle every
|
|
212
|
+
# other tab's poll respects) and patching the *existing* instance's
|
|
213
|
+
# data in place, exactly like `Routes::History`'s AG Grid does via
|
|
214
|
+
# its own `refreshRows`/`/history/data`.
|
|
215
|
+
def chart(period, script_name)
|
|
216
|
+
payload = chart_data_payload(period)
|
|
217
|
+
data_url = Layout.path(script_name, "stats/chart_data?period=#{period}")
|
|
218
|
+
|
|
219
|
+
<<~HTML
|
|
220
|
+
<div style="position: relative; height: #{CHART_CONTAINER_HEIGHT_PX}px; width: 100%;">
|
|
221
|
+
<canvas id="#{CHART_CANVAS_ID}"></canvas>
|
|
222
|
+
</div>
|
|
223
|
+
<script>
|
|
224
|
+
(function () {
|
|
225
|
+
var dataUrl = #{Layout.json_for_script(data_url)};
|
|
226
|
+
var fullDates = #{Layout.json_for_script(payload['fullDates'])};
|
|
227
|
+
var chart = new Chart(document.getElementById(#{Layout.json_for_script(CHART_CANVAS_ID)}), {
|
|
228
|
+
type: 'line',
|
|
229
|
+
data: {
|
|
230
|
+
labels: #{Layout.json_for_script(payload['labels'])},
|
|
231
|
+
datasets: [
|
|
232
|
+
{ label: 'Success', data: #{Layout.json_for_script(payload['success'])},
|
|
233
|
+
borderColor: #{Layout.json_for_script(CHART_SUCCESS_COLOR)},
|
|
234
|
+
backgroundColor: #{Layout.json_for_script(CHART_SUCCESS_COLOR)},
|
|
235
|
+
tension: 0.3, pointRadius: 2, borderWidth: 2 },
|
|
236
|
+
{ label: 'Failed', data: #{Layout.json_for_script(payload['failed'])},
|
|
237
|
+
borderColor: #{Layout.json_for_script(CHART_FAILED_COLOR)},
|
|
238
|
+
backgroundColor: #{Layout.json_for_script(CHART_FAILED_COLOR)},
|
|
239
|
+
tension: 0.3, pointRadius: 2, borderWidth: 2 }
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
options: {
|
|
243
|
+
responsive: true,
|
|
244
|
+
maintainAspectRatio: false,
|
|
245
|
+
interaction: { mode: 'index', intersect: false },
|
|
246
|
+
scales: {
|
|
247
|
+
x: { ticks: { color: '#6b7280', maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { display: false } },
|
|
248
|
+
y: { beginAtZero: true, ticks: { color: '#6b7280', precision: 0 }, grid: { color: 'rgba(107, 114, 128, 0.15)' } }
|
|
249
|
+
},
|
|
250
|
+
plugins: {
|
|
251
|
+
legend: { labels: { color: '#6b7280' } },
|
|
252
|
+
tooltip: { callbacks: { title: function (items) { return fullDates[items[0].dataIndex]; } } }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
function refreshChart() {
|
|
258
|
+
if (!window.cogworkerLiveUpdate) return;
|
|
259
|
+
fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
|
|
260
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
261
|
+
.then(function (data) {
|
|
262
|
+
if (!data) return;
|
|
263
|
+
fullDates = data.fullDates;
|
|
264
|
+
chart.data.labels = data.labels;
|
|
265
|
+
chart.data.datasets[0].data = data.success;
|
|
266
|
+
chart.data.datasets[1].data = data.failed;
|
|
267
|
+
chart.update();
|
|
268
|
+
})
|
|
269
|
+
.catch(function () {});
|
|
270
|
+
}
|
|
271
|
+
setInterval(refreshChart, #{Cogworker::Web.live_update_interval * 1000});
|
|
272
|
+
})();
|
|
273
|
+
</script>
|
|
274
|
+
HTML
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# `days_count` consecutive UTC calendar-day strings ending today —
|
|
278
|
+
# matches the UTC bucketing `Storage.daily_counts` uses, so lookups
|
|
279
|
+
# by day string always line up.
|
|
280
|
+
def day_labels(days_count)
|
|
281
|
+
now = Time.now.utc
|
|
282
|
+
(days_count - 1).downto(0).map { |offset| (now - (offset * 86_400)).strftime('%Y-%m-%d') }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def card(label, value)
|
|
286
|
+
<<~HTML
|
|
287
|
+
<div class="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-4">
|
|
288
|
+
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">#{Layout.h(label)}</div>
|
|
289
|
+
<div class="mt-1 text-2xl font-bold #{CARD_ACCENTS.fetch(label, '')}">#{Layout.h(value)}</div>
|
|
290
|
+
</div>
|
|
291
|
+
HTML
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Stats)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
# Reads a built-in view template by name. An extension that wants its
|
|
6
|
+
# own template file can use the same pattern (read the file itself,
|
|
7
|
+
# then hand `erb` the resulting String) — `Action#erb` accepts either a
|
|
8
|
+
# Symbol (looked up here) or a raw ERB-source String directly.
|
|
9
|
+
module Views
|
|
10
|
+
VIEWS_PATH = File.join(__dir__, 'views')
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def read(name)
|
|
14
|
+
cache[name] ||= File.read(File.join(VIEWS_PATH, "#{name}.erb"))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def cache
|
|
20
|
+
@cache ||= {}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'rack/session/cookie'
|
|
5
|
+
require 'rack/static'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module Cogworker
|
|
9
|
+
# Rack entry point: `map('/mount-point') { run Cogworker::Web }`.
|
|
10
|
+
#
|
|
11
|
+
# A plain, reopenable class on purpose — `safe_request?` is meant to be
|
|
12
|
+
# overridden by whoever mounts this (e.g. to let one specific external
|
|
13
|
+
# callback path through a same-origin check) without needing any hook
|
|
14
|
+
# mechanism from this gem: `class Cogworker::Web; def self.safe_request?(env);
|
|
15
|
+
# ...; end; end` just works, the same way monkey-patching any Ruby class
|
|
16
|
+
# does. Note the `self.` — `safe_request?` is defined inside `class << self`
|
|
17
|
+
# below (it's called as `Cogworker::Web.safe_request?`, not on an
|
|
18
|
+
# instance), so reopening it as a plain instance method is a silent no-op.
|
|
19
|
+
class Web
|
|
20
|
+
# Every built-in tab lives under here; touching each constant once
|
|
21
|
+
# forces Zeitwerk to load that file (and run its
|
|
22
|
+
# `Cogworker::Web.register(...)` bottom-of-file side effect) — nothing
|
|
23
|
+
# else ever references `Routes::Queues` etc. by name, so without this
|
|
24
|
+
# they would simply never load.
|
|
25
|
+
BUILT_IN_ROUTE_NAMES = %i[Queues Busy Retries Scheduled Periodic Dead History Stats SaveSession].freeze
|
|
26
|
+
DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
|
|
27
|
+
DEFAULT_HISTORY_PER_PAGE = 25
|
|
28
|
+
DEFAULT_LIVE_UPDATE_INTERVAL = 3
|
|
29
|
+
|
|
30
|
+
# htmx/Tailwind/AG Grid are vendored under here (not fetched from a CDN)
|
|
31
|
+
# so the Web UI works with no internet access at all — see `Layout`'s
|
|
32
|
+
# `<script>`/`<link>` tags and `Routes::History#ag_grid_head`, all of
|
|
33
|
+
# which build their `src`/`href` as `path(script_name, 'assets/...')`,
|
|
34
|
+
# same as every other in-app link. `root:` is this directory's *parent*
|
|
35
|
+
# (not `assets/` itself) because `Rack::Static` appends the full,
|
|
36
|
+
# unstripped request path (e.g. `/assets/htmx.min.js`) onto `root`.
|
|
37
|
+
ASSETS_ROOT = File.join(__dir__, 'web')
|
|
38
|
+
|
|
39
|
+
# Guards every request's dispatch while reloading is enabled — a plain
|
|
40
|
+
# class-level constant (not lazily memoized via `||=`, which would
|
|
41
|
+
# itself race) so it exists before any request can possibly reach it.
|
|
42
|
+
# See `call`/`dispatch` below for why this has to wrap the *entire*
|
|
43
|
+
# request, not just `reload!`.
|
|
44
|
+
RELOAD_MUTEX = Mutex.new
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# While `COGWORKER_RELOAD=true`, the whole request — the reload
|
|
48
|
+
# itself *and* the route handling that follows — runs inside
|
|
49
|
+
# `RELOAD_MUTEX`, not just the `Cogworker::LOADER.reload` call.
|
|
50
|
+
# `Zeitwerk::Loader#reload`'s `unload` step removes every constant it
|
|
51
|
+
# manages *process-wide* for the duration of the reload, not just
|
|
52
|
+
# from this thread's point of view — so a second thread mid-`app.call`
|
|
53
|
+
# (already past its own `reload!`, now executing a route that
|
|
54
|
+
# references `Layout`/`WorkSet`/etc.) can have those constants yanked
|
|
55
|
+
# out from under it and raise `NameError`, even though it never
|
|
56
|
+
# touched `reload!` concurrently itself. Only wrapping `reload!` (an
|
|
57
|
+
# earlier version of this fix) stopped the *permanent* `Zeitwerk::
|
|
58
|
+
# SetupRequired` wedge but not this — both were caught by
|
|
59
|
+
# `spec/cogworker/web_reload_spec.rb`'s concurrent-requests spec,
|
|
60
|
+
# which spawns several threads hammering `.call` at once. Outside
|
|
61
|
+
# `COGWORKER_RELOAD` (i.e. every real worker-adjacent Web process)
|
|
62
|
+
# `reloading?` is false and this adds no locking at all.
|
|
63
|
+
def call(env)
|
|
64
|
+
if reloading?
|
|
65
|
+
RELOAD_MUTEX.synchronize do
|
|
66
|
+
reload!
|
|
67
|
+
dispatch(env)
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
dispatch(env)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Extends the Rack middleware stack this Web app itself runs behind
|
|
75
|
+
# (session cookie, an auth middleware wrapping the whole app) —
|
|
76
|
+
# distinct from mounting external middleware *outside* this app via
|
|
77
|
+
# plain `Rack::Builder`/`map`, which also works and needs nothing from
|
|
78
|
+
# here.
|
|
79
|
+
def use(middleware, *args)
|
|
80
|
+
middlewares << [middleware, args]
|
|
81
|
+
@app = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def register(extension, name: extension.to_s, tab: nil, index: nil)
|
|
85
|
+
Cogworker::Web::Application.register(extension)
|
|
86
|
+
tabs[tab] = index if tab && index
|
|
87
|
+
name
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Public, mutable: `Cogworker::Web.tabs['History'] = 'history'` is how
|
|
91
|
+
# a registered extension actually gets a nav entry.
|
|
92
|
+
def tabs
|
|
93
|
+
@tabs ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def session_secret
|
|
97
|
+
@session_secret ||= SecureRandom.hex(32)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# How every `Layout.time_tag` timestamp is rendered — a Ruby
|
|
101
|
+
# `Time#strftime` pattern, reused as-is client-side (the same tokens:
|
|
102
|
+
# %Y %m %d %H %M %S %B %b %A %a %p), just evaluated against the
|
|
103
|
+
# browser's local time instead of the server's UTC fallback. Set this
|
|
104
|
+
# from your own init file/config.ru, e.g.
|
|
105
|
+
# `Cogworker::Web.time_format = '%d.%m.%Y %H:%M'`.
|
|
106
|
+
def time_format
|
|
107
|
+
@time_format ||= DEFAULT_TIME_FORMAT
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
attr_writer :time_format, :history_per_page, :live_update_interval, :prometheus_exporter_enabled
|
|
111
|
+
|
|
112
|
+
# Whether `GET /metrics` (`Cogworker::Prometheus::Exporter`, mounted
|
|
113
|
+
# automatically by `load_routes!` below like any other built-in) is
|
|
114
|
+
# actually served — checked at request time by the exporter's own
|
|
115
|
+
# route, not here, so this can be set any time before a request comes
|
|
116
|
+
# in, same as `time_format`/`live_update_interval`. Set this to
|
|
117
|
+
# `false` from your own init file/config.ru to opt back out, e.g. if
|
|
118
|
+
# you'd rather scrape metrics through a separate, unauthenticated
|
|
119
|
+
# mount and don't want `/metrics` reachable behind this one at all.
|
|
120
|
+
def prometheus_exporter_enabled
|
|
121
|
+
return true unless defined?(@prometheus_exporter_enabled)
|
|
122
|
+
|
|
123
|
+
@prometheus_exporter_enabled
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Rows per page on the History tab. Retention depth (how many entries
|
|
127
|
+
# exist to page through at all) is a separate, gem-wide setting:
|
|
128
|
+
# `Cogworker::History.max_entries`.
|
|
129
|
+
def history_per_page
|
|
130
|
+
@history_per_page ||= DEFAULT_HISTORY_PER_PAGE
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# How often (in seconds) every auto-refreshing tab polls while the
|
|
134
|
+
# global live-update toggle is on — both the htmx-polled Busy/Stats/
|
|
135
|
+
# Queues tabs (`Layout.poll_div`'s `hx-trigger="every Ns [...]"`) and
|
|
136
|
+
# History's own AG Grid `refreshRows()` JS poll share this one value,
|
|
137
|
+
# so there's a single knob rather than one per tab. Set this from your
|
|
138
|
+
# own init file/config.ru, e.g. `Cogworker::Web.live_update_interval = 10`.
|
|
139
|
+
def live_update_interval
|
|
140
|
+
@live_update_interval ||= DEFAULT_LIVE_UPDATE_INTERVAL
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A minimal same-origin check (default-open for safe/read-only HTTP
|
|
144
|
+
# methods, otherwise requires `Sec-Fetch-Site: same-origin`).
|
|
145
|
+
# Reopen this method to carve out an exception for a specific
|
|
146
|
+
# cross-site callback path (e.g. an SSO provider's POST redirect).
|
|
147
|
+
def safe_request?(env)
|
|
148
|
+
return true if safe_method?(env['REQUEST_METHOD'])
|
|
149
|
+
|
|
150
|
+
env['HTTP_SEC_FETCH_SITE'] == 'same-origin'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def safe_method?(method)
|
|
154
|
+
%w[GET HEAD OPTIONS].include?(method)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def reloading?
|
|
158
|
+
Cogworker::LOADER.reloading_enabled?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Only ever called from `.call` above (always already holding
|
|
162
|
+
# `RELOAD_MUTEX` — see there for why), i.e. only while handling a Web
|
|
163
|
+
# UI HTTP request — never from a job-processing thread. Note this
|
|
164
|
+
# reloads Zeitwerk's *entire* managed tree (there's one loader for the
|
|
165
|
+
# whole gem), not just lib/cogworker/web/**: editing an engine file
|
|
166
|
+
# (Processor, Config, ...) while a dev Web UI process is up will also
|
|
167
|
+
# pick up those changes on the next request. That's harmless for a
|
|
168
|
+
# process that only ever serves HTTP and never runs `Launcher`, but is
|
|
169
|
+
# exactly why this must never be wired into `exe/cogworker`.
|
|
170
|
+
#
|
|
171
|
+
# `Cogworker::Web` itself is *not* meaningfully reloadable: Rack
|
|
172
|
+
# captures a single reference to this class object once, at `run
|
|
173
|
+
# Cogworker::Web` boot time, and calls that same object's `.call`
|
|
174
|
+
# forever — editing this file's own methods (`call`, `build_app`, ...)
|
|
175
|
+
# has no effect without a real process restart. What *does* refresh
|
|
176
|
+
# every request is everything this class only ever reaches through a
|
|
177
|
+
# fresh, fully-qualified lookup: `@app` is rebuilt every time (so it
|
|
178
|
+
# re-resolves `Cogworker::Web::Application`, not a memoized stale
|
|
179
|
+
# reference to whatever object used to be there), and that in turn
|
|
180
|
+
# re-executes `Routes::*`/`Layout`/`Router`/`Action` fresh, so their
|
|
181
|
+
# bare cross-references to each other stay internally consistent.
|
|
182
|
+
def reload!
|
|
183
|
+
@tabs = {}
|
|
184
|
+
@app = nil
|
|
185
|
+
Cogworker::LOADER.reload
|
|
186
|
+
load_routes!
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Fully-qualified references throughout, not bare `Application`/
|
|
190
|
+
# `Routes`: right after `Zeitwerk::Loader#reload`, a bare constant
|
|
191
|
+
# lookup relying on lexical nesting can fail to find a freshly
|
|
192
|
+
# re-armed autoload even though the constant is genuinely there —
|
|
193
|
+
# `Cogworker::Web::Routes` resolves correctly where a bare `Routes`
|
|
194
|
+
# (searched via Module.nesting from inside this method) sometimes
|
|
195
|
+
# raises `NameError` immediately after a reload. Fully-qualifying
|
|
196
|
+
# sidesteps it entirely.
|
|
197
|
+
def load_routes!
|
|
198
|
+
BUILT_IN_ROUTE_NAMES.each { |name| Cogworker::Web::Routes.const_get(name) }
|
|
199
|
+
# Different namespace (Cogworker::Prometheus::Exporter, not
|
|
200
|
+
# Cogworker::Web::Routes::*) so it can't join BUILT_IN_ROUTE_NAMES
|
|
201
|
+
# above, but touching the constant is the same Zeitwerk-autoload
|
|
202
|
+
# trick either way: it runs that file's own bottom-of-file
|
|
203
|
+
# `Cogworker::Web.register(...)` exactly like a route file would.
|
|
204
|
+
# Previously this constant was only ever touched by an app's own
|
|
205
|
+
# init code remembering to reference it — forgetting to do so left
|
|
206
|
+
# `/metrics` returning a plain 404 with no indication why the route
|
|
207
|
+
# was missing.
|
|
208
|
+
# Mounting it always and gating on `prometheus_exporter_enabled`
|
|
209
|
+
# instead (checked inside the route itself, not here) fixes that
|
|
210
|
+
# while keeping the opt-out.
|
|
211
|
+
Cogworker::Prometheus::Exporter
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def dispatch(env)
|
|
217
|
+
return forbidden(env) unless safe_request?(env)
|
|
218
|
+
|
|
219
|
+
app.call(env)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def forbidden(_env)
|
|
223
|
+
[403, { 'content-type' => 'text/plain' }, ['Forbidden']]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def middlewares
|
|
227
|
+
@middlewares ||= []
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def app
|
|
231
|
+
@app ||= build_app
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def build_app
|
|
235
|
+
builder = Rack::Builder.new
|
|
236
|
+
builder.use(Rack::Session::Cookie, secret: session_secret, key: 'cogworker.session')
|
|
237
|
+
# No `cache_control:` (no `immutable`/long `max-age`): these files
|
|
238
|
+
# are plain, unfingerprinted paths that *do* change — every time
|
|
239
|
+
# this gem's Tailwind bundle gets rebuilt, or on any gem upgrade —
|
|
240
|
+
# and `immutable` previously told browsers to keep serving a stale
|
|
241
|
+
# cached copy under the old URL for up to a year with no
|
|
242
|
+
# revalidation at all (caught by hand: a real edit to tailwind.css
|
|
243
|
+
# didn't show up in an already-open tab until a hard reload).
|
|
244
|
+
# `Rack::Static`/`Rack::Files` already sends `Last-Modified` and
|
|
245
|
+
# honors conditional GETs by default, which is all that's needed
|
|
246
|
+
# here — a browser still avoids a full re-download when nothing
|
|
247
|
+
# changed, but always gets fresh content the moment something did.
|
|
248
|
+
builder.use(Rack::Static, urls: ['/assets'], root: ASSETS_ROOT)
|
|
249
|
+
middlewares.each { |mw, args| builder.use(mw, *args) }
|
|
250
|
+
builder.run(Cogworker::Web::Application)
|
|
251
|
+
builder.to_app
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
Cogworker::Web.load_routes!
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# One in-flight job on one process/thread.
|
|
5
|
+
class Work
|
|
6
|
+
attr_reader :process_id, :thread_id
|
|
7
|
+
|
|
8
|
+
def initialize(process_id, thread_id, hash)
|
|
9
|
+
@process_id = process_id
|
|
10
|
+
@thread_id = thread_id
|
|
11
|
+
@hash = hash
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def queue = @hash['queue']
|
|
15
|
+
def run_at = @hash['run_at']
|
|
16
|
+
# already a Hash: nested by the outer JSON.parse in WorkSet#each
|
|
17
|
+
def job = @hash['payload']
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class WorkSet
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def each
|
|
10
|
+
ProcessSet.new.each do |process|
|
|
11
|
+
identity = process['identity']
|
|
12
|
+
workers = Cogworker.config.redis { |c| c.hgetall(RedisKeys.workers(identity)) }
|
|
13
|
+
workers.each do |tid, raw|
|
|
14
|
+
yield identity, tid, Work.new(identity, tid, JSON.parse(raw))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def size
|
|
20
|
+
count { true }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|