@intranefr/superbackend 1.5.2 → 1.6.3

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.
Files changed (134) hide show
  1. package/cookies.txt +6 -0
  2. package/cookies1.txt +6 -0
  3. package/cookies2.txt +6 -0
  4. package/cookies3.txt +6 -0
  5. package/cookies4.txt +5 -0
  6. package/cookies_old.txt +5 -0
  7. package/cookies_old_test.txt +6 -0
  8. package/cookies_super.txt +5 -0
  9. package/cookies_super_test.txt +6 -0
  10. package/cookies_test.txt +6 -0
  11. package/index.js +9 -0
  12. package/manage.js +745 -0
  13. package/package.json +6 -2
  14. package/plugins/core-waiting-list-migration/README.md +118 -0
  15. package/plugins/core-waiting-list-migration/index.js +438 -0
  16. package/plugins/global-settings-presets/index.js +20 -0
  17. package/plugins/hello-cli/index.js +17 -0
  18. package/plugins/ui-components-seeder/components/suiAlert.js +212 -0
  19. package/plugins/ui-components-seeder/components/suiToast.js +186 -0
  20. package/plugins/ui-components-seeder/index.js +31 -0
  21. package/public/js/admin-ui-components-preview.js +281 -0
  22. package/public/js/admin-ui-components.js +408 -0
  23. package/public/js/llm-provider-model-picker.js +193 -0
  24. package/public/test-iframe-fix.html +63 -0
  25. package/public/test-iframe.html +14 -0
  26. package/src/admin/endpointRegistry.js +68 -0
  27. package/src/controllers/admin.controller.js +36 -10
  28. package/src/controllers/adminAgents.controller.js +37 -0
  29. package/src/controllers/adminDataCleanup.controller.js +45 -0
  30. package/src/controllers/adminLlm.controller.js +19 -8
  31. package/src/controllers/adminLogin.controller.js +269 -0
  32. package/src/controllers/adminMarkdowns.controller.js +157 -0
  33. package/src/controllers/adminPlugins.controller.js +55 -0
  34. package/src/controllers/adminRegistry.controller.js +106 -0
  35. package/src/controllers/adminScripts.controller.js +138 -0
  36. package/src/controllers/adminStats.controller.js +4 -4
  37. package/src/controllers/adminTelegram.controller.js +72 -0
  38. package/src/controllers/markdowns.controller.js +42 -0
  39. package/src/controllers/registry.controller.js +32 -0
  40. package/src/controllers/waitingList.controller.js +52 -74
  41. package/src/helpers/mongooseHelper.js +6 -6
  42. package/src/helpers/scriptBase.js +2 -2
  43. package/src/middleware/auth.js +71 -1
  44. package/src/middleware/rbac.js +62 -0
  45. package/src/middleware.js +584 -176
  46. package/src/models/Agent.js +105 -0
  47. package/src/models/AgentMessage.js +82 -0
  48. package/src/models/GlobalSetting.js +11 -1
  49. package/src/models/Markdown.js +75 -0
  50. package/src/models/ScriptRun.js +8 -0
  51. package/src/models/TelegramBot.js +42 -0
  52. package/src/models/UiComponent.js +2 -0
  53. package/src/models/User.js +1 -1
  54. package/src/routes/admin.routes.js +3 -3
  55. package/src/routes/adminAgents.routes.js +13 -0
  56. package/src/routes/adminAssets.routes.js +11 -11
  57. package/src/routes/adminBlog.routes.js +2 -2
  58. package/src/routes/adminBlogAi.routes.js +2 -2
  59. package/src/routes/adminBlogAutomation.routes.js +2 -2
  60. package/src/routes/adminCache.routes.js +2 -2
  61. package/src/routes/adminConsoleManager.routes.js +2 -2
  62. package/src/routes/adminCrons.routes.js +2 -2
  63. package/src/routes/adminDataCleanup.routes.js +26 -0
  64. package/src/routes/adminDbBrowser.routes.js +2 -2
  65. package/src/routes/adminEjsVirtual.routes.js +2 -2
  66. package/src/routes/adminFeatureFlags.routes.js +6 -6
  67. package/src/routes/adminHeadless.routes.js +2 -2
  68. package/src/routes/adminHealthChecks.routes.js +2 -2
  69. package/src/routes/adminI18n.routes.js +2 -2
  70. package/src/routes/adminJsonConfigs.routes.js +8 -8
  71. package/src/routes/adminLlm.routes.js +8 -7
  72. package/src/routes/adminLogin.routes.js +23 -0
  73. package/src/routes/adminMarkdowns.routes.js +10 -0
  74. package/src/routes/adminMigration.routes.js +12 -12
  75. package/src/routes/adminPages.routes.js +2 -2
  76. package/src/routes/adminPlugins.routes.js +15 -0
  77. package/src/routes/adminProxy.routes.js +2 -2
  78. package/src/routes/adminRateLimits.routes.js +8 -8
  79. package/src/routes/adminRbac.routes.js +2 -2
  80. package/src/routes/adminRegistry.routes.js +24 -0
  81. package/src/routes/adminScripts.routes.js +6 -3
  82. package/src/routes/adminSeoConfig.routes.js +10 -10
  83. package/src/routes/adminTelegram.routes.js +14 -0
  84. package/src/routes/adminTerminals.routes.js +2 -2
  85. package/src/routes/adminUiComponents.routes.js +2 -2
  86. package/src/routes/adminUploadNamespaces.routes.js +7 -7
  87. package/src/routes/blogInternal.routes.js +2 -2
  88. package/src/routes/experiments.routes.js +2 -2
  89. package/src/routes/formsAdmin.routes.js +6 -6
  90. package/src/routes/globalSettings.routes.js +8 -8
  91. package/src/routes/internalExperiments.routes.js +2 -2
  92. package/src/routes/markdowns.routes.js +16 -0
  93. package/src/routes/notificationAdmin.routes.js +7 -7
  94. package/src/routes/orgAdmin.routes.js +16 -16
  95. package/src/routes/pages.routes.js +3 -3
  96. package/src/routes/registry.routes.js +11 -0
  97. package/src/routes/stripeAdmin.routes.js +12 -12
  98. package/src/routes/userAdmin.routes.js +7 -7
  99. package/src/routes/waitingListAdmin.routes.js +2 -2
  100. package/src/routes/workflows.routes.js +3 -3
  101. package/src/services/agent.service.js +546 -0
  102. package/src/services/agentHistory.service.js +345 -0
  103. package/src/services/agentTools.service.js +578 -0
  104. package/src/services/dataCleanup.service.js +286 -0
  105. package/src/services/jsonConfigs.service.js +284 -10
  106. package/src/services/llm.service.js +219 -6
  107. package/src/services/markdowns.service.js +522 -0
  108. package/src/services/plugins.service.js +348 -0
  109. package/src/services/registry.service.js +452 -0
  110. package/src/services/scriptsRunner.service.js +328 -37
  111. package/src/services/telegram.service.js +130 -0
  112. package/src/services/uiComponents.service.js +180 -0
  113. package/src/services/waitingListJson.service.js +401 -0
  114. package/src/utils/rbac/rightsRegistry.js +118 -0
  115. package/test-access.js +63 -0
  116. package/test-iframe-fix.html +63 -0
  117. package/test-iframe.html +14 -0
  118. package/views/admin-403.ejs +92 -0
  119. package/views/admin-agents.ejs +273 -0
  120. package/views/admin-coolify-deploy.ejs +8 -8
  121. package/views/admin-dashboard-home.ejs +52 -2
  122. package/views/admin-dashboard.ejs +179 -7
  123. package/views/admin-data-cleanup.ejs +357 -0
  124. package/views/admin-experiments.ejs +1 -1
  125. package/views/admin-login.ejs +286 -0
  126. package/views/admin-markdowns.ejs +905 -0
  127. package/views/admin-plugins-system.ejs +223 -0
  128. package/views/admin-scripts.ejs +221 -4
  129. package/views/admin-telegram.ejs +269 -0
  130. package/views/admin-ui-components.ejs +82 -402
  131. package/views/admin-users.ejs +207 -11
  132. package/views/partials/dashboard/nav-items.ejs +5 -0
  133. package/views/partials/llm-provider-model-picker.ejs +0 -161
  134. package/analysis-only.skill +0 -0
