@imenam/self-register-proxy 1.0.40 → 1.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/routes/dashboard.js +1 -0
- package/routes/registration.js +19 -6
- package/services/proxyRouter.js +99 -99
- package/services/registry.js +1 -1
- package/views/dashboard.js +152 -10
package/README.md
CHANGED
|
@@ -82,7 +82,8 @@ const response = await fetch('http://localhost:3000/proxy/register', {
|
|
|
82
82
|
headers: { 'Content-Type': 'application/json' },
|
|
83
83
|
body: JSON.stringify({
|
|
84
84
|
path: '/myapp', // Chemin d'accès via le proxy
|
|
85
|
-
name: 'My App'
|
|
85
|
+
name: 'My App', // Nom de l'application (optionnel)
|
|
86
|
+
group: 'Google' // Section repliable du dashboard (optionnel)
|
|
86
87
|
})
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -163,6 +164,7 @@ Enregistre une nouvelle route et attribue un port.
|
|
|
163
164
|
| `path` | string | ✅ | Chemin URL pour accéder à l'app |
|
|
164
165
|
| `name` | string | ❌ | Nom descriptif |
|
|
165
166
|
| `port` | number | ❌ | Port spécifique (sinon auto-attribué) |
|
|
167
|
+
| `group` | string | ❌ | Section repliable du dashboard (repliée par défaut). Vide/absent ⇒ section « Non groupées » |
|
|
166
168
|
|
|
167
169
|
**Response (201):**
|
|
168
170
|
```json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/self-register-proxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"description": "Dynamic reverse proxy with self-registration API - applications can register themselves and receive an automatically assigned port",
|
|
5
5
|
"main": "proxy-client.js",
|
|
6
6
|
"bin": {
|
package/routes/dashboard.js
CHANGED
|
@@ -77,6 +77,7 @@ router.get('/', requireAuth, (req, res) => {
|
|
|
77
77
|
target: value.target || 'http://localhost',
|
|
78
78
|
name: value.name,
|
|
79
79
|
healthCheck: value.healthCheck,
|
|
80
|
+
group: value.group || null,
|
|
80
81
|
registeredAt: value.registeredAt,
|
|
81
82
|
fullTarget: `${value.target || 'http://localhost'}:${value.port}`
|
|
82
83
|
});
|
package/routes/registration.js
CHANGED
|
@@ -10,8 +10,18 @@ const router = Router();
|
|
|
10
10
|
/**
|
|
11
11
|
* Logique commune d'enregistrement (auto et manuel)
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Normalise un nom de groupe : trim, et chaîne vide => undefined (non groupé).
|
|
15
|
+
*/
|
|
16
|
+
function normalizeGroup(group) {
|
|
17
|
+
if (typeof group !== 'string') return undefined;
|
|
18
|
+
const trimmed = group.trim();
|
|
19
|
+
return trimmed === '' ? undefined : trimmed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function registerRouteInternal({ path, name, port, target, healthCheck, group, autoAssignPort }) {
|
|
14
23
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
24
|
+
const normalizedGroup = normalizeGroup(group);
|
|
15
25
|
|
|
16
26
|
let finalTarget = target || 'http://localhost';
|
|
17
27
|
if (!finalTarget.startsWith('http://') && !finalTarget.startsWith('https://')) {
|
|
@@ -63,10 +73,11 @@ async function registerRouteInternal({ path, name, port, target, healthCheck, au
|
|
|
63
73
|
target: finalTarget,
|
|
64
74
|
name: name || normalizedPath,
|
|
65
75
|
healthCheck: healthCheck !== undefined ? healthCheck === true || healthCheck === 'true' : true,
|
|
76
|
+
group: normalizedGroup,
|
|
66
77
|
registeredAt: new Date().toISOString()
|
|
67
78
|
});
|
|
68
79
|
|
|
69
|
-
console.log(`[REGISTER] ${normalizedPath} -> ${finalTarget}:${assignedPort} (${name || 'unnamed'}) [Health: ${healthCheck !== undefined ? healthCheck : 'default'}]`);
|
|
80
|
+
console.log(`[REGISTER] ${normalizedPath} -> ${finalTarget}:${assignedPort} (${name || 'unnamed'}) [Health: ${healthCheck !== undefined ? healthCheck : 'default'}]${normalizedGroup ? ` [Group: ${normalizedGroup}]` : ''}`);
|
|
70
81
|
|
|
71
82
|
return {
|
|
72
83
|
success: true,
|
|
@@ -75,6 +86,7 @@ async function registerRouteInternal({ path, name, port, target, healthCheck, au
|
|
|
75
86
|
target: finalTarget,
|
|
76
87
|
name: name || normalizedPath,
|
|
77
88
|
healthCheck: healthCheck !== undefined ? healthCheck === true || healthCheck === 'true' : true,
|
|
89
|
+
group: normalizedGroup,
|
|
78
90
|
message: `Route registered. Target: ${finalTarget}:${assignedPort}`
|
|
79
91
|
};
|
|
80
92
|
|
|
@@ -95,12 +107,12 @@ async function registerRouteInternal({ path, name, port, target, healthCheck, au
|
|
|
95
107
|
* Enregistrement automatique (rétro-compatibilité)
|
|
96
108
|
*/
|
|
97
109
|
router.post('/proxy/register', requireLocalOnly, async (req, res) => {
|
|
98
|
-
const { path, name, target, healthCheck } = req.body;
|
|
110
|
+
const { path, name, target, healthCheck, group } = req.body;
|
|
99
111
|
|
|
100
112
|
if (!path) return res.status(400).json({ success: false, error: 'Path is required' });
|
|
101
113
|
|
|
102
114
|
try {
|
|
103
|
-
const result = await registerRouteInternal({ path, name, target, healthCheck, autoAssignPort: true });
|
|
115
|
+
const result = await registerRouteInternal({ path, name, target, healthCheck, group, autoAssignPort: true });
|
|
104
116
|
res.json(result);
|
|
105
117
|
} catch (error) {
|
|
106
118
|
console.error('[REGISTER AUTO ERROR]', error.message);
|
|
@@ -114,13 +126,13 @@ router.post('/proxy/register', requireLocalOnly, async (req, res) => {
|
|
|
114
126
|
* Enregistrement manuel via l'interface graphique (port obligatoire)
|
|
115
127
|
*/
|
|
116
128
|
router.post('/proxy/register-manual', requireAuth, async (req, res) => {
|
|
117
|
-
const { path, name, port, target, healthCheck } = req.body;
|
|
129
|
+
const { path, name, port, target, healthCheck, group } = req.body;
|
|
118
130
|
|
|
119
131
|
if (!path) return res.status(400).json({ success: false, error: 'Path is required' });
|
|
120
132
|
if (!port) return res.status(400).json({ success: false, error: 'Port is required for manual registration' });
|
|
121
133
|
|
|
122
134
|
try {
|
|
123
|
-
const result = await registerRouteInternal({ path, name, port, target, healthCheck, autoAssignPort: false });
|
|
135
|
+
const result = await registerRouteInternal({ path, name, port, target, healthCheck, group, autoAssignPort: false });
|
|
124
136
|
res.json(result);
|
|
125
137
|
} catch (error) {
|
|
126
138
|
console.error('[REGISTER MANUAL ERROR]', error.message);
|
|
@@ -178,6 +190,7 @@ router.get('/proxy/routes', requireAuth, (req, res) => {
|
|
|
178
190
|
target: value.target || 'http://localhost',
|
|
179
191
|
name: value.name,
|
|
180
192
|
healthCheck: value.healthCheck,
|
|
193
|
+
group: value.group || null,
|
|
181
194
|
registeredAt: value.registeredAt,
|
|
182
195
|
fullTarget: `${value.target || 'http://localhost'}:${value.port}`
|
|
183
196
|
});
|
package/services/proxyRouter.js
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
2
|
-
const { registry } = require('./registry');
|
|
3
|
-
const { findRouteForPath } = require('./portManager');
|
|
4
|
-
const { unregisterServer } = require('./healthCheck');
|
|
5
|
-
const { requireAuth } = require('./auth');
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Échappe les caractères HTML pour éviter les injections XSS
|
|
9
|
-
*/
|
|
10
|
-
function escapeHtml(text) {
|
|
11
|
-
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
12
|
-
return String(text).replace(/[&<>"']/g, char => map[char]);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Middleware de proxy persistant — créé une seule fois pour éviter les fuites mémoire
|
|
17
|
-
*/
|
|
18
|
-
const persistentProxyMiddleware = createProxyMiddleware({
|
|
19
|
-
target: 'http://localhost',
|
|
20
|
-
router: (req) => req.proxyTarget,
|
|
21
|
-
changeOrigin: true,
|
|
22
|
-
pathRewrite: (path, req) => {
|
|
23
|
-
const routePath = req.proxyRoutePath;
|
|
24
|
-
if (routePath && path.startsWith(routePath)) {
|
|
25
|
-
return path.slice(routePath.length) || '/';
|
|
26
|
-
}
|
|
27
|
-
return path;
|
|
28
|
-
},
|
|
29
|
-
on: {
|
|
30
|
-
proxyReq: (proxyReq, req) => {
|
|
31
|
-
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
32
|
-
const bodyData = JSON.stringify(req.body);
|
|
33
|
-
proxyReq.setHeader('Content-Type', 'application/json');
|
|
34
|
-
proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
|
|
35
|
-
proxyReq.write(bodyData);
|
|
36
|
-
}
|
|
37
|
-
console.log(`[PROXY] ${req.method} ${req.path} -> ${req.proxyTarget}`);
|
|
38
|
-
},
|
|
39
|
-
error: (err, req, res) => {
|
|
40
|
-
if (res.headersSent) return;
|
|
41
|
-
console.error(`[PROXY ERROR] ${req.path}:`, err.message);
|
|
42
|
-
|
|
43
|
-
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
|
|
44
|
-
const route = findRouteForPath(req.path);
|
|
45
|
-
if (route) {
|
|
46
|
-
const routePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
47
|
-
if (routePath) {
|
|
48
|
-
unregisterServer(routePath, `connection error (${err.code})`).catch(() => {});
|
|
49
|
-
console.log(`[PROXY] Auto-unregistered ${routePath} due to connection error`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
res.status(502).json({ error: 'Proxy error' });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Middleware principal de routage vers le proxy
|
|
61
|
-
* Inclut le Smart Routing (Referer Fallback)
|
|
62
|
-
*/
|
|
63
|
-
function routingMiddleware(req, res, next) {
|
|
64
|
-
if (req.path.startsWith('/proxy/')) return next();
|
|
65
|
-
|
|
66
|
-
requireAuth(req, res, () => {
|
|
67
|
-
let route = findRouteForPath(req.path);
|
|
68
|
-
|
|
69
|
-
if (!route && req.get('Referer')) {
|
|
70
|
-
try {
|
|
71
|
-
const refererUrl = new URL(req.get('Referer'));
|
|
72
|
-
if (refererUrl.host === req.get('host')) {
|
|
73
|
-
const refererPath = refererUrl.pathname;
|
|
74
|
-
route = findRouteForPath(refererPath);
|
|
75
|
-
if (route) {
|
|
76
|
-
console.log(`[SMART ROUTE] Inferred ${route.name} from referer: ${refererPath}`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
} catch {
|
|
80
|
-
// URL de referer invalide, on ignore
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!route) {
|
|
85
|
-
return res.status(404).json({
|
|
86
|
-
error: 'No route registered for this path',
|
|
87
|
-
path: req.path,
|
|
88
|
-
hint: 'Register a route with POST /proxy/register or use the UI'
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const target = route.target || 'http://localhost';
|
|
93
|
-
req.proxyTarget = `${target}:${route.port}`;
|
|
94
|
-
req.proxyRoutePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
95
|
-
persistentProxyMiddleware(req, res, next);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
module.exports = { persistentProxyMiddleware, routingMiddleware, escapeHtml };
|
|
1
|
+
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
2
|
+
const { registry } = require('./registry');
|
|
3
|
+
const { findRouteForPath } = require('./portManager');
|
|
4
|
+
const { unregisterServer } = require('./healthCheck');
|
|
5
|
+
const { requireAuth } = require('./auth');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Échappe les caractères HTML pour éviter les injections XSS
|
|
9
|
+
*/
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
12
|
+
return String(text).replace(/[&<>"']/g, char => map[char]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Middleware de proxy persistant — créé une seule fois pour éviter les fuites mémoire
|
|
17
|
+
*/
|
|
18
|
+
const persistentProxyMiddleware = createProxyMiddleware({
|
|
19
|
+
target: 'http://localhost',
|
|
20
|
+
router: (req) => req.proxyTarget,
|
|
21
|
+
changeOrigin: true,
|
|
22
|
+
pathRewrite: (path, req) => {
|
|
23
|
+
const routePath = req.proxyRoutePath;
|
|
24
|
+
if (routePath && path.startsWith(routePath)) {
|
|
25
|
+
return path.slice(routePath.length) || '/';
|
|
26
|
+
}
|
|
27
|
+
return path;
|
|
28
|
+
},
|
|
29
|
+
on: {
|
|
30
|
+
proxyReq: (proxyReq, req) => {
|
|
31
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
32
|
+
const bodyData = JSON.stringify(req.body);
|
|
33
|
+
proxyReq.setHeader('Content-Type', 'application/json');
|
|
34
|
+
proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
|
|
35
|
+
proxyReq.write(bodyData);
|
|
36
|
+
}
|
|
37
|
+
console.log(`[PROXY] ${req.method} ${req.path} -> ${req.proxyTarget}`);
|
|
38
|
+
},
|
|
39
|
+
error: (err, req, res) => {
|
|
40
|
+
if (res.headersSent) return;
|
|
41
|
+
console.error(`[PROXY ERROR] ${req.path}:`, err.message);
|
|
42
|
+
|
|
43
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
|
|
44
|
+
const route = findRouteForPath(req.path);
|
|
45
|
+
if (route) {
|
|
46
|
+
const routePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
47
|
+
if (routePath) {
|
|
48
|
+
unregisterServer(routePath, `connection error (${err.code})`).catch(() => {});
|
|
49
|
+
console.log(`[PROXY] Auto-unregistered ${routePath} due to connection error`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
res.status(502).json({ error: 'Proxy error' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Middleware principal de routage vers le proxy
|
|
61
|
+
* Inclut le Smart Routing (Referer Fallback)
|
|
62
|
+
*/
|
|
63
|
+
function routingMiddleware(req, res, next) {
|
|
64
|
+
if (req.path.startsWith('/proxy/')) return next();
|
|
65
|
+
|
|
66
|
+
requireAuth(req, res, () => {
|
|
67
|
+
let route = findRouteForPath(req.path);
|
|
68
|
+
|
|
69
|
+
if (!route && req.get('Referer')) {
|
|
70
|
+
try {
|
|
71
|
+
const refererUrl = new URL(req.get('Referer'));
|
|
72
|
+
if (refererUrl.host === req.get('host')) {
|
|
73
|
+
const refererPath = refererUrl.pathname;
|
|
74
|
+
route = findRouteForPath(refererPath);
|
|
75
|
+
if (route) {
|
|
76
|
+
console.log(`[SMART ROUTE] Inferred ${route.name} from referer: ${refererPath}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// URL de referer invalide, on ignore
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!route) {
|
|
85
|
+
return res.status(404).json({
|
|
86
|
+
error: 'No route registered for this path',
|
|
87
|
+
path: req.path,
|
|
88
|
+
hint: 'Register a route with POST /proxy/register or use the UI'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const target = route.target || 'http://localhost';
|
|
93
|
+
req.proxyTarget = `${target}:${route.port}`;
|
|
94
|
+
req.proxyRoutePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
95
|
+
persistentProxyMiddleware(req, res, next);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { persistentProxyMiddleware, routingMiddleware, escapeHtml };
|
package/services/registry.js
CHANGED
|
@@ -31,7 +31,7 @@ class Mutex {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const registry = {
|
|
34
|
-
routes: new Map(), // path -> { port, target, name, healthCheck, registeredAt }
|
|
34
|
+
routes: new Map(), // path -> { port, target, name, healthCheck, group, registeredAt }
|
|
35
35
|
usedPorts: new Set(), // Ports déjà attribués
|
|
36
36
|
nextPort: INTERNAL_PORT_START
|
|
37
37
|
};
|
package/views/dashboard.js
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
const { escapeHtml } = require('../services/proxyRouter');
|
|
2
2
|
|
|
3
|
+
/** Libellé de la section pour les applications sans groupe (APP_GROUP absent). */
|
|
4
|
+
const UNGROUPED_LABEL = 'Non groupées';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Répartit les routes par groupe.
|
|
8
|
+
* @param {Array} routes
|
|
9
|
+
* @returns {Array<{ name: string, routes: Array }>} groupes ordonnés :
|
|
10
|
+
* groupes nommés triés alphabétiquement, section "non groupées" en dernier.
|
|
11
|
+
*/
|
|
12
|
+
function groupRoutes(routes) {
|
|
13
|
+
const groups = new Map();
|
|
14
|
+
for (const route of routes) {
|
|
15
|
+
const key = (route.group && String(route.group).trim()) || UNGROUPED_LABEL;
|
|
16
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
17
|
+
groups.get(key).push(route);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [...groups.entries()]
|
|
21
|
+
.map(([name, groupRoutes]) => ({ name, routes: groupRoutes }))
|
|
22
|
+
.sort((a, b) => {
|
|
23
|
+
if (a.name === UNGROUPED_LABEL) return 1;
|
|
24
|
+
if (b.name === UNGROUPED_LABEL) return -1;
|
|
25
|
+
return a.name.localeCompare(b.name);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
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
|
+
*/
|
|
37
|
+
function renderGroupedServices(routes, renderCard) {
|
|
38
|
+
const groups = groupRoutes(routes);
|
|
39
|
+
const onlyUngrouped = groups.length === 1 && groups[0].name === UNGROUPED_LABEL;
|
|
40
|
+
|
|
41
|
+
if (onlyUngrouped) {
|
|
42
|
+
return `<div class="services-list">
|
|
43
|
+
${groups[0].routes.map(renderCard).join('')}
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
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">▶</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('')}
|
|
57
|
+
</div>
|
|
58
|
+
</details>
|
|
59
|
+
`).join('')}
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
3
63
|
/**
|
|
4
64
|
* Page dashboard principale
|
|
5
65
|
* @param {Array} routes - liste des routes enregistrées
|
|
@@ -11,9 +71,7 @@ const { escapeHtml } = require('../services/proxyRouter');
|
|
|
11
71
|
*/
|
|
12
72
|
function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpCommand, minimalDashboard) {
|
|
13
73
|
if (minimalDashboard) {
|
|
14
|
-
const
|
|
15
|
-
? `<div class="services-list">
|
|
16
|
-
${routes.map(route => `
|
|
74
|
+
const renderMinimalCard = (route) => `
|
|
17
75
|
<div class="service-card">
|
|
18
76
|
<div class="service-info">
|
|
19
77
|
<span class="service-name">${escapeHtml(route.name)}</span>
|
|
@@ -22,8 +80,10 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
22
80
|
<a href="${escapeHtml(route.path)}" class="action-btn primary" title="Accéder" target="_blank" rel="noopener noreferrer">Ouvrir</a>
|
|
23
81
|
</div>
|
|
24
82
|
</div>
|
|
25
|
-
|
|
26
|
-
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const servicesHtml = routes.length > 0
|
|
86
|
+
? renderGroupedServices(routes, renderMinimalCard)
|
|
27
87
|
: `<div class="empty-state minimal-empty">
|
|
28
88
|
<p>Aucun service</p>
|
|
29
89
|
</div>`;
|
|
@@ -52,6 +112,38 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
52
112
|
-webkit-text-fill-color: transparent;
|
|
53
113
|
background-clip: text;
|
|
54
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; }
|
|
55
147
|
.services-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
56
148
|
.service-card {
|
|
57
149
|
background: rgba(255, 255, 255, 0.05);
|
|
@@ -118,9 +210,7 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
118
210
|
</html>`;
|
|
119
211
|
}
|
|
120
212
|
|
|
121
|
-
const
|
|
122
|
-
? `<div class="services-list">
|
|
123
|
-
${routes.map(route => `
|
|
213
|
+
const renderServiceCard = (route) => `
|
|
124
214
|
<div class="service-card" data-path="${escapeHtml(route.path)}">
|
|
125
215
|
<div class="service-info">
|
|
126
216
|
<span class="service-name">${escapeHtml(route.name)}</span>
|
|
@@ -135,8 +225,10 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
135
225
|
<button onclick="killPort(${route.port}, this)" class="action-btn danger" title="Tuer le process">Tuer</button>
|
|
136
226
|
</div>
|
|
137
227
|
</div>
|
|
138
|
-
|
|
139
|
-
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
const servicesHtml = routes.length > 0
|
|
231
|
+
? renderGroupedServices(routes, renderServiceCard)
|
|
140
232
|
: `<div class="empty-state">
|
|
141
233
|
<h2>Aucun service enregistré</h2>
|
|
142
234
|
<p>Cliquez sur "+ Ajouter" ou utilisez l'API:</p>
|
|
@@ -371,6 +463,51 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
371
463
|
.modal .submit-btn:hover {
|
|
372
464
|
opacity: 0.9;
|
|
373
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; }
|
|
374
511
|
.services-list {
|
|
375
512
|
display: flex;
|
|
376
513
|
flex-direction: column;
|
|
@@ -603,6 +740,10 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
603
740
|
<label for="port">Port</label>
|
|
604
741
|
<input type="number" id="port" name="port" placeholder="4000" required>
|
|
605
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 « Non groupées »">
|
|
746
|
+
</div>
|
|
606
747
|
<div class="checkbox-group">
|
|
607
748
|
<input type="checkbox" id="healthCheck" name="healthCheck">
|
|
608
749
|
<label for="healthCheck">Health check actif</label>
|
|
@@ -669,6 +810,7 @@ function renderDashboard(routes, availablePorts, proxyName, hasAuth, cleanUpComm
|
|
|
669
810
|
name: document.getElementById('name').value,
|
|
670
811
|
target: document.getElementById('target').value,
|
|
671
812
|
port: parseInt(document.getElementById('port').value),
|
|
813
|
+
group: document.getElementById('group').value,
|
|
672
814
|
healthCheck: document.getElementById('healthCheck').checked
|
|
673
815
|
};
|
|
674
816
|
|