@imenam/self-register-proxy 1.0.41 → 1.0.43

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.
@@ -1,643 +1,403 @@
1
- const { escapeHtml } = require('../services/proxyRouter');
2
-
3
- /**
4
- * Interface de gestion des méta-configurations
5
- * @param {string} proxyName
6
- * @param {Object} configs - metaConfigs object
7
- * @param {string|null} activeId - ID de la méta-config active
8
- */
9
- function renderMetaConfigs(proxyName, configs, activeId) {
10
- const configsHtml = Object.keys(configs).length > 0
11
- ? `<div class="configs-list" id="configsList">
12
- ${Object.entries(configs).map(([id, cfg]) => `
13
- <div class="config-card ${id === activeId ? 'active' : ''}" data-id="${escapeHtml(id)}">
14
- <div class="config-info">
15
- <div class="config-name">
16
- ${escapeHtml(cfg.name)}
17
- ${id === activeId ? '<span class="badge">actif</span>' : ''}
18
- </div>
19
- ${cfg.description ? `<div class="config-desc">${escapeHtml(cfg.description)}</div>` : ''}
20
- <div class="config-mappings">
21
- ${Object.entries(cfg.mappings).map(([p, c]) => `${escapeHtml(p)} &rarr; ${escapeHtml(c)}`).join(' &bull; ')}
22
- </div>
23
- </div>
24
- <div class="config-actions">
25
- <button class="btn btn-green" onclick="applyConfig('${escapeHtml(id)}', this)">Appliquer</button>
26
- <button class="btn btn-ghost" onclick="editConfig('${escapeHtml(id)}')">Modifier</button>
27
- <button class="btn btn-red" onclick="deleteConfig('${escapeHtml(id)}')">Suppr.</button>
28
- </div>
29
- </div>
30
- `).join('')}
31
- </div>`
32
- : `<div class="empty-state" id="emptyState">
33
- <h2>Aucun profil de m&eacute;ta-configuration</h2>
34
- <p>Cliquez sur "D&eacute;couvrir les MCPs" pour d&eacute;tecter les MCPs compatibles, puis cr&eacute;ez un profil.</p>
35
- </div>`;
36
-
37
- return `<!DOCTYPE html>
38
- <html lang="fr">
39
- <head>
40
- <meta charset="UTF-8">
41
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
- <title>${escapeHtml(proxyName)} - Meta Configurations</title>
43
- <style>
44
- * { margin: 0; padding: 0; box-sizing: border-box; }
45
- body {
46
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
47
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
48
- min-height: 100vh;
49
- color: #e4e4e7;
50
- padding: 1.5rem;
51
- }
52
- .container { max-width: 1100px; margin: 0 auto; }
53
- header { text-align: center; margin-bottom: 1.5rem; }
54
- h1 {
55
- font-size: 2rem;
56
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
57
- -webkit-background-clip: text;
58
- -webkit-text-fill-color: transparent;
59
- background-clip: text;
60
- margin-bottom: 0.25rem;
61
- }
62
- .subtitle { color: #a1a1aa; font-size: 0.95rem; }
63
- .toolbar {
64
- display: flex; justify-content: space-between; align-items: center;
65
- margin-bottom: 0.75rem; gap: 0.5rem;
66
- }
67
- .toolbar-right { display: flex; gap: 0.5rem; align-items: center; }
68
- .btn {
69
- border: none; padding: 0.5rem 1rem; border-radius: 6px;
70
- font-weight: 600; font-size: 0.8rem; cursor: pointer;
71
- transition: opacity 0.2s; display: inline-flex; align-items: center;
72
- gap: 0.4rem; white-space: nowrap; text-decoration: none; color: white;
73
- }
74
- .btn-primary { background: linear-gradient(90deg, #6366f1, #8b5cf6); }
75
- .btn-green { background: linear-gradient(90deg, #10b981, #059669); }
76
- .btn-amber { background: linear-gradient(90deg, #f59e0b, #d97706); }
77
- .btn-red { background: rgba(239, 68, 68, 0.8); }
78
- .btn-ghost {
79
- background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
80
- color: #e4e4e7;
81
- }
82
- .btn:hover { opacity: 0.9; }
83
- .btn:disabled { opacity: 0.4; cursor: not-allowed; }
84
-
85
- .section-title {
86
- font-size: 1rem; font-weight: 600; color: #f4f4f5;
87
- margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;
88
- }
89
-
90
- .configs-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
91
- .config-card {
92
- background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
93
- border-radius: 8px; padding: 0.75rem 1rem;
94
- display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
95
- transition: all 0.15s ease;
96
- }
97
- .config-card:hover {
98
- background: rgba(255,255,255,0.08); border-color: rgba(139,92,246,0.4);
99
- }
100
- .config-card.active { border-color: rgba(16,185,129,0.5); background: rgba(16,185,129,0.08); }
101
- .config-info { flex: 1; min-width: 0; }
102
- .config-name {
103
- font-size: 0.95rem; font-weight: 600; color: #f4f4f5;
104
- display: flex; align-items: center; gap: 0.5rem;
105
- }
106
- .config-name .badge {
107
- font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px;
108
- background: rgba(16,185,129,0.2); color: #10b981; font-weight: 500;
109
- }
110
- .config-desc { font-size: 0.8rem; color: #a1a1aa; margin-top: 0.15rem; }
111
- .config-mappings {
112
- font-size: 0.7rem; color: #71717a; margin-top: 0.25rem;
113
- font-family: 'SF Mono', 'Fira Code', monospace;
114
- }
115
- .config-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
116
-
117
- .empty-state {
118
- text-align: center; padding: 2.5rem 1.5rem;
119
- background: rgba(255,255,255,0.03); border-radius: 10px;
120
- border: 1px dashed rgba(255,255,255,0.1); margin-bottom: 1.5rem;
121
- }
122
- .empty-state h2 { color: #a1a1aa; font-size: 1rem; margin-bottom: 0.5rem; }
123
- .empty-state p { color: #71717a; font-size: 0.85rem; }
124
-
125
- .discovery-section {
126
- background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
127
- border-radius: 10px; padding: 1rem; margin-bottom: 1.5rem;
128
- }
129
- .discovery-section.hidden { display: none; }
130
- .mcp-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
131
- .mcp-item {
132
- background: rgba(0,0,0,0.2); border-radius: 6px; padding: 0.5rem 0.75rem;
133
- display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
134
- }
135
- .mcp-item-info { flex: 1; min-width: 0; }
136
- .mcp-item-name { font-size: 0.85rem; font-weight: 500; color: #f4f4f5; }
137
- .mcp-item-path {
138
- font-size: 0.7rem; color: #8b5cf6;
139
- font-family: 'SF Mono', 'Fira Code', monospace;
140
- }
141
- .mcp-item-error { font-size: 0.7rem; color: #71717a; font-style: italic; }
142
- .mcp-item select {
143
- background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.15);
144
- color: #e4e4e7; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
145
- }
146
- .mcp-item select:focus { outline: none; border-color: #8b5cf6; }
147
-
148
- .modal-overlay {
149
- display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
150
- background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
151
- z-index: 1000; align-items: center; justify-content: center;
152
- }
153
- .modal-overlay.active { display: flex; }
154
- .modal {
155
- background: linear-gradient(135deg, #1e1e3f 0%, #1a1a2e 100%);
156
- border: 1px solid rgba(255,255,255,0.15); border-radius: 12px;
157
- padding: 1.5rem; width: 100%; max-width: 480px;
158
- box-shadow: 0 20px 40px rgba(0,0,0,0.4);
159
- }
160
- .modal-header {
161
- display: flex; justify-content: space-between; align-items: center;
162
- margin-bottom: 1rem;
163
- }
164
- .modal-header h2 { font-size: 1.1rem; color: #f4f4f5; }
165
- .modal-close {
166
- background: transparent; border: none; color: #71717a;
167
- font-size: 1.25rem; cursor: pointer; padding: 0.25rem; line-height: 1;
168
- }
169
- .modal-close:hover { color: #e4e4e7; }
170
- .form-group { margin-bottom: 0.75rem; }
171
- .form-group label {
172
- font-size: 0.75rem; color: #a1a1aa; margin-bottom: 0.25rem; display: block;
173
- }
174
- .form-group input, .form-group textarea {
175
- background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1);
176
- padding: 0.5rem 0.75rem; border-radius: 6px; color: white;
177
- font-family: inherit; font-size: 0.9rem; width: 100%;
178
- }
179
- .form-group textarea { resize: vertical; min-height: 60px; }
180
- .form-group input:focus, .form-group textarea:focus {
181
- outline: none; border-color: #8b5cf6; background: rgba(0,0,0,0.4);
182
- }
183
-
184
- .modal-mappings { margin-top: 0.75rem; }
185
- .modal-mappings-title {
186
- font-size: 0.8rem; font-weight: 600; color: #a1a1aa; margin-bottom: 0.5rem;
187
- }
188
- .modal-mappings-list { display: flex; flex-direction: column; gap: 0.4rem; }
189
- .modal-mapping-item {
190
- display: flex; align-items: center; gap: 0.5rem;
191
- background: rgba(0,0,0,0.2); padding: 0.4rem 0.6rem; border-radius: 6px;
192
- }
193
- .modal-mapping-item .mcp-label {
194
- flex: 1; font-size: 0.8rem; color: #f4f4f5; font-weight: 500;
195
- }
196
- .modal-mapping-item .mcp-path-label {
197
- font-size: 0.7rem; color: #8b5cf6;
198
- font-family: 'SF Mono', 'Fira Code', monospace;
199
- }
200
- .modal-mapping-item select {
201
- background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.15);
202
- color: #e4e4e7; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
203
- min-width: 140px;
204
- }
205
- .modal-mapping-item select:focus { outline: none; border-color: #8b5cf6; }
206
-
207
- .apply-results { margin-top: 0.75rem; }
208
- .apply-result {
209
- display: flex; align-items: center; gap: 0.5rem;
210
- font-size: 0.8rem; padding: 0.25rem 0;
211
- }
212
- .apply-result.success { color: #10b981; }
213
- .apply-result.failure { color: #ef4444; }
214
-
215
- .spinner {
216
- display: inline-block; width: 14px; height: 14px;
217
- border: 2px solid rgba(255,255,255,0.2); border-top-color: #8b5cf6;
218
- border-radius: 50%; animation: spin 0.6s linear infinite;
219
- }
220
- @keyframes spin { to { transform: rotate(360deg); } }
221
-
222
- footer {
223
- margin-top: 1.5rem; text-align: center; color: #52525b; font-size: 0.75rem;
224
- }
225
- footer a { color: #8b5cf6; text-decoration: none; }
226
- footer a:hover { text-decoration: underline; }
227
-
228
- .sync-warning {
229
- display: none;
230
- background: rgba(245, 158, 11, 0.12);
231
- border: 1px solid rgba(245, 158, 11, 0.4);
232
- border-radius: 8px;
233
- padding: 0.65rem 1rem;
234
- margin-bottom: 1rem;
235
- font-size: 0.85rem;
236
- color: #fbbf24;
237
- align-items: center;
238
- gap: 0.5rem;
239
- }
240
- .sync-warning.visible { display: flex; }
241
-
242
- @media (max-width: 600px) {
243
- .toolbar { flex-direction: column; }
244
- .toolbar-right { width: 100%; justify-content: flex-end; }
245
- .config-card { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
246
- .config-actions { width: 100%; justify-content: flex-end; }
247
- .modal { margin: 1rem; max-width: none; }
248
- }
249
- </style>
250
- </head>
251
- <body>
252
- <div class="container">
253
- <header>
254
- <h1>&#9881; Meta Configurations</h1>
255
- <p class="subtitle">${escapeHtml(proxyName)} &mdash; Profils de configuration group&eacute;s</p>
256
- </header>
257
-
258
- <div class="toolbar">
259
- <a href="/" class="btn btn-ghost">&larr; Dashboard</a>
260
- <div class="toolbar-right">
261
- <button class="btn btn-amber" id="discoverBtn" onclick="discover()">&#128269; D&eacute;couvrir les MCPs</button>
262
- <button class="btn btn-ghost" id="syncBtn" onclick="checkSync()" style="display:none;">&#10227; V&eacute;rifier sync</button>
263
- <button class="btn btn-primary" id="createBtn" onclick="openCreateModal()" disabled>+ Cr&eacute;er</button>
264
- </div>
265
- </div>
266
-
267
- <div class="sync-warning" id="syncWarning">
268
- &#9888; Les configurations ne sont plus synchronis&eacute;es avec le profil actif &mdash; une ou plusieurs configs ont &eacute;t&eacute; modifi&eacute;es manuellement.
269
- </div>
270
-
271
- <div class="discovery-section hidden" id="discoverySection">
272
- <div class="section-title">
273
- MCPs compatibles
274
- <span id="discoveryCount" style="font-size:0.75rem;color:#71717a;font-weight:400;"></span>
275
- </div>
276
- <div class="mcp-list" id="mcpList"></div>
277
- </div>
278
-
279
- <div class="section-title">Profils sauvegard&eacute;s</div>
280
- ${configsHtml}
281
-
282
- <div class="apply-results" id="applyResults"></div>
283
-
284
- <div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
285
- <div class="modal">
286
- <div class="modal-header">
287
- <h2 id="modalTitle">Nouveau profil</h2>
288
- <button class="modal-close" onclick="closeModal()">&times;</button>
289
- </div>
290
- <form id="metaForm" onsubmit="return handleFormSubmit(event)">
291
- <input type="hidden" id="editId" value="">
292
- <div class="form-group">
293
- <label for="cfgId">Identifiant (slug)</label>
294
- <input type="text" id="cfgId" placeholder="mon-profil" required pattern="[a-z0-9\\-]+" title="Lettres minuscules, chiffres et tirets uniquement">
295
- </div>
296
- <div class="form-group">
297
- <label for="cfgName">Nom</label>
298
- <input type="text" id="cfgName" placeholder="Mon Profil" required>
299
- </div>
300
- <div class="form-group">
301
- <label for="cfgDesc">Description</label>
302
- <textarea id="cfgDesc" placeholder="Description optionnelle..."></textarea>
303
- </div>
304
- <div class="modal-mappings">
305
- <div class="modal-mappings-title">Mappings MCP &rarr; Configuration</div>
306
- <div class="modal-mappings-list" id="modalMappings">
307
- <p style="font-size:0.75rem;color:#71717a;">Lancez d'abord la d&eacute;couverte des MCPs.</p>
308
- </div>
309
- </div>
310
- <button type="submit" class="btn btn-primary" style="width:100%;margin-top:1rem;" id="modalSubmitBtn">Cr&eacute;er</button>
311
- </form>
312
- </div>
313
- </div>
314
-
315
- <footer>
316
- <p><a href="/">&larr; Dashboard</a> | API: <a href="/proxy/meta-configs">/proxy/meta-configs</a> | <a href="/proxy/mcp-configurations">/proxy/mcp-configurations</a></p>
317
- </footer>
318
- </div>
319
-
320
- <script>
321
- let ACTIVE_META_CONFIG_ID = ${JSON.stringify(activeId)};
322
- let discoveredMcps = [];
323
- let offlineMappings = {};
324
-
325
- let syncPollInterval = null;
326
- let currentlySynced = true;
327
-
328
- async function checkSync() {
329
- console.log('[sync-poll] V\u00e9rification de la synchronisation...');
330
- try {
331
- const res = await fetch('/proxy/meta-configs/active/sync');
332
- const data = await res.json();
333
- console.log('[sync-poll] R\u00e9ponse:', data);
334
-
335
- const wasSynced = currentlySynced;
336
- currentlySynced = data.synced !== false;
337
-
338
- const warning = document.getElementById('syncWarning');
339
- const activeCard = document.querySelector('.config-card.active');
340
-
341
- if (!currentlySynced) {
342
- console.log('[sync-poll] D\u00e9synchronisation d\u00e9tect\u00e9e, conflits:', data.conflicts);
343
- warning.classList.add('visible');
344
- if (activeCard) {
345
- activeCard.classList.remove('active');
346
- const badge = activeCard.querySelector('.badge');
347
- if (badge) badge.remove();
348
- }
349
- } else if (!wasSynced && currentlySynced) {
350
- console.log('[sync-poll] Retour en sync, rechargement...');
351
- warning.classList.remove('visible');
352
- window.location.reload();
353
- } else {
354
- console.log('[sync-poll] OK \u2014 tout est synchronis\u00e9.');
355
- }
356
- } catch (err) {
357
- console.warn('[sync-poll] Erreur r\u00e9seau:', err.message);
358
- }
359
- }
360
-
361
- if (ACTIVE_META_CONFIG_ID) {
362
- console.log('[sync-poll] D\u00e9marrage du polling (30s) pour la m\u00e9ta-config active:', ACTIVE_META_CONFIG_ID);
363
- document.getElementById('syncBtn').style.display = '';
364
- checkSync();
365
- syncPollInterval = setInterval(checkSync, 30000);
366
- } else {
367
- console.log('[sync-poll] Aucune m\u00e9ta-config active, polling d\u00e9sactiv\u00e9.');
368
- }
369
-
370
- window.addEventListener('beforeunload', () => {
371
- if (syncPollInterval) clearInterval(syncPollInterval);
372
- });
373
-
374
- async function discover() {
375
- const btn = document.getElementById('discoverBtn');
376
- btn.disabled = true;
377
- btn.innerHTML = '<span class="spinner"></span> D\u00e9couverte...';
378
-
379
- try {
380
- const res = await fetch('/proxy/mcp-configurations');
381
- const data = await res.json();
382
-
383
- discoveredMcps = data.results || [];
384
- const section = document.getElementById('discoverySection');
385
- const list = document.getElementById('mcpList');
386
- const count = document.getElementById('discoveryCount');
387
-
388
- const compatible = discoveredMcps.filter(m => m.configurations !== null);
389
- count.textContent = '(' + compatible.length + '/' + discoveredMcps.length + ' compatibles)';
390
-
391
- list.innerHTML = discoveredMcps.map(mcp => {
392
- if (mcp.configurations) {
393
- return '<div class="mcp-item">'
394
- + '<div class="mcp-item-info">'
395
- + '<div class="mcp-item-name">' + esc(mcp.name) + '</div>'
396
- + '<div class="mcp-item-path">' + esc(mcp.path) + '</div>'
397
- + '</div>'
398
- + '<span style="font-size:0.75rem;color:#10b981;">' + mcp.configurations.length + ' config(s)</span>'
399
- + '</div>';
400
- }
401
- return '<div class="mcp-item" style="opacity:0.5;">'
402
- + '<div class="mcp-item-info">'
403
- + '<div class="mcp-item-name">' + esc(mcp.name) + '</div>'
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 = '&#128269; 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">&#9888; ' + esc(path) + '</div>'
496
- + '<div class="mcp-path-label">MCP hors ligne &mdash; 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 ? '&#10003;' : '&#10007;') + ' '
568
- + esc(r.path)
569
- + (r.error ? ' &mdash; ' + esc(r.error) : '')
570
- + '</div>'
571
- ).join('')
572
- + (data.warnings || []).map(r =>
573
- '<div class="apply-result" style="color:#f59e0b;">'
574
- + '&#9888; ' + esc(r.path) + ' &mdash; 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 };