fix: DevTools Windows兼容性修复 + 日志UI重构 (7服务并排显示)
- process-manager: 移除ESM中的require(), 跨平台spawn(.exe/.cmd), path.dirname修复 - config: 自动检测Go二进制路径, Windows构建产物使用.exe后缀 - index: 移除SSH隧道代码, 改用Docker容器检查数据库状态 - index.html: 日志默认并排网格布局, 7个服务横向滚动, 数据库面板改用Docker控制 - docs: 更新Migration.md启动顺序(7服务+DevTools自动编译), README添加Windows用法 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+34
-14
@@ -5,11 +5,31 @@
|
||||
|
||||
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';
|
||||
|
||||
/** 跨平台 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 DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
||||
@@ -31,8 +51,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'iot-debug-service': {
|
||||
name: 'IoT Debug',
|
||||
@@ -44,8 +64,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8083/api/v1/health',
|
||||
port: 8083,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
gateway: {
|
||||
name: 'Gateway',
|
||||
@@ -64,8 +84,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'memory-service': {
|
||||
name: '记忆服务',
|
||||
@@ -78,8 +98,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8091/api/v1/health',
|
||||
port: 8091,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'tool-engine': {
|
||||
name: '工具引擎',
|
||||
@@ -93,8 +113,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8092/api/v1/health',
|
||||
port: 8092,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'voice-service': {
|
||||
name: '语音识别服务',
|
||||
@@ -109,8 +129,8 @@ export const SERVICES = {
|
||||
healthUrl: 'http://localhost:8093/api/v1/health',
|
||||
port: 8093,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
frontend: {
|
||||
name: 'Frontend',
|
||||
@@ -122,8 +142,8 @@ export const SERVICES = {
|
||||
},
|
||||
healthUrl: 'http://localhost:5173',
|
||||
port: 5173,
|
||||
nodeBin: '/usr/local/node/bin/node',
|
||||
npmBin: '/usr/local/node/bin/npx',
|
||||
nodeBin: 'node',
|
||||
npmBin: 'npx',
|
||||
// frontend不需要预编译,dev server即可
|
||||
buildCommand: null,
|
||||
},
|
||||
|
||||
+37
-106
@@ -23,7 +23,6 @@ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8
|
||||
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -222,17 +221,11 @@ app.get('/api/dashboard', async (_req, res) => {
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
// 数据库状态(快速检查,不阻塞)
|
||||
// 数据库状态(通过 TCP 端口检查 Docker 容器是否在运行)
|
||||
let dbStatus = { checked: false };
|
||||
try {
|
||||
let tunnelRunning = false;
|
||||
try {
|
||||
// ^ssh 锚点确保只匹配实际的 SSH 进程,排除 pgrep 自身的 shell 包装器
|
||||
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 2000 });
|
||||
tunnelRunning = out.trim().length > 0;
|
||||
} catch { /* 未运行 */ }
|
||||
const port5432Alive = checkPort(5432);
|
||||
dbStatus = { checked: true, tunnelRunning, postgresAlive: port5432Alive };
|
||||
const port5432Alive = await isPortOpen(5432);
|
||||
dbStatus = { checked: true, postgresAlive: port5432Alive };
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
const sysMem = process.memoryUsage();
|
||||
@@ -1036,65 +1029,42 @@ app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
});
|
||||
|
||||
// ---- 数据库状态检查 ----
|
||||
// 需要检查的远程数据库端口映射 (对应 tunnel.sh 中的 SERVICES)
|
||||
const DB_PORTS = [
|
||||
{ port: 5432, name: 'PostgreSQL' },
|
||||
{ port: 6379, name: 'Redis' },
|
||||
{ port: 6334, name: 'Qdrant HTTP' },
|
||||
{ port: 9000, name: 'MinIO API' },
|
||||
{ port: 4222, name: 'NATS' },
|
||||
];
|
||||
import net from 'net';
|
||||
|
||||
/**
|
||||
* 检查本地端口是否在监听 (对应 tunnel 转发的远程服务)
|
||||
*/
|
||||
function checkPort(port) {
|
||||
try {
|
||||
const hexPort = port.toString(16).padStart(4, '0').toUpperCase();
|
||||
const tcpContent = fs.readFileSync('/proc/net/tcp', 'utf-8');
|
||||
for (const line of tcpContent.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
const localAddr = parts[1].split(':')[1];
|
||||
if (localAddr === hexPort) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fallback: try TCP connect */ }
|
||||
// 备用方案: 使用 /dev/tcp (在 bash 中可用)
|
||||
try {
|
||||
execSync(`timeout 1 bash -c "echo >/dev/tcp/127.0.0.1/${port}" 2>/dev/null`, { timeout: 1500 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/database/status', (_req, res) => {
|
||||
// 检查 SSH 隧道进程是否运行
|
||||
let tunnelRunning = false;
|
||||
try {
|
||||
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 3000 });
|
||||
tunnelRunning = out.trim().length > 0;
|
||||
} catch { /* 没找到进程 */ }
|
||||
app.get('/api/database/status', async (_req, res) => {
|
||||
const DB_PORTS = [
|
||||
{ port: 5432, name: 'PostgreSQL' },
|
||||
{ port: 6379, name: 'Redis' },
|
||||
{ port: 6334, name: 'Qdrant gRPC' },
|
||||
{ port: 9000, name: 'MinIO API' },
|
||||
{ port: 4222, name: 'NATS' },
|
||||
];
|
||||
|
||||
// 检查各端口
|
||||
const ports = DB_PORTS.map(({ port, name }) => {
|
||||
const alive = checkPort(port);
|
||||
const results = await Promise.all(DB_PORTS.map(async ({ port, name }) => {
|
||||
const alive = await isPortOpen(port);
|
||||
return { port, name, alive };
|
||||
});
|
||||
}));
|
||||
|
||||
const allAlive = ports.every(p => p.alive);
|
||||
const aliveCount = ports.filter(p => p.alive).length;
|
||||
const allAlive = results.every(p => p.alive);
|
||||
const aliveCount = results.filter(p => p.alive).length;
|
||||
|
||||
// 尝试获取 PostgreSQL 详情
|
||||
let pgDetails = null;
|
||||
if (ports.find(p => p.port === 5432)?.alive) {
|
||||
const pgPort = results.find(p => p.port === 5432);
|
||||
if (pgPort?.alive) {
|
||||
try {
|
||||
// 读取 .env 获取凭据
|
||||
const envPath = path.join(ROOT, 'backend', '.env');
|
||||
let pgUser = 'cyrene', pgPass = 'change_me', pgDb = 'cyrene_ai';
|
||||
let pgUser = 'cyrene', pgPass = 'cyrene_pass', pgDb = 'cyrene_ai';
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
const mUser = envContent.match(/^POSTGRES_USER=(.+)$/m);
|
||||
@@ -1105,8 +1075,8 @@ app.get('/api/database/status', (_req, res) => {
|
||||
if (mDb) pgDb = mDb[1];
|
||||
}
|
||||
const out = execSync(
|
||||
`PGPASSWORD="${pgPass}" psql -h localhost -p 5432 -U "${pgUser}" -d "${pgDb}" -t -c "SELECT count(*) FROM memories;" 2>/dev/null`,
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
`docker exec cyrene_postgres psql -U "${pgUser}" -d "${pgDb}" -t -c "SELECT count(*) FROM memories;" 2>nul`,
|
||||
{ encoding: 'utf-8', timeout: 5000, windowsHide: true }
|
||||
);
|
||||
const match = out.match(/(\d+)/);
|
||||
pgDetails = { memories: match ? parseInt(match[1]) : 0, database: pgDb };
|
||||
@@ -1115,8 +1085,7 @@ app.get('/api/database/status', (_req, res) => {
|
||||
|
||||
res.json({
|
||||
timestamp: Date.now(),
|
||||
tunnelRunning,
|
||||
ports,
|
||||
ports: results,
|
||||
allAlive,
|
||||
aliveCount,
|
||||
totalPorts: DB_PORTS.length,
|
||||
@@ -1124,55 +1093,17 @@ app.get('/api/database/status', (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 隧道控制 ----
|
||||
app.post('/api/tunnel/:action', (req, res) => {
|
||||
const { action } = req.params;
|
||||
if (!['start', 'stop', 'restart', 'status'].includes(action)) {
|
||||
return res.status(400).json({ error: `不支持的操作: ${action},支持: start/stop/restart/status` });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(TUNNEL_SCRIPT)) {
|
||||
return res.status(404).json({ error: `隧道脚本不存在: ${TUNNEL_SCRIPT}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const out = execSync(`bash "${TUNNEL_SCRIPT}" ${action}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 20000,
|
||||
cwd: path.join(ROOT, 'scripts'),
|
||||
});
|
||||
res.json({ success: true, action, output: out.trim() });
|
||||
} catch (err) {
|
||||
// tunnel.sh 可能返回非零退出码但仍成功(如 start 时发现已在运行)
|
||||
const output = err.stdout || err.stderr || err.message;
|
||||
res.json({
|
||||
success: false,
|
||||
action,
|
||||
output: output.trim(),
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 数据库控制 (Docker Compose) ----
|
||||
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
|
||||
const DB_PORT = 5432;
|
||||
|
||||
// GET /api/db/status
|
||||
app.get('/api/db/status', (_req, res) => {
|
||||
app.get('/api/db/status', async (_req, res) => {
|
||||
try {
|
||||
const online = checkPort(DB_PORT);
|
||||
res.json({
|
||||
online,
|
||||
port: DB_PORT,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
online: false,
|
||||
port: DB_PORT,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
const online = await isPortOpen(DB_PORT);
|
||||
res.json({ online, port: DB_PORT, checked_at: new Date().toISOString() });
|
||||
} catch {
|
||||
res.json({ online: false, port: DB_PORT, checked_at: new Date().toISOString() });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -16,6 +17,7 @@ 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';
|
||||
|
||||
/**
|
||||
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
|
||||
@@ -149,7 +151,7 @@ class ProcessManager extends EventEmitter {
|
||||
|
||||
// 确保日志目录存在
|
||||
const logPath = logFile(serviceId);
|
||||
const logDir = logPath.substring(0, logPath.lastIndexOf('/'));
|
||||
const logDir = path.dirname(logPath);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
@@ -157,10 +159,10 @@ class ProcessManager extends EventEmitter {
|
||||
// 确定二进制路径或命令
|
||||
let command, args;
|
||||
if (svc.command === './main') {
|
||||
command = svc.command;
|
||||
command = isWin ? './main.exe' : './main';
|
||||
args = svc.args || [];
|
||||
} else if (svc.command === 'npx') {
|
||||
command = svc.npmBin || 'npx';
|
||||
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
|
||||
args = svc.args || [];
|
||||
} else {
|
||||
command = svc.command;
|
||||
@@ -168,12 +170,14 @@ class ProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
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: false,
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
@@ -407,13 +411,25 @@ class ProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序启动所有服务 (ai-core → gateway → frontend)
|
||||
* 检查服务是否需要编译 (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 → tool-engine → iot → voice → ai-core → gateway → frontend)
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
async startAllSequential() {
|
||||
const order = ['memory-service', 'tool-engine', 'iot-debug-service', 'ai-core', 'gateway', 'frontend'];
|
||||
const order = ['memory-service', 'tool-engine', 'iot-debug-service', 'voice-service', 'ai-core', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
// 先尝试接管已运行的服务
|
||||
@@ -422,12 +438,25 @@ class ProcessManager extends EventEmitter {
|
||||
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;
|
||||
@@ -453,7 +482,7 @@ class ProcessManager extends EventEmitter {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user