cogworker 0.1.0 → 0.2.1

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/attempts.rb +47 -0
  3. data/lib/cogworker/basic_fetch.rb +25 -2
  4. data/lib/cogworker/heartbeat.rb +4 -2
  5. data/lib/cogworker/history/storage.rb +58 -22
  6. data/lib/cogworker/history.rb +57 -7
  7. data/lib/cogworker/manager.rb +4 -0
  8. data/lib/cogworker/periodic/ticker.rb +16 -1
  9. data/lib/cogworker/process.rb +13 -5
  10. data/lib/cogworker/processor.rb +4 -0
  11. data/lib/cogworker/queue.rb +17 -0
  12. data/lib/cogworker/redis_keys.rb +5 -0
  13. data/lib/cogworker/throughput.rb +51 -0
  14. data/lib/cogworker/version.rb +1 -1
  15. data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
  16. data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
  17. data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
  18. data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
  19. data/lib/cogworker/web/layout.rb +232 -141
  20. data/lib/cogworker/web/routes/history.rb +86 -32
  21. data/lib/cogworker/web/routes/jobs.rb +469 -0
  22. data/lib/cogworker/web/routes/overview.rb +746 -0
  23. data/lib/cogworker/web/routes/schedules.rb +197 -0
  24. data/lib/cogworker/web/routes/workers.rb +227 -0
  25. data/lib/cogworker/web.rb +4 -3
  26. metadata +11 -9
  27. data/lib/cogworker/web/assets/tailwind.css +0 -1
  28. data/lib/cogworker/web/routes/busy.rb +0 -99
  29. data/lib/cogworker/web/routes/dead.rb +0 -93
  30. data/lib/cogworker/web/routes/periodic.rb +0 -67
  31. data/lib/cogworker/web/routes/queues.rb +0 -101
  32. data/lib/cogworker/web/routes/retries.rb +0 -89
  33. data/lib/cogworker/web/routes/scheduled.rb +0 -49
  34. data/lib/cogworker/web/routes/stats.rb +0 -298
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'fugit'
5
+ require 'json'
6
+
7
+ module Cogworker
8
+ class Web
9
+ module Routes
10
+ # Lists every registered `config.periodic { |mgr| mgr.register(...) }`
11
+ # entry and when it last/next fires, sourced entirely from Redis
12
+ # (`periodic:schedule`/`periodic:last_slot:<pjid>`), not from any
13
+ # in-process `Periodic::Manager` — those are only published once an
14
+ # actual worker process's `Periodic::Ticker` has booted (see
15
+ # `Ticker#publish_schedule!`), so a Web-UI-only process (no worker
16
+ # ever started) shows the empty state here, same as Workers shows no
17
+ # processes with no worker running.
18
+ #
19
+ # Named `Schedules` (not `Periodic`, the engine concept it reads —
20
+ # `Cogworker::Periodic::*`) to match the "Relay" concept's tab name.
21
+ # "Run now"/"Disable" are real actions (see `Ticker#disabled?`); "New
22
+ # schedule" still isn't — adding one from here, not from `config.
23
+ # periodic` in application code, would make Redis a second source of
24
+ # truth for entries a worker restart's own `publish_schedule!` knows
25
+ # nothing about, a real architecture question rather than a redesign
26
+ # one.
27
+ module Schedules
28
+ CONTENT_ID = 'schedules-content'
29
+
30
+ module_function
31
+
32
+ def registered(app)
33
+ app.get('/schedules') do
34
+ content = Routes::Schedules.render_content(request.script_name, params)
35
+ if hx_request?
36
+ content
37
+ else
38
+ Layout.wrap('Schedules', Layout.poll_div(CONTENT_ID, request.script_name, 'schedules', content),
39
+ script_name: request.script_name)
40
+ end
41
+ end
42
+
43
+ # Pushes this entry's class/args as one ordinary, independent job
44
+ # right now — not tied to any cron slot, so it carries no
45
+ # `periodic_pjid`/`periodic_slot` (unlike a real tick's own
46
+ # `Ticker#enqueue`) and leaves the claim/`last_slot`/
47
+ # `until_executed` running-lock bookkeeping in
48
+ # `Periodic::ReleaseMiddleware` completely untouched.
49
+ app.post('/schedules/:pjid/run_now') do
50
+ Routes::Schedules.run_now(url_params('pjid'))
51
+ Routes::Schedules.respond(self)
52
+ end
53
+
54
+ app.post('/schedules/:pjid/disable') do
55
+ Cogworker.config.redis { |c| c.sadd(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
56
+ Routes::Schedules.respond(self)
57
+ end
58
+
59
+ app.post('/schedules/:pjid/enable') do
60
+ Cogworker.config.redis { |c| c.srem(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
61
+ Routes::Schedules.respond(self)
62
+ end
63
+ end
64
+
65
+ def run_now(pjid)
66
+ raw = Cogworker.config.redis { |c| c.hget(RedisKeys::PERIODIC_SCHEDULE, pjid) }
67
+ return unless raw
68
+
69
+ entry = JSON.parse(raw)
70
+ Cogworker::Client.push('class' => entry['class'], 'args' => entry['args'], 'retry' => entry['retry'])
71
+ end
72
+
73
+ # `q` (a plain query param, same full-page-link-carried-through-
74
+ # actions pattern `Routes::Jobs`'s own search uses) survives both
75
+ # response shapes here — an hx-swap re-render keeps whatever the
76
+ # triggering form's own hidden `q` field carried (see
77
+ # `actions_cell`), and a plain redirect carries it in the URL.
78
+ def respond(action)
79
+ if action.hx_request?
80
+ render_content(action.request.script_name, action.params)
81
+ else
82
+ query = action.params['q'].to_s
83
+ suffix = query.empty? ? '' : "?q=#{CGI.escape(query)}"
84
+ action.redirect(Layout.path(action.request.script_name, "schedules#{suffix}"))
85
+ end
86
+ end
87
+
88
+ def render_content(script_name, params)
89
+ query = params['q'].to_s
90
+ schedule = Cogworker.config.redis { |c| c.hgetall(RedisKeys::PERIODIC_SCHEDULE) }
91
+ disabled = Cogworker.config.redis { |c| c.smembers(RedisKeys::PERIODIC_DISABLED) }
92
+ entries = schedule.map { |pjid, raw| [pjid, JSON.parse(raw)] }
93
+ filtered = entries.select { |_pjid, entry| matches_query?(entry, query) }
94
+ rows = filtered.map { |pjid, entry| row_for(pjid, entry, script_name, disabled.include?(pjid), query) }
95
+ table = Layout.table(%w[Class Cron Args NextRun LastRun Unique State Actions], rows,
96
+ empty_message: empty_message(entries.empty?))
97
+ <<~HTML
98
+ <div style="display: flex; flex-direction: column; gap: 16px;">
99
+ #{page_header(script_name, query, filtered.size, entries.size)}
100
+ #{table}
101
+ </div>
102
+ HTML
103
+ end
104
+
105
+ def matches_query?(entry, query)
106
+ return true if query.strip.empty?
107
+
108
+ haystack = "#{entry['class']}#{entry['cron']}#{entry['args']}".downcase
109
+ haystack.include?(query.strip.downcase)
110
+ end
111
+
112
+ def empty_message(nothing_registered)
113
+ return 'No schedules match that search.' unless nothing_registered
114
+
115
+ 'Nothing registered yet (no worker process has booted the periodic ' \
116
+ 'scheduler — periodic jobs are published to Redis by ' \
117
+ 'Periodic::Ticker on worker startup, not by the Web UI process).'
118
+ end
119
+
120
+ def page_header(script_name, query, shown_count, total_count)
121
+ search_action = Layout.path(script_name, 'schedules')
122
+ <<~HTML
123
+ <div style="display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
124
+ <div>
125
+ <h2 style="margin: 0 0 4px;">Schedules</h2>
126
+ <p class="text-muted" style="margin: 0; font-size: 13px;">#{shown_count} of #{total_count} shown</p>
127
+ </div>
128
+ <form method="get" action="#{search_action}">
129
+ <input class="input" style="width: 240px;" type="search" name="q" placeholder="Search class, cron or args" value="#{Layout.h(query)}">
130
+ </form>
131
+ </div>
132
+ HTML
133
+ end
134
+
135
+ def row_for(pjid, entry, script_name, disabled, query)
136
+ [Layout.h(entry['class']), Layout.h(entry['cron']), Layout.h(entry['args'].to_json),
137
+ next_run_tag(entry['cron'], disabled), last_run_tag(pjid), unique_cell(entry['unique']),
138
+ state_tag(disabled), actions_cell(pjid, script_name, disabled, query)]
139
+ end
140
+
141
+ def next_run_tag(cron, disabled)
142
+ return %(<span class="text-muted" style="font-style: italic;">disabled</span>) if disabled
143
+
144
+ Layout.time_tag(Fugit::Cron.parse(cron)&.next_time(Time.now)&.to_t)
145
+ rescue StandardError
146
+ Layout.h('invalid cron')
147
+ end
148
+
149
+ def last_run_tag(pjid)
150
+ last_slot = Cogworker.config.redis { |c| c.get(RedisKeys.periodic_last_slot(pjid)) }
151
+ last_slot ? Layout.time_tag(last_slot.to_f) : %(<span class="text-muted" style="font-style: italic;">never</span>)
152
+ end
153
+
154
+ def unique_cell(unique)
155
+ unique.nil? || unique.empty? ? '' : Layout.badge(unique, variant: :warning)
156
+ end
157
+
158
+ def state_tag(disabled)
159
+ disabled ? Layout.badge('Disabled', variant: :warning) : Layout.badge('Enabled', variant: :success)
160
+ end
161
+
162
+ def actions_cell(pjid, script_name, disabled, query)
163
+ run_now_path = Layout.path(script_name, "schedules/#{pjid}/run_now")
164
+ run_now = action_button(run_now_path, 'run now', query, variant: :primary, icon: 'play')
165
+ toggle = if disabled
166
+ enable_path = Layout.path(script_name, "schedules/#{pjid}/enable")
167
+ action_button(enable_path, 'enable', query, variant: :primary)
168
+ else
169
+ disable_path = Layout.path(script_name, "schedules/#{pjid}/disable")
170
+ action_button(disable_path, 'disable', query, variant: :warning, icon: 'pause')
171
+ end
172
+ run_now + toggle
173
+ end
174
+
175
+ # Like `Layout.action_button`, but carrying the current search (`q`)
176
+ # as a hidden field — so `respond` sees it in `action.params` and a
177
+ # row action (run now/disable/enable) doesn't silently clear an
178
+ # active search out from under the person who just clicked it.
179
+ def action_button(path, label, query, variant:, icon: nil)
180
+ if query.empty?
181
+ return Layout.action_button(path, label, hx_target: "##{CONTENT_ID}", variant: variant, icon: icon)
182
+ end
183
+
184
+ classes = "btn #{Layout::BUTTON_VARIANTS.fetch(variant)}"
185
+ <<~HTML
186
+ <form style="display: inline;" hx-post="#{path}" hx-target="##{CONTENT_ID}" hx-swap="innerHTML" method="post" action="#{path}">
187
+ <input type="hidden" name="q" value="#{Layout.h(query)}">
188
+ <button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{Layout.icon_tag(icon)}#{Layout.h(label)}</button>
189
+ </form>
190
+ HTML
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ Cogworker::Web.register(Cogworker::Web::Routes::Schedules)
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # One card per live process (ProcessSet) — identity, active/quiet
7
+ # state, served queues, live busy/concurrency + a load bar, memory,
8
+ # heartbeat freshness, and quiet/stop actions — plus the existing
9
+ # in-flight-jobs table (WorkSet) below, unchanged: the "Relay" concept
10
+ # mock's own Workers cards assume one job per worker, which doesn't
11
+ # fit this engine's multi-threaded-per-process model, so that table
12
+ # (one row per actual busy thread, not per process) stays the more
13
+ # honest place to look at what's actually running right now.
14
+ module Workers
15
+ CONTENT_ID = 'workers-content'
16
+
17
+ module_function
18
+
19
+ def registered(app)
20
+ app.get('/workers') do
21
+ content = Workers.render_content(request.script_name)
22
+ if hx_request?
23
+ content
24
+ else
25
+ Layout.wrap('Workers', Layout.poll_div(CONTENT_ID, request.script_name, 'workers', content),
26
+ script_name: request.script_name)
27
+ end
28
+ end
29
+
30
+ app.post('/workers/quiet') { Workers.apply(self, &:quiet!) }
31
+ app.post('/workers/resume') { Workers.apply(self, &:resume!) }
32
+ app.post('/workers/stop') { Workers.apply(self, &:stop!) }
33
+
34
+ # The header's own cluster status pill (`Layout.cluster_bar`,
35
+ # shown on every page, not just this tab) polls this small
36
+ # fragment independently — its own dedicated route, always just
37
+ # the fragment.
38
+ app.get('/workers/summary') { Layout.cluster_bar_content }
39
+
40
+ # The header's "Pause intake"/"Resume intake" buttons
41
+ # (`Layout.pause_intake_button`/`resume_intake_button`) — quiet/
42
+ # resume every live process at once, the cluster-wide counterpart
43
+ # to a single process's own quiet/resume card actions above.
44
+ app.post('/workers/pause_all') { Workers.apply_to_all(self, &:quiet!) }
45
+ app.post('/workers/resume_all') { Workers.apply_to_all(self, &:resume!) }
46
+ end
47
+
48
+ # Shared by the quiet/stop actions: perform the effect, then either
49
+ # hand back the refreshed fragment (htmx swaps it into
50
+ # #workers-content in place) or redirect back to the full page (a
51
+ # plain, JS-less form submission).
52
+ def apply(action)
53
+ identity = action.params['identity']
54
+ process = Cogworker::ProcessSet.new.find { |p| p.identity == identity }
55
+ yield process if process
56
+
57
+ if action.hx_request?
58
+ render_content(action.request.script_name)
59
+ else
60
+ action.redirect(Layout.path(action.request.script_name, 'workers'))
61
+ end
62
+ end
63
+
64
+ # Shared by the cluster-wide pause_all/resume_all actions: applies the
65
+ # effect to every live process, then hands back the same fragment/
66
+ # redirect shape `apply` above uses (the header's cluster bar for an
67
+ # hx request, since that's the piece these two buttons actually sit
68
+ # next to and refresh — not the Workers page fragment, unlike a
69
+ # single-card action).
70
+ def apply_to_all(action, &block)
71
+ Cogworker::ProcessSet.new.each(&block)
72
+ if action.hx_request?
73
+ Layout.cluster_bar_content
74
+ else
75
+ action.redirect(Layout.path(action.request.script_name, 'workers'))
76
+ end
77
+ end
78
+
79
+ def render_content(script_name)
80
+ quiet_path = Layout.path(script_name, 'workers/quiet')
81
+ resume_path = Layout.path(script_name, 'workers/resume')
82
+ stop_path = Layout.path(script_name, 'workers/stop')
83
+ processes = Cogworker::ProcessSet.new.to_a
84
+ # One `WorkSet` snapshot, reused for both the cards' live busy
85
+ # count and the in-flight jobs table below — see CLAUDE.md: the
86
+ # process's own "busy" figure must come from this real-time
87
+ # snapshot, not the heartbeat-published `p['busy']` (up to
88
+ # `Heartbeat::INTERVAL` seconds stale), or the two can visibly
89
+ # disagree on the same page.
90
+ work_set = Cogworker::WorkSet.new.to_a
91
+ busy_counts = work_set.each_with_object(Hash.new(0)) { |(identity, *), h| h[identity] += 1 }
92
+
93
+ cards = process_cards(processes, busy_counts, quiet_path, resume_path, stop_path)
94
+
95
+ work_rows = work_set.map do |identity, tid, work|
96
+ [Layout.h(identity), Layout.h(tid), Layout.h(work.job['jid']), Layout.h(work.queue),
97
+ Layout.h(work.job['class']), Layout.time_tag(work.run_at)]
98
+ end
99
+ jobs_table = Layout.table(%w[Identity Thread JID Queue Class RunAt], work_rows,
100
+ empty_message: 'No jobs in flight.')
101
+
102
+ # Same flex-column-with-gap wrapper `Routes::Jobs`/`Routes::
103
+ # Overview` use for their own page_header — without it, the
104
+ # header (a bare, unmargined `<div>`) sits flush against the
105
+ # process cards right below it, a real bug once caught by hand.
106
+ <<~HTML
107
+ <div style="display: flex; flex-direction: column; gap: 16px;">
108
+ #{page_header(processes)}
109
+ #{cards}
110
+ #{Layout.section('In-flight jobs', jobs_table)}
111
+ </div>
112
+ HTML
113
+ end
114
+
115
+ # The "Relay" concept mock's own summary line under the page title —
116
+ # real counts, not the mock's fixed "12 processes · 96 threads": live
117
+ # process count and the *configured* concurrency summed across them
118
+ # (each process's own `concurrency` heartbeat field, its full thread
119
+ # pool capacity — not `busy`, which is how many are working *right
120
+ # now* and already has its own place in each card below). Cadence
121
+ # comes straight from `Heartbeat::INTERVAL`, the one real source for
122
+ # it, rather than a hardcoded "5s" that could drift from it.
123
+ def page_header(processes)
124
+ total_threads = processes.sum { |p| p['concurrency'].to_i }
125
+ <<~HTML
126
+ <div>
127
+ <h2 style="margin: 0 0 4px;">Workers</h2>
128
+ <p class="text-muted" style="margin: 0; font-size: 13px;">
129
+ #{processes.size} #{processes.size == 1 ? 'process' : 'processes'} ·
130
+ #{total_threads} #{total_threads == 1 ? 'thread' : 'threads'} ·
131
+ heartbeat every #{Heartbeat::INTERVAL}s
132
+ </p>
133
+ </div>
134
+ HTML
135
+ end
136
+
137
+ def process_cards(processes, busy_counts, quiet_path, resume_path, stop_path)
138
+ if processes.empty?
139
+ return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">No worker processes ) +
140
+ 'are reporting in right now.</p>'
141
+ end
142
+
143
+ items = processes.map do |p|
144
+ process_card(p, busy_counts[p.identity], quiet_path, resume_path, stop_path)
145
+ end.join
146
+ %(<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px;">#{items}</div>)
147
+ end
148
+
149
+ def process_card(process, busy, quiet_path, resume_path, stop_path)
150
+ concurrency = process['concurrency'].to_i
151
+ pct = concurrency.positive? ? ((busy.to_f / concurrency) * 100).round : 0
152
+ quiet = [true, 'true'].include?(process['quiet'])
153
+ # A process already draining/exiting (mid-`stop!`) still reports
154
+ # `quiet: true` on its last couple of heartbeats — but `resume!`
155
+ # would be a no-op there (`Manager#stop!` already joined every
156
+ # processor thread) and the card is about to disappear from
157
+ # `ProcessSet` anyway, so showing "quiet" (not "resume") action
158
+ # would just needlessly relabel a button no one can usefully press
159
+ # in that narrow window. Not worth a separate stopping? signal over
160
+ # the wire just to distinguish it.
161
+ toggle_button = if quiet
162
+ Layout.form_button(resume_path, 'identity', process.identity, 'resume',
163
+ hx_target: "##{CONTENT_ID}", variant: :success, icon: 'play')
164
+ else
165
+ Layout.form_button(quiet_path, 'identity', process.identity, 'quiet',
166
+ hx_target: "##{CONTENT_ID}", variant: :warning, icon: 'pause')
167
+ end
168
+ <<~HTML
169
+ <div class="card elev-sm" style="gap: 10px; padding: 14px;">
170
+ <div style="display: flex; align-items: center; justify-content: space-between; gap: 10px;">
171
+ <span class="mono" style="font-size: 14px;">#{Layout.h(process.identity)}</span>
172
+ #{state_tag(process['quiet'])}
173
+ </div>
174
+ <div style="display: flex; gap: 14px; font-size: 12px; color: var(--color-neutral-400); flex-wrap: wrap;">
175
+ <span>#{Layout.h(Array(process['queues']).join(', '))}</span>
176
+ <span>#{busy} / #{concurrency} busy</span>
177
+ <span>#{memory_cell(process['rss_kb'])}</span>
178
+ <span>#{beat_label(process.identity)}</span>
179
+ </div>
180
+ <div style="height: 4px; border-radius: 2px; background: var(--color-neutral-800); overflow: hidden;">
181
+ <div style="height: 100%; border-radius: 2px; background: var(--color-accent); width: #{pct}%;"></div>
182
+ </div>
183
+ <div style="display: flex; align-items: center; justify-content: space-between; gap: 10px;">
184
+ <span style="font-size: 12px; color: var(--color-neutral-500);">started #{Layout.time_tag(process['started_at'])}</span>
185
+ <div style="display: flex; gap: 6px;">
186
+ #{toggle_button}
187
+ #{Layout.form_button(stop_path, 'identity', process.identity, 'stop', hx_target: "##{CONTENT_ID}", variant: :danger)}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ HTML
192
+ end
193
+
194
+ def state_tag(quiet)
195
+ if [true, 'true'].include?(quiet)
196
+ Layout.badge('Quiet', variant: :warning)
197
+ else
198
+ Layout.badge('Active', variant: :success)
199
+ end
200
+ end
201
+
202
+ # `rss_kb` is absent on any process whose heartbeat predates this
203
+ # field (or that never measured cleanly — see
204
+ # `Heartbeat#current_rss_kb`) — render "n/a" rather than "0M".
205
+ def memory_cell(rss_kb)
206
+ return 'n/a' unless rss_kb
207
+
208
+ format('%.1fM', rss_kb.to_f / 1024)
209
+ end
210
+
211
+ # No stored "last heartbeat at" field — derived instead from the
212
+ # process key's own remaining Redis TTL (refreshed to
213
+ # `Heartbeat::TTL` on every beat), which is already exactly the
214
+ # data needed for this and avoids adding a redundant timestamp
215
+ # field to the heartbeat payload.
216
+ def beat_label(identity)
217
+ ttl = Cogworker.config.redis { |c| c.ttl(RedisKeys.process(identity)) }
218
+ return 'beat unknown' if ttl.negative?
219
+
220
+ "beat #{[Heartbeat::TTL - ttl, 0].max}s ago"
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ Cogworker::Web.register(Cogworker::Web::Routes::Workers)
data/lib/cogworker/web.rb CHANGED
@@ -22,13 +22,14 @@ module Cogworker
22
22
  # `Cogworker::Web.register(...)` bottom-of-file side effect) — nothing
23
23
  # else ever references `Routes::Queues` etc. by name, so without this
24
24
  # they would simply never load.
25
- BUILT_IN_ROUTE_NAMES = %i[Queues Busy Retries Scheduled Periodic Dead History Stats SaveSession].freeze
25
+ BUILT_IN_ROUTE_NAMES = %i[Overview Jobs Schedules Workers History SaveSession].freeze
26
26
  DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
27
27
  DEFAULT_HISTORY_PER_PAGE = 25
28
28
  DEFAULT_LIVE_UPDATE_INTERVAL = 3
29
29
 
30
- # htmx/Tailwind/AG Grid are vendored under here (not fetched from a CDN)
31
- # so the Web UI works with no internet access at all — see `Layout`'s
30
+ # htmx/nocturne/Phosphor/AG Grid/Chart.js are vendored under here (not
31
+ # fetched from a CDN) so the Web UI works with no internet access at
32
+ # all — see `Layout`'s
32
33
  # `<script>`/`<link>` tags and `Routes::History#ag_grid_head`, all of
33
34
  # which build their `src`/`href` as `path(script_name, 'assets/...')`,
34
35
  # same as every other in-app link. `root:` is this directory's *parent*
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cogworker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -206,6 +206,7 @@ files:
206
206
  - exe/cogworker
207
207
  - exe/cogworkerswarm
208
208
  - lib/cogworker.rb
209
+ - lib/cogworker/attempts.rb
209
210
  - lib/cogworker/basic_fetch.rb
210
211
  - lib/cogworker/cli.rb
211
212
  - lib/cogworker/client.rb
@@ -245,6 +246,7 @@ files:
245
246
  - lib/cogworker/status/worker.rb
246
247
  - lib/cogworker/swarm.rb
247
248
  - lib/cogworker/testing.rb
249
+ - lib/cogworker/throughput.rb
248
250
  - lib/cogworker/unique_jobs.rb
249
251
  - lib/cogworker/unique_jobs/client_middleware.rb
250
252
  - lib/cogworker/unique_jobs/release_middleware.rb
@@ -257,18 +259,18 @@ files:
257
259
  - lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css
258
260
  - lib/cogworker/web/assets/chart.umd.min.js
259
261
  - lib/cogworker/web/assets/htmx.min.js
260
- - lib/cogworker/web/assets/tailwind.css
262
+ - lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2
263
+ - lib/cogworker/web/assets/nocturne/styles.css
264
+ - lib/cogworker/web/assets/phosphor/Phosphor.woff2
265
+ - lib/cogworker/web/assets/phosphor/style.css
261
266
  - lib/cogworker/web/layout.rb
262
267
  - lib/cogworker/web/router.rb
263
- - lib/cogworker/web/routes/busy.rb
264
- - lib/cogworker/web/routes/dead.rb
265
268
  - lib/cogworker/web/routes/history.rb
266
- - lib/cogworker/web/routes/periodic.rb
267
- - lib/cogworker/web/routes/queues.rb
268
- - lib/cogworker/web/routes/retries.rb
269
+ - lib/cogworker/web/routes/jobs.rb
270
+ - lib/cogworker/web/routes/overview.rb
269
271
  - lib/cogworker/web/routes/save_session.rb
270
- - lib/cogworker/web/routes/scheduled.rb
271
- - lib/cogworker/web/routes/stats.rb
272
+ - lib/cogworker/web/routes/schedules.rb
273
+ - lib/cogworker/web/routes/workers.rb
272
274
  - lib/cogworker/web/views.rb
273
275
  - lib/cogworker/work.rb
274
276
  - lib/cogworker/work_set.rb
@@ -1 +0,0 @@
1
- *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.relative{position:relative}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-auto{margin-left:auto}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-14{height:3.5rem}.h-full{height:100%}.max-h-\[60vh\]{max-height:60vh}.w-\[90vw\]{width:90vw}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-xs{max-width:20rem}.flex-shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.p-0{padding:0}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-sky-600{--tw-text-opacity:1;color:rgb(2 132 199/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}@media (min-width:640px){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\:divide-gray-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(31 41 55/var(--tw-divide-opacity,1))}.dark\:border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.dark\:bg-amber-900\/40{background-color:rgba(120,53,15,.4)}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-800\/60{background-color:rgba(31,41,55,.6)}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-gray-950{--tw-bg-opacity:1;background-color:rgb(3 7 18/var(--tw-bg-opacity,1))}.dark\:bg-green-900\/40{background-color:rgba(20,83,45,.4)}.dark\:bg-red-900\/30{background-color:rgba(127,29,29,.3)}.dark\:bg-red-900\/40{background-color:rgba(127,29,29,.4)}.dark\:bg-transparent{background-color:transparent}.dark\:text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.dark\:text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.dark\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.dark\:hover\:bg-amber-900\/70:hover{background-color:rgba(120,53,15,.7)}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-800\/60:hover{background-color:rgba(31,41,55,.6)}.dark\:hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.dark\:hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}}
@@ -1,99 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cogworker
4
- class Web
5
- module Routes
6
- # Lists live processes (ProcessSet) with quiet/stop actions, and the
7
- # in-flight jobs across all of them (WorkSet).
8
- module Busy
9
- CONTENT_ID = 'busy-content'
10
-
11
- module_function
12
-
13
- def registered(app)
14
- app.get('/busy') do
15
- content = Busy.render_content(request.script_name)
16
- if hx_request?
17
- content
18
- else
19
- Layout.wrap('Busy', Layout.poll_div(CONTENT_ID, request.script_name, 'busy', content),
20
- script_name: request.script_name)
21
- end
22
- end
23
-
24
- app.post('/busy/quiet') { Busy.apply(self, &:quiet!) }
25
- app.post('/busy/stop') { Busy.apply(self, &:stop!) }
26
- end
27
-
28
- # Shared by the quiet/stop actions: perform the effect, then either
29
- # hand back the refreshed fragment (htmx swaps it into
30
- # #busy-content in place) or redirect back to the full page (a
31
- # plain, JS-less form submission).
32
- def apply(action)
33
- identity = action.params['identity']
34
- process = Cogworker::ProcessSet.new.find { |p| p.identity == identity }
35
- yield process if process
36
-
37
- if action.hx_request?
38
- render_content(action.request.script_name)
39
- else
40
- action.redirect(Layout.path(action.request.script_name, 'busy'))
41
- end
42
- end
43
-
44
- def render_content(script_name)
45
- quiet_path = Layout.path(script_name, 'busy/quiet')
46
- stop_path = Layout.path(script_name, 'busy/stop')
47
- # One `WorkSet` snapshot, reused for both tables below — the
48
- # process table's own "Busy" count used to come from
49
- # `p['busy']` (`Manager#busy_count`, only as fresh as this
50
- # process's *last heartbeat*, up to `Heartbeat::INTERVAL`
51
- # seconds stale) while the Workers table already read `WorkSet`
52
- # directly (updated in real time on every job start/finish, not
53
- # throttled by the heartbeat at all) — the two could visibly
54
- # disagree (e.g. "Busy: 1" next to 3 real Workers rows for that
55
- # same identity) whenever a job started or finished within the
56
- # last heartbeat interval. Deriving both from this single,
57
- # real-time snapshot means they can never disagree again.
58
- work_set = Cogworker::WorkSet.new.to_a
59
- busy_counts = work_set.each_with_object(Hash.new(0)) { |(identity, *), h| h[identity] += 1 }
60
-
61
- process_rows = Cogworker::ProcessSet.new.map do |p|
62
- [Layout.h(p.identity), Layout.time_tag(p['started_at']), memory_cell(p['rss_kb']),
63
- Layout.h(Array(p['queues']).join(', ')), busy_counts[p.identity], badge(p['quiet']),
64
- action_forms(quiet_path, stop_path, p.identity)]
65
- end
66
- work_rows = work_set.map do |identity, tid, work|
67
- [Layout.h(identity), Layout.h(tid), Layout.h(work.job['jid']), Layout.h(work.queue),
68
- Layout.h(work.job['class']), Layout.time_tag(work.run_at)]
69
- end
70
- Layout.table(%w[Identity StartedAt Memory Queues Busy Quiet Actions], process_rows,
71
- empty_message: 'No worker processes are reporting in right now.') +
72
- Layout.section('Workers', Layout.table(%w[Identity Thread JID Queue Class RunAt], work_rows,
73
- empty_message: 'No jobs in flight.'))
74
- end
75
-
76
- def badge(quiet)
77
- quiet == 'true' ? Layout.badge('quiet', variant: :warning) : Layout.badge('running', variant: :success)
78
- end
79
-
80
- # `rss_kb` is absent on any process whose heartbeat predates this
81
- # field (or that never measured cleanly — see
82
- # `Heartbeat#current_rss_kb`) — render "n/a" rather than "0M".
83
- def memory_cell(rss_kb)
84
- return 'n/a' unless rss_kb
85
-
86
- Layout.h(format('%.1fM', rss_kb.to_f / 1024))
87
- end
88
-
89
- def action_forms(quiet_path, stop_path, identity)
90
- hx_target = "##{CONTENT_ID}"
91
- Layout.form_button(quiet_path, 'identity', identity, 'quiet', hx_target: hx_target, variant: :warning) +
92
- Layout.form_button(stop_path, 'identity', identity, 'stop', hx_target: hx_target, variant: :danger)
93
- end
94
- end
95
- end
96
- end
97
- end
98
-
99
- Cogworker::Web.register(Cogworker::Web::Routes::Busy)