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.
- keepup_http/__init__.py +38 -0
- keepup_http/api_docs.py +76 -0
- keepup_http/body_limit.py +156 -0
- keepup_http/events_api.py +237 -0
- keepup_http/factory.py +562 -0
- keepup_http/lock_routes.py +155 -0
- keepup_http/plugin.py +53 -0
- keepup_http/routes.py +549 -0
- keepup_http/static/css/aos.css +1 -0
- keepup_http/static/css/main_nebula.css +232 -0
- keepup_http/static/css/main_new.css +869 -0
- keepup_http/static/css/tailwind.css +1 -0
- keepup_http/static/index_nebula.html +282 -0
- keepup_http/static/index_new.html +275 -0
- keepup_http/static/js/aos.js +1 -0
- keepup_http/static/js/feather-icons.js +13 -0
- keepup_http/static/js/layout_bootstrap.js +29 -0
- keepup_http/static/js/main_new.js +2076 -0
- keepup_http/static/js/tailwind.js +83 -0
- keepup_http/static/modules/css/background_tasks.css +233 -0
- keepup_http/static/modules/css/cluster.css +16 -0
- keepup_http/static/modules/css/event_manager.css +386 -0
- keepup_http/static/modules/css/integration_logs.css +33 -0
- keepup_http/static/modules/css/metrics.css +115 -0
- keepup_http/static/modules/css/modules.css +189 -0
- keepup_http/static/modules/css/themes.css +563 -0
- keepup_http/static/modules/css/users.css +278 -0
- keepup_http/static/modules/js/background_tasks.js +695 -0
- keepup_http/static/modules/js/chart.js +14 -0
- keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup_http/static/modules/js/cluster.js +374 -0
- keepup_http/static/modules/js/event_manager.js +1007 -0
- keepup_http/static/modules/js/integration_logs.js +795 -0
- keepup_http/static/modules/js/metrics.js +929 -0
- keepup_http/static/modules/js/modules.js +1128 -0
- keepup_http/static/modules/js/themes.js +684 -0
- keepup_http/static/modules/js/users.js +898 -0
- keepup_http/web.py +214 -0
- keepup_http-0.5.0.dist-info/METADATA +29 -0
- keepup_http-0.5.0.dist-info/RECORD +43 -0
- keepup_http-0.5.0.dist-info/WHEEL +5 -0
- keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
- 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
|
+
});
|