@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,4 +1,85 @@
1
1
  const { escapeHtml } = require('../services/proxyRouter');
2
+ const { BASE_CSS, SHELL_CSS, SHELL_JS, renderSidebar, icon } = require('./theme');
3
+
4
+ /** Libellé de la section pour les applications sans groupe (APP_GROUP absent). */
5
+ const UNGROUPED_LABEL = 'Non groupées';
6
+
7
+ /**
8
+ * Répartit les routes par groupe.
9
+ * @param {Array} routes
10
+ * @returns {Array<{ name: string, routes: Array }>} groupes ordonnés :
11
+ * groupes nommés triés alphabétiquement, section "non groupées" en dernier.
12
+ */
13
+ function groupRoutes(routes) {
14
+ const groups = new Map();
15
+ for (const route of routes) {
16
+ const key = (route.group && String(route.group).trim()) || UNGROUPED_LABEL;
17
+ if (!groups.has(key)) groups.set(key, []);
18
+ groups.get(key).push(route);
19
+ }
20
+
21
+ return [...groups.entries()]
22
+ .map(([name, groupRoutes]) => ({ name, routes: groupRoutes }))
23
+ .sort((a, b) => {
24
+ if (a.name === UNGROUPED_LABEL) return 1;
25
+ if (b.name === UNGROUPED_LABEL) return -1;
26
+ return a.name.localeCompare(b.name);
27
+ });
28
+ }
29
+
30
+ /** Nom du groupe d'une route, jamais vide. */
31
+ function groupOf(route) {
32
+ return (route.group && String(route.group).trim()) || UNGROUPED_LABEL;
33
+ }
34
+
35
+ /**
36
+ * Filtres de groupe du panneau latéral. Le groupe est un filtre, pas une
37
+ * section repliable : la liste reste à plat quel que soit le filtre actif.
38
+ */
39
+ function renderGroupFilters(routes) {
40
+ const groups = groupRoutes(routes);
41
+ if (!groups.length) return '';
42
+
43
+ const item = (label, value, count, extraClass) => `
44
+ <button class="nav-item nav-item-plain${extraClass || ''}" data-filter="${escapeHtml(value)}" onclick="setGroupFilter(this)">
45
+ <span class="nav-item-label">${escapeHtml(label)}</span>
46
+ <span class="nav-count">${count}</span>
47
+ </button>`;
48
+
49
+ return item('Tous', '', routes.length, ' active')
50
+ + groups.map(g => item(g.name, g.name, g.routes.length, g.name === UNGROUPED_LABEL ? ' nav-item-muted' : '')).join('');
51
+ }
52
+
53
+ /** Une ligne de service : santé, identité, groupe, ouverture, menu d'actions. */
54
+ function renderServiceRow(route) {
55
+ const group = groupOf(route);
56
+ const fullTarget = `${route.target}:${route.port}`;
57
+ const healthy = route.healthCheck !== false;
58
+ const searchable = `${route.name} ${route.path} ${fullTarget} ${group}`.toLowerCase();
59
+
60
+ return `
61
+ <div class="row" data-path="${escapeHtml(route.path)}" data-port="${route.port}" data-group="${escapeHtml(group)}" data-search="${escapeHtml(searchable)}">
62
+ <span class="dot ${healthy ? 'up' : ''}" title="${healthy ? 'Health check actif' : 'Sans health check'}"></span>
63
+ <div class="row-main">
64
+ <div class="row-name">${escapeHtml(route.name)}</div>
65
+ <div class="row-meta">
66
+ <span class="row-detail">${escapeHtml(route.path)} · ${escapeHtml(fullTarget)}</span>
67
+ </div>
68
+ </div>
69
+ <span class="tag row-config hidden"></span>
70
+ <span class="tag${group === UNGROUPED_LABEL ? ' tag-muted' : ''}">${escapeHtml(group)}</span>
71
+ <a class="row-open" href="${escapeHtml(route.path)}" target="_blank" rel="noopener noreferrer">Ouvrir</a>
72
+ <div class="menu-wrap">
73
+ <button class="icon-btn" onclick="toggleMenu(this)" title="Actions" aria-label="Actions">${icon('more')}</button>
74
+ <div class="menu">
75
+ <button class="menu-item" onclick="checkHealth(this)">${icon('activity')}<span>Vérifier la santé</span></button>
76
+ <button class="menu-item" onclick="copyUrl(this)">${icon('copy')}<span>Copier l'URL</span></button>
77
+ <div class="menu-sep"></div>
78
+ <button class="menu-item danger" onclick="killPort(this)">${icon('circleX')}<span>Tuer le process</span></button>
79
+ </div>
80
+ </div>
81
+ </div>`;
82
+ }
2
83
 
3
84
  /**
4
85
  * Page dashboard principale
@@ -6,638 +87,239 @@ const { escapeHtml } = require('../services/proxyRouter');
6
87
  * @param {number} availablePorts - nombre de ports disponibles
7
88
  * @param {string} proxyName
8
89
  * @param {boolean} hasAuth - true si l'authentification est activée
9
- * @param {string|undefined} cleanUpCommand - si défini, affiche le bouton de nettoyage
90
+ * @param {string|undefined} cleanUpCommand - si défini, affiche l'action de nettoyage
10
91
  * @param {boolean} [minimalDashboard] - interface HTML réduite (nom + Ouvrir uniquement)
11
92
  */
