cogworker 0.2.1 → 0.2.2
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/version.rb +1 -1
- data/lib/cogworker/web/history_query.rb +181 -0
- data/lib/cogworker/web/routes/history.rb +111 -68
- 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: db7056e0bc7a73b9ed86025627dc3ab891e971f13797fb253ec2ea8df15362f9
|
|
4
|
+
data.tar.gz: bd4b8a4f65b9538284a8d8682513fd868c1e8bfbee7b73709016ae7826ed38cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a9e6c877cf0d62f2cadece94b9ea07c5d9c3f0386ce914f0a0132825de5128da1a26494aa1ad4866f5ef02ce5f8f8d52e1f009fb93583a32f873652fa4407b6
|
|
7
|
+
data.tar.gz: f06acea355f8898b7c0fdc3558c8ea76576e16f5070182cb30e4c883a87226b96ef3454594b4c8deab8e99a0862a2b951d71efb8f237d079205517fd4e076f51
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
# Server side of the History tab's AG Grid (Infinite Row Model): one
|
|
8
|
+
# block of rows at a time — `[rows, total]` for `offset`/`limit` —
|
|
9
|
+
# instead of handing the browser every retained entry up front.
|
|
10
|
+
#
|
|
11
|
+
# `sort`/`filters` arrive in AG Grid's own `sortModel`/`filterModel`
|
|
12
|
+
# shapes (`[{ 'colId' => ..., 'sort' => 'asc'|'desc' }]` /
|
|
13
|
+
# `{ colId => { 'filterType' => 'text'|'number', 'type' => ..., ... } }`,
|
|
14
|
+
# optionally combined via `operator`/`conditions`), and `COLUMNS` below
|
|
15
|
+
# computes each column's value the same way `Routes::History#grid_script`
|
|
16
|
+
# displays it, so a server-side filter matches what the viewer sees.
|
|
17
|
+
#
|
|
18
|
+
# Two paths:
|
|
19
|
+
# - no filters, sorted by Finished (the default): a plain `ZREVRANGE`/
|
|
20
|
+
# `ZRANGE` of just the requested slice, total from `ZCARD` — the
|
|
21
|
+
# everyday case stays O(limit), whatever `History.max_entries` is.
|
|
22
|
+
# - anything else (a column filter, `?jid=`, sorting by another column):
|
|
23
|
+
# a scan of the whole status ZSET in chunks, filtered/sorted in Ruby,
|
|
24
|
+
# then sliced. Bounded by retention (`History.retention_days`/
|
|
25
|
+
# `max_entries`), and only the requested slice ever leaves the server.
|
|
26
|
+
class HistoryQuery
|
|
27
|
+
SCAN_CHUNK = 1000
|
|
28
|
+
MAX_LIMIT = 1000
|
|
29
|
+
DEFAULT_SORT = 'finished_at'
|
|
30
|
+
|
|
31
|
+
COLUMNS = {
|
|
32
|
+
'finished_at' => ->(e) { e['finished_at'].to_f },
|
|
33
|
+
'class' => ->(e) { e['class'].to_s },
|
|
34
|
+
'queue' => ->(e) { e['queue'].to_s },
|
|
35
|
+
'jid' => ->(e) { e['jid'].to_s },
|
|
36
|
+
'args' => ->(e) { JSON.generate(e['args']) },
|
|
37
|
+
'status' => ->(e) { e['status'].to_s },
|
|
38
|
+
'duration' => ->(e) { ((e['finished_at'].to_f - e['started_at'].to_f) * 1000).round },
|
|
39
|
+
'error' => ->(e) { e['error_class'] ? "#{e['error_class']}: #{e['error_message']}" : '' }
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def initialize(status:, jid: '', sort: [], filters: {})
|
|
43
|
+
@key = Cogworker::History::Storage::LIST_KEYS.fetch(status, Cogworker::History::Storage::LIST_KEYS.fetch('all'))
|
|
44
|
+
@jid = jid.to_s
|
|
45
|
+
@sort = Array(sort).find { |s| s.is_a?(Hash) && COLUMNS.key?(s['colId']) }
|
|
46
|
+
@filters = filters.is_a?(Hash) ? filters.select { |col, _| COLUMNS.key?(col) } : {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(offset, limit)
|
|
50
|
+
offset = [offset.to_i, 0].max
|
|
51
|
+
limit = limit.to_i.clamp(0, MAX_LIMIT)
|
|
52
|
+
fast_path? ? fetch_slice(offset, limit) : fetch_scanned(offset, limit)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def sort_col
|
|
58
|
+
@sort ? @sort['colId'] : DEFAULT_SORT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ascending?
|
|
62
|
+
@sort && @sort['sort'] == 'asc'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fast_path?
|
|
66
|
+
@jid.empty? && @filters.empty? && sort_col == DEFAULT_SORT
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_slice(offset, limit)
|
|
70
|
+
return [[], Cogworker.config.redis { |c| c.zcard(@key) }] if limit.zero?
|
|
71
|
+
|
|
72
|
+
stop = offset + limit - 1
|
|
73
|
+
total, raw = Cogworker.config.redis do |c|
|
|
74
|
+
[c.zcard(@key), ascending? ? c.zrange(@key, offset, stop) : c.zrevrange(@key, offset, stop)]
|
|
75
|
+
end
|
|
76
|
+
[raw.map { |r| JSON.parse(r) }, total]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch_scanned(offset, limit)
|
|
80
|
+
matched = scan.select { |entry| matches?(entry) }
|
|
81
|
+
[sort(matched)[offset, limit] || [], matched.size]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Ties stay newest-first (the scan's own order) either direction —
|
|
85
|
+
# the index tiebreaker also makes Ruby's unstable `sort_by` stable.
|
|
86
|
+
def sort(entries)
|
|
87
|
+
getter = COLUMNS.fetch(sort_col)
|
|
88
|
+
keyed = entries.each_with_index.map { |entry, i| [getter.call(entry), i, entry] }
|
|
89
|
+
if ascending?
|
|
90
|
+
keyed.sort_by { |value, i, _| [value, i] }.map(&:last)
|
|
91
|
+
else
|
|
92
|
+
keyed.sort_by { |value, i, _| [value, -i] }.reverse.map(&:last)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def scan
|
|
97
|
+
needle = @jid.empty? ? nil : JSON.generate('jid' => @jid)[1..-2]
|
|
98
|
+
entries = []
|
|
99
|
+
Cogworker.config.redis do |c|
|
|
100
|
+
start = 0
|
|
101
|
+
loop do
|
|
102
|
+
chunk = c.zrevrange(@key, start, start + SCAN_CHUNK - 1)
|
|
103
|
+
chunk.each do |raw|
|
|
104
|
+
next if needle && !raw.include?(needle) # cheap pre-check before parsing
|
|
105
|
+
|
|
106
|
+
entry = JSON.parse(raw)
|
|
107
|
+
entries << entry if @jid.empty? || entry['jid'] == @jid
|
|
108
|
+
end
|
|
109
|
+
break if chunk.size < SCAN_CHUNK
|
|
110
|
+
|
|
111
|
+
start += SCAN_CHUNK
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
entries
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def matches?(entry)
|
|
118
|
+
@filters.all? { |col, model| model_matches?(model, COLUMNS.fetch(col).call(entry)) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def model_matches?(model, value)
|
|
122
|
+
return true unless model.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
if model['conditions'].is_a?(Array)
|
|
125
|
+
results = model['conditions'].map { |cond| condition_matches?(model['filterType'], cond, value) }
|
|
126
|
+
model['operator'] == 'OR' ? results.any? : results.all?
|
|
127
|
+
else
|
|
128
|
+
condition_matches?(model['filterType'], model, value)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def condition_matches?(filter_type, cond, value)
|
|
133
|
+
return true unless cond.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
case cond['filterType'] || filter_type
|
|
136
|
+
when 'number' then number_matches?(cond, value)
|
|
137
|
+
else text_matches?(cond, value.to_s)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# AG Grid's default text filter semantics: case-insensitive.
|
|
142
|
+
def text_matches?(cond, value)
|
|
143
|
+
value = value.downcase
|
|
144
|
+
needle = cond['filter'].to_s.downcase
|
|
145
|
+
case cond['type']
|
|
146
|
+
when 'contains' then value.include?(needle)
|
|
147
|
+
when 'notContains' then !value.include?(needle)
|
|
148
|
+
when 'equals' then value == needle
|
|
149
|
+
when 'notEqual' then value != needle
|
|
150
|
+
when 'startsWith' then value.start_with?(needle)
|
|
151
|
+
when 'endsWith' then value.end_with?(needle)
|
|
152
|
+
when 'blank' then value.empty?
|
|
153
|
+
when 'notBlank' then !value.empty?
|
|
154
|
+
else true
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# AG Grid's default number filter semantics (`inRange` exclusive).
|
|
159
|
+
def number_matches?(cond, value)
|
|
160
|
+
return value.nil? if cond['type'] == 'blank'
|
|
161
|
+
return !value.nil? if cond['type'] == 'notBlank'
|
|
162
|
+
|
|
163
|
+
target = Float(cond['filter'], exception: false)
|
|
164
|
+
return true if target.nil?
|
|
165
|
+
|
|
166
|
+
case cond['type']
|
|
167
|
+
when 'equals' then value == target
|
|
168
|
+
when 'notEqual' then value != target
|
|
169
|
+
when 'lessThan' then value < target
|
|
170
|
+
when 'lessThanOrEqual' then value <= target
|
|
171
|
+
when 'greaterThan' then value > target
|
|
172
|
+
when 'greaterThanOrEqual' then value >= target
|
|
173
|
+
when 'inRange'
|
|
174
|
+
to = Float(cond['filterTo'], exception: false)
|
|
175
|
+
to.nil? || (value > target && value < to)
|
|
176
|
+
else true
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -33,19 +33,35 @@ module Cogworker
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
36
|
+
# The grid's Infinite Row Model datasource (`grid_script`'s
|
|
37
|
+
# `getRows`) — one block of rows per call, sorted/filtered
|
|
38
|
+
# server-side by `Web::HistoryQuery`, so the page never ships
|
|
39
|
+
# every retained entry to the browser. Also what live updates
|
|
40
|
+
# re-hit (`refreshInfiniteCache`), for just the blocks on screen.
|
|
40
41
|
app.get('/history/data') do
|
|
41
42
|
status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
query = Cogworker::Web::HistoryQuery.new(
|
|
44
|
+
status: status, jid: params['jid'].to_s,
|
|
45
|
+
sort: Routes::History.parse_json_param(params['sort'], []),
|
|
46
|
+
filters: Routes::History.parse_json_param(params['filter'], {})
|
|
47
|
+
)
|
|
48
|
+
start = params['start'].to_i
|
|
49
|
+
rows, total = query.fetch(start, params['end'].to_i - start)
|
|
50
|
+
[200, { 'content-type' => 'application/json' }, [JSON.generate('rows' => rows, 'total' => total)]]
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
# A malformed/missing `sort`/`filter` param just means "none" rather
|
|
55
|
+
# than a 500 — it's built by our own script, but still user input.
|
|
56
|
+
def parse_json_param(raw, fallback)
|
|
57
|
+
return fallback if raw.nil? || raw.empty?
|
|
58
|
+
|
|
59
|
+
parsed = JSON.parse(raw)
|
|
60
|
+
parsed.is_a?(fallback.class) ? parsed : fallback
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
fallback
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
def ag_grid_head(script_name)
|
|
50
66
|
js, grid_css, theme_css = AG_GRID_ASSETS.map { |rel| Layout.path(script_name, "assets/#{rel}") }
|
|
51
67
|
<<~HTML
|
|
@@ -55,12 +71,11 @@ module Cogworker
|
|
|
55
71
|
HTML
|
|
56
72
|
end
|
|
57
73
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
# gem's own server-side paging on top of it.
|
|
74
|
+
# Renders just the grid shell — rows are fetched block by block
|
|
75
|
+
# from `GET /history/data` (AG Grid's Infinite Row Model), with
|
|
76
|
+
# sorting, per-column filtering and pagination all done server-side
|
|
77
|
+
# (`Web::HistoryQuery`), so page weight no longer grows with
|
|
78
|
+
# `History.max_entries`.
|
|
64
79
|
#
|
|
65
80
|
# `jid` (optional — empty string means "not filtering") narrows this
|
|
66
81
|
# down to one job's own run history across every retry attempt (a
|
|
@@ -69,12 +84,10 @@ module Cogworker
|
|
|
69
84
|
# history, which only shows its *failures*, capped to the last few —
|
|
70
85
|
# see `Routes::Jobs#attempts_section`, which links here).
|
|
71
86
|
def render_content(script_name, status, jid = '')
|
|
72
|
-
entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
|
|
73
|
-
entries = entries.select { |e| e['jid'] == jid } unless jid.empty?
|
|
74
87
|
<<~HTML
|
|
75
88
|
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
76
89
|
#{page_header(script_name, status, jid)}
|
|
77
|
-
#{grid(
|
|
90
|
+
#{grid(script_name, status, jid)}
|
|
78
91
|
</div>
|
|
79
92
|
HTML
|
|
80
93
|
end
|
|
@@ -111,7 +124,7 @@ module Cogworker
|
|
|
111
124
|
HTML
|
|
112
125
|
end
|
|
113
126
|
|
|
114
|
-
def grid(
|
|
127
|
+
def grid(script_name, status, jid)
|
|
115
128
|
<<~HTML
|
|
116
129
|
<div id="history-grid" class="ag-theme-alpine" style="height: 70vh; width: 100%;"></div>
|
|
117
130
|
|
|
@@ -123,11 +136,11 @@ module Cogworker
|
|
|
123
136
|
<pre id="history-backtrace-content" class="mono" style="margin: 0; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 60vh; overflow-y: auto; background: var(--color-bg); border: 1px solid var(--color-divider); border-radius: var(--radius-sm); padding: var(--space-3);"></pre>
|
|
124
137
|
</dialog>
|
|
125
138
|
|
|
126
|
-
#{grid_script(
|
|
139
|
+
#{grid_script(script_name, status, jid)}
|
|
127
140
|
HTML
|
|
128
141
|
end
|
|
129
142
|
|
|
130
|
-
def grid_script(
|
|
143
|
+
def grid_script(script_name, status, jid)
|
|
131
144
|
query = "status=#{status}"
|
|
132
145
|
query += "&jid=#{CGI.escape(jid)}" unless jid.empty?
|
|
133
146
|
data_url = Layout.path(script_name, "history/data?#{query}")
|
|
@@ -135,25 +148,18 @@ module Cogworker
|
|
|
135
148
|
<script>
|
|
136
149
|
(function () {
|
|
137
150
|
var dataUrl = #{Layout.json_for_script(data_url)};
|
|
138
|
-
var rowData = #{Layout.json_for_script(entries)};
|
|
139
|
-
var backtraces = {};
|
|
140
151
|
// The dialog leads with the error itself (class + message)
|
|
141
152
|
// and only then the backtrace — the grid's own Error column
|
|
142
153
|
// can be too narrow/truncated to read the full message, and
|
|
143
154
|
// clicking through to "just the backtrace" without it loses
|
|
144
|
-
// the one thing you're usually trying to look up.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
indexBacktraces();
|
|
154
|
-
|
|
155
|
-
window.cogworkerShowHistoryBacktrace = function (jid) {
|
|
156
|
-
document.getElementById('history-backtrace-content').textContent = backtraces[jid] || '(no backtrace)';
|
|
155
|
+
// the one thing you're usually trying to look up. Takes the
|
|
156
|
+
// row itself (not a jid lookup): every retry attempt of one
|
|
157
|
+
// job shares its jid but has its own backtrace.
|
|
158
|
+
window.cogworkerShowHistoryBacktrace = function (row) {
|
|
159
|
+
var text = row && row.backtrace
|
|
160
|
+
? (row.error_class || 'Error') + ': ' + (row.error_message || '') + '\\n\\n' + row.backtrace.join('\\n')
|
|
161
|
+
: '(no backtrace)';
|
|
162
|
+
document.getElementById('history-backtrace-content').textContent = text;
|
|
157
163
|
document.getElementById('history-backtrace-dialog').showModal();
|
|
158
164
|
};
|
|
159
165
|
|
|
@@ -167,7 +173,10 @@ module Cogworker
|
|
|
167
173
|
// it builds from a plain string/DOM node.
|
|
168
174
|
var cssVar = function (name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); };
|
|
169
175
|
|
|
176
|
+
// Infinite Row Model: a row whose block is still loading has
|
|
177
|
+
// no `data` yet — every getter/renderer below guards for it.
|
|
170
178
|
function statusCellRenderer(p) {
|
|
179
|
+
if (!p.value) return '';
|
|
171
180
|
var ok = p.value === 'success';
|
|
172
181
|
var bg = cssVar(ok ? '--color-success-800' : '--color-danger-800');
|
|
173
182
|
var fg = cssVar(ok ? '--color-success-100' : '--color-danger-100');
|
|
@@ -185,18 +194,19 @@ module Cogworker
|
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
function errorValueGetter(p) {
|
|
197
|
+
if (!p.data) return '';
|
|
188
198
|
return p.data.error_class ? (p.data.error_class + ': ' + p.data.error_message) : '';
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
function errorCellRenderer(p) {
|
|
192
|
-
if (!p.data.backtrace) return p.value || '';
|
|
202
|
+
if (!p.data || !p.data.backtrace) return p.value || '';
|
|
193
203
|
var span = document.createElement('span');
|
|
194
204
|
span.textContent = p.value;
|
|
195
205
|
span.style.color = cssVar('--color-danger-300');
|
|
196
206
|
span.style.textDecoration = 'underline';
|
|
197
207
|
span.style.cursor = 'pointer';
|
|
198
208
|
span.title = 'Click to view backtrace';
|
|
199
|
-
span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data
|
|
209
|
+
span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data); });
|
|
200
210
|
return span;
|
|
201
211
|
}
|
|
202
212
|
|
|
@@ -218,33 +228,73 @@ module Cogworker
|
|
|
218
228
|
return parts.join(' ');
|
|
219
229
|
}
|
|
220
230
|
|
|
231
|
+
// Sorting/filtering run server-side (`Web::HistoryQuery`,
|
|
232
|
+
// keyed by each column's `colId`), so every `colId` here
|
|
233
|
+
// must match one of its `COLUMNS` — and compute the same
|
|
234
|
+
// value there as `valueGetter`/`valueFormatter` show here.
|
|
235
|
+
// Status has no column filter: the All/Success/Failed
|
|
236
|
+
// switch above already picks which ZSET is read at all.
|
|
237
|
+
var textFilter = { filter: 'agTextColumnFilter', filterParams: { buttons: ['reset'], debounceMs: 400 } };
|
|
221
238
|
var columnDefs = [
|
|
222
|
-
{ field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170,
|
|
223
|
-
valueFormatter: function (p) { return window.cogworkerFormatTime(new Date(p.value * 1000)); } },
|
|
224
|
-
{ field: 'class', headerName: 'Class' },
|
|
225
|
-
{ field: 'queue', headerName: 'Queue' },
|
|
226
|
-
{ field: 'jid', headerName: 'JID', minWidth: 160 },
|
|
227
|
-
{ field: 'args', headerName: 'Args', minWidth: 200,
|
|
228
|
-
valueFormatter: function (p) { return JSON.stringify(p.value); } },
|
|
229
|
-
{ field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120 },
|
|
230
|
-
{ headerName: 'Duration', maxWidth: 160,
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
{ colId: 'finished_at', field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170, filter: false,
|
|
240
|
+
valueFormatter: function (p) { return p.value == null ? '' : window.cogworkerFormatTime(new Date(p.value * 1000)); } },
|
|
241
|
+
Object.assign({ colId: 'class', field: 'class', headerName: 'Class' }, textFilter),
|
|
242
|
+
Object.assign({ colId: 'queue', field: 'queue', headerName: 'Queue' }, textFilter),
|
|
243
|
+
Object.assign({ colId: 'jid', field: 'jid', headerName: 'JID', minWidth: 160 }, textFilter),
|
|
244
|
+
Object.assign({ colId: 'args', field: 'args', headerName: 'Args', minWidth: 200,
|
|
245
|
+
valueFormatter: function (p) { return p.data ? JSON.stringify(p.value) : ''; } }, textFilter),
|
|
246
|
+
{ colId: 'status', field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120, filter: false },
|
|
247
|
+
{ colId: 'duration', headerName: 'Duration', maxWidth: 160,
|
|
248
|
+
filter: 'agNumberColumnFilter', filterParams: { buttons: ['reset'], debounceMs: 400 },
|
|
249
|
+
// `valueGetter` stays a plain millisecond Integer (the
|
|
250
|
+
// same unit the number filter compares against server-
|
|
251
|
+
// side), `valueFormatter` only changes what's displayed.
|
|
252
|
+
valueGetter: function (p) { return p.data ? Math.round((p.data.finished_at - p.data.started_at) * 1000) : null; },
|
|
253
|
+
valueFormatter: function (p) { return p.value == null ? '' : formatDuration(p.value); } },
|
|
254
|
+
Object.assign({ colId: 'error', headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter,
|
|
255
|
+
cellRenderer: errorCellRenderer }, textFilter)
|
|
239
256
|
];
|
|
240
257
|
|
|
258
|
+
function blockUrl(params) {
|
|
259
|
+
return dataUrl +
|
|
260
|
+
'&start=' + params.startRow + '&end=' + params.endRow +
|
|
261
|
+
'&sort=' + encodeURIComponent(JSON.stringify(params.sortModel || [])) +
|
|
262
|
+
'&filter=' + encodeURIComponent(JSON.stringify(params.filterModel || {}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
var datasource = {
|
|
266
|
+
getRows: function (params) {
|
|
267
|
+
fetch(blockUrl(params), { headers: { 'Accept': 'application/json' } })
|
|
268
|
+
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
|
269
|
+
.then(function (data) { params.successCallback(data.rows, data.total); })
|
|
270
|
+
.catch(function () { params.failCallback(); });
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
241
274
|
var gridApi = agGrid.createGrid(document.getElementById('history-grid'), {
|
|
242
275
|
columnDefs: columnDefs,
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
rowModelType: 'infinite',
|
|
277
|
+
datasource: datasource,
|
|
278
|
+
// One block == one page, and only that one block cached:
|
|
279
|
+
// `refreshInfiniteCache()` (the live poll below) re-fetches
|
|
280
|
+
// *every* cached block, so a bigger cache meant one extra
|
|
281
|
+
// request per page visited on every tick. Paging back to an
|
|
282
|
+
// earlier page costs a fresh request instead — fine, and
|
|
283
|
+
// it's fresher anyway.
|
|
284
|
+
cacheBlockSize: #{Cogworker::Web.history_per_page},
|
|
285
|
+
maxBlocksInCache: 1,
|
|
286
|
+
defaultColDef: { sortable: true, resizable: true, flex: 1 },
|
|
245
287
|
pagination: true,
|
|
246
288
|
paginationPageSize: #{Cogworker::Web.history_per_page},
|
|
247
|
-
paginationPageSizeSelector: [10, 25, 50, 100]
|
|
289
|
+
paginationPageSizeSelector: [10, 25, 50, 100],
|
|
290
|
+
// Keeps block size == page size when the viewer picks a
|
|
291
|
+
// different page size — otherwise one page would span
|
|
292
|
+
// several blocks, more than the 1-block cache holds.
|
|
293
|
+
onPaginationChanged: function (e) {
|
|
294
|
+
if (!e.newPageSize) return;
|
|
295
|
+
var size = e.api.paginationGetPageSize();
|
|
296
|
+
if (e.api.getGridOption('cacheBlockSize') !== size) e.api.setGridOption('cacheBlockSize', size);
|
|
297
|
+
}
|
|
248
298
|
});
|
|
249
299
|
|
|
250
300
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
@@ -253,19 +303,12 @@ module Cogworker
|
|
|
253
303
|
|
|
254
304
|
// AG Grid isn't htmx-swapped (unlike Workers/Stats/Overview), so
|
|
255
305
|
// it needs its own poll — gated by the same global toggle —
|
|
256
|
-
// that
|
|
257
|
-
//
|
|
306
|
+
// that re-fetches just the cached blocks in place (current
|
|
307
|
+
// sort/filter/page kept) rather than reloading the fragment
|
|
308
|
+
// and tearing the grid instance down.
|
|
258
309
|
function refreshRows() {
|
|
259
310
|
if (!window.cogworkerLiveUpdate) return;
|
|
260
|
-
|
|
261
|
-
.then(function (r) { return r.ok ? r.json() : null; })
|
|
262
|
-
.then(function (data) {
|
|
263
|
-
if (!data) return;
|
|
264
|
-
rowData = data;
|
|
265
|
-
indexBacktraces();
|
|
266
|
-
gridApi.setGridOption('rowData', rowData);
|
|
267
|
-
})
|
|
268
|
-
.catch(function () {});
|
|
311
|
+
gridApi.refreshInfiniteCache();
|
|
269
312
|
}
|
|
270
313
|
setInterval(refreshRows, #{Cogworker::Web.live_update_interval * 1000});
|
|
271
314
|
})();
|
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.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vdonec
|
|
@@ -263,6 +263,7 @@ files:
|
|
|
263
263
|
- lib/cogworker/web/assets/nocturne/styles.css
|
|
264
264
|
- lib/cogworker/web/assets/phosphor/Phosphor.woff2
|
|
265
265
|
- lib/cogworker/web/assets/phosphor/style.css
|
|
266
|
+
- lib/cogworker/web/history_query.rb
|
|
266
267
|
- lib/cogworker/web/layout.rb
|
|
267
268
|
- lib/cogworker/web/router.rb
|
|
268
269
|
- lib/cogworker/web/routes/history.rb
|