@@ -15,7 +15,7 @@
15
15
  <body class="bg-gray-50 overflow-hidden">
16
16
  <div id="app" class="h-screen flex flex-col" v-cloak>
17
17
  <!-- Top Header -->
18
- <header class="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 shrink-0">
18
+ <header v-if="!globalZenMode" class="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 shrink-0">
19
19
  <div class="flex items-center gap-4">
20
20
  <i class="ti ti-layout-dashboard text-2xl text-blue-600"></i>
21
21
  <h1 class="text-xl font-bold text-gray-800">
@@ -30,6 +30,38 @@
30
30
  <span class="ml-1">to search</span>
31
31
  </div>
32
32
  <div class="flex items-center gap-4">
33
+ <!-- User Info & Logout -->
34
+ <div class="flex items-center gap-3">
35
+ <div class="text-right">
36
+ <div class="text-sm font-medium text-gray-700">{{ currentUser.name || currentUser.username || 'Admin User' }}</div>
37
+ <div class="text-xs text-gray-500">{{ currentUser.email || currentUser.authType + ' authentication' }}</div>
38
+ </div>
39
+ <div class="relative">
40
+ <button
41
+ @click="showUserMenu = !showUserMenu"
42
+ class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors">
43
+ <i class="ti ti-user text-sm"></i>
44
+ </button>
45
+
46
+ <!-- User Dropdown Menu -->
47
+ <div v-if="showUserMenu" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
48
+ <div class="px-4 py-2 border-b border-gray-100">
49
+ <div class="text-sm font-medium text-gray-900">{{ currentUser.name || currentUser.username || 'Admin User' }}</div>
50
+ <div class="text-xs text-gray-500">{{ currentUser.role }}</div>
51
+ </div>
52
+ <button
53
+ @click="handleLogout"
54
+ class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2">
55
+ <i class="ti ti-logout"></i>
56
+ Logout
57
+ </button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <button @click="globalZenMode = !globalZenMode" class="text-gray-500 hover:text-blue-600 p-1.5 rounded-lg border border-gray-200 bg-white" title="Toggle Global Zen Mode (ESC ESC to exit)">
63
+ <i class="ti ti-maximize"></i>
64
+ </button>
33
65
  <span class="text-sm text-gray-500">v1.0.0</span>
