cogworker 0.1.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 +7 -0
- data/exe/cogworker +6 -0
- data/exe/cogworkerswarm +6 -0
- data/lib/cogworker/basic_fetch.rb +27 -0
- data/lib/cogworker/cli.rb +64 -0
- data/lib/cogworker/client.rb +61 -0
- data/lib/cogworker/component.rb +19 -0
- data/lib/cogworker/config.rb +84 -0
- data/lib/cogworker/config_loader.rb +22 -0
- data/lib/cogworker/heartbeat.rb +150 -0
- data/lib/cogworker/history/middleware.rb +18 -0
- data/lib/cogworker/history/storage.rb +81 -0
- data/lib/cogworker/history.rb +33 -0
- data/lib/cogworker/job.rb +75 -0
- data/lib/cogworker/job_record.rb +24 -0
- data/lib/cogworker/job_util.rb +60 -0
- data/lib/cogworker/launcher.rb +93 -0
- data/lib/cogworker/logging.rb +18 -0
- data/lib/cogworker/manager.rb +71 -0
- data/lib/cogworker/middleware/chain.rb +64 -0
- data/lib/cogworker/periodic/claim.lua +27 -0
- data/lib/cogworker/periodic/entry.rb +24 -0
- data/lib/cogworker/periodic/manager.rb +28 -0
- data/lib/cogworker/periodic/release_middleware.rb +27 -0
- data/lib/cogworker/periodic/ticker.rb +123 -0
- data/lib/cogworker/process.rb +36 -0
- data/lib/cogworker/process_set.rb +29 -0
- data/lib/cogworker/processor.rb +129 -0
- data/lib/cogworker/prometheus/exporter.rb +62 -0
- data/lib/cogworker/queue.rb +60 -0
- data/lib/cogworker/redis_connection.rb +37 -0
- data/lib/cogworker/redis_keys.rb +32 -0
- data/lib/cogworker/scheduled.rb +67 -0
- data/lib/cogworker/signals.rb +15 -0
- data/lib/cogworker/stats.rb +47 -0
- data/lib/cogworker/status/client_middleware.rb +19 -0
- data/lib/cogworker/status/server_middleware.rb +31 -0
- data/lib/cogworker/status/storage.rb +30 -0
- data/lib/cogworker/status/worker.rb +27 -0
- data/lib/cogworker/status.rb +40 -0
- data/lib/cogworker/swarm.rb +169 -0
- data/lib/cogworker/testing.rb +109 -0
- data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
- data/lib/cogworker/unique_jobs.rb +32 -0
- data/lib/cogworker/version.rb +5 -0
- data/lib/cogworker/web/action.rb +63 -0
- data/lib/cogworker/web/application.rb +62 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
- data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
- data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
- data/lib/cogworker/web/assets/htmx.min.js +1 -0
- data/lib/cogworker/web/assets/tailwind.css +1 -0
- data/lib/cogworker/web/layout.rb +352 -0
- data/lib/cogworker/web/router.rb +27 -0
- data/lib/cogworker/web/routes/busy.rb +99 -0
- data/lib/cogworker/web/routes/dead.rb +93 -0
- data/lib/cogworker/web/routes/history.rb +226 -0
- data/lib/cogworker/web/routes/periodic.rb +67 -0
- data/lib/cogworker/web/routes/queues.rb +101 -0
- data/lib/cogworker/web/routes/retries.rb +89 -0
- data/lib/cogworker/web/routes/save_session.rb +21 -0
- data/lib/cogworker/web/routes/scheduled.rb +49 -0
- data/lib/cogworker/web/routes/stats.rb +298 -0
- data/lib/cogworker/web/views.rb +25 -0
- data/lib/cogworker/web.rb +257 -0
- data/lib/cogworker/work.rb +19 -0
- data/lib/cogworker/work_set.rb +23 -0
- data/lib/cogworker/worker.rb +5 -0
- data/lib/cogworker/workers.rb +8 -0
- data/lib/cogworker.rb +114 -0
- metadata +300 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
module Routes
|
|
6
|
+
# Lists jobs that exhausted their retries, with delete and retry
|
|
7
|
+
# (requeue) actions.
|
|
8
|
+
module Dead
|
|
9
|
+
CONTENT_ID = 'dead-content'
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def registered(app)
|
|
14
|
+
app.get('/dead') do
|
|
15
|
+
content = Dead.render_content(request.script_name)
|
|
16
|
+
if hx_request?
|
|
17
|
+
content
|
|
18
|
+
else
|
|
19
|
+
Layout.wrap('Dead', Layout.poll_div(CONTENT_ID, request.script_name, 'dead', content),
|
|
20
|
+
script_name: request.script_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
app.post('/dead/delete') do
|
|
25
|
+
Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, params['raw']) }
|
|
26
|
+
Dead.respond(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
app.post('/dead/delete_all') do
|
|
30
|
+
Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
|
|
31
|
+
Dead.respond(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Puts the job back on its original queue for one more attempt —
|
|
35
|
+
# same "graduate out of this ZSET, back onto cogworker:queue:<q>"
|
|
36
|
+
# move `Routes::Retries#retry_now` does for `cogworker:retry`,
|
|
37
|
+
# and `Cogworker::Scheduled#graduate` does for both `cogworker:
|
|
38
|
+
# schedule`/`cogworker:retry`. `zrem` winning (not losing) is what
|
|
39
|
+
# actually gates the requeue: without it, two browser tabs (or a
|
|
40
|
+
# slow double-click) retrying the same entry at once could both
|
|
41
|
+
# see it and each push a copy. NOTE: for a single (non-Array)
|
|
42
|
+
# member, the `redis` gem's `#zrem` returns a **Boolean**, not the
|
|
43
|
+
# raw `1`/`0` integer reply — `== 1` would always be false here
|
|
44
|
+
# (`true == 1` is `false` in Ruby); check truthiness instead, the
|
|
45
|
+
# same way `Cogworker::Scheduled#graduate`'s `next unless won` does.
|
|
46
|
+
app.post('/dead/retry') do
|
|
47
|
+
raw = params['raw']
|
|
48
|
+
Cogworker.config.redis do |c|
|
|
49
|
+
if c.zrem(RedisKeys::DEAD, raw)
|
|
50
|
+
job = JSON.parse(raw)
|
|
51
|
+
c.sadd(RedisKeys::QUEUES, job['queue'])
|
|
52
|
+
c.lpush(RedisKeys.queue(job['queue']), raw)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
Dead.respond(self)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# After an action, htmx gets the refreshed fragment swapped into
|
|
60
|
+
# #dead-content in place; a plain form submission (no JS) falls back
|
|
61
|
+
# to a normal redirect back to the full page.
|
|
62
|
+
def respond(action)
|
|
63
|
+
if action.hx_request?
|
|
64
|
+
render_content(action.request.script_name)
|
|
65
|
+
else
|
|
66
|
+
action.redirect(Layout.path(action.request.script_name, 'dead'))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_content(script_name)
|
|
71
|
+
# Newest DiedAt first — `zrevrange` (`cogworker:dead`'s score is
|
|
72
|
+
# the epoch it died at), not `zrange`.
|
|
73
|
+
entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
|
|
74
|
+
delete_path = Layout.path(script_name, 'dead/delete')
|
|
75
|
+
retry_path = Layout.path(script_name, 'dead/retry')
|
|
76
|
+
rows = entries.map do |raw, score|
|
|
77
|
+
job = JSON.parse(raw)
|
|
78
|
+
[job['jid'], Layout.h(job['class']), Layout.time_tag(score),
|
|
79
|
+
Layout.form_button(retry_path, 'raw', raw, 'retry', hx_target: "##{CONTENT_ID}", variant: :primary),
|
|
80
|
+
Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
|
|
81
|
+
end
|
|
82
|
+
delete_all_path = Layout.path(script_name, 'dead/delete_all')
|
|
83
|
+
delete_all_button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{CONTENT_ID}",
|
|
84
|
+
variant: :danger)
|
|
85
|
+
%(<div class="mb-3 flex justify-end">#{delete_all_button}</div>) +
|
|
86
|
+
Layout.table(%w[JID Class DiedAt Retry Delete], rows, empty_message: 'No dead jobs.')
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Dead)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
module Routes
|
|
8
|
+
# NOTE: always spelled `Cogworker::History::*` below, fully
|
|
9
|
+
# qualified — a bare `History::Storage` from in here would resolve
|
|
10
|
+
# (via lexical nesting) to *this* module first, which has no
|
|
11
|
+
# `Storage`, not to the top-level `Cogworker::History`.
|
|
12
|
+
module History
|
|
13
|
+
STATUSES = %w[all success failed].freeze
|
|
14
|
+
# Vendored under assets/ag-grid/ (pinned to Community v32.3.9 — see
|
|
15
|
+
# CLAUDE.md) rather than fetched from jsdelivr, so this tab works
|
|
16
|
+
# fully offline like the rest of the Web UI.
|
|
17
|
+
AG_GRID_ASSETS = %w[ag-grid/ag-grid-community.min.js ag-grid/ag-grid.min.css
|
|
18
|
+
ag-grid/ag-theme-alpine.min.css].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def registered(app)
|
|
23
|
+
app.get('/history') do
|
|
24
|
+
status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
|
|
25
|
+
content = Routes::History.render_content(request.script_name, status)
|
|
26
|
+
if hx_request?
|
|
27
|
+
content
|
|
28
|
+
else
|
|
29
|
+
Layout.wrap('History', content, script_name: request.script_name,
|
|
30
|
+
extra_head: Routes::History.ag_grid_head(request.script_name))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Polled from the browser (see `grid_script`'s `refreshRows`) to
|
|
35
|
+
# keep the grid live without a full-page/htmx fragment reload,
|
|
36
|
+
# which would tear down and rebuild the AG Grid instance (and lose
|
|
37
|
+
# its sort/filter/scroll state) on every tick.
|
|
38
|
+
app.get('/history/data') do
|
|
39
|
+
status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
|
|
40
|
+
entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
|
|
41
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate(entries)]]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ag_grid_head(script_name)
|
|
46
|
+
js, grid_css, theme_css = AG_GRID_ASSETS.map { |rel| Layout.path(script_name, "assets/#{rel}") }
|
|
47
|
+
<<~HTML
|
|
48
|
+
<script src="#{js}"></script>
|
|
49
|
+
<link rel="stylesheet" href="#{grid_css}">
|
|
50
|
+
<link rel="stylesheet" href="#{theme_css}">
|
|
51
|
+
HTML
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Loads up to `Cogworker::History.max_entries` rows for the chosen
|
|
55
|
+
# filter in one shot and hands them to AG Grid, which does its own
|
|
56
|
+
# client-side sorting/per-column filtering/quick-search/pagination
|
|
57
|
+
# from there — retention (`max_entries`) already bounds this to a
|
|
58
|
+
# size AG Grid handles comfortably, so there's no need for the
|
|
59
|
+
# gem's own server-side paging on top of it.
|
|
60
|
+
def render_content(script_name, status)
|
|
61
|
+
entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
|
|
62
|
+
filters(script_name, status) + grid(entries, script_name, status)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filters(script_name, current_status)
|
|
66
|
+
links = STATUSES.map { |status| filter_link(script_name, status, active: status == current_status) }.join
|
|
67
|
+
%(<div class="mb-4 flex gap-2">#{links}</div>)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filter_link(script_name, status, active:)
|
|
71
|
+
classes = if active
|
|
72
|
+
'bg-indigo-600 text-white'
|
|
73
|
+
else
|
|
74
|
+
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
75
|
+
end
|
|
76
|
+
href = Layout.path(script_name, "history?status=#{status}")
|
|
77
|
+
%(<a href="#{href}" class="px-3 py-1 rounded-md text-sm font-medium #{classes}">#{Layout.h(status.capitalize)}</a>)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def grid(entries, script_name, status)
|
|
81
|
+
<<~HTML
|
|
82
|
+
<div id="history-grid" class="ag-theme-alpine" style="height: 70vh; width: 100%;"></div>
|
|
83
|
+
|
|
84
|
+
<dialog id="history-backtrace-dialog" class="rounded-lg p-0 max-w-2xl w-[90vw] bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
|
85
|
+
<div class="p-4">
|
|
86
|
+
<div class="flex justify-between items-center mb-3">
|
|
87
|
+
<h3 class="font-semibold">Backtrace</h3>
|
|
88
|
+
<button type="button" onclick="this.closest('dialog').close()"
|
|
89
|
+
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
|
90
|
+
</div>
|
|
91
|
+
<pre id="history-backtrace-content" class="text-xs whitespace-pre-wrap max-h-[60vh] overflow-y-auto bg-gray-50 dark:bg-gray-950 p-3 rounded border border-gray-200 dark:border-gray-800"></pre>
|
|
92
|
+
</div>
|
|
93
|
+
</dialog>
|
|
94
|
+
|
|
95
|
+
#{grid_script(entries, script_name, status)}
|
|
96
|
+
HTML
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def grid_script(entries, script_name, status)
|
|
100
|
+
data_url = Layout.path(script_name, "history/data?status=#{status}")
|
|
101
|
+
<<~HTML
|
|
102
|
+
<script>
|
|
103
|
+
(function () {
|
|
104
|
+
var dataUrl = #{Layout.json_for_script(data_url)};
|
|
105
|
+
var rowData = #{Layout.json_for_script(entries)};
|
|
106
|
+
var backtraces = {};
|
|
107
|
+
// The dialog leads with the error itself (class + message)
|
|
108
|
+
// and only then the backtrace — the grid's own Error column
|
|
109
|
+
// can be too narrow/truncated to read the full message, and
|
|
110
|
+
// clicking through to "just the backtrace" without it loses
|
|
111
|
+
// the one thing you're usually trying to look up.
|
|
112
|
+
function indexBacktraces() {
|
|
113
|
+
backtraces = {};
|
|
114
|
+
rowData.forEach(function (row) {
|
|
115
|
+
if (!row.backtrace) return;
|
|
116
|
+
var header = (row.error_class || 'Error') + ': ' + (row.error_message || '');
|
|
117
|
+
backtraces[row.jid] = header + '\\n\\n' + row.backtrace.join('\\n');
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
indexBacktraces();
|
|
121
|
+
|
|
122
|
+
window.cogworkerShowHistoryBacktrace = function (jid) {
|
|
123
|
+
document.getElementById('history-backtrace-content').textContent = backtraces[jid] || '(no backtrace)';
|
|
124
|
+
document.getElementById('history-backtrace-dialog').showModal();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function statusCellRenderer(p) {
|
|
128
|
+
var ok = p.value === 'success';
|
|
129
|
+
var classes = ok
|
|
130
|
+
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
|
131
|
+
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300';
|
|
132
|
+
return '<span class="px-2 py-0.5 rounded-full text-xs font-medium ' + classes + '">' + p.value + '</span>';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function errorValueGetter(p) {
|
|
136
|
+
return p.data.error_class ? (p.data.error_class + ': ' + p.data.error_message) : '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function errorCellRenderer(p) {
|
|
140
|
+
if (!p.data.backtrace) return p.value || '';
|
|
141
|
+
var span = document.createElement('span');
|
|
142
|
+
span.textContent = p.value;
|
|
143
|
+
span.className = 'text-red-700 dark:text-red-400 underline cursor-pointer';
|
|
144
|
+
span.title = 'Click to view backtrace';
|
|
145
|
+
span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data.jid); });
|
|
146
|
+
return span;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// "2h 34m 23s 23ms" — each unit above the smallest one
|
|
150
|
+
// present only shows up once it (or a bigger one) is
|
|
151
|
+
// non-zero, so a typical sub-second job just reads "23ms"
|
|
152
|
+
// rather than "0h 0m 0s 23ms".
|
|
153
|
+
function formatDuration(ms) {
|
|
154
|
+
ms = Math.max(0, Math.round(ms));
|
|
155
|
+
var h = Math.floor(ms / 3600000);
|
|
156
|
+
var m = Math.floor((ms % 3600000) / 60000);
|
|
157
|
+
var s = Math.floor((ms % 60000) / 1000);
|
|
158
|
+
var msRemainder = ms % 1000;
|
|
159
|
+
var parts = [];
|
|
160
|
+
if (h > 0) parts.push(h + 'h');
|
|
161
|
+
if (h > 0 || m > 0) parts.push(m + 'm');
|
|
162
|
+
if (h > 0 || m > 0 || s > 0) parts.push(s + 's');
|
|
163
|
+
parts.push(msRemainder + 'ms');
|
|
164
|
+
return parts.join(' ');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var columnDefs = [
|
|
168
|
+
{ field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170,
|
|
169
|
+
valueFormatter: function (p) { return window.cogworkerFormatTime(new Date(p.value * 1000)); } },
|
|
170
|
+
{ field: 'class', headerName: 'Class' },
|
|
171
|
+
{ field: 'queue', headerName: 'Queue' },
|
|
172
|
+
{ field: 'jid', headerName: 'JID', minWidth: 160 },
|
|
173
|
+
{ field: 'args', headerName: 'Args', minWidth: 200,
|
|
174
|
+
valueFormatter: function (p) { return JSON.stringify(p.value); } },
|
|
175
|
+
{ field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120 },
|
|
176
|
+
{ headerName: 'Duration', maxWidth: 160,
|
|
177
|
+
// `valueGetter` stays a plain millisecond Integer — AG
|
|
178
|
+
// Grid sorts/filters on that raw value, `valueFormatter`
|
|
179
|
+
// only changes what's *displayed*, so numeric sort order
|
|
180
|
+
// ("23ms" before "1m 5s") stays correct regardless of
|
|
181
|
+
// formatting.
|
|
182
|
+
valueGetter: function (p) { return Math.round((p.data.finished_at - p.data.started_at) * 1000); },
|
|
183
|
+
valueFormatter: function (p) { return formatDuration(p.value); } },
|
|
184
|
+
{ headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter, cellRenderer: errorCellRenderer }
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
var gridApi = agGrid.createGrid(document.getElementById('history-grid'), {
|
|
188
|
+
columnDefs: columnDefs,
|
|
189
|
+
rowData: rowData,
|
|
190
|
+
defaultColDef: { sortable: true, filter: true, resizable: true, flex: 1 },
|
|
191
|
+
pagination: true,
|
|
192
|
+
paginationPageSize: #{Cogworker::Web.history_per_page},
|
|
193
|
+
paginationPageSizeSelector: [10, 25, 50, 100]
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
197
|
+
document.getElementById('history-grid').classList.add('ag-theme-alpine-dark');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// AG Grid isn't htmx-swapped (unlike Busy/Stats/Queues), so
|
|
201
|
+
// it needs its own poll — gated by the same global toggle —
|
|
202
|
+
// that replaces just `rowData` in place rather than
|
|
203
|
+
// reloading the fragment and tearing the grid instance down.
|
|
204
|
+
function refreshRows() {
|
|
205
|
+
if (!window.cogworkerLiveUpdate) return;
|
|
206
|
+
fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
|
|
207
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
208
|
+
.then(function (data) {
|
|
209
|
+
if (!data) return;
|
|
210
|
+
rowData = data;
|
|
211
|
+
indexBacktraces();
|
|
212
|
+
gridApi.setGridOption('rowData', rowData);
|
|
213
|
+
})
|
|
214
|
+
.catch(function () {});
|
|
215
|
+
}
|
|
216
|
+
setInterval(refreshRows, #{Cogworker::Web.live_update_interval * 1000});
|
|
217
|
+
})();
|
|
218
|
+
</script>
|
|
219
|
+
HTML
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
Cogworker::Web.register(Cogworker::Web::Routes::History)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fugit'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
class Web
|
|
8
|
+
module Routes
|
|
9
|
+
# Read-only: lists every registered `config.periodic { |mgr|
|
|
10
|
+
# mgr.register(...) }` entry and when it last/next fires. Sourced
|
|
11
|
+
# entirely from Redis (`periodic:schedule`/`periodic:last_slot:<pjid>`),
|
|
12
|
+
# not from any in-process `Periodic::Manager` — those are only
|
|
13
|
+
# published once an actual worker process's `Periodic::Ticker` has
|
|
14
|
+
# booted (see `Ticker#publish_schedule!`), so a Web-UI-only process
|
|
15
|
+
# (no worker ever started) shows the empty state here, same as Busy
|
|
16
|
+
# shows no processes with no worker running.
|
|
17
|
+
module Periodic
|
|
18
|
+
CONTENT_ID = 'periodic-content'
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def registered(app)
|
|
23
|
+
app.get('/periodic') do
|
|
24
|
+
content = Routes::Periodic.render_content
|
|
25
|
+
if hx_request?
|
|
26
|
+
content
|
|
27
|
+
else
|
|
28
|
+
Layout.wrap('Periodic', Layout.poll_div(CONTENT_ID, request.script_name, 'periodic', content),
|
|
29
|
+
script_name: request.script_name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_content
|
|
35
|
+
schedule = Cogworker.config.redis { |c| c.hgetall(RedisKeys::PERIODIC_SCHEDULE) }
|
|
36
|
+
rows = schedule.map { |pjid, raw| row_for(pjid, JSON.parse(raw)) }
|
|
37
|
+
Layout.table(%w[Class Cron Args NextRun LastRun Unique], rows,
|
|
38
|
+
empty_message: 'Nothing registered yet (no worker process has booted the periodic ' \
|
|
39
|
+
'scheduler — periodic jobs are published to Redis by ' \
|
|
40
|
+
'Periodic::Ticker on worker startup, not by the Web UI process).')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def row_for(pjid, entry)
|
|
44
|
+
[Layout.h(entry['class']), Layout.h(entry['cron']), Layout.h(entry['args'].to_json),
|
|
45
|
+
next_run_tag(entry['cron']), last_run_tag(pjid), unique_cell(entry['unique'])]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def next_run_tag(cron)
|
|
49
|
+
Layout.time_tag(Fugit::Cron.parse(cron)&.next_time(Time.now)&.to_t)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
Layout.h('invalid cron')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def last_run_tag(pjid)
|
|
55
|
+
last_slot = Cogworker.config.redis { |c| c.get(RedisKeys.periodic_last_slot(pjid)) }
|
|
56
|
+
last_slot ? Layout.time_tag(last_slot.to_f) : %(<span class="text-gray-400 italic">never</span>)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unique_cell(unique)
|
|
60
|
+
unique.nil? || unique.empty? ? '' : Layout.badge(unique, variant: :warning)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Periodic)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
module Routes
|
|
6
|
+
# Lists every known queue with its size/latency, and a per-queue drill
|
|
7
|
+
# down page listing that queue's pending jobs.
|
|
8
|
+
module Queues
|
|
9
|
+
CONTENT_ID = 'queues-content'
|
|
10
|
+
QUEUE_CONTENT_ID = 'queue-content'
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def registered(app)
|
|
15
|
+
app.get('/queues') do
|
|
16
|
+
content = Queues.render_content(request.script_name)
|
|
17
|
+
if hx_request?
|
|
18
|
+
content
|
|
19
|
+
else
|
|
20
|
+
Layout.wrap('Queues', Layout.poll_div(CONTENT_ID, request.script_name, 'queues', content),
|
|
21
|
+
script_name: request.script_name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
app.get('/queues/:name') do
|
|
26
|
+
name = url_params('name')
|
|
27
|
+
content = Queues.render_queue_content(name, request.script_name)
|
|
28
|
+
if hx_request?
|
|
29
|
+
content
|
|
30
|
+
else
|
|
31
|
+
Layout.wrap("Queue: #{Layout.h(name)}",
|
|
32
|
+
Layout.poll_div(QUEUE_CONTENT_ID, request.script_name, "queues/#{name}", content),
|
|
33
|
+
script_name: request.script_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `raw` is the exact JSON string the job entry was pushed with —
|
|
38
|
+
# same "raw" identity `Routes::Dead`/`Routes::Retries` already key
|
|
39
|
+
# their own per-row delete off — so `Queue#delete` can `LREM` it
|
|
40
|
+
# back out of the list.
|
|
41
|
+
app.post('/queues/:name/delete') do
|
|
42
|
+
name = url_params('name')
|
|
43
|
+
Cogworker::Queue.new(name).delete(params['raw'])
|
|
44
|
+
Queues.respond(self, name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
app.post('/queues/:name/delete_all') do
|
|
48
|
+
name = url_params('name')
|
|
49
|
+
Cogworker::Queue.new(name).clear
|
|
50
|
+
Queues.respond(self, name)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# After an action, htmx gets the refreshed fragment swapped into
|
|
55
|
+
# #queue-content in place; a plain form submission (no JS) falls
|
|
56
|
+
# back to a normal redirect back to the queue's own page.
|
|
57
|
+
def respond(action, name)
|
|
58
|
+
if action.hx_request?
|
|
59
|
+
render_queue_content(name, action.request.script_name)
|
|
60
|
+
else
|
|
61
|
+
action.redirect(Layout.path(action.request.script_name, "queues/#{name}"))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_content(script_name)
|
|
66
|
+
names = Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
|
|
67
|
+
rows = names.map do |name|
|
|
68
|
+
q = Cogworker::Queue.new(name)
|
|
69
|
+
link = Layout.path(script_name, "queues/#{Layout.h(name)}")
|
|
70
|
+
[%(<a class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium" href="#{link}">#{Layout.h(name)}</a>),
|
|
71
|
+
q.size, q.latency.round(2)]
|
|
72
|
+
end
|
|
73
|
+
Layout.table(%w[Name Size Latency], rows, empty_message: 'No queues yet — push a job to create one.')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_queue_content(name, script_name)
|
|
77
|
+
delete_path = Layout.path(script_name, "queues/#{name}/delete")
|
|
78
|
+
rows = Cogworker::Queue.new(name).map do |r|
|
|
79
|
+
[r.jid, Layout.h(r.klass), Layout.h(r.args.to_s),
|
|
80
|
+
Layout.form_button(delete_path, 'raw', r.value, 'delete', hx_target: "##{QUEUE_CONTENT_ID}",
|
|
81
|
+
variant: :danger)]
|
|
82
|
+
end
|
|
83
|
+
delete_all_button(name, script_name, rows.empty?) +
|
|
84
|
+
Layout.table(%w[JID Class Args Delete], rows, empty_message: 'This queue is empty.')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Nothing to bulk-delete once the queue is already empty.
|
|
88
|
+
def delete_all_button(name, script_name, empty)
|
|
89
|
+
return '' if empty
|
|
90
|
+
|
|
91
|
+
delete_all_path = Layout.path(script_name, "queues/#{name}/delete_all")
|
|
92
|
+
button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{QUEUE_CONTENT_ID}",
|
|
93
|
+
variant: :danger)
|
|
94
|
+
%(<div class="mb-3 flex justify-end">#{button}</div>)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Queues)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
module Routes
|
|
6
|
+
# Lists jobs currently scheduled for retry, with delete and
|
|
7
|
+
# retry-now (skip the backoff) actions.
|
|
8
|
+
module Retries
|
|
9
|
+
CONTENT_ID = 'retries-content'
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def registered(app)
|
|
14
|
+
app.get('/retries') do
|
|
15
|
+
content = Retries.render_content(request.script_name)
|
|
16
|
+
if hx_request?
|
|
17
|
+
content
|
|
18
|
+
else
|
|
19
|
+
Layout.wrap('Retries', Layout.poll_div(CONTENT_ID, request.script_name, 'retries', content),
|
|
20
|
+
script_name: request.script_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
app.post('/retries/delete') do
|
|
25
|
+
Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, params['raw']) }
|
|
26
|
+
Retries.respond(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
app.post('/retries/retry_now') do
|
|
30
|
+
raw = params['raw']
|
|
31
|
+
Cogworker.config.redis do |c|
|
|
32
|
+
# For a single (non-Array) member, the `redis` gem's `#zrem`
|
|
33
|
+
# returns a **Boolean**, not the raw `1`/`0` integer reply —
|
|
34
|
+
# `== 1` is always false here (`true == 1` is `false` in
|
|
35
|
+
# Ruby); check truthiness instead, matching
|
|
36
|
+
# `Cogworker::Scheduled#graduate`'s `next unless won`.
|
|
37
|
+
if c.zrem(RedisKeys::RETRY, raw)
|
|
38
|
+
job = JSON.parse(raw)
|
|
39
|
+
c.sadd(RedisKeys::QUEUES, job['queue'])
|
|
40
|
+
c.lpush(RedisKeys.queue(job['queue']), raw)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
Retries.respond(self)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# After an action, htmx gets the refreshed fragment swapped into
|
|
48
|
+
# #retries-content in place; a plain form submission (no JS) falls
|
|
49
|
+
# back to a normal redirect back to the full page.
|
|
50
|
+
def respond(action)
|
|
51
|
+
if action.hx_request?
|
|
52
|
+
render_content(action.request.script_name)
|
|
53
|
+
else
|
|
54
|
+
action.redirect(Layout.path(action.request.script_name, 'retries'))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_content(script_name)
|
|
59
|
+
entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1) }
|
|
60
|
+
rows = entries.map do |raw|
|
|
61
|
+
job = JSON.parse(raw)
|
|
62
|
+
[job['jid'], Layout.h(job['class']), error_cell(job), delete_form(script_name, raw),
|
|
63
|
+
retry_now_form(script_name, raw)]
|
|
64
|
+
end
|
|
65
|
+
Layout.table(%w[JID Class Error Delete RetryNow], rows, empty_message: 'No retries pending.')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def error_cell(job)
|
|
69
|
+
<<~HTML
|
|
70
|
+
<div class="font-mono text-xs text-red-700 dark:text-red-400">#{Layout.h(job['error_class'])}</div>
|
|
71
|
+
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">#{Layout.h(job['error_message'])}</div>
|
|
72
|
+
HTML
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete_form(script_name, raw)
|
|
76
|
+
action = Layout.path(script_name, 'retries/delete')
|
|
77
|
+
Layout.form_button(action, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def retry_now_form(script_name, raw)
|
|
81
|
+
action = Layout.path(script_name, 'retries/retry_now')
|
|
82
|
+
Layout.form_button(action, 'raw', raw, 'retry now', hx_target: "##{CONTENT_ID}", variant: :primary)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Retries)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
module Routes
|
|
6
|
+
# Reserves POST /save_session as a real, always-present route — in
|
|
7
|
+
# practice an external auth middleware wrapping this app (via `Web.use`)
|
|
8
|
+
# typically intercepts this exact path itself before it ever reaches
|
|
9
|
+
# here (that's the whole point of it being a stable, bare, un-prefixed
|
|
10
|
+
# path an auth callback can bypass same-origin checks for). This is
|
|
11
|
+
# just a harmless default for when nothing else claims the path.
|
|
12
|
+
module SaveSession
|
|
13
|
+
def self.registered(app)
|
|
14
|
+
app.post('/save_session') { [200, { 'content-type' => 'text/plain' }, ['OK']] }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Cogworker::Web.register(Cogworker::Web::Routes::SaveSession)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
module Routes
|
|
6
|
+
# Lists jobs pushed with `perform_in`/`perform_at`, still waiting for
|
|
7
|
+
# their run-at time, with a delete action.
|
|
8
|
+
module Scheduled
|
|
9
|
+
CONTENT_ID = 'scheduled-content'
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def registered(app)
|
|
14
|
+
app.get('/scheduled') do
|
|
15
|
+
content = Scheduled.render_content(request.script_name)
|
|
16
|
+
if hx_request?
|
|
17
|
+
content
|
|
18
|
+
else
|
|
19
|
+
Layout.wrap('Scheduled', Layout.poll_div(CONTENT_ID, request.script_name, 'scheduled', content),
|
|
20
|
+
script_name: request.script_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
app.post('/scheduled/delete') do
|
|
25
|
+
Cogworker.config.redis { |c| c.zrem(RedisKeys::SCHEDULE, params['raw']) }
|
|
26
|
+
if hx_request?
|
|
27
|
+
Scheduled.render_content(request.script_name)
|
|
28
|
+
else
|
|
29
|
+
redirect Layout.path(request.script_name, 'scheduled')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_content(script_name)
|
|
35
|
+
entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
|
|
36
|
+
delete_path = Layout.path(script_name, 'scheduled/delete')
|
|
37
|
+
rows = entries.map do |raw, score|
|
|
38
|
+
job = JSON.parse(raw)
|
|
39
|
+
[job['jid'], Layout.h(job['class']), Layout.time_tag(score),
|
|
40
|
+
Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
|
|
41
|
+
end
|
|
42
|
+
Layout.table(%w[JID Class RunAt Delete], rows, empty_message: 'Nothing scheduled.')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Cogworker::Web.register(Cogworker::Web::Routes::Scheduled)
|