12
93
  function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpCommand, minimalDashboard) {
13
- if (minimalDashboard) {
14
- const servicesHtml = routes.length > 0
15
- ? `<div class="services-list">
16
- ${routes.map(route => `
17
- <div class="service-card">
18
- <div class="service-info">
19
- <span class="service-name">${escapeHtml(route.name)}</span>
20
- </div>
21
- <div class="service-actions">
22
- <a href="${escapeHtml(route.path)}" class="action-btn primary" title="Acc&eacute;der" target="_blank" rel="noopener noreferrer">Ouvrir</a>
23
- </div>
24
- </div>
25
- `).join('')}
26
- </div>`
27
- : `<div class="empty-state minimal-empty">
28
- <p>Aucun service</p>
29
- </div>`;
30
-
31
- return `<!DOCTYPE html>
32
- <html lang="fr">
33
- <head>
34
- <meta charset="UTF-8">
35
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
36
- <title>${escapeHtml(proxyName)}</title>
37
- <style>
38
- * { margin: 0; padding: 0; box-sizing: border-box; }
39
- body.minimal-dashboard {
40
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
41
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
42
- min-height: 100vh;
43
- color: #e4e4e7;
44
- padding: 1rem;
45
- }
46
- .minimal-dashboard .container { max-width: 560px; margin: 0 auto; }
47
- .minimal-dashboard header { text-align: center; margin-bottom: 1rem; }
48
- .minimal-dashboard h1 {
49
- font-size: 1.75rem;
50
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
51
- -webkit-background-clip: text;
52
- -webkit-text-fill-color: transparent;
53
- background-clip: text;
54
- }
55
- .services-list { display: flex; flex-direction: column; gap: 0.5rem; }
56
- .service-card {
57
- background: rgba(255, 255, 255, 0.05);
58
- border: 1px solid rgba(255, 255, 255, 0.1);
59
- border-radius: 8px;
60
- padding: 0.6rem 0.9rem;
61
- display: flex;
62
- align-items: center;
63
- justify-content: space-between;
64
- gap: 0.75rem;
65
- transition: all 0.15s ease;
66
- }
67
- .service-card:hover {
68
- background: rgba(255, 255, 255, 0.08);
69
- border-color: rgba(139, 92, 246, 0.4);
70
- }
71
- .service-info { display: flex; align-items: center; min-width: 0; flex: 1; }
72
- .service-name {
73
- font-size: 0.95rem;
74
- font-weight: 600;
75
- color: #f4f4f5;
76
- white-space: nowrap;
77
- overflow: hidden;
78
- text-overflow: ellipsis;
79
- }
80
- .service-actions { display: flex; flex-shrink: 0; align-items: center; }
81
- .action-btn {
82
- background: rgba(255, 255, 255, 0.1);
83
- color: #e4e4e7;
84
- border: 1px solid rgba(255, 255, 255, 0.15);
85
- padding: 0.35rem 0.6rem;
86
- border-radius: 5px;
87
- cursor: pointer;
88
- font-size: 0.75rem;
89
- font-weight: 500;
90
- text-decoration: none;
91
- display: inline-flex;
92
- align-items: center;
93
- }
94
- .action-btn.primary {
95
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
96
- border: none;
97
- color: white;
98
- }
99
- .action-btn.primary:hover { opacity: 0.9; }
100
- .empty-state.minimal-empty {
101
- text-align: center;
102
- padding: 2rem 1rem;
103
- background: rgba(255, 255, 255, 0.03);
104
- border-radius: 10px;
105
- border: 1px dashed rgba(255, 255, 255, 0.1);
106
- }
107
- .empty-state.minimal-empty p { color: #a1a1aa; font-size: 0.95rem; }
108
- </style>
109
- </head>
110
- <body class="minimal-dashboard">
111
- <div class="container">
112
- <header>
113
- <h1>${escapeHtml(proxyName)}</h1>
114
- </header>
115
- ${servicesHtml}
116
- </div>
117
- </body>
118
- </html>`;
119
- }
94
+ if (minimalDashboard) return renderMinimalDashboard(routes, proxyName);
120
95
 
121
96
  const servicesHtml = routes.length > 0
122
- ? `<div class="services-list">
123
- ${routes.map(route => `
124
- <div class="service-card" data-path="${escapeHtml(route.path)}">
125
- <div class="service-info">
126
- <span class="service-name">${escapeHtml(route.name)}</span>
127
- <span class="service-path">${escapeHtml(route.path)}</span>
128
- <span class="service-target">&rarr; ${escapeHtml(route.target)}:${route.port}</span>
129
- <span class="service-health">${route.healthCheck !== false ? '✅' : '⚪'}</span>
130
- <span class="service-config"></span>
131
- </div>
132
- <div class="service-actions">
133
- <a href="${escapeHtml(route.path)}" class="action-btn primary" title="Acc&eacute;der" target="_blank" rel="noopener noreferrer">Ouvrir</a>
134
- <button onclick="checkHealth('${escapeHtml(route.path)}', this)" class="action-btn" title="V&eacute;rifier la sant&eacute;">Sant&eacute;</button>
135
- <button onclick="killPort(${route.port}, this)" class="action-btn danger" title="Tuer le process">Tuer</button>
136
- </div>
137
- </div>
138
- `).join('')}
139
- </div>`
140
- : `<div class="empty-state">
141
- <h2>Aucun service enregistr&eacute;</h2>
142
- <p>Cliquez sur "+ Ajouter" ou utilisez l'API:</p>
143
- <p><span class="code">POST /proxy/register-manual</span></p>
144
- </div>`;
97
+ ? `<div class="list" id="servicesList">${routes.map(renderServiceRow).join('')}</div>
98
+ <div class="empty hidden" id="noMatch"><strong>Aucun résultat</strong>Aucun service ne correspond à ce filtre.</div>`
99
+ : `<div class="empty">
100
+ <strong>Aucun service enregistré</strong>
101
+ Ajoutez-en un avec « Nouveau service », ou appelez <code>POST /proxy/register-manual</code>.
102
+ </div>`;
145
103
 
146
104
  return `<!DOCTYPE html>
147
105
  <html lang="fr">
148
106
  <head>
149
107
  <meta charset="UTF-8">
150
108
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
151
- <title>${escapeHtml(proxyName)} - Services disponibles</title>
109
+ <title>${escapeHtml(proxyName)} — Services</title>
152
110
  <style>
153
- * {
154
- margin: 0;
155
- padding: 0;
156
- box-sizing: border-box;
157
- }
158
- body {
159
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
160
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
161
- min-height: 100vh;
162
- color: #e4e4e7;
163
- padding: 1.5rem;
164
- }
165
- .container {
166
- max-width: 1100px;
167
- margin: 0 auto;
168
- }
169
- header {
170
- text-align: center;
171
- margin-bottom: 1.5rem;
172
- }
173
- h1 {
174
- font-size: 2rem;
175
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
176
- -webkit-background-clip: text;
177
- -webkit-text-fill-color: transparent;
178
- background-clip: text;
179
- margin-bottom: 0.25rem;
180
- }
181
- .subtitle {
182
- color: #a1a1aa;
183
- font-size: 0.95rem;
184
- }
185
- .stats {
186
- display: flex;
187
- justify-content: center;
188
- gap: 1.5rem;
189
- margin-top: 1rem;
190
- }
191
- .stat {
192
- background: rgba(255, 255, 255, 0.05);
193
- padding: 0.5rem 1rem;
194
- border-radius: 8px;
195
- text-align: center;
196
- }
197
- .stat-value {
198
- font-size: 1.25rem;
199
- font-weight: bold;
200
- color: #8b5cf6;
201
- }
202
- .stat-label {
203
- font-size: 0.75rem;
204
- color: #71717a;
205
- }
206
- .toolbar {
207
- display: flex;
208
- justify-content: space-between;
209
- align-items: center;
210
- margin-bottom: 0.75rem;
211
- gap: 0.5rem;
212
- }
213
- .toolbar-left {
214
- display: flex;
215
- gap: 0.5rem;
216
- align-items: center;
217
- }
218
- .toolbar-right {
219
- display: flex;
220
- gap: 0.5rem;
221
- align-items: center;
222
- }
223
- .agent-btn {
224
- background: linear-gradient(90deg, #10b981, #059669);
225
- color: white;
226
- border: none;
227
- padding: 0.5rem 1rem;
228
- border-radius: 6px;
229
- font-weight: 600;
230
- font-size: 0.8rem;
231
- cursor: pointer;
232
- transition: opacity 0.2s;
233
- display: inline-flex;
234
- align-items: center;
235
- gap: 0.4rem;
236
- white-space: nowrap;
237
- }
238
- .agent-btn:hover {
239
- opacity: 0.9;
240
- }
241
- .cleanup-btn {
242
- background: linear-gradient(90deg, #ef4444, #dc2626);
243
- color: white;
244
- border: none;
245
- padding: 0.5rem 1rem;
246
- border-radius: 6px;
247
- font-weight: 600;
248
- font-size: 0.8rem;
249
- cursor: pointer;
250
- transition: opacity 0.2s;
251
- display: inline-flex;
252
- align-items: center;
253
- gap: 0.4rem;
254
- white-space: nowrap;
255
- }
256
- .cleanup-btn:hover { opacity: 0.9; }
257
- .cleanup-btn:disabled { opacity: 0.5; cursor: not-allowed; }
258
- .add-btn {
259
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
260
- color: white;
261
- border: none;
262
- padding: 0.5rem 1rem;
263
- border-radius: 6px;
264
- font-weight: 600;
265
- font-size: 0.8rem;
266
- cursor: pointer;
267
- transition: opacity 0.2s;
268
- display: inline-flex;
269
- align-items: center;
270
- gap: 0.4rem;
271
- }
272
- .add-btn:hover {
273
- opacity: 0.9;
274
- }
275
- .modal-overlay {
276
- display: none;
277
- position: fixed;
278
- top: 0;
279
- left: 0;
280
- right: 0;
281
- bottom: 0;
282
- background: rgba(0, 0, 0, 0.6);
283
- z-index: 1000;
284
- align-items: center;
285
- justify-content: center;
286
- }
287
- .modal-overlay.active {
288
- display: flex;
289
- }
290
- .modal {
291
- background: linear-gradient(135deg, #1e1e3f 0%, #1a1a2e 100%);
292
- border: 1px solid rgba(255, 255, 255, 0.15);
293
- border-radius: 12px;
294
- padding: 1.5rem;
295
- width: 100%;
296
- max-width: 360px;
297
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
298
- }
299
- .modal-header {
300
- display: flex;
301
- justify-content: space-between;
302
- align-items: center;
303
- margin-bottom: 1rem;
304
- }
305
- .modal-header h2 {
306
- font-size: 1.1rem;
307
- color: #f4f4f5;
308
- }
309
- .modal-close {
310
- background: transparent;
311
- border: none;
312
- color: #71717a;
313
- font-size: 1.25rem;
314
- cursor: pointer;
315
- padding: 0.25rem;
316
- line-height: 1;
317
- }
318
- .modal-close:hover {
319
- color: #e4e4e7;
320
- }
321
- .modal .form-group {
322
- margin-bottom: 0.75rem;
323
- }
324
- .modal .form-group label {
325
- font-size: 0.75rem;
326
- color: #a1a1aa;
327
- margin-bottom: 0.25rem;
328
- display: block;
329
- }
330
- .modal .form-group input {
331
- background: rgba(0, 0, 0, 0.3);
332
- border: 1px solid rgba(255, 255, 255, 0.1);
333
- padding: 0.5rem 0.75rem;
334
- border-radius: 6px;
335
- color: white;
336
- font-family: inherit;
337
- font-size: 0.9rem;
338
- width: 100%;
339
- }
340
- .modal .form-group input:focus {
341
- outline: none;
342
- border-color: #8b5cf6;
343
- background: rgba(0, 0, 0, 0.4);
344
- }
345
- .modal .checkbox-group {
346
- display: flex;
347
- align-items: center;
348
- gap: 0.5rem;
349
- margin-bottom: 1rem;
350
- }
351
- .modal .checkbox-group input {
352
- width: auto;
353
- }
354
- .modal .checkbox-group label {
355
- font-size: 0.75rem;
356
- color: #a1a1aa;
357
- margin: 0;
358
- }
359
- .modal .submit-btn {
360
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
361
- color: white;
362
- border: none;
363
- padding: 0.6rem 1.25rem;
364
- border-radius: 6px;
365
- font-weight: 600;
366
- font-size: 0.85rem;
367
- cursor: pointer;
368
- transition: opacity 0.2s;
369
- width: 100%;
370
- }
371
- .modal .submit-btn:hover {
372
- opacity: 0.9;
373
- }
374
- .services-list {
375
- display: flex;
376
- flex-direction: column;
377
- gap: 0.5rem;
378
- }
379
- .service-card {
380
- background: rgba(255, 255, 255, 0.05);
381
- border: 1px solid rgba(255, 255, 255, 0.1);
382
- border-radius: 8px;
383
- padding: 0.6rem 0.9rem;
384
- display: flex;
385
- align-items: center;
386
- justify-content: space-between;
387
- gap: 0.75rem;
388
- transition: all 0.15s ease;
389
- }
390
- .service-card:hover {
391
- background: rgba(255, 255, 255, 0.08);
392
- border-color: rgba(139, 92, 246, 0.4);
393
- }
394
- .service-info {
395
- display: flex;
396
- align-items: center;
397
- gap: 0.6rem;
398
- flex-wrap: wrap;
399
- min-width: 0;
400
- flex: 1;
401
- }
402
- .service-name {
403
- font-size: 0.95rem;
404
- font-weight: 600;
405
- color: #f4f4f5;
406
- white-space: nowrap;
407
- }
408
- .service-path {
409
- font-family: 'SF Mono', 'Fira Code', monospace;
410
- font-size: 0.75rem;
411
- color: #8b5cf6;
412
- background: rgba(139, 92, 246, 0.15);
413
- padding: 0.15rem 0.5rem;
414
- border-radius: 4px;
415
- white-space: nowrap;
416
- }
417
- .service-target {
418
- font-size: 0.75rem;
419
- color: #71717a;
420
- white-space: nowrap;
421
- }
422
- .service-health {
423
- font-size: 0.7rem;
424
- }
425
- .service-config {
426
- font-size: 0.7rem;
427
- color: #10b981;
428
- background: rgba(16, 185, 129, 0.1);
429
- border: 1px solid rgba(16, 185, 129, 0.25);
430
- padding: 0.1rem 0.45rem;
431
- border-radius: 4px;
432
- white-space: nowrap;
433
- }
434
- .service-config:empty { display: none; }
435
- .service-actions {
436
- display: flex;
437
- gap: 0.4rem;
438
- flex-shrink: 0;
439
- align-items: center;
440
- }
441
- .action-btn {
442
- background: rgba(255, 255, 255, 0.1);
443
- color: #e4e4e7;
444
- border: 1px solid rgba(255, 255, 255, 0.15);
445
- padding: 0.35rem 0.6rem;
446
- border-radius: 5px;
447
- cursor: pointer;
448
- font-size: 0.75rem;
449
- font-weight: 500;
450
- transition: all 0.15s;
451
- text-decoration: none;
452
- display: inline-flex;
453
- align-items: center;
454
- }
455
- .action-btn:hover {
456
- background: rgba(255, 255, 255, 0.15);
457
- border-color: rgba(255, 255, 255, 0.25);
458
- }
459
- .action-btn.primary {
460
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
461
- border: none;
462
- color: white;
463
- }
464
- .action-btn.primary:hover {
465
- opacity: 0.9;
466
- }
467
- .action-btn.loading {
468
- opacity: 0.5;
469
- cursor: not-allowed;
470
- }
471
- .action-btn.healthy {
472
- background: rgba(16, 185, 129, 0.2);
473
- border-color: rgba(16, 185, 129, 0.4);
474
- color: #10b981;
475
- }
476
- .action-btn.unhealthy {
477
- background: rgba(239, 68, 68, 0.2);
478
- border-color: rgba(239, 68, 68, 0.4);
479
- color: #ef4444;
480
- }
481
- .action-btn.danger {
482
- background: rgba(239, 68, 68, 0.15);
483
- border-color: rgba(239, 68, 68, 0.35);
484
- color: #ef4444;
485
- }
486
- .action-btn.danger:hover {
487
- background: rgba(239, 68, 68, 0.25);
488
- border-color: rgba(239, 68, 68, 0.5);
489
- }
490
- .empty-state {
491
- text-align: center;
492
- padding: 2.5rem 1.5rem;
493
- background: rgba(255, 255, 255, 0.03);
494
- border-radius: 10px;
495
- border: 1px dashed rgba(255, 255, 255, 0.1);
496
- }
497
- .empty-state h2 {
498
- color: #a1a1aa;
499
- font-size: 1rem;
500
- margin-bottom: 0.5rem;
501
- }
502
- .empty-state p {
503
- color: #71717a;
504
- font-size: 0.85rem;
505
- margin-bottom: 0.25rem;
506
- }
507
- .code {
508
- font-family: 'SF Mono', 'Fira Code', monospace;
509
- background: rgba(0, 0, 0, 0.3);
510
- padding: 0.15rem 0.4rem;
511
- border-radius: 4px;
512
- font-size: 0.8rem;
513
- }
514
- footer {
515
- margin-top: 1.5rem;
516
- text-align: center;
517
- color: #52525b;
518
- font-size: 0.75rem;
519
- }
520
- footer a {
521
- color: #8b5cf6;
522
- text-decoration: none;
523
- }
524
- footer a:hover {
525
- text-decoration: underline;
526
- }
527
- @media (max-width: 600px) {
528
- .toolbar {
529
- flex-direction: column;
530
- }
531
- .toolbar-right {
532
- width: 100%;
533
- justify-content: flex-end;
534
- }
535
- .service-card {
536
- flex-direction: column;
537
- align-items: flex-start;
538
- gap: 0.5rem;
539
- }
540
- .service-actions {
541
- width: 100%;
542
- justify-content: flex-end;
543
- }
544
- .modal {
545
- margin: 1rem;
546
- max-width: none;
547
- }
548
- }
111
+ ${BASE_CSS}
112
+ ${SHELL_CSS}
113
+ .row-config { max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
114
+ .row-status { margin-left: 8px; color: var(--text-3); font-family: var(--sans); }
549
115
  </style>
550
116
  </head>
551
117
  <body>
552
- <div class="container">
553
- <header>
554
- <h1>🚀 ${escapeHtml(proxyName)}</h1>
555
- <p class="subtitle">Services disponibles</p>
556
- <div class="stats">
557
- <div class="stat">
558
- <div class="stat-value">${routes.length}</div>
559
- <div class="stat-label">Services actifs</div>
560
- </div>
561
- <div class="stat">
562
- <div class="stat-value">${availablePorts}</div>
563
- <div class="stat-label">Ports disponibles</div>
118
+ <div class="app">
119
+ ${renderSidebar({
120
+ proxyName,
121
+ active: 'services',
122
+ serviceCount: routes.length,
123
+ hasCleanUp: !!cleanUpCommand,
124
+ hasAuth,
125
+ sectionLabel: routes.length ? 'GROUPES' : '',
126
+ sectionHtml: renderGroupFilters(routes),
127
+ statusDetail: `${availablePorts} ports libres · ${routes.length} service${routes.length > 1 ? 's' : ''}`
128
+ })}
129
+
130
+ <main class="main">
131
+ <div class="main-inner">
132
+ <header class="page-head">
133
+ <div>
134
+ <h1 class="page-title">Services</h1>
135
+ <p class="page-sub">${routes.length} service${routes.length > 1 ? 's' : ''} exposé${routes.length > 1 ? 's' : ''} · ${availablePorts} ports disponibles</p>
136
+ </div>
137
+ <div class="page-actions">
138
+ <div class="search">
139
+ ${icon('search')}
140
+ <input class="input" type="search" id="searchInput" placeholder="Rechercher un service…" oninput="applyFilters()" autocomplete="off" style="width:240px;">
141
+ </div>
142
+ <button class="btn btn-primary" onclick="openModal()">${icon('plus')}<span>Nouveau service</span></button>
143
+ </div>
144
+ </header>
145
+
146
+ <div class="list-head">
147
+ <span id="listTitle">Vos services</span>
148
+ <button class="icon-btn" id="refreshConfigsBtn" onclick="loadActiveConfigs()" title="Rafraîchir les configurations actives">${icon('refresh', 14)}</button>
149
+ <span class="spacer"></span>
150
+ <span class="list-head-meta" id="listCount"></span>
564
151
  </div>
565
- </div>
566
- </header>
567
152
 
568
- <div class="toolbar">
569
- <div class="toolbar-left">
570
- <button class="agent-btn" onclick="openAgent()">&#9654; Lancer Agent</button>
571
- ${cleanUpCommand ? '<button class="cleanup-btn" id="cleanupBtn" onclick="runCleanUp()">&#128465; Nettoyer les process</button>' : ''}
153
+ ${servicesHtml}
572
154
  </div>
573
- <div class="toolbar-right">
574
- <a href="/proxy/meta-configs-ui" class="add-btn" style="text-decoration:none;">&#9881; Meta Configs</a>
575
- <button class="action-btn" id="refreshConfigsBtn" onclick="loadActiveConfigs()">&#10227; Configs</button>
576
- <button class="add-btn" onclick="openModal()">+ Ajouter</button>
577
- ${hasAuth ? '<button class="action-btn" onclick="logout()">D&eacute;connexion</button>' : ''}
578
- </div>
579
- </div>
580
-
581
- ${servicesHtml}
155
+ </main>
156
+ </div>
582
157
 
583
- <div class="modal-overlay" id="modalOverlay" onclick="closeModalOnOverlay(event)">
584
- <div class="modal">
585
- <div class="modal-header">
586
- <h2>Nouveau service</h2>
587
- <button class="modal-close" onclick="closeModal()">&times;</button>
588
- </div>
589
- <form id="registerForm">
590
- <div class="form-group">
591
- <label for="path">Chemin</label>
592
- <input type="text" id="path" name="path" placeholder="/mon-app" required>
593
- </div>
594
- <div class="form-group">
595
- <label for="name">Nom</label>
596
- <input type="text" id="name" name="name" placeholder="Mon Application">
597
- </div>
598
- <div class="form-group">
599
- <label for="target">H&ocirc;te cible</label>
600
- <input type="text" id="target" name="target" placeholder="http://localhost" value="http://localhost">
601
- </div>
602
- <div class="form-group">
603
- <label for="port">Port</label>
604
- <input type="number" id="port" name="port" placeholder="4000" required>
605
- </div>
606
- <div class="checkbox-group">
607
- <input type="checkbox" id="healthCheck" name="healthCheck">
608
- <label for="healthCheck">Health check actif</label>
609
- </div>
610
- <button type="submit" class="submit-btn" id="submitBtn">Enregistrer</button>
611
- </form>
158
+ <div class="overlay" id="modalOverlay" onclick="closeModalOnOverlay(event)">
159
+ <div class="modal">
160
+ <div class="modal-head">
161
+ <h2>Nouveau service</h2>
162
+ <button class="icon-btn" onclick="closeModal()" aria-label="Fermer">${icon('x')}</button>
612
163
  </div>
164
+ <form id="registerForm">
165
+ <div class="field">
166
+ <label for="path">Chemin</label>
167
+ <input class="input" type="text" id="path" name="path" placeholder="/mon-app" required>
168
+ </div>
169
+ <div class="field">
170
+ <label for="name">Nom</label>
171
+ <input class="input" type="text" id="name" name="name" placeholder="Mon Application">
172
+ </div>
173
+ <div class="field">
174
+ <label for="target">Hôte cible</label>
175
+ <input class="input" type="text" id="target" name="target" placeholder="http://localhost" value="http://localhost">
176
+ </div>
177
+ <div class="field">
178
+ <label for="port">Port</label>
179
+ <input class="input" type="number" id="port" name="port" placeholder="4000" required>
180
+ </div>
181
+ <div class="field">
182
+ <label for="group">Groupe (optionnel)</label>
183
+ <input class="input" type="text" id="group" name="group" placeholder="Vide → « ${UNGROUPED_LABEL} »">
184
+ </div>
185
+ <div class="field-inline">
186
+ <input type="checkbox" id="healthCheck" name="healthCheck">
187
+ <label for="healthCheck">Health check actif</label>
188
+ </div>
189
+ <button type="submit" class="btn btn-primary" id="submitBtn" style="width:100%;">Enregistrer</button>
190
+ </form>
613
191
  </div>
614
-
615
- <footer>
616
- <p>API: <a href="/proxy/routes">/proxy/routes</a> | <a href="/proxy/health">/proxy/health</a></p>
617
- </footer>
618
192
  </div>
619
193
 
620
194
  <script>
195
+ ${SHELL_JS}
196
+
197
+ document.querySelector('.brand-host').textContent = location.host;
198
+ refreshSyncBadge();
199
+
200
+ // --- Filtres : groupe (panneau) + recherche, combinés ---
201
+ let currentGroup = '';
202
+
203
+ function setGroupFilter(btn) {
204
+ currentGroup = btn.dataset.filter || '';
205
+ document.querySelectorAll('[data-filter]').forEach(el => el.classList.toggle('active', el === btn));
206
+ document.getElementById('listTitle').textContent = currentGroup || 'Vos services';
207
+ applyFilters();
208
+ }
209
+
210
+ function applyFilters() {
211
+ const input = document.getElementById('searchInput');
212
+ const query = input ? input.value.trim().toLowerCase() : '';
213
+ const rows = document.querySelectorAll('#servicesList .row');
214
+ let visible = 0;
215
+
216
+ rows.forEach(row => {
217
+ const matchGroup = !currentGroup || row.dataset.group === currentGroup;
218
+ const matchQuery = !query || row.dataset.search.includes(query);
219
+ const show = matchGroup && matchQuery;
220
+ row.classList.toggle('hidden', !show);
221
+ if (show) visible++;
222
+ });
223
+
224
+ const noMatch = document.getElementById('noMatch');
225
+ if (noMatch) noMatch.classList.toggle('hidden', visible > 0 || rows.length === 0);
226
+ const count = document.getElementById('listCount');
227
+ if (count) count.textContent = rows.length ? visible + ' sur ' + rows.length : '';
228
+ }
229
+
230
+ applyFilters();
231
+
232
+ // --- Configurations actives, affichées en étiquette sur chaque ligne ---
621
233
  async function loadActiveConfigs() {
622
234
  const btn = document.getElementById('refreshConfigsBtn');
623
- if (btn) { btn.disabled = true; btn.textContent = '\u2026'; }
235
+ if (btn) { btn.disabled = true; btn.classList.add('spinning'); }
624
236
  try {
625
237
  const res = await fetch('/proxy/mcp-configurations');
626
238
  const data = await res.json();
627
239
  (data.results || []).forEach(mcp => {
628
- const card = document.querySelector('.service-card[data-path="' + mcp.path + '"]');
629
- if (!card) return;
630
- const span = card.querySelector('.service-config');
631
- if (!mcp.configurations) { span.textContent = ''; return; }
632
- const active = mcp.configurations.find(c => c.active);
633
- span.textContent = active ? (active.name || active.id) : '';
240
+ const row = document.querySelector('.row[data-path="' + mcp.path + '"]');
241
+ if (!row) return;
242
+ const tag = row.querySelector('.row-config');
243
+ const active = mcp.configurations && mcp.configurations.find(c => c.active);
244
+ if (active) {
245
+ tag.textContent = active.name || active.id;
246
+ tag.classList.remove('hidden');
247
+ } else {
248
+ tag.textContent = '';
249
+ tag.classList.add('hidden');
250
+ }
634
251
  });
635
252
  } catch (_) {} finally {
636
- if (btn) { btn.disabled = false; btn.textContent = '\u21ba Configs'; }
253
+ if (btn) { btn.disabled = false; btn.classList.remove('spinning'); }
637
254
  }
638
255
  }
639
256
  loadActiveConfigs();
640
257
 
258
+ // --- Actions d'une ligne ---
259
+ function rowOf(el) { return el.closest('.row'); }
260
+
261
+ /** Message éphémère à droite du chemin, plutôt qu'un bouton qui change de texte. */
262
+ function setRowStatus(row, text, color) {
263
+ let el = row.querySelector('.row-status');
264
+ if (!el) {
265
+ el = document.createElement('span');
266
+ el.className = 'row-status';
267
+ row.querySelector('.row-meta').appendChild(el);
268
+ }
269
+ el.textContent = text ? ' · ' + text : '';
270
+ el.style.color = color || 'var(--text-3)';
271
+ if (text) setTimeout(() => { if (el.textContent === ' · ' + text) el.textContent = ''; }, 4000);
272
+ }
273
+
274
+ async function checkHealth(el) {
275
+ closeAllMenus();
276
+ const row = rowOf(el);
277
+ const dot = row.querySelector('.dot');
278
+ setRowStatus(row, 'vérification…');
279
+ try {
280
+ const res = await fetch('/proxy/check', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({ path: row.dataset.path })
284
+ });
285
+ const data = await res.json();
286
+ dot.className = 'dot ' + (data.healthy ? 'up' : 'down');
287
+ setRowStatus(row, data.healthy ? 'en ligne' : 'hors ligne', data.healthy ? 'var(--green)' : 'var(--red)');
288
+ if (!data.healthy) setTimeout(() => window.location.reload(), 2500);
289
+ } catch (_) {
290
+ setRowStatus(row, 'erreur réseau', 'var(--red)');
291
+ }
292
+ }
293
+
294
+ function copyUrl(el) {
295
+ closeAllMenus();
296
+ const row = rowOf(el);
297
+ const url = location.origin + row.dataset.path;
298
+ navigator.clipboard.writeText(url)
299
+ .then(() => setRowStatus(row, 'URL copiée', 'var(--green)'))
300
+ .catch(() => setRowStatus(row, 'copie impossible', 'var(--red)'));
301
+ }
302
+
303
+ async function killPort(el) {
304
+ closeAllMenus();
305
+ const row = rowOf(el);
306
+ const port = row.dataset.port;
307
+ if (!confirm('Tuer le process sur le port ' + port + ' ?')) return;
308
+ setRowStatus(row, 'arrêt…');
309
+ try {
310
+ await fetch('/proxy/kill-port', {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify({ port: parseInt(port, 10) })
314
+ });
315
+ row.querySelector('.dot').className = 'dot down';
316
+ setRowStatus(row, 'process tué', 'var(--amber)');
317
+ } catch (_) {
318
+ setRowStatus(row, 'échec', 'var(--red)');
319
+ }
320
+ }
321
+
322
+ // --- Modale d'ajout ---
641
323
  function openModal() {
642
324
  document.getElementById('modalOverlay').classList.add('active');
643
325
  document.getElementById('path').focus();
@@ -650,29 +332,33 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
650
332
  }
651
333
 
652
334
  function closeModalOnOverlay(event) {
653
- if (event.target === document.getElementById('modalOverlay')) {
654
- closeModal();
655
- }
335
+ if (event.target === document.getElementById('modalOverlay')) closeModal();
656
336
  }
657
337
 
658
338
  document.addEventListener('keydown', (e) => {
659
339
  if (e.key === 'Escape') closeModal();
340
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
341
+ e.preventDefault();
342
+ const input = document.getElementById('searchInput');
343
+ if (input) input.focus();
344
+ }
660
345
  });
661
346
 
662
347
  document.getElementById('registerForm').addEventListener('submit', async (e) => {
663
348
  e.preventDefault();
664
349
  const btn = document.getElementById('submitBtn');
665
- const originalText = btn.innerText;
350
+ const originalText = btn.textContent;
666
351
 
667
352
  const formData = {
668
353
  path: document.getElementById('path').value,
669
354
  name: document.getElementById('name').value,
670
355
  target: document.getElementById('target').value,
671
356
  port: parseInt(document.getElementById('port').value),
357
+ group: document.getElementById('group').value,
672
358
  healthCheck: document.getElementById('healthCheck').checked
673
359
  };
674
360
 
675
- btn.innerText = 'Enregistrement...';
361
+ btn.textContent = 'Enregistrement…';
676
362
  btn.disabled = true;
677
363
 
678
364
  try {
@@ -681,115 +367,77 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
681
367
  headers: { 'Content-Type': 'application/json' },
682
368
  body: JSON.stringify(formData)
683
369
  });
684
-
685
370
  const data = await response.json();
686
371
 
687
372
  if (response.ok) {
688
- btn.innerText = '\u2705 Succ\u00e8s\u00a0!';
689
- setTimeout(() => {
690
- window.location.reload();
691
- }, 1000);
373
+ btn.textContent = 'Enregistré';
374
+ setTimeout(() => window.location.reload(), 800);
692
375
  } else {
693
376
  alert('Erreur: ' + data.error);
694
- btn.innerText = originalText;
377
+ btn.textContent = originalText;
695
378
  btn.disabled = false;
696
379
  }
697
380
  } catch (error) {
698
- alert('Erreur r\u00e9seau');
699
- btn.innerText = originalText;
381
+ alert('Erreur réseau');
382
+ btn.textContent = originalText;
700
383
  btn.disabled = false;
701
384
  }
702
385
  });
386
+ </script>
387
+ </body>
388
+ </html>`;
389
+ }
703
390
 
