cogworker 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cogworker/attempts.rb +47 -0
- data/lib/cogworker/basic_fetch.rb +25 -2
- data/lib/cogworker/heartbeat.rb +4 -2
- data/lib/cogworker/manager.rb +4 -0
- data/lib/cogworker/periodic/ticker.rb +16 -1
- data/lib/cogworker/process.rb +13 -5
- data/lib/cogworker/processor.rb +4 -0
- data/lib/cogworker/queue.rb +17 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/throughput.rb +51 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
- data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
- data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
- data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
- data/lib/cogworker/web/layout.rb +232 -141
- data/lib/cogworker/web/routes/history.rb +86 -32
- data/lib/cogworker/web/routes/jobs.rb +469 -0
- data/lib/cogworker/web/routes/overview.rb +746 -0
- data/lib/cogworker/web/routes/schedules.rb +197 -0
- data/lib/cogworker/web/routes/workers.rb +227 -0
- data/lib/cogworker/web.rb +4 -3
- metadata +11 -9
- data/lib/cogworker/web/assets/tailwind.css +0 -1
- data/lib/cogworker/web/routes/busy.rb +0 -99
- data/lib/cogworker/web/routes/dead.rb +0 -93
- data/lib/cogworker/web/routes/periodic.rb +0 -67
- data/lib/cogworker/web/routes/queues.rb +0 -101
- data/lib/cogworker/web/routes/retries.rb +0 -89
- data/lib/cogworker/web/routes/scheduled.rb +0 -49
- data/lib/cogworker/web/routes/stats.rb +0 -298
|
@@ -0,0 +1,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[
|
|
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/
|
|
31
|
-
# so the Web UI works with no internet access at
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
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/
|
|
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/
|
|
267
|
-
- lib/cogworker/web/routes/
|
|
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/
|
|
271
|
-
- lib/cogworker/web/routes/
|
|
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)
|