keepup-http 0.5.0__py3-none-any.whl

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 (43) hide show
  1. keepup_http/__init__.py +38 -0
  2. keepup_http/api_docs.py +76 -0
  3. keepup_http/body_limit.py +156 -0
  4. keepup_http/events_api.py +237 -0
  5. keepup_http/factory.py +562 -0
  6. keepup_http/lock_routes.py +155 -0
  7. keepup_http/plugin.py +53 -0
  8. keepup_http/routes.py +549 -0
  9. keepup_http/static/css/aos.css +1 -0
  10. keepup_http/static/css/main_nebula.css +232 -0
  11. keepup_http/static/css/main_new.css +869 -0
  12. keepup_http/static/css/tailwind.css +1 -0
  13. keepup_http/static/index_nebula.html +282 -0
  14. keepup_http/static/index_new.html +275 -0
  15. keepup_http/static/js/aos.js +1 -0
  16. keepup_http/static/js/feather-icons.js +13 -0
  17. keepup_http/static/js/layout_bootstrap.js +29 -0
  18. keepup_http/static/js/main_new.js +2076 -0
  19. keepup_http/static/js/tailwind.js +83 -0
  20. keepup_http/static/modules/css/background_tasks.css +233 -0
  21. keepup_http/static/modules/css/cluster.css +16 -0
  22. keepup_http/static/modules/css/event_manager.css +386 -0
  23. keepup_http/static/modules/css/integration_logs.css +33 -0
  24. keepup_http/static/modules/css/metrics.css +115 -0
  25. keepup_http/static/modules/css/modules.css +189 -0
  26. keepup_http/static/modules/css/themes.css +563 -0
  27. keepup_http/static/modules/css/users.css +278 -0
  28. keepup_http/static/modules/js/background_tasks.js +695 -0
  29. keepup_http/static/modules/js/chart.js +14 -0
  30. keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  31. keepup_http/static/modules/js/cluster.js +374 -0
  32. keepup_http/static/modules/js/event_manager.js +1007 -0
  33. keepup_http/static/modules/js/integration_logs.js +795 -0
  34. keepup_http/static/modules/js/metrics.js +929 -0
  35. keepup_http/static/modules/js/modules.js +1128 -0
  36. keepup_http/static/modules/js/themes.js +684 -0
  37. keepup_http/static/modules/js/users.js +898 -0
  38. keepup_http/web.py +214 -0
  39. keepup_http-0.5.0.dist-info/METADATA +29 -0
  40. keepup_http-0.5.0.dist-info/RECORD +43 -0
  41. keepup_http-0.5.0.dist-info/WHEEL +5 -0
  42. keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
  43. keepup_http-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1128 @@
