refactor: DevTools → ethend 重命名 + 加入生产环境
- 目录 devtools/ → ethend/ - CLI 脚本 devtools.sh/.bat → ethend.sh/.bat - 环境变量 DEVTOOLS_PORT → ETHEND_PORT - docker-compose.yml 新增 ethend 服务(生产部署) - 同步更新全部文档、注释和配置文件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 管理控制台配置
|
||||
* 定义各服务的启动参数、端口、健康检查等
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
const isWin = os.platform() === 'win32';
|
||||
|
||||
// 读取 backend/.env 文件,将值合并到 process.env(不覆盖已有的环境变量)
|
||||
// 这样 ethend 启动各服务时能传递用户配置的凭据
|
||||
function loadEnvFile() {
|
||||
const envPath = path.join(ROOT, 'backend', '.env');
|
||||
try {
|
||||
const content = fs.readFileSync(envPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
const val = trimmed.substring(eqIdx + 1).trim();
|
||||
if (key && val !== undefined) {
|
||||
process.env[key] = process.env[key] || val;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// .env 文件不存在,使用默认值
|
||||
}
|
||||
}
|
||||
loadEnvFile();
|
||||
|
||||
/** 跨平台 Go 二进制路径 */
|
||||
function findGoBin() {
|
||||
// 优先使用环境变量
|
||||
if (process.env.GOROOT) return path.join(process.env.GOROOT, 'bin', 'go');
|
||||
// Windows 常见路径
|
||||
const candidates = isWin
|
||||
? ['C:\\Program Files\\Go\\bin\\go.exe', 'C:\\Go\\bin\\go.exe', 'go']
|
||||
: ['/usr/local/go/bin/go', '/usr/bin/go', 'go'];
|
||||
for (const p of candidates) {
|
||||
if (p === 'go' || fs.existsSync(p)) return p;
|
||||
}
|
||||
return 'go';
|
||||
}
|
||||
|
||||
const GO_BIN = findGoBin();
|
||||
|
||||
export const ETHEND_PORT = process.env.ETHEND_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
||||
export const PLUGIN_MANAGER_URL = process.env.PLUGIN_MANAGER_URL || 'http://localhost:8094';
|
||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
|
||||
|
||||
export const SERVICES = {
|
||||
'ai-core': {
|
||||
name: 'AI-Core',
|
||||
cwd: path.join(ROOT, 'backend/ai-core'),
|
||||
command: './main',
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
SEARXNG_URL: process.env.SEARXNG_URL || 'http://localhost:8088',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'iot-debug-service': {
|
||||
name: 'IoT Debug',
|
||||
cwd: path.join(ROOT, 'backend/iot-debug-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
IOT_DEBUG_PORT: '8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8083/api/v1/health',
|
||||
port: 8083,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
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',
|
||||
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'memory-service': {
|
||||
name: '记忆服务',
|
||||
cwd: path.join(ROOT, 'backend/memory-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8091',
|
||||
DB_URL: process.env.DB_URL || 'postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable',
|
||||
},
|
||||
healthUrl: 'http://localhost:8091/api/v1/health',
|
||||
port: 8091,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'voice-service': {
|
||||
name: '语音识别服务',
|
||||
cwd: path.join(ROOT, 'backend/voice-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8093',
|
||||
WHISPER_BINARY: './whisper.cpp/main',
|
||||
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
|
||||
WHISPER_LANGUAGE: 'zh',
|
||||
DASHSCOPE_API_KEY: process.env.DASHSCOPE_API_KEY || '',
|
||||
DASHSCOPE_STT_MODEL: 'gummy-chat-v1',
|
||||
},
|
||||
healthUrl: 'http://localhost:8093/api/v1/health',
|
||||
port: 8093,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'plugin-manager': {
|
||||
name: '插件管理器',
|
||||
cwd: path.join(ROOT, 'backend/plugin-manager'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8094',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8094/api/v1/health',
|
||||
port: 8094,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'platform-bridge': {
|
||||
name: '多平台桥接',
|
||||
cwd: path.join(ROOT, 'backend/platform-bridge'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8095',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
QQ_BOT_PORT: process.env.QQ_BOT_PORT || '8096',
|
||||
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
TELEGRAM_WEBHOOK_URL: process.env.TELEGRAM_WEBHOOK_URL || '',
|
||||
QQ_ADMIN_UID: process.env.QQ_ADMIN_UID || '',
|
||||
TELEGRAM_ADMIN_UID: process.env.TELEGRAM_ADMIN_UID || '',
|
||||
},
|
||||
healthUrl: 'http://localhost:8095/health',
|
||||
port: 8095,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
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: 'node',
|
||||
npmBin: 'npx',
|
||||
// frontend不需要预编译,dev server即可
|
||||
buildCommand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 各服务默认的日志文件路径 */
|
||||
export function logFile(serviceId) {
|
||||
return path.join(LOGS_DIR, `${serviceId}.log`);
|
||||
}
|
||||
+1752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 性能监控模块
|
||||
* 监控各服务进程的 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;
|
||||
|
||||
// Ring buffer for actual HTTP request latencies (ms)
|
||||
this.latencyBuffer = [];
|
||||
this.maxLatencySamples = 500;
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.history.set(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an HTTP request duration (ms). Called by middleware. */
|
||||
recordLatency(durationMs) {
|
||||
this.latencyBuffer.push(durationMs);
|
||||
if (this.latencyBuffer.length > this.maxLatencySamples) {
|
||||
this.latencyBuffer.splice(0, this.latencyBuffer.length - this.maxLatencySamples);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get average request latency from recent samples. Returns null if no data. */
|
||||
getAverageLatency() {
|
||||
if (this.latencyBuffer.length === 0) return null;
|
||||
const sum = this.latencyBuffer.reduce((a, b) => a + b, 0);
|
||||
return Math.round(sum / this.latencyBuffer.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始定期采样 (每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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新仪表盘数据 — 返回聚合的性能摘要供首页仪表盘使用
|
||||
* 调用方负责将数据渲染到 #performance-dashboard 元素
|
||||
* @returns {object} 仪表盘性能摘要
|
||||
*/
|
||||
async updateDashboard() {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const entries = Object.entries(snapshot);
|
||||
|
||||
let totalCpu = 0, totalMem = 0, activeCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
totalCpu += p.cpu || 0;
|
||||
totalMem += p.mem || 0;
|
||||
if (p.pid) activeCount++;
|
||||
}
|
||||
|
||||
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
|
||||
const totalMemRounded = Math.round(totalMem * 100) / 100;
|
||||
|
||||
// 计算平均请求延迟 (基于实际 HTTP 请求耗时,非进程 uptime)
|
||||
const avgLatencyMs = this.getAverageLatency();
|
||||
|
||||
// 获取最近历史用于趋势判断
|
||||
const recentHistory = this.getAllHistory();
|
||||
let trendCpu = 'stable', trendMem = 'stable';
|
||||
for (const [, hist] of Object.entries(recentHistory)) {
|
||||
if (hist.length < 5) continue;
|
||||
const recent = hist.slice(-5);
|
||||
const firstCpu = recent[0].cpu, lastCpu = recent[recent.length - 1].cpu;
|
||||
const firstMem = recent[0].mem, lastMem = recent[recent.length - 1].mem;
|
||||
if (lastCpu > firstCpu * 1.15) trendCpu = 'up';
|
||||
else if (lastCpu < firstCpu * 0.85) trendCpu = 'down';
|
||||
if (lastMem > firstMem * 1.15) trendMem = 'up';
|
||||
else if (lastMem < firstMem * 0.85) trendMem = 'down';
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
summary: {
|
||||
avgCpu,
|
||||
totalMemMB: totalMemRounded,
|
||||
activeProcesses: activeCount,
|
||||
monitoredServices: entries.length,
|
||||
avgLatencyMs,
|
||||
trend: { cpu: trendCpu, mem: trendMem },
|
||||
},
|
||||
perService: snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* 进程管理器
|
||||
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { SERVICES, logFile } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const DB_PORTS = [5432, 5433, 5434];
|
||||
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
|
||||
const isWin = os.platform() === 'win32';
|
||||
|
||||
// ---- Docker 检测缓存 ----
|
||||
let _dockerCache = null;
|
||||
let _dockerCacheTime = 0;
|
||||
const DOCKER_CACHE_TTL = 5000; // 5秒缓存,避免每次 status 轮询都执行 docker ps
|
||||
|
||||
/**
|
||||
* 检测哪些服务运行在 Docker 容器中
|
||||
* 通过 docker ps 获取所有容器端口映射,与 SERVICES 端口匹配
|
||||
* @returns {Map<string, {containerName: string, containerId: string, status: string}>}
|
||||
*/
|
||||
function detectDockerServices() {
|
||||
const now = Date.now();
|
||||
if (_dockerCache && (now - _dockerCacheTime) < DOCKER_CACHE_TTL) {
|
||||
return _dockerCache;
|
||||
}
|
||||
|
||||
const result = new Map();
|
||||
try {
|
||||
const out = execSync('docker ps --format "{{.ID}}\\t{{.Names}}\\t{{.Ports}}\\t{{.Status}}"', {
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).toString().trim();
|
||||
|
||||
if (!out) return result;
|
||||
|
||||
for (const line of out.split('\n')) {
|
||||
const [containerId, containerName, ports, status] = line.split('\t');
|
||||
if (!ports) continue;
|
||||
|
||||
// 解析端口映射: "0.0.0.0:8080->8080/tcp, :::8080->8080/tcp"
|
||||
const hostPorts = new Set();
|
||||
for (const m of ports.matchAll(/:(\d+)->/g)) {
|
||||
hostPorts.add(parseInt(m[1]));
|
||||
}
|
||||
|
||||
// 匹配 SERVICES 中定义的端口
|
||||
for (const [svcId, svc] of Object.entries(SERVICES)) {
|
||||
if (svc.port && hostPorts.has(svc.port)) {
|
||||
result.set(svcId, {
|
||||
containerName,
|
||||
containerId: containerId.substring(0, 12),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Docker 不可用,返回空 Map
|
||||
}
|
||||
|
||||
_dockerCache = result;
|
||||
_dockerCacheTime = now;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 清除 Docker 缓存(用于手动刷新状态) */
|
||||
export function clearDockerCache() {
|
||||
_dockerCache = null;
|
||||
_dockerCacheTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 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');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可连接 (TCP connect, 超时2秒)
|
||||
*/
|
||||
function isPortOpen(port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(2000);
|
||||
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
||||
sock.on('error', () => { sock.destroy(); resolve(false); });
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
||||
sock.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库在线
|
||||
* 检查 DB_PORTS 中至少有一个端口可用,若不可用则尝试 docker compose up
|
||||
* 等待最多 30 秒检查数据库就绪
|
||||
* @param {string} serviceId - 正在启动的服务 ID
|
||||
* @param {EventEmitter} emitter - 用于发送日志事件
|
||||
*/
|
||||
async function ensureDBOnline(serviceId, emitter) {
|
||||
// 1. 快速检查:任意数据库端口是否已在线
|
||||
for (const port of DB_PORTS) {
|
||||
if (await isPortOpen(port)) {
|
||||
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已在线`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 数据库不在线,尝试 docker compose up
|
||||
emitter.emit('log', serviceId, 'system', '数据库未启动,正在通过 Docker Compose 启动...');
|
||||
try {
|
||||
execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
emitter.emit('log', serviceId, 'system', 'Docker Compose 启动命令已执行,等待数据库就绪...');
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
emitter.emit('log', serviceId, 'error', `Docker Compose 启动失败: ${stderr}`);
|
||||
}
|
||||
|
||||
// 3. 等待最多 30 秒检查数据库就绪
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
for (const port of DB_PORTS) {
|
||||
if (await isPortOpen(port)) {
|
||||
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已就绪 (等待 ${i + 1}s)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 30 秒后仍不可用
|
||||
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Docker 管理的服务拒绝本地操作
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (procInfo.process) {
|
||||
throw new Error(`${svc.name} 已在运行中`);
|
||||
}
|
||||
|
||||
// 对需要数据库的服务做前置检查
|
||||
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
|
||||
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
||||
await ensureDBOnline(serviceId, this);
|
||||
}
|
||||
|
||||
// 启动前释放端口,避免 "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 = path.dirname(logPath);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
|
||||
// 确定二进制路径或命令
|
||||
let command, args;
|
||||
if (svc.command === './main') {
|
||||
command = isWin ? './main.exe' : './main';
|
||||
args = svc.args || [];
|
||||
} else if (svc.command === 'npx') {
|
||||
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
|
||||
args = svc.args || [];
|
||||
} else {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
}
|
||||
|
||||
// 对使用 npm 的服务,检查 node_modules 是否完整
|
||||
if (svc.command === 'npx' || svc.command === 'node') {
|
||||
const modulesDir = path.join(svc.cwd, 'node_modules');
|
||||
const installMarker = path.join(modulesDir, '.package-lock.json');
|
||||
if (!fs.existsSync(installMarker)) {
|
||||
// 如果 node_modules 目录存在但不完整,先删除
|
||||
if (fs.existsSync(modulesDir)) {
|
||||
this.emit('log', serviceId, 'system', 'node_modules 不完整,正在清理...');
|
||||
fs.rmSync(modulesDir, { recursive: true, force: true });
|
||||
}
|
||||
this.emit('log', serviceId, 'system', '正在运行 npm install...');
|
||||
// Windows: npm.cmd batch file has module resolution issues when
|
||||
// cwd has a node_modules directory, even if empty. Use node+npm-cli.js directly.
|
||||
const installCmd = isWin
|
||||
? `"${process.execPath}" "${path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')}" install`
|
||||
: 'npm install';
|
||||
try {
|
||||
execSync(installCmd, { cwd: svc.cwd, timeout: 120000, stdio: 'pipe' });
|
||||
this.emit('log', serviceId, 'system', 'npm install 完成');
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
this.emit('log', serviceId, 'error', `npm install 失败: ${stderr}`);
|
||||
throw new Error(`npm install 失败: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...process.env, ...svc.env };
|
||||
// .cmd/.bat on Windows needs shell:true
|
||||
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
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}`);
|
||||
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
|
||||
}
|
||||
|
||||
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) {
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
const svc = SERVICES[serviceId];
|
||||
return { success: false, error: 'docker_managed', message: `${svc?.name || serviceId} 由 Docker 管理` };
|
||||
}
|
||||
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 (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请在容器内构建或重建镜像` };
|
||||
}
|
||||
|
||||
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', GOWORK: 'off' },
|
||||
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 dockerSvcs = detectDockerServices();
|
||||
const result = {};
|
||||
for (const [id, info] of this.processes) {
|
||||
const svc = SERVICES[id];
|
||||
const docker = dockerSvcs.get(id);
|
||||
let source = 'none';
|
||||
if (docker) {
|
||||
source = 'docker';
|
||||
} else if (info.status === 'running' || info.status === 'starting') {
|
||||
source = 'local';
|
||||
}
|
||||
result[id] = {
|
||||
name: svc.name,
|
||||
status: docker ? 'running' : info.status, // Docker 容器总是 running
|
||||
pid: docker ? null : info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
source,
|
||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务状态
|
||||
*/
|
||||
getServiceStatus(serviceId) {
|
||||
const info = this.processes.get(serviceId);
|
||||
if (!info) return null;
|
||||
const svc = SERVICES[serviceId];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
const docker = dockerSvcs.get(serviceId);
|
||||
let source = 'none';
|
||||
if (docker) {
|
||||
source = 'docker';
|
||||
} else if (info.status === 'running' || info.status === 'starting') {
|
||||
source = 'local';
|
||||
}
|
||||
return {
|
||||
name: svc.name,
|
||||
status: docker ? 'running' : info.status,
|
||||
pid: docker ? null : info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
source,
|
||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
async stopAll() {
|
||||
const results = [];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
if (dockerSvcs.has(id)) {
|
||||
const svc = SERVICES[id];
|
||||
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理,跳过停止`, docker: true });
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否需要编译 (Go 服务且二进制不存在)
|
||||
*/
|
||||
needsBuild(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc || !svc.buildCommand) return false;
|
||||
|
||||
const binaryPath = path.join(svc.cwd, 'main');
|
||||
const exePath = binaryPath + '.exe';
|
||||
return !fs.existsSync(binaryPath) && !fs.existsSync(exePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序启动所有服务 (memory → iot → voice → ai-core → gateway → frontend)
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
async startAllSequential() {
|
||||
const order = ['memory-service', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
|
||||
// Docker 管理的服务:跳过
|
||||
if (dockerSvcs.has(id)) {
|
||||
const d = dockerSvcs.get(id);
|
||||
this.emit('log', id, 'system', `${svc.name} 由 Docker 容器 ${d.containerName} 管理,跳过本地启动`);
|
||||
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理 (${d.containerName}),已跳过`, docker: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 编译检查: 如果 Go 服务二进制不存在,先编译
|
||||
if (this.needsBuild(id)) {
|
||||
this.emit('log', id, 'system', `未找到编译产物,正在编译 ${svc.name}...`);
|
||||
const buildResult = await this.build(id);
|
||||
if (!buildResult.success) {
|
||||
const errMsg = `编译失败: ${buildResult.message}`;
|
||||
this.emit('log', id, 'error', errMsg);
|
||||
results.push({ id, success: false, message: errMsg });
|
||||
continue;
|
||||
}
|
||||
this.emit('log', id, 'system', `${svc.name} 编译完成`);
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
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} 健康检查通过 ✓`);
|
||||
// Gateway 和 AI-Core 启动后额外等待 2 秒,确保内部路由和 Handler 完全初始化
|
||||
if (id === 'gateway' || id === 'ai-core') {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
this.emit('log', id, 'system', `${svc.name} 已就绪 (额外等待 2s 确保服务稳定)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager();
|
||||
Reference in New Issue
Block a user