feat: DevTools调试工具 + 前端样式修复 + 管理员登录系统
DevTools (新增): - 进程管理器: 启动/停止/重启/编译 + 端口自动释放 - 服务接管 (tryAdopt): 检测已运行服务,健康检查通过则直接接管 - 一键启动 (startAllSequential): 按 ai-core→gateway→frontend 顺序启动 - 日志布局切换: 标签页模式 ↔ 三栏并列模式 - 性能监控: CPU/内存采样 + SVG 折线图 - Web UI + WebSocket 实时推送 前端修复: - tailwind.config.ts: 修复空配置导致 CSS 不加载 (增加 content/colors/fontFamily) - postcss.config.js: 新建缺失的 PostCSS 配置 - App.tsx: 移除注册功能,仅保留管理员登录 (admin / cyrene-dev-admin) 后端新增: - config.go: AdminUsername/AdminPassword/RegistrationEnabled 环境变量 - auth_handler.go: 管理员登录 + 注册邮箱验证码 + 注册开关控制 - 管理员凭据: admin / cyrene-dev-admin (默认) 其他: - .gitignore: 新增 devtools/node_modules/ devtools/logs/ devtools/package-lock.json - devtools.sh: DevTools 一键启动脚本
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 调试工具配置
|
||||
* 定义各服务的启动参数、端口、健康检查等
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
|
||||
export const SERVICES = {
|
||||
'ai-core': {
|
||||
name: 'AI-Core',
|
||||
cwd: path.join(ROOT, 'backend/ai-core'),
|
||||
command: './main',
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
LLM_API_URL: process.env.LLM_API_URL || 'https://api.openai.com/v1',
|
||||
LLM_API_KEY: process.env.LLM_API_KEY || '',
|
||||
LLM_MODEL: process.env.LLM_MODEL || 'gpt-4o',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
gateway: {
|
||||
name: 'Gateway',
|
||||
cwd: path.join(ROOT, 'backend/gateway'),
|
||||
command: './main',
|
||||
env: {
|
||||
GATEWAY_PORT: '8080',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'false',
|
||||
},
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
frontend: {
|
||||
name: 'Frontend',
|
||||
cwd: path.join(ROOT, 'frontend/web'),
|
||||
command: 'npx',
|
||||
args: ['vite', '--host', '0.0.0.0'],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
healthUrl: 'http://localhost:5173',
|
||||
port: 5173,
|
||||
nodeBin: '/usr/local/node/bin/node',
|
||||
npmBin: '/usr/local/node/bin/npx',
|
||||
// frontend不需要预编译,dev server即可
|
||||
buildCommand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 各服务默认的日志文件路径 */
|
||||
export function logFile(serviceId) {
|
||||
return path.join(LOGS_DIR, `${serviceId}.log`);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Cyrene DevTools - 主入口
|
||||
*
|
||||
* 提供:
|
||||
* - REST API: 服务管理、状态查询、性能分析、健康检查代理
|
||||
* - WebSocket: 实时日志推送
|
||||
* - Web UI: 管理控制台
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { processManager } from './process-manager.js';
|
||||
import { performanceMonitor } from './performance.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ========== 初始化 ==========
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// 静态文件 - Web控制台
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// ========== WebSocket ==========
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
/** @type {Set<WebSocket>} */
|
||||
const wsClients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
wsClients.add(ws);
|
||||
ws.on('close', () => wsClients.delete(ws));
|
||||
});
|
||||
|
||||
/** 广播到所有WebSocket客户端 */
|
||||
function broadcast(type, data) {
|
||||
const msg = JSON.stringify({ type, data, ts: Date.now() });
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 日志事件 -> WebSocket广播
|
||||
processManager.on('log', (serviceId, stream, text) => {
|
||||
broadcast('log', { service: serviceId, stream, text: text.trimEnd() });
|
||||
});
|
||||
|
||||
// 状态变化
|
||||
processManager.on('log', (serviceId, stream, text) => {
|
||||
if (stream === 'system') {
|
||||
broadcast('status', processManager.getStatus());
|
||||
}
|
||||
});
|
||||
|
||||
// ========== REST API 路由 ==========
|
||||
|
||||
// ---- 健康检查 ----
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'cyrene-devtools',
|
||||
uptime: process.uptime(),
|
||||
wsClients: wsClients.size,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 服务状态 ----
|
||||
app.get('/api/services', (_req, res) => {
|
||||
res.json(processManager.getStatus());
|
||||
});
|
||||
|
||||
app.get('/api/services/:id', (req, res) => {
|
||||
const status = processManager.getServiceStatus(req.params.id);
|
||||
if (!status) return res.status(404).json({ error: '未知服务' });
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// ---- 服务控制 ----
|
||||
app.post('/api/services/:id/start', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.start(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.stop(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/restart', async (req, res) => {
|
||||
try {
|
||||
// 异步重启,因为可能耗时较长
|
||||
res.json({ success: true, message: '重启中...' });
|
||||
const result = await processManager.restart(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
} catch (err) {
|
||||
// 已经在上面res了
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/build', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.build(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量操作
|
||||
// 一键按顺序启动 (接管已运行 + 健康检查)
|
||||
app.post('/api/services/start-all', async (_req, res) => {
|
||||
const results = await processManager.startAllSequential();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// 强制重启全部 (先杀后启)
|
||||
app.post('/api/services/start-all-fresh', async (_req, res) => {
|
||||
await processManager.stopAll();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const results = await processManager.startAllSequential();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
app.post('/api/services/stop-all', async (_req, res) => {
|
||||
const results = await processManager.stopAll();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// ---- 性能监控 ----
|
||||
app.get('/api/performance', async (_req, res) => {
|
||||
const snapshot = await performanceMonitor.getSnapshot();
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
app.get('/api/performance/history', (_req, res) => {
|
||||
res.json(performanceMonitor.getAllHistory());
|
||||
});
|
||||
|
||||
app.get('/api/performance/:id', (req, res) => {
|
||||
const history = performanceMonitor.getHistory(req.params.id);
|
||||
res.json(history);
|
||||
});
|
||||
|
||||
app.get('/api/performance/:id/summary', async (req, res) => {
|
||||
const snap = await performanceMonitor.getSnapshot();
|
||||
const history = performanceMonitor.getHistory(req.params.id);
|
||||
const svc = snap[req.params.id];
|
||||
if (!svc) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
// 计算汇总统计
|
||||
let maxCpu = 0, maxMem = 0;
|
||||
const cpuValues = [], memValues = [];
|
||||
for (const h of history) {
|
||||
if (h.cpu > maxCpu) maxCpu = h.cpu;
|
||||
if (h.mem > maxMem) maxMem = h.mem;
|
||||
cpuValues.push(h.cpu);
|
||||
memValues.push(h.mem);
|
||||
}
|
||||
|
||||
const avgCpu = cpuValues.length > 0 ? Math.round(cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length * 100) / 100 : 0;
|
||||
const avgMem = memValues.length > 0 ? Math.round(memValues.reduce((a, b) => a + b, 0) / memValues.length * 100) / 100 : 0;
|
||||
|
||||
res.json({
|
||||
current: svc,
|
||||
history: { count: history.length, maxCpu, maxMem, avgCpu, avgMem },
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 日志查询 ----
|
||||
app.get('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
const lines = req.query.lines ? parseInt(req.query.lines) : 200;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset) : 0;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.json({ service: id, lines: [], total: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用tail方式读取
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = content.split('\n').filter(Boolean);
|
||||
const total = allLines.length;
|
||||
const sliced = allLines.slice(Math.max(0, total - lines - offset), total - offset);
|
||||
res.json({ service: id, lines: sliced, total });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/logs/:id/recent', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.json({ service: id, lines: [], total: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = content.split('\n').filter(Boolean);
|
||||
const total = allLines.length;
|
||||
res.json({ service: id, lines: allLines.slice(-100), total });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除日志
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, '');
|
||||
}
|
||||
res.json({ success: true, message: '日志已清空' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 健康检查代理 ----
|
||||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
const svc = SERVICES[req.params.id];
|
||||
if (!svc || !svc.healthUrl) {
|
||||
return res.status(404).json({ error: '未知服务或无健康检查端点' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(5000) });
|
||||
const data = await resp.json();
|
||||
res.json({ proxy: true, service: req.params.id, status: resp.status, data });
|
||||
} catch {
|
||||
res.json({ proxy: true, service: req.params.id, status: 'unreachable', data: null });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 启动 ==========
|
||||
// 启动性能监控
|
||||
performanceMonitor.start();
|
||||
|
||||
// 确保日志目录存在
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
|
||||
server.listen(DEVTOOLS_PORT, () => {
|
||||
console.log(`🛠️ Cyrene DevTools 已启动: http://localhost:${DEVTOOLS_PORT}`);
|
||||
console.log(` API: http://localhost:${DEVTOOLS_PORT}/api/health`);
|
||||
console.log(` WebSocket: ws://localhost:${DEVTOOLS_PORT}/ws`);
|
||||
console.log(` Web控制台: http://localhost:${DEVTOOLS_PORT}`);
|
||||
console.log('');
|
||||
console.log(' 可用服务:');
|
||||
for (const [id, svc] of Object.entries(SERVICES)) {
|
||||
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n关闭所有服务...');
|
||||
await processManager.stopAll();
|
||||
performanceMonitor.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await processManager.stopAll();
|
||||
performanceMonitor.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 性能监控模块
|
||||
* 监控各服务进程的 CPU、内存使用情况
|
||||
*/
|
||||
|
||||
import pidusage from 'pidusage';
|
||||
import { processManager } from './process-manager.js';
|
||||
import { SERVICES } from './config.js';
|
||||
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
|
||||
this.history = new Map();
|
||||
this.interval = null;
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.history.set(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始定期采样 (每3秒)
|
||||
*/
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.sample(), 3000);
|
||||
this.interval.unref(); // 不阻止进程退出
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止采样
|
||||
*/
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样一次
|
||||
*/
|
||||
async sample() {
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) continue;
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
const history = this.history.get(id);
|
||||
history.push({
|
||||
ts: Date.now(),
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
|
||||
});
|
||||
// 保留最近300条 (约15分钟)
|
||||
if (history.length > 300) {
|
||||
history.splice(0, history.length - 300);
|
||||
}
|
||||
} catch {
|
||||
// 进程可能已退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前性能快照
|
||||
*/
|
||||
async getSnapshot() {
|
||||
const result = {};
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) {
|
||||
result[id] = { pid: null, cpu: 0, mem: 0 };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
result[id] = {
|
||||
pid: info.pid,
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
|
||||
elapsed: stats.elapsed,
|
||||
};
|
||||
} catch {
|
||||
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史数据
|
||||
*/
|
||||
getHistory(serviceId) {
|
||||
return this.history.get(serviceId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务的历史数据
|
||||
*/
|
||||
getAllHistory() {
|
||||
const result = {};
|
||||
for (const id of this.history.keys()) {
|
||||
result[id] = this.history.get(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 进程管理器
|
||||
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import { SERVICES, logFile } from './config.js';
|
||||
|
||||
/**
|
||||
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
|
||||
*/
|
||||
function releasePort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(1000);
|
||||
sock.on('connect', () => {
|
||||
sock.destroy();
|
||||
// 端口被占用,尝试释放
|
||||
try {
|
||||
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
sock.on('error', () => {
|
||||
sock.destroy();
|
||||
resolve(); // 端口空闲
|
||||
});
|
||||
sock.on('timeout', () => {
|
||||
sock.destroy();
|
||||
resolve();
|
||||
});
|
||||
sock.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
class ProcessManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
|
||||
this.processes = new Map();
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.processes.set(id, {
|
||||
process: null,
|
||||
status: 'stopped',
|
||||
startTime: null,
|
||||
pid: null,
|
||||
buildLog: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*/
|
||||
async start(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (procInfo.process) {
|
||||
throw new Error(`${svc.name} 已在运行中`);
|
||||
}
|
||||
|
||||
// 启动前释放端口,避免 "address already in use"
|
||||
if (svc.port) {
|
||||
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
|
||||
await releasePort(svc.port);
|
||||
}
|
||||
|
||||
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
|
||||
procInfo.status = 'starting';
|
||||
procInfo.buildLog = [];
|
||||
|
||||
// 确保日志目录存在
|
||||
const logPath = logFile(serviceId);
|
||||
const logDir = logPath.substring(0, logPath.lastIndexOf('/'));
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
|
||||
// 确定二进制路径或命令
|
||||
let command, args;
|
||||
if (svc.command === './main') {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
} else if (svc.command === 'npx') {
|
||||
command = svc.npmBin || 'npx';
|
||||
args = svc.args || [];
|
||||
} else {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
}
|
||||
|
||||
const env = { ...process.env, ...svc.env };
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stdout', text);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stderr', text);
|
||||
});
|
||||
|
||||
// spawn() 返回后进程已启动,立即记录 PID 和状态
|
||||
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
|
||||
procInfo.pid = child.pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = child;
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
|
||||
|
||||
child.on('error', (err) => {
|
||||
const msg = `进程错误: ${err.message}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'error', msg);
|
||||
procInfo.status = 'error';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const msg = `进程退出,退出码: ${code}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'system', msg);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
logStream.end();
|
||||
});
|
||||
|
||||
return { success: true, message: `${svc.name} 启动中...` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务
|
||||
*/
|
||||
async stop(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (!procInfo.process) {
|
||||
// 可能已经崩溃了,重置状态
|
||||
procInfo.status = 'stopped';
|
||||
return { success: true, message: `${svc.name} 未在运行` };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// 强制杀死
|
||||
if (procInfo.process) {
|
||||
procInfo.process.kill('SIGKILL');
|
||||
}
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已强制停止` });
|
||||
}, 5000);
|
||||
|
||||
procInfo.process.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已停止` });
|
||||
});
|
||||
|
||||
procInfo.process.kill('SIGTERM');
|
||||
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启服务
|
||||
*/
|
||||
async restart(serviceId) {
|
||||
await this.stop(serviceId);
|
||||
// 等待一小段时间确保端口释放
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.start(serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务 (Go服务需要预编译)
|
||||
*/
|
||||
async build(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
if (!svc.buildCommand) {
|
||||
return { success: false, message: `${svc.name} 不需要预编译` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
procInfo.status = 'building';
|
||||
procInfo.buildLog = [];
|
||||
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const buildCmd = svc.goBin || svc.buildCommand;
|
||||
const buildArgs = svc.buildArgs || [];
|
||||
|
||||
const child = spawn(buildCmd, buildArgs, {
|
||||
cwd: svc.cwd,
|
||||
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
child.on('close', (code) => {
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.buildLog = [
|
||||
...stdout.split('\n').filter(Boolean),
|
||||
...stderr.split('\n').filter(Boolean),
|
||||
];
|
||||
|
||||
if (code === 0) {
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
|
||||
resolve({ success: true, message: `${svc.name} 编译成功` });
|
||||
} else {
|
||||
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
|
||||
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
procInfo.status = 'stopped';
|
||||
resolve({ success: false, message: `编译错误: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
const result = {};
|
||||
for (const [id, info] of this.processes) {
|
||||
const svc = SERVICES[id];
|
||||
result[id] = {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务状态
|
||||
*/
|
||||
getServiceStatus(serviceId) {
|
||||
const info = this.processes.get(serviceId);
|
||||
if (!info) return null;
|
||||
const svc = SERVICES[serviceId];
|
||||
return {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
async stopAll() {
|
||||
const results = [];
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
try {
|
||||
const r = await this.stop(id);
|
||||
results.push({ id, ...r });
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试接管已运行的服务 (通过健康检查端点)
|
||||
* 如果服务已在运行,直接标记为 running 而不是杀死重启
|
||||
*/
|
||||
async tryAdopt(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc || !svc.healthUrl) return false;
|
||||
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
// 尝试通过 fuser 获取 PID
|
||||
let pid = null;
|
||||
try {
|
||||
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
|
||||
const match = out.match(/(\d+)/);
|
||||
if (match) pid = parseInt(match[1]);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
procInfo.pid = pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = null; // 不是我们的子进程,但标记为已接管
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
|
||||
return true;
|
||||
}
|
||||
} catch { /* 未运行或不可达 */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序启动所有服务 (ai-core → gateway → frontend)
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
async startAllSequential() {
|
||||
const order = ['ai-core', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
try {
|
||||
const r = await this.start(id);
|
||||
results.push({ id, ...r });
|
||||
|
||||
// 等待健康检查通过
|
||||
if (svc.healthUrl) {
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) { healthy = true; break; }
|
||||
} catch { /* continue waiting */ }
|
||||
}
|
||||
if (!healthy) {
|
||||
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
|
||||
} else {
|
||||
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager();
|
||||
Reference in New Issue
Block a user