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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef260933f908739609343cb6b72e5fbbe75a01d0c93dd8fba5d1c4983e807f26
4
- data.tar.gz: 22b576f3c302378c1ca2e8075893c3c4b9404329f6dacc22c281ad4b7c31f688
3
+ metadata.gz: db7056e0bc7a73b9ed86025627dc3ab891e971f13797fb253ec2ea8df15362f9
4
+ data.tar.gz: bd4b8a4f65b9538284a8d8682513fd868c1e8bfbee7b73709016ae7826ed38cb
5
5
  SHA512:
6
- metadata.gz: a48c5530445af6a80846ef5313202f88a3638ecdb33614e7f86baba87c33c4e214add29febf257e025cdf1921ad76bc35140cd91828101aa556a4fb0a461e662
7
- data.tar.gz: f6b2cdaef9cf94adb3823d29cffca99d50697a7553dc6178357fb6cf435ba8fb82320be291e1b04bd5075ab05ad2cf86cb5b2ed67a4f6420e20283360ec399f0
6
+ metadata.gz: 9a9e6c877cf0d62f2cadece94b9ea07c5d9c3f0386ce914f0a0132825de5128da1a26494aa1ad4866f5ef02ce5f8f8d52e1f009fb93583a32f873652fa4407b6
7
+ data.tar.gz: f06acea355f8898b7c0fdc3558c8ea76576e16f5070182cb30e4c883a87226b96ef3454594b4c8deab8e99a0862a2b951d71efb8f237d079205517fd4e076f51
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
@@ -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
- # Polled from the browser (see `grid_script`'s `refreshRows`) to
37
- # keep the grid live without a full-page/htmx fragment reload,
38
- # which would tear down and rebuild the AG Grid instance (and lose
39
- # its sort/filter/scroll state) on every tick.
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
- jid = params['jid'].to_s
43
- entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
44
- entries = entries.select { |e| e['jid'] == jid } unless jid.empty?
45
- [200, { 'content-type' => 'application/json' }, [JSON.generate(entries)]]
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
- # Loads up to `Cogworker::History.max_entries` rows for the chosen
59
- # filter in one shot and hands them to AG Grid, which does its own
60
- # client-side sorting/per-column filtering/quick-search/pagination
61
- # from there — retention (`max_entries`) already bounds this to a
62
- # size AG Grid handles comfortably, so there's no need for the
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(entries, script_name, status, jid)}
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(entries, script_name, status, jid)
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(entries, script_name, status, jid)}
139
+ #{grid_script(script_name, status, jid)}
127
140
  HTML
128
141
  end
129
142
 
130
- def grid_script(entries, script_name, status, jid)
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
- function indexBacktraces() {
146
- backtraces = {};
147
- rowData.forEach(function (row) {
148
- if (!row.backtrace) return;
149
- var header = (row.error_class || 'Error') + ': ' + (row.error_message || '');
150
- backtraces[row.jid] = header + '\\n\\n' + row.backtrace.join('\\n');
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.jid); });
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
- // `valueGetter` stays a plain millisecond Integer — AG
232
- // Grid sorts/filters on that raw value, `valueFormatter`
233
- // only changes what's *displayed*, so numeric sort order
234
- // ("23ms" before "1m 5s") stays correct regardless of
235
- // formatting.
236
- valueGetter: function (p) { return Math.round((p.data.finished_at - p.data.started_at) * 1000); },
237
- valueFormatter: function (p) { return formatDuration(p.value); } },
238
- { headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter, cellRenderer: errorCellRenderer }
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
- rowData: rowData,
244
- defaultColDef: { sortable: true, filter: true, resizable: true, flex: 1 },
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 replaces just `rowData` in place rather than
257
- // reloading the fragment and tearing the grid instance down.
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
- fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
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.1
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