cogworker 0.7.0 → 0.8.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/heartbeat.rb +3 -1
- data/lib/cogworker/key_guard.rb +1 -0
- data/lib/cogworker/memory_history.rb +68 -0
- data/lib/cogworker/redis_keys.rb +1 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/routes/overview.rb +28 -3
- data/lib/cogworker/web/routes/workers.rb +156 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 472527fd7492d45c642f017b2cdc788c0741ebe7226b42b416aee90431b61194
|
|
4
|
+
data.tar.gz: '078375dda087dfe1064ed79e35b92b4fb2e4dbf017e68fb681b939d96a45fdea'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e66fb20f141b58faa5c13eaa1e9df971d890a8bc5a77c845f80fbdcc621beacf2b03c5d9e1dbae68a9b4981a2e322a4147d1e366677e607cb069b846d2d905e
|
|
7
|
+
data.tar.gz: f3297307cd6201b8421a0acd54217d5a79cdaa1f93fed083ccbd7bd492cb9867340ffc52b787847058a0ce030ed5b17bd1b9d389fbdbc6ef0c64195f83f3422a
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -75,7 +75,8 @@ module Cogworker
|
|
|
75
75
|
|
|
76
76
|
def cleanup_presence!
|
|
77
77
|
Cogworker.config.redis do |c|
|
|
78
|
-
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity)
|
|
78
|
+
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity),
|
|
79
|
+
RedisKeys.memory_history(Cogworker.identity))
|
|
79
80
|
c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
|
|
80
81
|
# Clean shutdown: Manager#stop! already requeued this process's
|
|
81
82
|
# in-progress list, so there's nothing left for recovery to find.
|
|
@@ -132,6 +133,7 @@ module Cogworker
|
|
|
132
133
|
'quiet', @manager.quiet?.to_s)
|
|
133
134
|
c.expire(RedisKeys.process(identity), TTL)
|
|
134
135
|
write_registries(c, identity, reliable)
|
|
136
|
+
secondary('memory history') { MemoryHistory.record(c, identity, info['rss_kb']) }
|
|
135
137
|
touch_running_periodic_locks(c, identity)
|
|
136
138
|
# Not via #secondary: Redis failing here, after the presence key went
|
|
137
139
|
# through, doesn't make this beat a failed one.
|
data/lib/cogworker/key_guard.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Cogworker
|
|
|
65
65
|
readable_members(RedisKeys::PROCESSES).each do |id|
|
|
66
66
|
types[RedisKeys.process(id)] = 'hash'
|
|
67
67
|
types[RedisKeys.workers(id)] = 'hash'
|
|
68
|
+
types[RedisKeys.memory_history(id)] = 'zset'
|
|
68
69
|
end
|
|
69
70
|
readable_fields(RedisKeys::PERIODIC_SCHEDULE).each do |pjid|
|
|
70
71
|
types[RedisKeys.periodic_running(pjid)] = 'string'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Per-process RSS for the last 24 hours, in SLOTS fixed SAMPLE_SECONDS
|
|
5
|
+
# slots, feeding the memory sparkline on the Workers tab's process cards.
|
|
6
|
+
# Written by Heartbeat on every beat; a slot keeps the *highest* reading
|
|
7
|
+
# any beat in it took (a peak is what matters for memory, and a short
|
|
8
|
+
# spike between two slot boundaries would otherwise vanish). One ZSET per
|
|
9
|
+
# identity, scored by slot start, members "<slot_start>:<rss_kb>" — so a
|
|
10
|
+
# slot's sample is unique by score, not by value. Old slots are trimmed
|
|
11
|
+
# on every write, and the key itself expires a little after the window,
|
|
12
|
+
# so a process that's gone leaves nothing behind for long.
|
|
13
|
+
module MemoryHistory
|
|
14
|
+
SAMPLE_SECONDS = 900
|
|
15
|
+
SLOTS = 96 # 24h of 15-minute slots
|
|
16
|
+
WINDOW = SLOTS * SAMPLE_SECONDS
|
|
17
|
+
KEY_TTL = WINDOW + 3600
|
|
18
|
+
|
|
19
|
+
# KEYS[1] = the identity's ZSET; ARGV = slot start, rss_kb, window, TTL.
|
|
20
|
+
RECORD_SCRIPT = <<~LUA
|
|
21
|
+
local slot = tonumber(ARGV[1])
|
|
22
|
+
local kb = tonumber(ARGV[2])
|
|
23
|
+
for _, member in ipairs(redis.call("ZRANGEBYSCORE", KEYS[1], slot, slot)) do
|
|
24
|
+
local seen = tonumber(string.match(member, ":(%d+)$"))
|
|
25
|
+
if seen and seen >= kb then kb = nil break end
|
|
26
|
+
end
|
|
27
|
+
if kb then
|
|
28
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], slot, slot)
|
|
29
|
+
redis.call("ZADD", KEYS[1], slot, slot .. ":" .. kb)
|
|
30
|
+
end
|
|
31
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. (slot - tonumber(ARGV[3])))
|
|
32
|
+
redis.call("EXPIRE", KEYS[1], ARGV[4])
|
|
33
|
+
return 1
|
|
34
|
+
LUA
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def record(conn, identity, rss_kb, at: Time.now.to_i)
|
|
39
|
+
return unless rss_kb
|
|
40
|
+
|
|
41
|
+
LuaScript.run(conn, RECORD_SCRIPT, keys: [RedisKeys.memory_history(identity)],
|
|
42
|
+
argv: [slot_for(at), rss_kb.to_i, WINDOW, KEY_TTL])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Per identity, exactly SLOTS `[slot_start, rss_kb_or_nil]` pairs,
|
|
46
|
+
# oldest first, the last one being the current (still filling) slot —
|
|
47
|
+
# a slot no beat wrote to is `nil`, so the chart always spans the full
|
|
48
|
+
# 24h whatever data exists. One pipelined read for every identity.
|
|
49
|
+
def series(identities, now: Time.now.to_i)
|
|
50
|
+
return {} if identities.empty?
|
|
51
|
+
|
|
52
|
+
first = slot_for(now) - ((SLOTS - 1) * SAMPLE_SECONDS)
|
|
53
|
+
raw = Cogworker.config.redis do |c|
|
|
54
|
+
c.pipelined do |pipe|
|
|
55
|
+
identities.each { |id| pipe.zrangebyscore(RedisKeys.memory_history(id), first, '+inf') }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
identities.zip(raw).to_h do |id, members|
|
|
59
|
+
values = Array(members).to_h { |m| m.split(':', 2).map(&:to_i) }
|
|
60
|
+
[id, Array.new(SLOTS) { |i| [first + (i * SAMPLE_SECONDS), values[first + (i * SAMPLE_SECONDS)]] }]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def slot_for(time)
|
|
65
|
+
time.to_i - (time.to_i % SAMPLE_SECONDS)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -47,6 +47,7 @@ module Cogworker
|
|
|
47
47
|
def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
|
|
48
48
|
def process(identity) = "cogworker:process:#{identity}"
|
|
49
49
|
def workers(identity) = "cogworker:workers:#{identity}"
|
|
50
|
+
def memory_history(identity) = "cogworker:memory:#{identity}"
|
|
50
51
|
def in_progress(identity) = "#{IN_PROGRESS_PREFIX}#{identity}"
|
|
51
52
|
def signal(identity) = "cogworker:signal:#{identity}"
|
|
52
53
|
def periodic_running(pjid) = "periodic:running:#{pjid}"
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -53,6 +53,13 @@ module Cogworker
|
|
|
53
53
|
'connected_clients' => 'Connections', 'used_memory_human' => 'Memory Usage',
|
|
54
54
|
'used_memory_peak_human' => 'Peak Memory Usage'
|
|
55
55
|
}.freeze
|
|
56
|
+
# Runtime dependency gem name => card label, for the "Cogworker"
|
|
57
|
+
# section — read from `Gem.loaded_specs`, so it shows what this
|
|
58
|
+
# process actually loaded, not the gemspec's version constraint.
|
|
59
|
+
GEM_INFO_DEPENDENCIES = {
|
|
60
|
+
'redis' => 'redis gem', 'connection_pool' => 'connection_pool', 'rack' => 'Rack',
|
|
61
|
+
'zeitwerk' => 'Zeitwerk', 'fugit' => 'Fugit'
|
|
62
|
+
}.freeze
|
|
56
63
|
# Period switcher options for the "Runs per day" chart —
|
|
57
64
|
# `params['period']` (a plain query string, read fresh on every
|
|
58
65
|
# request) selects one of these by key. Ordered as displayed,
|
|
@@ -95,6 +102,9 @@ module Cogworker
|
|
|
95
102
|
# part of CONTENT_ID's own poll_div.
|
|
96
103
|
page_body += Layout.poll_div(REDIS_CONTENT_ID, request.script_name, 'overview/redis',
|
|
97
104
|
Overview.redis_section)
|
|
105
|
+
# Static (versions don't change while the process runs), so
|
|
106
|
+
# it needs no poll of its own.
|
|
107
|
+
page_body += Overview.gem_info_section
|
|
98
108
|
end
|
|
99
109
|
extra_head = layout == 'a' ? Overview.chart_head(request.script_name) : ''
|
|
100
110
|
Layout.wrap('Overview', page_body, script_name: request.script_name, extra_head: extra_head)
|
|
@@ -261,17 +271,32 @@ module Cogworker
|
|
|
261
271
|
# it pinned below the charts rather than above them.
|
|
262
272
|
def redis_section
|
|
263
273
|
info = Cogworker::Stats.new.redis_info
|
|
264
|
-
|
|
274
|
+
info_section('Redis', REDIS_INFO_FIELDS.to_h { |field, label| [label, info[field]] })
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The basics about the gem itself and what it's running on — the
|
|
278
|
+
# last block on the page, below the Redis grid.
|
|
279
|
+
def gem_info_section
|
|
280
|
+
spec = Gem.loaded_specs['cogworker']
|
|
281
|
+
values = { 'Version' => Cogworker::VERSION, 'Ruby' => "#{RUBY_VERSION} (#{RUBY_PLATFORM})" }
|
|
282
|
+
GEM_INFO_DEPENDENCIES.each { |name, label| values[label] = Gem.loaded_specs[name]&.version&.to_s }
|
|
283
|
+
values['License'] = spec&.license
|
|
284
|
+
info_section('Cogworker', values)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A titled grid of label/value cards; a nil value renders as "n/a".
|
|
288
|
+
def info_section(title, values)
|
|
289
|
+
cards = values.map do |label, value|
|
|
265
290
|
<<~HTML
|
|
266
291
|
<div class="card elev-sm" style="gap: 4px;">
|
|
267
292
|
<span class="card-kicker">#{Layout.h(label)}</span>
|
|
268
|
-
<span style="font-size:
|
|
293
|
+
<span style="font-size: 15px; font-family: var(--font-heading);">#{Layout.h(value || 'n/a')}</span>
|
|
269
294
|
</div>
|
|
270
295
|
HTML
|
|
271
296
|
end.join
|
|
272
297
|
<<~HTML
|
|
273
298
|
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px;">
|
|
274
|
-
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;"
|
|
299
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">#{Layout.h(title)}</h4>
|
|
275
300
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px;">#{cards}</div>
|
|
276
301
|
</section>
|
|
277
302
|
HTML
|
|
@@ -13,6 +13,7 @@ module Cogworker
|
|
|
13
13
|
# honest place to look at what's actually running right now.
|
|
14
14
|
module Workers
|
|
15
15
|
CONTENT_ID = 'workers-content'
|
|
16
|
+
SPARKLINE_HEIGHT_PX = 32
|
|
16
17
|
|
|
17
18
|
module_function
|
|
18
19
|
|
|
@@ -22,7 +23,8 @@ module Cogworker
|
|
|
22
23
|
if hx_request?
|
|
23
24
|
content
|
|
24
25
|
else
|
|
25
|
-
Layout.wrap('Workers', Layout.poll_div(CONTENT_ID, request.script_name, 'workers', content)
|
|
26
|
+
Layout.wrap('Workers', Layout.poll_div(CONTENT_ID, request.script_name, 'workers', content) +
|
|
27
|
+
Workers.sparkline_script,
|
|
26
28
|
script_name: request.script_name)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -90,7 +92,9 @@ module Cogworker
|
|
|
90
92
|
work_set = Cogworker::WorkSet.new.to_a
|
|
91
93
|
busy_counts = work_set.each_with_object(Hash.new(0)) { |(identity, *), h| h[identity] += 1 }
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
memory = Cogworker::MemoryHistory.series(processes.map(&:identity))
|
|
96
|
+
|
|
97
|
+
cards = process_cards(processes, busy_counts, memory, quiet_path, resume_path, stop_path)
|
|
94
98
|
|
|
95
99
|
work_rows = work_set.map do |identity, tid, work|
|
|
96
100
|
[Layout.h(identity), Layout.h(tid), Layout.h(work.job['jid']), Layout.h(work.queue),
|
|
@@ -134,19 +138,19 @@ module Cogworker
|
|
|
134
138
|
HTML
|
|
135
139
|
end
|
|
136
140
|
|
|
137
|
-
def process_cards(processes, busy_counts, quiet_path, resume_path, stop_path)
|
|
141
|
+
def process_cards(processes, busy_counts, memory, quiet_path, resume_path, stop_path)
|
|
138
142
|
if processes.empty?
|
|
139
143
|
return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">No worker processes ) +
|
|
140
144
|
'are reporting in right now.</p>'
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
items = processes.map do |p|
|
|
144
|
-
process_card(p, busy_counts[p.identity], quiet_path, resume_path, stop_path)
|
|
148
|
+
process_card(p, busy_counts[p.identity], memory[p.identity], quiet_path, resume_path, stop_path)
|
|
145
149
|
end.join
|
|
146
150
|
%(<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px;">#{items}</div>)
|
|
147
151
|
end
|
|
148
152
|
|
|
149
|
-
def process_card(process, busy, quiet_path, resume_path, stop_path)
|
|
153
|
+
def process_card(process, busy, memory_samples, quiet_path, resume_path, stop_path)
|
|
150
154
|
concurrency = process['concurrency'].to_i
|
|
151
155
|
pct = concurrency.positive? ? ((busy.to_f / concurrency) * 100).round : 0
|
|
152
156
|
quiet = [true, 'true'].include?(process['quiet'])
|
|
@@ -177,6 +181,7 @@ module Cogworker
|
|
|
177
181
|
<span>#{memory_cell(process['rss_kb'])}</span>
|
|
178
182
|
<span>#{beat_label(process.identity)}</span>
|
|
179
183
|
</div>
|
|
184
|
+
#{memory_sparkline(process.identity, memory_samples)}
|
|
180
185
|
<div style="height: 4px; border-radius: 2px; background: var(--color-neutral-800); overflow: hidden;">
|
|
181
186
|
<div style="height: 100%; border-radius: 2px; background: var(--color-accent); width: #{pct}%;"></div>
|
|
182
187
|
</div>
|
|
@@ -199,6 +204,152 @@ module Cogworker
|
|
|
199
204
|
end
|
|
200
205
|
end
|
|
201
206
|
|
|
207
|
+
# The last 24h of this process's RSS (MemoryHistory), always the
|
|
208
|
+
# full 24h wide — one MemoryHistory slot per x position, broken
|
|
209
|
+
# wherever a slot has no sample (before the process started, or
|
|
210
|
+
# while Redis was away). An inline SVG rather than a Chart.js chart:
|
|
211
|
+
# these cards are re-rendered by every htmx poll, and a Chart.js
|
|
212
|
+
# instance per swap leaks (see CLAUDE.md's Overview charts).
|
|
213
|
+
# Stretched to the card's width with `preserveAspectRatio="none"`,
|
|
214
|
+
# so it draws only lines and fills (`non-scaling-stroke` keeps the
|
|
215
|
+
# line's width) — no point markers, which that stretch would
|
|
216
|
+
# distort; the hover marker and tooltip are HTML, positioned by
|
|
217
|
+
# `sparkline_script` from the data attributes.
|
|
218
|
+
def memory_sparkline(identity, slots)
|
|
219
|
+
slots ||= Cogworker::MemoryHistory.series([identity])[identity]
|
|
220
|
+
values = slots.map(&:last)
|
|
221
|
+
low, high = sparkline_range(values.compact)
|
|
222
|
+
<<~HTML
|
|
223
|
+
<div class="cw-spark" data-cw-spark-id="#{Layout.h(identity)}" data-cw-spark="#{Layout.h(JSON.generate(values))}"
|
|
224
|
+
data-cw-spark-start="#{slots.first.first}" data-cw-spark-step="#{Cogworker::MemoryHistory::SAMPLE_SECONDS}"
|
|
225
|
+
data-cw-spark-min="#{low}" data-cw-spark-max="#{high}"
|
|
226
|
+
style="position: relative; height: #{SPARKLINE_HEIGHT_PX}px; cursor: crosshair; background: var(--color-bg); border-radius: 3px;">
|
|
227
|
+
<span class="cw-spark-label" style="position: absolute; top: 3px; left: 6px; font-size: 10px; line-height: 1; color: var(--color-neutral-500); pointer-events: none;">Memory · 24h</span>
|
|
228
|
+
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="Memory, last 24 hours"
|
|
229
|
+
style="display: block; width: 100%; height: 100%; overflow: visible;">
|
|
230
|
+
#{sparkline_segments(values, low, high)}
|
|
231
|
+
</svg>
|
|
232
|
+
</div>
|
|
233
|
+
HTML
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# One line + fill per run of consecutive slots that have a sample,
|
|
237
|
+
# each point at its slot's center; a lone sample gets a short
|
|
238
|
+
# horizontal stroke across its own slot, so it's still visible.
|
|
239
|
+
def sparkline_segments(values, low, high)
|
|
240
|
+
width = 100.0 / values.size
|
|
241
|
+
runs = values.each_with_index.chunk_while { |(a, _), (b, _)| a.nil? == b.nil? }.reject { |run| run[0][0].nil? }
|
|
242
|
+
runs.map do |run|
|
|
243
|
+
points = run.map { |kb, i| [(i + 0.5) * width, (1 - ((kb - low) / (high - low))) * 100] }
|
|
244
|
+
sparkline_segment(points, width)
|
|
245
|
+
end.join
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def sparkline_segment(points, width)
|
|
249
|
+
if points.one?
|
|
250
|
+
x, y = points.first
|
|
251
|
+
points = [[x - (width / 2), y], [x + (width / 2), y]]
|
|
252
|
+
end
|
|
253
|
+
coords = points.map { |x, y| format('%<x>.2f,%<y>.2f', x: x, y: y) }.join(' ')
|
|
254
|
+
area = format('%<x0>.2f,100 %<coords>s %<x1>.2f,100', x0: points.first[0], coords: coords, x1: points.last[0])
|
|
255
|
+
<<~HTML
|
|
256
|
+
<polygon points="#{area}" style="fill: var(--color-accent); opacity: 0.15;"></polygon>
|
|
257
|
+
<polyline points="#{coords}" vector-effect="non-scaling-stroke" style="fill: none; stroke: var(--color-accent); stroke-width: 1.5;"></polyline>
|
|
258
|
+
HTML
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A little headroom above and below, so the line never hugs the
|
|
262
|
+
# card's edges; a flat series gets a range around its one value.
|
|
263
|
+
def sparkline_range(values)
|
|
264
|
+
return [0.0, 1.0] if values.empty?
|
|
265
|
+
|
|
266
|
+
low = values.min.to_f
|
|
267
|
+
high = values.max.to_f
|
|
268
|
+
pad = [(high - low) * 0.1, 1024].max
|
|
269
|
+
[(low - pad).clamp(0, nil), high + pad]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Hover readout for every `.cw-spark` on the page: the sample
|
|
273
|
+
# nearest the pointer, as a marker line + dot and a tooltip with
|
|
274
|
+
# its value and (local) time, hiding the "Memory" label meanwhile.
|
|
275
|
+
# Delegated from `document`, so it keeps working on cards htmx
|
|
276
|
+
# swaps in; a poll that replaces the card under the pointer
|
|
277
|
+
# re-shows the readout on the new one.
|
|
278
|
+
def sparkline_script
|
|
279
|
+
<<~HTML
|
|
280
|
+
<script>
|
|
281
|
+
(function () {
|
|
282
|
+
var hover = null;
|
|
283
|
+
function part(el, cls, css) {
|
|
284
|
+
var node = el.querySelector('.' + cls);
|
|
285
|
+
if (!node) {
|
|
286
|
+
node = document.createElement('div');
|
|
287
|
+
node.className = cls;
|
|
288
|
+
node.style.cssText = 'position: absolute; pointer-events: none; ' + css;
|
|
289
|
+
el.appendChild(node);
|
|
290
|
+
}
|
|
291
|
+
return node;
|
|
292
|
+
}
|
|
293
|
+
function label(el, visible) {
|
|
294
|
+
var node = el.querySelector('.cw-spark-label');
|
|
295
|
+
if (node) node.style.visibility = visible ? '' : 'hidden';
|
|
296
|
+
}
|
|
297
|
+
function hide(el) {
|
|
298
|
+
el.querySelectorAll('.cw-spark-line, .cw-spark-dot, .cw-spark-tip').forEach(function (n) { n.remove(); });
|
|
299
|
+
label(el, true);
|
|
300
|
+
}
|
|
301
|
+
function show(el, clientX) {
|
|
302
|
+
var values = JSON.parse(el.getAttribute('data-cw-spark'));
|
|
303
|
+
var start = parseInt(el.getAttribute('data-cw-spark-start'), 10);
|
|
304
|
+
var step = parseInt(el.getAttribute('data-cw-spark-step'), 10);
|
|
305
|
+
var lo = parseFloat(el.getAttribute('data-cw-spark-min'));
|
|
306
|
+
var hi = parseFloat(el.getAttribute('data-cw-spark-max'));
|
|
307
|
+
var rect = el.getBoundingClientRect();
|
|
308
|
+
label(el, false);
|
|
309
|
+
var frac = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
|
310
|
+
var i = Math.min(Math.floor(frac * values.length), values.length - 1);
|
|
311
|
+
var x = (i + 0.5) / values.length * rect.width;
|
|
312
|
+
var kb = values[i];
|
|
313
|
+
part(el, 'cw-spark-line', 'top: 0; bottom: 0; width: 1px; background: var(--color-neutral-500);').style.left = x + 'px';
|
|
314
|
+
var dot = part(el, 'cw-spark-dot', 'width: 6px; height: 6px; border-radius: 50%; margin: -3px 0 0 -3px; background: var(--color-accent);');
|
|
315
|
+
dot.style.display = kb === null ? 'none' : '';
|
|
316
|
+
dot.style.left = x + 'px';
|
|
317
|
+
if (kb !== null) dot.style.top = (1 - (kb - lo) / (hi - lo)) * rect.height + 'px';
|
|
318
|
+
var tip = part(el, 'cw-spark-tip', 'bottom: calc(100% + 4px); z-index: 10; white-space: nowrap; font-size: 11px; ' +
|
|
319
|
+
'padding: 3px 6px; border-radius: 4px; background: var(--color-surface); color: var(--color-text); ' +
|
|
320
|
+
'box-shadow: var(--shadow-md); border: 1px solid var(--color-neutral-800);');
|
|
321
|
+
var at = new Date((start + i * step) * 1000);
|
|
322
|
+
var when = window.cogworkerFormatTime ? window.cogworkerFormatTime(at) : at.toLocaleString();
|
|
323
|
+
tip.textContent = (kb === null ? 'no data' : (kb / 1024).toFixed(1) + 'M') + ' · ' + when;
|
|
324
|
+
tip.style.left = x + 'px';
|
|
325
|
+
tip.style.transform = 'translateX(-' + (frac * 100) + '%)';
|
|
326
|
+
}
|
|
327
|
+
function find(id) {
|
|
328
|
+
var all = document.querySelectorAll('.cw-spark');
|
|
329
|
+
for (var i = 0; i < all.length; i++) if (all[i].getAttribute('data-cw-spark-id') === id) return all[i];
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
document.addEventListener('mousemove', function (evt) {
|
|
333
|
+
var el = evt.target.closest && evt.target.closest('.cw-spark');
|
|
334
|
+
if (hover && (!el || el.getAttribute('data-cw-spark-id') !== hover.id)) {
|
|
335
|
+
var old = find(hover.id);
|
|
336
|
+
if (old) hide(old);
|
|
337
|
+
hover = null;
|
|
338
|
+
}
|
|
339
|
+
if (!el) return;
|
|
340
|
+
hover = { id: el.getAttribute('data-cw-spark-id'), x: evt.clientX };
|
|
341
|
+
show(el, evt.clientX);
|
|
342
|
+
});
|
|
343
|
+
document.addEventListener('htmx:afterSwap', function () {
|
|
344
|
+
if (!hover) return;
|
|
345
|
+
var el = find(hover.id);
|
|
346
|
+
if (el) show(el, hover.x);
|
|
347
|
+
});
|
|
348
|
+
})();
|
|
349
|
+
</script>
|
|
350
|
+
HTML
|
|
351
|
+
end
|
|
352
|
+
|
|
202
353
|
# `rss_kb` is absent on any process whose heartbeat predates this
|
|
203
354
|
# field (or that never measured cleanly — see
|
|
204
355
|
# `Heartbeat#current_rss_kb`) — render "n/a" rather than "0M".
|
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vdonec
|
|
@@ -233,6 +233,7 @@ files:
|
|
|
233
233
|
- lib/cogworker/logging.rb
|
|
234
234
|
- lib/cogworker/lua_script.rb
|
|
235
235
|
- lib/cogworker/manager.rb
|
|
236
|
+
- lib/cogworker/memory_history.rb
|
|
236
237
|
- lib/cogworker/middleware/chain.rb
|
|
237
238
|
- lib/cogworker/owned_key.rb
|
|
238
239
|
- lib/cogworker/periodic/claim.lua
|