@intranefr/superbackend 1.5.1 → 1.5.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.
- package/.env.example +10 -0
- package/analysis-only.skill +0 -0
- package/package.json +2 -1
- package/src/controllers/admin.controller.js +68 -1
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminScripts.controller.js +105 -74
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware.js +65 -11
- package/src/models/CacheEntry.js +1 -1
- package/src/models/ConsoleLog.js +1 -1
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/RateLimitCounter.js +1 -1
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminConsoleManager.routes.js +1 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/blogInternal.routes.js +2 -2
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/services/blogCronsBootstrap.service.js +7 -6
- package/src/services/consoleManager.service.js +56 -18
- package/src/services/consoleOverride.service.js +1 -0
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/scriptsRunner.service.js +214 -14
- package/src/utils/rbac/rightsRegistry.js +4 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-scripts.ejs +596 -2
- package/views/partials/dashboard/nav-items.ejs +1 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/src/middleware/internalCronAuth.js +0 -29
|
@@ -1,11 +1,76 @@
|
|
|
1
1
|
const { EventEmitter } = require('events');
|
|
2
2
|
const { spawn } = require('child_process');
|
|
3
3
|
const { NodeVM } = require('vm2');
|
|
4
|
+
const mongoose = require('mongoose');
|
|
4
5
|
|
|
5
6
|
const ScriptRun = require('../models/ScriptRun');
|
|
7
|
+
const { mongooseHelper } = require('../helpers/mongooseHelper');
|
|
6
8
|
|
|
7
9
|
const MAX_TAIL_BYTES = 64 * 1024;
|
|
8
10
|
|
|
11
|
+
function isTruthyEnv(v) {
|
|
12
|
+
const s = String(v || '').trim().toLowerCase();
|
|
13
|
+
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shouldAutoWrapAsyncScripts() {
|
|
17
|
+
if (process.env.SCRIPT_AUTO_ASYNC_WRAP === undefined) return true;
|
|
18
|
+
return isTruthyEnv(process.env.SCRIPT_AUTO_ASYNC_WRAP);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function detectTopLevelAwait(code) {
|
|
22
|
+
const s = String(code || '');
|
|
23
|
+
if (!/\bawait\b/.test(s)) return false;
|
|
24
|
+
if (/^\s*\(\s*async\s*\(/.test(s)) return false;
|
|
25
|
+
if (/^\s*async\s+function\b/.test(s)) return false;
|
|
26
|
+
if (/\bmodule\.exports\b/.test(s) || /\bexports\./.test(s)) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wrapInAsyncIife(code) {
|
|
31
|
+
const body = String(code || '');
|
|
32
|
+
return [
|
|
33
|
+
'(async () => {',
|
|
34
|
+
body,
|
|
35
|
+
'})().catch((err) => {',
|
|
36
|
+
' try { console.error(err && err.stack ? err.stack : err); } catch {}',
|
|
37
|
+
'});',
|
|
38
|
+
'',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function prepareVmCodeForExecution(code) {
|
|
43
|
+
const raw = String(code || '');
|
|
44
|
+
if (!shouldAutoWrapAsyncScripts()) return { code: raw, wrapped: false };
|
|
45
|
+
if (!detectTopLevelAwait(raw)) return { code: raw, wrapped: false };
|
|
46
|
+
return { code: wrapInAsyncIife(raw), wrapped: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildAwaitSyntaxHelpMessage() {
|
|
50
|
+
return [
|
|
51
|
+
'Your script uses `await` at top-level.',
|
|
52
|
+
'Wrap it in an async IIFE, or rely on auto-wrapping:',
|
|
53
|
+
'',
|
|
54
|
+
'(async () => {',
|
|
55
|
+
' const count = await countCollectionDocuments("users");',
|
|
56
|
+
' console.log("count:", count);',
|
|
57
|
+
'})();',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper function to decode script content
|
|
63
|
+
function decodeScriptContent(script, format) {
|
|
64
|
+
if (format === 'base64') {
|
|
65
|
+
try {
|
|
66
|
+
return Buffer.from(script, 'base64').toString('utf8');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error('Failed to decode base64 script content');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return script;
|
|
72
|
+
}
|
|
73
|
+
|
|
9
74
|
function nowIso() {
|
|
10
75
|
return new Date().toISOString();
|
|
11
76
|
}
|
|
@@ -114,24 +179,16 @@ async function startRun(scriptDef, options) {
|
|
|
114
179
|
runId: runDoc._id,
|
|
115
180
|
bus,
|
|
116
181
|
command: 'bash',
|
|
117
|
-
args: ['-lc', scriptDef.script],
|
|
182
|
+
args: ['-lc', decodeScriptContent(scriptDef.script, scriptDef.scriptFormat)],
|
|
118
183
|
env,
|
|
119
184
|
cwd,
|
|
120
185
|
timeoutMs,
|
|
121
186
|
});
|
|
122
187
|
} else if (scriptDef.type === 'node') {
|
|
123
188
|
if (scriptDef.runner === 'vm2') {
|
|
124
|
-
exitCode = await runVm2({ runId: runDoc._id, bus, code: scriptDef.script, timeoutMs });
|
|
189
|
+
exitCode = await runVm2({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), timeoutMs });
|
|
125
190
|
} else if (scriptDef.runner === 'host') {
|
|
126
|
-
exitCode = await
|
|
127
|
-
runId: runDoc._id,
|
|
128
|
-
bus,
|
|
129
|
-
command: 'node',
|
|
130
|
-
args: ['-e', scriptDef.script],
|
|
131
|
-
env,
|
|
132
|
-
cwd,
|
|
133
|
-
timeoutMs,
|
|
134
|
-
});
|
|
191
|
+
exitCode = await runHostWithDatabase({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), env, cwd, timeoutMs });
|
|
135
192
|
} else {
|
|
136
193
|
throw Object.assign(new Error('Invalid runner for node script'), { code: 'VALIDATION' });
|
|
137
194
|
}
|
|
@@ -213,6 +270,144 @@ async function runSpawned({ runId, bus, command, args, env, cwd, timeoutMs }) {
|
|
|
213
270
|
});
|
|
214
271
|
}
|
|
215
272
|
|
|
273
|
+
async function runHostWithDatabase({ runId, bus, code, env, cwd, timeoutMs }) {
|
|
274
|
+
let tail = '';
|
|
275
|
+
|
|
276
|
+
function pushLog(stream, line) {
|
|
277
|
+
const s = String(line || '');
|
|
278
|
+
tail = appendTail(tail, s);
|
|
279
|
+
bus.push({ type: 'log', ts: nowIso(), stream, line: s });
|
|
280
|
+
return ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
// Use existing app connection if available, otherwise create new one
|
|
285
|
+
if (mongoose.connection.readyState !== 1) {
|
|
286
|
+
await pushLog('stdout', 'No existing connection found, establishing new connection...\n');
|
|
287
|
+
await mongooseHelper.connect();
|
|
288
|
+
|
|
289
|
+
// Wait for connection to be fully ready
|
|
290
|
+
await mongooseHelper.waitForConnection(5000);
|
|
291
|
+
} else {
|
|
292
|
+
await pushLog('stdout', 'Using existing app database connection\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Validate connection is ready
|
|
296
|
+
if (mongoose.connection.readyState !== 1) {
|
|
297
|
+
throw new Error('Database connection is not ready');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const prepared = prepareVmCodeForExecution(code);
|
|
301
|
+
if (prepared.wrapped) {
|
|
302
|
+
await pushLog('stdout', 'Auto-wrapping script in async function (detected top-level await)\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Create a VM with database context
|
|
306
|
+
const vm = new NodeVM({
|
|
307
|
+
console: 'inherit',
|
|
308
|
+
sandbox: {
|
|
309
|
+
// Expose pre-connected mongoose instance
|
|
310
|
+
mongoose: mongoose,
|
|
311
|
+
db: mongoose.connection.db,
|
|
312
|
+
|
|
313
|
+
// Expose helper functions
|
|
314
|
+
countCollectionDocuments: async (collectionName, query = {}) => {
|
|
315
|
+
try {
|
|
316
|
+
// Ensure connection is still valid
|
|
317
|
+
if (mongoose.connection.readyState !== 1) {
|
|
318
|
+
throw new Error('Database connection lost during operation');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const db = mongoose.connection.db;
|
|
322
|
+
if (!db) {
|
|
323
|
+
throw new Error('Database instance not available');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const collection = db.collection(collectionName);
|
|
327
|
+
const count = await collection.countDocuments(query);
|
|
328
|
+
return count;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw new Error(`Failed to count documents in ${collectionName}: ${error.message}`);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
// Expose connection status helper
|
|
335
|
+
getConnectionStatus: () => {
|
|
336
|
+
const readyStateMap = {
|
|
337
|
+
0: 'disconnected',
|
|
338
|
+
1: 'connected',
|
|
339
|
+
2: 'connecting',
|
|
340
|
+
3: 'disconnecting'
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
readyState: mongoose.connection.readyState,
|
|
345
|
+
readyStateText: readyStateMap[mongoose.connection.readyState] || 'unknown',
|
|
346
|
+
host: mongoose.connection.host,
|
|
347
|
+
name: mongoose.connection.name,
|
|
348
|
+
hasActiveConnection: mongoose.connection.readyState === 1
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// Expose models if available
|
|
353
|
+
models: mongoose.models || {},
|
|
354
|
+
|
|
355
|
+
// Global objects
|
|
356
|
+
JSON,
|
|
357
|
+
Date,
|
|
358
|
+
Math,
|
|
359
|
+
parseInt,
|
|
360
|
+
parseFloat,
|
|
361
|
+
String,
|
|
362
|
+
Number,
|
|
363
|
+
Object,
|
|
364
|
+
Array,
|
|
365
|
+
|
|
366
|
+
// Process environment
|
|
367
|
+
process: {
|
|
368
|
+
env: { ...process.env, ...env }
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
require: {
|
|
372
|
+
external: false,
|
|
373
|
+
builtin: ['util', 'path', 'os'], // Allow some basic built-ins
|
|
374
|
+
},
|
|
375
|
+
timeout: timeoutMs,
|
|
376
|
+
eval: false,
|
|
377
|
+
wasm: false,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Set up console redirection
|
|
381
|
+
vm.on('console.log', (...args) => {
|
|
382
|
+
pushLog('stdout', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
|
|
383
|
+
});
|
|
384
|
+
vm.on('console.error', (...args) => {
|
|
385
|
+
pushLog('stderr', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Run the script code with better error handling
|
|
389
|
+
try {
|
|
390
|
+
vm.run(prepared.code, 'script.host.js');
|
|
391
|
+
} catch (vmError) {
|
|
392
|
+
const baseMsg = vmError?.message || 'Unknown VM error';
|
|
393
|
+
const help = baseMsg.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
|
|
394
|
+
const errorMsg = `VM execution error: ${baseMsg}${help}`;
|
|
395
|
+
await pushLog('stderr', errorMsg + '\n');
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return 0;
|
|
400
|
+
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const msg = err?.message || 'Host script error';
|
|
403
|
+
await pushLog('stderr', msg + '\n');
|
|
404
|
+
return 1;
|
|
405
|
+
} finally {
|
|
406
|
+
// Don't disconnect here - let mongooseHelper manage connection pooling
|
|
407
|
+
// The connection will be cleaned up when the helper decides
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
216
411
|
async function runVm2({ runId, bus, code, timeoutMs }) {
|
|
217
412
|
let tail = '';
|
|
218
413
|
|
|
@@ -243,11 +438,16 @@ async function runVm2({ runId, bus, code, timeoutMs }) {
|
|
|
243
438
|
});
|
|
244
439
|
|
|
245
440
|
try {
|
|
246
|
-
|
|
441
|
+
const prepared = prepareVmCodeForExecution(code);
|
|
442
|
+
if (prepared.wrapped) {
|
|
443
|
+
await pushLog('stdout', 'Auto-wrapping script in async function (detected top-level await)\n');
|
|
444
|
+
}
|
|
445
|
+
vm.run(prepared.code, 'script.vm2.js');
|
|
247
446
|
return 0;
|
|
248
447
|
} catch (err) {
|
|
249
|
-
const
|
|
250
|
-
|
|
448
|
+
const baseMsg = err?.message || 'vm2 error';
|
|
449
|
+
const help = baseMsg.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
|
|
450
|
+
await pushLog('stderr', baseMsg + help + '\n');
|
|
251
451
|
return 1;
|
|
252
452
|
}
|
|
253
453
|
}
|
|
@@ -222,6 +222,10 @@
|
|
|
222
222
|
const paletteQuery = ref('');
|
|
223
223
|
const paletteCursor = ref(0);
|
|
224
224
|
const paletteInput = ref(null);
|
|
225
|
+
|
|
226
|
+
// Toggle safeguards and debugging
|
|
227
|
+
let toggleTimeout = null;
|
|
228
|
+
let lastToggleTime = 0;
|
|
225
229
|
|
|
226
230
|
// Flattened modules for search
|
|
227
231
|
const allModules = computed(() => {
|
|
@@ -285,19 +289,34 @@
|
|
|
285
289
|
};
|
|
286
290
|
|
|
287
291
|
// Palette methods
|
|
288
|
-
const togglePalette = () => {
|
|
292
|
+
const togglePalette = (source = 'unknown') => {
|
|
293
|
+
// Prevent rapid successive toggles
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (now - lastToggleTime < 100) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
lastToggleTime = now;
|
|
299
|
+
|
|
300
|
+
clearTimeout(toggleTimeout);
|
|
301
|
+
|
|
289
302
|
showPalette.value = !showPalette.value;
|
|
290
303
|
if (showPalette.value) {
|
|
291
304
|
paletteQuery.value = '';
|
|
292
305
|
paletteCursor.value = 0;
|
|
293
|
-
nextTick
|
|
294
|
-
|
|
295
|
-
|
|
306
|
+
// Use setTimeout instead of nextTick for better timing
|
|
307
|
+
toggleTimeout = setTimeout(() => {
|
|
308
|
+
if (paletteInput.value) {
|
|
309
|
+
paletteInput.value.focus();
|
|
310
|
+
}
|
|
311
|
+
}, 50);
|
|
296
312
|
}
|
|
297
313
|
};
|
|
298
314
|
|
|
299
|
-
const closePalette = () => {
|
|
300
|
-
showPalette.value
|
|
315
|
+
const closePalette = (source = 'unknown') => {
|
|
316
|
+
if (showPalette.value) {
|
|
317
|
+
showPalette.value = false;
|
|
318
|
+
clearTimeout(toggleTimeout);
|
|
319
|
+
}
|
|
301
320
|
};
|
|
302
321
|
|
|
303
322
|
const navigatePalette = (dir) => {
|
|
@@ -325,13 +344,14 @@
|
|
|
325
344
|
const handleKeydown = (e) => {
|
|
326
345
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
327
346
|
e.preventDefault();
|
|
328
|
-
|
|
347
|
+
e.stopPropagation();
|
|
348
|
+
togglePalette('keyboard-ctrl-k');
|
|
329
349
|
}
|
|
330
350
|
};
|
|
331
351
|
|
|
332
352
|
const handleMessage = (e) => {
|
|
333
353
|
if (e.data && e.data.type === 'keydown' && e.data.ctrlK) {
|
|
334
|
-
togglePalette();
|
|
354
|
+
togglePalette('iframe-message');
|
|
335
355
|
}
|
|
336
356
|
};
|
|
337
357
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Experiments</title>
|
|
7
|
+
<link rel="stylesheet" href="<%= baseUrl %><%= adminPath %>/assets/styles.css" />
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; }
|
|
10
|
+
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
11
|
+
input, select, button { padding: 8px 10px; }
|
|
12
|
+
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
13
|
+
th, td { border-bottom: 1px solid #e5e7eb; padding: 10px; text-align: left; }
|
|
14
|
+
.muted { color: #6b7280; }
|
|
15
|
+
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f3f4f6; }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<h1>Experiments</h1>
|
|
20
|
+
|
|
21
|
+
<div class="row">
|
|
22
|
+
<label>
|
|
23
|
+
orgId
|
|
24
|
+
<input id="orgId" placeholder="(required for RBAC)" style="min-width: 320px" />
|
|
25
|
+
</label>
|
|
26
|
+
<button id="refresh">Refresh</button>
|
|
27
|
+
<span id="status" class="muted"></span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<table>
|
|
31
|
+
<thead>
|
|
32
|
+
<tr>
|
|
33
|
+
<th>Code</th>
|
|
34
|
+
<th>Status</th>
|
|
35
|
+
<th>Org</th>
|
|
36
|
+
<th>Winner</th>
|
|
37
|
+
<th>Updated</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody id="tbody"></tbody>
|
|
41
|
+
</table>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
const statusEl = document.getElementById('status');
|
|
45
|
+
const tbody = document.getElementById('tbody');
|
|
46
|
+
const orgIdEl = document.getElementById('orgId');
|
|
47
|
+
|
|
48
|
+
function esc(s) {
|
|
49
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function load() {
|
|
53
|
+
const orgId = orgIdEl.value.trim();
|
|
54
|
+
statusEl.textContent = 'Loading...';
|
|
55
|
+
tbody.innerHTML = '';
|
|
56
|
+
|
|
57
|
+
const url = new URL('<%= baseUrl %>/api/admin/experiments', window.location.origin);
|
|
58
|
+
if (orgId) url.searchParams.set('orgId', orgId);
|
|
59
|
+
|
|
60
|
+
const res = await fetch(url.toString(), { headers: { 'Content-Type': 'application/json' } });
|
|
61
|
+
const json = await res.json().catch(() => ({}));
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
statusEl.textContent = `Error: ${json.error || res.status}`;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const items = Array.isArray(json.items) ? json.items : [];
|
|
69
|
+
statusEl.textContent = `${items.length} experiments`;
|
|
70
|
+
|
|
71
|
+
tbody.innerHTML = items
|
|
72
|
+
.map((e) => {
|
|
73
|
+
const winner = e.winnerVariantKey ? esc(e.winnerVariantKey) : '<span class="muted">-</span>';
|
|
74
|
+
return `
|
|
75
|
+
<tr>
|
|
76
|
+
<td><span class="pill">${esc(e.code)}</span></td>
|
|
77
|
+
<td>${esc(e.status)}</td>
|
|
78
|
+
<td class="muted">${esc(e.organizationId || '')}</td>
|
|
79
|
+
<td>${winner}</td>
|
|
80
|
+
<td class="muted">${esc(e.updatedAt || '')}</td>
|
|
81
|
+
</tr>
|
|
82
|
+
`;
|
|
83
|
+
})
|
|
84
|
+
.join('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
document.getElementById('refresh').addEventListener('click', () => load());
|
|
88
|
+
load();
|
|
89
|
+
</script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|