async-background 0.7.2 → 1.0.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/CHANGELOG.md +108 -1
- data/README.md +13 -7
- data/async-background.gemspec +3 -1
- data/lib/async/background/metrics.rb +3 -1
- data/lib/async/background/queue/schema.rb +6 -1
- data/lib/async/background/queue/sql.rb +15 -4
- data/lib/async/background/runner/schedule.rb +2 -0
- data/lib/async/background/runner.rb +17 -2
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +138 -0
- data/lib/async/background/web/assets.rb +726 -0
- data/lib/async/background/web/auth.rb +19 -0
- data/lib/async/background/web/configuration.rb +158 -0
- data/lib/async/background/web/cursor.rb +58 -0
- data/lib/async/background/web/errors.rb +14 -0
- data/lib/async/background/web/event_hub.rb +194 -0
- data/lib/async/background/web/metrics_reader.rb +96 -0
- data/lib/async/background/web/request.rb +36 -0
- data/lib/async/background/web/response.rb +85 -0
- data/lib/async/background/web/router.rb +30 -0
- data/lib/async/background/web/serializer.rb +154 -0
- data/lib/async/background/web/snapshot.rb +247 -0
- data/lib/async/background/web/sql.rb +88 -0
- data/lib/async/background/web/stream.rb +43 -0
- data/lib/async/background/web.rb +52 -0
- metadata +46 -2
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Background
|
|
8
|
+
module Web
|
|
9
|
+
module Assets
|
|
10
|
+
CSS = <<~CSS
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0f1115;
|
|
13
|
+
--panel: #161a20;
|
|
14
|
+
--panel-soft: #1c2128;
|
|
15
|
+
--border: #2a313b;
|
|
16
|
+
--text: #e6e8ec;
|
|
17
|
+
--text-dim: #98a2b3;
|
|
18
|
+
--accent: #4f8ef7;
|
|
19
|
+
--green: #4ade80;
|
|
20
|
+
--amber: #fbbf24;
|
|
21
|
+
--red: #f87171;
|
|
22
|
+
--blue: #60a5fa;
|
|
23
|
+
--gray: #94a3b8;
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
29
|
+
background: var(--bg);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
}
|
|
32
|
+
header {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 16px;
|
|
36
|
+
padding: 14px 24px;
|
|
37
|
+
border-bottom: 1px solid var(--border);
|
|
38
|
+
background: var(--panel);
|
|
39
|
+
}
|
|
40
|
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
41
|
+
header .meta { color: var(--text-dim); font-size: 12px; }
|
|
42
|
+
header .status-dot {
|
|
43
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
44
|
+
background: var(--gray);
|
|
45
|
+
display: inline-block; margin-right: 6px;
|
|
46
|
+
}
|
|
47
|
+
header .status-dot.ok { background: var(--green); }
|
|
48
|
+
header .status-dot.stale { background: var(--amber); }
|
|
49
|
+
header .status-dot.error { background: var(--red); }
|
|
50
|
+
main { padding: 16px 24px; }
|
|
51
|
+
.counts {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
54
|
+
gap: 12px;
|
|
55
|
+
margin-bottom: 20px;
|
|
56
|
+
}
|
|
57
|
+
.count-card {
|
|
58
|
+
background: var(--panel);
|
|
59
|
+
border: 1px solid var(--border);
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
padding: 12px 14px;
|
|
62
|
+
}
|
|
63
|
+
.count-card .label { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
|
64
|
+
.count-card .value { font-size: 24px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
|
65
|
+
.count-card.executing .value { color: var(--blue); }
|
|
66
|
+
.count-card.claimed .value { color: var(--amber); }
|
|
67
|
+
.count-card.pending .value { color: var(--text); }
|
|
68
|
+
.count-card.done .value { color: var(--green); }
|
|
69
|
+
.count-card.failed .value { color: var(--red); }
|
|
70
|
+
.totals {
|
|
71
|
+
display: grid;
|
|
72
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
73
|
+
gap: 12px;
|
|
74
|
+
margin-bottom: 20px;
|
|
75
|
+
}
|
|
76
|
+
.total-card {
|
|
77
|
+
background: var(--panel-soft);
|
|
78
|
+
border: 1px solid var(--border);
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
padding: 10px 12px;
|
|
81
|
+
}
|
|
82
|
+
.total-card .label { color: var(--text-dim); font-size: 11px; }
|
|
83
|
+
.total-card .value { font-variant-numeric: tabular-nums; font-size: 18px; margin-top: 2px; }
|
|
84
|
+
nav.tabs {
|
|
85
|
+
display: flex;
|
|
86
|
+
gap: 2px;
|
|
87
|
+
border-bottom: 1px solid var(--border);
|
|
88
|
+
margin-bottom: 12px;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
}
|
|
91
|
+
nav.tabs button {
|
|
92
|
+
background: transparent;
|
|
93
|
+
color: var(--text-dim);
|
|
94
|
+
border: none;
|
|
95
|
+
border-bottom: 2px solid transparent;
|
|
96
|
+
padding: 10px 14px;
|
|
97
|
+
font: inherit;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
border-radius: 0;
|
|
100
|
+
}
|
|
101
|
+
nav.tabs button:hover { color: var(--text); }
|
|
102
|
+
nav.tabs button.active {
|
|
103
|
+
color: var(--text);
|
|
104
|
+
border-bottom-color: var(--accent);
|
|
105
|
+
}
|
|
106
|
+
nav.tabs button .badge {
|
|
107
|
+
display: inline-block;
|
|
108
|
+
background: var(--panel-soft);
|
|
109
|
+
color: var(--text-dim);
|
|
110
|
+
border-radius: 10px;
|
|
111
|
+
padding: 1px 8px;
|
|
112
|
+
font-size: 11px;
|
|
113
|
+
margin-left: 6px;
|
|
114
|
+
font-variant-numeric: tabular-nums;
|
|
115
|
+
}
|
|
116
|
+
table {
|
|
117
|
+
width: 100%;
|
|
118
|
+
border-collapse: collapse;
|
|
119
|
+
background: var(--panel);
|
|
120
|
+
border: 1px solid var(--border);
|
|
121
|
+
border-radius: 8px;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
th, td {
|
|
125
|
+
text-align: left;
|
|
126
|
+
padding: 9px 12px;
|
|
127
|
+
border-bottom: 1px solid var(--border);
|
|
128
|
+
vertical-align: top;
|
|
129
|
+
font-variant-numeric: tabular-nums;
|
|
130
|
+
}
|
|
131
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
132
|
+
th {
|
|
133
|
+
background: var(--panel-soft);
|
|
134
|
+
color: var(--text-dim);
|
|
135
|
+
font-weight: 500;
|
|
136
|
+
font-size: 11px;
|
|
137
|
+
text-transform: uppercase;
|
|
138
|
+
letter-spacing: .04em;
|
|
139
|
+
}
|
|
140
|
+
td.dim { color: var(--text-dim); }
|
|
141
|
+
td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
142
|
+
.empty {
|
|
143
|
+
padding: 32px;
|
|
144
|
+
text-align: center;
|
|
145
|
+
color: var(--text-dim);
|
|
146
|
+
background: var(--panel);
|
|
147
|
+
border: 1px solid var(--border);
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
}
|
|
150
|
+
.pagination {
|
|
151
|
+
display: flex;
|
|
152
|
+
gap: 8px;
|
|
153
|
+
margin-top: 12px;
|
|
154
|
+
}
|
|
155
|
+
button.btn {
|
|
156
|
+
background: var(--panel-soft);
|
|
157
|
+
color: var(--text);
|
|
158
|
+
border: 1px solid var(--border);
|
|
159
|
+
border-radius: 6px;
|
|
160
|
+
padding: 6px 12px;
|
|
161
|
+
font: inherit;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
}
|
|
164
|
+
button.btn:hover { border-color: var(--accent); }
|
|
165
|
+
button.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
166
|
+
.err-msg {
|
|
167
|
+
color: var(--red);
|
|
168
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
169
|
+
font-size: 12px;
|
|
170
|
+
max-width: 480px;
|
|
171
|
+
white-space: pre-wrap;
|
|
172
|
+
word-break: break-word;
|
|
173
|
+
}
|
|
174
|
+
.err-class { color: var(--amber); font-weight: 600; }
|
|
175
|
+
.args-cell pre {
|
|
176
|
+
margin: 0;
|
|
177
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
178
|
+
font-size: 12px;
|
|
179
|
+
color: var(--text-dim);
|
|
180
|
+
white-space: pre-wrap;
|
|
181
|
+
word-break: break-all;
|
|
182
|
+
max-width: 380px;
|
|
183
|
+
}
|
|
184
|
+
.args-redacted { color: var(--text-dim); font-style: italic; }
|
|
185
|
+
CSS
|
|
186
|
+
|
|
187
|
+
JS = <<~'JS'
|
|
188
|
+
(function () {
|
|
189
|
+
const state = {
|
|
190
|
+
config: null,
|
|
191
|
+
activeTab: 'executing',
|
|
192
|
+
data: { executing: [], claimed: [], pending: [], done: [], failed: [] },
|
|
193
|
+
cursors: { pending: null, done: null, failed: null },
|
|
194
|
+
counts: { executing: 0, claimed: 0, pending: 0, done: 0, failed: 0 },
|
|
195
|
+
totals: null,
|
|
196
|
+
overview: null,
|
|
197
|
+
dataVersion: null,
|
|
198
|
+
lastUpdate: null,
|
|
199
|
+
connection: 'connecting',
|
|
200
|
+
stream: null,
|
|
201
|
+
pollingTimer: null,
|
|
202
|
+
pollingInFlight: false,
|
|
203
|
+
listAbort: null,
|
|
204
|
+
listRequestId: 0,
|
|
205
|
+
listRefreshTimer: null,
|
|
206
|
+
listRefreshQueued: false,
|
|
207
|
+
listError: null
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
function initialBasePath() {
|
|
211
|
+
if (document.body && document.body.dataset.mountPath !== undefined) {
|
|
212
|
+
return document.body.dataset.mountPath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (document.currentScript && document.currentScript.src) {
|
|
216
|
+
const scriptUrl = new URL(document.currentScript.src, location.origin);
|
|
217
|
+
return scriptUrl.pathname.replace(/\/assets\/app\.js$/, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return location.pathname.replace(/\/$/, '');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const bootBasePath = initialBasePath();
|
|
224
|
+
|
|
225
|
+
function basePath() {
|
|
226
|
+
return state.config && state.config.mount_path !== undefined ?
|
|
227
|
+
state.config.mount_path : bootBasePath;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
class HttpError extends Error {
|
|
231
|
+
constructor(response) {
|
|
232
|
+
super('http ' + response.status);
|
|
233
|
+
this.name = 'HttpError';
|
|
234
|
+
this.status = response.status;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function api(path, params, signal) {
|
|
239
|
+
const url = new URL(basePath() + path, location.origin);
|
|
240
|
+
if (params) {
|
|
241
|
+
Object.keys(params).forEach((key) => {
|
|
242
|
+
if (params[key] !== null && params[key] !== undefined) {
|
|
243
|
+
url.searchParams.set(key, params[key]);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const response = await fetch(url.toString(), {
|
|
249
|
+
credentials: 'same-origin',
|
|
250
|
+
headers: { accept: 'application/json' },
|
|
251
|
+
signal: signal
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (!response.ok) throw new HttpError(response);
|
|
255
|
+
return response.json();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function loadConfig() {
|
|
259
|
+
state.config = await api('/api/config');
|
|
260
|
+
const title = document.getElementById('title');
|
|
261
|
+
if (title) title.textContent = state.config.title;
|
|
262
|
+
document.title = state.config.title + ' dashboard';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function refreshOverview() {
|
|
266
|
+
try {
|
|
267
|
+
applyOverview(await api('/api/overview'));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
setConnection('error');
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function refreshActiveList(reset) {
|
|
275
|
+
if (!state.config) return;
|
|
276
|
+
|
|
277
|
+
const tab = state.activeTab;
|
|
278
|
+
const requestId = ++state.listRequestId;
|
|
279
|
+
const cursor = !reset ? state.cursors[tab] : null;
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
|
|
282
|
+
if (state.listAbort) state.listAbort.abort();
|
|
283
|
+
state.listAbort = controller;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const params = { limit: state.config.list_limit };
|
|
287
|
+
if (cursor) params.cursor = cursor;
|
|
288
|
+
const payload = await api('/api/' + tab, params, controller.signal);
|
|
289
|
+
|
|
290
|
+
if (requestId !== state.listRequestId || tab !== state.activeTab) return;
|
|
291
|
+
|
|
292
|
+
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? payload : [];
|
|
293
|
+
const nextCursor = Array.isArray(payload.items) ? payload.next_cursor || null : null;
|
|
294
|
+
state.data[tab] = cursor ? state.data[tab].concat(items) : items;
|
|
295
|
+
state.cursors[tab] = nextCursor;
|
|
296
|
+
state.listError = null;
|
|
297
|
+
renderList();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error.name === 'AbortError') return;
|
|
300
|
+
if (requestId !== state.listRequestId || tab !== state.activeTab) return;
|
|
301
|
+
|
|
302
|
+
state.listError = error;
|
|
303
|
+
setConnection('error');
|
|
304
|
+
renderList();
|
|
305
|
+
} finally {
|
|
306
|
+
if (requestId === state.listRequestId) state.listAbort = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Coalesce a burst of queue changes into at most one list request at a
|
|
311
|
+
// time. The data_version snapshot is authoritative, so skipped
|
|
312
|
+
// intermediate renders do not lose state.
|
|
313
|
+
function scheduleActiveListRefresh() {
|
|
314
|
+
state.listRefreshQueued = true;
|
|
315
|
+
if (state.listRefreshTimer) return;
|
|
316
|
+
|
|
317
|
+
state.listRefreshTimer = setTimeout(async function () {
|
|
318
|
+
state.listRefreshTimer = null;
|
|
319
|
+
while (state.listRefreshQueued) {
|
|
320
|
+
state.listRefreshQueued = false;
|
|
321
|
+
await refreshActiveList(true);
|
|
322
|
+
}
|
|
323
|
+
}, 100);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyOverview(overview) {
|
|
327
|
+
state.overview = overview;
|
|
328
|
+
state.counts = overview.counts || state.counts;
|
|
329
|
+
state.dataVersion = overview.data_version;
|
|
330
|
+
state.totals = overview.metrics || null;
|
|
331
|
+
state.lastUpdate = Date.now();
|
|
332
|
+
setConnection('ok', false);
|
|
333
|
+
renderCounts();
|
|
334
|
+
renderTotals();
|
|
335
|
+
renderTabBadges();
|
|
336
|
+
renderHeader(overview);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function setConnection(connection, render) {
|
|
340
|
+
state.connection = connection;
|
|
341
|
+
if (render !== false) renderHeader(state.overview);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function renderHeader(overview) {
|
|
345
|
+
const dot = document.getElementById('status-dot');
|
|
346
|
+
const meta = document.getElementById('meta');
|
|
347
|
+
if (!dot || !meta) return;
|
|
348
|
+
|
|
349
|
+
const stateClass = state.connection === 'ok' ? 'ok' : state.connection === 'stale' ? 'stale' : 'error';
|
|
350
|
+
dot.className = 'status-dot ' + stateClass;
|
|
351
|
+
|
|
352
|
+
const parts = [];
|
|
353
|
+
if (state.connection === 'stale') parts.push('reconnecting');
|
|
354
|
+
if (state.connection === 'error') parts.push('connection error');
|
|
355
|
+
if (state.lastUpdate) parts.push('updated ' + relTime(state.lastUpdate) + ' ago');
|
|
356
|
+
if (state.dataVersion !== null && state.dataVersion !== undefined) parts.push('data_version ' + state.dataVersion);
|
|
357
|
+
if (overview && overview.next_pending_run_at) {
|
|
358
|
+
parts.push('next pending in ' + formatDuration(overview.next_pending_run_at - (Date.now() / 1000)));
|
|
359
|
+
}
|
|
360
|
+
meta.textContent = parts.join(' · ');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderCounts() {
|
|
364
|
+
const root = document.getElementById('counts');
|
|
365
|
+
if (!root) return;
|
|
366
|
+
root.replaceChildren();
|
|
367
|
+
|
|
368
|
+
[
|
|
369
|
+
['executing', 'Executing'],
|
|
370
|
+
['claimed', 'Claimed'],
|
|
371
|
+
['pending', 'Pending'],
|
|
372
|
+
['done', 'Done'],
|
|
373
|
+
['failed', 'Failed']
|
|
374
|
+
].forEach(([key, label]) => {
|
|
375
|
+
const card = document.createElement('div');
|
|
376
|
+
card.className = 'count-card ' + key;
|
|
377
|
+
const labelElement = document.createElement('div');
|
|
378
|
+
labelElement.className = 'label';
|
|
379
|
+
labelElement.textContent = label;
|
|
380
|
+
const value = document.createElement('div');
|
|
381
|
+
value.className = 'value';
|
|
382
|
+
value.textContent = (state.counts[key] || 0).toLocaleString();
|
|
383
|
+
card.append(labelElement, value);
|
|
384
|
+
root.append(card);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderTotals() {
|
|
389
|
+
const root = document.getElementById('totals');
|
|
390
|
+
if (!root) return;
|
|
391
|
+
root.replaceChildren();
|
|
392
|
+
if (!state.totals || !state.totals.totals) {
|
|
393
|
+
root.style.display = 'none';
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
root.style.display = '';
|
|
398
|
+
const totals = state.totals.totals;
|
|
399
|
+
[
|
|
400
|
+
['total_runs', 'Runs'],
|
|
401
|
+
['total_successes', 'Successes'],
|
|
402
|
+
['total_failures', 'Failures'],
|
|
403
|
+
['total_timeouts', 'Timeouts'],
|
|
404
|
+
['total_skips', 'Skipped'],
|
|
405
|
+
['active_jobs', 'Active workers'],
|
|
406
|
+
['last_duration_ms', 'Last duration (ms)']
|
|
407
|
+
].forEach(([key, label]) => {
|
|
408
|
+
const card = document.createElement('div');
|
|
409
|
+
card.className = 'total-card';
|
|
410
|
+
const labelElement = document.createElement('div');
|
|
411
|
+
labelElement.className = 'label';
|
|
412
|
+
labelElement.textContent = label;
|
|
413
|
+
const value = document.createElement('div');
|
|
414
|
+
value.className = 'value';
|
|
415
|
+
value.textContent = totals[key] !== null && totals[key] !== undefined ? Number(totals[key]).toLocaleString() : '-';
|
|
416
|
+
card.append(labelElement, value);
|
|
417
|
+
root.append(card);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderTabBadges() {
|
|
422
|
+
['executing', 'claimed', 'pending', 'done', 'failed'].forEach((key) => {
|
|
423
|
+
const badge = document.querySelector('button[data-tab="' + key + '"] .badge');
|
|
424
|
+
if (badge) badge.textContent = (state.counts[key] || 0).toLocaleString();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderList() {
|
|
429
|
+
const root = document.getElementById('list');
|
|
430
|
+
const pagination = document.getElementById('pagination');
|
|
431
|
+
if (!root) return;
|
|
432
|
+
root.replaceChildren();
|
|
433
|
+
if (pagination) pagination.replaceChildren();
|
|
434
|
+
|
|
435
|
+
if (state.listError) {
|
|
436
|
+
const error = document.createElement('div');
|
|
437
|
+
error.className = 'empty';
|
|
438
|
+
error.textContent = 'Unable to load jobs (' + state.listError.message + ')';
|
|
439
|
+
root.append(error);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const items = state.data[state.activeTab] || [];
|
|
444
|
+
if (items.length === 0) {
|
|
445
|
+
const empty = document.createElement('div');
|
|
446
|
+
empty.className = 'empty';
|
|
447
|
+
empty.textContent = 'No jobs in this list';
|
|
448
|
+
root.append(empty);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
root.append(buildTable(state.activeTab, items));
|
|
453
|
+
renderPagination();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderPagination() {
|
|
457
|
+
const root = document.getElementById('pagination');
|
|
458
|
+
if (!root || !['pending', 'done', 'failed'].includes(state.activeTab)) return;
|
|
459
|
+
if (!state.cursors[state.activeTab]) return;
|
|
460
|
+
|
|
461
|
+
const button = document.createElement('button');
|
|
462
|
+
button.className = 'btn';
|
|
463
|
+
button.type = 'button';
|
|
464
|
+
button.textContent = 'Load more';
|
|
465
|
+
button.addEventListener('click', () => refreshActiveList(false));
|
|
466
|
+
root.append(button);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function buildTable(tab, items) {
|
|
470
|
+
const table = document.createElement('table');
|
|
471
|
+
const columns = tableColumns(tab);
|
|
472
|
+
const head = document.createElement('thead');
|
|
473
|
+
const headRow = document.createElement('tr');
|
|
474
|
+
columns.forEach((column) => {
|
|
475
|
+
const cell = document.createElement('th');
|
|
476
|
+
cell.textContent = column.label;
|
|
477
|
+
headRow.append(cell);
|
|
478
|
+
});
|
|
479
|
+
head.append(headRow);
|
|
480
|
+
table.append(head);
|
|
481
|
+
|
|
482
|
+
const body = document.createElement('tbody');
|
|
483
|
+
items.forEach((item) => {
|
|
484
|
+
const row = document.createElement('tr');
|
|
485
|
+
columns.forEach((column) => {
|
|
486
|
+
const cell = document.createElement('td');
|
|
487
|
+
column.render(cell, item);
|
|
488
|
+
row.append(cell);
|
|
489
|
+
});
|
|
490
|
+
body.append(row);
|
|
491
|
+
});
|
|
492
|
+
table.append(body);
|
|
493
|
+
return table;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function tableColumns(tab) {
|
|
497
|
+
const id = { label: 'ID', render: (cell, item) => { cell.className = 'mono dim'; cell.textContent = item.id; } };
|
|
498
|
+
const klass = { label: 'Class', render: (cell, item) => { cell.className = 'mono'; cell.textContent = item.class_name; } };
|
|
499
|
+
const args = {
|
|
500
|
+
label: 'Args',
|
|
501
|
+
render: (cell, item) => {
|
|
502
|
+
cell.className = 'args-cell';
|
|
503
|
+
if (state.config && state.config.expose_args) {
|
|
504
|
+
if (item.args === null || item.args === undefined) {
|
|
505
|
+
cell.textContent = '(' + (item.args_count || 0) + ')';
|
|
506
|
+
} else {
|
|
507
|
+
const pre = document.createElement('pre');
|
|
508
|
+
pre.textContent = JSON.stringify(item.args);
|
|
509
|
+
cell.append(pre);
|
|
510
|
+
}
|
|
511
|
+
} else {
|
|
512
|
+
const hidden = document.createElement('span');
|
|
513
|
+
hidden.className = 'args-redacted';
|
|
514
|
+
hidden.textContent = (item.args_count || 0) + ' args (hidden)';
|
|
515
|
+
cell.append(hidden);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
const time = (key, label) => ({
|
|
520
|
+
label: label,
|
|
521
|
+
render: (cell, item) => { cell.className = 'dim mono'; cell.textContent = formatTime(item[key]); }
|
|
522
|
+
});
|
|
523
|
+
const duration = {
|
|
524
|
+
label: 'Duration',
|
|
525
|
+
render: (cell, item) => { cell.className = 'mono dim'; cell.textContent = item.duration_ms ? item.duration_ms + ' ms' : '-'; }
|
|
526
|
+
};
|
|
527
|
+
const error = {
|
|
528
|
+
label: 'Error',
|
|
529
|
+
render: (cell, item) => {
|
|
530
|
+
cell.className = 'err-msg';
|
|
531
|
+
if (!item.last_error_class) {
|
|
532
|
+
cell.textContent = '-';
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const errorClass = document.createElement('span');
|
|
536
|
+
errorClass.className = 'err-class';
|
|
537
|
+
errorClass.textContent = item.last_error_class;
|
|
538
|
+
cell.append(errorClass, document.createTextNode(' ' + (item.last_error_message || '')));
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
if (tab === 'executing') return [id, klass, args, time('started_at', 'Started'), { label: 'Worker', render: (cell, item) => { cell.className = 'mono dim'; cell.textContent = item.locked_by; } }];
|
|
543
|
+
if (tab === 'claimed') return [id, klass, args, time('locked_at', 'Claimed'), { label: 'Worker', render: (cell, item) => { cell.className = 'mono dim'; cell.textContent = item.locked_by; } }];
|
|
544
|
+
if (tab === 'done') return [id, klass, args, time('finished_at', 'Finished'), duration];
|
|
545
|
+
if (tab === 'failed') return [id, klass, args, time('finished_at', 'Finished'), duration, error];
|
|
546
|
+
return [id, klass, args, time('run_at', 'Run at')];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function setActiveTab(tab) {
|
|
550
|
+
if (tab === state.activeTab) return;
|
|
551
|
+
state.activeTab = tab;
|
|
552
|
+
state.cursors[tab] = null;
|
|
553
|
+
state.listError = null;
|
|
554
|
+
document.querySelectorAll('nav.tabs button').forEach((button) => {
|
|
555
|
+
button.classList.toggle('active', button.dataset.tab === tab);
|
|
556
|
+
});
|
|
557
|
+
refreshActiveList(true);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function attachTabs() {
|
|
561
|
+
document.querySelectorAll('nav.tabs button').forEach((button) => {
|
|
562
|
+
button.addEventListener('click', () => setActiveTab(button.dataset.tab));
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function streamUrl() {
|
|
567
|
+
return new URL(basePath() + '/api/stream', location.origin).toString();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function stopStream() {
|
|
571
|
+
if (state.stream) state.stream.close();
|
|
572
|
+
state.stream = null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function startStream() {
|
|
576
|
+
stopStream();
|
|
577
|
+
setConnection('stale');
|
|
578
|
+
|
|
579
|
+
const stream = new EventSource(streamUrl());
|
|
580
|
+
state.stream = stream;
|
|
581
|
+
|
|
582
|
+
stream.addEventListener('overview', (event) => {
|
|
583
|
+
if (state.stream !== stream) return;
|
|
584
|
+
try {
|
|
585
|
+
applyOverview(JSON.parse(event.data));
|
|
586
|
+
scheduleActiveListRefresh();
|
|
587
|
+
} catch (_) {
|
|
588
|
+
setConnection('error');
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
stream.addEventListener('unavailable', () => {
|
|
593
|
+
if (state.stream === stream) setConnection('stale');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
stream.addEventListener('open', () => {
|
|
597
|
+
if (state.stream === stream) setConnection('ok');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
stream.addEventListener('error', () => {
|
|
601
|
+
if (state.stream !== stream) return;
|
|
602
|
+
setConnection(stream.readyState === EventSource.CLOSED ? 'error' : 'stale');
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function pollingTick() {
|
|
607
|
+
if (state.pollingInFlight) return;
|
|
608
|
+
state.pollingInFlight = true;
|
|
609
|
+
try {
|
|
610
|
+
await refreshOverview();
|
|
611
|
+
await refreshActiveList(true);
|
|
612
|
+
} finally {
|
|
613
|
+
state.pollingInFlight = false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function startPolling() {
|
|
618
|
+
pollingTick();
|
|
619
|
+
state.pollingTimer = setInterval(pollingTick, state.config.poll_interval_ms);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function formatTime(seconds) {
|
|
623
|
+
if (!seconds) return '-';
|
|
624
|
+
const date = new Date(seconds * 1000);
|
|
625
|
+
return isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function formatDuration(seconds) {
|
|
629
|
+
if (!isFinite(seconds)) return '-';
|
|
630
|
+
if (seconds <= 0) return 'now';
|
|
631
|
+
if (seconds < 60) return Math.round(seconds) + 's';
|
|
632
|
+
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
|
|
633
|
+
return Math.round(seconds / 3600) + 'h';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function relTime(timestamp) {
|
|
637
|
+
return formatDuration((Date.now() - timestamp) / 1000);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function boot() {
|
|
641
|
+
attachTabs();
|
|
642
|
+
await loadConfig();
|
|
643
|
+
await Promise.all([refreshOverview(), refreshActiveList(true)]);
|
|
644
|
+
|
|
645
|
+
if (state.config.transport === 'sse' && typeof EventSource !== 'undefined') {
|
|
646
|
+
startStream();
|
|
647
|
+
} else {
|
|
648
|
+
startPolling();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
setInterval(() => renderHeader(state.overview), 1000);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function start() {
|
|
655
|
+
boot().catch((error) => {
|
|
656
|
+
setConnection('error');
|
|
657
|
+
if (window.console && console.error) console.error('Async::Background dashboard boot failed', error);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
window.addEventListener('beforeunload', () => {
|
|
662
|
+
stopStream();
|
|
663
|
+
if (state.pollingTimer) clearInterval(state.pollingTimer);
|
|
664
|
+
if (state.listRefreshTimer) clearTimeout(state.listRefreshTimer);
|
|
665
|
+
if (state.listAbort) state.listAbort.abort();
|
|
666
|
+
}, { once: true });
|
|
667
|
+
|
|
668
|
+
if (document.readyState === 'loading') {
|
|
669
|
+
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
670
|
+
} else {
|
|
671
|
+
start();
|
|
672
|
+
}
|
|
673
|
+
})();
|
|
674
|
+
JS
|
|
675
|
+
|
|
676
|
+
INDEX_HTML = <<~HTML
|
|
677
|
+
<!DOCTYPE html>
|
|
678
|
+
<html lang="en">
|
|
679
|
+
<head>
|
|
680
|
+
<meta charset="utf-8">
|
|
681
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
682
|
+
<title>%<title>s</title>
|
|
683
|
+
<link rel="stylesheet" href="%<base>s/assets/app.css?v=%<asset_version>s">
|
|
684
|
+
</head>
|
|
685
|
+
<body data-mount-path="%<base>s">
|
|
686
|
+
<header>
|
|
687
|
+
<h1 id="title">%<title>s</h1>
|
|
688
|
+
<span class="meta"><span id="status-dot" class="status-dot"></span><span id="meta"></span></span>
|
|
689
|
+
</header>
|
|
690
|
+
<main>
|
|
691
|
+
<section id="counts" class="counts"></section>
|
|
692
|
+
<section id="totals" class="totals"></section>
|
|
693
|
+
<nav class="tabs">
|
|
694
|
+
<button type="button" data-tab="executing" class="active">Executing <span class="badge">0</span></button>
|
|
695
|
+
<button type="button" data-tab="claimed">Claimed <span class="badge">0</span></button>
|
|
696
|
+
<button type="button" data-tab="pending">Pending <span class="badge">0</span></button>
|
|
697
|
+
<button type="button" data-tab="done">Done <span class="badge">0</span></button>
|
|
698
|
+
<button type="button" data-tab="failed">Failed <span class="badge">0</span></button>
|
|
699
|
+
</nav>
|
|
700
|
+
<section id="list"></section>
|
|
701
|
+
<section id="pagination" class="pagination"></section>
|
|
702
|
+
</main>
|
|
703
|
+
<script defer src="%<base>s/assets/app.js?v=%<asset_version>s"></script>
|
|
704
|
+
</body>
|
|
705
|
+
</html>
|
|
706
|
+
HTML
|
|
707
|
+
|
|
708
|
+
module_function
|
|
709
|
+
|
|
710
|
+
def asset_version
|
|
711
|
+
@asset_version ||= Digest::SHA256.hexdigest("#{JS}\0#{CSS}")[0, 12]
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def render_index(config)
|
|
715
|
+
base = config.mount_path.to_s.sub(%r{/\z}, '')
|
|
716
|
+
format(
|
|
717
|
+
INDEX_HTML,
|
|
718
|
+
title: CGI.escapeHTML(config.title.to_s),
|
|
719
|
+
base: CGI.escapeHTML(base),
|
|
720
|
+
asset_version: asset_version
|
|
721
|
+
)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|