@intranefr/superbackend 1.4.4 → 1.5.1

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 (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -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
- <div class="min-w-[200px]">
33
- <label class="block text-xs font-medium text-gray-600 mb-1">LLM Provider Key</label>
34
- <input id="providerKey" class="w-full border rounded px-2 py-2 text-sm" placeholder="e.g. openrouter" />
35
- </div>
36
-
37
- <div class="min-w-[260px]">
38
- <label class="block text-xs font-medium text-gray-600 mb-1">Model</label>
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 %>" || window.location.origin;
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() {