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
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
class Web
|
|
8
|
+
module Routes
|
|
9
|
+
# Landing tab: the 5 headline counters, a latency-by-queue glance, and
|
|
10
|
+
# the queue list — plus, in "queue first" layout, a per-queue detail
|
|
11
|
+
# pane (own counters + its pending jobs, with delete/delete-all)
|
|
12
|
+
# replacing what used to be a separate `/queues/:name` page. Absorbs
|
|
13
|
+
# the old, standalone `Routes::Queues` tab entirely (see CLAUDE.md's
|
|
14
|
+
# nocturne migration notes) rather than sitting alongside it.
|
|
15
|
+
#
|
|
16
|
+
# `?layout=a` (default, "Metrics first": counters + latency bars +
|
|
17
|
+
# queue table) vs `?layout=b` ("Queue first": a queue sidebar plus the
|
|
18
|
+
# selected queue's own detail) is a plain query param read fresh on
|
|
19
|
+
# every request — same full-page-link pattern `Routes::History`'s
|
|
20
|
+
# status filter already uses, not client-side state. The `.seg`
|
|
21
|
+
# segmented control still renders as a real radio pair (so it gets
|
|
22
|
+
# nocturne's own `:has(input:checked)` styling for free) with a
|
|
23
|
+
# one-line `onchange` navigation, since there's no SPA state to flip
|
|
24
|
+
# instead. Also mounted at bare `/` (the app's landing page) — the
|
|
25
|
+
# old `Routes::Stats` tab used to own that alias; absorbed here along
|
|
26
|
+
# with its "Runs per day" chart and Redis info grid when that tab was
|
|
27
|
+
# retired (its own 6 job-counter cards weren't carried over — this
|
|
28
|
+
# tab already has its own 5-card grid).
|
|
29
|
+
module Overview
|
|
30
|
+
CONTENT_ID = 'overview-content'
|
|
31
|
+
# The Redis info grid lives at the very bottom of the page, below
|
|
32
|
+
# both charts — visually separate from `CONTENT_ID`'s own poll_div,
|
|
33
|
+
# so it needs its own dedicated self-polling fragment rather than
|
|
34
|
+
# being one of the pieces `render_content`/`CONTENT_ID` refreshes
|
|
35
|
+
# together.
|
|
36
|
+
REDIS_CONTENT_ID = 'overview-redis-content'
|
|
37
|
+
# Vendored under assets/ (see CLAUDE.md's "Fully offline" section —
|
|
38
|
+
# served locally, not fetched from a CDN) — shared by both charts
|
|
39
|
+
# below (Chart.js supports multiple independent instances off one
|
|
40
|
+
# loaded script).
|
|
41
|
+
CHART_ASSET = 'assets/chart.umd.min.js'
|
|
42
|
+
CHART_CANVAS_ID = 'overview-throughput-canvas'
|
|
43
|
+
CHART_CONTAINER_HEIGHT_PX = 170
|
|
44
|
+
RUNS_CHART_CANVAS_ID = 'overview-runs-canvas'
|
|
45
|
+
RUNS_CHART_CONTAINER_HEIGHT_PX = 220
|
|
46
|
+
# `INFO` field name => card label, for the Redis section. Pulled
|
|
47
|
+
# from the flat Hash `Cogworker::Stats#redis_info` returns (same
|
|
48
|
+
# field names the `redis` gem always uses, regardless of Redis
|
|
49
|
+
# version) — a field missing from a given server/deployment renders
|
|
50
|
+
# as "n/a" rather than raising.
|
|
51
|
+
REDIS_INFO_FIELDS = {
|
|
52
|
+
'redis_version' => 'Version', 'uptime_in_days' => 'Uptime (days)',
|
|
53
|
+
'connected_clients' => 'Connections', 'used_memory_human' => 'Memory Usage',
|
|
54
|
+
'used_memory_peak_human' => 'Peak Memory Usage'
|
|
55
|
+
}.freeze
|
|
56
|
+
# Period switcher options for the "Runs per day" chart —
|
|
57
|
+
# `params['period']` (a plain query string, read fresh on every
|
|
58
|
+
# request) selects one of these by key. Ordered as displayed,
|
|
59
|
+
# shortest first.
|
|
60
|
+
PERIODS = {
|
|
61
|
+
'week' => { 'label' => 'Week', 'days' => 7 },
|
|
62
|
+
'month' => { 'label' => 'Month', 'days' => 30 },
|
|
63
|
+
'3months' => { 'label' => '3 Months', 'days' => 90 },
|
|
64
|
+
'6months' => { 'label' => '6 Months', 'days' => 182 }
|
|
65
|
+
}.freeze
|
|
66
|
+
DEFAULT_PERIOD = 'month'
|
|
67
|
+
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
def registered(app)
|
|
71
|
+
renderer = lambda do
|
|
72
|
+
layout = params['layout'] == 'b' ? 'b' : 'a'
|
|
73
|
+
period = Overview.resolve_period(params['period'])
|
|
74
|
+
content = Overview.render_content(request.script_name, params)
|
|
75
|
+
if hx_request?
|
|
76
|
+
content
|
|
77
|
+
else
|
|
78
|
+
poll_wrapped = Layout.poll_div(CONTENT_ID, request.script_name,
|
|
79
|
+
"overview#{Overview.query_string(params)}", content)
|
|
80
|
+
# Both charts live *outside* this poll_div on purpose: an
|
|
81
|
+
# htmx innerHTML swap destroys a `<canvas>` DOM node outright,
|
|
82
|
+
# and Chart.js doesn't notice its own instance is now pointed
|
|
83
|
+
# at an orphaned element, so it never frees it — one more
|
|
84
|
+
# leaked instance every tick, a real bug once (see CLAUDE.md).
|
|
85
|
+
# Rendered once per full page load here, layout A only
|
|
86
|
+
# (layout B has neither), and kept live by their own
|
|
87
|
+
# independent `fetch` polls instead (`throughput_section`/
|
|
88
|
+
# `runs_section` below).
|
|
89
|
+
page_body = poll_wrapped
|
|
90
|
+
if layout == 'a'
|
|
91
|
+
page_body += Overview.throughput_section(request.script_name)
|
|
92
|
+
page_body += Overview.runs_section(period, request.script_name)
|
|
93
|
+
# The very last thing on the page — its own dedicated
|
|
94
|
+
# self-polling fragment (see REDIS_CONTENT_ID above), not
|
|
95
|
+
# part of CONTENT_ID's own poll_div.
|
|
96
|
+
page_body += Layout.poll_div(REDIS_CONTENT_ID, request.script_name, 'overview/redis',
|
|
97
|
+
Overview.redis_section)
|
|
98
|
+
end
|
|
99
|
+
extra_head = layout == 'a' ? Overview.chart_head(request.script_name) : ''
|
|
100
|
+
Layout.wrap('Overview', page_body, script_name: request.script_name, extra_head: extra_head)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
app.get('/', &renderer)
|
|
105
|
+
app.get('/overview', &renderer)
|
|
106
|
+
|
|
107
|
+
# The Redis info grid's own self-polling fragment route (see
|
|
108
|
+
# REDIS_CONTENT_ID above) — its own dedicated route, always just
|
|
109
|
+
# the fragment, same pattern as `/workers/summary` below.
|
|
110
|
+
app.get('/overview/redis') { Overview.redis_section }
|
|
111
|
+
|
|
112
|
+
# Polled directly by the chart's own inline script
|
|
113
|
+
# (`throughput_section`), not an htmx target — returning just the
|
|
114
|
+
# plotted numbers lets it patch the *existing* Chart.js instance
|
|
115
|
+
# in place (`chart.update()`) instead of tearing down and
|
|
116
|
+
# recreating the `<canvas>`, same idea as `Routes::History`'s
|
|
117
|
+
# `/history/data` for its AG Grid.
|
|
118
|
+
app.get('/overview/throughput_data') do
|
|
119
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate(Overview.throughput_payload)]]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
app.get('/overview/runs_data') do
|
|
123
|
+
period = Overview.resolve_period(params['period'])
|
|
124
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate(Overview.runs_per_day_payload(period))]]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `raw` is the exact JSON string the job entry was pushed with —
|
|
128
|
+
# same "raw" identity `Routes::Dead`/`Routes::Retries` already key
|
|
129
|
+
# their own per-row delete off — so `Queue#delete` can `LREM` it
|
|
130
|
+
# back out of the list.
|
|
131
|
+
app.post('/overview/:name/delete') do
|
|
132
|
+
name = url_params('name')
|
|
133
|
+
Cogworker::Queue.new(name).delete(params['raw'])
|
|
134
|
+
Overview.respond(self, name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
app.post('/overview/:name/delete_all') do
|
|
138
|
+
name = url_params('name')
|
|
139
|
+
Cogworker::Queue.new(name).clear
|
|
140
|
+
Overview.respond(self, name)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
app.post('/overview/:name/pause') do
|
|
144
|
+
name = url_params('name')
|
|
145
|
+
Cogworker::Queue.new(name).pause!
|
|
146
|
+
Overview.respond(self, name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
app.post('/overview/:name/resume') do
|
|
150
|
+
name = url_params('name')
|
|
151
|
+
Cogworker::Queue.new(name).resume!
|
|
152
|
+
Overview.respond(self, name)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Same "graduate back onto its queue" move `Routes::Jobs`' own
|
|
156
|
+
# per-entry retry_now makes, just applied to every `cogworker:
|
|
157
|
+
# retry` entry whose `queue` matches this one, one at a time (so
|
|
158
|
+
# the usual `zrem`-wins-or-skip concurrency guard still protects
|
|
159
|
+
# each entry individually against a second click/tab).
|
|
160
|
+
app.post('/overview/:name/retry_all') do
|
|
161
|
+
name = url_params('name')
|
|
162
|
+
Overview.retry_all(name)
|
|
163
|
+
Overview.respond(self, name)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def retry_all(name)
|
|
168
|
+
entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1) }
|
|
169
|
+
entries.each do |raw|
|
|
170
|
+
job = JSON.parse(raw)
|
|
171
|
+
next unless job['queue'] == name
|
|
172
|
+
|
|
173
|
+
Cogworker.config.redis do |c|
|
|
174
|
+
if c.zrem(RedisKeys::RETRY, raw)
|
|
175
|
+
c.sadd(RedisKeys::QUEUES, job['queue'])
|
|
176
|
+
c.lpush(RedisKeys.queue(job['queue']), raw)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# After an action, htmx gets the refreshed fragment swapped into
|
|
183
|
+
# #overview-content in place; a plain form submission (no JS) falls
|
|
184
|
+
# back to a normal redirect to that same URL. `action.params
|
|
185
|
+
# ['layout']` — a hidden field every action's own form carries (see
|
|
186
|
+
# `queue_action_button`) — decides whether the response stays on
|
|
187
|
+
# layout A (the queue table a Pause button there was clicked from)
|
|
188
|
+
# or layout B with this queue selected (delete/pause/retry-all
|
|
189
|
+
# triggered from the queue detail pane); it defaults to 'b' since
|
|
190
|
+
# that's the only layout `delete`/`delete_all` have ever been
|
|
191
|
+
# reachable from.
|
|
192
|
+
def respond(action, name)
|
|
193
|
+
layout = action.params['layout'] == 'a' ? 'a' : 'b'
|
|
194
|
+
target = layout == 'a' ? { 'layout' => 'a' } : { 'layout' => 'b', 'queue' => name }
|
|
195
|
+
if action.hx_request?
|
|
196
|
+
render_content(action.request.script_name, target)
|
|
197
|
+
else
|
|
198
|
+
action.redirect(Layout.path(action.request.script_name, "overview#{query_string(target)}"))
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def query_string(params)
|
|
203
|
+
layout = params['layout'] == 'b' ? 'b' : 'a'
|
|
204
|
+
qs = "?layout=#{layout}"
|
|
205
|
+
qs += "&queue=#{CGI.escape(params['queue'])}" if layout == 'b' && params['queue']
|
|
206
|
+
qs
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_content(script_name, params)
|
|
210
|
+
layout = params['layout'] == 'b' ? 'b' : 'a'
|
|
211
|
+
body = layout == 'b' ? render_layout_b(script_name, params['queue']) : render_layout_a(script_name)
|
|
212
|
+
<<~HTML
|
|
213
|
+
<div style="display: flex; flex-direction: column; gap: 20px;">
|
|
214
|
+
#{page_header(script_name, layout)}
|
|
215
|
+
#{body}
|
|
216
|
+
</div>
|
|
217
|
+
HTML
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def page_header(script_name, layout)
|
|
221
|
+
a_href = Layout.path(script_name, 'overview?layout=a')
|
|
222
|
+
b_href = Layout.path(script_name, 'overview?layout=b')
|
|
223
|
+
<<~HTML
|
|
224
|
+
<div style="display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
|
|
225
|
+
<h2 style="margin: 0;">Overview</h2>
|
|
226
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
227
|
+
<span style="font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-500);">Layout</span>
|
|
228
|
+
<div class="seg">
|
|
229
|
+
<label class="seg-opt"><input type="radio" name="layout" #{'checked' if layout == 'a'} onchange="location.href='#{a_href}'">A · Metrics first</label>
|
|
230
|
+
<label class="seg-opt"><input type="radio" name="layout" #{'checked' if layout == 'b'} onchange="location.href='#{b_href}'">B · Queue first</label>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
HTML
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def render_layout_a(script_name)
|
|
238
|
+
queues = queue_names.map { |n| Cogworker::Queue.new(n) }
|
|
239
|
+
stat_cards + queue_table(queues, script_name)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def stat_cards
|
|
243
|
+
stats = Cogworker::Stats.new
|
|
244
|
+
values = [
|
|
245
|
+
['Processed', stats.processed, 'var(--color-success)'],
|
|
246
|
+
['Enqueued', stats.enqueued, 'var(--color-accent)'],
|
|
247
|
+
['Retrying', stats.retry_size, 'var(--color-warning)'],
|
|
248
|
+
['Failed', stats.failed, 'var(--color-danger)'],
|
|
249
|
+
['Dead', stats.dead_size, 'var(--color-neutral-300)']
|
|
250
|
+
]
|
|
251
|
+
cards = values.map do |label, value, color|
|
|
252
|
+
<<~HTML
|
|
253
|
+
<div class="card elev-sm" style="gap: 4px;">
|
|
254
|
+
<span class="card-kicker">#{Layout.h(label)}</span>
|
|
255
|
+
<span style="font-size: 28px; font-family: var(--font-heading); line-height: 1.1; color: #{color};">#{Layout.h(value)}</span>
|
|
256
|
+
</div>
|
|
257
|
+
HTML
|
|
258
|
+
end.join
|
|
259
|
+
%(<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px;">#{cards}</div>)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Rendered at the very bottom of the page (layout A only), below
|
|
263
|
+
# both charts, in its own self-polling fragment (REDIS_CONTENT_ID
|
|
264
|
+
# above) rather than as part of CONTENT_ID's own poll_div — plain
|
|
265
|
+
# text with no chart, so unlike the two chart sections it *could*
|
|
266
|
+
# safely sit inside CONTENT_ID's poll_div, it just doesn't, to keep
|
|
267
|
+
# it pinned below the charts rather than above them.
|
|
268
|
+
def redis_section
|
|
269
|
+
info = Cogworker::Stats.new.redis_info
|
|
270
|
+
cards = REDIS_INFO_FIELDS.map do |field, label|
|
|
271
|
+
<<~HTML
|
|
272
|
+
<div class="card elev-sm" style="gap: 4px;">
|
|
273
|
+
<span class="card-kicker">#{Layout.h(label)}</span>
|
|
274
|
+
<span style="font-size: 22px; font-family: var(--font-heading);">#{Layout.h(info[field] || 'n/a')}</span>
|
|
275
|
+
</div>
|
|
276
|
+
HTML
|
|
277
|
+
end.join
|
|
278
|
+
<<~HTML
|
|
279
|
+
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px;">
|
|
280
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">Redis</h4>
|
|
281
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px;">#{cards}</div>
|
|
282
|
+
</section>
|
|
283
|
+
HTML
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def chart_head(script_name)
|
|
287
|
+
%(<script src="#{Layout.path(script_name, CHART_ASSET)}"></script>)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def resolve_period(raw)
|
|
291
|
+
PERIODS.key?(raw) ? raw : DEFAULT_PERIOD
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# `days_count` consecutive UTC calendar-day strings ending today —
|
|
295
|
+
# matches the UTC bucketing `Cogworker::History::Storage.
|
|
296
|
+
# daily_counts` uses, so lookups by day string always line up.
|
|
297
|
+
def day_labels(days_count)
|
|
298
|
+
now = Time.now.utc
|
|
299
|
+
(days_count - 1).downto(0).map { |offset| (now - (offset * 86_400)).strftime('%Y-%m-%d') }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Success/failed counts for `period`, keyed exactly as Chart.js
|
|
303
|
+
# wants them — `labels`/`fullDates` line up index-for-index with
|
|
304
|
+
# `success`/`failed`. Shared between the initial render
|
|
305
|
+
# (`runs_section`, baked into the page) and `/overview/runs_data`
|
|
306
|
+
# (what the chart's own poll re-fetches from then on), so the two
|
|
307
|
+
# can never drift apart.
|
|
308
|
+
def runs_per_day_payload(period)
|
|
309
|
+
days_count = PERIODS.fetch(period, PERIODS[DEFAULT_PERIOD])['days']
|
|
310
|
+
counts = Cogworker::History::Storage.daily_counts(days_count)
|
|
311
|
+
days = day_labels(days_count)
|
|
312
|
+
{
|
|
313
|
+
'labels' => days.map { |d| d[5..] },
|
|
314
|
+
'fullDates' => days,
|
|
315
|
+
'success' => days.map { |d| (counts[d] || {})['success'] || 0 },
|
|
316
|
+
'failed' => days.map { |d| (counts[d] || {})['failed'] || 0 }
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def period_switcher(current_period)
|
|
321
|
+
links = PERIODS.map do |key, opts|
|
|
322
|
+
period_link(key, opts['label'], active: key == current_period)
|
|
323
|
+
end.join
|
|
324
|
+
%(<div class="seg">#{links}</div>)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Unlike every other filter in this app (History's status filter,
|
|
328
|
+
# Jobs' status/search, Overview's own A/B layout switch), this one
|
|
329
|
+
# deliberately does *not* navigate (`location.href=`) — switching
|
|
330
|
+
# periods only needs to change the Runs-per-day chart's own data,
|
|
331
|
+
# and a full page reload would tear down and rebuild the
|
|
332
|
+
# Throughput chart's `<canvas>`/Chart.js instance right along with
|
|
333
|
+
# it for no reason (the exact zombie-instance hazard the "fully
|
|
334
|
+
# outside any poll_div" comment on `runs_section`/`throughput_
|
|
335
|
+
# section` already guards against for htmx swaps — a full
|
|
336
|
+
# navigation is just as destructive). `window.
|
|
337
|
+
# cogworkerChangeRunsPeriod` (defined in `runs_section` below)
|
|
338
|
+
# fetches the new period's data and patches the *existing* chart
|
|
339
|
+
# instance in place instead.
|
|
340
|
+
def period_link(key, label, active:)
|
|
341
|
+
%(<label class="seg-opt"><input type="radio" name="period" #{'checked' if active} ) +
|
|
342
|
+
%(onchange="window.cogworkerChangeRunsPeriod('#{key}')">#{Layout.h(label)}</label>)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A small two-line chart (success/failed per day) rendered by
|
|
346
|
+
# Chart.js (vendored, loaded via `chart_head` — see CLAUDE.md's
|
|
347
|
+
# "Fully offline" section, not fetched from a CDN). Deliberately
|
|
348
|
+
# outside any `Layout.poll_div` (see `registered` above for why):
|
|
349
|
+
# the `<canvas>`/`new Chart(...)` render exactly once per page
|
|
350
|
+
# load, and `refreshRuns` keeps it live afterwards by patching the
|
|
351
|
+
# *existing* instance's data in place, gated by the same `window.
|
|
352
|
+
# cogworkerLiveUpdate` toggle every other tab's poll respects.
|
|
353
|
+
def runs_section(period, script_name)
|
|
354
|
+
payload = runs_per_day_payload(period)
|
|
355
|
+
data_base_url = Layout.path(script_name, 'overview/runs_data')
|
|
356
|
+
<<~HTML
|
|
357
|
+
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px;">
|
|
358
|
+
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap;">
|
|
359
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">Runs per day</h4>
|
|
360
|
+
#{period_switcher(period)}
|
|
361
|
+
</div>
|
|
362
|
+
<div style="position: relative; height: #{RUNS_CHART_CONTAINER_HEIGHT_PX}px; width: 100%;">
|
|
363
|
+
<canvas id="#{RUNS_CHART_CANVAS_ID}"></canvas>
|
|
364
|
+
</div>
|
|
365
|
+
<script>
|
|
366
|
+
(function () {
|
|
367
|
+
var dataBaseUrl = #{Layout.json_for_script(data_base_url)};
|
|
368
|
+
var currentPeriod = #{Layout.json_for_script(period)};
|
|
369
|
+
var fullDates = #{Layout.json_for_script(payload['fullDates'])};
|
|
370
|
+
var cssVar = function (name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); };
|
|
371
|
+
var successColor = cssVar('--color-success');
|
|
372
|
+
var dangerColor = cssVar('--color-danger');
|
|
373
|
+
var tickColor = cssVar('--color-neutral-500');
|
|
374
|
+
var gridColor = cssVar('--color-divider');
|
|
375
|
+
var chart = new Chart(document.getElementById(#{Layout.json_for_script(RUNS_CHART_CANVAS_ID)}), {
|
|
376
|
+
type: 'line',
|
|
377
|
+
data: {
|
|
378
|
+
labels: #{Layout.json_for_script(payload['labels'])},
|
|
379
|
+
datasets: [
|
|
380
|
+
{ label: 'Success', data: #{Layout.json_for_script(payload['success'])},
|
|
381
|
+
borderColor: successColor, backgroundColor: successColor,
|
|
382
|
+
tension: 0.3, pointRadius: 2, borderWidth: 2 },
|
|
383
|
+
{ label: 'Failed', data: #{Layout.json_for_script(payload['failed'])},
|
|
384
|
+
borderColor: dangerColor, backgroundColor: dangerColor,
|
|
385
|
+
tension: 0.3, pointRadius: 2, borderWidth: 2 }
|
|
386
|
+
]
|
|
387
|
+
},
|
|
388
|
+
options: {
|
|
389
|
+
responsive: true,
|
|
390
|
+
maintainAspectRatio: false,
|
|
391
|
+
interaction: { mode: 'index', intersect: false },
|
|
392
|
+
scales: {
|
|
393
|
+
x: { ticks: { color: tickColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { display: false } },
|
|
394
|
+
y: { beginAtZero: true, ticks: { color: tickColor, precision: 0 }, grid: { color: gridColor } }
|
|
395
|
+
},
|
|
396
|
+
plugins: {
|
|
397
|
+
legend: { labels: { color: tickColor } },
|
|
398
|
+
tooltip: { callbacks: { title: function (items) { return fullDates[items[0].dataIndex]; } } }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
function applyPeriodData(data) {
|
|
404
|
+
fullDates = data.fullDates;
|
|
405
|
+
chart.data.labels = data.labels;
|
|
406
|
+
chart.data.datasets[0].data = data.success;
|
|
407
|
+
chart.data.datasets[1].data = data.failed;
|
|
408
|
+
chart.update();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function fetchPeriod(period) {
|
|
412
|
+
return fetch(dataBaseUrl + '?period=' + encodeURIComponent(period), { headers: { 'Accept': 'application/json' } })
|
|
413
|
+
.then(function (r) { return r.ok ? r.json() : null; });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function refreshRuns() {
|
|
417
|
+
if (!window.cogworkerLiveUpdate) return;
|
|
418
|
+
fetchPeriod(currentPeriod).then(function (data) { if (data) applyPeriodData(data); }).catch(function () {});
|
|
419
|
+
}
|
|
420
|
+
setInterval(refreshRuns, #{Cogworker::Web.live_update_interval * 1000});
|
|
421
|
+
|
|
422
|
+
// Switching periods only needs this chart's own data —
|
|
423
|
+
// see `Routes::Overview#period_link`'s comment for why
|
|
424
|
+
// this patches the existing instance in place instead of
|
|
425
|
+
// navigating (which would also tear down and rebuild the
|
|
426
|
+
// unrelated Throughput chart). Also syncs the `period`
|
|
427
|
+
// query param via `replaceState` (no navigation/reload),
|
|
428
|
+
// so a manual page refresh or shared link still lands on
|
|
429
|
+
// the period last chosen here.
|
|
430
|
+
window.cogworkerChangeRunsPeriod = function (period) {
|
|
431
|
+
currentPeriod = period;
|
|
432
|
+
fetchPeriod(period).then(function (data) { if (data) applyPeriodData(data); }).catch(function () {});
|
|
433
|
+
try {
|
|
434
|
+
var url = new URL(window.location.href);
|
|
435
|
+
url.searchParams.set('period', period);
|
|
436
|
+
history.replaceState(null, '', url);
|
|
437
|
+
} catch (e) {}
|
|
438
|
+
};
|
|
439
|
+
})();
|
|
440
|
+
</script>
|
|
441
|
+
</section>
|
|
442
|
+
HTML
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Success/failed counts per hour, keyed exactly as Chart.js wants
|
|
446
|
+
# them — `labels`/`fullDates` line up index-for-index with
|
|
447
|
+
# `processed`/`failed`. Shared between the initial render
|
|
448
|
+
# (`throughput_section`, baked into the page) and `/overview/
|
|
449
|
+
# throughput_data` (what the chart's own poll re-fetches from then
|
|
450
|
+
# on), so the two can never drift apart — same pattern `Routes::
|
|
451
|
+
# Stats#chart_data_payload` already uses for its own chart.
|
|
452
|
+
def throughput_payload
|
|
453
|
+
series = Cogworker::Throughput.series
|
|
454
|
+
{
|
|
455
|
+
'labels' => series.map { |e| e['time'].strftime('%H:%M') },
|
|
456
|
+
'fullDates' => series.map { |e| e['time'].strftime('%Y-%m-%d %H:00 UTC') },
|
|
457
|
+
'processed' => series.map { |e| e['processed'] },
|
|
458
|
+
'failed' => series.map { |e| e['failed'] }
|
|
459
|
+
}
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Deliberately outside any `Layout.poll_div` — see the long comment
|
|
463
|
+
# in `registered` above for why. The `<canvas>`/`new Chart(...)`
|
|
464
|
+
# render exactly once per full page load; `refreshThroughput`
|
|
465
|
+
# keeps it live afterwards by patching the *existing* instance's
|
|
466
|
+
# data in place, gated by the same `window.cogworkerLiveUpdate`
|
|
467
|
+
# toggle every other tab's poll respects.
|
|
468
|
+
def throughput_section(script_name)
|
|
469
|
+
payload = throughput_payload
|
|
470
|
+
data_url = Layout.path(script_name, 'overview/throughput_data')
|
|
471
|
+
<<~HTML
|
|
472
|
+
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px;">
|
|
473
|
+
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px;">
|
|
474
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">Throughput</h4>
|
|
475
|
+
<span style="font-size: 12px; color: var(--color-neutral-500);">jobs per hour · 24h</span>
|
|
476
|
+
</div>
|
|
477
|
+
<div style="position: relative; height: #{CHART_CONTAINER_HEIGHT_PX}px; width: 100%;">
|
|
478
|
+
<canvas id="#{CHART_CANVAS_ID}"></canvas>
|
|
479
|
+
</div>
|
|
480
|
+
<script>
|
|
481
|
+
(function () {
|
|
482
|
+
var dataUrl = #{Layout.json_for_script(data_url)};
|
|
483
|
+
var fullDates = #{Layout.json_for_script(payload['fullDates'])};
|
|
484
|
+
// Reads the live nocturne tokens, not a hardcoded hex —
|
|
485
|
+
// theme-aware (dark/light), and stays in sync with a
|
|
486
|
+
// retuned ramp (same reasoning as `runs_section` below).
|
|
487
|
+
var cssVar = function (name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); };
|
|
488
|
+
var processedColor = cssVar('--color-accent');
|
|
489
|
+
var failedColor = cssVar('--color-danger');
|
|
490
|
+
var tickColor = cssVar('--color-neutral-500');
|
|
491
|
+
var gridColor = cssVar('--color-divider');
|
|
492
|
+
var chart = new Chart(document.getElementById(#{Layout.json_for_script(CHART_CANVAS_ID)}), {
|
|
493
|
+
type: 'line',
|
|
494
|
+
data: {
|
|
495
|
+
labels: #{Layout.json_for_script(payload['labels'])},
|
|
496
|
+
datasets: [
|
|
497
|
+
{ label: 'Processed', data: #{Layout.json_for_script(payload['processed'])},
|
|
498
|
+
borderColor: processedColor, backgroundColor: processedColor,
|
|
499
|
+
tension: 0.3, pointRadius: 0, borderWidth: 2 },
|
|
500
|
+
{ label: 'Failed', data: #{Layout.json_for_script(payload['failed'])},
|
|
501
|
+
borderColor: failedColor, backgroundColor: failedColor, borderDash: [4, 4],
|
|
502
|
+
tension: 0.3, pointRadius: 0, borderWidth: 1.5 }
|
|
503
|
+
]
|
|
504
|
+
},
|
|
505
|
+
options: {
|
|
506
|
+
responsive: true,
|
|
507
|
+
maintainAspectRatio: false,
|
|
508
|
+
interaction: { mode: 'index', intersect: false },
|
|
509
|
+
scales: {
|
|
510
|
+
x: { ticks: { color: tickColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { display: false } },
|
|
511
|
+
y: { beginAtZero: true, ticks: { color: tickColor, precision: 0 }, grid: { color: gridColor } }
|
|
512
|
+
},
|
|
513
|
+
plugins: {
|
|
514
|
+
legend: { labels: { color: tickColor } },
|
|
515
|
+
tooltip: { callbacks: { title: function (items) { return fullDates[items[0].dataIndex]; } } }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
function refreshThroughput() {
|
|
521
|
+
if (!window.cogworkerLiveUpdate) return;
|
|
522
|
+
fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
|
|
523
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
524
|
+
.then(function (data) {
|
|
525
|
+
if (!data) return;
|
|
526
|
+
fullDates = data.fullDates;
|
|
527
|
+
chart.data.labels = data.labels;
|
|
528
|
+
chart.data.datasets[0].data = data.processed;
|
|
529
|
+
chart.data.datasets[1].data = data.failed;
|
|
530
|
+
chart.update();
|
|
531
|
+
})
|
|
532
|
+
.catch(function () {});
|
|
533
|
+
}
|
|
534
|
+
setInterval(refreshThroughput, #{Cogworker::Web.live_update_interval * 1000});
|
|
535
|
+
})();
|
|
536
|
+
</script>
|
|
537
|
+
</section>
|
|
538
|
+
HTML
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The "Latency by queue" section used to sit above this table as its
|
|
542
|
+
# own card, but its bars and this table's own Latency column just
|
|
543
|
+
# showed the same number twice in a row — folded the bar into the
|
|
544
|
+
# column itself (`latency_cell` below) instead of dropping it.
|
|
545
|
+
def queue_table(queues, script_name)
|
|
546
|
+
max_latency = queue_max_latency(queues)
|
|
547
|
+
rows = queues.map do |q|
|
|
548
|
+
link = Layout.path(script_name, "overview?layout=b&queue=#{CGI.escape(q.name)}")
|
|
549
|
+
name_cell = %(<a href="#{link}" style="font-weight: var(--font-heading-weight);">#{Layout.h(q.name)}</a>)
|
|
550
|
+
name_cell += " #{Layout.badge('paused', variant: :warning)}" if q.paused?
|
|
551
|
+
[name_cell, q.size, latency_cell(q, max_latency), queue_pause_button(q, script_name, 'a')]
|
|
552
|
+
end
|
|
553
|
+
table = Layout.table(%w[Name Size Latency Actions], rows,
|
|
554
|
+
empty_message: 'No queues yet — push a job to create one.', wrapped: false)
|
|
555
|
+
<<~HTML
|
|
556
|
+
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px;">
|
|
557
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">Queues</h4>
|
|
558
|
+
#{table}
|
|
559
|
+
</section>
|
|
560
|
+
HTML
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def queue_max_latency(queues)
|
|
564
|
+
[queues.map(&:latency).max.to_f, 0.001].max
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def latency_cell(queue, max_latency)
|
|
568
|
+
pct = ((queue.latency / max_latency) * 100).round
|
|
569
|
+
<<~HTML
|
|
570
|
+
<div style="display: flex; flex-direction: column; gap: 4px; min-width: 90px;">
|
|
571
|
+
<span style="font-size: 13px;">#{format_latency(queue.latency)}</span>
|
|
572
|
+
<div style="height: 4px; border-radius: 2px; background: var(--color-neutral-800); overflow: hidden;">
|
|
573
|
+
<div style="height: 100%; border-radius: 2px; background: var(--color-accent); width: #{pct}%;"></div>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
HTML
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# A one-button `<form>` for a queue-scoped action (pause/resume/
|
|
580
|
+
# retry all) — like `Layout.action_button`, but carrying a hidden
|
|
581
|
+
# `layout` field too, so `respond` (above) knows whether to render
|
|
582
|
+
# the response back as layout A (the queue table this may have been
|
|
583
|
+
# clicked from) or layout B (the queue detail pane).
|
|
584
|
+
def queue_action_button(path, label, layout_context, variant:, icon: nil)
|
|
585
|
+
classes = "btn #{Layout::BUTTON_VARIANTS.fetch(variant)}"
|
|
586
|
+
<<~HTML
|
|
587
|
+
<form style="display: inline;" hx-post="#{path}" hx-target="##{CONTENT_ID}" hx-swap="innerHTML" method="post" action="#{path}">
|
|
588
|
+
<input type="hidden" name="layout" value="#{layout_context}">
|
|
589
|
+
<button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{Layout.icon_tag(icon)}#{Layout.h(label)}</button>
|
|
590
|
+
</form>
|
|
591
|
+
HTML
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def queue_pause_button(queue, script_name, layout_context)
|
|
595
|
+
if queue.paused?
|
|
596
|
+
action = Layout.path(script_name, "overview/#{CGI.escape(queue.name)}/resume")
|
|
597
|
+
queue_action_button(action, 'resume', layout_context, variant: :primary, icon: 'play')
|
|
598
|
+
else
|
|
599
|
+
action = Layout.path(script_name, "overview/#{CGI.escape(queue.name)}/pause")
|
|
600
|
+
queue_action_button(action, 'pause', layout_context, variant: :warning, icon: 'pause')
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Nothing to bulk-retry when nothing on this queue is retrying.
|
|
605
|
+
def retry_all_button(name, script_name, retrying_count)
|
|
606
|
+
return '' if retrying_count.zero?
|
|
607
|
+
|
|
608
|
+
action = Layout.path(script_name, "overview/#{CGI.escape(name)}/retry_all")
|
|
609
|
+
queue_action_button(action, 'retry all', 'b', variant: :primary, icon: 'arrow-clockwise')
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# `selected_name` wins even when it isn't (yet) in the registered
|
|
613
|
+
# `cogworker:queues` set — e.g. right after a job landed on a brand
|
|
614
|
+
# new queue name via a raw Redis write, before anything called
|
|
615
|
+
# `Client.push` for it — same independence the old standalone
|
|
616
|
+
# `/queues/:name` page had from that registry. Only an *absent*
|
|
617
|
+
# `selected_name` falls back to the registry, and only the true
|
|
618
|
+
# empty-registry case shows the empty state.
|
|
619
|
+
def render_layout_b(script_name, selected_name)
|
|
620
|
+
names = queue_names
|
|
621
|
+
selected_name = names.first if (selected_name.nil? || selected_name.empty?) && !names.empty?
|
|
622
|
+
return %(<p class="text-muted">No queues yet — push a job to create one.</p>) if selected_name.nil? || selected_name.empty?
|
|
623
|
+
|
|
624
|
+
<<~HTML
|
|
625
|
+
<div style="display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); gap: 16px; align-items: start;">
|
|
626
|
+
#{queue_sidebar(names, selected_name, script_name)}
|
|
627
|
+
#{queue_detail(selected_name, script_name)}
|
|
628
|
+
</div>
|
|
629
|
+
HTML
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def queue_sidebar(names, selected_name, script_name)
|
|
633
|
+
max_latency = names.map { |n| Cogworker::Queue.new(n).latency }.max || 0.001
|
|
634
|
+
max_latency = 0.001 if max_latency.zero?
|
|
635
|
+
items = names.map do |n|
|
|
636
|
+
q = Cogworker::Queue.new(n)
|
|
637
|
+
active = n == selected_name
|
|
638
|
+
href = Layout.path(script_name, "overview?layout=b&queue=#{CGI.escape(n)}")
|
|
639
|
+
bg = active ? 'color-mix(in srgb, var(--color-accent) 12%, var(--color-surface))' : 'var(--color-surface)'
|
|
640
|
+
border = active ? 'var(--color-accent)' : 'var(--color-divider)'
|
|
641
|
+
pct = ((q.latency / max_latency) * 100).round
|
|
642
|
+
<<~HTML
|
|
643
|
+
<a href="#{href}" style="text-align: left; padding: 11px 13px; border-radius: var(--radius-md); background: #{bg}; border: 1px solid #{border}; display: flex; flex-direction: column; gap: 6px; color: var(--color-text); text-decoration: none;">
|
|
644
|
+
<span style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
|
|
645
|
+
<span class="mono" style="font-size: 14px;">#{Layout.h(n)}</span>
|
|
646
|
+
#{q.paused? ? Layout.badge('paused', variant: :warning) : ''}
|
|
647
|
+
</span>
|
|
648
|
+
<span style="font-size: 12px; color: var(--color-neutral-400);">#{q.size} enqueued · #{format_latency(q.latency)} latency</span>
|
|
649
|
+
<span style="height: 3px; border-radius: 2px; background: var(--color-neutral-800); overflow: hidden; display: block;">
|
|
650
|
+
<span style="display: block; height: 100%; background: var(--color-accent); width: #{pct}%;"></span>
|
|
651
|
+
</span>
|
|
652
|
+
</a>
|
|
653
|
+
HTML
|
|
654
|
+
end.join
|
|
655
|
+
%(<aside style="display: flex; flex-direction: column; gap: 6px;">#{items}</aside>)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Running/Retrying/Dead are scoped to this one queue by scanning the
|
|
659
|
+
# (global, not per-queue) WorkSet/retry/dead collections and
|
|
660
|
+
# filtering on each entry's own `queue` field — same technique
|
|
661
|
+
# `Routes::History` already uses to bucket its own global ZSET by
|
|
662
|
+
# day. No "Concurrency" figure here (unlike the "Relay" concept
|
|
663
|
+
# mock): Cogworker has no per-queue concurrency limit, only the
|
|
664
|
+
# process-wide thread pool `Manager` draws from, so showing one
|
|
665
|
+
# would be fabricated.
|
|
666
|
+
def queue_detail(name, script_name)
|
|
667
|
+
q = Cogworker::Queue.new(name)
|
|
668
|
+
retrying_count = zset_count_for_queue(RedisKeys::RETRY, name)
|
|
669
|
+
counters = [
|
|
670
|
+
['Enqueued', q.size, 'var(--color-text)'],
|
|
671
|
+
['Running', running_count_for_queue(name), 'var(--color-accent)'],
|
|
672
|
+
['Retrying', retrying_count, 'var(--color-warning)'],
|
|
673
|
+
['Dead', zset_count_for_queue(RedisKeys::DEAD, name), 'var(--color-neutral-300)']
|
|
674
|
+
]
|
|
675
|
+
stat_html = counters.map do |label, value, color|
|
|
676
|
+
<<~HTML
|
|
677
|
+
<div style="display: flex; flex-direction: column; gap: 2px;">
|
|
678
|
+
<span class="card-kicker">#{Layout.h(label)}</span>
|
|
679
|
+
<span style="font-size: 22px; font-family: var(--font-heading); color: #{color};">#{Layout.h(value)}</span>
|
|
680
|
+
</div>
|
|
681
|
+
HTML
|
|
682
|
+
end.join
|
|
683
|
+
paused_tag = q.paused? ? " #{Layout.badge('paused', variant: :warning)}" : ''
|
|
684
|
+
header_actions = queue_pause_button(q, script_name, 'b') + retry_all_button(name, script_name,
|
|
685
|
+
retrying_count)
|
|
686
|
+
<<~HTML
|
|
687
|
+
<section style="display: flex; flex-direction: column; gap: 16px; min-width: 0;">
|
|
688
|
+
<div style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 18px;">
|
|
689
|
+
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
|
|
690
|
+
<h3 class="mono" style="margin: 0;">#{Layout.h(name)}#{paused_tag}</h3>
|
|
691
|
+
<div style="display: flex; gap: 8px;">#{header_actions}</div>
|
|
692
|
+
</div>
|
|
693
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 12px; margin-top: 12px;">
|
|
694
|
+
#{stat_html}
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
#{queue_jobs_section(name, script_name)}
|
|
698
|
+
</section>
|
|
699
|
+
HTML
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def queue_jobs_section(name, script_name)
|
|
703
|
+
delete_path = Layout.path(script_name, "overview/#{CGI.escape(name)}/delete")
|
|
704
|
+
rows = Cogworker::Queue.new(name).map do |r|
|
|
705
|
+
[r.jid, Layout.h(r.klass), Layout.h(r.args.to_s),
|
|
706
|
+
Layout.form_button(delete_path, 'raw', r.value, 'delete', hx_target: "##{CONTENT_ID}",
|
|
707
|
+
variant: :danger)]
|
|
708
|
+
end
|
|
709
|
+
delete_all_button(name, script_name, rows.empty?) +
|
|
710
|
+
Layout.table(%w[JID Class Args Delete], rows, empty_message: 'This queue is empty.')
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Nothing to bulk-delete once the queue is already empty.
|
|
714
|
+
def delete_all_button(name, script_name, empty)
|
|
715
|
+
return '' if empty
|
|
716
|
+
|
|
717
|
+
delete_all_path = Layout.path(script_name, "overview/#{CGI.escape(name)}/delete_all")
|
|
718
|
+
button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{CONTENT_ID}", variant: :danger)
|
|
719
|
+
%(<div style="margin-bottom: var(--space-3); display: flex; justify-content: flex-end;">#{button}</div>)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def running_count_for_queue(name)
|
|
723
|
+
Cogworker::WorkSet.new.count { |_identity, _tid, work| work.queue == name }
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def zset_count_for_queue(redis_key, name)
|
|
727
|
+
entries = Cogworker.config.redis { |c| c.zrange(redis_key, 0, -1) }
|
|
728
|
+
entries.count { |raw| JSON.parse(raw)['queue'] == name }
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def queue_names
|
|
732
|
+
Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def format_latency(seconds)
|
|
736
|
+
return "#{(seconds * 1000).round}ms" if seconds < 1
|
|
737
|
+
return "#{seconds.round(1)}s" if seconds < 60
|
|
738
|
+
|
|
739
|
+
"#{(seconds / 60).round(1)}m"
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Overview)
|