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.
@@ -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)