@intranefr/superbackend 1.5.0 → 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 +15 -0
- package/README.md +11 -0
- package/analysis-only.skill +0 -0
- package/index.js +23 -0
- package/package.json +8 -2
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +90 -6
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminHeadless.controller.js +9 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +126 -4
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +80 -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/rbac.js +62 -0
- package/src/middleware.js +810 -48
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- 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/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- 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/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/adminHeadless.routes.js +2 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminUiComponents.routes.js +2 -1
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +1 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +185 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +738 -0
- package/src/services/consoleOverride.service.js +7 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- 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/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +215 -15
- package/src/services/uiComponentsAi.service.js +6 -19
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +33 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +163 -1
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +597 -3
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-ui-components.ejs +57 -25
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +12 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/views/partials/llm-provider-model-picker.ejs +183 -0
- package/src/routes/llmUi.routes.js +0 -26
|
@@ -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,445 @@
|
|
|
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>Admin Database Browser</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
|
|
10
|
+
<style>[v-cloak]{display:none}</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body class="bg-gray-50">
|
|
13
|
+
<div id="app" class="max-w-7xl mx-auto px-6 py-6" v-cloak>
|
|
14
|
+
<div class="flex items-center justify-between mb-6">
|
|
15
|
+
<div>
|
|
16
|
+
<h1 class="text-2xl font-semibold text-gray-900">Database Browser</h1>
|
|
17
|
+
<div class="text-sm text-gray-500">Manage external DB connections (Mongo/MySQL) and browse records (read-only).</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="flex items-center gap-2">
|
|
20
|
+
<button @click="refreshAll" class="px-3 py-2 rounded bg-gray-700 text-white text-sm hover:bg-gray-800">
|
|
21
|
+
<i class="ti ti-refresh mr-1"></i> Refresh
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div v-if="error" class="mb-4 p-3 rounded border border-red-200 bg-red-50 text-red-700 text-sm">
|
|
27
|
+
{{ error }}
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="grid grid-cols-12 gap-6">
|
|
31
|
+
<!-- Left: Connections -->
|
|
32
|
+
<div class="col-span-5 space-y-6">
|
|
33
|
+
<div class="bg-white border border-gray-200 rounded-lg">
|
|
34
|
+
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
35
|
+
<div class="text-sm font-semibold text-gray-800">Connections</div>
|
|
36
|
+
<div class="text-xs text-gray-400">URIs stored encrypted; only masked version is shown.</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="p-4 space-y-3">
|
|
39
|
+
<div v-if="connections.length === 0" class="text-sm text-gray-500">No connections yet.</div>
|
|
40
|
+
<div v-for="c in connections" :key="c.id" class="p-3 border rounded flex items-start justify-between" :class="selectedConnectionId===c.id ? 'border-gray-800 bg-gray-50' : 'border-gray-200'">
|
|
41
|
+
<div class="min-w-0">
|
|
42
|
+
<div class="flex items-center gap-2">
|
|
43
|
+
<div class="font-semibold text-gray-900 truncate">{{ c.name }}</div>
|
|
44
|
+
<span class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700">{{ c.type }}</span>
|
|
45
|
+
<span v-if="c.enabled" class="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700">enabled</span>
|
|
46
|
+
<span v-else class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-500">disabled</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="text-xs text-gray-500 mt-1 break-all">{{ c.uriMasked || '—' }}</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="flex flex-col gap-2 pl-3">
|
|
51
|
+
<button @click="selectConnection(c)" class="px-2 py-1 rounded text-xs bg-gray-800 text-white hover:bg-gray-900">
|
|
52
|
+
Browse
|
|
53
|
+
</button>
|
|
54
|
+
<button @click="testConnection(c.id)" class="px-2 py-1 rounded text-xs bg-blue-600 text-white hover:bg-blue-700">
|
|
55
|
+
Test
|
|
56
|
+
</button>
|
|
57
|
+
<button @click="startEdit(c)" class="px-2 py-1 rounded text-xs bg-gray-200 hover:bg-gray-300">
|
|
58
|
+
Edit
|
|
59
|
+
</button>
|
|
60
|
+
<button @click="toggleEnabled(c)" class="px-2 py-1 rounded text-xs bg-gray-200 hover:bg-gray-300">
|
|
61
|
+
{{ c.enabled ? 'Disable' : 'Enable' }}
|
|
62
|
+
</button>
|
|
63
|
+
<button @click="deleteConnection(c.id)" class="px-2 py-1 rounded text-xs bg-red-600 text-white hover:bg-red-700">
|
|
64
|
+
Delete
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="bg-white border border-gray-200 rounded-lg">
|
|
72
|
+
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
73
|
+
<div class="text-sm font-semibold text-gray-800">{{ editing ? 'Edit connection' : 'Create connection' }}</div>
|
|
74
|
+
<button v-if="editing" @click="cancelEdit" class="text-xs text-gray-600 hover:text-gray-900">Cancel</button>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="p-4 space-y-3">
|
|
77
|
+
<div class="grid grid-cols-2 gap-3">
|
|
78
|
+
<div>
|
|
79
|
+
<label class="text-xs font-semibold text-gray-600">Name</label>
|
|
80
|
+
<input v-model="form.name" class="mt-1 w-full border rounded px-3 py-2" placeholder="prod-mongo" />
|
|
81
|
+
</div>
|
|
82
|
+
<div>
|
|
83
|
+
<label class="text-xs font-semibold text-gray-600">Type</label>
|
|
84
|
+
<select v-model="form.type" class="mt-1 w-full border rounded px-3 py-2">
|
|
85
|
+
<option value="mongo">mongo</option>
|
|
86
|
+
<option value="mysql">mysql</option>
|
|
87
|
+
</select>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div>
|
|
92
|
+
<label class="text-xs font-semibold text-gray-600">URI {{ editing ? '(leave empty to keep existing)' : '' }}</label>
|
|
93
|
+
<input v-model="form.uri" class="mt-1 w-full border rounded px-3 py-2 font-mono text-sm" placeholder="mongodb://user:pass@host:27017/db" />
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="flex items-center gap-2">
|
|
97
|
+
<input id="enabled" type="checkbox" v-model="form.enabled" class="h-4 w-4" />
|
|
98
|
+
<label for="enabled" class="text-sm text-gray-700">Enabled</label>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="flex items-center gap-2">
|
|
102
|
+
<button v-if="!editing" @click="createConnection" class="px-3 py-2 rounded bg-green-600 text-white text-sm hover:bg-green-700">
|
|
103
|
+
<i class="ti ti-plus mr-1"></i> Create
|
|
104
|
+
</button>
|
|
105
|
+
<button v-else @click="saveEdit" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">
|
|
106
|
+
<i class="ti ti-device-floppy mr-1"></i> Save
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Right: Browser -->
|
|
114
|
+
<div class="col-span-7 space-y-6">
|
|
115
|
+
<div class="bg-white border border-gray-200 rounded-lg">
|
|
116
|
+
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
|
117
|
+
<div class="text-sm font-semibold text-gray-800">Browse</div>
|
|
118
|
+
<div class="text-xs text-gray-400">Connection → Database → Namespace</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div class="p-4 space-y-4">
|
|
122
|
+
<div v-if="!selectedConnection" class="text-sm text-gray-500">Select a connection and click “Browse”.</div>
|
|
123
|
+
<div v-else class="space-y-4">
|
|
124
|
+
<div class="grid grid-cols-3 gap-3">
|
|
125
|
+
<div>
|
|
126
|
+
<label class="text-xs font-semibold text-gray-600">Database</label>
|
|
127
|
+
<select v-model="selectedDatabase" @change="onDatabaseChange" class="mt-1 w-full border rounded px-3 py-2">
|
|
128
|
+
<option value="">—</option>
|
|
129
|
+
<option v-for="d in databases" :key="d" :value="d">{{ d }}</option>
|
|
130
|
+
</select>
|
|
131
|
+
</div>
|
|
132
|
+
<div>
|
|
133
|
+
<label class="text-xs font-semibold text-gray-600">Namespace</label>
|
|
134
|
+
<select v-model="selectedNamespace" @change="onNamespaceChange" class="mt-1 w-full border rounded px-3 py-2">
|
|
135
|
+
<option value="">—</option>
|
|
136
|
+
<option v-for="n in namespaces" :key="n" :value="n">{{ n }}</option>
|
|
137
|
+
</select>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="flex items-end">
|
|
140
|
+
<button @click="loadRecords" :disabled="!canBrowse" class="w-full px-3 py-2 rounded bg-gray-800 text-white text-sm hover:bg-gray-900 disabled:opacity-50">
|
|
141
|
+
Load records
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="grid grid-cols-5 gap-3">
|
|
147
|
+
<div class="col-span-2">
|
|
148
|
+
<label class="text-xs font-semibold text-gray-600">Filter field</label>
|
|
149
|
+
<select v-if="schemaFields.length" v-model="query.filterField" class="mt-1 w-full border rounded px-3 py-2">
|
|
150
|
+
<option value="">—</option>
|
|
151
|
+
<option v-for="f in schemaFields" :key="f" :value="f">{{ f }}</option>
|
|
152
|
+
</select>
|
|
153
|
+
<input v-else v-model="query.filterField" class="mt-1 w-full border rounded px-3 py-2" placeholder="field" />
|
|
154
|
+
</div>
|
|
155
|
+
<div class="col-span-3">
|
|
156
|
+
<label class="text-xs font-semibold text-gray-600">Filter value (contains)</label>
|
|
157
|
+
<input v-model="query.filterValue" class="mt-1 w-full border rounded px-3 py-2" placeholder="search" />
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div class="grid grid-cols-5 gap-3">
|
|
162
|
+
<div class="col-span-2">
|
|
163
|
+
<label class="text-xs font-semibold text-gray-600">Sort field</label>
|
|
164
|
+
<select v-if="schemaFields.length" v-model="query.sortField" class="mt-1 w-full border rounded px-3 py-2">
|
|
165
|
+
<option value="">—</option>
|
|
166
|
+
<option v-for="f in schemaFields" :key="f" :value="f">{{ f }}</option>
|
|
167
|
+
</select>
|
|
168
|
+
<input v-else v-model="query.sortField" class="mt-1 w-full border rounded px-3 py-2" placeholder="field" />
|
|
169
|
+
</div>
|
|
170
|
+
<div>
|
|
171
|
+
<label class="text-xs font-semibold text-gray-600">Order</label>
|
|
172
|
+
<select v-model="query.sortOrder" class="mt-1 w-full border rounded px-3 py-2">
|
|
173
|
+
<option value="desc">desc</option>
|
|
174
|
+
<option value="asc">asc</option>
|
|
175
|
+
</select>
|
|
176
|
+
</div>
|
|
177
|
+
<div>
|
|
178
|
+
<label class="text-xs font-semibold text-gray-600">Page</label>
|
|
179
|
+
<input v-model.number="query.page" type="number" min="1" class="mt-1 w-full border rounded px-3 py-2" />
|
|
180
|
+
</div>
|
|
181
|
+
<div>
|
|
182
|
+
<label class="text-xs font-semibold text-gray-600">Page size</label>
|
|
183
|
+
<input v-model.number="query.pageSize" type="number" min="1" max="100" class="mt-1 w-full border rounded px-3 py-2" />
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div v-if="records.items.length" class="border rounded overflow-hidden">
|
|
188
|
+
<div class="p-3 border-b bg-gray-50 flex items-center justify-between">
|
|
189
|
+
<div class="text-sm text-gray-700">Total: <span class="font-semibold">{{ records.total }}</span> • Page {{ records.page }} / {{ records.totalPages || 1 }}</div>
|
|
190
|
+
<div class="flex items-center gap-2">
|
|
191
|
+
<button @click="prevPage" :disabled="records.page<=1" class="px-2 py-1 rounded text-xs bg-gray-200 hover:bg-gray-300 disabled:opacity-50">Prev</button>
|
|
192
|
+
<button @click="nextPage" :disabled="records.page>=records.totalPages" class="px-2 py-1 rounded text-xs bg-gray-200 hover:bg-gray-300 disabled:opacity-50">Next</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="overflow-auto" style="max-height: 520px;">
|
|
196
|
+
<table class="min-w-full text-sm">
|
|
197
|
+
<thead class="bg-white sticky top-0">
|
|
198
|
+
<tr>
|
|
199
|
+
<th v-for="h in tableHeaders" :key="h" class="text-left px-3 py-2 border-b text-xs font-semibold text-gray-600">{{ h }}</th>
|
|
200
|
+
</tr>
|
|
201
|
+
</thead>
|
|
202
|
+
<tbody>
|
|
203
|
+
<tr v-for="(row, idx) in records.items" :key="idx" @click="openRow(row)" class="cursor-pointer hover:bg-gray-50">
|
|
204
|
+
<td v-for="h in tableHeaders" :key="h" class="px-3 py-2 border-b whitespace-nowrap">
|
|
205
|
+
{{ formatCell(row[h]) }}
|
|
206
|
+
</td>
|
|
207
|
+
</tr>
|
|
208
|
+
</tbody>
|
|
209
|
+
</table>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div v-else class="text-sm text-gray-500">No records loaded yet (or empty result).</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<!-- Row modal -->
|
|
219
|
+
<div v-if="rowModal.open" class="fixed inset-0 bg-black/40 flex items-center justify-center p-6" @click.self="rowModal.open=false">
|
|
220
|
+
<div class="bg-white w-full max-w-3xl rounded-lg shadow">
|
|
221
|
+
<div class="p-4 border-b flex items-center justify-between">
|
|
222
|
+
<div class="font-semibold text-gray-900">Row / Document</div>
|
|
223
|
+
<button @click="rowModal.open=false" class="text-gray-500 hover:text-gray-900"><i class="ti ti-x"></i></button>
|
|
224
|
+
</div>
|
|
225
|
+
<pre class="p-4 text-xs overflow-auto" style="max-height: 70vh;">{{ JSON.stringify(rowModal.row, null, 2) }}</pre>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<script>
|
|
233
|
+
const { createApp, computed, reactive, ref } = Vue;
|
|
234
|
+
|
|
235
|
+
createApp({
|
|
236
|
+
setup() {
|
|
237
|
+
const baseUrl = '<%= baseUrl %>';
|
|
238
|
+
const apiBase = baseUrl + '/api/admin/db-browser';
|
|
239
|
+
|
|
240
|
+
const error = ref('');
|
|
241
|
+
const connections = ref([]);
|
|
242
|
+
const selectedConnectionId = ref('');
|
|
243
|
+
const selectedConnection = computed(() => connections.value.find(c => c.id === selectedConnectionId.value) || null);
|
|
244
|
+
|
|
245
|
+
const editing = ref(false);
|
|
246
|
+
const editingId = ref('');
|
|
247
|
+
const form = reactive({ name: '', type: 'mongo', uri: '', enabled: true });
|
|
248
|
+
|
|
249
|
+
const databases = ref([]);
|
|
250
|
+
const namespaces = ref([]);
|
|
251
|
+
const selectedDatabase = ref('');
|
|
252
|
+
const selectedNamespace = ref('');
|
|
253
|
+
const schema = ref(null);
|
|
254
|
+
const query = reactive({ page: 1, pageSize: 20, filterField: '', filterValue: '', sortField: '', sortOrder: 'desc' });
|
|
255
|
+
const records = reactive({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 1 });
|
|
256
|
+
|
|
257
|
+
const rowModal = reactive({ open: false, row: null });
|
|
258
|
+
|
|
259
|
+
const schemaFields = computed(() => (Array.isArray(schema.value) ? schema.value.map(c => c.field).filter(Boolean) : []));
|
|
260
|
+
const canBrowse = computed(() => Boolean(selectedConnection.value && selectedDatabase.value && selectedNamespace.value));
|
|
261
|
+
|
|
262
|
+
const tableHeaders = computed(() => {
|
|
263
|
+
const first = records.items?.[0];
|
|
264
|
+
if (!first || typeof first !== 'object') return [];
|
|
265
|
+
return Object.keys(first);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
function clearError() { error.value = ''; }
|
|
269
|
+
|
|
270
|
+
async function api(path, opts = {}) {
|
|
271
|
+
clearError();
|
|
272
|
+
const res = await fetch(apiBase + path, {
|
|
273
|
+
credentials: 'same-origin',
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
...opts,
|
|
276
|
+
});
|
|
277
|
+
const json = await res.json().catch(() => ({}));
|
|
278
|
+
if (!res.ok) throw new Error(json.error || 'Request failed');
|
|
279
|
+
return json;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadConnections() {
|
|
283
|
+
const data = await api('/connections', { method: 'GET' });
|
|
284
|
+
connections.value = data.items || [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function createConnection() {
|
|
288
|
+
const payload = { name: form.name, type: form.type, enabled: !!form.enabled, uri: form.uri };
|
|
289
|
+
const data = await api('/connections', { method: 'POST', body: JSON.stringify(payload) });
|
|
290
|
+
connections.value.unshift(data.item);
|
|
291
|
+
form.name = ''; form.uri = ''; form.type = 'mongo'; form.enabled = true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function startEdit(c) {
|
|
295
|
+
editing.value = true;
|
|
296
|
+
editingId.value = c.id;
|
|
297
|
+
form.name = c.name;
|
|
298
|
+
form.type = c.type;
|
|
299
|
+
form.enabled = !!c.enabled;
|
|
300
|
+
form.uri = '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function cancelEdit() {
|
|
304
|
+
editing.value = false;
|
|
305
|
+
editingId.value = '';
|
|
306
|
+
form.name = ''; form.uri = ''; form.type = 'mongo'; form.enabled = true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function saveEdit() {
|
|
310
|
+
const payload = { name: form.name, type: form.type, enabled: !!form.enabled };
|
|
311
|
+
if (String(form.uri || '').trim()) payload.uri = form.uri;
|
|
312
|
+
const data = await api('/connections/' + editingId.value, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
313
|
+
const idx = connections.value.findIndex(x => x.id === editingId.value);
|
|
314
|
+
if (idx >= 0) connections.value[idx] = data.item;
|
|
315
|
+
cancelEdit();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function deleteConnection(id) {
|
|
319
|
+
if (!confirm('Delete this connection?')) return;
|
|
320
|
+
await api('/connections/' + id, { method: 'DELETE' });
|
|
321
|
+
connections.value = connections.value.filter(c => c.id !== id);
|
|
322
|
+
if (selectedConnectionId.value === id) {
|
|
323
|
+
selectedConnectionId.value = '';
|
|
324
|
+
databases.value = []; namespaces.value = []; selectedDatabase.value = ''; selectedNamespace.value = ''; schema.value = null;
|
|
325
|
+
records.items = []; records.total = 0; records.page = 1; records.pageSize = 20; records.totalPages = 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function testConnection(id) {
|
|
330
|
+
const out = await api('/connections/' + id + '/test', { method: 'POST' });
|
|
331
|
+
alert(out.ok ? 'OK' : 'Failed');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function toggleEnabled(c) {
|
|
335
|
+
const data = await api('/connections/' + c.id, { method: 'PATCH', body: JSON.stringify({ enabled: !c.enabled }) });
|
|
336
|
+
const idx = connections.value.findIndex(x => x.id === c.id);
|
|
337
|
+
if (idx >= 0) connections.value[idx] = data.item;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function selectConnection(c) {
|
|
341
|
+
selectedConnectionId.value = c.id;
|
|
342
|
+
databases.value = []; namespaces.value = []; selectedDatabase.value = ''; selectedNamespace.value = ''; schema.value = null;
|
|
343
|
+
records.items = []; records.total = 0; records.page = 1; records.pageSize = query.pageSize; records.totalPages = 1;
|
|
344
|
+
const data = await api('/connections/' + c.id + '/databases', { method: 'GET' });
|
|
345
|
+
databases.value = (data.items || []);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function onDatabaseChange() {
|
|
349
|
+
if (!selectedConnection.value || !selectedDatabase.value) return;
|
|
350
|
+
namespaces.value = []; selectedNamespace.value = ''; schema.value = null;
|
|
351
|
+
const data = await api(`/connections/${selectedConnectionId.value}/databases/${encodeURIComponent(selectedDatabase.value)}/namespaces`, { method: 'GET' });
|
|
352
|
+
namespaces.value = (data.items || []);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function onNamespaceChange() {
|
|
356
|
+
schema.value = null;
|
|
357
|
+
records.items = []; records.total = 0; records.totalPages = 1;
|
|
358
|
+
if (!canBrowse.value) return;
|
|
359
|
+
const data = await api(`/connections/${selectedConnectionId.value}/databases/${encodeURIComponent(selectedDatabase.value)}/namespaces/${encodeURIComponent(selectedNamespace.value)}/schema`, { method: 'GET' });
|
|
360
|
+
schema.value = data.schema;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function loadRecords() {
|
|
364
|
+
if (!canBrowse.value) return;
|
|
365
|
+
const qs = new URLSearchParams();
|
|
366
|
+
qs.set('page', String(query.page || 1));
|
|
367
|
+
qs.set('pageSize', String(query.pageSize || 20));
|
|
368
|
+
if (String(query.filterField || '').trim()) qs.set('filterField', query.filterField);
|
|
369
|
+
if (String(query.filterValue || '').trim()) qs.set('filterValue', query.filterValue);
|
|
370
|
+
if (String(query.sortField || '').trim()) qs.set('sortField', query.sortField);
|
|
371
|
+
if (String(query.sortOrder || '').trim()) qs.set('sortOrder', query.sortOrder);
|
|
372
|
+
|
|
373
|
+
const data = await api(`/connections/${selectedConnectionId.value}/databases/${encodeURIComponent(selectedDatabase.value)}/namespaces/${encodeURIComponent(selectedNamespace.value)}/records?` + qs.toString(), { method: 'GET' });
|
|
374
|
+
records.items = data.items || [];
|
|
375
|
+
records.total = data.total || 0;
|
|
376
|
+
records.page = data.page || 1;
|
|
377
|
+
records.pageSize = data.pageSize || 20;
|
|
378
|
+
records.totalPages = data.totalPages || 1;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function prevPage() { if (records.page > 1) { query.page = records.page - 1; loadRecords(); } }
|
|
382
|
+
function nextPage() { if (records.page < records.totalPages) { query.page = records.page + 1; loadRecords(); } }
|
|
383
|
+
|
|
384
|
+
function openRow(row) { rowModal.row = row; rowModal.open = true; }
|
|
385
|
+
|
|
386
|
+
function formatCell(v) {
|
|
387
|
+
if (v === null || v === undefined) return '';
|
|
388
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
389
|
+
return String(v);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function refreshAll() {
|
|
393
|
+
try {
|
|
394
|
+
await loadConnections();
|
|
395
|
+
if (selectedConnection.value) {
|
|
396
|
+
await selectConnection(selectedConnection.value);
|
|
397
|
+
}
|
|
398
|
+
} catch (e) {
|
|
399
|
+
error.value = e.message || String(e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// bootstrap
|
|
404
|
+
loadConnections().catch((e) => (error.value = e.message || String(e)));
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
error,
|
|
408
|
+
connections,
|
|
409
|
+
selectedConnectionId,
|
|
410
|
+
selectedConnection,
|
|
411
|
+
databases,
|
|
412
|
+
namespaces,
|
|
413
|
+
selectedDatabase,
|
|
414
|
+
selectedNamespace,
|
|
415
|
+
schemaFields,
|
|
416
|
+
schema,
|
|
417
|
+
canBrowse,
|
|
418
|
+
query,
|
|
419
|
+
records,
|
|
420
|
+
tableHeaders,
|
|
421
|
+
rowModal,
|
|
422
|
+
editing,
|
|
423
|
+
form,
|
|
424
|
+
refreshAll,
|
|
425
|
+
selectConnection,
|
|
426
|
+
createConnection,
|
|
427
|
+
deleteConnection,
|
|
428
|
+
testConnection,
|
|
429
|
+
toggleEnabled,
|
|
430
|
+
startEdit,
|
|
431
|
+
cancelEdit,
|
|
432
|
+
saveEdit,
|
|
433
|
+
onDatabaseChange,
|
|
434
|
+
onNamespaceChange,
|
|
435
|
+
loadRecords,
|
|
436
|
+
prevPage,
|
|
437
|
+
nextPage,
|
|
438
|
+
openRow,
|
|
439
|
+
formatCell,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}).mount('#app');
|
|
443
|
+
</script>
|
|
444
|
+
</body>
|
|
445
|
+
</html>
|
|
@@ -29,15 +29,13 @@
|
|
|
29
29
|
<select id="fileSelect" class="w-full border rounded px-2 py-2 text-sm"></select>
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<input id="model" class="w-full border rounded px-2 py-2 text-sm" placeholder="e.g. x-ai/grok-code-fast-1" />
|
|
40
|
-
</div>
|
|
32
|
+
<%- include('partials/llm-provider-model-picker', {
|
|
33
|
+
providerInputId: 'providerKey',
|
|
34
|
+
modelInputId: 'model',
|
|
35
|
+
providerLabel: 'LLM Provider Key',
|
|
36
|
+
modelLabel: 'Model',
|
|
37
|
+
showOpenRouterFetch: true,
|
|
38
|
+
}) %>
|
|
41
39
|
|
|
42
40
|
<div class="flex items-center gap-2">
|
|
43
41
|
<button id="btnReload" class="px-3 py-2 bg-gray-200 text-gray-900 rounded text-sm">Reload</button>
|
|
@@ -78,7 +76,7 @@
|
|
|
78
76
|
</div>
|
|
79
77
|
|
|
80
78
|
<script>
|
|
81
|
-
const API_BASE = window.location.origin + "<%= baseUrl %>"
|
|
79
|
+
const API_BASE = window.location.origin + "<%= baseUrl || '' %>";
|
|
82
80
|
|
|
83
81
|
const STORAGE_KEYS = {
|
|
84
82
|
providerKey: 'ejsVirtual.providerKey',
|
|
@@ -106,6 +104,14 @@
|
|
|
106
104
|
const model = localStorage.getItem(STORAGE_KEYS.model) || '';
|
|
107
105
|
document.getElementById('providerKey').value = pk;
|
|
108
106
|
document.getElementById('model').value = model;
|
|
107
|
+
|
|
108
|
+
if (window.__llmProviderModelPicker && window.__llmProviderModelPicker.init) {
|
|
109
|
+
window.__llmProviderModelPicker.init({
|
|
110
|
+
apiBase: API_BASE,
|
|
111
|
+
providerInputId: 'providerKey',
|
|
112
|
+
modelInputId: 'model',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function saveSettings() {
|
|
@@ -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>
|