async-background 0.7.1 → 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.
@@ -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