1
+ var currentEditingModule = null;
2
+ let modulesDataLoaded = false;
3
+ let isModulesDataLoading = false;
4
+ let isModulesRouteActive = false;
5
+
6
+ async function loadModulesData() {
7
+ // Already loaded or currently loading -- nothing to do
8
+ if (modulesDataLoaded || isModulesDataLoading) {
9
+ console.log('Modules data already loaded or loading, skipping...');
10
+ return;
11
+ }
12
+
13
+ isModulesDataLoading = true;
14
+
15
+ try {
16
+ const token = localStorage.getItem('authToken');
17
+
18
+ console.log('Loading modules data...');
19
+
20
+ // Load the modules
21
+ const modulesResponse = await fetch('/api/admin/modules', {
22
+ headers: { 'Authorization': `Bearer ${token}` }
23
+ });
24
+
25
+ // Load the role-module associations
26
+ const rolesResponse = await fetch('/api/admin/role-modules', {
27
+ headers: { 'Authorization': `Bearer ${token}` }
28
+ });
29
+
30
+ if (modulesResponse.ok && rolesResponse.ok) {
31
+ const modules = await modulesResponse.json();
32
+ const rolesData = await rolesResponse.json();
33
+
34
+ displayModules(modules.modules, rolesData);
35
+ updateStats(modules.modules, rolesData);
36
+
37
+ modulesDataLoaded = true;
38
+ console.log('Modules data loaded successfully');
39
+ } else {
40
+ throw new Error('Error loading data');
41
+ }
42
+ } catch (error) {
43
+ console.error('Error loading modules:', error);
44
+ showNotification('Error loading module data', 'error');
45
+ modulesDataLoaded = false;
46
+ } finally {
47
+ isModulesDataLoading = false;
48
+ }
49
+ }
50
+
51
+ function reloadModulesData() {
52
+ modulesDataLoaded = false;
53
+ loadModulesData();
54
+ }
55
+
56
+ function renderModules() {
57
+ addModulesNavigation();
58
+ addModulesSection();
59
+ setupModulesEventListeners();
60
+ setupGlobalRouter();
61
+ setTimeout(() => {
62
+ handleUrlChange();
63
+ }, 100);
64
+ }
65
+
66
+ function addModulesNavigation() {
67
+ const mainNav = document.getElementById('main-nav');
68
+ if (!mainNav) return;
69
+
70
+ if (document.querySelector('[data-target="modules-management-section"]')) return;
71
+
72
+ const modulesNavItem = document.createElement('a');
73
+ modulesNavItem.href = '#';
74
+ modulesNavItem.className = 'nav-item flex items-center px-6 py-3 text-blue-100 hover:bg-blue-600 admin-only';
75
+ modulesNavItem.setAttribute('data-target', 'modules-management-section');
76
+ modulesNavItem.innerHTML = `
77
+ <i data-feather="grid" class="w-5 h-5 mr-3"></i>
78
+ <span class="nav-text">Module management</span>
79
+ `;
80
+
81
+ modulesNavItem.addEventListener('click', function(e) {
82
+ e.preventDefault();
83
+ handleModulesNavigation.call(this, e);
84
+ });
85
+
86
+ const adminItems = document.querySelectorAll('.admin-only');
87
+ const lastAdminItem = adminItems[adminItems.length - 1];
88
+
89
+ if (lastAdminItem) {
90
+ lastAdminItem.parentNode.insertBefore(modulesNavItem, lastAdminItem.nextSibling);
91
+ } else {
92
+ mainNav.appendChild(modulesNavItem);
93
+ }
94
+
95
+ // Update the state for the collapsed menu
96
+ const sidebar = document.getElementById('sidebar');
97
+ if (sidebar && sidebar.classList.contains('sidebar-collapsed')) {
98
+ updateNavItemForCollapsedState(modulesNavItem, true);
99
+ }
100
+
101
+ feather.replace();
102
+ }
103
+
104
+ /**
105
+ * Adds a function that was missing.
106
+ */
107
+ function updateNavItemForCollapsedState(navItem, isCollapsed) {
108
+ const navText = navItem.querySelector('.nav-text');
109
+ const icon = navItem.querySelector('i');
110
+
111
+ if (isCollapsed) {
112
+ if (navText) {
113
+ navText.style.display = 'none';
114
+ }
115
+ navItem.classList.add('justify-center', 'px-3');
116
+ if (icon) {
117
+ icon.classList.remove('mr-3');
118
+ }
119
+ } else {
120
+ if (navText) {
121
+ navText.style.display = 'inline';
122
+ }
123
+ navItem.classList.remove('justify-center', 'px-3');
124
+ if (icon) {
125
+ icon.classList.add('mr-3');
126
+ }
127
+ }
128
+ }
129
+
130
+ function handleModulesNavigation(e) {
131
+ e.preventDefault();
132
+ window.history.pushState({}, '', '/selfcare/modules/modules');
133
+ showModulesManagementSection();
134
+ }
135
+
136
+ function showModulesManagementSection() {
137
+ const navItems = document.querySelectorAll('.nav-item');
138
+ navItems.forEach(navItem => {
139
+ navItem.classList.remove('text-white', 'bg-blue-800');
140
+ navItem.classList.add('text-blue-100', 'hover:bg-blue-600');
141
+ });
142
+
143
+ const modulesNav = document.querySelector('[data-target="modules-management-section"]');
144
+ if (modulesNav) {
145
+ modulesNav.classList.remove('text-blue-100', 'hover:bg-blue-600');
146
+ modulesNav.classList.add('text-white', 'bg-blue-800');
147
+ }
148
+
149
+ // Hide all content sections
150
+ const contentSections = document.querySelectorAll('.content-section');
151
+ contentSections.forEach(section => {
152
+ section.classList.remove('active');
153
+ });
154
+
155
+ // Show the module management section
156
+ const targetSection = document.getElementById('modules-management-section');
157
+ if (targetSection) {
158
+ targetSection.classList.add('active');
159
+ document.getElementById('current-section-title').textContent = 'Module management';
160
+
161
+ // Load data if needed
162
+ if (!modulesDataLoaded && !isModulesDataLoading) {
163
+ loadModulesData();
164
+ }
165
+ // Plugin state -- always reload: it changes with a server restart.
166
+ loadBackendPlugins();
167
+ }
168
+
169
+ isModulesRouteActive = true;
170
+ feather.replace();
171
+ }
172
+
173
+ function addModulesSection() {
174
+ if (document.getElementById('modules-management-section')) return;
175
+
176
+ const modulesSection = document.createElement('div');
177
+ modulesSection.id = 'modules-management-section';
178
+ modulesSection.className = 'content-section';
179
+ modulesSection.innerHTML = `
180
+ <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
181
+ <div class="flex justify-between items-center mb-6">
182
+ <h2 class="text-2xl font-bold text-gray-800">Module management</h2>
183
+ <div class="flex space-x-2">
184
+ <button data-action="modules.import-export"
185
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
186
+ <i data-feather="download" class="w-4 h-4 mr-2"></i>
187
+ Import/Export
188
+ </button>
189
+ <button data-action="modules.add"
190
+ class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center">
191
+ <i data-feather="plus" class="w-4 h-4 mr-2"></i>
192
+ Add module
193
+ </button>
194
+ </div>
195
+ </div>
196
+
197
+ <!-- Stats -->
198
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6" id="modules-stats">
199
+ <div class="bg-gradient-to-r from-blue-50 to-blue-100 border border-blue-200 rounded-lg p-4 text-center">
200
+ <div class="text-2xl font-bold text-blue-600" id="total-modules">0</div>
201
+ <div class="text-sm text-blue-800">Total modules</div>
202
+ </div>
203
+ <div class="bg-gradient-to-r from-green-50 to-green-100 border border-green-200 rounded-lg p-4 text-center">
204
+ <div class="text-2xl font-bold text-green-600" id="active-modules">0</div>
205
+ <div class="text-sm text-green-800">Active</div>
206
+ </div>
207
+ <div class="bg-gradient-to-r from-purple-50 to-purple-100 border border-purple-200 rounded-lg p-4 text-center">
208
+ <div class="text-2xl font-bold text-purple-600" id="admin-role-modules">0</div>
209
+ <div class="text-sm text-purple-800">For admins</div>
210
+ </div>
211
+ <div class="bg-gradient-to-r from-orange-50 to-orange-100 border border-orange-200 rounded-lg p-4 text-center">
212
+ <div class="text-2xl font-bold text-orange-600" id="client-role-modules">0</div>
213
+ <div class="text-sm text-orange-800">For clients</div>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- Modules table -->
218
+ <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
219
+ <div class="overflow-x-auto">
220
+ <table class="responsive-table min-w-full divide-y divide-gray-200">
221
+ <thead class="bg-gray-50">
222
+ <tr>
223
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
224
+ Module
225
+ </th>
226
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
227
+ ID
228
+ </th>
229
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
230
+ Version
231
+ </th>
232
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
233
+ Roles
234
+ </th>
235
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
236
+ Status
237
+ </th>
238
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
239
+ Actions
240
+ </th>
241
+ </tr>
242
+ </thead>
243
+ <tbody id="modules-table-body" class="bg-white divide-y divide-gray-200">
244
+ <tr>
245
+ <td colspan="6" class="px-6 py-4 text-center text-gray-500">
246
+ Loading modules...
247
+ </td>
248
+ </tr>
249
+ </tbody>
250
+ </table>
251
+ </div>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Backend plugins: read-only, the decision takes effect from the next restart -->
256
+ <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
257
+ <div class="flex justify-between items-center mb-4">
258
+ <div>
259
+ <h2 class="text-xl font-bold text-gray-800">Backend plugins</h2>
260
+ <p class="text-sm text-gray-500 mt-1">
261
+ Enabled by the <code>enabled</code> flag in <code>config/modules.json</code> or by the
262
+ <code>PLUGINS_ENABLE</code> / <code>PLUGINS_DISABLE</code> variables; roles decide only visibility.
263
+ A change takes effect after a restart.
264
+ </p>
265
+ </div>
266
+ <button data-action="modules.refresh-plugins"
267
+ class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 flex items-center">
268
+ <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>
269
+ Refresh
270
+ </button>
271
+ </div>
272
+ <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
273
+ <div class="overflow-x-auto">
274
+ <table class="responsive-table min-w-full divide-y divide-gray-200">
275
+ <thead class="bg-gray-50">
276
+ <tr>
277
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plugin</th>
278
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
279
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
280
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Decision</th>
281
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">State</th>
282
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Toggle</th>
283
+ </tr>
284
+ </thead>
285
+ <tbody id="backend-plugins-table-body" class="bg-white divide-y divide-gray-200">
286
+ <tr><td colspan="6" class="px-6 py-4 text-center text-gray-500">Loading plugins...</td></tr>
287
+ </tbody>
288
+ </table>
289
+ </div>
290
+ </div>
291
+ </div>
292
+
293
+ <!-- Modals will be added here -->
294
+ `;
295
+
296
+ const mainContent = document.querySelector('main');
297
+ if (mainContent) {
298
+ mainContent.appendChild(modulesSection);
299
+ }
300
+
301
+ // Add the modals
302
+ addModulesModals();
303
+
304
+ feather.replace();
305
+ }
306
+
307
+ function addModulesModals() {
308
+ // Modal for adding/editing a module
309
+ const moduleModal = document.createElement('div');
310
+ moduleModal.id = 'module-modal';
311
+ moduleModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 hidden';
312
+ moduleModal.innerHTML = `
313
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
314
+ <div class="flex justify-between items-center p-6 border-b">
315
+ <h3 class="text-xl font-bold text-gray-800" id="module-modal-title">Add module</h3>
316
+ <button data-close-module-modal class="text-gray-500 hover:text-gray-700 p-2">
317
+ <i data-feather="x" class="w-6 h-6"></i>
318
+ </button>
319
+ </div>
320
+
321
+ <div class="flex-1 overflow-auto p-6">
322
+ <form id="module-form" class="space-y-4">
323
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
324
+ <div>
325
+ <label class="block text-sm font-medium text-gray-700 mb-1">Module ID *</label>
326
+ <input type="text" id="module-id" required
327
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
328
+ placeholder="Unique identifier">
329
+ </div>
330
+ <div>
331
+ <label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
332
+ <input type="text" id="module-name" required
333
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
334
+ placeholder="Display name">
335
+ </div>
336
+ </div>
337
+
338
+ <div>
339
+ <label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
340
+ <textarea id="module-description" rows="2"
341
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
342
+ placeholder="Module description"></textarea>
343
+ </div>
344
+
345
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
346
+ <div>
347
+ <label class="block text-sm font-medium text-gray-700 mb-1">JS path</label>
348
+ <input type="text" id="module-js-path"
349
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
350
+ placeholder="/static/modules/js/...">
351
+ </div>
352
+ <div>
353
+ <label class="block text-sm font-medium text-gray-700 mb-1">CSS path</label>
354
+ <input type="text" id="module-css-path"
355
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
356
+ placeholder="/static/modules/css/...">
357
+ </div>
358
+ </div>
359
+
360
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
361
+ <div>
362
+ <label class="block text-sm font-medium text-gray-700 mb-1">Init function</label>
363
+ <input type="text" id="module-init-function"
364
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
365
+ placeholder="renderModule">
366
+ </div>
367
+ <div>
368
+ <label class="block text-sm font-medium text-gray-700 mb-1">Version</label>
369
+ <input type="text" id="module-version" value="1.0.0"
370
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
371
+ </div>
372
+ </div>
373
+
374
+ <div>
375
+ <label class="block text-sm font-medium text-gray-700 mb-1">Configuration (JSON)</label>
376
+ <textarea id="module-config" rows="4"
377
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
378
+ placeholder='{"key": "value"}'></textarea>
379
+ </div>
380
+
381
+ <div>
382
+ <label class="flex items-center">
383
+ <input type="checkbox" id="module-active" class="rounded text-blue-600" checked>
384
+ <span class="ml-2 text-sm text-gray-700">Active module</span>
385
+ </label>
386
+ </div>
387
+ </form>
388
+ </div>
389
+
390
+ <div class="flex justify-end space-x-2 p-6 border-t bg-gray-50">
391
+ <button data-close-module-modal
392
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
393
+ Cancel
394
+ </button>
395
+ <button data-save-module
396
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
397
+ <i data-feather="save" class="w-4 h-4 mr-2"></i>
398
+ Save
399
+ </button>
400
+ </div>
401
+ </div>
402
+ `;
403
+
404
+ // Modal for managing roles
405
+ const rolesModal = document.createElement('div');
406
+ rolesModal.id = 'roles-modal';
407
+ rolesModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 hidden';
408
+ rolesModal.innerHTML = `
409
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
410
+ <div class="flex justify-between items-center p-6 border-b">
411
+ <h3 class="text-xl font-bold text-gray-800" id="roles-modal-title">Role settings</h3>
412
+ <button data-close-roles-modal class="text-gray-500 hover:text-gray-700 p-2">
413
+ <i data-feather="x" class="w-6 h-6"></i>
414
+ </button>
415
+ </div>
416
+
417
+ <div class="flex-1 overflow-auto p-6">
418
+ <div id="roles-list" class="space-y-3">
419
+ <!-- Role list goes here -->
420
+ </div>
421
+ </div>
422
+
423
+ <div class="flex justify-end space-x-2 p-6 border-t bg-gray-50">
424
+ <button data-close-roles-modal
425
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
426
+ Cancel
427
+ </button>
428
+ <button data-save-role-modules
429
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
430
+ <i data-feather="save" class="w-4 h-4 mr-2"></i>
431
+ Save
432
+ </button>
433
+ </div>
434
+ </div>
435
+ `;
436
+
437
+ // Modal for import/export
438
+ const importExportModal = document.createElement('div');
439
+ importExportModal.id = 'import-export-modal';
440
+ importExportModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 hidden';
441
+ importExportModal.innerHTML = `
442
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
443
+ <div class="flex justify-between items-center p-6 border-b">
444
+ <h3 class="text-xl font-bold text-gray-800">Import/Export modules</h3>
445
+ <button data-close-import-export class="text-gray-500 hover:text-gray-700 p-2">
446
+ <i data-feather="x" class="w-6 h-6"></i>
447
+ </button>
448
+ </div>
449
+
450
+ <div class="p-6 space-y-4">
451
+ <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
452
+ <h4 class="font-semibold text-blue-800 mb-2">Export to JSON</h4>
453
+ <p class="text-blue-700 text-sm mb-3">Export the current module configuration to a file</p>
454
+ <button data-export-modules
455
+ class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center">
456
+ <i data-feather="download" class="w-4 h-4 mr-2"></i>
457
+ Export to JSON
458
+ </button>
459
+ </div>
460
+
461
+ <div class="bg-green-50 border border-green-200 rounded-lg p-4">
462
+ <h4 class="font-semibold text-green-800 mb-2">Import from JSON</h4>
463
+ <p class="text-green-700 text-sm mb-3">Import configuration from a JSON file</p>
464
+ <button data-import-modules
465
+ class="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center justify-center">
466
+ <i data-feather="upload" class="w-4 h-4 mr-2"></i>
467
+ Import from JSON
468
+ </button>
469
+ </div>
470
+
471
+ <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
472
+ <h4 class="font-semibold text-yellow-800 mb-2">Import from file</h4>
473
+ <p class="text-yellow-700 text-sm mb-3">If the DB has no data, config/modules.json is used as a fallback</p>
474
+ <div class="text-xs text-yellow-600">
475
+ Automatic fallback when loading from the DB fails
476
+ </div>
477
+ </div>
478
+ </div>
479
+
480
+ <div class="flex justify-end p-6 border-t bg-gray-50">
481
+ <button data-close-import-export
482
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
483
+ Close
484
+ </button>
485
+ </div>
486
+ </div>
487
+ `;
488
+
489
+ document.body.appendChild(moduleModal);
490
+ document.body.appendChild(rolesModal);
491
+ document.body.appendChild(importExportModal);
492
+
493
+ // The controls of these three modals are bound where they were put in the
494
+ // page: each modal is created once and appended once, so the registry would
495
+ // only add an indirection (keepup-93). The tables rebuild themselves, and
496
+ // those rows name an action instead.
497
+ moduleModal.querySelectorAll('[data-close-module-modal]').forEach(button =>
498
+ button.addEventListener('click', () => closeModuleModal()));
499
+ const saveModuleButton = moduleModal.querySelector('[data-save-module]');
500
+ if (saveModuleButton) saveModuleButton.addEventListener('click', () => saveModule());
501
+
502
+ rolesModal.querySelectorAll('[data-close-roles-modal]').forEach(button =>
503
+ button.addEventListener('click', () => closeRolesModal()));
504
+ const saveRoleModulesButton = rolesModal.querySelector('[data-save-role-modules]');
505
+ if (saveRoleModulesButton) saveRoleModulesButton.addEventListener('click', () => saveRoleModules());
506
+
507
+ importExportModal.querySelectorAll('[data-close-import-export]').forEach(button =>
508
+ button.addEventListener('click', () => closeImportExportModal()));
509
+ const exportModulesButton = importExportModal.querySelector('[data-export-modules]');
510
+ if (exportModulesButton) exportModulesButton.addEventListener('click', () => exportModulesToJson());
511
+ const importModulesButton = importExportModal.querySelector('[data-import-modules]');
512
+ if (importModulesButton) importModulesButton.addEventListener('click', () => importModulesFromJson());
513
+
514
+ feather.replace();
515
+ }
516
+
517
+ // Functions for working with data
518
+ async function loadModulesData() {
519
+ try {
520
+ const token = localStorage.getItem('authToken');
521
+
522
+ // Load the modules
523
+ const modulesResponse = await fetch('/api/admin/modules', {
524
+ headers: { 'Authorization': `Bearer ${token}` }
525
+ });
526
+
527
+ // Load the role-module associations
528
+ const rolesResponse = await fetch('/api/admin/role-modules', {
529
+ headers: { 'Authorization': `Bearer ${token}` }
530
+ });
531
+
532
+ if (modulesResponse.ok && rolesResponse.ok) {
533
+ const modules = await modulesResponse.json();
534
+ const rolesData = await rolesResponse.json();
535
+
536
+ displayModules(modules.modules, rolesData);
537
+ updateStats(modules.modules, rolesData);
538
+ } else {
539
+ throw new Error('Error loading data');
540
+ }
541
+ } catch (error) {
542
+ console.error('Error loading modules:', error);
543
+ showNotification('Error loading module data', 'error');
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Table rows for backend plugins, from the /api/admin/plugins response.
549
+ *
550
+ * Pure function: takes status rows, returns HTML. The three outcomes are
551
+ * told apart by wording, not only by color: "Not enabled" is a decision,
552
+ * not a failure; "Failed to start" means it is enabled but did not load or
553
+ * initialize, and this is the only place that failure is visible without
554
+ * reading the log.
555
+ *
556
+ * @param {Array<Object>} rows - Plugin status rows
557
+ * @returns {string} HTML for the <tr> rows
558
+ */
559
+ function renderBackendPlugins(rows) {
560
+ if (!rows || rows.length === 0) {
561
+ return `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No plugins: none are declared in config/modules.json</td></tr>`;
562
+ }
563
+ const sourceText = { config: 'file', env: 'environment', panel: 'panel', default: 'default' };
564
+ return rows.map(row => {
565
+ const decision = row.enabled
566
+ ? `<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Enabled</span>`
567
+ : `<span class="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs">Not enabled</span>`;
568
+ const source = `<span class="ml-2 text-xs text-gray-500">${sourceText[row.source] || row.source || ''}</span>`;
569
+ let state;
570
+ if (row.pending_restart) {
571
+ // The decision has already changed but the old one is still running: a
572
+ // plugin's routes are built at startup and never torn down. We say so
573
+ // plainly, or the row would show "disabled" next to a plugin still running.
574
+ const willBe = row.desired_enabled ? 'will enable' : 'will disable';
575
+ state = `<span class="px-2 py-1 bg-amber-100 text-amber-800 rounded text-xs">${willBe} after restart</span>
576
+ <span class="ml-2 text-xs text-gray-500">currently ${row.initialized ? 'running' : 'not running'}</span>`;
577
+ } else if (!row.enabled) {
578
+ state = `<span class="text-gray-500 text-sm">—</span>`;
579
+ } else if (row.initialized) {
580
+ state = `<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Running</span>`;
581
+ } else {
582
+ const why = row.loaded ? 'initialization returned a failure' : 'failed to load: no file or class';
583
+ state = `<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">Failed to start</span>
584
+ <span class="ml-2 text-xs text-gray-500">${why}</span>`;
585
+ }
586
+ return `
587
+ <tr class="hover:bg-gray-50">
588
+ <td class="px-6 py-4"><div class="text-sm font-medium text-gray-900">${keepupEscapeHtml(row.name || row.id)}</div></td>
589
+ <td class="px-6 py-4 whitespace-nowrap"><div class="text-sm text-gray-900 font-mono">${keepupEscapeHtml(row.id)}</div></td>
590
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">${row.priority}</td>
591
+ <td class="px-6 py-4 whitespace-nowrap">${decision}${source}</td>
592
+ <td class="px-6 py-4 whitespace-nowrap">${state}</td>
593
+ <td class="px-6 py-4 whitespace-nowrap">${pluginSwitch(row)}</td>
594
+ </tr>`;
595
+ }).join('');
596
+ }
597
+
598
+ /**
599
+ * The plugin toggle in a table row.
600
+ *
601
+ * Pure function. For a plugin named in PLUGINS_ENABLE/PLUGINS_DISABLE, the
602
+ * deployment decides -- there is no button, only the reason: the server would
603
+ * reject such a request anyway, and the button would promise what will not happen.
604
+ *
605
+ * @param {Object} row - Plugin status row
606
+ * @returns {string} HTML for the cell
607
+ */
608
+ function pluginSwitch(row) {
609
+ if (row.decided_by_environment) {
610
+ return `<span class="text-xs text-gray-500">decided by deployment: PLUGINS_ENABLE / PLUGINS_DISABLE</span>`;
611
+ }
612
+ const target = !row.desired_enabled;
613
+ const label = target ? 'Enable' : 'Disable';
614
+ const colour = target ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-600 hover:bg-gray-700';
615
+ // The plugin id and the wanted state travel in data attributes, not in a
616
+ // script built into the markup: the row is rebuilt on every reload, and a
617
+ // name that came from the configuration must never run as code (keepup-93).
618
+ const reset = row.desired_source === 'panel'
619
+ ? `<button data-action="modules.plugin-clear-override" data-plugin-id="${keepupEscapeHtml(row.id)}"
620
+ class="ml-2 px-2 py-1 text-xs text-gray-600 underline">from file</button>`
621
+ : '';
622
+ return `<button data-action="modules.plugin-set-enabled" data-plugin-id="${keepupEscapeHtml(row.id)}"
623
+ data-enabled="${target}"
624
+ class="px-3 py-1 ${colour} text-white rounded text-xs">${label}</button>${reset}`;
625
+ }
626
+
627
+ /**
628
+ * The restart hint. There is deliberately no restart button: an application
629
+ * restarting itself on its own request behaves unpredictably with several
630
+ * replicas and in a container, where a restart is the supervisor's job.
631
+ *
632
+ * @returns {string} Hint text
633
+ */
634
+ function pluginRestartHint() {
635
+ return 'The change takes effect after a server restart: docker compose restart app '
636
+ + '(or restarting the service on this stand).';
637
+ }
638
+
639
+ async function setPluginEnabled(pluginId, enabled) {
640
+ await callPluginSwitch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/enabled`, 'POST',
641
+ JSON.stringify({ enabled }));
642
+ }
643
+
644
+ async function clearPluginOverride(pluginId) {
645
+ await callPluginSwitch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/enabled`, 'DELETE', null);
646
+ }
647
+
648
+ async function callPluginSwitch(url, method, body) {
649
+ try {
650
+ const token = localStorage.getItem('authToken');
651
+ const headers = { 'Authorization': `Bearer ${token}` };
652
+ if (body) headers['Content-Type'] = 'application/json';
653
+ const response = await fetch(url, { method, headers, body });
654
+ const data = await response.json().catch(() => ({}));
655
+ if (!response.ok) {
656
+ // The failure is shown as-is: the server's reason is specific --
657
+ // the environment decides for this plugin, or it is not in config/modules.json.
658
+ showNotification(data.detail || `Could not change the plugin's state (HTTP ${response.status})`, 'error');
659
+ return;
660
+ }
661
+ showNotification(pluginRestartHint(), 'info');
662
+ await loadBackendPlugins();
663
+ } catch (error) {
664
+ showNotification(`Could not change the plugin's state: ${error.message}`, 'error');
665
+ }
666
+ }
667
+
668
+ async function loadBackendPlugins() {
669
+ const tbody = document.getElementById('backend-plugins-table-body');
670
+ if (!tbody) return;
671
+ try {
672
+ const token = localStorage.getItem('authToken');
673
+ const response = await fetch('/api/admin/plugins', {
674
+ headers: { 'Authorization': `Bearer ${token}` }
675
+ });
676
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
677
+ const data = await response.json();
678
+ tbody.innerHTML = renderBackendPlugins(data.plugins);
679
+ } catch (error) {
680
+ console.error('Error loading plugin status:', error);
681
+ tbody.innerHTML = `<tr><td colspan="6" class="px-6 py-4 text-center text-red-500">Could not get plugin status: ${keepupEscapeHtml(error.message)}</td></tr>`;
682
+ }
683
+ feather.replace();
684
+ }
685
+
686
+ function displayModules(modules, rolesData) {
687
+ const tbody = document.getElementById('modules-table-body');
688
+
689
+ if (!modules || modules.length === 0) {
690
+ tbody.innerHTML = `
691
+ <tr>
692
+ <td colspan="6" class="px-6 py-8 text-center text-gray-500">
693
+ <i data-feather="package" class="w-12 h-12 mx-auto mb-3 text-gray-400"></i>
694
+ <p>No modules found</p>
695
+ <button data-action="modules.add"
696
+ class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
697
+ Add the first module
698
+ </button>
699
+ </td>
700
+ </tr>
701
+ `;
702
+ feather.replace();
703
+ return;
704
+ }
705
+
706
+ tbody.innerHTML = modules.map(module => {
707
+ // Determine the roles for this module
708
+ const moduleRoles = [];
709
+ for (const [roleName, roleModules] of Object.entries(rolesData)) {
710
+ if (roleModules.some(m => m.module_id === module.module_id)) {
711
+ moduleRoles.push(roleName);
712
+ }
713
+ }
714
+
715
+ const rolesText = moduleRoles.length > 0 ?
716
+ moduleRoles.map(role => `<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">${role}</span>`).join(' ') :
717
+ '<span class="text-gray-500 text-sm">Not assigned</span>';
718
+
719
+ return `
720
+ <tr class="hover:bg-gray-50">
721
+ <td class="px-6 py-4">
722
+ <div class="flex items-center">
723
+ <div class="flex-shrink-0 h-10 w-10 bg-blue-100 rounded-lg flex items-center justify-center">
724
+ <i data-feather="grid" class="w-5 h-5 text-blue-600"></i>
725
+ </div>
726
+ <div class="ml-4">
727
+ <div class="text-sm font-medium text-gray-900">${keepupEscapeHtml(module.name)}</div>
728
+ <div class="text-sm text-gray-500">${keepupEscapeHtml(module.description || 'No description')}</div>
729
+ </div>
730
+ </div>
731
+ </td>
732
+ <td class="px-6 py-4 whitespace-nowrap">
733
+ <div class="text-sm text-gray-900 font-mono">${keepupEscapeHtml(module.module_id)}</div>
734
+ </td>
735
+ <td class="px-6 py-4 whitespace-nowrap">
736
+ <span class="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs">${keepupEscapeHtml(module.version)}</span>
737
+ </td>
738
+ <td class="px-6 py-4">
739
+ <div class="flex flex-wrap gap-1">${rolesText}</div>
740
+ </td>
741
+ <td class="px-6 py-4 whitespace-nowrap">
742
+ <span class="px-2 py-1 ${module.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'} rounded text-xs">
743
+ ${module.is_active ? 'Active' : 'Inactive'}
744
+ </span>
745
+ </td>
746
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
747
+ <div class="flex space-x-2">
748
+ <button data-action="modules.edit" data-module-id="${keepupEscapeHtml(module.module_id)}"
749
+ class="text-blue-600 hover:text-blue-900 bg-blue-100 px-3 py-1 rounded text-sm flex items-center">
750
+ <i data-feather="edit" class="w-3 h-3 mr-1"></i>
751
+ Edit
752
+ </button>
753
+ <button data-action="modules.roles" data-module-id="${keepupEscapeHtml(module.module_id)}"
754
+ class="text-purple-600 hover:text-purple-900 bg-purple-100 px-3 py-1 rounded text-sm flex items-center">
755
+ <i data-feather="users" class="w-3 h-3 mr-1"></i>
756
+ Roles
757
+ </button>
758
+ <button data-action="modules.delete" data-module-id="${keepupEscapeHtml(module.module_id)}"
759
+ class="text-red-600 hover:text-red-900 bg-red-100 px-3 py-1 rounded text-sm flex items-center">
760
+ <i data-feather="trash-2" class="w-3 h-3 mr-1"></i>
761
+ Delete
762
+ </button>
763
+ </div>
764
+ </td>
765
+ </tr>
766
+ `;
767
+ }).join('');
768
+
769
+ feather.replace();
770
+ }
771
+
772
+ function updateStats(modules, rolesData) {
773
+ const totalModules = modules.length;
774
+ const activeModules = modules.filter(m => m.is_active).length;
775
+
776
+ let adminModules = 0;
777
+ let clientModules = 0;
778
+
779
+ if (rolesData.ADMIN) adminModules = rolesData.ADMIN.length;
780
+ if (rolesData.CLIENT) clientModules = rolesData.CLIENT.length;
781
+
782
+ document.getElementById('total-modules').textContent = totalModules;
783
+ document.getElementById('active-modules').textContent = activeModules;
784
+ document.getElementById('admin-role-modules').textContent = adminModules;
785
+ document.getElementById('client-role-modules').textContent = clientModules;
786
+ }
787
+
788
+ // Modal functions
789
+ function showAddModuleModal() {
790
+ currentEditingModule = null;
791
+ document.getElementById('module-modal-title').textContent = 'Add module';
792
+ document.getElementById('module-form').reset();
793
+ document.getElementById('module-id').disabled = false;
794
+ document.getElementById('module-modal').classList.remove('hidden');
795
+ }
796
+
797
+ function showEditModuleModal(module) {
798
+ currentEditingModule = module;
799
+ document.getElementById('module-modal-title').textContent = 'Edit module';
800
+
801
+ document.getElementById('module-id').value = module.module_id;
802
+ document.getElementById('module-id').disabled = true;
803
+ document.getElementById('module-name').value = module.name;
804
+ document.getElementById('module-description').value = module.description || '';
805
+ document.getElementById('module-js-path').value = module.js_path || '';
806
+ document.getElementById('module-css-path').value = module.css_path || '';
807
+ document.getElementById('module-init-function').value = module.init_function || '';
808
+ document.getElementById('module-version').value = module.version;
809
+ document.getElementById('module-config').value = module.config ? JSON.stringify(module.config, null, 2) : '';
810
+ document.getElementById('module-active').checked = module.is_active;
811
+
812
+ document.getElementById('module-modal').classList.remove('hidden');
813
+ }
814
+
815
+ function closeModuleModal() {
816
+ document.getElementById('module-modal').classList.add('hidden');
817
+ currentEditingModule = null;
818
+ }
819
+
820
+ async function saveModule() {
821
+ const formData = {
822
+ module_id: document.getElementById('module-id').value,
823
+ name: document.getElementById('module-name').value,
824
+ description: document.getElementById('module-description').value,
825
+ js_path: document.getElementById('module-js-path').value,
826
+ css_path: document.getElementById('module-css-path').value,
827
+ init_function: document.getElementById('module-init-function').value,
828
+ version: document.getElementById('module-version').value,
829
+ is_active: document.getElementById('module-active').checked
830
+ };
831
+
832
+ // Parse the JSON configuration
833
+ const configText = document.getElementById('module-config').value.trim();
834
+ if (configText) {
835
+ try {
836
+ formData.config = JSON.parse(configText);
837
+ } catch (e) {
838
+ showNotification('Invalid JSON configuration format', 'error');
839
+ return;
840
+ }
841
+ }
842
+
843
+ try {
844
+ const token = localStorage.getItem('authToken');
845
+ const url = currentEditingModule ?
846
+ `/api/admin/modules/${currentEditingModule.module_id}` :
847
+ '/api/admin/modules';
848
+
849
+ const method = currentEditingModule ? 'PUT' : 'POST';
850
+
851
+ const response = await fetch(url, {
852
+ method: method,
853
+ headers: {
854
+ 'Content-Type': 'application/json',
855
+ 'Authorization': `Bearer ${token}`
856
+ },
857
+ body: JSON.stringify(formData)
858
+ });
859
+
860
+ if (response.ok) {
861
+ showNotification(
862
+ currentEditingModule ? 'Module updated' : 'Module created',
863
+ 'success'
864
+ );
865
+ closeModuleModal();
866
+ loadModulesData();
867
+ } else {
868
+ const error = await response.json();
869
+ showNotification(error.detail || 'Error saving', 'error');
870
+ }
871
+ } catch (error) {
872
+ showNotification('Network error: ' + error.message, 'error');
873
+ }
874
+ }
875
+
876
+ async function editModule(moduleId) {
877
+ try {
878
+ const token = localStorage.getItem('authToken');
879
+ const response = await fetch(`/api/admin/modules/${moduleId}`, {
880
+ headers: { 'Authorization': `Bearer ${token}` }
881
+ });
882
+
883
+ if (response.ok) {
884
+ const module = await response.json();
885
+ showEditModuleModal(module);
886
+ } else {
887
+ throw new Error('Module not found');
888
+ }
889
+ } catch (error) {
890
+ showNotification('Error loading module', 'error');
891
+ }
892
+ }
893
+
894
+ async function deleteModule(moduleId) {
895
+ if (!confirm(`Are you sure you want to delete module ${moduleId}?`)) return;
896
+
897
+ try {
898
+ const token = localStorage.getItem('authToken');
899
+ const response = await fetch(`/api/admin/modules/${moduleId}`, {
900
+ method: 'DELETE',
901
+ headers: { 'Authorization': `Bearer ${token}` }
902
+ });
903
+
904
+ if (response.ok) {
905
+ showNotification('Module deleted', 'success');
906
+ loadModulesData();
907
+ } else {
908
+ throw new Error('Error deleting');
909
+ }
910
+ } catch (error) {
911
+ showNotification('Error deleting module', 'error');
912
+ }
913
+ }
914
+
915
+ // Role management
916
+ let currentManagingModule = null;
917
+
918
+ async function manageModuleRoles(moduleId) {
919
+ currentManagingModule = moduleId;
920
+
921
+ try {
922
+ const token = localStorage.getItem('authToken');
923
+ const [moduleResponse, rolesResponse] = await Promise.all([
924
+ fetch(`/api/admin/modules/${moduleId}`, {
925
+ headers: { 'Authorization': `Bearer ${token}` }
926
+ }),
927
+ fetch('/api/admin/role-modules', {
928
+ headers: { 'Authorization': `Bearer ${token}` }
929
+ })
930
+ ]);
931
+
932
+ if (moduleResponse.ok && rolesResponse.ok) {
933
+ const module = await moduleResponse.json();
934
+ const rolesData = await rolesResponse.json();
935
+
936
+ displayRolesForModule(module, rolesData);
937
+ document.getElementById('roles-modal-title').textContent = `Roles for: ${module.name}`;
938
+ document.getElementById('roles-modal').classList.remove('hidden');
939
+ } else {
940
+ throw new Error('Error loading data');
941
+ }
942
+ } catch (error) {
943
+ showNotification('Error loading role data', 'error');
944
+ }
945
+ }
946
+
947
+ function displayRolesForModule(module, rolesData) {
948
+ const rolesList = document.getElementById('roles-list');
949
+ const availableRoles = ['ADMIN', 'CLIENT'];
950
+
951
+ rolesList.innerHTML = availableRoles.map(role => {
952
+ const isAssigned = rolesData[role] &&
953
+ rolesData[role].some(m => m.module_id === module.module_id);
954
+
955
+ return `
956
+ <div class="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
957
+ <div class="flex items-center">
958
+ <input type="checkbox" id="role-${role}" ${isAssigned ? 'checked' : ''}
959
+ class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
960
+ <label for="role-${role}" class="ml-3 text-sm font-medium text-gray-700">
961
+ ${role === 'ADMIN' ? 'Administrator' : 'Client'}
962
+ </label>
963
+ </div>
964
+ <span class="text-xs text-gray-500 px-2 py-1 bg-gray-100 rounded">
965
+ ${role}
966
+ </span>
967
+ </div>
968
+ `;
969
+ }).join('');
970
+ }
971
+
972
+ function closeRolesModal() {
973
+ document.getElementById('roles-modal').classList.add('hidden');
974
+ currentManagingModule = null;
975
+ }
976
+
977
+ async function saveRoleModules() {
978
+ if (!currentManagingModule) return;
979
+
980
+ const selectedRoles = [];
981
+ if (document.getElementById('role-ADMIN').checked) selectedRoles.push('ADMIN');
982
+ if (document.getElementById('role-CLIENT').checked) selectedRoles.push('CLIENT');
983
+
984
+ try {
985
+ const token = localStorage.getItem('authToken');
986
+ const response = await fetch('/api/admin/role-modules', {
987
+ method: 'POST',
988
+ headers: {
989
+ 'Content-Type': 'application/json',
990
+ 'Authorization': `Bearer ${token}`
991
+ },
992
+ body: JSON.stringify({
993
+ role_name: 'ADMIN', // Update separately for each role
994
+ module_ids: selectedRoles.includes('ADMIN') ? [currentManagingModule] : []
995
+ })
996
+ });
997
+
998
+ // Also update for the CLIENT role
999
+ const clientResponse = await fetch('/api/admin/role-modules', {
1000
+ method: 'POST',
1001
+ headers: {
1002
+ 'Content-Type': 'application/json',
1003
+ 'Authorization': `Bearer ${token}`
1004
+ },
1005
+ body: JSON.stringify({
1006
+ role_name: 'CLIENT',
1007
+ module_ids: selectedRoles.includes('CLIENT') ? [currentManagingModule] : []
1008
+ })
1009
+ });
1010
+
1011
+ if (response.ok && clientResponse.ok) {
1012
+ showNotification('Role settings saved', 'success');
1013
+ closeRolesModal();
1014
+ loadModulesData();
1015
+ } else {
1016
+ throw new Error('Error saving');
1017
+ }
1018
+ } catch (error) {
1019
+ showNotification('Error saving role settings', 'error');
1020
+ }
1021
+ }
1022
+
1023
+ // Import/Export
1024
+ function showImportExportModal() {
1025
+ document.getElementById('import-export-modal').classList.remove('hidden');
1026
+ }
1027
+
1028
+ function closeImportExportModal() {
1029
+ document.getElementById('import-export-modal').classList.add('hidden');
1030
+ }
1031
+
1032
+ async function exportModulesToJson() {
1033
+ try {
1034
+ const token = localStorage.getItem('authToken');
1035
+ const response = await fetch('/api/admin/modules/export-to-json', {
1036
+ method: 'POST',
1037
+ headers: { 'Authorization': `Bearer ${token}` }
1038
+ });
1039
+
1040
+ if (response.ok) {
1041
+ showNotification('Modules exported to a JSON file', 'success');
1042
+ closeImportExportModal();
1043
+ } else {
1044
+ throw new Error('Error exporting');
1045
+ }
1046
+ } catch (error) {
1047
+ showNotification('Error exporting modules', 'error');
1048
+ }
1049
+ }
1050
+
1051
+ async function importModulesFromJson() {
1052
+ try {
1053
+ const token = localStorage.getItem('authToken');
1054
+ const response = await fetch('/api/admin/modules/import-from-json', {
1055
+ method: 'POST',
1056
+ headers: { 'Authorization': `Bearer ${token}` }
1057
+ });
1058
+
1059
+ if (response.ok) {
1060
+ showNotification('Modules imported from a JSON file', 'success');
1061
+ closeImportExportModal();
1062
+ loadModulesData();
1063
+ } else {
1064
+ throw new Error('Error importing');
1065
+ }
1066
+ } catch (error) {
1067
+ showNotification('Error importing modules', 'error');
1068
+ }
1069
+ }
1070
+
1071
+ // URL change handler
1072
+ function setupModulesEventListeners() {
1073
+ // Listen for URL changes
1074
+ window.addEventListener('popstate', function(event) {
1075
+ handleUrlChange();
1076
+ });
1077
+ setTimeout(handleUrlChange, 100);
1078
+ }
1079
+
1080
+ function handleUrlChange() {
1081
+ const currentPath = window.location.pathname;
1082
+ if (currentPath === '/selfcare/modules/modules' || currentPath.startsWith('/selfcare/modules/')) {
1083
+ if (!isModulesRouteActive) {
1084
+ showModulesManagementSection();
1085
+ }
1086
+ } else {
1087
+ isModulesRouteActive = false;
1088
+ }
1089
+ }
1090
+
1091
+ function setupGlobalRouter() {
1092
+ // History navigation handler
1093
+ window.addEventListener('popstate', function(event) {
1094
+ handleUrlChange();
1095
+ });
1096
+
1097
+ // Link click handler
1098
+ document.addEventListener('click', function(e) {
1099
+ const link = e.target.closest('a');
1100
+ if (link && link.href && link.href.startsWith(window.location.origin + '/modules')) {
1101
+ e.preventDefault();
1102
+ window.history.pushState({}, '', link.getAttribute('href'));
1103
+ handleUrlChange();
1104
+ }
1105
+ });
1106
+ }
1107
+
1108
+ // Initialize on load
1109
+ document.addEventListener('DOMContentLoaded', function() {
1110
+ handleUrlChange();
1111
+ });
1112
+
1113
+ // The actions this section's markup asks the shell to take (keepup-93). The
1114
+ // tables and the plugin rows are rebuilt on every reload, so they carry a name
1115
+ // and their values in data attributes instead of a script; the modals, built
1116
+ // once and appended, bind their own controls directly (see addModulesModals).
1117
+ KeepupActions.register({
1118
+ 'modules.add': () => showAddModuleModal(),
1119
+ 'modules.import-export': () => showImportExportModal(),
1120
+ 'modules.refresh-plugins': () => loadBackendPlugins(),
1121
+ 'modules.edit': (element) => editModule(element.dataset.moduleId),
1122
+ 'modules.roles': (element) => manageModuleRoles(element.dataset.moduleId),
1123
+ 'modules.delete': (element) => deleteModule(element.dataset.moduleId),
1124
+ // data-enabled is a string; the handler used to receive a boolean.
1125
+ 'modules.plugin-set-enabled': (element) =>
1126
+ setPluginEnabled(element.dataset.pluginId, element.dataset.enabled === 'true'),
1127
+ 'modules.plugin-clear-override': (element) => clearPluginOverride(element.dataset.pluginId),
1128
+ });