cogworker 0.1.0 → 0.2.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/lib/cogworker/attempts.rb +47 -0
- data/lib/cogworker/basic_fetch.rb +25 -2
- data/lib/cogworker/heartbeat.rb +4 -2
- data/lib/cogworker/manager.rb +4 -0
- data/lib/cogworker/periodic/ticker.rb +16 -1
- data/lib/cogworker/process.rb +13 -5
- data/lib/cogworker/processor.rb +4 -0
- data/lib/cogworker/queue.rb +17 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/throughput.rb +51 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
- data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
- data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
- data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
- data/lib/cogworker/web/layout.rb +232 -141
- data/lib/cogworker/web/routes/history.rb +86 -32
- data/lib/cogworker/web/routes/jobs.rb +469 -0
- data/lib/cogworker/web/routes/overview.rb +746 -0
- data/lib/cogworker/web/routes/schedules.rb +197 -0
- data/lib/cogworker/web/routes/workers.rb +227 -0
- data/lib/cogworker/web.rb +4 -3
- metadata +11 -9
- data/lib/cogworker/web/assets/tailwind.css +0 -1
- data/lib/cogworker/web/routes/busy.rb +0 -99
- data/lib/cogworker/web/routes/dead.rb +0 -93
- data/lib/cogworker/web/routes/periodic.rb +0 -67
- data/lib/cogworker/web/routes/queues.rb +0 -101
- data/lib/cogworker/web/routes/retries.rb +0 -89
- data/lib/cogworker/web/routes/scheduled.rb +0 -49
- data/lib/cogworker/web/routes/stats.rb +0 -298
|
@@ -1,298 +0,0 @@
|
|
|
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)
|