@imenam/self-register-proxy 1.0.42 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/views/terminal.js CHANGED
@@ -1,30 +1,34 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { escapeHtml } = require('../services/proxyRouter');
4
-
5
- const templatePath = path.join(__dirname, 'terminal.html');
6
- let templateCache = null;
7
-
8
- function loadTemplate() {
9
- if (!templateCache) {
10
- templateCache = fs.readFileSync(templatePath, 'utf-8');
11
- }
12
- return templateCache;
13
- }
14
-
15
- /**
16
- * Page du terminal en ligne (xterm.js + WebSocket)
17
- * @param {string} proxyName
18
- * @param {string} terminalCommand
19
- */
20
- function renderTerminal(proxyName, terminalCommand) {
21
- const commandHtml = terminalCommand
22
- ? '<span class="terminal-command">' + escapeHtml(terminalCommand) + '</span>'
23
- : '';
24
-
25
- return loadTemplate()
26
- .replace('{{PROXY_NAME}}', escapeHtml(proxyName))
27
- .replace('{{TERMINAL_COMMAND}}', commandHtml);
28
- }
29
-
30
- module.exports = { renderTerminal };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { escapeHtml } = require('../services/proxyRouter');
4
+ const { BASE_CSS } = require('./theme');
5
+
6
+ const templatePath = path.join(__dirname, 'terminal.html');
7
+ let templateCache = null;
8
+
9
+ function loadTemplate() {
10
+ if (!templateCache) {
11
+ templateCache = fs.readFileSync(templatePath, 'utf-8');
12
+ }
13
+ return templateCache;
14
+ }
15
+
16
+ /**
17
+ * Page du terminal en ligne (xterm.js + WebSocket)
18
+ * @param {string} proxyName
19
+ * @param {string} terminalCommand
20
+ */
21
+ function renderTerminal(proxyName, terminalCommand) {
22
+ const commandHtml = terminalCommand
23
+ ? '<span class="terminal-command">' + escapeHtml(terminalCommand) + '</span>'
24
+ : '';
25
+
26
+ // Remplacements par fonction : le CSS et la commande peuvent contenir des `$`,
27
+ // qui seraient sinon interprétés comme des motifs de remplacement.
28
+ return loadTemplate()
29
+ .replace('{{BASE_CSS}}', () => BASE_CSS)
30
+ .replace('{{PROXY_NAME}}', () => escapeHtml(proxyName))
31
+ .replace('{{TERMINAL_COMMAND}}', () => commandHtml);
32
+ }
33
+
34
+ module.exports = { renderTerminal };
package/views/theme.js ADDED
@@ -0,0 +1,431 @@
1
+ const { escapeHtml } = require('../services/proxyRouter');
2
+
3
+ /**
4
+ * Socle visuel partagé par toutes les pages du proxy.
5
+ * Un seul jeu de tokens, un seul accent, une seule échelle typographique.
6
+ */
7
+
8
+ /** Icônes Lucide inlinées — pas de CDN, colorées par `currentColor`. */
9
+ const ICONS = {
10
+ network: '<rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/>',
11
+ layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
12
+ terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
13
+ sliders: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
14
+ trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
15
+ logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
16
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
17
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
18
+ more: '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
19
+ activity: '<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>',
20
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
21
+ circleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
22
+ chevronDown: '<path d="m6 9 6 6 6-6"/>',
23
+ alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
24
+ circleAlert: '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>',
25
+ lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
26
+ arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
27
+ arrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
28
+ check: '<path d="M20 6 9 17l-5-5"/>',
29
+ x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
30
+ refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
31
+ externalLink: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
32
+ pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>'
33
+ };
34
+
35
+ /**
36
+ * Rend une icône SVG inline.
37
+ * @param {string} name - clé de ICONS
38
+ * @param {number} [size=16]
39
+ */
40
+ function icon(name, size = 16) {
41
+ const body = ICONS[name];
42
+ if (!body) return '';
43
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
44
+ }
45
+
46
+ /** Tokens + composants communs. Injecté dans le <style> de chaque page. */
47
+ const BASE_CSS = `
48
+ :root {
49
+ --bg: #0F0F12;
50
+ --sidebar: #131317;
51
+ --surface: #17171C;
52
+ --surface-2: #1C1C22;
53
+ --border: #26262E;
54
+ --border-subtle: #1F1F25;
55
+ --text: #F4F4F5;
56
+ --text-2: #A1A1AA;
57
+ --text-3: #71717A;
58
+ --text-4: #52525B;
59
+ --accent: #6366F1;
60
+ --accent-soft: #818CF8;
61
+ --green: #22C55E;
62
+ --amber: #F59E0B;
63
+ --red: #F87171;
64
+ --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
65
+ --mono: ui-monospace, SFMono-Regular, Consolas, Menlo, monospace;
66
+ }
67
+ * { margin: 0; padding: 0; box-sizing: border-box; }
68
+ body {
69
+ font-family: var(--sans);
70
+ background: var(--bg);
71
+ color: var(--text);
72
+ min-height: 100vh;
73
+ -webkit-font-smoothing: antialiased;
74
+ }
75
+ a { color: inherit; text-decoration: none; }
76
+ ::selection { background: rgba(99,102,241,.35); }
77
+
78
+ /* Boutons */
79
+ .btn {
80
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
81
+ height: 34px; padding: 0 14px; border-radius: 8px;
82
+ font-family: inherit; font-size: 13px; cursor: pointer;
83
+ border: 1px solid transparent; white-space: nowrap; transition: background .15s, border-color .15s;
84
+ }
85
+ .btn-primary { background: var(--accent); color: #fff; font-weight: 600; }
86
+ .btn-primary:hover { background: #5457E5; }
87
+ .btn-ghost { background: var(--surface); border-color: var(--border); color: var(--text); }
88
+ .btn-ghost:hover { border-color: #3A3A45; }
89
+ .btn-danger { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.3); color: var(--red); }
90
+ .btn-danger:hover { background: rgba(239,68,68,.2); }
91
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
92
+ .btn-link {
93
+ background: none; border: 0; font-family: inherit; font-size: 13px;
94
+ color: var(--text-3); cursor: pointer; padding: 0;
95
+ }
96
+ .btn-link:hover { color: var(--text-2); }
97
+ .btn-link.danger { color: var(--red); }
98
+
99
+ /* Champs */
100
+ .input, select, textarea {
101
+ width: 100%; background: var(--surface); border: 1px solid var(--border);
102
+ border-radius: 8px; color: var(--text); font-family: inherit; font-size: 13px;
103
+ padding: 0 12px; height: 36px;
104
+ }
105
+ textarea { padding: 10px 12px; height: auto; resize: vertical; line-height: 1.5; }
106
+ .input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
107
+ .input::placeholder, textarea::placeholder { color: var(--text-3); }
108
+ .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
109
+ .field label { font-size: 12px; color: var(--text-2); }
110
+ .field-inline { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
111
+ .field-inline input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
112
+ .field-inline label { font-size: 13px; color: var(--text-2); }
113
+ .search { position: relative; }
114
+ .search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; }
115
+ .search .input { padding-left: 34px; }
116
+
117
+ /* En-tête de page */
118
+ .page-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 26px; }
119
+ .page-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
120
+ .page-sub { font-size: 13px; color: var(--text-3); margin-top: 5px; }
121
+ .page-actions { margin-left: auto; display: flex; gap: 12px; align-items: center; flex-shrink: 0; }
122
+
123
+ /* Listes */
124
+ .list-head {
125
+ display: flex; align-items: center; gap: 10px;
126
+ font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 8px;
127
+ }
128
+ .list-head .spacer { margin-left: auto; }
129
+ .list-head-meta { font-size: 12px; font-weight: 400; color: var(--text-4); }
130
+ .list { border-top: 1px solid var(--border-subtle); }
131
+ .row {
132
+ display: flex; align-items: center; gap: 16px;
133
+ min-height: 60px; padding: 8px; border-bottom: 1px solid var(--border-subtle);
134
+ border-radius: 10px; position: relative;
135
+ }
136
+ .row:hover { background: var(--surface); }
137
+ .row-main { min-width: 0; flex: 1 1 auto; }
138
+ .row-name { font-size: 15px; font-weight: 600; color: var(--text); }
139
+ .row-meta {
140
+ font-size: 12px; color: var(--text-4); font-family: var(--mono);
141
+ margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
142
+ }
143
+ .row-open { font-size: 13px; font-weight: 500; color: var(--accent-soft); }
144
+ .row-open:hover { color: #A5B4FC; }
145
+
146
+ /* Pastilles, badges, étiquettes */
147
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; background: #3F3F46; }
148
+ .dot.up { background: var(--green); }
149
+ .dot.warn { background: var(--amber); }
150
+ .dot.down { background: var(--red); }
151
+ .badge { font-size: 11px; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
152
+ .badge-green { color: #4ADE80; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.3); }
153
+ .tag {
154
+ font-size: 11px; color: var(--text-2); background: var(--surface-2);
155
+ border: 1px solid var(--border); border-radius: 6px; padding: 3px 10px; white-space: nowrap;
156
+ }
157
+ .tag-muted { color: var(--text-3); }
158
+
159
+ /* Menu contextuel */
160
+ .menu-wrap { position: relative; flex-shrink: 0; }
161
+ .icon-btn {
162
+ display: grid; place-items: center; width: 28px; height: 28px;
163
+ border-radius: 6px; background: none; border: 0; color: var(--text-3); cursor: pointer;
164
+ }
165
+ .icon-btn:hover { background: var(--surface-2); color: var(--text-2); }
166
+ .menu {
167
+ position: absolute; right: 0; top: calc(100% + 4px); z-index: 50; min-width: 196px;
168
+ background: var(--surface-2); border: 1px solid #2E2E38; border-radius: 10px; padding: 6px;
169
+ box-shadow: 0 16px 40px rgba(0,0,0,.55); display: none;
170
+ }
171
+ .menu.open { display: block; }
172
+ .menu-up { top: auto; bottom: calc(100% + 4px); }
173
+ .menu-item {
174
+ display: flex; align-items: center; gap: 10px; width: 100%; height: 32px; padding: 0 8px;
175
+ border-radius: 6px; border: 0; background: none; color: var(--text);
176
+ font-family: inherit; font-size: 13px; text-align: left; cursor: pointer;
177
+ }
178
+ .menu-item:hover { background: #26262E; }
179
+ .menu-item svg { color: var(--text-2); flex: 0 0 16px; }
180
+ .menu-item.danger, .menu-item.danger svg { color: var(--red); }
181
+ .menu-item.active svg { color: var(--accent-soft); }
182
+ .menu-sep { height: 1px; background: #2E2E38; margin: 5px -6px; }
183
+
184
+ /* Bandeaux */
185
+ .banner {
186
+ display: flex; align-items: center; gap: 12px; padding: 12px 16px;
187
+ border-radius: 10px; font-size: 13px; margin-bottom: 24px;
188
+ }
189
+ .banner svg { flex: 0 0 16px; }
190
+ .banner-warn { background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.25); color: #FBBF24; }
191
+ .banner-error { background: rgba(239,68,68,.08); border: 1px solid rgba(239,68,68,.25); color: var(--red); }
192
+ .banner-action {
193
+ margin-left: auto; background: none; border: 0; color: inherit;
194
+ font-family: inherit; font-size: 13px; font-weight: 500; cursor: pointer;
195
+ }
196
+ .banner-action:hover { text-decoration: underline; }
197
+
198
+ /* Modale */
199
+ .overlay {
200
+ position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 100;
201
+ display: none; align-items: center; justify-content: center; padding: 24px;
202
+ }
203
+ .overlay.active { display: flex; }
204
+ .modal {
205
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 14px;
206
+ padding: 24px; width: 100%; max-width: 400px; max-height: 90vh; overflow-y: auto;
207
+ }
208
+ .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
209
+ .modal-head h2 { font-size: 17px; font-weight: 600; }
210
+
211
+ /* Divers */
212
+ .empty {
213
+ border: 1px dashed var(--border); border-radius: 12px; padding: 48px 24px;
214
+ text-align: center; color: var(--text-3); font-size: 14px;
215
+ }
216
+ .empty strong { display: block; color: var(--text-2); font-size: 15px; margin-bottom: 6px; font-weight: 600; }
217
+ .spinner {
218
+ display: inline-block; width: 12px; height: 12px; border-radius: 50%;
219
+ border: 2px solid currentColor; border-top-color: transparent; animation: spin .7s linear infinite;
220
+ }
221
+ .spinning { animation: spin .7s linear infinite; }
222
+ @keyframes spin { to { transform: rotate(360deg); } }
223
+ .hidden { display: none !important; }
224
+ `;
225
+
226
+ /** Mise en page à panneau latéral fixe. */
227
+ const SHELL_CSS = `
228
+ .app { display: flex; min-height: 100vh; }
229
+ .sidebar {
230
+ width: 240px; flex: 0 0 240px; background: var(--sidebar);
231
+ border-right: 1px solid var(--border); padding: 20px 0;
232
+ display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh;
233
+ }
234
+ .brand { display: flex; align-items: center; gap: 12px; padding: 0 20px 26px; }
235
+ .brand-logo {
236
+ width: 32px; height: 32px; border-radius: 9px; background: var(--accent);
237
+ display: grid; place-items: center; color: #fff; flex-shrink: 0;
238
+ }
239
+ .brand-text { min-width: 0; }
240
+ .brand-name { font-size: 14px; font-weight: 600; line-height: 1.3; }
241
+ .brand-host {
242
+ font-size: 11px; color: var(--text-3); font-family: var(--mono);
243
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
244
+ }
245
+ .nav-label {
246
+ font-size: 10px; font-weight: 600; letter-spacing: .09em;
247
+ color: var(--text-4); padding: 0 20px; margin-bottom: 8px;
248
+ }
249
+ .nav { display: flex; flex-direction: column; gap: 2px; padding: 0 12px; margin-bottom: 26px; }
250
+ .nav-item {
251
+ display: flex; align-items: center; gap: 12px; width: 100%; height: 36px; padding: 0 16px;
252
+ border: 0; background: none; border-radius: 8px; color: var(--text-2);
253
+ font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; position: relative;
254
+ }
255
+ .nav-item:hover { background: var(--surface); color: var(--text); }
256
+ .nav-item.active { background: var(--surface-2); color: var(--text); font-weight: 500; }
257
+ .nav-item.active::before {
258
+ content: ''; position: absolute; left: 0; top: 8px; bottom: 8px;
259
+ width: 2px; border-radius: 1px; background: var(--accent);
260
+ }
261
+ .nav-item > svg { flex: 0 0 16px; color: var(--text-3); }
262
+ .nav-item.active > svg { color: #A5B4FC; }
263
+ .nav-item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
264
+ .nav-count { font-size: 12px; color: var(--text-4); flex-shrink: 0; }
265
+ .nav-item.active .nav-count { color: var(--text-3); }
266
+ .nav-warn { color: var(--amber); display: none; flex-shrink: 0; }
267
+ .nav-warn.visible { display: block; }
268
+ .nav-item-plain { height: 32px; padding: 0 20px; }
269
+ .nav-item-plain .nav-item-label { font-size: 13px; }
270
+ .nav-item-muted { color: var(--text-3); }
271
+ .sidebar-spacer { flex: 1 1 auto; min-height: 24px; }
272
+ .status-card {
273
+ margin: 0 16px; padding: 14px 16px; border-radius: 10px;
274
+ background: var(--surface); border: 1px solid var(--border);
275
+ }
276
+ .status-line { display: flex; align-items: center; gap: 10px; font-size: 12px; font-weight: 500; }
277
+ .status-detail { font-size: 11px; color: var(--text-3); margin-top: 6px; }
278
+ .main { flex: 1 1 auto; min-width: 0; padding: 24px 32px 64px; }
279
+ .main-inner { max-width: 1160px; margin: 0 auto; }
280
+
281
+ @media (max-width: 900px) {
282
+ .app { flex-direction: column; }
283
+ .sidebar {
284
+ width: auto; flex: none; height: auto; position: static;
285
+ border-right: 0; border-bottom: 1px solid var(--border); padding: 16px 0;
286
+ }
287
+ .sidebar-spacer { display: none; }
288
+ .nav { flex-direction: row; flex-wrap: wrap; margin-bottom: 16px; }
289
+ .nav-item { width: auto; }
290
+ .nav-item.active::before { display: none; }
291
+ .status-card { display: none; }
292
+ .main { padding: 20px 16px 48px; }
293
+ .page-head { flex-wrap: wrap; }
294
+ .page-actions { margin-left: 0; width: 100%; }
295
+ }
296
+ `;
297
+
298
+ /**
299
+ * Panneau latéral commun.
300
+ * @param {Object} o
301
+ * @param {string} o.proxyName
302
+ * @param {string} [o.host] - libellé sous le nom du produit
303
+ * @param {'services'|'agent'|'meta'} o.active - page courante
304
+ * @param {number} [o.serviceCount]
305
+ * @param {boolean} [o.hasCleanUp]
306
+ * @param {boolean} [o.hasAuth]
307
+ * @param {string} [o.sectionLabel] - titre de la section contextuelle
308
+ * @param {string} [o.sectionHtml] - contenu de la section contextuelle
309
+ * @param {string} [o.statusDetail] - deuxième ligne de la carte de statut
310
+ */
311
+ function renderSidebar(o) {
312
+ const count = typeof o.serviceCount === 'number'
313
+ ? `<span class="nav-count">${o.serviceCount}</span>`
314
+ : '';
315
+
316
+ return `<aside class="sidebar">
317
+ <div class="brand">
318
+ <span class="brand-logo">${icon('network', 16)}</span>
319
+ <span class="brand-text">
320
+ <span class="brand-name">${escapeHtml(o.proxyName)}</span>
321
+ <span class="brand-host">${escapeHtml(o.host || '')}</span>
322
+ </span>
323
+ </div>
324
+
325
+ <div class="nav-label">NAVIGATION</div>
326
+ <nav class="nav">
327
+ <a class="nav-item${o.active === 'services' ? ' active' : ''}" href="/">
328
+ ${icon('layers')}<span class="nav-item-label">Services</span>${count}
329
+ </a>
330
+ <a class="nav-item${o.active === 'agent' ? ' active' : ''}" href="/proxy/terminal" target="_blank" rel="noopener noreferrer">
331
+ ${icon('terminal')}<span class="nav-item-label">Agent</span>
332
+ </a>
333
+ <a class="nav-item${o.active === 'meta' ? ' active' : ''}" href="/proxy/meta-configs-ui">
334
+ ${icon('sliders')}<span class="nav-item-label">Meta configs</span>
335
+ <span class="nav-warn" id="navSyncWarn" title="Configurations désynchronisées">${icon('alert', 14)}</span>
336
+ </a>
337
+ ${o.hasCleanUp ? `<button class="nav-item" id="cleanupBtn" onclick="runCleanUp()">
338
+ ${icon('trash')}<span class="nav-item-label">Nettoyer les process</span>
339
+ </button>` : ''}
340
+ </nav>
341
+
342
+ ${o.sectionLabel ? `<div class="nav-label">${escapeHtml(o.sectionLabel)}</div>
343
+ <nav class="nav">${o.sectionHtml || ''}</nav>` : ''}
344
+
345
+ <div class="sidebar-spacer"></div>
346
+
347
+ ${o.hasAuth ? `<nav class="nav" style="margin-bottom:16px;">
348
+ <button class="nav-item" onclick="logout()">
349
+ ${icon('logout')}<span class="nav-item-label">Déconnexion</span>
350
+ </button>
351
+ </nav>` : ''}
352
+
353
+ <div class="status-card">
354
+ <div class="status-line"><span class="dot up"></span><span>Proxy en ligne</span></div>
355
+ <div class="status-detail">${escapeHtml(o.statusDetail || '')}</div>
356
+ </div>
357
+ </aside>`;
358
+ }
359
+
360
+ /** Scripts partagés : déconnexion, nettoyage, menus contextuels, badge de désync. */
361
+ const SHELL_JS = `
362
+ function logout() {
363
+ const form = document.createElement('form');
364
+ form.method = 'POST';
365
+ form.action = '/proxy/logout';
366
+ document.body.appendChild(form);
367
+ form.submit();
368
+ }
369
+
370
+ async function runCleanUp() {
371
+ if (!confirm('Nettoyer tous les processus ?')) return;
372
+ const btn = document.getElementById('cleanupBtn');
373
+ const label = btn.querySelector('.nav-item-label');
374
+ const original = label.textContent;
375
+ btn.disabled = true;
376
+ label.textContent = 'Nettoyage…';
377
+ try {
378
+ await fetch('/proxy/cleanup', { method: 'POST' });
379
+ label.textContent = 'Nettoyé';
380
+ } catch (_) {
381
+ label.textContent = 'Échec du nettoyage';
382
+ } finally {
383
+ setTimeout(() => { label.textContent = original; btn.disabled = false; }, 3000);
384
+ }
385
+ }
386
+
387
+ /* Un seul menu ouvert à la fois ; clic extérieur et Échap le referment. */
388
+ function toggleMenu(btn) {
389
+ const menu = btn.nextElementSibling;
390
+ const wasOpen = menu.classList.contains('open');
391
+ closeAllMenus();
392
+ if (wasOpen) return;
393
+ menu.classList.remove('menu-up');
394
+ menu.classList.add('open');
395
+ // Bascule au-dessus du bouton quand le bas de la fenêtre est trop proche.
396
+ if (menu.getBoundingClientRect().bottom > window.innerHeight - 8) {
397
+ menu.classList.add('menu-up');
398
+ }
399
+ }
400
+
401
+ function closeAllMenus() {
402
+ document.querySelectorAll('.menu.open').forEach(m => m.classList.remove('open'));
403
+ }
404
+
405
+ document.addEventListener('click', (e) => {
406
+ if (!e.target.closest('.menu-wrap')) closeAllMenus();
407
+ });
408
+
409
+ document.addEventListener('keydown', (e) => {
410
+ if (e.key === 'Escape') closeAllMenus();
411
+ });
412
+
413
+ /** Signale la désynchronisation des méta-configs dans le panneau. */
414
+ async function refreshSyncBadge() {
415
+ const el = document.getElementById('navSyncWarn');
416
+ if (!el) return;
417
+ try {
418
+ const res = await fetch('/proxy/meta-configs/active/sync');
419
+ const data = await res.json();
420
+ el.classList.toggle('visible', data.synced === false);
421
+ } catch (_) {}
422
+ }
423
+
424
+ function esc(str) {
425
+ const d = document.createElement('div');
426
+ d.textContent = String(str == null ? '' : str);
427
+ return d.innerHTML;
428
+ }
429
+ `;
430
+
431
+ module.exports = { BASE_CSS, SHELL_CSS, SHELL_JS, renderSidebar, icon };