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