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:
+49
-19
@@ -244,17 +244,46 @@ docker-compose -f docker-compose.dev.db.yml up -d
|
||||
|
||||
### 6.5 启动顺序
|
||||
|
||||
服务启动顺序很重要,请按以下顺序启动:
|
||||
**推荐使用 DevTools 一键启动**(自动编译 + 按序启动 + 健康检查):
|
||||
|
||||
| 顺序 | 服务 | 默认端口 | 命令 |
|
||||
|------|------|---------|------|
|
||||
| 1 | memory-service | — | `cd backend\memory-service && .\main.exe` |
|
||||
| 2 | iot-debug-service | 8083 | `cd backend\iot-debug-service && .\main.exe` |
|
||||
| 3 | tool-engine | — | `cd backend\tool-engine && .\main.exe` |
|
||||
| 4 | ai-core | 8081 | `cd backend\ai-core && .\main.exe` |
|
||||
| 5 | gateway | 8080 | `cd backend\gateway && .\main.exe` |
|
||||
```cmd
|
||||
cd devtools
|
||||
node src\index.js
|
||||
:: 浏览器打开 http://localhost:9090,点击「一键启动」
|
||||
```
|
||||
|
||||
> 每个服务需要在独立的终端窗口中启动。
|
||||
或使用启动脚本:
|
||||
|
||||
```cmd
|
||||
devtools.bat
|
||||
```
|
||||
|
||||
DevTools 会按以下顺序自动编译并启动所有 7 个服务:
|
||||
|
||||
| 顺序 | 服务 | 端口 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | memory-service | 8091 | 记忆 CRUD 与检索 |
|
||||
| 2 | tool-engine | 8092 | 工具执行引擎 |
|
||||
| 3 | iot-debug-service | 8083 | 模拟智能家居设备 |
|
||||
| 4 | voice-service | 8093 | TTS/STT 语音服务 |
|
||||
| 5 | ai-core | 8081 | LLM 推理与编排 |
|
||||
| 6 | gateway | 8080 | API 网关 / JWT / WebSocket |
|
||||
| 7 | frontend | 5173 | React 开发服务器 |
|
||||
|
||||
> 每个步骤会自动等待健康检查通过后再启动下一个服务。如果 Go 二进制未编译,DevTools 会自动先编译再启动。
|
||||
|
||||
如需手动逐个启动:
|
||||
|
||||
```powershell
|
||||
# 按顺序执行,每个在独立终端中运行
|
||||
cd backend\memory-service && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd backend\tool-engine && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd backend\iot-debug-service && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd backend\voice-service && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd backend\ai-core && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd backend\gateway && go build -o main.exe .\cmd\main.go && .\main.exe
|
||||
cd frontend\web && npm install && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -364,16 +393,17 @@ find backend -name "*.go" -exec dos2unix {} \;
|
||||
| 2 | ✅ 前端构建成功 | `cd frontend\web && npm run build` 无报错 |
|
||||
| 3 | ✅ 数据库连接正常 | 使用 `psql` 或数据库客户端连接 PostgreSQL |
|
||||
| 4 | ✅ pgvector 扩展已安装 | `SELECT * FROM pg_extension WHERE extname='vector';` 返回一行 |
|
||||
| 5 | ✅ memory-service 启动成功 | 无 panic 日志,监听预期端口 |
|
||||
| 6 | ✅ iot-debug-service 启动成功 | 访问 `http://localhost:8083/health` 返回 200 |
|
||||
| 7 | ✅ tool-engine 启动成功 | 无 panic 日志 |
|
||||
| 8 | ✅ ai-core 启动成功 | 访问 `http://localhost:8081/health` 返回 200 |
|
||||
| 9 | ✅ gateway 启动成功 | 访问 `http://localhost:8080/health` 返回 200 |
|
||||
| 10 | ✅ 前端开发服务器启动 | 访问 `http://localhost:5173` 显示登录页面 |
|
||||
| 11 | ✅ WebSocket 连接正常 | 登录后聊天功能正常,能收到 AI 回复 |
|
||||
| 12 | ✅ IoT 设备控制正常 | 发送 IoT 控制指令,设备响应正确 |
|
||||
| 13 | ✅ 语音合成 (TTS) 正常 | AI 回复能正常播放语音 |
|
||||
| 14 | ✅ 语音识别 (ASR) 正常 | 语音输入能被正确识别 |
|
||||
| 5 | ✅ memory-service 启动成功 | 无 panic 日志,监听 8091 端口 |
|
||||
| 6 | ✅ tool-engine 启动成功 | 无 panic 日志,监听 8092 端口 |
|
||||
| 7 | ✅ iot-debug-service 启动成功 | 访问 `http://localhost:8083/api/v1/health` 返回 200 |
|
||||
| 8 | ✅ voice-service 启动成功 | 访问 `http://localhost:8093/api/v1/health` 返回 200 |
|
||||
| 9 | ✅ ai-core 启动成功 | 访问 `http://localhost:8081/api/v1/health` 返回 200 |
|
||||
| 10 | ✅ gateway 启动成功 | 访问 `http://localhost:8080/api/v1/health` 返回 200 |
|
||||
| 11 | ✅ 前端开发服务器启动 | 访问 `http://localhost:5173` 显示登录页面 |
|
||||
| 12 | ✅ WebSocket 连接正常 | 登录后聊天功能正常,能收到 AI 回复 |
|
||||
| 13 | ✅ IoT 设备控制正常 | 发送 IoT 控制指令,设备响应正确 |
|
||||
| 14 | ✅ 语音合成 (TTS) 正常 | AI 回复能正常播放语音 |
|
||||
| 15 | ✅ 语音识别 (ASR) 正常 | 语音输入能被正确识别 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -94,9 +94,14 @@ docker compose -f docker-compose.dev.db.yml up -d
|
||||
使用 DevTools 一键管理:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
./devtools.sh start all # 编译并启动所有后端 + 前端
|
||||
./devtools.sh status # 查看服务状态
|
||||
./devtools.sh logs ai-core # 查看 AI-Core 日志
|
||||
|
||||
# Windows
|
||||
devtools.bat # 启动 DevTools Web 面板
|
||||
# 浏览器打开 http://localhost:9090,点击「一键启动」
|
||||
```
|
||||
|
||||
或手动逐个启动:
|
||||
|
||||
+64
-54
@@ -695,11 +695,11 @@ const STATE = {
|
||||
serviceStatus: {},
|
||||
// 日志
|
||||
activeLogTab: 'ai-core',
|
||||
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [] },
|
||||
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
|
||||
maxLogLines: 500,
|
||||
logLayout: 'tabs',
|
||||
logLayout: 'grid',
|
||||
// 性能
|
||||
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
|
||||
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
|
||||
// 会话
|
||||
sessionsData: [],
|
||||
sessionsAutoRefresh: null,
|
||||
@@ -868,8 +868,10 @@ function statusBadge(status) {
|
||||
return map[status] || 'badge-stopped';
|
||||
}
|
||||
|
||||
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'tool-engine', 'voice-service'];
|
||||
|
||||
function escapeId(id) {
|
||||
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug' };
|
||||
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'tool-engine': 'Tool Engine', 'voice-service': 'Voice' };
|
||||
return map[id] || id;
|
||||
}
|
||||
|
||||
@@ -2033,21 +2035,24 @@ function renderServicesPanel() {
|
||||
<span class="card-title">📋 实时日志</span>
|
||||
<div class="btn-group">
|
||||
<div class="log-tabs" id="services-log-tabs" style="margin:0"></div>
|
||||
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📐 并列</button>
|
||||
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📋 标签页</button>
|
||||
<button class="btn btn-xs" onclick="clearSvcLogs()">🗑 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="services-log-tabs-panel">
|
||||
<div id="services-log-tabs-panel" style="display:none">
|
||||
<div class="log-container" id="services-log-panel">
|
||||
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="services-log-grid" style="display:none">
|
||||
<div class="cards-grid cards-4">
|
||||
<div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
|
||||
<div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
|
||||
<div class="log-container" id="log-panel-iot-debug-service" style="height:280px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div>
|
||||
<div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
|
||||
<div id="services-log-grid">
|
||||
<div class="svc-log-grid" style="display:flex;gap:12px;overflow-x:auto;padding-bottom:8px">
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-ai-core" style="height:300px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-tool-engine" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Tool Engine</div></div></div>
|
||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2055,14 +2060,19 @@ function renderServicesPanel() {
|
||||
|
||||
renderServiceCards();
|
||||
initSvcLogTabs();
|
||||
renderServiceLog();
|
||||
if (STATE.logLayout === 'grid') {
|
||||
document.getElementById('services-log-tabs').style.display = 'none';
|
||||
ALL_SVC_IDS.forEach(id => renderGridLog(id));
|
||||
} else {
|
||||
renderServiceLog();
|
||||
}
|
||||
}
|
||||
|
||||
function renderServiceCards() {
|
||||
const container = document.getElementById('services-svc-cards');
|
||||
if (!container) return;
|
||||
const status = STATE.serviceStatus;
|
||||
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
|
||||
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
|
||||
|
||||
container.innerHTML = ids.map(id => {
|
||||
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
|
||||
@@ -2096,7 +2106,7 @@ function renderServiceCards() {
|
||||
function initSvcLogTabs() {
|
||||
const tabs = document.getElementById('services-log-tabs');
|
||||
if (!tabs) return;
|
||||
tabs.innerHTML = ['ai-core', 'gateway', 'iot-debug-service', 'frontend'].map(id =>
|
||||
tabs.innerHTML = ALL_SVC_IDS.map(id =>
|
||||
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
|
||||
).join('');
|
||||
}
|
||||
@@ -2141,20 +2151,20 @@ function toggleSvcLogLayout() {
|
||||
const gridPanel = document.getElementById('services-log-grid');
|
||||
const logTabs = document.getElementById('services-log-tabs');
|
||||
|
||||
if (STATE.logLayout === 'tabs') {
|
||||
if (STATE.logLayout === 'grid') {
|
||||
STATE.logLayout = 'tabs';
|
||||
gridPanel.style.display = 'none';
|
||||
tabsPanel.style.display = '';
|
||||
logTabs.style.display = '';
|
||||
btn.textContent = '📐 并列';
|
||||
renderServiceLog();
|
||||
} else {
|
||||
STATE.logLayout = 'grid';
|
||||
tabsPanel.style.display = 'none';
|
||||
gridPanel.style.display = '';
|
||||
logTabs.style.display = 'none';
|
||||
btn.textContent = '📋 标签页';
|
||||
['ai-core', 'gateway', 'iot-debug-service', 'frontend'].forEach(id => renderGridLog(id));
|
||||
} else {
|
||||
STATE.logLayout = 'tabs';
|
||||
tabsPanel.style.display = '';
|
||||
gridPanel.style.display = 'none';
|
||||
logTabs.style.display = '';
|
||||
btn.textContent = '📐 并列';
|
||||
renderServiceLog();
|
||||
ALL_SVC_IDS.forEach(id => renderGridLog(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2233,7 +2243,7 @@ async function refreshPerf() {
|
||||
function renderPerfPanels(snap) {
|
||||
const container = document.getElementById('perf-panels');
|
||||
if (!container) return;
|
||||
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
|
||||
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ALL_SVC_IDS;
|
||||
|
||||
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
|
||||
const isFirstRender = !container.querySelector('.perf-card');
|
||||
@@ -2315,7 +2325,6 @@ async function renderDatabasePanel() {
|
||||
}
|
||||
|
||||
const ports = data.ports || [];
|
||||
const tunnelRunning = data.tunnelRunning;
|
||||
const allAlive = data.allAlive;
|
||||
const aliveCount = data.aliveCount;
|
||||
const totalPorts = data.totalPorts;
|
||||
@@ -2342,13 +2351,13 @@ async function renderDatabasePanel() {
|
||||
'<!-- 概览 -->' +
|
||||
'<div class="card">' +
|
||||
'<div class="card-header">' +
|
||||
'<span class="card-title">🔌 SSH 隧道状态</span>' +
|
||||
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</span>' +
|
||||
'<span class="card-title">🐳 数据库连接状态</span>' +
|
||||
'<span class="badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error')) + '" id="db-tunnel-badge">' + (allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线')) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="db-summary">' +
|
||||
'<div class="db-summary-stat">' +
|
||||
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
|
||||
'<div class="lbl">数据库端口通联</div>' +
|
||||
'<div class="lbl">容器端口通联</div>' +
|
||||
'</div>' +
|
||||
(pg ?
|
||||
'<div class="db-summary-stat">' +
|
||||
@@ -2377,16 +2386,15 @@ async function renderDatabasePanel() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<!-- 隧道操作 -->' +
|
||||
'<!-- 数据库容器控制 -->' +
|
||||
'<div class="card">' +
|
||||
'<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>' +
|
||||
'<div class="card-header"><span class="card-title">🕹️ 数据库容器控制</span></div>' +
|
||||
'<div class="btn-group" style="margin-bottom:8px">' +
|
||||
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
|
||||
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="tunnelAction(\'stop\')"' + (!tunnelRunning ? ' disabled' : '') + '>⏹ 停止隧道</button>' +
|
||||
'<button class="btn btn-sm" onclick="tunnelAction(\'restart\')">🔄 重启隧道</button>' +
|
||||
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
|
||||
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="dbContainerAction(\'start\')"' + (allAlive ? ' disabled' : '') + '>▶ 启动</button>' +
|
||||
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="dbContainerAction(\'stop\')"' + (aliveCount === 0 ? ' disabled' : '') + '>⏹ 停止</button>' +
|
||||
'<button class="btn btn-sm" onclick="dbContainerAction(\'restart\')">🔄 重启</button>' +
|
||||
'</div>' +
|
||||
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (tunnelRunning && !allAlive ? 'block' : 'none') + '">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' +
|
||||
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (aliveCount > 0 && !allAlive ? 'block' : 'none') + '">⚠️ 部分容器端口不通,请尝试重启 Docker 容器</div>' +
|
||||
'<div id="tunnel-log-container" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
||||
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
|
||||
@@ -2396,13 +2404,13 @@ async function renderDatabasePanel() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<!-- 数据库连接信息 -->' +
|
||||
'<!-- 连接信息 -->' +
|
||||
'<div class="card">' +
|
||||
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
|
||||
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
|
||||
'<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
|
||||
'<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>' +
|
||||
'<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
|
||||
'<div>🐳 Docker Compose: <code style="color:var(--text)">docker compose -f docker-compose.dev.db.yml up -d</code></div>' +
|
||||
'<div>📁 配置文件: <code style="color:var(--text)">docker-compose.dev.db.yml</code></div>' +
|
||||
'<div>💡 所有数据库服务运行在本地 Docker 容器中,端口映射至 <code style="color:var(--text)">localhost</code></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
STATE.dbInitialized = true;
|
||||
@@ -2410,11 +2418,11 @@ async function renderDatabasePanel() {
|
||||
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
|
||||
var el;
|
||||
|
||||
// 隧道状态徽章
|
||||
// 数据库状态徽章
|
||||
el = document.getElementById('db-tunnel-badge');
|
||||
if (el) {
|
||||
el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped');
|
||||
el.textContent = tunnelRunning ? '运行中' : '未运行';
|
||||
el.className = 'badge ' + (allAlive ? 'badge-running' : (aliveCount > 0 ? 'badge-starting' : 'badge-error'));
|
||||
el.textContent = allAlive ? '全部在线' : (aliveCount > 0 ? '部分在线' : '离线');
|
||||
}
|
||||
|
||||
// 端口通联计数
|
||||
@@ -2453,13 +2461,13 @@ async function renderDatabasePanel() {
|
||||
|
||||
// 更新按钮 disable 状态
|
||||
el = document.getElementById('db-tunnel-start');
|
||||
if (el) el.disabled = !!(tunnelRunning && allAlive);
|
||||
if (el) el.disabled = !!allAlive;
|
||||
el = document.getElementById('db-tunnel-stop');
|
||||
if (el) el.disabled = !tunnelRunning;
|
||||
if (el) el.disabled = !(aliveCount > 0);
|
||||
|
||||
// 僵尸警告
|
||||
// 部分在线警告
|
||||
el = document.getElementById('db-zombie-warn');
|
||||
if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none';
|
||||
if (el) el.style.display = (aliveCount > 0 && !allAlive) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2467,23 +2475,25 @@ function refreshDatabasePanel() {
|
||||
renderDatabasePanel();
|
||||
}
|
||||
|
||||
async function tunnelAction(action) {
|
||||
showToast(`正在执行: ${action} 隧道...`, 'info');
|
||||
async function dbContainerAction(action) {
|
||||
var labelMap = { start: '启动', stop: '停止', restart: '重启' };
|
||||
var label = labelMap[action] || action;
|
||||
showToast('正在' + label + '数据库容器...', 'info');
|
||||
const logContainer = document.getElementById('tunnel-log-container');
|
||||
const logEl = document.getElementById('tunnel-log');
|
||||
logEl.textContent = '执行中...';
|
||||
logContainer.style.display = 'block';
|
||||
|
||||
const data = await api(`/api/tunnel/${action}`, { method: 'POST' });
|
||||
const data = await api('/api/db/' + action, { method: 'POST' });
|
||||
if (data.error && !data.output) {
|
||||
logEl.textContent = `错误: ${data.error}`;
|
||||
showToast(`操作失败: ${data.error}`, 'error');
|
||||
logEl.textContent = '错误: ' + data.error;
|
||||
showToast('操作失败: ' + data.error, 'error');
|
||||
} else {
|
||||
logEl.textContent = data.output || data.error || '(无输出)';
|
||||
if (data.success) {
|
||||
showToast(`${action} 隧道完成`, 'success');
|
||||
showToast(label + '数据库完成', 'success');
|
||||
} else {
|
||||
showToast(`${action} 完成 (查看日志)`, 'info');
|
||||
showToast(label + '完成 (查看日志)', 'info');
|
||||
}
|
||||
setTimeout(refreshDatabasePanel, 1500);
|
||||
}
|
||||
|
||||
+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