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:
2026-05-22 21:05:07 +08:00
parent 697ed72db4
commit e4d2eab9ad
6 changed files with 228 additions and 203 deletions
+34 -14
View File
@@ -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
View File
@@ -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() });
}
});
+39 -10
View File
@@ -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;
}
}