34
66
  <a :href="baseUrl + adminBase + '/api/test'" target="_blank" class="text-sm text-blue-600 hover:underline">API Test</a>
35
67
  </div>
@@ -37,11 +69,15 @@
37
69
  </header>
38
70
 
39
71
  <div class="flex-1 flex overflow-hidden">
40
- <%- include('partials/dashboard/sidebar') %>
72
+ <template v-if="!globalZenMode">
73
+ <%- include('partials/dashboard/sidebar') %>
74
+ </template>
41
75
 
42
76
  <!-- Main Content Area -->
43
77
  <main class="flex-1 flex flex-col overflow-hidden bg-gray-50">
44
- <%- include('partials/dashboard/tab-bar') %>
78
+ <template v-if="!globalZenMode">
79
+ <%- include('partials/dashboard/tab-bar') %>
80
+ </template>
45
81
 
46
82
  <div class="flex-1 relative">
47
83
  <!-- Iframes -->
@@ -49,7 +85,7 @@
49
85
  v-for="tab in tabs"
50
86
  v-show="activeTabId === tab.id"
51
87
  :key="tab.id"
52
- :src="baseUrl + tab.path"
88
+ :src="getIframeSrc(tab.path)"
53
89
  class="absolute inset-0 w-full h-full border-none"
54
90
  :id="'frame-' + tab.id"
55
91
  ></iframe>
@@ -89,6 +125,15 @@
89
125
  // Tabs state
90
126
  const tabs = ref([]);
91
127
  const activeTabId = ref(null);
128
+ const globalZenMode = ref(false);
129
+
130
+ // User authentication state
131
+ const currentUser = ref({});
132
+ const showUserMenu = ref(false);
133
+
134
+ // ESC ESC to exit Zen Mode
135
+ let escCount = 0;
136
+ let escTimeout = null;
92
137
 
