@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.
@@ -1,4 +1,5 @@
1
1
  const { escapeHtml } = require('../services/proxyRouter');
2
+ const { BASE_CSS, SHELL_CSS, SHELL_JS, renderSidebar, icon } = require('./theme');
2
3
 
3
4
  /** Libellé de la section pour les applications sans groupe (APP_GROUP absent). */
4
5
  const UNGROUPED_LABEL = 'Non groupées';
@@ -26,38 +27,58 @@ function groupRoutes(routes) {
26
27
  });
27
28
  }
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
+
29
35
  /**
30
- * Enveloppe des cartes de service dans des sections repliables (`<details>`),
31
- * repliées par défaut. S'il n'existe qu'une seule section "non groupées",
32
- * les cartes sont rendues à plat (aucun groupe explicite à afficher).
33
- * @param {Array} routes
34
- * @param {(route: any) => string} renderCard
35
- * @returns {string}
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.
36
38
  */
37
- function renderGroupedServices(routes, renderCard) {
39
+ function renderGroupFilters(routes) {
38
40
  const groups = groupRoutes(routes);
39
- const onlyUngrouped = groups.length === 1 && groups[0].name === UNGROUPED_LABEL;
41
+ if (!groups.length) return '';
40
42
 
41
- if (onlyUngrouped) {
42
- return `<div class="services-list">
43
- ${groups[0].routes.map(renderCard).join('')}
44
- </div>`;
45
- }
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>`;
46
48
 
47
- return `<div class="service-groups">
48
- ${groups.map(group => `
49
- <details class="service-group"${group.name === UNGROUPED_LABEL ? ' open' : ''}>
50
- <summary class="service-group-summary">
51
- <span class="service-group-caret">&#9654;</span>
52
- <span class="service-group-name">${escapeHtml(group.name)}</span>
53
- <span class="service-group-count">${group.routes.length}</span>
54
- </summary>
55
- <div class="services-list">
56
- ${group.routes.map(renderCard).join('')}
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>
57
68
  </div>
58
- </details>
59
- `).join('')}
60
- </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>`;
61
82
  }
62
83
 
63
84
  /**
@@ -66,719 +87,239 @@ function renderGroupedServices(routes, renderCard) {
66
87
  * @param {number} availablePorts - nombre de ports disponibles
67
88
  * @param {string} proxyName
68
89
  * @param {boolean} hasAuth - true si l'authentification est activée
69
- * @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
70
91
  * @param {boolean} [minimalDashboard] - interface HTML réduite (nom + Ouvrir uniquement)
71
92
  */
72
93
  function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpCommand, minimalDashboard) {
73
- if (minimalDashboard) {
74
- const renderMinimalCard = (route) => `
75
- <div class="service-card">
76
- <div class="service-info">
77
- <span class="service-name">${escapeHtml(route.name)}</span>
78
- </div>
79
- <div class="service-actions">
80
- <a href="${escapeHtml(route.path)}" class="action-btn primary" title="Acc&eacute;der" target="_blank" rel="noopener noreferrer">Ouvrir</a>
81
- </div>
82
- </div>
83
- `;
84
-
85
- const servicesHtml = routes.length > 0
86
- ? renderGroupedServices(routes, renderMinimalCard)
87
- : `<div class="empty-state minimal-empty">
88
- <p>Aucun service</p>
89
- </div>`;
90
-
91
- return `<!DOCTYPE html>
92
- <html lang="fr">
93
- <head>
94
- <meta charset="UTF-8">
95
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
96
- <title>${escapeHtml(proxyName)}</title>
97
- <style>
98
- * { margin: 0; padding: 0; box-sizing: border-box; }
99
- body.minimal-dashboard {
100
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
101
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
102
- min-height: 100vh;
103
- color: #e4e4e7;
104
- padding: 1rem;
105
- }
106
- .minimal-dashboard .container { max-width: 560px; margin: 0 auto; }
107
- .minimal-dashboard header { text-align: center; margin-bottom: 1rem; }
108
- .minimal-dashboard h1 {
109
- font-size: 1.75rem;
110
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
111
- -webkit-background-clip: text;
112
- -webkit-text-fill-color: transparent;
113
- background-clip: text;
114
- }
115
- .service-groups { display: flex; flex-direction: column; gap: 0.5rem; }
116
- .service-group {
117
- background: rgba(255, 255, 255, 0.03);
118
- border: 1px solid rgba(255, 255, 255, 0.08);
119
- border-radius: 8px;
120
- overflow: hidden;
121
- }
122
- .service-group-summary {
123
- list-style: none;
124
- cursor: pointer;
125
- display: flex;
126
- align-items: center;
127
- gap: 0.5rem;
128
- padding: 0.55rem 0.8rem;
129
- user-select: none;
130
- }
131
- .service-group-summary::-webkit-details-marker { display: none; }
132
- .service-group-caret {
133
- font-size: 0.7rem;
134
- color: #8b5cf6;
135
- transition: transform 0.15s ease;
136
- }
137
- .service-group[open] > .service-group-summary .service-group-caret { transform: rotate(90deg); }
138
- .service-group-name { font-size: 0.9rem; font-weight: 600; color: #e4e4e7; }
139
- .service-group-count {
140
- font-size: 0.7rem;
141
- color: #a1a1aa;
142
- background: rgba(255, 255, 255, 0.08);
143
- border-radius: 999px;
144
- padding: 0.05rem 0.5rem;
145
- }
146
- .service-group .services-list { padding: 0 0.6rem 0.6rem; }
147
- .services-list { display: flex; flex-direction: column; gap: 0.5rem; }
148
- .service-card {
149
- background: rgba(255, 255, 255, 0.05);
150
- border: 1px solid rgba(255, 255, 255, 0.1);
151
- border-radius: 8px;
152
- padding: 0.6rem 0.9rem;
153
- display: flex;
154
- align-items: center;
155
- justify-content: space-between;
156
- gap: 0.75rem;
157
- transition: all 0.15s ease;
158
- }
159
- .service-card:hover {
160
- background: rgba(255, 255, 255, 0.08);
161
- border-color: rgba(139, 92, 246, 0.4);
162
- }
163
- .service-info { display: flex; align-items: center; min-width: 0; flex: 1; }
164
- .service-name {
165
- font-size: 0.95rem;
166
- font-weight: 600;
167
- color: #f4f4f5;
168
- white-space: nowrap;
169
- overflow: hidden;
170
- text-overflow: ellipsis;
171
- }
172
- .service-actions { display: flex; flex-shrink: 0; align-items: center; }
173
- .action-btn {
174
- background: rgba(255, 255, 255, 0.1);
175
- color: #e4e4e7;
176
- border: 1px solid rgba(255, 255, 255, 0.15);
177
- padding: 0.35rem 0.6rem;
178
- border-radius: 5px;
179
- cursor: pointer;
180
- font-size: 0.75rem;
181
- font-weight: 500;
182
- text-decoration: none;
183
- display: inline-flex;
184
- align-items: center;
185
- }
186
- .action-btn.primary {
187
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
188
- border: none;
189
- color: white;
190
- }
191
- .action-btn.primary:hover { opacity: 0.9; }
192
- .empty-state.minimal-empty {
193
- text-align: center;
194
- padding: 2rem 1rem;
195
- background: rgba(255, 255, 255, 0.03);
196
- border-radius: 10px;
197
- border: 1px dashed rgba(255, 255, 255, 0.1);
198
- }
199
- .empty-state.minimal-empty p { color: #a1a1aa; font-size: 0.95rem; }
200
- </style>
201
- </head>
202
- <body class="minimal-dashboard">
203
- <div class="container">
204
- <header>
205
- <h1>${escapeHtml(proxyName)}</h1>
206
- </header>
207
- ${servicesHtml}
208
- </div>
209
- </body>
210
- </html>`;
211
- }
212
-
213
- const renderServiceCard = (route) => `
214
- <div class="service-card" data-path="${escapeHtml(route.path)}">
215
- <div class="service-info">
216
- <span class="service-name">${escapeHtml(route.name)}</span>
217
- <span class="service-path">${escapeHtml(route.path)}</span>
218
- <span class="service-target">&rarr; ${escapeHtml(route.target)}:${route.port}</span>
219
- <span class="service-health">${route.healthCheck !== false ? '✅' : '⚪'}</span>
220
- <span class="service-config"></span>
221
- </div>
222
- <div class="service-actions">
223
- <a href="${escapeHtml(route.path)}" class="action-btn primary" title="Acc&eacute;der" target="_blank" rel="noopener noreferrer">Ouvrir</a>
224
- <button onclick="checkHealth('${escapeHtml(route.path)}', this)" class="action-btn" title="V&eacute;rifier la sant&eacute;">Sant&eacute;</button>
225
- <button onclick="killPort(${route.port}, this)" class="action-btn danger" title="Tuer le process">Tuer</button>
226
- </div>
227
- </div>
228
- `;
94
+ if (minimalDashboard) return renderMinimalDashboard(routes, proxyName);
229
95
 
230
96
  const servicesHtml = routes.length > 0
231
- ? renderGroupedServices(routes, renderServiceCard)
232
- : `<div class="empty-state">
233
- <h2>Aucun service enregistr&eacute;</h2>
234
- <p>Cliquez sur "+ Ajouter" ou utilisez l'API:</p>
235
- <p><span class="code">POST /proxy/register-manual</span></p>
236
- </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>`;
237
103
 
238
104
  return `<!DOCTYPE html>
239
105
  <html lang="fr">
240
106
  <head>
241
107
  <meta charset="UTF-8">
242
108
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
243
- <title>${escapeHtml(proxyName)} - Services disponibles</title>
109
+ <title>${escapeHtml(proxyName)} — Services</title>
244
110
  <style>
245
- * {
246
- margin: 0;
247
- padding: 0;
248
- box-sizing: border-box;
249
- }
250
- body {
251
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
252
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
253
- min-height: 100vh;
254
- color: #e4e4e7;
255
- padding: 1.5rem;
256
- }
257
- .container {
258
- max-width: 1100px;
259
- margin: 0 auto;
260
- }
261
- header {
262
- text-align: center;
263
- margin-bottom: 1.5rem;
264
- }
265
- h1 {
266
- font-size: 2rem;
267
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
268
- -webkit-background-clip: text;
269
- -webkit-text-fill-color: transparent;
270
- background-clip: text;
271
- margin-bottom: 0.25rem;
272
- }
273
- .subtitle {
274
- color: #a1a1aa;
275
- font-size: 0.95rem;
276
- }
277
- .stats {
278
- display: flex;
279
- justify-content: center;
280
- gap: 1.5rem;
281
- margin-top: 1rem;
282
- }
283
- .stat {
284
- background: rgba(255, 255, 255, 0.05);
285
- padding: 0.5rem 1rem;
286
- border-radius: 8px;
287
- text-align: center;
288
- }
289
- .stat-value {
290
- font-size: 1.25rem;
291
- font-weight: bold;
292
- color: #8b5cf6;
293
- }
294
- .stat-label {
295
- font-size: 0.75rem;
296
- color: #71717a;
297
- }
298
- .toolbar {
299
- display: flex;
300
- justify-content: space-between;
301
- align-items: center;
302
- margin-bottom: 0.75rem;
303
- gap: 0.5rem;
304
- }
305
- .toolbar-left {
306
- display: flex;
307
- gap: 0.5rem;
308
- align-items: center;
309
- }
310
- .toolbar-right {
311
- display: flex;
312
- gap: 0.5rem;
313
- align-items: center;
314
- }
315
- .agent-btn {
316
- background: linear-gradient(90deg, #10b981, #059669);
317
- color: white;
318
- border: none;
319
- padding: 0.5rem 1rem;
320
- border-radius: 6px;
321
- font-weight: 600;
322
- font-size: 0.8rem;
323
- cursor: pointer;
324
- transition: opacity 0.2s;
325
- display: inline-flex;
326
- align-items: center;
327
- gap: 0.4rem;
328
- white-space: nowrap;
329
- }
330
- .agent-btn:hover {
331
- opacity: 0.9;
332
- }
333
- .cleanup-btn {
334
- background: linear-gradient(90deg, #ef4444, #dc2626);
335
- color: white;
336
- border: none;
337
- padding: 0.5rem 1rem;
338
- border-radius: 6px;
339
- font-weight: 600;
340
- font-size: 0.8rem;
341
- cursor: pointer;
342
- transition: opacity 0.2s;
343
- display: inline-flex;
344
- align-items: center;
345
- gap: 0.4rem;
346
- white-space: nowrap;
347
- }
348
- .cleanup-btn:hover { opacity: 0.9; }
349
- .cleanup-btn:disabled { opacity: 0.5; cursor: not-allowed; }
350
- .add-btn {
351
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
352
- color: white;
353
- border: none;
354
- padding: 0.5rem 1rem;
355
- border-radius: 6px;
356
- font-weight: 600;
357
- font-size: 0.8rem;
358
- cursor: pointer;
359
- transition: opacity 0.2s;
360
- display: inline-flex;
361
- align-items: center;
362
- gap: 0.4rem;
363
- }
364
- .add-btn:hover {
365
- opacity: 0.9;
366
- }
367
- .modal-overlay {
368
- display: none;
369
- position: fixed;
370
- top: 0;
371
- left: 0;
372
- right: 0;
373
- bottom: 0;
374
- background: rgba(0, 0, 0, 0.6);
375
- z-index: 1000;
376
- align-items: center;
377
- justify-content: center;
378
- }
379
- .modal-overlay.active {
380
- display: flex;
381
- }
382
- .modal {
383
- background: linear-gradient(135deg, #1e1e3f 0%, #1a1a2e 100%);
384
- border: 1px solid rgba(255, 255, 255, 0.15);
385
- border-radius: 12px;
386
- padding: 1.5rem;
387
- width: 100%;
388
- max-width: 360px;
389
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
390
- }
391
- .modal-header {
392
- display: flex;
393
- justify-content: space-between;
394
- align-items: center;
395
- margin-bottom: 1rem;
396
- }
397
- .modal-header h2 {
398
- font-size: 1.1rem;
399
- color: #f4f4f5;
400
- }
401
- .modal-close {
402
- background: transparent;
403
- border: none;
404
- color: #71717a;
405
- font-size: 1.25rem;
406
- cursor: pointer;
407
- padding: 0.25rem;
408
- line-height: 1;
409
- }
410
- .modal-close:hover {
411
- color: #e4e4e7;
412
- }
413
- .modal .form-group {
414
- margin-bottom: 0.75rem;
415
- }
416
- .modal .form-group label {
417
- font-size: 0.75rem;
418
- color: #a1a1aa;
419
- margin-bottom: 0.25rem;
420
- display: block;
421
- }
422
- .modal .form-group input {
423
- background: rgba(0, 0, 0, 0.3);
424
- border: 1px solid rgba(255, 255, 255, 0.1);
425
- padding: 0.5rem 0.75rem;
426
- border-radius: 6px;
427
- color: white;
428
- font-family: inherit;
429
- font-size: 0.9rem;
430
- width: 100%;
431
- }
432
- .modal .form-group input:focus {
433
- outline: none;
434
- border-color: #8b5cf6;
435
- background: rgba(0, 0, 0, 0.4);
436
- }
437
- .modal .checkbox-group {
438
- display: flex;
439
- align-items: center;
440
- gap: 0.5rem;
441
- margin-bottom: 1rem;
442
- }
443
- .modal .checkbox-group input {
444
- width: auto;
445
- }
446
- .modal .checkbox-group label {
447
- font-size: 0.75rem;
448
- color: #a1a1aa;
449
- margin: 0;
450
- }
451
- .modal .submit-btn {
452
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
453
- color: white;
454
- border: none;
455
- padding: 0.6rem 1.25rem;
456
- border-radius: 6px;
457
- font-weight: 600;
458
- font-size: 0.85rem;
459
- cursor: pointer;
460
- transition: opacity 0.2s;
461
- width: 100%;
462
- }
463
- .modal .submit-btn:hover {
464
- opacity: 0.9;
465
- }
466
- .service-groups {
467
- display: flex;
468
- flex-direction: column;
469
- gap: 0.6rem;
470
- }
471
- .service-group {
472
- background: rgba(255, 255, 255, 0.03);
473
- border: 1px solid rgba(255, 255, 255, 0.08);
474
- border-radius: 10px;
475
- overflow: hidden;
476
- }
477
- .service-group-summary {
478
- list-style: none;
479
- cursor: pointer;
480
- display: flex;
481
- align-items: center;
482
- gap: 0.6rem;
483
- padding: 0.7rem 1rem;
484
- user-select: none;
485
- transition: background 0.15s ease;
486
- }
487
- .service-group-summary:hover { background: rgba(255, 255, 255, 0.04); }
488
- .service-group-summary::-webkit-details-marker { display: none; }
489
- .service-group-caret {
490
- font-size: 0.75rem;
491
- color: #8b5cf6;
492
- transition: transform 0.15s ease;
493
- }
494
- .service-group[open] > .service-group-summary .service-group-caret { transform: rotate(90deg); }
495
- .service-group-name {
496
- font-size: 0.95rem;
497
- font-weight: 700;
498
- color: #f4f4f5;
499
- flex: 1;
500
- }
501
- .service-group-count {
502
- font-size: 0.72rem;
503
- font-weight: 600;
504
- color: #a1a1aa;
505
- background: rgba(139, 92, 246, 0.15);
506
- border: 1px solid rgba(139, 92, 246, 0.25);
507
- border-radius: 999px;
508
- padding: 0.1rem 0.55rem;
509
- }
510
- .service-group .services-list { padding: 0 0.75rem 0.75rem; }
511
- .services-list {
512
- display: flex;
513
- flex-direction: column;
514
- gap: 0.5rem;
515
- }
516
- .service-card {
517
- background: rgba(255, 255, 255, 0.05);
518
- border: 1px solid rgba(255, 255, 255, 0.1);
519
- border-radius: 8px;
520
- padding: 0.6rem 0.9rem;
521
- display: flex;
522
- align-items: center;
523
- justify-content: space-between;
524
- gap: 0.75rem;
525
- transition: all 0.15s ease;
526
- }
527
- .service-card:hover {
528
- background: rgba(255, 255, 255, 0.08);
529
- border-color: rgba(139, 92, 246, 0.4);
530
- }
531
- .service-info {
532
- display: flex;
533
- align-items: center;
534
- gap: 0.6rem;
535
- flex-wrap: wrap;
536
- min-width: 0;
537
- flex: 1;
538
- }
539
- .service-name {
540
- font-size: 0.95rem;
541
- font-weight: 600;
542
- color: #f4f4f5;
543
- white-space: nowrap;
544
- }
545
- .service-path {
546
- font-family: 'SF Mono', 'Fira Code', monospace;
547
- font-size: 0.75rem;
548
- color: #8b5cf6;
549
- background: rgba(139, 92, 246, 0.15);
550
- padding: 0.15rem 0.5rem;
551
- border-radius: 4px;
552
- white-space: nowrap;
553
- }
554
- .service-target {
555
- font-size: 0.75rem;
556
- color: #71717a;
557
- white-space: nowrap;
558
- }
559
- .service-health {
560
- font-size: 0.7rem;
561
- }
562
- .service-config {
563
- font-size: 0.7rem;
564
- color: #10b981;
565
- background: rgba(16, 185, 129, 0.1);
566
- border: 1px solid rgba(16, 185, 129, 0.25);
567
- padding: 0.1rem 0.45rem;
568
- border-radius: 4px;
569
- white-space: nowrap;
570
- }
571
- .service-config:empty { display: none; }
572
- .service-actions {
573
- display: flex;
574
- gap: 0.4rem;
575
- flex-shrink: 0;
576
- align-items: center;
577
- }
578
- .action-btn {
579
- background: rgba(255, 255, 255, 0.1);
580
- color: #e4e4e7;
581
- border: 1px solid rgba(255, 255, 255, 0.15);
582
- padding: 0.35rem 0.6rem;
583
- border-radius: 5px;
584
- cursor: pointer;
585
- font-size: 0.75rem;
586
- font-weight: 500;
587
- transition: all 0.15s;
588
- text-decoration: none;
589
- display: inline-flex;
590
- align-items: center;
591
- }
592
- .action-btn:hover {
593
- background: rgba(255, 255, 255, 0.15);
594
- border-color: rgba(255, 255, 255, 0.25);
595
- }
596
- .action-btn.primary {
597
- background: linear-gradient(90deg, #6366f1, #8b5cf6);
598
- border: none;
599
- color: white;
600
- }
601
- .action-btn.primary:hover {
602
- opacity: 0.9;
603
- }
604
- .action-btn.loading {
605
- opacity: 0.5;
606
- cursor: not-allowed;
607
- }
608
- .action-btn.healthy {
609
- background: rgba(16, 185, 129, 0.2);
610
- border-color: rgba(16, 185, 129, 0.4);
611
- color: #10b981;
612
- }
613
- .action-btn.unhealthy {
614
- background: rgba(239, 68, 68, 0.2);
615
- border-color: rgba(239, 68, 68, 0.4);
616
- color: #ef4444;
617
- }
618
- .action-btn.danger {
619
- background: rgba(239, 68, 68, 0.15);
620
- border-color: rgba(239, 68, 68, 0.35);
621
- color: #ef4444;
622
- }
623
- .action-btn.danger:hover {
624
- background: rgba(239, 68, 68, 0.25);
625
- border-color: rgba(239, 68, 68, 0.5);
626
- }
627
- .empty-state {
628
- text-align: center;
629
- padding: 2.5rem 1.5rem;
630
- background: rgba(255, 255, 255, 0.03);
631
- border-radius: 10px;
632
- border: 1px dashed rgba(255, 255, 255, 0.1);
633
- }
634
- .empty-state h2 {
635
- color: #a1a1aa;
636
- font-size: 1rem;
637
- margin-bottom: 0.5rem;
638
- }
639
- .empty-state p {
640
- color: #71717a;
641
- font-size: 0.85rem;
642
- margin-bottom: 0.25rem;
643
- }
644
- .code {
645
- font-family: 'SF Mono', 'Fira Code', monospace;
646
- background: rgba(0, 0, 0, 0.3);
647
- padding: 0.15rem 0.4rem;
648
- border-radius: 4px;
649
- font-size: 0.8rem;
650
- }
651
- footer {
652
- margin-top: 1.5rem;
653
- text-align: center;
654
- color: #52525b;
655
- font-size: 0.75rem;
656
- }
657
- footer a {
658
- color: #8b5cf6;
659
- text-decoration: none;
660
- }
661
- footer a:hover {
662
- text-decoration: underline;
663
- }
664
- @media (max-width: 600px) {
665
- .toolbar {
666
- flex-direction: column;
667
- }
668
- .toolbar-right {
669
- width: 100%;
670
- justify-content: flex-end;
671
- }
672
- .service-card {
673
- flex-direction: column;
674
- align-items: flex-start;
675
- gap: 0.5rem;
676
- }
677
- .service-actions {
678
- width: 100%;
679
- justify-content: flex-end;
680
- }
681
- .modal {
682
- margin: 1rem;
683
- max-width: none;
684
- }
685
- }
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); }
686
115
  </style>
687
116
  </head>
688
117
  <body>
689
- <div class="container">
690
- <header>
691
- <h1>🚀 ${escapeHtml(proxyName)}</h1>
692
- <p class="subtitle">Services disponibles</p>
693
- <div class="stats">
694
- <div class="stat">
695
- <div class="stat-value">${routes.length}</div>
696
- <div class="stat-label">Services actifs</div>
697
- </div>
698
- <div class="stat">
699
- <div class="stat-value">${availablePorts}</div>
700
- <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>
701
151
  </div>
702
- </div>
703
- </header>
704
152
 
705
- <div class="toolbar">
706
- <div class="toolbar-left">
707
- <button class="agent-btn" onclick="openAgent()">&#9654; Lancer Agent</button>
708
- ${cleanUpCommand ? '<button class="cleanup-btn" id="cleanupBtn" onclick="runCleanUp()">&#128465; Nettoyer les process</button>' : ''}
153
+ ${servicesHtml}
709
154
  </div>
710
- <div class="toolbar-right">
711
- <a href="/proxy/meta-configs-ui" class="add-btn" style="text-decoration:none;">&#9881; Meta Configs</a>
712
- <button class="action-btn" id="refreshConfigsBtn" onclick="loadActiveConfigs()">&#10227; Configs</button>
713
- <button class="add-btn" onclick="openModal()">+ Ajouter</button>
714
- ${hasAuth ? '<button class="action-btn" onclick="logout()">D&eacute;connexion</button>' : ''}
715
- </div>
716
- </div>
717
-
718
- ${servicesHtml}
155
+ </main>
156
+ </div>
719
157
 
720
- <div class="modal-overlay" id="modalOverlay" onclick="closeModalOnOverlay(event)">
721
- <div class="modal">
722
- <div class="modal-header">
723
- <h2>Nouveau service</h2>
724
- <button class="modal-close" onclick="closeModal()">&times;</button>
725
- </div>
726
- <form id="registerForm">
727
- <div class="form-group">
728
- <label for="path">Chemin</label>
729
- <input type="text" id="path" name="path" placeholder="/mon-app" required>
730
- </div>
731
- <div class="form-group">
732
- <label for="name">Nom</label>
733
- <input type="text" id="name" name="name" placeholder="Mon Application">
734
- </div>
735
- <div class="form-group">
736
- <label for="target">H&ocirc;te cible</label>
737
- <input type="text" id="target" name="target" placeholder="http://localhost" value="http://localhost">
738
- </div>
739
- <div class="form-group">
740
- <label for="port">Port</label>
741
- <input type="number" id="port" name="port" placeholder="4000" required>
742
- </div>
743
- <div class="form-group">
744
- <label for="group">Groupe (optionnel)</label>
745
- <input type="text" id="group" name="group" placeholder="Laisser vide pour &laquo; Non group&eacute;es &raquo;">
746
- </div>
747
- <div class="checkbox-group">
748
- <input type="checkbox" id="healthCheck" name="healthCheck">
749
- <label for="healthCheck">Health check actif</label>
750
- </div>
751
- <button type="submit" class="submit-btn" id="submitBtn">Enregistrer</button>
752
- </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>
753
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>
754
191
  </div>
755
-
756
- <footer>
757
- <p>API: <a href="/proxy/routes">/proxy/routes</a> | <a href="/proxy/health">/proxy/health</a></p>
758
- </footer>
759
192
  </div>
760
193
 
761
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 ---
762
233
  async function loadActiveConfigs() {
763
234
  const btn = document.getElementById('refreshConfigsBtn');
764
- if (btn) { btn.disabled = true; btn.textContent = '\u2026'; }
235
+ if (btn) { btn.disabled = true; btn.classList.add('spinning'); }
765
236
  try {
766
237
  const res = await fetch('/proxy/mcp-configurations');
767
238
  const data = await res.json();
768
239
  (data.results || []).forEach(mcp => {
769
- const card = document.querySelector('.service-card[data-path="' + mcp.path + '"]');
770
- if (!card) return;
771
- const span = card.querySelector('.service-config');
772
- if (!mcp.configurations) { span.textContent = ''; return; }
773
- const active = mcp.configurations.find(c => c.active);
774
- 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
+ }
775
251
  });
776
252
  } catch (_) {} finally {
777
- if (btn) { btn.disabled = false; btn.textContent = '\u21ba Configs'; }
253
+ if (btn) { btn.disabled = false; btn.classList.remove('spinning'); }
778
254
  }
779
255
  }
780
256
  loadActiveConfigs();
781
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 ---
782
323
  function openModal() {
783
324
  document.getElementById('modalOverlay').classList.add('active');
784
325
  document.getElementById('path').focus();
@@ -791,19 +332,22 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
791
332
  }
792
333
 
793
334
  function closeModalOnOverlay(event) {
794
- if (event.target === document.getElementById('modalOverlay')) {
795
- closeModal();
796
- }
335
+ if (event.target === document.getElementById('modalOverlay')) closeModal();
797
336
  }
798
337
 
799
338
  document.addEventListener('keydown', (e) => {
800
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
+ }
801
345
  });
802
346
 
803
347
  document.getElementById('registerForm').addEventListener('submit', async (e) => {
804
348
  e.preventDefault();
805
349
  const btn = document.getElementById('submitBtn');
806
- const originalText = btn.innerText;
350
+ const originalText = btn.textContent;
807
351
 
808
352
  const formData = {
809
353
  path: document.getElementById('path').value,
@@ -814,7 +358,7 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
814
358
  healthCheck: document.getElementById('healthCheck').checked
815
359
  };
816
360
 
817
- btn.innerText = 'Enregistrement...';
361
+ btn.textContent = 'Enregistrement…';
818
362
  btn.disabled = true;
819
363
 
820
364
  try {
@@ -823,115 +367,77 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
823
367
  headers: { 'Content-Type': 'application/json' },
824
368
  body: JSON.stringify(formData)
825
369
  });
826
-
827
370
  const data = await response.json();
828
371
 
829
372
  if (response.ok) {
830
- btn.innerText = '\u2705 Succ\u00e8s\u00a0!';
831
- setTimeout(() => {
832
- window.location.reload();
833
- }, 1000);
373
+ btn.textContent = 'Enregistré';
374
+ setTimeout(() => window.location.reload(), 800);
834
375
  } else {
835
376
  alert('Erreur: ' + data.error);
836
- btn.innerText = originalText;
377
+ btn.textContent = originalText;
837
378
  btn.disabled = false;
838
379
  }
839
380
  } catch (error) {
840
- alert('Erreur r\u00e9seau');
841
- btn.innerText = originalText;
381
+ alert('Erreur réseau');
382
+ btn.textContent = originalText;
842
383
  btn.disabled = false;
843
384
  }
844
385
  });
386
+ </script>
387
+ </body>
388
+ </html>`;
389
+ }
845
390
 
846
- function openAgent() {
847
- window.open('/proxy/terminal', '_blank');
848
- }
849
-
850
- async function runCleanUp() {
851
- if (!confirm('Nettoyer tous les processus ?')) return;
852
- const btn = document.getElementById('cleanupBtn');
853
- const originalText = btn.innerHTML;
854
- btn.disabled = true;
855
- btn.innerHTML = '&#8987; Nettoyage\u2026';
856
- try {
857
- await fetch('/proxy/cleanup', { method: 'POST' });
858
- btn.innerHTML = '&#10003; Nettoy\u00e9';
859
- setTimeout(() => { btn.innerHTML = originalText; btn.disabled = false; }, 3000);
860
- } catch (_) {
861
- btn.innerHTML = originalText;
862
- btn.disabled = false;
863
- }
864
- }
865
-
866
- function logout() {
867
- const form = document.createElement('form');
868
- form.method = 'POST';
869
- form.action = '/proxy/logout';
870
- document.body.appendChild(form);
871
- form.submit();
872
- }
873
-
874
- async function killPort(port, btn) {
875
- if (btn.classList.contains('loading')) return;
876
- if (!confirm('Tuer le process sur le port ' + port + ' ?')) return;
877
- const originalText = btn.innerText;
878
- btn.innerText = '\u2026';
879
- btn.classList.add('loading');
880
- try {
881
- await fetch('/proxy/kill-port', {
882
- method: 'POST',
883
- headers: { 'Content-Type': 'application/json' },
884
- body: JSON.stringify({ port })
885
- });
886
- btn.innerText = '\u2713 Tu\u00e9';
887
- setTimeout(() => { btn.innerText = originalText; btn.classList.remove('loading'); }, 2000);
888
- } catch (_) {
889
- btn.innerText = originalText;
890
- btn.classList.remove('loading');
891
- }
892
- }
893
-
894
- async function checkHealth(path, btn) {
895
- if (btn.classList.contains('loading')) return;
896
-
897
- const originalText = btn.innerText;
898
- btn.innerText = 'V\u00e9rification...';
899
- btn.classList.add('loading');
900
-
901
- try {
902
- const response = await fetch('/proxy/check', {
903
- method: 'POST',
904
- headers: { 'Content-Type': 'application/json' },
905
- body: JSON.stringify({ path })
906
- });
391
+ /** Variante réduite : nom et bouton d'ouverture, groupes affichés comme intertitres. */
392
+ function renderMinimalDashboard(routes, proxyName) {
393
+ const groups = groupRoutes(routes);
907
394
 
908
- 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>';
909
408
 
910
- if (data.healthy) {
911
- btn.innerText = '\u2705 Sain';
912
- btn.classList.remove('loading');
913
- btn.classList.add('healthy');
914
- setTimeout(() => {
915
- btn.innerText = originalText;
916
- btn.classList.remove('healthy');
917
- }, 3000);
918
- } else {
919
- btn.innerText = '\u274c Hors ligne';
920
- btn.classList.remove('loading');
921
- btn.classList.add('unhealthy');
922
- setTimeout(() => {
923
- window.location.reload();
924
- }, 2000);
925
- }
926
- } catch (error) {
927
- btn.innerText = '\u26a0\ufe0f Erreur';
928
- btn.classList.remove('loading');
929
- setTimeout(() => {
930
- btn.innerText = originalText;
931
- }, 3000);
932
- }
933
- }
934
- </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>
935
441
  </body>
936
442
  </html>`;
937
443
  }