@imenam/self-register-proxy 1.0.42 → 1.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -8
- package/config.js +40 -2
- package/package.json +1 -1
- package/proxy.js +39 -2
- package/routes/dashboard.js +1 -1
- package/routes/metaConfig.js +2 -2
- package/services/auth.js +20 -2
- package/services/portManager.js +59 -4
- package/views/dashboard.js +309 -803
- package/views/login.js +61 -130
- package/views/metaConfigs.js +403 -643
- package/views/terminal.html +272 -691
- package/views/terminal.js +34 -30
- package/views/theme.js +431 -0
package/views/metaConfigs.js
CHANGED
|
@@ -1,643 +1,403 @@
|
|
|
1
|
-
const { escapeHtml } = require('../services/proxyRouter');
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
+ '<div class="mcp-item-path">' + esc(mcp.path) + '</div>'
|
|
405
|
-
+ '</div>'
|
|
406
|
-
+ '<span class="mcp-item-error">' + esc(mcp.error || 'non compatible') + '</span>'
|
|
407
|
-
+ '</div>';
|
|
408
|
-
}).join('');
|
|
409
|
-
|
|
410
|
-
section.classList.remove('hidden');
|
|
411
|
-
document.getElementById('createBtn').disabled = compatible.length === 0;
|
|
412
|
-
} catch (err) {
|
|
413
|
-
alert('Erreur lors de la d\u00e9couverte: ' + err.message);
|
|
414
|
-
} finally {
|
|
415
|
-
btn.disabled = false;
|
|
416
|
-
btn.innerHTML = '🔍 D\u00e9couvrir les MCPs';
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function openCreateModal() {
|
|
421
|
-
document.getElementById('modalTitle').textContent = 'Nouveau profil';
|
|
422
|
-
document.getElementById('modalSubmitBtn').textContent = 'Cr\u00e9er';
|
|
423
|
-
document.getElementById('editId').value = '';
|
|
424
|
-
document.getElementById('cfgId').value = '';
|
|
425
|
-
document.getElementById('cfgId').readOnly = false;
|
|
426
|
-
document.getElementById('cfgName').value = '';
|
|
427
|
-
document.getElementById('cfgDesc').value = '';
|
|
428
|
-
renderModalMappings({});
|
|
429
|
-
document.getElementById('modalOverlay').classList.add('active');
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function editConfig(id) {
|
|
433
|
-
fetch('/proxy/meta-configs').then(r => r.json()).then(data => {
|
|
434
|
-
const cfg = data.metaConfigs[id];
|
|
435
|
-
if (!cfg) return alert('Profil introuvable');
|
|
436
|
-
|
|
437
|
-
document.getElementById('modalTitle').textContent = 'Modifier: ' + cfg.name;
|
|
438
|
-
document.getElementById('modalSubmitBtn').textContent = 'Mettre \u00e0 jour';
|
|
439
|
-
document.getElementById('editId').value = id;
|
|
440
|
-
document.getElementById('cfgId').value = id;
|
|
441
|
-
document.getElementById('cfgId').readOnly = true;
|
|
442
|
-
document.getElementById('cfgName').value = cfg.name;
|
|
443
|
-
document.getElementById('cfgDesc').value = cfg.description || '';
|
|
444
|
-
|
|
445
|
-
if (discoveredMcps.length === 0) {
|
|
446
|
-
discover().then(() => {
|
|
447
|
-
renderModalMappings(cfg.mappings || {});
|
|
448
|
-
document.getElementById('modalOverlay').classList.add('active');
|
|
449
|
-
});
|
|
450
|
-
} else {
|
|
451
|
-
renderModalMappings(cfg.mappings || {});
|
|
452
|
-
document.getElementById('modalOverlay').classList.add('active');
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function renderModalMappings(currentMappings) {
|
|
458
|
-
const container = document.getElementById('modalMappings');
|
|
459
|
-
const compatible = discoveredMcps.filter(m => m.configurations !== null);
|
|
460
|
-
const compatiblePaths = new Set(compatible.map(m => m.path));
|
|
461
|
-
|
|
462
|
-
offlineMappings = {};
|
|
463
|
-
Object.entries(currentMappings).forEach(([path, configId]) => {
|
|
464
|
-
if (!compatiblePaths.has(path)) offlineMappings[path] = configId;
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
if (compatible.length === 0 && Object.keys(offlineMappings).length === 0) {
|
|
468
|
-
container.innerHTML = '<p style="font-size:0.75rem;color:#71717a;">Lancez d\\'abord la d\u00e9couverte des MCPs.</p>';
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
const onlineHtml = compatible.map(mcp => {
|
|
473
|
-
const selected = currentMappings[mcp.path] || '';
|
|
474
|
-
const options = mcp.configurations.map(c =>
|
|
475
|
-
'<option value="' + esc(c.id) + '"' + (c.id === selected ? ' selected' : '') + '>'
|
|
476
|
-
+ esc(c.name || c.id)
|
|
477
|
-
+ (c.active ? ' (actif)' : '') + '</option>'
|
|
478
|
-
).join('');
|
|
479
|
-
|
|
480
|
-
return '<div class="modal-mapping-item">'
|
|
481
|
-
+ '<div style="flex:1;">'
|
|
482
|
-
+ '<div class="mcp-label">' + esc(mcp.name) + '</div>'
|
|
483
|
-
+ '<div class="mcp-path-label">' + esc(mcp.path) + '</div>'
|
|
484
|
-
+ '</div>'
|
|
485
|
-
+ '<select data-mcp-path="' + esc(mcp.path) + '">'
|
|
486
|
-
+ '<option value="">-- ignorer --</option>'
|
|
487
|
-
+ options
|
|
488
|
-
+ '</select>'
|
|
489
|
-
+ '</div>';
|
|
490
|
-
}).join('');
|
|
491
|
-
|
|
492
|
-
const offlineHtml = Object.entries(offlineMappings).map(([path, configId]) =>
|
|
493
|
-
'<div class="modal-mapping-item" style="opacity:0.5;">'
|
|
494
|
-
+ '<div style="flex:1;">'
|
|
495
|
-
+ '<div class="mcp-label">⚠ ' + esc(path) + '</div>'
|
|
496
|
-
+ '<div class="mcp-path-label">MCP hors ligne — mapping conserv\u00e9</div>'
|
|
497
|
-
+ '</div>'
|
|
498
|
-
+ '<span style="font-size:0.8rem;color:#a1a1aa;">' + esc(configId) + '</span>'
|
|
499
|
-
+ '</div>'
|
|
500
|
-
).join('');
|
|
501
|
-
|
|
502
|
-
container.innerHTML = onlineHtml + offlineHtml;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
async function handleFormSubmit(e) {
|
|
506
|
-
e.preventDefault();
|
|
507
|
-
const editId = document.getElementById('editId').value;
|
|
508
|
-
const id = document.getElementById('cfgId').value.trim();
|
|
509
|
-
const name = document.getElementById('cfgName').value.trim();
|
|
510
|
-
const description = document.getElementById('cfgDesc').value.trim();
|
|
511
|
-
|
|
512
|
-
const mappings = { ...offlineMappings };
|
|
513
|
-
document.querySelectorAll('#modalMappings select[data-mcp-path]').forEach(sel => {
|
|
514
|
-
if (sel.value) mappings[sel.dataset.mcpPath] = sel.value;
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
const btn = document.getElementById('modalSubmitBtn');
|
|
518
|
-
btn.disabled = true;
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
let res;
|
|
522
|
-
if (editId) {
|
|
523
|
-
res = await fetch('/proxy/meta-configs/' + encodeURIComponent(editId), {
|
|
524
|
-
method: 'PUT',
|
|
525
|
-
headers: { 'Content-Type': 'application/json' },
|
|
526
|
-
body: JSON.stringify({ name, description, mappings })
|
|
527
|
-
});
|
|
528
|
-
} else {
|
|
529
|
-
res = await fetch('/proxy/meta-configs', {
|
|
530
|
-
method: 'POST',
|
|
531
|
-
headers: { 'Content-Type': 'application/json' },
|
|
532
|
-
body: JSON.stringify({ id, name, description, mappings })
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const data = await res.json();
|
|
537
|
-
if (res.ok) {
|
|
538
|
-
window.location.reload();
|
|
539
|
-
} else {
|
|
540
|
-
alert('Erreur: ' + (data.error || 'Inconnue'));
|
|
541
|
-
}
|
|
542
|
-
} catch (err) {
|
|
543
|
-
alert('Erreur r\u00e9seau: ' + err.message);
|
|
544
|
-
} finally {
|
|
545
|
-
btn.disabled = false;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async function applyConfig(id, btn) {
|
|
550
|
-
if (btn.disabled) return;
|
|
551
|
-
const originalText = btn.textContent;
|
|
552
|
-
btn.disabled = true;
|
|
553
|
-
btn.innerHTML = '<span class="spinner"></span>';
|
|
554
|
-
|
|
555
|
-
const resultsDiv = document.getElementById('applyResults');
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const res = await fetch('/proxy/meta-configs/' + encodeURIComponent(id) + '/apply', {
|
|
559
|
-
method: 'POST'
|
|
560
|
-
});
|
|
561
|
-
const data = await res.json();
|
|
562
|
-
|
|
563
|
-
const appliedResults = (data.results || []).filter(r => !(data.warnings || []).some(w => w.path === r.path));
|
|
564
|
-
resultsDiv.innerHTML = '<div class="section-title" style="margin-top:0.5rem;">R\u00e9sultat d\\'application</div>'
|
|
565
|
-
+ appliedResults.map(r =>
|
|
566
|
-
'<div class="apply-result ' + (r.success ? 'success' : 'failure') + '">'
|
|
567
|
-
+ (r.success ? '✓' : '✗') + ' '
|
|
568
|
-
+ esc(r.path)
|
|
569
|
-
+ (r.error ? ' — ' + esc(r.error) : '')
|
|
570
|
-
+ '</div>'
|
|
571
|
-
).join('')
|
|
572
|
-
+ (data.warnings || []).map(r =>
|
|
573
|
-
'<div class="apply-result" style="color:#f59e0b;">'
|
|
574
|
-
+ '⚠ ' + esc(r.path) + ' — MCP non enregistr\u00e9 (ignor\u00e9)'
|
|
575
|
-
+ '</div>'
|
|
576
|
-
).join('');
|
|
577
|
-
|
|
578
|
-
if (data.success) {
|
|
579
|
-
document.querySelectorAll('.config-card').forEach(card => {
|
|
580
|
-
card.classList.remove('active');
|
|
581
|
-
const badge = card.querySelector('.badge');
|
|
582
|
-
if (badge) badge.remove();
|
|
583
|
-
});
|
|
584
|
-
const activeCard = document.querySelector('.config-card[data-id="' + id + '"]');
|
|
585
|
-
if (activeCard) {
|
|
586
|
-
activeCard.classList.add('active');
|
|
587
|
-
const nameEl = activeCard.querySelector('.config-name');
|
|
588
|
-
if (nameEl) nameEl.insertAdjacentHTML('beforeend', '<span class="badge">actif</span>');
|
|
589
|
-
}
|
|
590
|
-
ACTIVE_META_CONFIG_ID = id;
|
|
591
|
-
document.getElementById('syncBtn').style.display = '';
|
|
592
|
-
currentlySynced = true;
|
|
593
|
-
document.getElementById('syncWarning').classList.remove('visible');
|
|
594
|
-
if (!syncPollInterval) {
|
|
595
|
-
checkSync();
|
|
596
|
-
syncPollInterval = setInterval(checkSync, 30000);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
} catch (err) {
|
|
600
|
-
resultsDiv.innerHTML = '<div class="apply-result failure">Erreur r\u00e9seau: ' + esc(err.message) + '</div>';
|
|
601
|
-
} finally {
|
|
602
|
-
btn.textContent = originalText;
|
|
603
|
-
btn.disabled = false;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
async function deleteConfig(id) {
|
|
608
|
-
if (!confirm('Supprimer ce profil ?')) return;
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
const res = await fetch('/proxy/meta-configs/' + encodeURIComponent(id), {
|
|
612
|
-
method: 'DELETE'
|
|
613
|
-
});
|
|
614
|
-
if (res.ok) {
|
|
615
|
-
window.location.reload();
|
|
616
|
-
} else {
|
|
617
|
-
const data = await res.json();
|
|
618
|
-
alert('Erreur: ' + (data.error || 'Inconnue'));
|
|
619
|
-
}
|
|
620
|
-
} catch (err) {
|
|
621
|
-
alert('Erreur r\u00e9seau: ' + err.message);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function closeModal() {
|
|
626
|
-
document.getElementById('modalOverlay').classList.remove('active');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
document.addEventListener('keydown', (e) => {
|
|
630
|
-
if (e.key === 'Escape') closeModal();
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
function esc(str) {
|
|
634
|
-
const d = document.createElement('div');
|
|
635
|
-
d.textContent = String(str);
|
|
636
|
-
return d.innerHTML;
|
|
637
|
-
}
|
|
638
|
-
</script>
|
|
639
|
-
</body>
|
|
640
|
-
</html>`;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
module.exports = { renderMetaConfigs };
|
|
1
|
+
const { escapeHtml } = require('../services/proxyRouter');
|
|
2
|
+
const { BASE_CSS, SHELL_CSS, SHELL_JS, renderSidebar, icon } = require('./theme');
|
|
3
|
+
|
|
4
|
+
/** Un profil dans le panneau latéral : sélection à gauche, détail à droite. */
|
|
5
|
+
function renderProfileItems(configs, activeId, selectedId) {
|
|
6
|
+
return Object.entries(configs).map(([id, cfg]) => `
|
|
7
|
+
<button class="nav-item nav-item-plain${id === selectedId ? ' active' : ''}" data-profile="${escapeHtml(id)}" onclick="selectProfile('${escapeHtml(id)}')">
|
|
8
|
+
<span class="nav-item-label">${escapeHtml(cfg.name)}</span>
|
|
9
|
+
${id === activeId ? '<span class="dot up" title="Profil actif"></span>' : ''}
|
|
10
|
+
</button>`).join('');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Interface de gestion des méta-configurations
|
|
15
|
+
* @param {string} proxyName
|
|
16
|
+
* @param {Object} configs - metaConfigs object
|
|
17
|
+
* @param {string|null} activeId - ID de la méta-config active
|
|
18
|
+
* @param {string|undefined} [cleanUpCommand] - si défini, affiche l'action de nettoyage
|
|
19
|
+
* @param {boolean} [hasAuth] - true si l'authentification est activée
|
|
20
|
+
*/
|
|
21
|
+
function renderMetaConfigs(proxyName, configs, activeId, cleanUpCommand, hasAuth) {
|
|
22
|
+
const ids = Object.keys(configs);
|
|
23
|
+
const selectedId = activeId && configs[activeId] ? activeId : (ids[0] || null);
|
|
24
|
+
|
|
25
|
+
return `<!DOCTYPE html>
|
|
26
|
+
<html lang="fr">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="UTF-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
30
|
+
<title>${escapeHtml(proxyName)} — Meta configs</title>
|
|
31
|
+
<style>
|
|
32
|
+
${BASE_CSS}
|
|
33
|
+
${SHELL_CSS}
|
|
34
|
+
.nav-item-plain .dot { margin-left: auto; }
|
|
35
|
+
.map-row { display: flex; align-items: center; gap: 16px; padding: 10px 8px; border-bottom: 1px solid var(--border-subtle); }
|
|
36
|
+
.map-main { flex: 1 1 auto; min-width: 0; }
|
|
37
|
+
.map-name { font-size: 14px; font-weight: 500; }
|
|
38
|
+
.map-path { font-size: 12px; color: var(--text-4); font-family: var(--mono); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
39
|
+
.map-row select { width: 240px; flex-shrink: 0; }
|
|
40
|
+
.map-row select.changed { border-color: rgba(245,158,11,.4); color: #FBBF24; }
|
|
41
|
+
.map-row.offline { opacity: .55; }
|
|
42
|
+
.map-state { font-size: 12px; color: var(--text-3); width: 88px; text-align: right; flex-shrink: 0; }
|
|
43
|
+
.detail-actions { display: flex; align-items: center; margin-top: 28px; }
|
|
44
|
+
.detail-actions .spacer { margin-left: auto; }
|
|
45
|
+
.apply-result { font-size: 12px; font-family: var(--mono); padding: 4px 0; color: var(--text-2); }
|
|
46
|
+
.apply-result.failure { color: var(--red); }
|
|
47
|
+
.apply-result.warn { color: #FBBF24; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div class="app">
|
|
52
|
+
${renderSidebar({
|
|
53
|
+
proxyName,
|
|
54
|
+
active: 'meta',
|
|
55
|
+
hasCleanUp: !!cleanUpCommand,
|
|
56
|
+
hasAuth,
|
|
57
|
+
sectionLabel: 'PROFILS',
|
|
58
|
+
sectionHtml: renderProfileItems(configs, activeId, selectedId) + `
|
|
59
|
+
<button class="nav-item nav-item-plain nav-item-muted" onclick="openProfileModal()" style="gap:8px;">
|
|
60
|
+
${icon('plus', 14)}<span class="nav-item-label">Nouveau profil</span>
|
|
61
|
+
</button>`,
|
|
62
|
+
statusDetail: `${ids.length} profil${ids.length > 1 ? 's' : ''} enregistré${ids.length > 1 ? 's' : ''}`
|
|
63
|
+
})}
|
|
64
|
+
|
|
65
|
+
<main class="main">
|
|
66
|
+
<div class="main-inner" id="detail"></div>
|
|
67
|
+
</main>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="overlay" id="profileModal" onclick="if(event.target===this)closeProfileModal()">
|
|
71
|
+
<div class="modal">
|
|
72
|
+
<div class="modal-head">
|
|
73
|
+
<h2 id="profileModalTitle">Nouveau profil</h2>
|
|
74
|
+
<button class="icon-btn" onclick="closeProfileModal()" aria-label="Fermer">${icon('x')}</button>
|
|
75
|
+
</div>
|
|
76
|
+
<form id="profileForm" onsubmit="return saveProfile(event)">
|
|
77
|
+
<input type="hidden" id="editId" value="">
|
|
78
|
+
<div class="field" id="idField">
|
|
79
|
+
<label for="cfgId">Identifiant</label>
|
|
80
|
+
<input class="input" type="text" id="cfgId" placeholder="mon-profil" required pattern="[a-z0-9\\-]+" title="Lettres minuscules, chiffres et tirets uniquement">
|
|
81
|
+
</div>
|
|
82
|
+
<div class="field">
|
|
83
|
+
<label for="cfgName">Nom</label>
|
|
84
|
+
<input class="input" type="text" id="cfgName" placeholder="Mon profil" required>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="field">
|
|
87
|
+
<label for="cfgDesc">Description</label>
|
|
88
|
+
<textarea id="cfgDesc" rows="3" placeholder="Description optionnelle…"></textarea>
|
|
89
|
+
</div>
|
|
90
|
+
<button type="submit" class="btn btn-primary" id="profileSubmitBtn" style="width:100%;">Créer</button>
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<script>
|
|
96
|
+
${SHELL_JS}
|
|
97
|
+
|
|
98
|
+
document.querySelector('.brand-host').textContent = location.host;
|
|
99
|
+
|
|
100
|
+
let CONFIGS = ${JSON.stringify(configs)};
|
|
101
|
+
let ACTIVE_ID = ${JSON.stringify(activeId)};
|
|
102
|
+
let selectedId = ${JSON.stringify(selectedId)};
|
|
103
|
+
let discovered = [];
|
|
104
|
+
let discovering = false;
|
|
105
|
+
let synced = true;
|
|
106
|
+
let syncInterval = null;
|
|
107
|
+
|
|
108
|
+
// --- Découverte des MCPs : nécessaire pour éditer les mappings en place ---
|
|
109
|
+
async function discover(silent) {
|
|
110
|
+
discovering = true;
|
|
111
|
+
if (!silent) renderDetail();
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch('/proxy/mcp-configurations');
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
discovered = data.results || [];
|
|
116
|
+
} catch (err) {
|
|
117
|
+
discovered = [];
|
|
118
|
+
} finally {
|
|
119
|
+
discovering = false;
|
|
120
|
+
renderDetail();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compatibleMcps() {
|
|
125
|
+
return discovered.filter(m => m.configurations !== null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Sélection ---
|
|
129
|
+
function selectProfile(id) {
|
|
130
|
+
selectedId = id;
|
|
131
|
+
document.querySelectorAll('[data-profile]').forEach(el => {
|
|
132
|
+
el.classList.toggle('active', el.dataset.profile === id);
|
|
133
|
+
});
|
|
134
|
+
renderDetail();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Détail du profil ---
|
|
138
|
+
function renderDetail() {
|
|
139
|
+
const el = document.getElementById('detail');
|
|
140
|
+
|
|
141
|
+
if (!selectedId || !CONFIGS[selectedId]) {
|
|
142
|
+
el.innerHTML = '<div class="empty"><strong>Aucun profil</strong>'
|
|
143
|
+
+ 'Créez un profil pour regrouper les configurations de vos MCPs.</div>';
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cfg = CONFIGS[selectedId];
|
|
148
|
+
const isActive = selectedId === ACTIVE_ID;
|
|
149
|
+
const mappings = cfg.mappings || {};
|
|
150
|
+
const compatible = compatibleMcps();
|
|
151
|
+
const compatiblePaths = new Set(compatible.map(m => m.path));
|
|
152
|
+
const offline = Object.entries(mappings).filter(([path]) => !compatiblePaths.has(path));
|
|
153
|
+
|
|
154
|
+
let mapHtml;
|
|
155
|
+
if (discovering) {
|
|
156
|
+
mapHtml = '<div class="map-row"><span class="spinner"></span>'
|
|
157
|
+
+ '<span style="font-size:13px;color:var(--text-3);margin-left:10px;">Découverte des MCPs…</span></div>';
|
|
158
|
+
} else if (!compatible.length && !offline.length) {
|
|
159
|
+
mapHtml = '<div class="empty"><strong>Aucun MCP compatible</strong>'
|
|
160
|
+
+ 'Aucun service enregistré n\\'expose de configurations.</div>';
|
|
161
|
+
} else {
|
|
162
|
+
mapHtml = compatible.map(mcp => {
|
|
163
|
+
const selected = mappings[mcp.path] || '';
|
|
164
|
+
const options = ['<option value="">— ignorer —</option>'].concat(
|
|
165
|
+
mcp.configurations.map(c =>
|
|
166
|
+
'<option value="' + esc(c.id) + '"' + (c.id === selected ? ' selected' : '') + '>'
|
|
167
|
+
+ esc(c.name || c.id) + (c.active ? ' (actif)' : '') + '</option>')
|
|
168
|
+
).join('');
|
|
169
|
+
return '<div class="map-row">'
|
|
170
|
+
+ '<div class="map-main"><div class="map-name">' + esc(mcp.name) + '</div>'
|
|
171
|
+
+ '<div class="map-path">' + esc(mcp.path) + '</div></div>'
|
|
172
|
+
+ '<span class="map-state" data-state="' + esc(mcp.path) + '"></span>'
|
|
173
|
+
+ '<select data-mcp-path="' + esc(mcp.path) + '" onchange="onMappingChange(this)">' + options + '</select>'
|
|
174
|
+
+ '</div>';
|
|
175
|
+
}).join('')
|
|
176
|
+
+ offline.map(([path, configId]) =>
|
|
177
|
+
'<div class="map-row offline">'
|
|
178
|
+
+ '<div class="map-main"><div class="map-name">' + esc(path) + '</div>'
|
|
179
|
+
+ '<div class="map-path">MCP hors ligne — mapping conservé</div></div>'
|
|
180
|
+
+ '<span class="map-state">' + esc(configId) + '</span></div>'
|
|
181
|
+
).join('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
el.innerHTML = ''
|
|
185
|
+
+ '<header class="page-head">'
|
|
186
|
+
+ '<div>'
|
|
187
|
+
+ '<h1 class="page-title">' + esc(cfg.name)
|
|
188
|
+
+ (isActive ? ' <span class="badge badge-green">actif</span>' : '') + '</h1>'
|
|
189
|
+
+ '<p class="page-sub">' + (cfg.description ? esc(cfg.description) : 'Aucune description') + '</p>'
|
|
190
|
+
+ '</div>'
|
|
191
|
+
+ '<div class="page-actions">'
|
|
192
|
+
+ '<button class="btn btn-ghost" onclick="discover()">' + ICON_SEARCH + '<span>Découvrir les MCPs</span></button>'
|
|
193
|
+
+ '<button class="btn btn-primary" id="applyBtn" onclick="applyProfile()">Appliquer ce profil</button>'
|
|
194
|
+
+ '</div>'
|
|
195
|
+
+ '</header>'
|
|
196
|
+
+ '<div id="syncBanner"></div>'
|
|
197
|
+
+ '<div id="applyBanner"></div>'
|
|
198
|
+
+ '<div class="list-head"><span>Mappings</span><span class="spacer"></span>'
|
|
199
|
+
+ '<span class="list-head-meta">' + (compatible.length + offline.length) + ' MCP</span></div>'
|
|
200
|
+
+ '<div class="list">' + mapHtml + '</div>'
|
|
201
|
+
+ '<div class="detail-actions">'
|
|
202
|
+
+ '<button class="btn-link" onclick="openProfileModal(selectedId)">Renommer…</button>'
|
|
203
|
+
+ '<span class="spacer"></span>'
|
|
204
|
+
+ '<button class="btn-link danger" onclick="deleteProfile()">Supprimer le profil</button>'
|
|
205
|
+
+ '</div>';
|
|
206
|
+
|
|
207
|
+
renderSyncBanner();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ICON_SEARCH = ${JSON.stringify(icon('search'))};
|
|
211
|
+
const ICON_ALERT = ${JSON.stringify(icon('alert'))};
|
|
212
|
+
|
|
213
|
+
// --- Édition en place d'un mapping : enregistrement immédiat ---
|
|
214
|
+
async function onMappingChange(select) {
|
|
215
|
+
const cfg = CONFIGS[selectedId];
|
|
216
|
+
if (!cfg) return;
|
|
217
|
+
const path = select.dataset.mcpPath;
|
|
218
|
+
const state = document.querySelector('[data-state="' + CSS.escape(path) + '"]');
|
|
219
|
+
|
|
220
|
+
const mappings = { ...(cfg.mappings || {}) };
|
|
221
|
+
if (select.value) mappings[path] = select.value;
|
|
222
|
+
else delete mappings[path];
|
|
223
|
+
|
|
224
|
+
if (state) state.textContent = 'Enregistrement…';
|
|
225
|
+
try {
|
|
226
|
+
const res = await fetch('/proxy/meta-configs/' + encodeURIComponent(selectedId), {
|
|
227
|
+
method: 'PUT',
|
|
228
|
+
headers: { 'Content-Type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ name: cfg.name, description: cfg.description || '', mappings })
|
|
230
|
+
});
|
|
231
|
+
const data = await res.json();
|
|
232
|
+
if (!res.ok) throw new Error(data.error || 'Inconnue');
|
|
233
|
+
cfg.mappings = mappings;
|
|
234
|
+
if (state) {
|
|
235
|
+
state.textContent = 'Enregistré';
|
|
236
|
+
state.style.color = 'var(--green)';
|
|
237
|
+
setTimeout(() => { state.textContent = ''; state.style.color = ''; }, 2000);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (state) { state.textContent = 'Échec'; state.style.color = 'var(--red)'; }
|
|
241
|
+
alert('Erreur: ' + err.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- Application ---
|
|
246
|
+
async function applyProfile() {
|
|
247
|
+
const btn = document.getElementById('applyBtn');
|
|
248
|
+
btn.disabled = true;
|
|
249
|
+
btn.innerHTML = '<span class="spinner"></span>';
|
|
250
|
+
const banner = document.getElementById('applyBanner');
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch('/proxy/meta-configs/' + encodeURIComponent(selectedId) + '/apply', { method: 'POST' });
|
|
254
|
+
const data = await res.json();
|
|
255
|
+
const warnings = data.warnings || [];
|
|
256
|
+
const applied = (data.results || []).filter(r => !warnings.some(w => w.path === r.path));
|
|
257
|
+
|
|
258
|
+
banner.innerHTML = '<div class="banner ' + (data.success ? 'banner-warn' : 'banner-error') + '" style="flex-direction:column;align-items:stretch;gap:6px;">'
|
|
259
|
+
+ '<strong style="font-size:13px;">' + (data.success ? 'Profil appliqué' : 'Application incomplète') + '</strong>'
|
|
260
|
+
+ applied.map(r => '<div class="apply-result' + (r.success ? '' : ' failure') + '">'
|
|
261
|
+
+ (r.success ? '✓ ' : '✗ ') + esc(r.path) + (r.error ? ' — ' + esc(r.error) : '') + '</div>').join('')
|
|
262
|
+
+ warnings.map(r => '<div class="apply-result warn">⚠ ' + esc(r.path) + ' — MCP non enregistré (ignoré)</div>').join('')
|
|
263
|
+
+ '</div>';
|
|
264
|
+
|
|
265
|
+
if (data.success) {
|
|
266
|
+
ACTIVE_ID = selectedId;
|
|
267
|
+
synced = true;
|
|
268
|
+
refreshProfileDots();
|
|
269
|
+
startSyncPolling();
|
|
270
|
+
renderDetail();
|
|
271
|
+
banner.scrollIntoView({ block: 'nearest' });
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
banner.innerHTML = '<div class="banner banner-error">Erreur réseau : ' + esc(err.message) + '</div>';
|
|
275
|
+
} finally {
|
|
276
|
+
btn.disabled = false;
|
|
277
|
+
btn.textContent = 'Appliquer ce profil';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function refreshProfileDots() {
|
|
282
|
+
document.querySelectorAll('[data-profile]').forEach(el => {
|
|
283
|
+
const dot = el.querySelector('.dot');
|
|
284
|
+
if (dot) dot.remove();
|
|
285
|
+
if (el.dataset.profile === ACTIVE_ID) {
|
|
286
|
+
const d = document.createElement('span');
|
|
287
|
+
d.className = 'dot up';
|
|
288
|
+
d.title = 'Profil actif';
|
|
289
|
+
el.appendChild(d);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Création / renommage ---
|
|
295
|
+
function openProfileModal(id) {
|
|
296
|
+
const editing = !!id;
|
|
297
|
+
const cfg = editing ? CONFIGS[id] : null;
|
|
298
|
+
document.getElementById('profileModalTitle').textContent = editing ? 'Renommer le profil' : 'Nouveau profil';
|
|
299
|
+
document.getElementById('profileSubmitBtn').textContent = editing ? 'Enregistrer' : 'Créer';
|
|
300
|
+
document.getElementById('editId').value = editing ? id : '';
|
|
301
|
+
document.getElementById('idField').classList.toggle('hidden', editing);
|
|
302
|
+
document.getElementById('cfgId').required = !editing;
|
|
303
|
+
document.getElementById('cfgId').value = editing ? id : '';
|
|
304
|
+
document.getElementById('cfgName').value = cfg ? cfg.name : '';
|
|
305
|
+
document.getElementById('cfgDesc').value = cfg ? (cfg.description || '') : '';
|
|
306
|
+
document.getElementById('profileModal').classList.add('active');
|
|
307
|
+
document.getElementById(editing ? 'cfgName' : 'cfgId').focus();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function closeProfileModal() {
|
|
311
|
+
document.getElementById('profileModal').classList.remove('active');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function saveProfile(e) {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
const editId = document.getElementById('editId').value;
|
|
317
|
+
const id = document.getElementById('cfgId').value.trim();
|
|
318
|
+
const name = document.getElementById('cfgName').value.trim();
|
|
319
|
+
const description = document.getElementById('cfgDesc').value.trim();
|
|
320
|
+
const btn = document.getElementById('profileSubmitBtn');
|
|
321
|
+
btn.disabled = true;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
let res;
|
|
325
|
+
if (editId) {
|
|
326
|
+
const cfg = CONFIGS[editId];
|
|
327
|
+
res = await fetch('/proxy/meta-configs/' + encodeURIComponent(editId), {
|
|
328
|
+
method: 'PUT',
|
|
329
|
+
headers: { 'Content-Type': 'application/json' },
|
|
330
|
+
body: JSON.stringify({ name, description, mappings: cfg.mappings || {} })
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
res = await fetch('/proxy/meta-configs', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ id, name, description, mappings: {} })
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const data = await res.json();
|
|
340
|
+
if (!res.ok) { alert('Erreur: ' + (data.error || 'Inconnue')); return false; }
|
|
341
|
+
window.location.href = '/proxy/meta-configs-ui';
|
|
342
|
+
} catch (err) {
|
|
343
|
+
alert('Erreur réseau: ' + err.message);
|
|
344
|
+
} finally {
|
|
345
|
+
btn.disabled = false;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function deleteProfile() {
|
|
351
|
+
if (!confirm('Supprimer ce profil ?')) return;
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch('/proxy/meta-configs/' + encodeURIComponent(selectedId), { method: 'DELETE' });
|
|
354
|
+
if (res.ok) { window.location.href = '/proxy/meta-configs-ui'; return; }
|
|
355
|
+
const data = await res.json();
|
|
356
|
+
alert('Erreur: ' + (data.error || 'Inconnue'));
|
|
357
|
+
} catch (err) {
|
|
358
|
+
alert('Erreur réseau: ' + err.message);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Synchronisation ---
|
|
363
|
+
function renderSyncBanner() {
|
|
364
|
+
const banner = document.getElementById('syncBanner');
|
|
365
|
+
if (!banner) return;
|
|
366
|
+
const concerned = ACTIVE_ID && selectedId === ACTIVE_ID;
|
|
367
|
+
banner.innerHTML = (!synced && concerned)
|
|
368
|
+
? '<div class="banner banner-warn">' + ICON_ALERT
|
|
369
|
+
+ '<span>Des configurations ont été modifiées hors de l\\'application.</span>'
|
|
370
|
+
+ '<button class="banner-action" onclick="applyProfile()">Resynchroniser</button></div>'
|
|
371
|
+
: '';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function checkSync() {
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch('/proxy/meta-configs/active/sync');
|
|
377
|
+
const data = await res.json();
|
|
378
|
+
const was = synced;
|
|
379
|
+
synced = data.synced !== false;
|
|
380
|
+
const warn = document.getElementById('navSyncWarn');
|
|
381
|
+
if (warn) warn.classList.toggle('visible', !synced);
|
|
382
|
+
if (was !== synced) renderSyncBanner();
|
|
383
|
+
} catch (_) {}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function startSyncPolling() {
|
|
387
|
+
if (syncInterval || !ACTIVE_ID) return;
|
|
388
|
+
checkSync();
|
|
389
|
+
syncInterval = setInterval(checkSync, 30000);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
window.addEventListener('beforeunload', () => { if (syncInterval) clearInterval(syncInterval); });
|
|
393
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeProfileModal(); });
|
|
394
|
+
|
|
395
|
+
renderDetail();
|
|
396
|
+
startSyncPolling();
|
|
397
|
+
discover();
|
|
398
|
+
</script>
|
|
399
|
+
</body>
|
|
400
|
+
</html>`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
module.exports = { renderMetaConfigs };
|