93
138
  // localStorage utilities
94
139
  const STORAGE_KEY = 'adminDashboardTabs';
@@ -342,6 +387,19 @@
342
387
 
343
388
  // Keyboard events
344
389
  const handleKeydown = (e) => {
390
+ if (e.key === 'Escape') {
391
+ escCount++;
392
+ clearTimeout(escTimeout);
393
+ if (escCount >= 2) {
394
+ globalZenMode.value = false;
395
+ escCount = 0;
396
+ } else {
397
+ escTimeout = setTimeout(() => {
398
+ escCount = 0;
399
+ }, 500);
400
+ }
401
+ }
402
+
345
403
  if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
346
404
  e.preventDefault();
347
405
  e.stopPropagation();
@@ -350,14 +408,69 @@
350
408
  };
351
409
 
352
410
  const handleMessage = (e) => {
353
- if (e.data && e.data.type === 'keydown' && e.data.ctrlK) {
354
- togglePalette('iframe-message');
411
+ if (e.data && e.data.type === 'keydown') {
412
+ if (e.data.ctrlK) {
413
+ togglePalette('iframe-message');
414
+ }
415
+ if (e.data.key === 'Escape') {
416
+ handleKeydown({ key: 'Escape' });
417
+ }
418
+ }
419
+ };
420
+
421
+ // User authentication functions
422
+ const fetchCurrentUser = async () => {
423
+ try {
424
+ const response = await fetch(`${adminBase}/auth-status`);
425
+ if (response.ok) {
426
+ const userData = await response.json();
427
+ if (userData.authenticated) {
428
+ currentUser.value = userData.user || {};
429
+ }
430
+ }
431
+ } catch (error) {
432
+ console.error('Failed to fetch user data:', error);
433
+ // Set default user data on error
434
+ currentUser.value = { name: 'Admin User', authType: 'unknown' };
435
+ }
436
+ };
437
+
438
+ const handleLogout = async () => {
439
+ try {
440
+ const response = await fetch(`${adminBase}/logout`, {
441
+ method: 'POST',
442
+ headers: {
443
+ 'Content-Type': 'application/x-www-form-urlencoded',
444
+ }
445
+ });
446
+
447
+ if (response.redirected) {
448
+ window.location.href = response.url;
449
+ } else {
450
+ // Fallback redirect
451
+ window.location.href = `${adminBase}/login`;
452
+ }
453
+ } catch (error) {
454
+ console.error('Logout error:', error);
455
+ // Fallback redirect
456
+ window.location.href = `${adminBase}/login`;
457
+ }
458
+ };
459
+
460
+ // Close user menu when clicking outside
461
+ const handleClickOutside = (event) => {
462
+ if (showUserMenu.value && !event.target.closest('.relative')) {
463
+ showUserMenu.value = false;
355
464
  }
356
465
  };
357
466
 
358
467
  onMounted(() => {
359
468
  window.addEventListener('keydown', handleKeydown);
360
469
  window.addEventListener('message', handleMessage);
470
+ window.addEventListener('click', handleClickOutside);
471
+
472
+ // Fetch current user data
473
+ fetchCurrentUser();
361
474
 
362
475
  // Load saved tabs from localStorage
363
476
  const savedState = loadTabsFromStorage();
@@ -381,14 +494,26 @@
381
494
  onUnmounted(() => {
382
495
  window.removeEventListener('keydown', handleKeydown);
383
496
  window.removeEventListener('message', handleMessage);
497
+ window.removeEventListener('click', handleClickOutside);
384
498
  });
385
499
 
500
+ const getIframeSrc = (path) => {
501
+ // Ensure path starts with /
502
+ const cleanPath = path.startsWith('/') ? path : ('/' + path);
503
+ // Add iframe token for authentication
504
+ const separator = cleanPath.includes('?') ? '&' : '?';
505
+ return cleanPath + separator + 'iframe_token=authenticated';
506
+ };
507
+
386
508
  return {
387
509
  baseUrl,
388
510
  adminBase,
389
511
  navSections,
390
512
  tabs,
391
513
  activeTabId,
514
+ globalZenMode,
515
+ currentUser,
516
+ showUserMenu,
392
517
  openTab,
393
518
  closeTab,
394
519
  showPalette,
@@ -400,12 +525,59 @@
400
525
  closePalette,
401
526
  navigatePalette,
402
527
  selectPaletteItem,
403
- selectModule
528
+ selectModule,
529
+ handleLogout,
530
+ getIframeSrc
404
531
  };
405
532
  }
406
533
  }).mount('#app');
