@iflow-mcp/npcnpc09-remotex 0.4.0
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/CLAUDE.md +201 -0
- package/bin/cli.js +484 -0
- package/package.json +1 -0
- package/public/index.html +1806 -0
- package/public/status.html +689 -0
- package/public/terminal.html +454 -0
- package/src/bridge.js +1124 -0
- package/src/dashboard-server.js +799 -0
- package/src/mcp-server.js +849 -0
|
@@ -0,0 +1,1806 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>TERMIX-CC Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #0a0e17;
|
|
14
|
+
--bg2: #111827;
|
|
15
|
+
--bg3: #1a2236;
|
|
16
|
+
--border: #2a3a5c;
|
|
17
|
+
--text: #e2e8f0;
|
|
18
|
+
--text2: #94a3b8;
|
|
19
|
+
--accent: #38bdf8;
|
|
20
|
+
--accent2: #818cf8;
|
|
21
|
+
--green: #22c55e;
|
|
22
|
+
--red: #ef4444;
|
|
23
|
+
--orange: #f59e0b;
|
|
24
|
+
--pink: #f472b6;
|
|
25
|
+
--radius: 8px;
|
|
26
|
+
--shadow: 0 2px 8px rgba(0,0,0,.3);
|
|
27
|
+
}
|
|
28
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
29
|
+
body {
|
|
30
|
+
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
31
|
+
background: var(--bg);
|
|
32
|
+
color: var(--text);
|
|
33
|
+
min-height: 100vh;
|
|
34
|
+
overflow-x: hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ── Header ── */
|
|
38
|
+
.header {
|
|
39
|
+
background: var(--bg2);
|
|
40
|
+
border-bottom: 1px solid var(--border);
|
|
41
|
+
padding: 12px 24px;
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
position: sticky;
|
|
46
|
+
top: 0;
|
|
47
|
+
z-index: 100;
|
|
48
|
+
}
|
|
49
|
+
.header-left { display:flex; align-items:center; gap:16px; }
|
|
50
|
+
.logo {
|
|
51
|
+
font-size: 20px;
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
54
|
+
-webkit-background-clip: text;
|
|
55
|
+
-webkit-text-fill-color: transparent;
|
|
56
|
+
letter-spacing: 1px;
|
|
57
|
+
}
|
|
58
|
+
.ws-status {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 6px;
|
|
62
|
+
font-size: 13px;
|
|
63
|
+
color: var(--text2);
|
|
64
|
+
}
|
|
65
|
+
.ws-dot {
|
|
66
|
+
width: 8px; height: 8px;
|
|
67
|
+
border-radius: 50%;
|
|
68
|
+
background: var(--red);
|
|
69
|
+
transition: background .3s;
|
|
70
|
+
}
|
|
71
|
+
.ws-dot.connected { background: var(--green); }
|
|
72
|
+
.header-right { display:flex; align-items:center; gap:12px; }
|
|
73
|
+
.stats-bar {
|
|
74
|
+
display: flex;
|
|
75
|
+
gap: 16px;
|
|
76
|
+
font-size: 13px;
|
|
77
|
+
color: var(--text2);
|
|
78
|
+
}
|
|
79
|
+
.stats-bar span strong { color: var(--text); }
|
|
80
|
+
.btn {
|
|
81
|
+
padding: 6px 14px;
|
|
82
|
+
border: 1px solid var(--border);
|
|
83
|
+
border-radius: var(--radius);
|
|
84
|
+
background: var(--bg3);
|
|
85
|
+
color: var(--text);
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
transition: all .2s;
|
|
89
|
+
}
|
|
90
|
+
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
91
|
+
.btn.primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight:600; }
|
|
92
|
+
.btn.primary:hover { opacity:.85; }
|
|
93
|
+
.btn.danger { border-color: var(--red); color: var(--red); }
|
|
94
|
+
.btn.danger:hover { background: var(--red); color: #fff; }
|
|
95
|
+
|
|
96
|
+
/* ── Layout ── */
|
|
97
|
+
.container {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: 240px 1fr;
|
|
100
|
+
min-height: calc(100vh - 53px);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── Sidebar ── */
|
|
104
|
+
.sidebar {
|
|
105
|
+
background: var(--bg2);
|
|
106
|
+
border-right: 1px solid var(--border);
|
|
107
|
+
padding: 16px 0;
|
|
108
|
+
overflow-y: auto;
|
|
109
|
+
}
|
|
110
|
+
.sidebar-title {
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
letter-spacing: 1.5px;
|
|
114
|
+
color: var(--text2);
|
|
115
|
+
padding: 8px 16px 6px;
|
|
116
|
+
}
|
|
117
|
+
.group-item {
|
|
118
|
+
padding: 8px 16px;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
font-size: 14px;
|
|
121
|
+
display: flex;
|
|
122
|
+
justify-content: space-between;
|
|
123
|
+
align-items: center;
|
|
124
|
+
transition: background .15s;
|
|
125
|
+
}
|
|
126
|
+
.group-item:hover { background: var(--bg3); }
|
|
127
|
+
.group-item.active { background: var(--bg3); color: var(--accent); border-left: 3px solid var(--accent); }
|
|
128
|
+
.group-count {
|
|
129
|
+
background: var(--bg);
|
|
130
|
+
padding: 1px 8px;
|
|
131
|
+
border-radius: 10px;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
color: var(--text2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Main ── */
|
|
137
|
+
.main { padding: 20px; overflow-y: auto; }
|
|
138
|
+
|
|
139
|
+
/* ── Tabs ── */
|
|
140
|
+
.tabs {
|
|
141
|
+
display: flex;
|
|
142
|
+
gap: 4px;
|
|
143
|
+
margin-bottom: 20px;
|
|
144
|
+
border-bottom: 1px solid var(--border);
|
|
145
|
+
padding-bottom: 0;
|
|
146
|
+
}
|
|
147
|
+
.tab {
|
|
148
|
+
padding: 10px 20px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
font-size: 14px;
|
|
151
|
+
color: var(--text2);
|
|
152
|
+
border-bottom: 2px solid transparent;
|
|
153
|
+
transition: all .2s;
|
|
154
|
+
margin-bottom: -1px;
|
|
155
|
+
}
|
|
156
|
+
.tab:hover { color: var(--text); }
|
|
157
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
158
|
+
|
|
159
|
+
/* ── Server Grid ── */
|
|
160
|
+
.server-grid {
|
|
161
|
+
display: grid;
|
|
162
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
163
|
+
gap: 14px;
|
|
164
|
+
}
|
|
165
|
+
.server-card {
|
|
166
|
+
background: var(--bg2);
|
|
167
|
+
border: 1px solid var(--border);
|
|
168
|
+
border-radius: var(--radius);
|
|
169
|
+
padding: 16px;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
transition: all .2s;
|
|
172
|
+
position: relative;
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
}
|
|
175
|
+
.server-card:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow); }
|
|
176
|
+
.server-card.offline { opacity:.6; }
|
|
177
|
+
.server-card-header {
|
|
178
|
+
display: flex;
|
|
179
|
+
justify-content: space-between;
|
|
180
|
+
align-items: flex-start;
|
|
181
|
+
margin-bottom: 12px;
|
|
182
|
+
}
|
|
183
|
+
.server-name { font-weight: 600; font-size: 15px; word-break: break-all; }
|
|
184
|
+
.server-host { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
|
185
|
+
.status-badge {
|
|
186
|
+
padding: 2px 10px;
|
|
187
|
+
border-radius: 10px;
|
|
188
|
+
font-size: 11px;
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
text-transform: uppercase;
|
|
191
|
+
letter-spacing: .5px;
|
|
192
|
+
flex-shrink: 0;
|
|
193
|
+
}
|
|
194
|
+
.status-badge.online { background: rgba(34,197,94,.15); color: var(--green); }
|
|
195
|
+
.status-badge.offline { background: rgba(239,68,68,.15); color: var(--red); }
|
|
196
|
+
.server-metrics {
|
|
197
|
+
display: grid;
|
|
198
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
199
|
+
gap: 8px;
|
|
200
|
+
}
|
|
201
|
+
.metric {
|
|
202
|
+
text-align: center;
|
|
203
|
+
}
|
|
204
|
+
.metric-label { font-size: 11px; color: var(--text2); margin-bottom: 4px; }
|
|
205
|
+
.metric-bar {
|
|
206
|
+
height: 4px;
|
|
207
|
+
background: var(--bg);
|
|
208
|
+
border-radius: 2px;
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
margin-bottom: 4px;
|
|
211
|
+
}
|
|
212
|
+
.metric-bar-fill {
|
|
213
|
+
height: 100%;
|
|
214
|
+
border-radius: 2px;
|
|
215
|
+
transition: width .5s;
|
|
216
|
+
}
|
|
217
|
+
.metric-bar-fill.cpu { background: var(--accent); }
|
|
218
|
+
.metric-bar-fill.mem { background: var(--accent2); }
|
|
219
|
+
.metric-bar-fill.disk { background: var(--orange); }
|
|
220
|
+
.metric-bar-fill.high { background: var(--red); }
|
|
221
|
+
.metric-value { font-size: 13px; font-weight: 600; }
|
|
222
|
+
.agent-badge {
|
|
223
|
+
position: absolute;
|
|
224
|
+
top: 0; left: 0; right: 0;
|
|
225
|
+
padding: 3px 10px;
|
|
226
|
+
font-size: 11px;
|
|
227
|
+
text-align: center;
|
|
228
|
+
background: rgba(129,140,248,.2);
|
|
229
|
+
color: var(--accent2);
|
|
230
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
231
|
+
}
|
|
232
|
+
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
|
|
233
|
+
|
|
234
|
+
/* ── Server Detail Panel ── */
|
|
235
|
+
.detail-overlay {
|
|
236
|
+
display: none;
|
|
237
|
+
position: fixed;
|
|
238
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
239
|
+
background: rgba(0,0,0,.6);
|
|
240
|
+
z-index: 200;
|
|
241
|
+
justify-content: center;
|
|
242
|
+
align-items: flex-start;
|
|
243
|
+
padding-top: 60px;
|
|
244
|
+
}
|
|
245
|
+
.detail-overlay.show { display: flex; }
|
|
246
|
+
.detail-panel {
|
|
247
|
+
background: var(--bg2);
|
|
248
|
+
border: 1px solid var(--border);
|
|
249
|
+
border-radius: 12px;
|
|
250
|
+
width: 700px;
|
|
251
|
+
max-height: 80vh;
|
|
252
|
+
overflow-y: auto;
|
|
253
|
+
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
|
254
|
+
}
|
|
255
|
+
.detail-header {
|
|
256
|
+
padding: 20px 24px;
|
|
257
|
+
border-bottom: 1px solid var(--border);
|
|
258
|
+
display: flex;
|
|
259
|
+
justify-content: space-between;
|
|
260
|
+
align-items: center;
|
|
261
|
+
}
|
|
262
|
+
.detail-header h2 { font-size: 18px; }
|
|
263
|
+
.detail-close {
|
|
264
|
+
width: 32px; height: 32px;
|
|
265
|
+
border-radius: 50%;
|
|
266
|
+
border: none;
|
|
267
|
+
background: var(--bg3);
|
|
268
|
+
color: var(--text2);
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
font-size: 18px;
|
|
271
|
+
display: flex;
|
|
272
|
+
align-items: center;
|
|
273
|
+
justify-content: center;
|
|
274
|
+
}
|
|
275
|
+
.detail-close:hover { color: var(--text); }
|
|
276
|
+
.detail-body { padding: 20px 24px; }
|
|
277
|
+
.detail-info-grid {
|
|
278
|
+
display: grid;
|
|
279
|
+
grid-template-columns: 1fr 1fr;
|
|
280
|
+
gap: 12px;
|
|
281
|
+
margin-bottom: 20px;
|
|
282
|
+
}
|
|
283
|
+
.info-item { font-size: 13px; }
|
|
284
|
+
.info-item label { color: var(--text2); display: block; margin-bottom: 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
|
285
|
+
.info-item span { font-weight: 500; }
|
|
286
|
+
.detail-section { margin-bottom: 20px; }
|
|
287
|
+
.detail-section h3 {
|
|
288
|
+
font-size: 14px;
|
|
289
|
+
color: var(--text2);
|
|
290
|
+
margin-bottom: 10px;
|
|
291
|
+
text-transform: uppercase;
|
|
292
|
+
letter-spacing: .5px;
|
|
293
|
+
}
|
|
294
|
+
.service-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
295
|
+
.service-tag {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: 6px;
|
|
299
|
+
padding: 4px 12px;
|
|
300
|
+
border-radius: 6px;
|
|
301
|
+
font-size: 13px;
|
|
302
|
+
background: var(--bg3);
|
|
303
|
+
border: 1px solid var(--border);
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
transition: all .2s;
|
|
306
|
+
}
|
|
307
|
+
.service-tag:hover { border-color: var(--accent); }
|
|
308
|
+
.service-dot {
|
|
309
|
+
width: 6px; height: 6px;
|
|
310
|
+
border-radius: 50%;
|
|
311
|
+
}
|
|
312
|
+
.service-dot.active { background: var(--green); }
|
|
313
|
+
.service-dot.failed { background: var(--red); }
|
|
314
|
+
.service-actions {
|
|
315
|
+
display: none;
|
|
316
|
+
gap: 4px;
|
|
317
|
+
margin-left: 4px;
|
|
318
|
+
}
|
|
319
|
+
.service-tag:hover .service-actions { display: flex; }
|
|
320
|
+
.svc-btn {
|
|
321
|
+
padding: 1px 6px;
|
|
322
|
+
border: none;
|
|
323
|
+
border-radius: 4px;
|
|
324
|
+
font-size: 10px;
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
background: var(--bg);
|
|
327
|
+
color: var(--text2);
|
|
328
|
+
}
|
|
329
|
+
.svc-btn:hover { color: var(--text); }
|
|
330
|
+
.svc-btn.restart { color: var(--orange); }
|
|
331
|
+
.svc-btn.stop { color: var(--red); }
|
|
332
|
+
|
|
333
|
+
/* ── Terminal ── */
|
|
334
|
+
.terminal-section { margin-top: 16px; }
|
|
335
|
+
.terminal-input-wrap {
|
|
336
|
+
display: flex;
|
|
337
|
+
gap: 8px;
|
|
338
|
+
margin-bottom: 10px;
|
|
339
|
+
}
|
|
340
|
+
.terminal-input-wrap input {
|
|
341
|
+
flex: 1;
|
|
342
|
+
padding: 8px 12px;
|
|
343
|
+
border: 1px solid var(--border);
|
|
344
|
+
border-radius: var(--radius);
|
|
345
|
+
background: var(--bg);
|
|
346
|
+
color: var(--text);
|
|
347
|
+
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
|
348
|
+
font-size: 13px;
|
|
349
|
+
outline: none;
|
|
350
|
+
}
|
|
351
|
+
.terminal-input-wrap input:focus { border-color: var(--accent); }
|
|
352
|
+
.terminal-output {
|
|
353
|
+
background: #000;
|
|
354
|
+
border-radius: var(--radius);
|
|
355
|
+
padding: 12px;
|
|
356
|
+
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
|
357
|
+
font-size: 12px;
|
|
358
|
+
line-height: 1.5;
|
|
359
|
+
max-height: 250px;
|
|
360
|
+
overflow-y: auto;
|
|
361
|
+
white-space: pre-wrap;
|
|
362
|
+
word-break: break-all;
|
|
363
|
+
color: #a0f0a0;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ── Batch Exec Panel ── */
|
|
367
|
+
.batch-panel {
|
|
368
|
+
background: var(--bg2);
|
|
369
|
+
border: 1px solid var(--border);
|
|
370
|
+
border-radius: var(--radius);
|
|
371
|
+
padding: 20px;
|
|
372
|
+
margin-bottom: 20px;
|
|
373
|
+
}
|
|
374
|
+
.batch-panel h3 { font-size: 15px; margin-bottom: 12px; }
|
|
375
|
+
.batch-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
|
376
|
+
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
|
377
|
+
.form-group label { font-size: 12px; color: var(--text2); }
|
|
378
|
+
.form-group select, .form-group input {
|
|
379
|
+
padding: 7px 12px;
|
|
380
|
+
border: 1px solid var(--border);
|
|
381
|
+
border-radius: var(--radius);
|
|
382
|
+
background: var(--bg);
|
|
383
|
+
color: var(--text);
|
|
384
|
+
font-size: 13px;
|
|
385
|
+
outline: none;
|
|
386
|
+
min-width: 160px;
|
|
387
|
+
}
|
|
388
|
+
.form-group select:focus, .form-group input:focus { border-color: var(--accent); }
|
|
389
|
+
.batch-results {
|
|
390
|
+
margin-top: 12px;
|
|
391
|
+
max-height: 300px;
|
|
392
|
+
overflow-y: auto;
|
|
393
|
+
}
|
|
394
|
+
.batch-result-item {
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
gap: 8px;
|
|
398
|
+
padding: 6px 10px;
|
|
399
|
+
font-size: 13px;
|
|
400
|
+
border-bottom: 1px solid var(--border);
|
|
401
|
+
}
|
|
402
|
+
.batch-result-item:last-child { border-bottom: none; }
|
|
403
|
+
.batch-result-item .code0 { color: var(--green); }
|
|
404
|
+
.batch-result-item .code-fail { color: var(--red); }
|
|
405
|
+
|
|
406
|
+
/* ── Log Panel ── */
|
|
407
|
+
.log-panel {
|
|
408
|
+
background: #000;
|
|
409
|
+
border-radius: var(--radius);
|
|
410
|
+
padding: 12px;
|
|
411
|
+
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
|
412
|
+
font-size: 12px;
|
|
413
|
+
line-height: 1.6;
|
|
414
|
+
max-height: 500px;
|
|
415
|
+
overflow-y: auto;
|
|
416
|
+
}
|
|
417
|
+
.log-entry { display: flex; gap: 8px; }
|
|
418
|
+
.log-ts { color: #555; flex-shrink: 0; }
|
|
419
|
+
.log-source { color: var(--accent); min-width: 70px; flex-shrink: 0; }
|
|
420
|
+
.log-msg { word-break: break-all; }
|
|
421
|
+
.log-entry.info .log-msg { color: #ccc; }
|
|
422
|
+
.log-entry.ok .log-msg { color: var(--green); }
|
|
423
|
+
.log-entry.error .log-msg { color: var(--red); }
|
|
424
|
+
.log-entry.warn .log-msg { color: var(--orange); }
|
|
425
|
+
.log-entry.claude .log-msg { color: var(--pink); }
|
|
426
|
+
|
|
427
|
+
/* ── Deploy Panel ── */
|
|
428
|
+
.deploy-form {
|
|
429
|
+
display: grid;
|
|
430
|
+
grid-template-columns: 1fr 1fr;
|
|
431
|
+
gap: 12px;
|
|
432
|
+
margin-bottom: 16px;
|
|
433
|
+
}
|
|
434
|
+
.deploy-form .full { grid-column: 1 / -1; }
|
|
435
|
+
.deploy-progress {
|
|
436
|
+
background: var(--bg3);
|
|
437
|
+
border-radius: var(--radius);
|
|
438
|
+
padding: 16px;
|
|
439
|
+
margin-bottom: 16px;
|
|
440
|
+
display: none;
|
|
441
|
+
}
|
|
442
|
+
.deploy-progress.active { display: block; }
|
|
443
|
+
.deploy-progress-bar {
|
|
444
|
+
height: 8px;
|
|
445
|
+
background: var(--bg);
|
|
446
|
+
border-radius: 4px;
|
|
447
|
+
overflow: hidden;
|
|
448
|
+
margin: 10px 0;
|
|
449
|
+
}
|
|
450
|
+
.deploy-progress-fill {
|
|
451
|
+
height: 100%;
|
|
452
|
+
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
453
|
+
border-radius: 4px;
|
|
454
|
+
transition: width .5s;
|
|
455
|
+
width: 0%;
|
|
456
|
+
}
|
|
457
|
+
.deploy-phase {
|
|
458
|
+
font-size: 13px;
|
|
459
|
+
color: var(--text2);
|
|
460
|
+
}
|
|
461
|
+
.deploy-results {
|
|
462
|
+
max-height: 200px;
|
|
463
|
+
overflow-y: auto;
|
|
464
|
+
font-size: 12px;
|
|
465
|
+
font-family: monospace;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ── Search ── */
|
|
469
|
+
.search-bar {
|
|
470
|
+
margin-bottom: 16px;
|
|
471
|
+
}
|
|
472
|
+
.search-bar input {
|
|
473
|
+
width: 100%;
|
|
474
|
+
padding: 10px 14px;
|
|
475
|
+
border: 1px solid var(--border);
|
|
476
|
+
border-radius: var(--radius);
|
|
477
|
+
background: var(--bg2);
|
|
478
|
+
color: var(--text);
|
|
479
|
+
font-size: 14px;
|
|
480
|
+
outline: none;
|
|
481
|
+
}
|
|
482
|
+
.search-bar input:focus { border-color: var(--accent); }
|
|
483
|
+
.search-bar input::placeholder { color: var(--text2); }
|
|
484
|
+
|
|
485
|
+
/* ── Responsive ── */
|
|
486
|
+
@media (max-width: 800px) {
|
|
487
|
+
.container { grid-template-columns: 1fr; }
|
|
488
|
+
.sidebar { display: none; }
|
|
489
|
+
.server-grid { grid-template-columns: 1fr; }
|
|
490
|
+
.detail-panel { width: 95%; }
|
|
491
|
+
.deploy-form { grid-template-columns: 1fr; }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* Scrollbar */
|
|
495
|
+
::-webkit-scrollbar { width: 6px; }
|
|
496
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
497
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
498
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text2); }
|
|
499
|
+
|
|
500
|
+
/* Tab content */
|
|
501
|
+
.tab-content { display: none; }
|
|
502
|
+
.tab-content.active { display: block; }
|
|
503
|
+
|
|
504
|
+
/* ── Terminal Tab ── */
|
|
505
|
+
.terminal-tab-bar {
|
|
506
|
+
display: flex;
|
|
507
|
+
align-items: center;
|
|
508
|
+
gap: 0;
|
|
509
|
+
background: var(--bg);
|
|
510
|
+
border: 1px solid var(--border);
|
|
511
|
+
border-bottom: none;
|
|
512
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
513
|
+
overflow-x: auto;
|
|
514
|
+
min-height: 38px;
|
|
515
|
+
}
|
|
516
|
+
.terminal-tab-bar::-webkit-scrollbar { height: 0; }
|
|
517
|
+
.term-tab {
|
|
518
|
+
display: flex;
|
|
519
|
+
align-items: center;
|
|
520
|
+
gap: 8px;
|
|
521
|
+
padding: 8px 16px;
|
|
522
|
+
font-size: 13px;
|
|
523
|
+
color: var(--text2);
|
|
524
|
+
cursor: pointer;
|
|
525
|
+
border-right: 1px solid var(--border);
|
|
526
|
+
white-space: nowrap;
|
|
527
|
+
transition: all .15s;
|
|
528
|
+
user-select: none;
|
|
529
|
+
}
|
|
530
|
+
.term-tab:hover { background: var(--bg3); color: var(--text); }
|
|
531
|
+
.term-tab.active { background: var(--bg2); color: var(--accent); }
|
|
532
|
+
.term-tab .term-tab-dot {
|
|
533
|
+
width: 7px; height: 7px;
|
|
534
|
+
border-radius: 50%;
|
|
535
|
+
background: var(--green);
|
|
536
|
+
flex-shrink: 0;
|
|
537
|
+
}
|
|
538
|
+
.term-tab .term-tab-close {
|
|
539
|
+
width: 18px; height: 18px;
|
|
540
|
+
border-radius: 50%;
|
|
541
|
+
border: none;
|
|
542
|
+
background: transparent;
|
|
543
|
+
color: var(--text2);
|
|
544
|
+
cursor: pointer;
|
|
545
|
+
font-size: 14px;
|
|
546
|
+
display: flex;
|
|
547
|
+
align-items: center;
|
|
548
|
+
justify-content: center;
|
|
549
|
+
flex-shrink: 0;
|
|
550
|
+
}
|
|
551
|
+
.term-tab .term-tab-close:hover { background: var(--red); color: #fff; }
|
|
552
|
+
.term-new-btn {
|
|
553
|
+
padding: 8px 14px;
|
|
554
|
+
color: var(--text2);
|
|
555
|
+
cursor: pointer;
|
|
556
|
+
font-size: 16px;
|
|
557
|
+
transition: color .15s;
|
|
558
|
+
flex-shrink: 0;
|
|
559
|
+
}
|
|
560
|
+
.term-new-btn:hover { color: var(--accent); }
|
|
561
|
+
.terminal-container {
|
|
562
|
+
background: #000;
|
|
563
|
+
border: 1px solid var(--border);
|
|
564
|
+
border-top: none;
|
|
565
|
+
border-radius: 0 0 var(--radius) var(--radius);
|
|
566
|
+
position: relative;
|
|
567
|
+
height: calc(100vh - 220px);
|
|
568
|
+
min-height: 400px;
|
|
569
|
+
}
|
|
570
|
+
.terminal-container .xterm {
|
|
571
|
+
padding: 4px;
|
|
572
|
+
}
|
|
573
|
+
.term-connect-panel {
|
|
574
|
+
position: absolute;
|
|
575
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
576
|
+
display: flex;
|
|
577
|
+
flex-direction: column;
|
|
578
|
+
align-items: center;
|
|
579
|
+
justify-content: center;
|
|
580
|
+
gap: 16px;
|
|
581
|
+
background: rgba(0,0,0,.95);
|
|
582
|
+
z-index: 10;
|
|
583
|
+
}
|
|
584
|
+
.term-connect-panel h3 {
|
|
585
|
+
font-size: 18px;
|
|
586
|
+
color: var(--text);
|
|
587
|
+
font-weight: 500;
|
|
588
|
+
}
|
|
589
|
+
.term-connect-panel p {
|
|
590
|
+
font-size: 13px;
|
|
591
|
+
color: var(--text2);
|
|
592
|
+
max-width: 400px;
|
|
593
|
+
text-align: center;
|
|
594
|
+
}
|
|
595
|
+
.term-connect-row {
|
|
596
|
+
display: flex;
|
|
597
|
+
gap: 10px;
|
|
598
|
+
align-items: center;
|
|
599
|
+
}
|
|
600
|
+
.term-connect-row select {
|
|
601
|
+
padding: 8px 14px;
|
|
602
|
+
border: 1px solid var(--border);
|
|
603
|
+
border-radius: var(--radius);
|
|
604
|
+
background: var(--bg2);
|
|
605
|
+
color: var(--text);
|
|
606
|
+
font-size: 14px;
|
|
607
|
+
outline: none;
|
|
608
|
+
min-width: 220px;
|
|
609
|
+
}
|
|
610
|
+
.term-connect-row select:focus { border-color: var(--accent); }
|
|
611
|
+
.term-status-bar {
|
|
612
|
+
display: flex;
|
|
613
|
+
align-items: center;
|
|
614
|
+
justify-content: space-between;
|
|
615
|
+
padding: 4px 12px;
|
|
616
|
+
background: var(--bg2);
|
|
617
|
+
border: 1px solid var(--border);
|
|
618
|
+
border-top: none;
|
|
619
|
+
border-radius: 0 0 var(--radius) var(--radius);
|
|
620
|
+
font-size: 12px;
|
|
621
|
+
color: var(--text2);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.empty-state {
|
|
625
|
+
text-align: center;
|
|
626
|
+
padding: 60px 20px;
|
|
627
|
+
color: var(--text2);
|
|
628
|
+
}
|
|
629
|
+
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
|
630
|
+
.empty-state p { font-size: 14px; }
|
|
631
|
+
|
|
632
|
+
/* Loading spinner */
|
|
633
|
+
.spinner {
|
|
634
|
+
display: inline-block;
|
|
635
|
+
width: 16px; height: 16px;
|
|
636
|
+
border: 2px solid var(--border);
|
|
637
|
+
border-top-color: var(--accent);
|
|
638
|
+
border-radius: 50%;
|
|
639
|
+
animation: spin .6s linear infinite;
|
|
640
|
+
}
|
|
641
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
642
|
+
</style>
|
|
643
|
+
</head>
|
|
644
|
+
<body>
|
|
645
|
+
|
|
646
|
+
<!-- Header -->
|
|
647
|
+
<div class="header">
|
|
648
|
+
<div class="header-left">
|
|
649
|
+
<div class="logo">TERMIX-CC</div>
|
|
650
|
+
<div class="ws-status">
|
|
651
|
+
<div class="ws-dot" id="wsDot"></div>
|
|
652
|
+
<span id="wsLabel">Disconnected</span>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="header-right">
|
|
656
|
+
<div class="stats-bar">
|
|
657
|
+
<span>Servers: <strong id="statTotal">0</strong></span>
|
|
658
|
+
<span>Online: <strong id="statOnline" style="color:var(--green)">0</strong></span>
|
|
659
|
+
<span>Offline: <strong id="statOffline" style="color:var(--red)">0</strong></span>
|
|
660
|
+
</div>
|
|
661
|
+
<button class="btn" onclick="refreshServers()">Refresh</button>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
|
|
665
|
+
<!-- Layout -->
|
|
666
|
+
<div class="container">
|
|
667
|
+
<!-- Sidebar -->
|
|
668
|
+
<div class="sidebar">
|
|
669
|
+
<div class="sidebar-title">Server Groups</div>
|
|
670
|
+
<div id="groupList">
|
|
671
|
+
<div class="group-item active" data-group="__all" onclick="selectGroup(this, '__all')">
|
|
672
|
+
All Servers <span class="group-count" id="groupCountAll">0</span>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<!-- Main -->
|
|
678
|
+
<div class="main">
|
|
679
|
+
<!-- Tabs -->
|
|
680
|
+
<div class="tabs">
|
|
681
|
+
<div class="tab active" data-tab="terminal" onclick="switchTab('terminal')">Terminal</div>
|
|
682
|
+
<div class="tab" data-tab="servers" onclick="switchTab('servers')">Servers</div>
|
|
683
|
+
<div class="tab" data-tab="batch" onclick="switchTab('batch')">Batch Exec</div>
|
|
684
|
+
<div class="tab" data-tab="deploy" onclick="switchTab('deploy')">Deploy</div>
|
|
685
|
+
<div class="tab" data-tab="manage" onclick="switchTab('manage')">Server Mgmt</div>
|
|
686
|
+
<div class="tab" data-tab="logs" onclick="switchTab('logs')">Logs</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<!-- Tab: Terminal -->
|
|
690
|
+
<div class="tab-content active" id="tab-terminal">
|
|
691
|
+
<div class="terminal-tab-bar" id="termTabBar">
|
|
692
|
+
<div class="term-new-btn" onclick="openNewTerminal()" title="New Terminal">+</div>
|
|
693
|
+
</div>
|
|
694
|
+
<div class="terminal-container" id="termContainer">
|
|
695
|
+
<div class="term-connect-panel" id="termConnectPanel">
|
|
696
|
+
<h3>SSH Terminal</h3>
|
|
697
|
+
<p>Select a server and click Connect to open an interactive shell session</p>
|
|
698
|
+
<div class="term-connect-row">
|
|
699
|
+
<select id="termServerSelect">
|
|
700
|
+
<option value="">-- Select Server --</option>
|
|
701
|
+
</select>
|
|
702
|
+
<button class="btn primary" onclick="openNewTerminal()">Connect</button>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="term-status-bar" id="termStatusBar">
|
|
707
|
+
<span id="termStatusLeft">No active session</span>
|
|
708
|
+
<span id="termStatusRight"></span>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<!-- Tab: Servers -->
|
|
713
|
+
<div class="tab-content" id="tab-servers">
|
|
714
|
+
<div class="search-bar">
|
|
715
|
+
<input type="text" id="searchInput" placeholder="Search servers by name, host, or group..." oninput="renderServers()">
|
|
716
|
+
</div>
|
|
717
|
+
<div class="server-grid" id="serverGrid"></div>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
<!-- Tab: Batch Exec -->
|
|
721
|
+
<div class="tab-content" id="tab-batch">
|
|
722
|
+
<div class="batch-panel">
|
|
723
|
+
<h3>Batch Command Execution</h3>
|
|
724
|
+
<div class="batch-row">
|
|
725
|
+
<div class="form-group">
|
|
726
|
+
<label>Target Group</label>
|
|
727
|
+
<select id="batchGroup"></select>
|
|
728
|
+
</div>
|
|
729
|
+
<div class="form-group" style="flex:1">
|
|
730
|
+
<label>Command</label>
|
|
731
|
+
<input type="text" id="batchCmd" placeholder="e.g. uptime, df -h, systemctl status nginx">
|
|
732
|
+
</div>
|
|
733
|
+
<button class="btn primary" onclick="execBatch()">Execute</button>
|
|
734
|
+
</div>
|
|
735
|
+
<div class="batch-results" id="batchResults"></div>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<!-- Tab: Deploy -->
|
|
740
|
+
<div class="tab-content" id="tab-deploy">
|
|
741
|
+
<div class="batch-panel">
|
|
742
|
+
<h3>Canary Deployment</h3>
|
|
743
|
+
<div class="deploy-form">
|
|
744
|
+
<div class="form-group">
|
|
745
|
+
<label>Target Group</label>
|
|
746
|
+
<select id="deployGroup"></select>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="form-group">
|
|
749
|
+
<label>Canary Server</label>
|
|
750
|
+
<select id="deployCanary"></select>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="form-group full">
|
|
753
|
+
<label>Deploy Command</label>
|
|
754
|
+
<input type="text" id="deployCmd" placeholder="e.g. cd /opt/app && git pull && systemctl restart app">
|
|
755
|
+
</div>
|
|
756
|
+
<div class="form-group full">
|
|
757
|
+
<label>Rollback Command</label>
|
|
758
|
+
<input type="text" id="deployRollback" placeholder="e.g. cd /opt/app && git checkout HEAD~1 && systemctl restart app">
|
|
759
|
+
</div>
|
|
760
|
+
<div class="form-group full">
|
|
761
|
+
<label>Post-Check Command (verify health)</label>
|
|
762
|
+
<input type="text" id="deployCheck" placeholder="e.g. curl -sf http://localhost:8080/health">
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
<div style="display:flex;gap:8px">
|
|
766
|
+
<button class="btn primary" onclick="startDeploy()">Start Canary Deploy</button>
|
|
767
|
+
<button class="btn danger" onclick="abortDeploy()">Abort</button>
|
|
768
|
+
</div>
|
|
769
|
+
<div class="deploy-progress" id="deployProgress">
|
|
770
|
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
771
|
+
<strong id="deployPhase">Phase: precheck</strong>
|
|
772
|
+
<span id="deployPct">0%</span>
|
|
773
|
+
</div>
|
|
774
|
+
<div class="deploy-progress-bar">
|
|
775
|
+
<div class="deploy-progress-fill" id="deployFill"></div>
|
|
776
|
+
</div>
|
|
777
|
+
<div class="deploy-results" id="deployResults"></div>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
<!-- Tab: Server Management -->
|
|
783
|
+
<div class="tab-content" id="tab-manage">
|
|
784
|
+
<!-- Import from servers.txt -->
|
|
785
|
+
<div class="batch-panel">
|
|
786
|
+
<h3>Import from servers.txt</h3>
|
|
787
|
+
<p style="color:var(--text2);font-size:13px;margin-bottom:12px">
|
|
788
|
+
One-click import all servers from the local <code>servers.txt</code> file (format: name,host,port,user,password,group)
|
|
789
|
+
</p>
|
|
790
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
791
|
+
<button class="btn primary" onclick="importFromFile()">Import servers.txt</button>
|
|
792
|
+
<span id="importFileResult" style="font-size:13px"></span>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<!-- Paste CSV Import -->
|
|
797
|
+
<div class="batch-panel" style="margin-top:16px">
|
|
798
|
+
<h3>Paste CSV Import</h3>
|
|
799
|
+
<p style="color:var(--text2);font-size:13px;margin-bottom:12px">
|
|
800
|
+
Paste CSV data, one server per line: <code>name,host,port,username,password,group</code>
|
|
801
|
+
</p>
|
|
802
|
+
<textarea id="csvImport" rows="5" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);font-family:monospace;font-size:13px;resize:vertical;outline:none" placeholder="server1,10.0.1.1,22,root,password,production server2,10.0.1.2,22,root,password,production"></textarea>
|
|
803
|
+
<div style="display:flex;gap:8px;align-items:center;margin-top:8px">
|
|
804
|
+
<button class="btn primary" onclick="importCSV()">Import CSV</button>
|
|
805
|
+
<span id="importCsvResult" style="font-size:13px"></span>
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
<!-- Add Single Server -->
|
|
810
|
+
<div class="batch-panel" style="margin-top:16px">
|
|
811
|
+
<h3>Add Server</h3>
|
|
812
|
+
<div class="deploy-form" style="margin-bottom:0">
|
|
813
|
+
<div class="form-group">
|
|
814
|
+
<label>Server Name</label>
|
|
815
|
+
<input type="text" id="addName" placeholder="e.g. prod-01">
|
|
816
|
+
</div>
|
|
817
|
+
<div class="form-group">
|
|
818
|
+
<label>Host</label>
|
|
819
|
+
<input type="text" id="addHost" placeholder="e.g. 10.0.1.1">
|
|
820
|
+
</div>
|
|
821
|
+
<div class="form-group">
|
|
822
|
+
<label>Port</label>
|
|
823
|
+
<input type="number" id="addPort" value="22">
|
|
824
|
+
</div>
|
|
825
|
+
<div class="form-group">
|
|
826
|
+
<label>Username</label>
|
|
827
|
+
<input type="text" id="addUser" value="root">
|
|
828
|
+
</div>
|
|
829
|
+
<div class="form-group">
|
|
830
|
+
<label>Password</label>
|
|
831
|
+
<input type="password" id="addPass" placeholder="password">
|
|
832
|
+
</div>
|
|
833
|
+
<div class="form-group">
|
|
834
|
+
<label>Group</label>
|
|
835
|
+
<input type="text" id="addGroup" placeholder="e.g. production">
|
|
836
|
+
</div>
|
|
837
|
+
</div>
|
|
838
|
+
<div style="display:flex;gap:8px;align-items:center;margin-top:12px">
|
|
839
|
+
<button class="btn primary" onclick="addServer()">Add Server</button>
|
|
840
|
+
<span id="addResult" style="font-size:13px"></span>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<!-- Server Config List -->
|
|
845
|
+
<div class="batch-panel" style="margin-top:16px">
|
|
846
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
847
|
+
<h3 style="margin:0">Configured Servers (<span id="configCount">0</span>)</h3>
|
|
848
|
+
<div style="display:flex;gap:8px">
|
|
849
|
+
<button class="btn" onclick="loadConfigList()">Reload</button>
|
|
850
|
+
<button class="btn danger" onclick="clearAllServers()">Clear All</button>
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
<input type="text" id="configSearch" placeholder="Search configured servers..." oninput="filterConfigList()" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);font-size:13px;outline:none;margin-bottom:10px">
|
|
854
|
+
<div id="configList" style="max-height:400px;overflow-y:auto"></div>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<!-- Tab: Logs -->
|
|
859
|
+
<div class="tab-content" id="tab-logs">
|
|
860
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
861
|
+
<span style="font-size:14px;color:var(--text2)">Real-time event log</span>
|
|
862
|
+
<button class="btn" onclick="clearLogs()">Clear</button>
|
|
863
|
+
</div>
|
|
864
|
+
<div class="log-panel" id="logPanel"></div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<!-- Server Detail Overlay -->
|
|
870
|
+
<div class="detail-overlay" id="detailOverlay" onclick="if(event.target===this)closeDetail()">
|
|
871
|
+
<div class="detail-panel">
|
|
872
|
+
<div class="detail-header">
|
|
873
|
+
<h2 id="detailTitle">Server</h2>
|
|
874
|
+
<button class="detail-close" onclick="closeDetail()">×</button>
|
|
875
|
+
</div>
|
|
876
|
+
<div class="detail-body">
|
|
877
|
+
<div class="detail-info-grid" id="detailInfo"></div>
|
|
878
|
+
<div class="detail-section">
|
|
879
|
+
<h3>Services</h3>
|
|
880
|
+
<div class="service-list" id="detailServices">
|
|
881
|
+
<span style="color:var(--text2);font-size:13px">Loading...</span>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<div class="terminal-section">
|
|
885
|
+
<h3 style="font-size:14px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">Terminal</h3>
|
|
886
|
+
<div class="terminal-input-wrap">
|
|
887
|
+
<input type="text" id="terminalInput" placeholder="Enter command..." onkeydown="if(event.key==='Enter')execTerminal()">
|
|
888
|
+
<button class="btn primary" onclick="execTerminal()">Run</button>
|
|
889
|
+
</div>
|
|
890
|
+
<div class="terminal-output" id="terminalOutput">Ready.</div>
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
<script>
|
|
897
|
+
// ═══════════════════════════════════════════════════
|
|
898
|
+
// State
|
|
899
|
+
// ═══════════════════════════════════════════════════
|
|
900
|
+
const API = `http://${location.hostname}:7700`;
|
|
901
|
+
const WS_URL = `ws://${location.hostname}:7700`;
|
|
902
|
+
let ws = null;
|
|
903
|
+
let servers = [];
|
|
904
|
+
let groups = {};
|
|
905
|
+
let agentStatuses = {};
|
|
906
|
+
let logs = [];
|
|
907
|
+
let selectedGroup = '__all';
|
|
908
|
+
let currentDetailServer = null;
|
|
909
|
+
|
|
910
|
+
// ═══════════════════════════════════════════════════
|
|
911
|
+
// WebSocket
|
|
912
|
+
// ═══════════════════════════════════════════════════
|
|
913
|
+
function connectWS() {
|
|
914
|
+
ws = new WebSocket(WS_URL);
|
|
915
|
+
ws.onopen = () => {
|
|
916
|
+
document.getElementById('wsDot').classList.add('connected');
|
|
917
|
+
document.getElementById('wsLabel').textContent = 'Connected';
|
|
918
|
+
};
|
|
919
|
+
ws.onclose = () => {
|
|
920
|
+
document.getElementById('wsDot').classList.remove('connected');
|
|
921
|
+
document.getElementById('wsLabel').textContent = 'Disconnected';
|
|
922
|
+
setTimeout(connectWS, 3000);
|
|
923
|
+
};
|
|
924
|
+
ws.onerror = () => ws.close();
|
|
925
|
+
ws.onmessage = (e) => {
|
|
926
|
+
const msg = JSON.parse(e.data);
|
|
927
|
+
handleMessage(msg);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function handleMessage(msg) {
|
|
932
|
+
// Route shell messages
|
|
933
|
+
if (msg.type && msg.type.startsWith('shell_')) {
|
|
934
|
+
handleShellMessage(msg);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
switch (msg.type) {
|
|
939
|
+
case 'init':
|
|
940
|
+
logs = msg.logs || [];
|
|
941
|
+
agentStatuses = msg.agentStatuses || {};
|
|
942
|
+
if (msg.activeDeployment) updateDeployUI(msg.activeDeployment);
|
|
943
|
+
renderLogs();
|
|
944
|
+
break;
|
|
945
|
+
case 'servers':
|
|
946
|
+
servers = msg.servers;
|
|
947
|
+
updateStats();
|
|
948
|
+
buildGroups();
|
|
949
|
+
renderServers();
|
|
950
|
+
updateTermServerSelect();
|
|
951
|
+
break;
|
|
952
|
+
case 'log':
|
|
953
|
+
logs.push(msg.entry);
|
|
954
|
+
if (logs.length > 500) logs.shift();
|
|
955
|
+
appendLog(msg.entry);
|
|
956
|
+
break;
|
|
957
|
+
case 'agent_status':
|
|
958
|
+
if (msg.status) agentStatuses[msg.serverId] = msg.status;
|
|
959
|
+
else delete agentStatuses[msg.serverId];
|
|
960
|
+
renderServers();
|
|
961
|
+
break;
|
|
962
|
+
case 'deployment':
|
|
963
|
+
updateDeployUI(msg.deployment);
|
|
964
|
+
break;
|
|
965
|
+
case 'exec_result':
|
|
966
|
+
if (msg.server === currentDetailServer) {
|
|
967
|
+
const out = document.getElementById('terminalOutput');
|
|
968
|
+
const text = msg.result.stdout || msg.result.stderr || '(no output)';
|
|
969
|
+
out.textContent = `$ exit ${msg.result.code}\n${text}`;
|
|
970
|
+
out.scrollTop = out.scrollHeight;
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ═══════════════════════════════════════════════════
|
|
977
|
+
// Data
|
|
978
|
+
// ═══════════════════════════════════════════════════
|
|
979
|
+
function updateStats() {
|
|
980
|
+
const online = servers.filter(s => s.status === 'online').length;
|
|
981
|
+
document.getElementById('statTotal').textContent = servers.length;
|
|
982
|
+
document.getElementById('statOnline').textContent = online;
|
|
983
|
+
document.getElementById('statOffline').textContent = servers.length - online;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function buildGroups() {
|
|
987
|
+
groups = {};
|
|
988
|
+
for (const s of servers) {
|
|
989
|
+
const g = s.group || 'default';
|
|
990
|
+
if (!groups[g]) groups[g] = [];
|
|
991
|
+
groups[g].push(s.id);
|
|
992
|
+
}
|
|
993
|
+
renderGroupList();
|
|
994
|
+
updateGroupSelects();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function renderGroupList() {
|
|
998
|
+
const container = document.getElementById('groupList');
|
|
999
|
+
let html = `<div class="group-item ${selectedGroup === '__all' ? 'active' : ''}" data-group="__all" onclick="selectGroup(this,'__all')">
|
|
1000
|
+
All Servers <span class="group-count">${servers.length}</span>
|
|
1001
|
+
</div>`;
|
|
1002
|
+
for (const [name, ids] of Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1003
|
+
const online = ids.filter(id => servers.find(s => s.id === id && s.status === 'online')).length;
|
|
1004
|
+
html += `<div class="group-item ${selectedGroup === name ? 'active' : ''}" data-group="${name}" onclick="selectGroup(this,'${name}')">
|
|
1005
|
+
${name} <span class="group-count">${online}/${ids.length}</span>
|
|
1006
|
+
</div>`;
|
|
1007
|
+
}
|
|
1008
|
+
container.innerHTML = html;
|
|
1009
|
+
document.getElementById('groupCountAll') && (document.getElementById('groupCountAll').textContent = servers.length);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function selectGroup(el, group) {
|
|
1013
|
+
selectedGroup = group;
|
|
1014
|
+
document.querySelectorAll('.group-item').forEach(e => e.classList.remove('active'));
|
|
1015
|
+
el.classList.add('active');
|
|
1016
|
+
renderServers();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function updateGroupSelects() {
|
|
1020
|
+
const selects = ['batchGroup', 'deployGroup'];
|
|
1021
|
+
for (const id of selects) {
|
|
1022
|
+
const sel = document.getElementById(id);
|
|
1023
|
+
if (!sel) continue;
|
|
1024
|
+
sel.innerHTML = '<option value="__all">All Servers</option>';
|
|
1025
|
+
for (const name of Object.keys(groups).sort()) {
|
|
1026
|
+
sel.innerHTML += `<option value="${name}">${name} (${groups[name].length})</option>`;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ═══════════════════════════════════════════════════
|
|
1032
|
+
// Render Servers
|
|
1033
|
+
// ═══════════════════════════════════════════════════
|
|
1034
|
+
function renderServers() {
|
|
1035
|
+
const grid = document.getElementById('serverGrid');
|
|
1036
|
+
const search = document.getElementById('searchInput').value.toLowerCase();
|
|
1037
|
+
let filtered = servers;
|
|
1038
|
+
|
|
1039
|
+
if (selectedGroup !== '__all') {
|
|
1040
|
+
filtered = filtered.filter(s => s.group === selectedGroup);
|
|
1041
|
+
}
|
|
1042
|
+
if (search) {
|
|
1043
|
+
filtered = filtered.filter(s =>
|
|
1044
|
+
s.id.toLowerCase().includes(search) ||
|
|
1045
|
+
s.host.toLowerCase().includes(search) ||
|
|
1046
|
+
(s.group || '').toLowerCase().includes(search) ||
|
|
1047
|
+
(s.hostname || '').toLowerCase().includes(search)
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (!filtered.length) {
|
|
1052
|
+
grid.innerHTML = '<div class="empty-state"><div class="icon">--</div><p>No servers found</p></div>';
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
grid.innerHTML = filtered.map(s => {
|
|
1057
|
+
const agent = agentStatuses[s.id];
|
|
1058
|
+
const cpuClass = s.cpu > 80 ? 'high' : 'cpu';
|
|
1059
|
+
const memClass = s.mem > 80 ? 'high' : 'mem';
|
|
1060
|
+
const diskClass = s.disk > 80 ? 'high' : 'disk';
|
|
1061
|
+
return `<div class="server-card ${s.status}" onclick="openDetail('${s.id}')">
|
|
1062
|
+
${agent ? `<div class="agent-badge" style="color:${agent.color || '#818cf8'}">${agent.text}</div>` : ''}
|
|
1063
|
+
<div class="server-card-header" ${agent ? 'style="margin-top:20px"' : ''}>
|
|
1064
|
+
<div>
|
|
1065
|
+
<div class="server-name">${s.id}</div>
|
|
1066
|
+
<div class="server-host">${s.host}:${s.port} @ ${s.username}</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
<span class="status-badge ${s.status}">${s.status}</span>
|
|
1069
|
+
</div>
|
|
1070
|
+
${s.status === 'online' ? `
|
|
1071
|
+
<div class="server-metrics">
|
|
1072
|
+
<div class="metric">
|
|
1073
|
+
<div class="metric-label">CPU</div>
|
|
1074
|
+
<div class="metric-bar"><div class="metric-bar-fill ${cpuClass}" style="width:${s.cpu}%"></div></div>
|
|
1075
|
+
<div class="metric-value">${s.cpu.toFixed(1)}%</div>
|
|
1076
|
+
</div>
|
|
1077
|
+
<div class="metric">
|
|
1078
|
+
<div class="metric-label">MEM</div>
|
|
1079
|
+
<div class="metric-bar"><div class="metric-bar-fill ${memClass}" style="width:${s.mem}%"></div></div>
|
|
1080
|
+
<div class="metric-value">${s.mem.toFixed(1)}%</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
<div class="metric">
|
|
1083
|
+
<div class="metric-label">DISK</div>
|
|
1084
|
+
<div class="metric-bar"><div class="metric-bar-fill ${diskClass}" style="width:${s.disk}%"></div></div>
|
|
1085
|
+
<div class="metric-value">${s.disk.toFixed(1)}%</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
<div style="margin-top:10px;text-align:right">
|
|
1089
|
+
<button class="btn" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();switchTab('terminal');openNewTerminal('${s.id}')">Open Terminal</button>
|
|
1090
|
+
</div>` : `<div style="color:var(--text2);font-size:12px;margin-top:8px">${s.error || 'Unreachable'}</div>`}
|
|
1091
|
+
</div>`;
|
|
1092
|
+
}).join('');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ═══════════════════════════════════════════════════
|
|
1096
|
+
// Server Detail
|
|
1097
|
+
// ═══════════════════════════════════════════════════
|
|
1098
|
+
async function openDetail(serverId) {
|
|
1099
|
+
currentDetailServer = serverId;
|
|
1100
|
+
const s = servers.find(x => x.id === serverId);
|
|
1101
|
+
document.getElementById('detailTitle').textContent = serverId;
|
|
1102
|
+
document.getElementById('terminalOutput').textContent = 'Ready.';
|
|
1103
|
+
|
|
1104
|
+
document.getElementById('detailInfo').innerHTML = `
|
|
1105
|
+
<div class="info-item"><label>Host</label><span>${s.host}:${s.port}</span></div>
|
|
1106
|
+
<div class="info-item"><label>Username</label><span>${s.username}</span></div>
|
|
1107
|
+
<div class="info-item"><label>Group</label><span>${s.group}</span></div>
|
|
1108
|
+
<div class="info-item"><label>Status</label><span style="color:${s.status==='online'?'var(--green)':'var(--red)'}">${s.status}</span></div>
|
|
1109
|
+
${s.status === 'online' ? `
|
|
1110
|
+
<div class="info-item"><label>Hostname</label><span>${s.hostname || '?'}</span></div>
|
|
1111
|
+
<div class="info-item"><label>OS</label><span>${s.os || '?'}</span></div>
|
|
1112
|
+
<div class="info-item"><label>Kernel</label><span>${s.kernel || '?'}</span></div>
|
|
1113
|
+
<div class="info-item"><label>Uptime</label><span>${s.uptime}</span></div>
|
|
1114
|
+
<div class="info-item"><label>Load</label><span>${s.load}</span></div>
|
|
1115
|
+
<div class="info-item"><label>CPU Cores</label><span>${s.cpuCores || '?'}</span></div>
|
|
1116
|
+
<div class="info-item"><label>Memory</label><span>${s.memUsed || '?'} / ${s.memTotal || '?'}</span></div>
|
|
1117
|
+
<div class="info-item"><label>Disk</label><span>${s.diskUsed || '?'} / ${s.diskTotal || '?'}</span></div>
|
|
1118
|
+
<div class="info-item"><label>IP</label><span>${s.ip || s.host}</span></div>
|
|
1119
|
+
<div class="info-item"><label>Docker</label><span>${s.dockerRunning || '0'} running</span></div>
|
|
1120
|
+
` : ''}
|
|
1121
|
+
`;
|
|
1122
|
+
|
|
1123
|
+
document.getElementById('detailServices').innerHTML = '<span style="color:var(--text2);font-size:13px">Loading...</span>';
|
|
1124
|
+
document.getElementById('detailOverlay').classList.add('show');
|
|
1125
|
+
|
|
1126
|
+
if (s.status === 'online') {
|
|
1127
|
+
try {
|
|
1128
|
+
const resp = await fetch(`${API}/api/server/${serverId}`);
|
|
1129
|
+
const info = await resp.json();
|
|
1130
|
+
if (info.services && Object.keys(info.services).length) {
|
|
1131
|
+
document.getElementById('detailServices').innerHTML = Object.entries(info.services).map(([name, state]) =>
|
|
1132
|
+
`<div class="service-tag">
|
|
1133
|
+
<span class="service-dot ${state}"></span>
|
|
1134
|
+
${name}
|
|
1135
|
+
<div class="service-actions">
|
|
1136
|
+
<button class="svc-btn restart" onclick="event.stopPropagation();svcAction('${serverId}','${name}','restart')">restart</button>
|
|
1137
|
+
<button class="svc-btn stop" onclick="event.stopPropagation();svcAction('${serverId}','${name}','stop')">stop</button>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>`
|
|
1140
|
+
).join('');
|
|
1141
|
+
} else {
|
|
1142
|
+
document.getElementById('detailServices').innerHTML = '<span style="color:var(--text2);font-size:13px">No known services detected</span>';
|
|
1143
|
+
}
|
|
1144
|
+
} catch {
|
|
1145
|
+
document.getElementById('detailServices').innerHTML = '<span style="color:var(--red);font-size:13px">Failed to load</span>';
|
|
1146
|
+
}
|
|
1147
|
+
} else {
|
|
1148
|
+
document.getElementById('detailServices').innerHTML = '<span style="color:var(--text2);font-size:13px">Server offline</span>';
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function closeDetail() {
|
|
1153
|
+
document.getElementById('detailOverlay').classList.remove('show');
|
|
1154
|
+
currentDetailServer = null;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function svcAction(server, service, action) {
|
|
1158
|
+
try {
|
|
1159
|
+
await fetch(`${API}/api/service`, {
|
|
1160
|
+
method: 'POST',
|
|
1161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1162
|
+
body: JSON.stringify({ server, service, action })
|
|
1163
|
+
});
|
|
1164
|
+
// Refresh detail
|
|
1165
|
+
setTimeout(() => openDetail(server), 1000);
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
alert('Failed: ' + e.message);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function execTerminal() {
|
|
1172
|
+
const input = document.getElementById('terminalInput');
|
|
1173
|
+
const cmd = input.value.trim();
|
|
1174
|
+
if (!cmd || !currentDetailServer) return;
|
|
1175
|
+
const out = document.getElementById('terminalOutput');
|
|
1176
|
+
out.textContent = `$ ${cmd}\nExecuting...`;
|
|
1177
|
+
|
|
1178
|
+
if (ws && ws.readyState === 1) {
|
|
1179
|
+
ws.send(JSON.stringify({
|
|
1180
|
+
type: 'exec',
|
|
1181
|
+
server: currentDetailServer,
|
|
1182
|
+
command: cmd,
|
|
1183
|
+
requestId: Date.now().toString()
|
|
1184
|
+
}));
|
|
1185
|
+
} else {
|
|
1186
|
+
fetch(`${API}/api/exec`, {
|
|
1187
|
+
method: 'POST',
|
|
1188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1189
|
+
body: JSON.stringify({ server: currentDetailServer, command: cmd })
|
|
1190
|
+
}).then(r => r.json()).then(r => {
|
|
1191
|
+
out.textContent = `$ ${cmd}\nexit ${r.code}\n${r.stdout || r.stderr || '(no output)'}`;
|
|
1192
|
+
}).catch(e => {
|
|
1193
|
+
out.textContent = `$ ${cmd}\nError: ${e.message}`;
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
input.value = '';
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ═══════════════════════════════════════════════════
|
|
1200
|
+
// Batch Exec
|
|
1201
|
+
// ═══════════════════════════════════════════════════
|
|
1202
|
+
async function execBatch() {
|
|
1203
|
+
const group = document.getElementById('batchGroup').value;
|
|
1204
|
+
const cmd = document.getElementById('batchCmd').value.trim();
|
|
1205
|
+
if (!cmd) return;
|
|
1206
|
+
|
|
1207
|
+
const container = document.getElementById('batchResults');
|
|
1208
|
+
container.innerHTML = '<div style="padding:10px;color:var(--text2)"><span class="spinner"></span> Executing...</div>';
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
const body = group === '__all'
|
|
1212
|
+
? { servers: servers.map(s => s.id), command: cmd }
|
|
1213
|
+
: { group, command: cmd };
|
|
1214
|
+
|
|
1215
|
+
const resp = await fetch(`${API}/api/exec-batch`, {
|
|
1216
|
+
method: 'POST',
|
|
1217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1218
|
+
body: JSON.stringify(body)
|
|
1219
|
+
});
|
|
1220
|
+
const data = await resp.json();
|
|
1221
|
+
|
|
1222
|
+
container.innerHTML = Object.entries(data.results).map(([id, r]) =>
|
|
1223
|
+
`<div class="batch-result-item">
|
|
1224
|
+
<span class="${r.code === 0 ? 'code0' : 'code-fail'}">[${r.code}]</span>
|
|
1225
|
+
<strong>${id}</strong>
|
|
1226
|
+
<span style="color:var(--text2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${(r.stdout || r.stderr || '').substring(0, 120)}</span>
|
|
1227
|
+
</div>`
|
|
1228
|
+
).join('');
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
container.innerHTML = `<div style="padding:10px;color:var(--red)">Error: ${e.message}</div>`;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ═══════════════════════════════════════════════════
|
|
1235
|
+
// Deploy
|
|
1236
|
+
// ═══════════════════════════════════════════════════
|
|
1237
|
+
function updateDeployCanary() {
|
|
1238
|
+
const group = document.getElementById('deployGroup').value;
|
|
1239
|
+
const sel = document.getElementById('deployCanary');
|
|
1240
|
+
let ids = group === '__all' ? servers.map(s => s.id) : (groups[group] || []);
|
|
1241
|
+
sel.innerHTML = ids.map(id => `<option value="${id}">${id}</option>`).join('');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function startDeploy() {
|
|
1245
|
+
const group = document.getElementById('deployGroup').value;
|
|
1246
|
+
const canary = document.getElementById('deployCanary').value;
|
|
1247
|
+
const cmd = document.getElementById('deployCmd').value.trim();
|
|
1248
|
+
const rollback = document.getElementById('deployRollback').value.trim();
|
|
1249
|
+
const check = document.getElementById('deployCheck').value.trim();
|
|
1250
|
+
if (!cmd || !canary) return alert('Please fill deploy command and select canary server');
|
|
1251
|
+
|
|
1252
|
+
const serverIds = group === '__all' ? servers.map(s => s.id) : (groups[group] || []);
|
|
1253
|
+
|
|
1254
|
+
try {
|
|
1255
|
+
await fetch(`${API}/api/deploy`, {
|
|
1256
|
+
method: 'POST',
|
|
1257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1258
|
+
body: JSON.stringify({
|
|
1259
|
+
command: cmd,
|
|
1260
|
+
rollbackCommand: rollback || undefined,
|
|
1261
|
+
canaryServerId: canary,
|
|
1262
|
+
serverIds,
|
|
1263
|
+
postCheck: check || undefined
|
|
1264
|
+
})
|
|
1265
|
+
});
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
alert('Failed to start deployment: ' + e.message);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function abortDeploy() {
|
|
1272
|
+
try {
|
|
1273
|
+
await fetch(`${API}/api/deploy/abort`, { method: 'POST' });
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
alert('Failed: ' + e.message);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function updateDeployUI(d) {
|
|
1280
|
+
const panel = document.getElementById('deployProgress');
|
|
1281
|
+
panel.classList.add('active');
|
|
1282
|
+
document.getElementById('deployPhase').textContent = `Phase: ${d.phase} | Status: ${d.status}`;
|
|
1283
|
+
document.getElementById('deployPct').textContent = `${Math.round(d.progress || 0)}%`;
|
|
1284
|
+
document.getElementById('deployFill').style.width = `${d.progress || 0}%`;
|
|
1285
|
+
if (d.results) {
|
|
1286
|
+
document.getElementById('deployResults').innerHTML = d.results.map(r =>
|
|
1287
|
+
`<div style="color:${r.status === 'ok' ? 'var(--green)' : 'var(--red)'}">[${r.phase}] ${r.server}: ${r.status} ${r.output ? '- ' + r.output.substring(0, 80) : ''}</div>`
|
|
1288
|
+
).join('');
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// ═══════════════════════════════════════════════════
|
|
1293
|
+
// Logs
|
|
1294
|
+
// ═══════════════════════════════════════════════════
|
|
1295
|
+
function renderLogs() {
|
|
1296
|
+
const panel = document.getElementById('logPanel');
|
|
1297
|
+
panel.innerHTML = logs.map(logHTML).join('');
|
|
1298
|
+
panel.scrollTop = panel.scrollHeight;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function appendLog(entry) {
|
|
1302
|
+
const panel = document.getElementById('logPanel');
|
|
1303
|
+
panel.innerHTML += logHTML(entry);
|
|
1304
|
+
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
|
1305
|
+
panel.scrollTop = panel.scrollHeight;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function logHTML(e) {
|
|
1309
|
+
return `<div class="log-entry ${e.level}">
|
|
1310
|
+
<span class="log-ts">${e.ts}</span>
|
|
1311
|
+
<span class="log-source">[${e.source}]</span>
|
|
1312
|
+
<span class="log-msg">${escapeHtml(e.msg)}</span>
|
|
1313
|
+
</div>`;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function clearLogs() {
|
|
1317
|
+
logs = [];
|
|
1318
|
+
document.getElementById('logPanel').innerHTML = '';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function escapeHtml(s) {
|
|
1322
|
+
const div = document.createElement('div');
|
|
1323
|
+
div.textContent = s;
|
|
1324
|
+
return div.innerHTML;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// ═══════════════════════════════════════════════════
|
|
1328
|
+
// Tabs
|
|
1329
|
+
// ═══════════════════════════════════════════════════
|
|
1330
|
+
function switchTab(name) {
|
|
1331
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
|
1332
|
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// ═══════════════════════════════════════════════════
|
|
1336
|
+
// Refresh
|
|
1337
|
+
// ═══════════════════════════════════════════════════
|
|
1338
|
+
async function refreshServers() {
|
|
1339
|
+
try {
|
|
1340
|
+
await fetch(`${API}/api/servers/refresh`, { method: 'POST' });
|
|
1341
|
+
} catch {}
|
|
1342
|
+
try {
|
|
1343
|
+
const resp = await fetch(`${API}/api/servers`);
|
|
1344
|
+
const data = await resp.json();
|
|
1345
|
+
if (data.servers) {
|
|
1346
|
+
servers = data.servers;
|
|
1347
|
+
updateStats();
|
|
1348
|
+
buildGroups();
|
|
1349
|
+
renderServers();
|
|
1350
|
+
}
|
|
1351
|
+
} catch {}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ═══════════════════════════════════════════════════
|
|
1355
|
+
// Server Management
|
|
1356
|
+
// ═══════════════════════════════════════════════════
|
|
1357
|
+
let configServers = [];
|
|
1358
|
+
|
|
1359
|
+
async function importFromFile() {
|
|
1360
|
+
const span = document.getElementById('importFileResult');
|
|
1361
|
+
span.textContent = 'Importing...';
|
|
1362
|
+
span.style.color = 'var(--text2)';
|
|
1363
|
+
try {
|
|
1364
|
+
const resp = await fetch(`${API}/api/servers/import-file`, { method: 'POST' });
|
|
1365
|
+
const data = await resp.json();
|
|
1366
|
+
if (data.ok) {
|
|
1367
|
+
span.textContent = `Imported ${data.count} servers!`;
|
|
1368
|
+
span.style.color = 'var(--green)';
|
|
1369
|
+
loadConfigList();
|
|
1370
|
+
setTimeout(refreshServers, 2000);
|
|
1371
|
+
} else {
|
|
1372
|
+
span.textContent = `Error: ${data.error}`;
|
|
1373
|
+
span.style.color = 'var(--red)';
|
|
1374
|
+
}
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
span.textContent = `Failed: ${e.message}`;
|
|
1377
|
+
span.style.color = 'var(--red)';
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function importCSV() {
|
|
1382
|
+
const csv = document.getElementById('csvImport').value.trim();
|
|
1383
|
+
if (!csv) return;
|
|
1384
|
+
const span = document.getElementById('importCsvResult');
|
|
1385
|
+
span.textContent = 'Importing...';
|
|
1386
|
+
span.style.color = 'var(--text2)';
|
|
1387
|
+
try {
|
|
1388
|
+
const resp = await fetch(`${API}/api/servers/import`, {
|
|
1389
|
+
method: 'POST',
|
|
1390
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1391
|
+
body: JSON.stringify({ csv })
|
|
1392
|
+
});
|
|
1393
|
+
const data = await resp.json();
|
|
1394
|
+
if (data.ok) {
|
|
1395
|
+
span.textContent = `Imported ${data.count} servers!`;
|
|
1396
|
+
span.style.color = 'var(--green)';
|
|
1397
|
+
document.getElementById('csvImport').value = '';
|
|
1398
|
+
loadConfigList();
|
|
1399
|
+
setTimeout(refreshServers, 2000);
|
|
1400
|
+
} else {
|
|
1401
|
+
span.textContent = `Error: ${data.error}`;
|
|
1402
|
+
span.style.color = 'var(--red)';
|
|
1403
|
+
}
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
span.textContent = `Failed: ${e.message}`;
|
|
1406
|
+
span.style.color = 'var(--red)';
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function addServer() {
|
|
1411
|
+
const id = document.getElementById('addName').value.trim();
|
|
1412
|
+
const host = document.getElementById('addHost').value.trim();
|
|
1413
|
+
const port = parseInt(document.getElementById('addPort').value) || 22;
|
|
1414
|
+
const username = document.getElementById('addUser').value.trim() || 'root';
|
|
1415
|
+
const password = document.getElementById('addPass').value;
|
|
1416
|
+
const group = document.getElementById('addGroup').value.trim() || 'default';
|
|
1417
|
+
const span = document.getElementById('addResult');
|
|
1418
|
+
|
|
1419
|
+
if (!id || !host) {
|
|
1420
|
+
span.textContent = 'Name and Host are required';
|
|
1421
|
+
span.style.color = 'var(--red)';
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
try {
|
|
1426
|
+
const resp = await fetch(`${API}/api/server/add`, {
|
|
1427
|
+
method: 'POST',
|
|
1428
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1429
|
+
body: JSON.stringify({ id, host, port, username, password, group })
|
|
1430
|
+
});
|
|
1431
|
+
const data = await resp.json();
|
|
1432
|
+
if (data.ok) {
|
|
1433
|
+
span.textContent = `Server "${id}" added!`;
|
|
1434
|
+
span.style.color = 'var(--green)';
|
|
1435
|
+
document.getElementById('addName').value = '';
|
|
1436
|
+
document.getElementById('addHost').value = '';
|
|
1437
|
+
loadConfigList();
|
|
1438
|
+
setTimeout(refreshServers, 1000);
|
|
1439
|
+
} else {
|
|
1440
|
+
span.textContent = `Error: ${data.error}`;
|
|
1441
|
+
span.style.color = 'var(--red)';
|
|
1442
|
+
}
|
|
1443
|
+
} catch (e) {
|
|
1444
|
+
span.textContent = `Failed: ${e.message}`;
|
|
1445
|
+
span.style.color = 'var(--red)';
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function removeServer(id) {
|
|
1450
|
+
if (!confirm(`Remove server "${id}"?`)) return;
|
|
1451
|
+
try {
|
|
1452
|
+
await fetch(`${API}/api/server/remove`, {
|
|
1453
|
+
method: 'POST',
|
|
1454
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1455
|
+
body: JSON.stringify({ id })
|
|
1456
|
+
});
|
|
1457
|
+
loadConfigList();
|
|
1458
|
+
setTimeout(refreshServers, 1000);
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
alert('Failed: ' + e.message);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function clearAllServers() {
|
|
1465
|
+
if (!confirm('Are you sure you want to remove ALL servers from config?')) return;
|
|
1466
|
+
if (!confirm('This cannot be undone. Continue?')) return;
|
|
1467
|
+
try {
|
|
1468
|
+
const resp = await fetch(`${API}/api/servers/clear`, { method: 'POST' });
|
|
1469
|
+
const data = await resp.json();
|
|
1470
|
+
if (data.ok) {
|
|
1471
|
+
loadConfigList();
|
|
1472
|
+
refreshServers();
|
|
1473
|
+
}
|
|
1474
|
+
} catch (e) {
|
|
1475
|
+
alert('Failed: ' + e.message);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function loadConfigList() {
|
|
1480
|
+
try {
|
|
1481
|
+
const resp = await fetch(`${API}/api/servers/list-config`);
|
|
1482
|
+
const data = await resp.json();
|
|
1483
|
+
configServers = data.servers || [];
|
|
1484
|
+
document.getElementById('configCount').textContent = data.total || 0;
|
|
1485
|
+
renderConfigList();
|
|
1486
|
+
} catch { }
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function filterConfigList() {
|
|
1490
|
+
renderConfigList();
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function renderConfigList() {
|
|
1494
|
+
const container = document.getElementById('configList');
|
|
1495
|
+
const search = (document.getElementById('configSearch')?.value || '').toLowerCase();
|
|
1496
|
+
let filtered = configServers;
|
|
1497
|
+
if (search) {
|
|
1498
|
+
filtered = filtered.filter(s =>
|
|
1499
|
+
s.id.toLowerCase().includes(search) ||
|
|
1500
|
+
s.host.toLowerCase().includes(search) ||
|
|
1501
|
+
(s.group || '').toLowerCase().includes(search)
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
if (!filtered.length) {
|
|
1505
|
+
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text2)">No servers configured</div>';
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
container.innerHTML = filtered.map(s =>
|
|
1509
|
+
`<div class="batch-result-item" style="justify-content:space-between">
|
|
1510
|
+
<div style="display:flex;gap:12px;align-items:center;flex:1;min-width:0">
|
|
1511
|
+
<strong style="min-width:200px">${s.id}</strong>
|
|
1512
|
+
<span style="color:var(--text2)">${s.host}:${s.port}</span>
|
|
1513
|
+
<span style="color:var(--text2)">@${s.username}</span>
|
|
1514
|
+
<span style="color:var(--accent);font-size:12px">${s.group}</span>
|
|
1515
|
+
</div>
|
|
1516
|
+
<button class="btn danger" style="padding:3px 10px;font-size:11px" onclick="removeServer('${s.id}')">Remove</button>
|
|
1517
|
+
</div>`
|
|
1518
|
+
).join('');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ═══════════════════════════════════════════════════
|
|
1522
|
+
// Interactive Terminal (xterm.js + SSH shell)
|
|
1523
|
+
// ═══════════════════════════════════════════════════
|
|
1524
|
+
const termSessions = new Map(); // sessionId -> { term, fitAddon, serverId, element }
|
|
1525
|
+
let activeTermSession = null;
|
|
1526
|
+
|
|
1527
|
+
function updateTermServerSelect() {
|
|
1528
|
+
const sel = document.getElementById('termServerSelect');
|
|
1529
|
+
const current = sel.value;
|
|
1530
|
+
sel.innerHTML = '<option value="">-- Select Server --</option>';
|
|
1531
|
+
for (const s of servers) {
|
|
1532
|
+
sel.innerHTML += `<option value="${s.id}" ${s.status !== 'online' ? 'disabled' : ''}>${s.id} (${s.host}) ${s.status !== 'online' ? '[offline]' : ''}</option>`;
|
|
1533
|
+
}
|
|
1534
|
+
if (current) sel.value = current;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function openNewTerminal(serverId) {
|
|
1538
|
+
if (!serverId) {
|
|
1539
|
+
serverId = document.getElementById('termServerSelect').value;
|
|
1540
|
+
}
|
|
1541
|
+
if (!serverId) return;
|
|
1542
|
+
|
|
1543
|
+
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1544
|
+
|
|
1545
|
+
// Create xterm instance
|
|
1546
|
+
const term = new Terminal({
|
|
1547
|
+
cursorBlink: true,
|
|
1548
|
+
fontSize: 14,
|
|
1549
|
+
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
|
|
1550
|
+
theme: {
|
|
1551
|
+
background: '#0a0e17',
|
|
1552
|
+
foreground: '#e2e8f0',
|
|
1553
|
+
cursor: '#38bdf8',
|
|
1554
|
+
cursorAccent: '#0a0e17',
|
|
1555
|
+
selectionBackground: 'rgba(56,189,248,0.3)',
|
|
1556
|
+
black: '#1a2236',
|
|
1557
|
+
red: '#ef4444',
|
|
1558
|
+
green: '#22c55e',
|
|
1559
|
+
yellow: '#f59e0b',
|
|
1560
|
+
blue: '#38bdf8',
|
|
1561
|
+
magenta: '#f472b6',
|
|
1562
|
+
cyan: '#22d3ee',
|
|
1563
|
+
white: '#e2e8f0',
|
|
1564
|
+
brightBlack: '#475569',
|
|
1565
|
+
brightRed: '#f87171',
|
|
1566
|
+
brightGreen: '#4ade80',
|
|
1567
|
+
brightYellow: '#fbbf24',
|
|
1568
|
+
brightBlue: '#60a5fa',
|
|
1569
|
+
brightMagenta: '#f9a8d4',
|
|
1570
|
+
brightCyan: '#67e8f9',
|
|
1571
|
+
brightWhite: '#f8fafc',
|
|
1572
|
+
},
|
|
1573
|
+
scrollback: 5000,
|
|
1574
|
+
allowProposedApi: true,
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
1578
|
+
term.loadAddon(fitAddon);
|
|
1579
|
+
|
|
1580
|
+
try {
|
|
1581
|
+
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
1582
|
+
term.loadAddon(webLinksAddon);
|
|
1583
|
+
} catch {}
|
|
1584
|
+
|
|
1585
|
+
// Create terminal DOM element
|
|
1586
|
+
const termEl = document.createElement('div');
|
|
1587
|
+
termEl.id = `term-${sessionId}`;
|
|
1588
|
+
termEl.style.cssText = 'width:100%;height:100%;display:none;';
|
|
1589
|
+
document.getElementById('termContainer').appendChild(termEl);
|
|
1590
|
+
|
|
1591
|
+
term.open(termEl);
|
|
1592
|
+
|
|
1593
|
+
// Store session
|
|
1594
|
+
const session = { term, fitAddon, serverId, element: termEl, connected: false };
|
|
1595
|
+
termSessions.set(sessionId, session);
|
|
1596
|
+
|
|
1597
|
+
// Add tab
|
|
1598
|
+
const tabBar = document.getElementById('termTabBar');
|
|
1599
|
+
const newBtn = tabBar.querySelector('.term-new-btn');
|
|
1600
|
+
const tab = document.createElement('div');
|
|
1601
|
+
tab.className = 'term-tab';
|
|
1602
|
+
tab.dataset.session = sessionId;
|
|
1603
|
+
tab.innerHTML = `
|
|
1604
|
+
<span class="term-tab-dot"></span>
|
|
1605
|
+
<span class="term-tab-label">${serverId}</span>
|
|
1606
|
+
<button class="term-tab-close" onclick="event.stopPropagation();closeTerminal('${sessionId}')">×</button>
|
|
1607
|
+
`;
|
|
1608
|
+
tab.addEventListener('click', () => switchTermSession(sessionId));
|
|
1609
|
+
tabBar.insertBefore(tab, newBtn);
|
|
1610
|
+
|
|
1611
|
+
// Switch to this session
|
|
1612
|
+
switchTermSession(sessionId);
|
|
1613
|
+
|
|
1614
|
+
// Fit after visible
|
|
1615
|
+
setTimeout(() => {
|
|
1616
|
+
fitAddon.fit();
|
|
1617
|
+
|
|
1618
|
+
// Connect SSH shell via WebSocket
|
|
1619
|
+
const cols = term.cols;
|
|
1620
|
+
const rows = term.rows;
|
|
1621
|
+
|
|
1622
|
+
if (ws && ws.readyState === 1) {
|
|
1623
|
+
ws.send(JSON.stringify({
|
|
1624
|
+
type: 'shell_open',
|
|
1625
|
+
server: serverId,
|
|
1626
|
+
sessionId,
|
|
1627
|
+
cols,
|
|
1628
|
+
rows
|
|
1629
|
+
}));
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Handle terminal input -> send to server
|
|
1633
|
+
term.onData((data) => {
|
|
1634
|
+
if (ws && ws.readyState === 1 && session.connected) {
|
|
1635
|
+
// Encode string as UTF-8 bytes then base64
|
|
1636
|
+
const encoder = new TextEncoder();
|
|
1637
|
+
const bytes = encoder.encode(data);
|
|
1638
|
+
let binary = '';
|
|
1639
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
1640
|
+
ws.send(JSON.stringify({
|
|
1641
|
+
type: 'shell_input',
|
|
1642
|
+
sessionId,
|
|
1643
|
+
data: btoa(binary)
|
|
1644
|
+
}));
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
// Handle resize
|
|
1649
|
+
term.onResize(({ cols, rows }) => {
|
|
1650
|
+
if (ws && ws.readyState === 1 && session.connected) {
|
|
1651
|
+
ws.send(JSON.stringify({
|
|
1652
|
+
type: 'shell_resize',
|
|
1653
|
+
sessionId,
|
|
1654
|
+
cols,
|
|
1655
|
+
rows
|
|
1656
|
+
}));
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
}, 50);
|
|
1660
|
+
|
|
1661
|
+
// Hide connect panel
|
|
1662
|
+
document.getElementById('termConnectPanel').style.display = 'none';
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function switchTermSession(sessionId) {
|
|
1666
|
+
// Hide all term elements
|
|
1667
|
+
for (const [id, s] of termSessions) {
|
|
1668
|
+
s.element.style.display = 'none';
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Deactivate all tabs
|
|
1672
|
+
document.querySelectorAll('.term-tab').forEach(t => t.classList.remove('active'));
|
|
1673
|
+
|
|
1674
|
+
// Show selected
|
|
1675
|
+
const session = termSessions.get(sessionId);
|
|
1676
|
+
if (session) {
|
|
1677
|
+
session.element.style.display = 'block';
|
|
1678
|
+
activeTermSession = sessionId;
|
|
1679
|
+
|
|
1680
|
+
// Activate tab
|
|
1681
|
+
const tab = document.querySelector(`.term-tab[data-session="${sessionId}"]`);
|
|
1682
|
+
if (tab) tab.classList.add('active');
|
|
1683
|
+
|
|
1684
|
+
// Update status bar
|
|
1685
|
+
const s = servers.find(x => x.id === session.serverId);
|
|
1686
|
+
document.getElementById('termStatusLeft').textContent =
|
|
1687
|
+
`${session.serverId} (${s ? s.host : '?'}) — ${session.connected ? 'Connected' : 'Connecting...'}`;
|
|
1688
|
+
document.getElementById('termStatusRight').textContent =
|
|
1689
|
+
`${session.term.cols}x${session.term.rows}`;
|
|
1690
|
+
|
|
1691
|
+
// Re-fit and focus
|
|
1692
|
+
setTimeout(() => {
|
|
1693
|
+
session.fitAddon.fit();
|
|
1694
|
+
session.term.focus();
|
|
1695
|
+
}, 10);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function closeTerminal(sessionId) {
|
|
1700
|
+
const session = termSessions.get(sessionId);
|
|
1701
|
+
if (!session) return;
|
|
1702
|
+
|
|
1703
|
+
// Send close to server
|
|
1704
|
+
if (ws && ws.readyState === 1) {
|
|
1705
|
+
ws.send(JSON.stringify({ type: 'shell_close', sessionId }));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Dispose xterm
|
|
1709
|
+
session.term.dispose();
|
|
1710
|
+
session.element.remove();
|
|
1711
|
+
|
|
1712
|
+
// Remove tab
|
|
1713
|
+
const tab = document.querySelector(`.term-tab[data-session="${sessionId}"]`);
|
|
1714
|
+
if (tab) tab.remove();
|
|
1715
|
+
|
|
1716
|
+
termSessions.delete(sessionId);
|
|
1717
|
+
|
|
1718
|
+
// Switch to another session or show connect panel
|
|
1719
|
+
if (termSessions.size > 0) {
|
|
1720
|
+
const nextId = termSessions.keys().next().value;
|
|
1721
|
+
switchTermSession(nextId);
|
|
1722
|
+
} else {
|
|
1723
|
+
activeTermSession = null;
|
|
1724
|
+
document.getElementById('termConnectPanel').style.display = '';
|
|
1725
|
+
document.getElementById('termStatusLeft').textContent = 'No active session';
|
|
1726
|
+
document.getElementById('termStatusRight').textContent = '';
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Handle shell messages from WebSocket
|
|
1731
|
+
function handleShellMessage(msg) {
|
|
1732
|
+
if (msg.type === 'shell_opened') {
|
|
1733
|
+
const session = termSessions.get(msg.sessionId);
|
|
1734
|
+
if (session) {
|
|
1735
|
+
session.connected = true;
|
|
1736
|
+
const tab = document.querySelector(`.term-tab[data-session="${msg.sessionId}"]`);
|
|
1737
|
+
if (tab) tab.querySelector('.term-tab-dot').style.background = 'var(--green)';
|
|
1738
|
+
if (activeTermSession === msg.sessionId) {
|
|
1739
|
+
const s = servers.find(x => x.id === session.serverId);
|
|
1740
|
+
document.getElementById('termStatusLeft').textContent =
|
|
1741
|
+
`${session.serverId} (${s ? s.host : '?'}) — Connected`;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
if (msg.type === 'shell_data') {
|
|
1747
|
+
const session = termSessions.get(msg.sessionId);
|
|
1748
|
+
if (session) {
|
|
1749
|
+
// Decode base64 to binary string, then to Uint8Array for proper UTF-8 handling
|
|
1750
|
+
const binaryStr = atob(msg.data);
|
|
1751
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
1752
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
1753
|
+
session.term.write(bytes);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (msg.type === 'shell_closed') {
|
|
1758
|
+
const session = termSessions.get(msg.sessionId);
|
|
1759
|
+
if (session) {
|
|
1760
|
+
session.connected = false;
|
|
1761
|
+
session.term.write('\r\n\x1b[31m--- Session closed ---\x1b[0m\r\n');
|
|
1762
|
+
const tab = document.querySelector(`.term-tab[data-session="${msg.sessionId}"]`);
|
|
1763
|
+
if (tab) tab.querySelector('.term-tab-dot').style.background = 'var(--red)';
|
|
1764
|
+
if (activeTermSession === msg.sessionId) {
|
|
1765
|
+
document.getElementById('termStatusLeft').textContent =
|
|
1766
|
+
`${session.serverId} — Disconnected`;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (msg.type === 'shell_error') {
|
|
1772
|
+
const session = termSessions.get(msg.sessionId);
|
|
1773
|
+
if (session) {
|
|
1774
|
+
session.term.write(`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Window resize -> refit active terminal
|
|
1780
|
+
window.addEventListener('resize', () => {
|
|
1781
|
+
if (activeTermSession) {
|
|
1782
|
+
const session = termSessions.get(activeTermSession);
|
|
1783
|
+
if (session) {
|
|
1784
|
+
session.fitAddon.fit();
|
|
1785
|
+
document.getElementById('termStatusRight').textContent =
|
|
1786
|
+
`${session.term.cols}x${session.term.rows}`;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
// ═══════════════════════════════════════════════════
|
|
1792
|
+
// Init
|
|
1793
|
+
// ═══════════════════════════════════════════════════
|
|
1794
|
+
document.getElementById('deployGroup').addEventListener('change', updateDeployCanary);
|
|
1795
|
+
|
|
1796
|
+
connectWS();
|
|
1797
|
+
refreshServers();
|
|
1798
|
+
loadConfigList();
|
|
1799
|
+
|
|
1800
|
+
// Keyboard: Escape closes detail
|
|
1801
|
+
document.addEventListener('keydown', (e) => {
|
|
1802
|
+
if (e.key === 'Escape') closeDetail();
|
|
1803
|
+
});
|
|
1804
|
+
</script>
|
|
1805
|
+
</body>
|
|
1806
|
+
</html>
|