704
- function openAgent() {
705
- window.open('/proxy/terminal', '_blank');
706
- }
707
-
708
- async function runCleanUp() {
709
- if (!confirm('Nettoyer tous les processus ?')) return;
710
- const btn = document.getElementById('cleanupBtn');
711
- const originalText = btn.innerHTML;
712
- btn.disabled = true;
713
- btn.innerHTML = '&#8987; Nettoyage\u2026';
714
- try {
715
- await fetch('/proxy/cleanup', { method: 'POST' });
716
- btn.innerHTML = '&#10003; Nettoy\u00e9';
717
- setTimeout(() => { btn.innerHTML = originalText; btn.disabled = false; }, 3000);
718
- } catch (_) {
719
- btn.innerHTML = originalText;
720
- btn.disabled = false;
721
- }
722
- }
723
-
724
- function logout() {
725
- const form = document.createElement('form');
726
- form.method = 'POST';
727
- form.action = '/proxy/logout';
728
- document.body.appendChild(form);
729
- form.submit();
730
- }
731
-
732
- async function killPort(port, btn) {
733
- if (btn.classList.contains('loading')) return;
734
- if (!confirm('Tuer le process sur le port ' + port + ' ?')) return;
735
- const originalText = btn.innerText;
736
- btn.innerText = '\u2026';
737
- btn.classList.add('loading');
738
- try {
739
- await fetch('/proxy/kill-port', {
740
- method: 'POST',
741
- headers: { 'Content-Type': 'application/json' },
742
- body: JSON.stringify({ port })
743
- });
744
- btn.innerText = '\u2713 Tu\u00e9';
745
- setTimeout(() => { btn.innerText = originalText; btn.classList.remove('loading'); }, 2000);
746
- } catch (_) {
747
- btn.innerText = originalText;
748
- btn.classList.remove('loading');
749
- }
750
- }
751
-
752
- async function checkHealth(path, btn) {
753
- if (btn.classList.contains('loading')) return;
754
-
755
- const originalText = btn.innerText;
756
- btn.innerText = 'V\u00e9rification...';
757
- btn.classList.add('loading');
758
-
759
- try {
760
- const response = await fetch('/proxy/check', {
761
- method: 'POST',
762
- headers: { 'Content-Type': 'application/json' },
763
- body: JSON.stringify({ path })
764
- });
391
+ /** Variante réduite : nom et bouton d'ouverture, groupes affichés comme intertitres. */
392
+ function renderMinimalDashboard(routes, proxyName) {
393
+ const groups = groupRoutes(routes);
765
394
 
766
- const data = await response.json();
395
+ const servicesHtml = routes.length > 0
396
+ ? groups.map(group => `
397
+ <section class="min-group">
398
+ ${groups.length > 1 ? `<div class="min-group-label">${escapeHtml(group.name)}</div>` : ''}
399
+ <div class="list">
400
+ ${group.routes.map(route => `
401
+ <div class="row">
402
+ <div class="row-main"><div class="row-name">${escapeHtml(route.name)}</div></div>
403
+ <a class="row-open" href="${escapeHtml(route.path)}" target="_blank" rel="noopener noreferrer">Ouvrir</a>
404
+ </div>`).join('')}
405
+ </div>
406
+ </section>`).join('')
407
+ : '<div class="empty"><strong>Aucun service</strong>Rien n\'est enregistré pour le moment.</div>';
767
408
 
768
- if (data.healthy) {
769
- btn.innerText = '\u2705 Sain';
770
- btn.classList.remove('loading');
771
- btn.classList.add('healthy');
772
- setTimeout(() => {
773
- btn.innerText = originalText;
774
- btn.classList.remove('healthy');
775
- }, 3000);
776
- } else {
777
- btn.innerText = '\u274c Hors ligne';
778
- btn.classList.remove('loading');
779
- btn.classList.add('unhealthy');
780
- setTimeout(() => {
781
- window.location.reload();
782
- }, 2000);
783
- }
784
- } catch (error) {
785
- btn.innerText = '\u26a0\ufe0f Erreur';
786
- btn.classList.remove('loading');
787
- setTimeout(() => {
788
- btn.innerText = originalText;
789
- }, 3000);
790
- }
791
- }
792
- </script>
409
+ return `<!DOCTYPE html>
410
+ <html lang="fr">
411
+ <head>
412
+ <meta charset="UTF-8">
413
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
414
+ <title>${escapeHtml(proxyName)}</title>
415
+ <style>
416
+ ${BASE_CSS}
417
+ body { padding: 48px 24px; }
418
+ .min { max-width: 560px; margin: 0 auto; }
419
+ .min-head { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; }
420
+ .min-logo {
421
+ width: 32px; height: 32px; border-radius: 9px; background: var(--accent);
422
+ display: grid; place-items: center; color: #fff;
423
+ }
424
+ .min-head h1 { font-size: 18px; font-weight: 600; }
425
+ .min-group + .min-group { margin-top: 28px; }
426
+ .min-group-label {
427
+ font-size: 10px; font-weight: 600; letter-spacing: .09em;
428
+ color: var(--text-4); margin-bottom: 8px;
429
+ }
430
+ .min .row { min-height: 52px; }
431
+ </style>
432
+ </head>
433
+ <body>
434
+ <main class="min">
435
+ <header class="min-head">
436
+ <span class="min-logo">${icon('network', 16)}</span>
437
+ <h1>${escapeHtml(proxyName)}</h1>
438
+ </header>
439
+ ${servicesHtml}
440
+ </main>
793
441
  </body>
794
442
  </html>`;
795
443
  }