@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,799 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* RemoteX Dashboard Server
|
|
4
|
+
*
|
|
5
|
+
* WebSocket + HTTP server that bridges the dashboard UI with the SSH bridge.
|
|
6
|
+
* Provides:
|
|
7
|
+
* - Real-time server status streaming
|
|
8
|
+
* - Canary deployment engine (precheck → canary → wave → verify → rollback)
|
|
9
|
+
* - Claude Code activity event bus
|
|
10
|
+
* - REST API for dashboard
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node src/dashboard-server.js [--port 7700]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer } from 'http';
|
|
17
|
+
import { WebSocketServer } from 'ws';
|
|
18
|
+
import { readFileSync } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import * as bridge from './bridge.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const PORT = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--port') || '7700');
|
|
25
|
+
|
|
26
|
+
// ═══════════════════════════════════════════════════
|
|
27
|
+
// Event Bus — all dashboard events flow through here
|
|
28
|
+
// ═══════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
class EventBus {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.clients = new Set();
|
|
33
|
+
this.logs = [];
|
|
34
|
+
this.maxLogs = 500;
|
|
35
|
+
this.agentStatuses = {}; // serverId -> { text, color, timestamp }
|
|
36
|
+
this.deployments = []; // deployment history
|
|
37
|
+
this.activeDeployment = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
addClient(ws) {
|
|
41
|
+
this.clients.add(ws);
|
|
42
|
+
// Send initial state
|
|
43
|
+
ws.send(JSON.stringify({
|
|
44
|
+
type: 'init',
|
|
45
|
+
logs: this.logs.slice(-100),
|
|
46
|
+
agentStatuses: this.agentStatuses,
|
|
47
|
+
activeDeployment: this.activeDeployment,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
removeClient(ws) { this.clients.delete(ws); }
|
|
52
|
+
|
|
53
|
+
broadcast(msg) {
|
|
54
|
+
const data = JSON.stringify(msg);
|
|
55
|
+
for (const ws of this.clients) {
|
|
56
|
+
if (ws.readyState === 1) ws.send(data);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
log(level, source, msg) {
|
|
61
|
+
const entry = {
|
|
62
|
+
ts: new Date().toLocaleTimeString('en-US', { hour12: false }),
|
|
63
|
+
level, source, msg,
|
|
64
|
+
};
|
|
65
|
+
this.logs.push(entry);
|
|
66
|
+
if (this.logs.length > this.maxLogs) this.logs.shift();
|
|
67
|
+
this.broadcast({ type: 'log', entry });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setAgentStatus(serverId, text, color = '#ff71ce') {
|
|
71
|
+
if (text) {
|
|
72
|
+
this.agentStatuses[serverId] = { text, color, timestamp: Date.now() };
|
|
73
|
+
} else {
|
|
74
|
+
delete this.agentStatuses[serverId];
|
|
75
|
+
}
|
|
76
|
+
this.broadcast({ type: 'agent_status', serverId, status: this.agentStatuses[serverId] || null });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
updateDeployment(deployment) {
|
|
80
|
+
this.activeDeployment = deployment;
|
|
81
|
+
this.broadcast({ type: 'deployment', deployment });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const bus = new EventBus();
|
|
86
|
+
|
|
87
|
+
// ═══════════════════════════════════════════════════
|
|
88
|
+
// Server Status Poller
|
|
89
|
+
// ═══════════════════════════════════════════════════
|
|
90
|
+
|
|
91
|
+
let cachedServerData = [];
|
|
92
|
+
|
|
93
|
+
async function pollServerStatus() {
|
|
94
|
+
try {
|
|
95
|
+
const config = bridge.loadConfig();
|
|
96
|
+
const serverIds = Object.keys(config.servers);
|
|
97
|
+
if (!serverIds.length) return;
|
|
98
|
+
|
|
99
|
+
const results = [];
|
|
100
|
+
const batchSize = 10;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < serverIds.length; i += batchSize) {
|
|
103
|
+
const chunk = serverIds.slice(i, i + batchSize);
|
|
104
|
+
const promises = chunk.map(async (id) => {
|
|
105
|
+
const srv = config.servers[id];
|
|
106
|
+
try {
|
|
107
|
+
const info = await bridge.getServerInfo(id);
|
|
108
|
+
return {
|
|
109
|
+
id, host: srv.host, port: srv.port || 22,
|
|
110
|
+
username: srv.username || 'root',
|
|
111
|
+
group: srv.group || 'default',
|
|
112
|
+
status: 'online',
|
|
113
|
+
cpu: parseFloat(info.cpu_usage) || 0,
|
|
114
|
+
mem: parseFloat(info.mem_percent) || 0,
|
|
115
|
+
disk: parseFloat(info.disk_percent) || 0,
|
|
116
|
+
load: info.load || '0',
|
|
117
|
+
uptime: info.uptime || '?',
|
|
118
|
+
ports: info.listening_ports || '',
|
|
119
|
+
hostname: info.hostname || id,
|
|
120
|
+
os: info.os || '',
|
|
121
|
+
kernel: info.kernel || '',
|
|
122
|
+
ip: info.ip || srv.host,
|
|
123
|
+
memTotal: info.mem_total || '0',
|
|
124
|
+
memUsed: info.mem_used || '0',
|
|
125
|
+
diskTotal: info.disk_total || '0',
|
|
126
|
+
diskUsed: info.disk_used || '0',
|
|
127
|
+
cpuCores: info.cpu_cores || '?',
|
|
128
|
+
dockerRunning: info.docker_running || '0',
|
|
129
|
+
};
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
id, host: srv.host, port: srv.port || 22,
|
|
133
|
+
username: srv.username || 'root',
|
|
134
|
+
group: srv.group || 'default',
|
|
135
|
+
status: 'offline',
|
|
136
|
+
cpu: 0, mem: 0, disk: 0, load: '0', uptime: '?',
|
|
137
|
+
ports: '', error: err.message,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
results.push(...await Promise.all(promises));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cachedServerData = results;
|
|
145
|
+
bus.broadcast({ type: 'servers', servers: results });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
bus.log('error', 'poller', `Status poll failed: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Service discovery for each server
|
|
152
|
+
async function getServerServices(serverId) {
|
|
153
|
+
try {
|
|
154
|
+
const r = await bridge.execCommand(serverId,
|
|
155
|
+
"systemctl list-units --type=service --state=active,failed --no-pager --no-legend | awk '{print $1, $3}' | head -30",
|
|
156
|
+
10000
|
|
157
|
+
);
|
|
158
|
+
const services = {};
|
|
159
|
+
for (const line of r.stdout.split('\n').filter(Boolean)) {
|
|
160
|
+
const [name, sub] = line.split(/\s+/);
|
|
161
|
+
const svcName = name.replace('.service', '');
|
|
162
|
+
// Filter to interesting services only
|
|
163
|
+
const keep = ['nginx', 'apache2', 'httpd', 'mysql', 'mysqld', 'mariadb',
|
|
164
|
+
'postgresql', 'redis', 'redis-server', 'docker', 'sshd', 'named', 'bind9',
|
|
165
|
+
'prometheus', 'grafana-server', 'node_exporter', 'haproxy', 'keepalived',
|
|
166
|
+
'smsc', 'elasticsearch', 'kibana', 'logstash', 'mongod', 'rabbitmq-server',
|
|
167
|
+
'php-fpm', 'supervisord', 'crond', 'cron', 'firewalld', 'ufw'];
|
|
168
|
+
if (keep.includes(svcName)) {
|
|
169
|
+
services[svcName] = sub === 'running' ? 'active' : 'failed';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return services;
|
|
173
|
+
} catch {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ═══════════════════════════════════════════════════
|
|
179
|
+
// Canary Deployment Engine
|
|
180
|
+
// ═══════════════════════════════════════════════════
|
|
181
|
+
|
|
182
|
+
class CanaryDeployer {
|
|
183
|
+
constructor(eventBus) {
|
|
184
|
+
this.bus = eventBus;
|
|
185
|
+
this.aborted = false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async deploy(opts) {
|
|
189
|
+
const { command, rollbackCommand, canaryServerId, serverIds, waveSizes, preCheck, postCheck } = opts;
|
|
190
|
+
|
|
191
|
+
this.aborted = false;
|
|
192
|
+
const deployment = {
|
|
193
|
+
id: Date.now().toString(36),
|
|
194
|
+
command, rollbackCommand,
|
|
195
|
+
canaryServerId,
|
|
196
|
+
serverIds,
|
|
197
|
+
phase: 'precheck',
|
|
198
|
+
progress: 0,
|
|
199
|
+
results: [],
|
|
200
|
+
startedAt: Date.now(),
|
|
201
|
+
status: 'running',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
this.bus.updateDeployment(deployment);
|
|
205
|
+
this.bus.log('info', 'canary', `Starting canary deployment: ${command}`);
|
|
206
|
+
this.bus.log('info', 'canary', `Canary: ${canaryServerId} | Fleet: ${serverIds.length} servers`);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// ── Phase 1: Pre-Check ──
|
|
210
|
+
deployment.phase = 'precheck';
|
|
211
|
+
this.bus.updateDeployment(deployment);
|
|
212
|
+
this.bus.log('info', 'precheck', 'Running pre-flight checks...');
|
|
213
|
+
|
|
214
|
+
const preCheckCmd = preCheck || 'echo "ready"';
|
|
215
|
+
const allIds = [canaryServerId, ...serverIds.filter(id => id !== canaryServerId)];
|
|
216
|
+
|
|
217
|
+
for (const id of allIds) {
|
|
218
|
+
if (this.aborted) throw new Error('Aborted by user');
|
|
219
|
+
this.bus.setAgentStatus(id, '🔍 Pre-checking');
|
|
220
|
+
try {
|
|
221
|
+
const r = await bridge.execCommand(id, preCheckCmd, 15000);
|
|
222
|
+
deployment.results.push({ phase: 'precheck', server: id, status: r.code === 0 ? 'ok' : 'fail', output: r.stdout || r.stderr });
|
|
223
|
+
if (r.code !== 0) {
|
|
224
|
+
this.bus.log('error', 'precheck', `Pre-check FAILED on ${id}: ${r.stderr}`);
|
|
225
|
+
throw new Error(`Pre-check failed on ${id}`);
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err.message.includes('Aborted')) throw err;
|
|
229
|
+
deployment.results.push({ phase: 'precheck', server: id, status: 'fail', output: err.message });
|
|
230
|
+
this.bus.log('error', 'precheck', `Cannot reach ${id}: ${err.message}`);
|
|
231
|
+
throw new Error(`Pre-check failed: ${id} unreachable`);
|
|
232
|
+
} finally {
|
|
233
|
+
this.bus.setAgentStatus(id, null);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
deployment.progress = 20;
|
|
238
|
+
this.bus.updateDeployment(deployment);
|
|
239
|
+
this.bus.log('ok', 'precheck', `Pre-check passed on ${allIds.length} servers`);
|
|
240
|
+
|
|
241
|
+
// ── Phase 2: Canary ──
|
|
242
|
+
deployment.phase = 'canary';
|
|
243
|
+
this.bus.updateDeployment(deployment);
|
|
244
|
+
this.bus.log('info', 'canary', `Deploying to canary: ${canaryServerId}`);
|
|
245
|
+
this.bus.setAgentStatus(canaryServerId, '🐤 Canary deploying', '#ff9f43');
|
|
246
|
+
|
|
247
|
+
const canaryResult = await bridge.execCommand(canaryServerId, command, 60000);
|
|
248
|
+
deployment.results.push({ phase: 'canary', server: canaryServerId, status: canaryResult.code === 0 ? 'ok' : 'fail', output: canaryResult.stdout || canaryResult.stderr });
|
|
249
|
+
this.bus.setAgentStatus(canaryServerId, null);
|
|
250
|
+
|
|
251
|
+
if (canaryResult.code !== 0) {
|
|
252
|
+
this.bus.log('error', 'canary', `Canary FAILED on ${canaryServerId}: ${canaryResult.stderr}`);
|
|
253
|
+
throw new Error(`Canary failed on ${canaryServerId}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Canary verification
|
|
257
|
+
this.bus.log('info', 'canary', 'Verifying canary...');
|
|
258
|
+
this.bus.setAgentStatus(canaryServerId, '🔍 Verifying canary');
|
|
259
|
+
const verifyCmd = postCheck || 'echo "ok"';
|
|
260
|
+
const verifyResult = await bridge.execCommand(canaryServerId, verifyCmd, 15000);
|
|
261
|
+
this.bus.setAgentStatus(canaryServerId, null);
|
|
262
|
+
|
|
263
|
+
if (verifyResult.code !== 0) {
|
|
264
|
+
this.bus.log('error', 'canary', `Canary verification FAILED — rolling back`);
|
|
265
|
+
if (rollbackCommand) {
|
|
266
|
+
await bridge.execCommand(canaryServerId, rollbackCommand, 30000);
|
|
267
|
+
this.bus.log('warn', 'rollback', `Rolled back canary: ${canaryServerId}`);
|
|
268
|
+
}
|
|
269
|
+
throw new Error('Canary verification failed');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
deployment.progress = 40;
|
|
273
|
+
this.bus.updateDeployment(deployment);
|
|
274
|
+
this.bus.log('ok', 'canary', `Canary passed on ${canaryServerId}`);
|
|
275
|
+
|
|
276
|
+
// ── Phase 3: Wave Deployment ──
|
|
277
|
+
const remaining = serverIds.filter(id => id !== canaryServerId);
|
|
278
|
+
const waveSize = waveSizes || Math.max(1, Math.ceil(remaining.length / 3));
|
|
279
|
+
const waves = [];
|
|
280
|
+
for (let i = 0; i < remaining.length; i += waveSize) {
|
|
281
|
+
waves.push(remaining.slice(i, i + waveSize));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (let wi = 0; wi < waves.length; wi++) {
|
|
285
|
+
if (this.aborted) throw new Error('Aborted by user');
|
|
286
|
+
|
|
287
|
+
const wave = waves[wi];
|
|
288
|
+
deployment.phase = `wave${wi + 1}`;
|
|
289
|
+
this.bus.updateDeployment(deployment);
|
|
290
|
+
this.bus.log('info', `wave${wi + 1}`, `Deploying wave ${wi + 1}/${waves.length}: ${wave.join(', ')}`);
|
|
291
|
+
|
|
292
|
+
// Set agent status on all wave servers
|
|
293
|
+
for (const id of wave) {
|
|
294
|
+
this.bus.setAgentStatus(id, `🌊 Wave ${wi + 1}`, '#b967ff');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Execute in parallel within wave
|
|
298
|
+
const waveResults = await bridge.execBatch(wave, command, wave.length);
|
|
299
|
+
|
|
300
|
+
let waveFailed = false;
|
|
301
|
+
for (const [id, r] of Object.entries(waveResults)) {
|
|
302
|
+
this.bus.setAgentStatus(id, null);
|
|
303
|
+
const ok = r.code === 0;
|
|
304
|
+
deployment.results.push({ phase: `wave${wi + 1}`, server: id, status: ok ? 'ok' : 'fail', output: r.stdout || r.stderr });
|
|
305
|
+
if (!ok) {
|
|
306
|
+
this.bus.log('error', `wave${wi + 1}`, `FAILED on ${id}: ${r.stderr}`);
|
|
307
|
+
waveFailed = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (waveFailed) {
|
|
312
|
+
this.bus.log('error', `wave${wi + 1}`, 'Wave had failures — initiating rollback');
|
|
313
|
+
// Rollback this wave + canary
|
|
314
|
+
if (rollbackCommand) {
|
|
315
|
+
const rollbackTargets = [canaryServerId, ...waves.slice(0, wi + 1).flat()];
|
|
316
|
+
this.bus.log('warn', 'rollback', `Rolling back ${rollbackTargets.length} servers...`);
|
|
317
|
+
for (const id of rollbackTargets) {
|
|
318
|
+
this.bus.setAgentStatus(id, '⏪ Rolling back', '#ff4757');
|
|
319
|
+
}
|
|
320
|
+
await bridge.execBatch(rollbackTargets, rollbackCommand, 10);
|
|
321
|
+
for (const id of rollbackTargets) {
|
|
322
|
+
this.bus.setAgentStatus(id, null);
|
|
323
|
+
}
|
|
324
|
+
this.bus.log('warn', 'rollback', 'Rollback complete');
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Wave ${wi + 1} failed`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
deployment.progress = 40 + ((wi + 1) / waves.length) * 40;
|
|
330
|
+
this.bus.updateDeployment(deployment);
|
|
331
|
+
this.bus.log('ok', `wave${wi + 1}`, `Wave ${wi + 1} complete: ${wave.length} servers OK`);
|
|
332
|
+
|
|
333
|
+
// Brief pause between waves
|
|
334
|
+
if (wi < waves.length - 1) {
|
|
335
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Phase 4: Post-Verify ──
|
|
340
|
+
deployment.phase = 'verify';
|
|
341
|
+
deployment.progress = 90;
|
|
342
|
+
this.bus.updateDeployment(deployment);
|
|
343
|
+
this.bus.log('info', 'verify', 'Running post-deployment verification...');
|
|
344
|
+
|
|
345
|
+
if (postCheck) {
|
|
346
|
+
const verifyResults = await bridge.execBatch(allIds, postCheck, 10);
|
|
347
|
+
let allOk = true;
|
|
348
|
+
for (const [id, r] of Object.entries(verifyResults)) {
|
|
349
|
+
if (r.code !== 0) {
|
|
350
|
+
this.bus.log('error', 'verify', `Post-check failed on ${id}`);
|
|
351
|
+
allOk = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!allOk) {
|
|
355
|
+
this.bus.log('warn', 'verify', 'Some post-checks failed — review manually');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Done ──
|
|
360
|
+
deployment.phase = 'done';
|
|
361
|
+
deployment.progress = 100;
|
|
362
|
+
deployment.status = 'success';
|
|
363
|
+
deployment.completedAt = Date.now();
|
|
364
|
+
this.bus.updateDeployment(deployment);
|
|
365
|
+
this.bus.log('ok', 'canary', `Deployment complete — ${allIds.length} servers updated successfully`);
|
|
366
|
+
|
|
367
|
+
} catch (err) {
|
|
368
|
+
deployment.status = 'failed';
|
|
369
|
+
deployment.error = err.message;
|
|
370
|
+
deployment.completedAt = Date.now();
|
|
371
|
+
this.bus.updateDeployment(deployment);
|
|
372
|
+
this.bus.log('error', 'deploy', `Deployment failed: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return deployment;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
abort() {
|
|
379
|
+
this.aborted = true;
|
|
380
|
+
this.bus.log('warn', 'deploy', 'Deployment abort requested');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const deployer = new CanaryDeployer(bus);
|
|
385
|
+
|
|
386
|
+
// ═══════════════════════════════════════════════════
|
|
387
|
+
// HTTP + WebSocket Server
|
|
388
|
+
// ═══════════════════════════════════════════════════
|
|
389
|
+
|
|
390
|
+
const httpServer = createServer(async (req, res) => {
|
|
391
|
+
// CORS
|
|
392
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
393
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
394
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
395
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
396
|
+
|
|
397
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
398
|
+
const path = url.pathname;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
// ── REST API ──
|
|
402
|
+
|
|
403
|
+
if (path === '/api/servers' && req.method === 'GET') {
|
|
404
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
|
+
res.end(JSON.stringify({ servers: cachedServerData }));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (path === '/api/servers/refresh' && req.method === 'POST') {
|
|
410
|
+
pollServerStatus();
|
|
411
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
412
|
+
res.end(JSON.stringify({ ok: true }));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (path.startsWith('/api/server/') && req.method === 'GET') {
|
|
417
|
+
const serverId = path.split('/')[3];
|
|
418
|
+
const info = await bridge.getServerInfo(serverId);
|
|
419
|
+
const services = await getServerServices(serverId);
|
|
420
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
421
|
+
res.end(JSON.stringify({ ...info, services }));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (path === '/api/exec' && req.method === 'POST') {
|
|
426
|
+
const body = await readBody(req);
|
|
427
|
+
const { server, command, timeout } = JSON.parse(body);
|
|
428
|
+
bus.log('info', 'exec', `${server}: ${command}`);
|
|
429
|
+
bus.setAgentStatus(server, '⚡ Executing');
|
|
430
|
+
const r = await bridge.execCommand(server, command, timeout || 30000);
|
|
431
|
+
bus.setAgentStatus(server, null);
|
|
432
|
+
bus.log(r.code === 0 ? 'ok' : 'error', server, `Exit ${r.code}: ${(r.stdout || r.stderr).substring(0, 100)}`);
|
|
433
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
434
|
+
res.end(JSON.stringify(r));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (path === '/api/exec-batch' && req.method === 'POST') {
|
|
439
|
+
const body = await readBody(req);
|
|
440
|
+
const { servers: srvList, group, command } = JSON.parse(body);
|
|
441
|
+
let ids = srvList || [];
|
|
442
|
+
if (group) {
|
|
443
|
+
const groups = bridge.listGroups();
|
|
444
|
+
ids = groups[group] || [];
|
|
445
|
+
}
|
|
446
|
+
bus.log('info', 'batch', `Executing on ${ids.length} servers: ${command}`);
|
|
447
|
+
for (const id of ids) bus.setAgentStatus(id, '⚡ Batch exec');
|
|
448
|
+
const results = await bridge.execBatch(ids, command);
|
|
449
|
+
for (const id of ids) bus.setAgentStatus(id, null);
|
|
450
|
+
const ok = Object.values(results).filter(r => r.code === 0).length;
|
|
451
|
+
bus.log('ok', 'batch', `Batch complete: ${ok}/${ids.length} OK`);
|
|
452
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
453
|
+
res.end(JSON.stringify({ results }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (path === '/api/service' && req.method === 'POST') {
|
|
458
|
+
const body = await readBody(req);
|
|
459
|
+
const { server, service, action } = JSON.parse(body);
|
|
460
|
+
bus.log('info', 'service', `${server}: ${action} ${service}`);
|
|
461
|
+
bus.setAgentStatus(server, `🔧 ${action} ${service}`);
|
|
462
|
+
const r = await bridge.manageService(server, service, action);
|
|
463
|
+
bus.setAgentStatus(server, null);
|
|
464
|
+
bus.log(r.code === 0 ? 'ok' : 'error', server, `${service} ${action}: exit ${r.code}`);
|
|
465
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
466
|
+
res.end(JSON.stringify(r));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (path === '/api/deploy' && req.method === 'POST') {
|
|
471
|
+
const body = await readBody(req);
|
|
472
|
+
const opts = JSON.parse(body);
|
|
473
|
+
// Don't await — run in background
|
|
474
|
+
deployer.deploy(opts);
|
|
475
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
476
|
+
res.end(JSON.stringify({ ok: true, message: 'Deployment started' }));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (path === '/api/deploy/abort' && req.method === 'POST') {
|
|
481
|
+
deployer.abort();
|
|
482
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
483
|
+
res.end(JSON.stringify({ ok: true }));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (path === '/api/groups' && req.method === 'GET') {
|
|
488
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
489
|
+
res.end(JSON.stringify(bridge.listGroups()));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (path === '/api/logs' && req.method === 'GET') {
|
|
494
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
495
|
+
res.end(JSON.stringify({ logs: bus.logs.slice(-100) }));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Server Management API ──
|
|
500
|
+
|
|
501
|
+
if (path === '/api/server/add' && req.method === 'POST') {
|
|
502
|
+
const body = await readBody(req);
|
|
503
|
+
const { id, host, port, username, password, group } = JSON.parse(body);
|
|
504
|
+
if (!id || !host) {
|
|
505
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
506
|
+
res.end(JSON.stringify({ error: 'id and host are required' }));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const config = bridge.loadConfig();
|
|
510
|
+
config.servers[id] = { host, port: port || 22, username: username || 'root', password: password || '', group: group || 'default' };
|
|
511
|
+
bridge.saveConfig(config);
|
|
512
|
+
bus.log('info', 'config', `Server added: ${id} (${host})`);
|
|
513
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
514
|
+
res.end(JSON.stringify({ ok: true }));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (path === '/api/server/remove' && req.method === 'POST') {
|
|
519
|
+
const body = await readBody(req);
|
|
520
|
+
const { id } = JSON.parse(body);
|
|
521
|
+
const config = bridge.loadConfig();
|
|
522
|
+
if (config.servers[id]) {
|
|
523
|
+
delete config.servers[id];
|
|
524
|
+
bridge.saveConfig(config);
|
|
525
|
+
bus.log('info', 'config', `Server removed: ${id}`);
|
|
526
|
+
}
|
|
527
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
528
|
+
res.end(JSON.stringify({ ok: true }));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (path === '/api/servers/import' && req.method === 'POST') {
|
|
533
|
+
const body = await readBody(req);
|
|
534
|
+
const { csv } = JSON.parse(body);
|
|
535
|
+
const config = bridge.loadConfig();
|
|
536
|
+
let count = 0;
|
|
537
|
+
for (const line of csv.split('\n')) {
|
|
538
|
+
const trimmed = line.trim();
|
|
539
|
+
if (!trimmed) continue;
|
|
540
|
+
const parts = trimmed.split(',');
|
|
541
|
+
if (parts.length < 2) continue;
|
|
542
|
+
const [id, host, port, username, password, group] = parts.map(s => s.trim());
|
|
543
|
+
config.servers[id] = {
|
|
544
|
+
host,
|
|
545
|
+
port: parseInt(port) || 22,
|
|
546
|
+
username: username || 'root',
|
|
547
|
+
password: password || '',
|
|
548
|
+
group: group || 'default'
|
|
549
|
+
};
|
|
550
|
+
count++;
|
|
551
|
+
}
|
|
552
|
+
bridge.saveConfig(config);
|
|
553
|
+
bus.log('info', 'config', `Imported ${count} servers from CSV`);
|
|
554
|
+
// Trigger refresh
|
|
555
|
+
pollServerStatus();
|
|
556
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
557
|
+
res.end(JSON.stringify({ ok: true, count }));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (path === '/api/servers/import-file' && req.method === 'POST') {
|
|
562
|
+
// Import from local servers.txt file
|
|
563
|
+
try {
|
|
564
|
+
const filePath = join(__dirname, '..', 'servers.txt');
|
|
565
|
+
const csv = readFileSync(filePath, 'utf-8');
|
|
566
|
+
const config = bridge.loadConfig();
|
|
567
|
+
let count = 0;
|
|
568
|
+
for (const line of csv.split('\n')) {
|
|
569
|
+
const trimmed = line.trim();
|
|
570
|
+
if (!trimmed) continue;
|
|
571
|
+
const parts = trimmed.split(',');
|
|
572
|
+
if (parts.length < 2) continue;
|
|
573
|
+
const [id, host, port, username, password, group] = parts.map(s => s.trim());
|
|
574
|
+
config.servers[id] = {
|
|
575
|
+
host,
|
|
576
|
+
port: parseInt(port) || 22,
|
|
577
|
+
username: username || 'root',
|
|
578
|
+
password: password || '',
|
|
579
|
+
group: group || 'default'
|
|
580
|
+
};
|
|
581
|
+
count++;
|
|
582
|
+
}
|
|
583
|
+
bridge.saveConfig(config);
|
|
584
|
+
bus.log('ok', 'config', `Imported ${count} servers from servers.txt`);
|
|
585
|
+
pollServerStatus();
|
|
586
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
587
|
+
res.end(JSON.stringify({ ok: true, count }));
|
|
588
|
+
} catch (err) {
|
|
589
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
590
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (path === '/api/servers/clear' && req.method === 'POST') {
|
|
596
|
+
const config = bridge.loadConfig();
|
|
597
|
+
const count = Object.keys(config.servers).length;
|
|
598
|
+
config.servers = {};
|
|
599
|
+
bridge.saveConfig(config);
|
|
600
|
+
cachedServerData = [];
|
|
601
|
+
bus.broadcast({ type: 'servers', servers: [] });
|
|
602
|
+
bus.log('warn', 'config', `Cleared all ${count} servers`);
|
|
603
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
604
|
+
res.end(JSON.stringify({ ok: true, count }));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (path === '/api/servers/list-config' && req.method === 'GET') {
|
|
609
|
+
const config = bridge.loadConfig();
|
|
610
|
+
const list = Object.entries(config.servers).map(([id, s]) => ({
|
|
611
|
+
id, host: s.host, port: s.port || 22, username: s.username || 'root', group: s.group || 'default'
|
|
612
|
+
}));
|
|
613
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
614
|
+
res.end(JSON.stringify({ servers: list, total: list.length }));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ── Claude Code webhook (for hooks integration) ──
|
|
619
|
+
if (path === '/api/claude-event' && req.method === 'POST') {
|
|
620
|
+
const body = await readBody(req);
|
|
621
|
+
const event = JSON.parse(body);
|
|
622
|
+
bus.log('claude', 'claude', event.message || 'Claude Code event');
|
|
623
|
+
if (event.server) bus.setAgentStatus(event.server, event.status || '◆ Claude active');
|
|
624
|
+
if (event.clearServer) bus.setAgentStatus(event.clearServer, null);
|
|
625
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
626
|
+
res.end(JSON.stringify({ ok: true }));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Serve Dashboard UI ──
|
|
631
|
+
if (path === '/' || path === '/index.html') {
|
|
632
|
+
try {
|
|
633
|
+
const html = readFileSync(join(__dirname, '..', 'public', 'index.html'), 'utf-8');
|
|
634
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
635
|
+
res.end(html);
|
|
636
|
+
return;
|
|
637
|
+
} catch { /* fall through to 404 */ }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 404
|
|
641
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
642
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
643
|
+
|
|
644
|
+
} catch (err) {
|
|
645
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
646
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
function readBody(req) {
|
|
651
|
+
return new Promise((resolve, reject) => {
|
|
652
|
+
let body = '';
|
|
653
|
+
req.on('data', chunk => body += chunk);
|
|
654
|
+
req.on('end', () => resolve(body));
|
|
655
|
+
req.on('error', reject);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// WebSocket
|
|
660
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
661
|
+
|
|
662
|
+
// ═══════════════════════════════════════════════════
|
|
663
|
+
// Shell Session Manager
|
|
664
|
+
// ═══════════════════════════════════════════════════
|
|
665
|
+
|
|
666
|
+
const shellSessions = new Map(); // sessionId -> { shell, serverId, ws }
|
|
667
|
+
|
|
668
|
+
function cleanupShellSessions(ws) {
|
|
669
|
+
for (const [sessionId, session] of shellSessions) {
|
|
670
|
+
if (session.ws === ws) {
|
|
671
|
+
try { session.shell.close(); } catch {}
|
|
672
|
+
shellSessions.delete(sessionId);
|
|
673
|
+
bus.log('info', 'shell', `Session closed: ${session.serverId} (${sessionId})`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
wss.on('connection', (ws) => {
|
|
679
|
+
bus.addClient(ws);
|
|
680
|
+
bus.log('info', 'ws', 'Dashboard client connected');
|
|
681
|
+
|
|
682
|
+
ws.on('message', async (raw) => {
|
|
683
|
+
try {
|
|
684
|
+
const msg = JSON.parse(raw.toString());
|
|
685
|
+
|
|
686
|
+
if (msg.type === 'exec') {
|
|
687
|
+
bus.setAgentStatus(msg.server, '⚡ Executing');
|
|
688
|
+
const r = await bridge.execCommand(msg.server, msg.command, msg.timeout || 30000);
|
|
689
|
+
bus.setAgentStatus(msg.server, null);
|
|
690
|
+
ws.send(JSON.stringify({ type: 'exec_result', requestId: msg.requestId, server: msg.server, result: r }));
|
|
691
|
+
bus.log(r.code === 0 ? 'ok' : 'error', msg.server, `${msg.command.substring(0, 60)} → exit ${r.code}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (msg.type === 'service') {
|
|
695
|
+
bus.setAgentStatus(msg.server, `🔧 ${msg.action} ${msg.service}`);
|
|
696
|
+
const r = await bridge.manageService(msg.server, msg.service, msg.action);
|
|
697
|
+
bus.setAgentStatus(msg.server, null);
|
|
698
|
+
ws.send(JSON.stringify({ type: 'service_result', requestId: msg.requestId, result: r }));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (msg.type === 'refresh') {
|
|
702
|
+
pollServerStatus();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Interactive Shell ──
|
|
706
|
+
if (msg.type === 'shell_open') {
|
|
707
|
+
const sessionId = msg.sessionId || Date.now().toString(36);
|
|
708
|
+
try {
|
|
709
|
+
const shell = await bridge.openShell(msg.server, { cols: msg.cols || 120, rows: msg.rows || 30 });
|
|
710
|
+
shellSessions.set(sessionId, { shell, serverId: msg.server, ws });
|
|
711
|
+
|
|
712
|
+
shell.stream.on('data', (data) => {
|
|
713
|
+
if (ws.readyState === 1) {
|
|
714
|
+
ws.send(JSON.stringify({ type: 'shell_data', sessionId, data: data.toString('base64') }));
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
shell.stream.on('close', () => {
|
|
719
|
+
shellSessions.delete(sessionId);
|
|
720
|
+
if (ws.readyState === 1) {
|
|
721
|
+
ws.send(JSON.stringify({ type: 'shell_closed', sessionId }));
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
shell.stream.stderr.on('data', (data) => {
|
|
726
|
+
if (ws.readyState === 1) {
|
|
727
|
+
ws.send(JSON.stringify({ type: 'shell_data', sessionId, data: data.toString('base64') }));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
ws.send(JSON.stringify({ type: 'shell_opened', sessionId, server: msg.server }));
|
|
732
|
+
bus.log('info', 'shell', `Shell opened: ${msg.server} (${sessionId})`);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
ws.send(JSON.stringify({ type: 'shell_error', sessionId, error: err.message }));
|
|
735
|
+
bus.log('error', 'shell', `Shell failed for ${msg.server}: ${err.message}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (msg.type === 'shell_input') {
|
|
740
|
+
const session = shellSessions.get(msg.sessionId);
|
|
741
|
+
if (session) {
|
|
742
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
743
|
+
session.shell.stream.write(buf);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (msg.type === 'shell_resize') {
|
|
748
|
+
const session = shellSessions.get(msg.sessionId);
|
|
749
|
+
if (session) {
|
|
750
|
+
session.shell.resize(msg.cols, msg.rows);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (msg.type === 'shell_close') {
|
|
755
|
+
const session = shellSessions.get(msg.sessionId);
|
|
756
|
+
if (session) {
|
|
757
|
+
session.shell.close();
|
|
758
|
+
shellSessions.delete(msg.sessionId);
|
|
759
|
+
bus.log('info', 'shell', `Shell closed: ${session.serverId} (${msg.sessionId})`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
} catch (err) {
|
|
764
|
+
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
ws.on('close', () => {
|
|
769
|
+
cleanupShellSessions(ws);
|
|
770
|
+
bus.removeClient(ws);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// ═══════════════════════════════════════════════════
|
|
775
|
+
// Start
|
|
776
|
+
// ═══════════════════════════════════════════════════
|
|
777
|
+
|
|
778
|
+
httpServer.listen(PORT, () => {
|
|
779
|
+
console.log(`
|
|
780
|
+
◈ SOVEREIGN Dashboard Server
|
|
781
|
+
─────────────────────────────
|
|
782
|
+
HTTP API: http://localhost:${PORT}/api/
|
|
783
|
+
WebSocket: ws://localhost:${PORT}
|
|
784
|
+
Dashboard: http://localhost:${PORT}/
|
|
785
|
+
|
|
786
|
+
Claude Code hook:
|
|
787
|
+
curl -X POST http://localhost:${PORT}/api/claude-event \\
|
|
788
|
+
-H "Content-Type: application/json" \\
|
|
789
|
+
-d '{"message": "Task started", "server": "prod-01", "status": "⚡ Working"}'
|
|
790
|
+
`);
|
|
791
|
+
|
|
792
|
+
bus.log('info', 'system', `SOVEREIGN Dashboard Server started on port ${PORT}`);
|
|
793
|
+
|
|
794
|
+
// Initial poll
|
|
795
|
+
pollServerStatus();
|
|
796
|
+
|
|
797
|
+
// Poll every 30 seconds
|
|
798
|
+
setInterval(pollServerStatus, 30000);
|
|
799
|
+
});
|