407
534
  </script>
408
535
  <script>
536
+ // Handle iframe communication for API requests
537
+ window.addEventListener('message', async (event) => {
538
+ if (event.data.type === 'stats-request') {
539
+ try {
540
+ const response = await fetch(event.data.endpoint);
541
+ const data = await response.json();
542
+
543
+ // Send response back to iframe
544
+ event.source.postMessage({
545
+ type: 'stats-response',
546
+ messageId: event.data.messageId,
547
+ data: data
548
+ }, '*');
549
+ } catch (error) {
550
+ // Send error back to iframe
551
+ event.source.postMessage({
552
+ type: 'stats-response',
553
+ messageId: event.data.messageId,
554
+ error: error.message
555
+ }, '*');
556
+ }
557
+ } else if (event.data.type === 'api-request') {
558
+ try {
559
+ const response = await fetch(event.data.url, event.data.options);
560
+ const data = await response.json();
561
+
562
+ // Send response back to iframe
563
+ event.source.postMessage({
564
+ type: 'api-response',
565
+ messageId: event.data.messageId,
566
+ ok: response.ok,
567
+ data: data
568
+ }, '*');
569
+ } catch (error) {
570
+ // Send error back to iframe
571
+ event.source.postMessage({
572
+ type: 'api-response',
573
+ messageId: event.data.messageId,
574
+ ok: false,
575
+ error: error.message
576
+ }, '*');
577
+ }
578
+ }
579
+ });
580
+
409
581
  window.addEventListener("keydown", (e) => {
410
582
  if ((e.ctrlKey || e.metaKey) && e.key === "k") {
411
583
  e.preventDefault();
@@ -0,0 +1,357 @@
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 Data Cleanup</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">Data cleanup</h1>
17
+ <div class="text-sm text-gray-500">Inspect MongoDB storage and run manual cleanup for old documents.</div>
18
+ </div>
19
+ <button @click="loadOverview" :disabled="loadingOverview" class="px-3 py-2 rounded bg-gray-800 text-white text-sm hover:bg-gray-900 disabled:opacity-50 disabled:cursor-not-allowed">
20
+ <i v-if="!loadingOverview" class="ti ti-refresh mr-1"></i>
21
+ <span v-if="loadingOverview">Refreshing...</span>
22
+ <span v-else>Refresh</span>
23
+ </button>
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">{{ error }}</div>
27
+
28
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
29
+ <div class="bg-white border rounded p-4">
30
+ <div class="text-xs text-gray-500 uppercase">Collections</div>
31
+ <div class="text-2xl font-bold text-gray-900">{{ overview.global.collections || 0 }}</div>
32
+ </div>
33
+ <div class="bg-white border rounded p-4">
34
+ <div class="text-xs text-gray-500 uppercase">Data size</div>
35
+ <div class="text-2xl font-bold text-gray-900">{{ bytes(overview.global.dataSizeBytes) }}</div>
36
+ </div>
37
+ <div class="bg-white border rounded p-4">
38
+ <div class="text-xs text-gray-500 uppercase">Total size</div>
39
+ <div class="text-2xl font-bold text-gray-900">{{ bytes(overview.global.totalSizeBytes) }}</div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="bg-white border rounded mb-6">
44
+ <div class="p-4 border-b font-semibold text-gray-800">Collections</div>
45
+ <div class="overflow-auto" style="max-height:320px;">
46
+ <table class="min-w-full text-sm">
47
+ <thead class="bg-gray-50 sticky top-0">
48
+ <tr>
49
+ <th class="text-left p-2">Collection</th>
50
+ <th class="text-left p-2">Count</th>
51
+ <th class="text-left p-2">Data</th>
52
+ <th class="text-left p-2">Storage</th>
53
+ <th class="text-left p-2">Indexes</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ <tr
58
+ v-for="c in sortedCollections"
59
+ :key="c.name"
60
+ class="border-t cursor-pointer hover:bg-gray-50"
61
+ @click="form.collection = c.name"
62
+ >
63
+ <td class="p-2">{{ c.name }}</td>
64
+ <td class="p-2">{{ c.count }}</td>
65
+ <td class="p-2">{{ bytes(c.sizeBytes) }}</td>
66
+ <td class="p-2">{{ bytes(c.storageSizeBytes) }}</td>
67
+ <td class="p-2">{{ bytes(c.totalIndexSizeBytes) }}</td>
68
+ </tr>
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
75
+ <div class="bg-white border rounded p-4 space-y-3">
76
+ <h2 class="font-semibold text-gray-900">Dry run</h2>
77
+ <div>
78
+ <label class="text-xs text-gray-600">Collection</label>
79
+ <select v-model="form.collection" class="mt-1 w-full border rounded px-3 py-2">
80
+ <option value="">Select collection</option>
81
+ <option v-for="c in overview.collections" :key="c.name" :value="c.name">{{ c.name }}</option>
82
+ </select>
83
+ </div>
84
+ <div class="grid grid-cols-2 gap-3">
85
+ <div>
86
+ <label class="text-xs text-gray-600">Older than days</label>
87
+ <input v-model.number="form.olderThanDays" type="number" min="1" class="mt-1 w-full border rounded px-3 py-2" />
88
+ </div>
89
+ <div>
90
+ <label class="text-xs text-gray-600">Date field</label>
91
+ <input v-model="form.dateField" class="mt-1 w-full border rounded px-3 py-2" />
92
+ </div>
93
+ </div>
94
+ <div v-if="loadingFields" class="text-xs text-gray-500">Loading fields...</div>
95
+ <div v-else-if="availableFields.length > 0" class="text-xs text-gray-600">
96
+ <span class="font-medium">Available fields:</span> {{ availableFields.join(', ') }}
97
+ </div>
98
+ <button @click="runDryRun" :disabled="loadingDryRun" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
99
+ <span v-if="loadingDryRun">Running...</span>
100
+ <span v-else>Run dry run</span>
101
+ </button>
102
+
103
+ <div v-if="dryRun" class="border rounded p-3 bg-blue-50 text-sm space-y-1">
104
+ <div><strong>Candidates:</strong> {{ dryRun.candidateCount }}</div>
105
+ <div><strong>Estimated reclaim:</strong> {{ bytes(dryRun.estimatedReclaimableBytes) }}</div>
106
+ <div><strong>Cutoff:</strong> {{ dryRun.cutoffIso }}</div>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="bg-white border rounded p-4 space-y-3">
111
+ <h2 class="font-semibold text-gray-900">Execute cleanup</h2>
112
+ <p class="text-xs text-orange-700 bg-orange-50 border border-orange-200 rounded p-2">
113
+ This operation is destructive. Dry run first, then confirm execution.
114
+ </p>
115
+
116
+ <!-- Recap section -->
117
+ <div v-if="form.collection" class="bg-gray-50 border rounded p-3 text-sm">
118
+ <div class="font-medium text-gray-700 mb-2">Operation recap:</div>
119
+ <div class="space-y-1">
120
+ <div><strong>Collection:</strong> {{ form.collection }}</div>
121
+ <div><strong>Older than:</strong> {{ form.olderThanDays }} days</div>
122
+ <div><strong>Date field:</strong> {{ form.dateField }}</div>
123
+ <div><strong>Delete limit:</strong> {{ form.limit }}</div>
124
+ </div>
125
+ </div>
126
+ <div v-else class="bg-gray-50 border rounded p-3 text-sm text-gray-500">
127
+ Select a collection in the dry run section to see operation details.
128
+ </div>
129
+
130
+ <div>
131
+ <label class="text-xs text-gray-600">Delete limit for this run</label>
132
+ <input v-model.number="form.limit" type="number" min="1" class="mt-1 w-full border rounded px-3 py-2" />
133
+ </div>
134
+ <label class="flex items-center gap-2 text-sm text-gray-700">
135
+ <input type="checkbox" v-model="form.confirm" /> I understand this deletes data permanently.
136
+ </label>
137
+ <button @click="executeCleanup" :disabled="loadingExecute || !form.collection || !form.confirm" class="px-3 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
138
+ <span v-if="loadingExecute">Executing...</span>
139
+ <span v-else>Execute cleanup</span>
140
+ </button>
141
+
142
+ <div v-if="execution" class="border rounded p-3 bg-red-50 text-sm space-y-1">
143
+ <div><strong>Deleted:</strong> {{ execution.deletedCount }}</div>
144
+ <div><strong>Duration:</strong> {{ execution.durationMs }} ms</div>
145
+ <div><strong>Limit:</strong> {{ execution.limitApplied }}</div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Confirmation Dialog - positioned outside main container but still in Vue app -->
151
+ <div v-if="showConfirmDialog && isMounted" v-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
152
+ <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
153
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Confirm Delete Operation</h3>
154
+ <div class="bg-red-50 border border-red-200 rounded p-3 mb-4 text-sm">
155
+ <div class="font-medium text-red-800 mb-2">You are about to permanently delete documents from:</div>
156
+ <div class="space-y-1">
157
+ <div><strong>Collection:</strong> <span class="text-red-600">{{ form.collection }}</span></div>
158
+ <div><strong>Older than:</strong> {{ form.olderThanDays }} days</div>
159
+ <div><strong>Date field:</strong> {{ form.dateField }}</div>
160
+ <div><strong>Max documents:</strong> {{ form.limit }}</div>
161
+ </div>
162
+ </div>
163
+ <div class="mb-4 text-sm text-gray-600">
164
+ Type the collection name <strong>"{{ form.collection }}"</strong> to confirm:
165
+ </div>
166
+ <input
167
+ v-model="confirmCollectionName"
168
+ type="text"
169
+ class="w-full border rounded px-3 py-2 mb-4"
170
+ placeholder="Enter collection name to confirm"
171
+ >
172
+ <div class="flex gap-3">
173
+ <button
174
+ @click="executeCleanup"
175
+ :disabled="confirmCollectionName !== form.collection || loadingExecute"
176
+ class="flex-1 px-3 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
177
+ >
178
+ <span v-if="loadingExecute">Deleting...</span>
179
+ <span v-else>Delete Documents</span>
180
+ </button>
181
+ <button
182
+ @click="cancelExecute"
183
+ :disabled="loadingExecute"
184
+ class="flex-1 px-3 py-2 rounded bg-gray-300 text-gray-700 text-sm hover:bg-gray-400 disabled:opacity-50 disabled:cursor-not-allowed"
185
+ >
186
+ Cancel
187
+ </button>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <script>
194
+ const { createApp, reactive, ref, computed, watch } = Vue;
195
+
196
+ createApp({
197
+ setup() {
198
+ const baseUrl = '<%= baseUrl %>';
199
+ const apiBase = baseUrl + '/api/admin/data-cleanup';
200
+
201
+ const error = ref('');
202
+ const overview = reactive({ global: {}, collections: [] });
203
+ const dryRun = ref(null);
204
+ const execution = ref(null);
205
+ const form = reactive({
206
+ collection: '',
207
+ olderThanDays: 30,
208
+ dateField: 'createdAt',
209
+ limit: 5000,
210
+ confirm: false,
211
+ });
212
+
213
+ const sortedCollections = computed(() => {
214
+ return [...(overview.collections || [])].sort((a, b) => (b.sizeBytes || 0) - (a.sizeBytes || 0));
215
+ });
216
+
217
+ const availableFields = ref([]);
218
+ const loadingFields = ref(false);
219
+ const loadingOverview = ref(false);
220
+ const loadingDryRun = ref(false);
221
+ const loadingExecute = ref(false);
222
+ const showConfirmDialog = ref(false);
223
+ const confirmCollectionName = ref('');
224
+ const isMounted = ref(false);
225
+
226
+ function bytes(v) {
227
+ const n = Number(v || 0);
228
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
229
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
230
+ let i = 0;
231
+ let x = n;
232
+ while (x >= 1024 && i < units.length - 1) { x /= 1024; i += 1; }
233
+ return `${x.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
234
+ }
235
+
236
+ async function api(path, opts = {}) {
237
+ error.value = '';
238
+ const res = await fetch(apiBase + path, {
239
+ credentials: 'same-origin',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ ...opts,
242
+ });
243
+ const data = await res.json().catch(() => ({}));
244
+ if (!res.ok) throw new Error(data.error || 'Request failed');
245
+ return data;
246
+ }
247
+
248
+ async function loadOverview() {
249
+ loadingOverview.value = true;
250
+ try {
251
+ const data = await api('/overview');
252
+ overview.global = data.global || {};
253
+ overview.collections = data.collections || [];
254
+ } finally {
255
+ loadingOverview.value = false;
256
+ }
257
+ }
258
+
259
+ async function runDryRun() {
260
+ loadingDryRun.value = true;
261
+ try {
262
+ dryRun.value = await api('/dry-run', {
263
+ method: 'POST',
264
+ body: JSON.stringify({
265
+ collection: form.collection,
266
+ olderThanDays: form.olderThanDays,
267
+ dateField: form.dateField,
268
+ }),
269
+ });
270
+ } finally {
271
+ loadingDryRun.value = false;
272
+ }
273
+ }
274
+
275
+ async function loadAvailableFields() {
276
+ if (!form.collection) {
277
+ availableFields.value = [];
278
+ return;
279
+ }
280
+ loadingFields.value = true;
281
+ try {
282
+ const data = await api(`/infer-fields?collection=${encodeURIComponent(form.collection)}`);
283
+ availableFields.value = data.fields || [];
284
+ } catch (e) {
285
+ availableFields.value = [];
286
+ } finally {
287
+ loadingFields.value = false;
288
+ }
289
+ }
290
+
291
+ async function executeCleanup() {
292
+ if (!showConfirmDialog.value) {
293
+ showConfirmDialog.value = true;
294
+ return;
295
+ }
296
+
297
+ loadingExecute.value = true;
298
+ try {
299
+ execution.value = await api('/execute', {
300
+ method: 'POST',
301
+ body: JSON.stringify({
302
+ collection: form.collection,
303
+ olderThanDays: form.olderThanDays,
304
+ dateField: form.dateField,
305
+ limit: form.limit,
306
+ confirm: form.confirm,
307
+ }),
308
+ });
309
+ await loadOverview();
310
+ showConfirmDialog.value = false;
311
+ } finally {
312
+ loadingExecute.value = false;
313
+ }
314
+ }
315
+
316
+ function cancelExecute() {
317
+ showConfirmDialog.value = false;
318
+ confirmCollectionName.value = '';
319
+ }
320
+
321
+ loadOverview().catch((e) => { error.value = e.message || String(e); });
322
+
323
+ // Add watcher for collection field
324
+ watch(() => form.collection, () => {
325
+ loadAvailableFields();
326
+ });
327
+
328
+ // Set mounted state after Vue is ready
329
+ isMounted.value = true;
330
+
331
+ return {
332
+ error,
333
+ overview,
334
+ sortedCollections,
335
+ dryRun,
336
+ execution,
337
+ form,
338
+ bytes,
339
+ loadOverview,
340
+ runDryRun,
341
+ executeCleanup,
342
+ cancelExecute,
343
+ availableFields,
344
+ loadingFields,
345
+ loadAvailableFields,
346
+ loadingOverview,
347
+ loadingDryRun,
348
+ loadingExecute,
349
+ showConfirmDialog,
350
+ confirmCollectionName,
351
+ isMounted,
352
+ };
353
+ },
354
+ }).mount('#app');
355
+ </script>
356
+ </body>
357
+ </html>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Experiments</title>
7
- <link rel="stylesheet" href="<%= baseUrl %><%= adminPath %>/assets/styles.css" />
7
+ <link rel="stylesheet" href="<%= baseUrl %><%= adminPath %>/assets/css/styles.css" />
8
8
  <style>
9
9
  body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; }
10
10
  .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }