e4d2eab9ad
- 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>
1196 lines
41 KiB
JavaScript
1196 lines
41 KiB
JavaScript
/**
|
||
* Cyrene DevTools - 主入口
|
||
*
|
||
* 提供:
|
||
* - REST API: 服务管理、状态查询、性能分析、健康检查代理
|
||
* - WebSocket: 实时日志推送
|
||
* - Web UI: 管理控制台
|
||
*/
|
||
|
||
import express from 'express';
|
||
import { WebSocketServer, WebSocket } from 'ws';
|
||
import http from 'http';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { execSync, spawn } from 'child_process';
|
||
|
||
import { processManager } from './process-manager.js';
|
||
import { performanceMonitor } from './performance.js';
|
||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||
|
||
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
||
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// ========== 初始化 ==========
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
// 静态文件 - Web控制台
|
||
app.use(express.static(path.join(__dirname, '../public')));
|
||
|
||
// ========== WebSocket ==========
|
||
const server = http.createServer(app);
|
||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||
|
||
/** @type {Set<WebSocket>} */
|
||
const wsClients = new Set();
|
||
|
||
wss.on('connection', (ws) => {
|
||
wsClients.add(ws);
|
||
ws.on('close', () => wsClients.delete(ws));
|
||
});
|
||
|
||
/** 广播到所有WebSocket客户端 */
|
||
function broadcast(type, data) {
|
||
const msg = JSON.stringify({ type, data, ts: Date.now() });
|
||
for (const ws of wsClients) {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(msg);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 日志事件 -> WebSocket广播
|
||
processManager.on('log', (serviceId, stream, text) => {
|
||
broadcast('log', { service: serviceId, stream, text: text.trimEnd() });
|
||
});
|
||
|
||
// 状态变化
|
||
processManager.on('log', (serviceId, stream, text) => {
|
||
if (stream === 'system') {
|
||
broadcast('status', processManager.getStatus());
|
||
}
|
||
});
|
||
|
||
// ========== Gateway 代理辅助函数 ==========
|
||
|
||
/** 缓存的 JWT token 和过期时间 */
|
||
let cachedToken = null;
|
||
let tokenExpiry = 0;
|
||
|
||
/**
|
||
* 获取 Gateway JWT token (通过 admin 凭据登录,缓存直到过期,支持重试)
|
||
*/
|
||
async function getGatewayToken() {
|
||
if (cachedToken && Date.now() < tokenExpiry - 60000) {
|
||
return cachedToken;
|
||
}
|
||
|
||
const maxRetries = 3;
|
||
const baseDelay = 1000;
|
||
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
try {
|
||
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
if (!resp.ok) {
|
||
if (attempt < maxRetries - 1) {
|
||
const delay = baseDelay * Math.pow(2, attempt);
|
||
console.log(`[Gateway代理] 登录失败 (HTTP ${resp.status}),${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
continue;
|
||
}
|
||
console.error('[Gateway代理] 登录失败:', resp.status);
|
||
return null;
|
||
}
|
||
const data = await resp.json();
|
||
cachedToken = data.token;
|
||
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
|
||
console.log('[Gateway代理] 登录成功,token 已缓存');
|
||
return cachedToken;
|
||
} catch (err) {
|
||
if (attempt < maxRetries - 1) {
|
||
const delay = baseDelay * Math.pow(2, attempt);
|
||
console.log(`[Gateway代理] 登录异常: ${err.message},${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
continue;
|
||
}
|
||
console.error('[Gateway代理] 登录异常 (已达最大重试次数):', err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 代理请求到 Gateway,自动携带 JWT token
|
||
* @param {string} path - Gateway API 路径 (如 /api/v1/memory/search?user_id=...)
|
||
* @param {object} opts - fetch 选项
|
||
*/
|
||
async function proxyToGateway(path, opts = {}) {
|
||
const token = await getGatewayToken();
|
||
if (!token) {
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: '无法连接到 Gateway 认证服务',
|
||
errorType: 'gateway_auth_failed',
|
||
hint: '请确认 Gateway 服务已启动 (端口 8080)',
|
||
},
|
||
};
|
||
}
|
||
|
||
const url = `${GATEWAY_URL}${path}`;
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`,
|
||
...opts.headers,
|
||
};
|
||
|
||
try {
|
||
const resp = await fetch(url, { ...opts, headers, signal: AbortSignal.timeout(15000) });
|
||
const body = await resp.json().catch(() => null);
|
||
return { status: resp.status, body };
|
||
} catch (err) {
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: `Gateway 不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'gateway_not_running' : 'gateway_unreachable',
|
||
hint: isConnRefused
|
||
? 'Gateway 服务未启动,请先在「服务管理」面板中启动 Gateway'
|
||
: 'Gateway 服务无响应,请检查网络连接和服务状态',
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// ========== REST API 路由 ==========
|
||
|
||
// ---- 健康检查 ----
|
||
app.get('/api/health', (_req, res) => {
|
||
res.json({
|
||
status: 'ok',
|
||
service: 'cyrene-devtools',
|
||
uptime: process.uptime(),
|
||
wsClients: wsClients.size,
|
||
});
|
||
});
|
||
|
||
// ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ----
|
||
app.get('/api/dashboard', async (_req, res) => {
|
||
try {
|
||
const [services, perfSnapshot, sessionsResult] = await Promise.all([
|
||
Promise.resolve(processManager.getStatus()),
|
||
performanceMonitor.getSnapshot(),
|
||
proxyToGateway('/api/v1/admin/sessions').catch(() => ({ status: 502, body: { sessions: [], total: 0 } })),
|
||
]);
|
||
|
||
let runningCount = 0, totalCpu = 0, totalMem = 0;
|
||
for (const svc of Object.values(services)) {
|
||
if (svc.status === 'running') runningCount++;
|
||
}
|
||
for (const p of Object.values(perfSnapshot)) {
|
||
totalCpu += p.cpu || 0;
|
||
totalMem += p.mem || 0;
|
||
}
|
||
|
||
const sessionsData = sessionsResult.body || {};
|
||
const activeSessions = sessionsData.total || sessionsData.sessions?.length || 0;
|
||
let totalMessages = 0;
|
||
if (sessionsData.sessions) {
|
||
for (const s of sessionsData.sessions) {
|
||
totalMessages += (s.message_count || 0);
|
||
}
|
||
}
|
||
|
||
let memoryCount = null;
|
||
try {
|
||
const token = await getGatewayToken();
|
||
if (token) {
|
||
const memResp = await fetch(`${GATEWAY_URL}/api/v1/memory?user_id=admin`, {
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
if (memResp.ok) {
|
||
const memData = await memResp.json();
|
||
memoryCount = Array.isArray(memData) ? memData.length : (memData.memories ? memData.memories.length : null);
|
||
}
|
||
}
|
||
} catch { /* 忽略 */ }
|
||
|
||
// 数据库状态(通过 TCP 端口检查 Docker 容器是否在运行)
|
||
let dbStatus = { checked: false };
|
||
try {
|
||
const port5432Alive = await isPortOpen(5432);
|
||
dbStatus = { checked: true, postgresAlive: port5432Alive };
|
||
} catch { /* 忽略 */ }
|
||
|
||
const sysMem = process.memoryUsage();
|
||
|
||
res.json({
|
||
timestamp: Date.now(),
|
||
services: { total: Object.keys(services).length, running: runningCount, list: services },
|
||
performance: { totalCpu: Math.round(totalCpu * 100) / 100, totalMem: Math.round(totalMem * 100) / 100, perService: perfSnapshot },
|
||
sessions: { active: activeSessions, totalMessages },
|
||
memory: { total: memoryCount },
|
||
database: dbStatus,
|
||
system: { heapUsedMB: Math.round(sysMem.heapUsed / 1024 / 1024 * 100) / 100, heapTotalMB: Math.round(sysMem.heapTotal / 1024 / 1024 * 100) / 100, uptime: process.uptime() },
|
||
});
|
||
} catch (err) {
|
||
res.status(500).json({ error: `获取仪表盘数据失败: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
// ---- 会话监看代理 (必须在 /api/services/:id 之前) ----
|
||
app.get('/api/sessions', async (_req, res) => {
|
||
const result = await proxyToGateway('/api/v1/admin/sessions');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/sessions/active — 获取按用户分组的活跃会话 (管理员权限)
|
||
app.get('/api/sessions/active', async (_req, res) => {
|
||
const result = await proxyToGateway('/api/v1/admin/sessions/active');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
app.get('/api/sessions/:id', async (req, res) => {
|
||
const result = await proxyToGateway(`/api/v1/admin/sessions/${req.params.id}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---- 记忆管理代理 (必须在 /api/services/:id 之前) ----
|
||
app.get('/api/memory/search', async (req, res) => {
|
||
const { user_id, q } = req.query;
|
||
if (!user_id || !q) return res.status(400).json({ error: '缺少 user_id 或 q 参数' });
|
||
const qs = new URLSearchParams({ user_id, q }).toString();
|
||
const result = await proxyToGateway(`/api/v1/memory/search?${qs}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
app.get('/api/memory/list', async (req, res) => {
|
||
const { user_id } = req.query;
|
||
if (!user_id) return res.status(400).json({ error: '缺少 user_id 参数' });
|
||
const qs = new URLSearchParams({ user_id }).toString();
|
||
const result = await proxyToGateway(`/api/v1/memory?${qs}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
app.post('/api/memory/add', async (req, res) => {
|
||
const { user_id, content, category, priority } = req.body;
|
||
if (!user_id || !content) return res.status(400).json({ error: '缺少 user_id 或 content' });
|
||
const result = await proxyToGateway('/api/v1/memory', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ user_id, content, category: category || 'other', priority: priority || 1 }),
|
||
});
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
app.delete('/api/memory/:id', async (req, res) => {
|
||
const { id } = req.params;
|
||
if (!id) return res.status(400).json({ error: '缺少 memory id' });
|
||
const qs = new URLSearchParams({ id }).toString();
|
||
const result = await proxyToGateway(`/api/v1/memory?${qs}`, { method: 'DELETE' });
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---- 服务状态 ----
|
||
app.get('/api/services', (_req, res) => {
|
||
res.json(processManager.getStatus());
|
||
});
|
||
|
||
app.get('/api/services/:id', (req, res) => {
|
||
const status = processManager.getServiceStatus(req.params.id);
|
||
if (!status) return res.status(404).json({ error: '未知服务' });
|
||
res.json(status);
|
||
});
|
||
|
||
// ---- 服务控制 ----
|
||
app.post('/api/services/:id/start', async (req, res) => {
|
||
try {
|
||
const result = await processManager.start(req.params.id);
|
||
broadcast('status', processManager.getStatus());
|
||
res.json(result);
|
||
} catch (err) {
|
||
res.status(400).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/services/:id/stop', async (req, res) => {
|
||
try {
|
||
const result = await processManager.stop(req.params.id);
|
||
broadcast('status', processManager.getStatus());
|
||
res.json(result);
|
||
} catch (err) {
|
||
res.status(400).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/services/:id/restart', async (req, res) => {
|
||
try {
|
||
// 异步重启,因为可能耗时较长
|
||
res.json({ success: true, message: '重启中...' });
|
||
const result = await processManager.restart(req.params.id);
|
||
broadcast('status', processManager.getStatus());
|
||
} catch (err) {
|
||
// 已经在上面res了
|
||
}
|
||
});
|
||
|
||
app.post('/api/services/:id/build', async (req, res) => {
|
||
try {
|
||
const result = await processManager.build(req.params.id);
|
||
res.json(result);
|
||
} catch (err) {
|
||
res.status(400).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 批量操作
|
||
// 一键按顺序启动 (接管已运行 + 健康检查)
|
||
app.post('/api/services/start-all', async (_req, res) => {
|
||
const results = await processManager.startAllSequential();
|
||
broadcast('status', processManager.getStatus());
|
||
res.json(results);
|
||
});
|
||
|
||
// 强制重启全部 (先杀后启)
|
||
app.post('/api/services/start-all-fresh', async (_req, res) => {
|
||
await processManager.stopAll();
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
const results = await processManager.startAllSequential();
|
||
broadcast('status', processManager.getStatus());
|
||
res.json(results);
|
||
});
|
||
|
||
app.post('/api/services/stop-all', async (_req, res) => {
|
||
const results = await processManager.stopAll();
|
||
broadcast('status', processManager.getStatus());
|
||
res.json(results);
|
||
});
|
||
|
||
// ---- 性能监控 ----
|
||
app.get('/api/performance', async (_req, res) => {
|
||
const snapshot = await performanceMonitor.getSnapshot();
|
||
res.json(snapshot);
|
||
});
|
||
|
||
// 性能仪表盘聚合数据 (供首页仪表盘使用)
|
||
app.get('/api/performance/dashboard', async (_req, res) => {
|
||
try {
|
||
const dashboardData = await performanceMonitor.updateDashboard();
|
||
res.json(dashboardData);
|
||
} catch (err) {
|
||
res.status(500).json({ error: `获取性能仪表盘数据失败: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
app.get('/api/performance/history', (_req, res) => {
|
||
res.json(performanceMonitor.getAllHistory());
|
||
});
|
||
|
||
app.get('/api/performance/:id', (req, res) => {
|
||
const history = performanceMonitor.getHistory(req.params.id);
|
||
res.json(history);
|
||
});
|
||
|
||
app.get('/api/performance/:id/summary', async (req, res) => {
|
||
const snap = await performanceMonitor.getSnapshot();
|
||
const history = performanceMonitor.getHistory(req.params.id);
|
||
const svc = snap[req.params.id];
|
||
if (!svc) return res.status(404).json({ error: '未知服务' });
|
||
|
||
// 计算汇总统计
|
||
let maxCpu = 0, maxMem = 0;
|
||
const cpuValues = [], memValues = [];
|
||
for (const h of history) {
|
||
if (h.cpu > maxCpu) maxCpu = h.cpu;
|
||
if (h.mem > maxMem) maxMem = h.mem;
|
||
cpuValues.push(h.cpu);
|
||
memValues.push(h.mem);
|
||
}
|
||
|
||
const avgCpu = cpuValues.length > 0 ? Math.round(cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length * 100) / 100 : 0;
|
||
const avgMem = memValues.length > 0 ? Math.round(memValues.reduce((a, b) => a + b, 0) / memValues.length * 100) / 100 : 0;
|
||
|
||
res.json({
|
||
current: svc,
|
||
history: { count: history.length, maxCpu, maxMem, avgCpu, avgMem },
|
||
});
|
||
});
|
||
|
||
// ---- 日志查询 ----
|
||
app.get('/api/logs/:id', (req, res) => {
|
||
const id = req.params.id;
|
||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||
|
||
const filePath = logFile(id);
|
||
const lines = req.query.lines ? parseInt(req.query.lines) : 200;
|
||
const offset = req.query.offset ? parseInt(req.query.offset) : 0;
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
return res.json({ service: id, lines: [], total: 0 });
|
||
}
|
||
|
||
try {
|
||
// 使用tail方式读取
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
const allLines = content.split('\n').filter(Boolean);
|
||
const total = allLines.length;
|
||
const sliced = allLines.slice(Math.max(0, total - lines - offset), total - offset);
|
||
res.json({ service: id, lines: sliced, total });
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/logs/:id/recent', (req, res) => {
|
||
const id = req.params.id;
|
||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||
|
||
const filePath = logFile(id);
|
||
if (!fs.existsSync(filePath)) {
|
||
return res.json({ service: id, lines: [], total: 0 });
|
||
}
|
||
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
const allLines = content.split('\n').filter(Boolean);
|
||
const total = allLines.length;
|
||
res.json({ service: id, lines: allLines.slice(-100), total });
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// 删除日志
|
||
app.delete('/api/logs/:id', (req, res) => {
|
||
const id = req.params.id;
|
||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||
|
||
const filePath = logFile(id);
|
||
try {
|
||
if (fs.existsSync(filePath)) {
|
||
fs.writeFileSync(filePath, '');
|
||
}
|
||
res.json({ success: true, message: '日志已清空' });
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// ---- IoT 设备管理 (代理到 iot-debug-service) ----
|
||
const IOT_SERVICE_URL = process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083';
|
||
|
||
/** 通用 IoT 代理:转发请求到 iot-debug-service */
|
||
async function proxyToIoT(path, opts = {}) {
|
||
const url = `${IOT_SERVICE_URL}${path}`;
|
||
const logPrefix = `[IoT代理]`;
|
||
try {
|
||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||
const resp = await fetch(url, {
|
||
...opts,
|
||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
const body = await resp.json().catch(() => null);
|
||
if (!resp.ok) {
|
||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||
}
|
||
return { status: resp.status, body };
|
||
} catch (err) {
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: `IoT 调试服务不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'iot_not_running' : 'iot_unreachable',
|
||
hint: isConnRefused
|
||
? 'IoT 调试服务未启动,请先在「服务管理」面板中启动 IoT Debug 服务'
|
||
: 'IoT 调试服务无响应,请检查网络连接和服务状态',
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// GET /api/iot/devices — 获取所有模拟设备
|
||
app.get('/api/iot/devices', async (_req, res) => {
|
||
console.log('[IoT] 获取设备列表');
|
||
const result = await proxyToIoT('/api/v1/devices');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/iot/devices/:id — 获取单个设备
|
||
app.get('/api/iot/devices/:id', async (req, res) => {
|
||
console.log(`[IoT] 获取设备: ${req.params.id}`);
|
||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// POST /api/iot/devices/:id/toggle — 切换设备开关
|
||
app.post('/api/iot/devices/:id/toggle', async (req, res) => {
|
||
console.log(`[IoT] 切换设备: ${req.params.id}`);
|
||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/toggle`, { method: 'POST' });
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// POST /api/iot/devices/:id/set — 设置设备属性
|
||
app.post('/api/iot/devices/:id/set', async (req, res) => {
|
||
const { field, value } = req.body;
|
||
if (!field) {
|
||
return res.status(400).json({ error: '缺少 field 参数' });
|
||
}
|
||
console.log(`[IoT] 设置设备属性: ${req.params.id} -> ${field} = ${value}`);
|
||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/set`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ field, value }),
|
||
});
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/iot/devices/:id/history — 获取设备操作历史
|
||
app.get('/api/iot/devices/:id/history', async (req, res) => {
|
||
console.log(`[IoT] 获取设备历史: ${req.params.id}`);
|
||
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/history`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---- 工具调用记录代理 (转发到 tool-engine) ----
|
||
|
||
/**
|
||
* 代理请求到 Tool-Engine
|
||
* @param {string} path - Tool-Engine API 路径
|
||
* @param {object} opts - fetch 选项
|
||
*/
|
||
async function proxyToToolEngine(path, opts = {}) {
|
||
const url = `${TOOL_ENGINE_URL}${path}`;
|
||
const logPrefix = `[ToolEngine代理]`;
|
||
try {
|
||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||
const resp = await fetch(url, {
|
||
...opts,
|
||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
const body = await resp.json().catch(() => null);
|
||
if (!resp.ok) {
|
||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||
}
|
||
return { status: resp.status, body };
|
||
} catch (err) {
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: `Tool-Engine 不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'tool_engine_not_running' : 'tool_engine_unreachable',
|
||
hint: isConnRefused
|
||
? 'Tool-Engine 服务未启动,请先在「服务管理」面板中启动 Tool-Engine'
|
||
: 'Tool-Engine 服务无响应,请检查网络连接和服务状态',
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// GET /api/tool-calls — 查询工具调用记录
|
||
app.get('/api/tool-calls', async (req, res) => {
|
||
const { tool_name, page, limit } = req.query;
|
||
const params = new URLSearchParams();
|
||
if (tool_name) params.set('tool_name', tool_name);
|
||
params.set('page', page || '1');
|
||
params.set('limit', limit || '20');
|
||
const result = await proxyToToolEngine(`/api/v1/tools/calls?${params.toString()}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/tool-calls/stats — 工具调用统计
|
||
app.get('/api/tool-calls/stats', async (_req, res) => {
|
||
const result = await proxyToToolEngine('/api/v1/tools/calls/stats');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---- STT 处理日志存储 (内存环形缓冲区) ----
|
||
const sttLogEntries = [];
|
||
const MAX_STT_LOGS = 200;
|
||
|
||
/**
|
||
* 记录 STT 请求日志(devtools 自身维护,因为 voice-service 无持久化日志)
|
||
*/
|
||
function recordSTTLog(entry) {
|
||
sttLogEntries.unshift(entry);
|
||
if (sttLogEntries.length > MAX_STT_LOGS) {
|
||
sttLogEntries.length = MAX_STT_LOGS;
|
||
}
|
||
// 通过 WebSocket 广播给前端面板实时更新
|
||
broadcast('stt-log', entry);
|
||
}
|
||
|
||
// GET /api/voice/logs — 获取 STT 处理日志
|
||
app.get('/api/voice/logs', (req, res) => {
|
||
const limit = parseInt(req.query.limit) || 50;
|
||
const logs = sttLogEntries.slice(0, limit);
|
||
res.json({
|
||
total: sttLogEntries.length,
|
||
logs,
|
||
});
|
||
});
|
||
|
||
// POST /api/voice/transcribe — 代理到 Voice-Service 并记录日志
|
||
// 接受 JSON (base64 音频) 并转发为 multipart/form-data 到 Voice-Service
|
||
app.post('/api/voice/transcribe', async (req, res) => {
|
||
const startTime = Date.now();
|
||
let { audio_base64, language, filename } = req.body || {};
|
||
|
||
// 也支持直接通过 FormData 上传 (express.raw 中间件处理后手动解析)
|
||
if (!audio_base64 && req.is('multipart/form-data')) {
|
||
return res.status(400).json({ error: 'multipart/form-data 暂不支持,请使用 JSON 格式发送 base64 编码的音频' });
|
||
}
|
||
|
||
if (!audio_base64) {
|
||
return res.status(400).json({ error: '缺少 audio_base64 字段' });
|
||
}
|
||
|
||
// 计算音频大小 (解码后)
|
||
let audioBuffer;
|
||
try {
|
||
audioBuffer = Buffer.from(audio_base64, 'base64');
|
||
} catch {
|
||
return res.status(400).json({ error: 'audio_base64 格式无效,无法解码' });
|
||
}
|
||
const audioSizeBytes = audioBuffer.length;
|
||
// 估算音频时长 (WAV 16kHz 16bit mono: ~32000 bytes/sec)
|
||
const estimatedDurationSec = audioSizeBytes > 0 ? (audioSizeBytes / 32000).toFixed(1) : '0';
|
||
|
||
if (!filename) filename = 'audio.wav';
|
||
|
||
try {
|
||
// 构建 multipart/form-data 请求转发到 Voice-Service
|
||
const boundary = '----DevToolsFormBoundary' + Math.random().toString(36).slice(2);
|
||
const crlf = '\r\n';
|
||
const headerParts = [
|
||
'--' + boundary + crlf,
|
||
'Content-Disposition: form-data; name="audio"; filename="' + filename + '"' + crlf,
|
||
'Content-Type: application/octet-stream' + crlf,
|
||
crlf,
|
||
];
|
||
const headerBytes = Buffer.from(headerParts.join(''), 'utf-8');
|
||
const footerBytes = Buffer.from(crlf + '--' + boundary + '--' + crlf, 'utf-8');
|
||
|
||
// 如果有 language 参数
|
||
let languagePart = Buffer.alloc(0);
|
||
if (language) {
|
||
const langHeader = [
|
||
'--' + boundary + crlf,
|
||
'Content-Disposition: form-data; name="language"' + crlf,
|
||
crlf,
|
||
language + crlf,
|
||
];
|
||
languagePart = Buffer.from(langHeader.join(''), 'utf-8');
|
||
}
|
||
|
||
const multipartBody = Buffer.concat([headerBytes, audioBuffer, footerBytes]);
|
||
// 如果需要 language 字段,插入在 audio 字段之后
|
||
const finalBody = languagePart.length > 0
|
||
? Buffer.concat([headerBytes, audioBuffer, languagePart, footerBytes])
|
||
: multipartBody;
|
||
|
||
const voiceResp = await fetch(`${VOICE_SERVICE_URL}/api/v1/transcribe`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||
},
|
||
body: finalBody,
|
||
signal: AbortSignal.timeout(60000),
|
||
});
|
||
|
||
const voiceBody = await voiceResp.json().catch(() => null);
|
||
const elapsedMs = Date.now() - startTime;
|
||
|
||
if (!voiceResp.ok || (voiceBody && voiceBody.error)) {
|
||
const logEntry = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||
timestamp: new Date().toISOString(),
|
||
status: 'error',
|
||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||
estimatedDurationSec,
|
||
language: language || 'zh',
|
||
filename,
|
||
durationMs: elapsedMs,
|
||
text: null,
|
||
error: voiceBody?.error || `HTTP ${voiceResp.status}`,
|
||
};
|
||
recordSTTLog(logEntry);
|
||
return res.status(voiceResp.status).json({
|
||
...voiceBody,
|
||
devtools_log_id: logEntry.id,
|
||
});
|
||
}
|
||
|
||
const logEntry = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||
timestamp: new Date().toISOString(),
|
||
status: 'success',
|
||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||
estimatedDurationSec,
|
||
language: voiceBody?.language || language || 'zh',
|
||
filename,
|
||
durationMs: voiceBody?.duration_ms || elapsedMs,
|
||
text: voiceBody?.text || '',
|
||
textLength: (voiceBody?.text || '').length,
|
||
};
|
||
recordSTTLog(logEntry);
|
||
return res.json({
|
||
...voiceBody,
|
||
devtools_log_id: logEntry.id,
|
||
});
|
||
} catch (err) {
|
||
const elapsedMs = Date.now() - startTime;
|
||
const logEntry = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||
timestamp: new Date().toISOString(),
|
||
status: 'error',
|
||
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
|
||
estimatedDurationSec,
|
||
language: language || 'zh',
|
||
filename,
|
||
durationMs: elapsedMs,
|
||
text: null,
|
||
error: err.message,
|
||
};
|
||
recordSTTLog(logEntry);
|
||
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
return res.status(502).json({
|
||
error: `Voice-Service 不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
|
||
hint: isConnRefused
|
||
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
|
||
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
|
||
devtools_log_id: logEntry.id,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 自主思考日志代理 (转发到 memory-service) ----
|
||
|
||
// ---- 语音识别服务代理 (转发到 voice-service) ----
|
||
|
||
/**
|
||
* 代理请求到 Voice-Service
|
||
* @param {string} path - Voice-Service API 路径
|
||
* @param {object} opts - fetch 选项
|
||
*/
|
||
async function proxyToVoiceService(path, opts = {}) {
|
||
const url = `${VOICE_SERVICE_URL}${path}`;
|
||
const logPrefix = `[VoiceService代理]`;
|
||
try {
|
||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||
const resp = await fetch(url, {
|
||
...opts,
|
||
signal: AbortSignal.timeout(60000), // 语音转录可能需要较长时间
|
||
});
|
||
const body = await resp.json().catch(() => null);
|
||
if (!resp.ok) {
|
||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||
}
|
||
return { status: resp.status, body };
|
||
} catch (err) {
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: `Voice-Service 不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
|
||
hint: isConnRefused
|
||
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
|
||
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// GET /api/voice/status — 获取 STT 服务状态
|
||
app.get('/api/voice/status', async (_req, res) => {
|
||
const result = await proxyToVoiceService('/api/v1/status');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/voice/health — STT 健康检查
|
||
app.get('/api/voice/health', async (_req, res) => {
|
||
const result = await proxyToVoiceService('/api/v1/health');
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
/**
|
||
* 代理请求到 Memory-Service
|
||
* @param {string} path - Memory-Service API 路径
|
||
* @param {object} opts - fetch 选项
|
||
*/
|
||
async function proxyToMemoryService(path, opts = {}) {
|
||
const url = `${MEMORY_SERVICE_URL}${path}`;
|
||
const logPrefix = `[MemoryService代理]`;
|
||
try {
|
||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||
const resp = await fetch(url, {
|
||
...opts,
|
||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
const body = await resp.json().catch(() => null);
|
||
if (!resp.ok) {
|
||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||
}
|
||
return { status: resp.status, body };
|
||
} catch (err) {
|
||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||
return {
|
||
status: 502,
|
||
body: {
|
||
error: `Memory-Service 不可达: ${err.message}`,
|
||
errorType: isConnRefused ? 'memory_service_not_running' : 'memory_service_unreachable',
|
||
hint: isConnRefused
|
||
? 'Memory-Service 服务未启动,请先在「服务管理」面板中启动 Memory-Service'
|
||
: 'Memory-Service 服务无响应,请检查网络连接和服务状态',
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// GET /api/v1/thinking — 查询自主思考日志列表
|
||
app.get('/api/v1/thinking', async (req, res) => {
|
||
const { user_id, limit, offset } = req.query;
|
||
const params = new URLSearchParams();
|
||
if (user_id) params.set('user_id', user_id);
|
||
if (limit) params.set('limit', limit);
|
||
if (offset) params.set('offset', offset);
|
||
const result = await proxyToMemoryService(`/api/v1/thinking?${params.toString()}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// POST /api/v1/thinking — 创建自主思考日志
|
||
app.post('/api/v1/thinking', async (req, res) => {
|
||
const result = await proxyToMemoryService('/api/v1/thinking', {
|
||
method: 'POST',
|
||
body: JSON.stringify(req.body),
|
||
});
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/v1/thinking/stats — 自主思考统计
|
||
app.get('/api/v1/thinking/stats', async (req, res) => {
|
||
const { user_id } = req.query;
|
||
const params = user_id ? `?user_id=${encodeURIComponent(user_id)}` : '';
|
||
const result = await proxyToMemoryService(`/api/v1/thinking/stats${params}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// GET /api/v1/thinking/:id — 获取单条自主思考日志
|
||
app.get('/api/v1/thinking/:id', async (req, res) => {
|
||
const result = await proxyToMemoryService(`/api/v1/thinking/${req.params.id}`);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---- 记忆时间线 (合并记忆 + 思考) ----
|
||
app.get('/api/memory-timeline', async (req, res) => {
|
||
const { user_id, limit } = req.query;
|
||
if (!user_id) {
|
||
return res.status(400).json({ error: '缺少 user_id 参数' });
|
||
}
|
||
const maxItems = parseInt(limit) || 100;
|
||
|
||
try {
|
||
// 并行调用记忆和思考 API
|
||
const memQs = new URLSearchParams({ user_id, limit: String(maxItems) }).toString();
|
||
const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: '0' }).toString();
|
||
|
||
const [memResult, thinkResult] = await Promise.all([
|
||
proxyToMemoryService(`/api/v1/memories?${memQs}`),
|
||
proxyToMemoryService(`/api/v1/thinking?${thinkQs}`),
|
||
]);
|
||
|
||
const memories = [];
|
||
const thinkingLogs = [];
|
||
|
||
// 解析记忆列表
|
||
if (memResult.body && !memResult.body.error) {
|
||
const memData = Array.isArray(memResult.body) ? memResult.body : (memResult.body.memories || memResult.body.results || []);
|
||
for (const m of memData) {
|
||
const createdAt = m.created_at || m.CreatedAt || m.timestamp;
|
||
memories.push({
|
||
id: m.id || m.ID || '',
|
||
type: 'memory',
|
||
title: m.title || (m.content || '').substring(0, 60),
|
||
content: m.content || '',
|
||
summary: m.summary || '',
|
||
importance: m.importance || 1,
|
||
category: m.category || 'other',
|
||
source: m.source || 'unknown',
|
||
session_id: m.session_id || '',
|
||
keywords: m.keywords || [],
|
||
access_count: m.access_count || 0,
|
||
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
|
||
user_id: m.user_id || user_id,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 解析思考日志列表
|
||
if (thinkResult.body && !thinkResult.body.error) {
|
||
const thinkData = thinkResult.body.logs || (Array.isArray(thinkResult.body) ? thinkResult.body : []);
|
||
for (const t of thinkData) {
|
||
const createdAt = t.created_at || t.CreatedAt || t.timestamp;
|
||
// 解析思考主题:提取第一行或前80个字符
|
||
const content = t.content || '';
|
||
const firstLine = content.split('\n')[0] || '';
|
||
const topic = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
|
||
|
||
// 尝试从内容推断触发方式
|
||
let trigger = '定时';
|
||
if (content.includes('用户') || content.includes('手动') || content.includes('manual')) {
|
||
trigger = '手动';
|
||
} else if (content.includes('scheduled') || content.includes('定时') || content.includes('interval')) {
|
||
trigger = '定时';
|
||
}
|
||
|
||
thinkingLogs.push({
|
||
id: t.id || t.ID || '',
|
||
type: 'thinking',
|
||
title: topic || '自主思考',
|
||
content: content,
|
||
summary: content.length > 200 ? content.substring(0, 197) + '...' : content,
|
||
tool_call_count: t.tool_call_count || 0,
|
||
content_length: t.content_length || content.length,
|
||
trigger: trigger,
|
||
tool_calls: t.tool_calls || null,
|
||
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
|
||
user_id: t.user_id || user_id,
|
||
// 思考没有重要性,设为0用于排序
|
||
importance: 0,
|
||
source: 'thinking',
|
||
});
|
||
}
|
||
}
|
||
|
||
// 合并并按时间排序(降序:最新的在前)
|
||
const timeline = [...memories, ...thinkingLogs].sort((a, b) => {
|
||
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||
return tb - ta;
|
||
});
|
||
|
||
// 截取限制条数
|
||
const result = timeline.slice(0, maxItems);
|
||
|
||
// 统计摘要
|
||
const stats = {
|
||
total_memories: memories.length,
|
||
total_thinking: thinkingLogs.length,
|
||
latest_memory_time: memories.length > 0 ? memories.reduce((max, m) => {
|
||
const t = m.timestamp ? new Date(m.timestamp).getTime() : 0;
|
||
return t > max ? t : max;
|
||
}, 0) : null,
|
||
latest_thinking_time: thinkingLogs.length > 0 ? thinkingLogs.reduce((max, t) => {
|
||
const ts = t.timestamp ? new Date(t.timestamp).getTime() : 0;
|
||
return ts > max ? ts : max;
|
||
}, 0) : null,
|
||
};
|
||
|
||
res.json({
|
||
timeline: result,
|
||
stats,
|
||
total: timeline.length,
|
||
user_id,
|
||
});
|
||
} catch (err) {
|
||
console.error('[记忆时间线] 错误:', err.message);
|
||
res.status(500).json({ error: `获取时间线失败: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
// ---- 健康检查代理 ----
|
||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||
const svc = SERVICES[req.params.id];
|
||
if (!svc || !svc.healthUrl) {
|
||
return res.status(404).json({ error: '未知服务或无健康检查端点' });
|
||
}
|
||
try {
|
||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(5000) });
|
||
const data = await resp.json();
|
||
res.json({ proxy: true, service: req.params.id, status: resp.status, data });
|
||
} catch {
|
||
res.json({ proxy: true, service: req.params.id, status: 'unreachable', data: null });
|
||
}
|
||
});
|
||
|
||
// ---- 数据库状态检查 ----
|
||
import net from 'net';
|
||
|
||
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', 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 results = await Promise.all(DB_PORTS.map(async ({ port, name }) => {
|
||
const alive = await isPortOpen(port);
|
||
return { port, name, alive };
|
||
}));
|
||
|
||
const allAlive = results.every(p => p.alive);
|
||
const aliveCount = results.filter(p => p.alive).length;
|
||
|
||
let pgDetails = null;
|
||
const pgPort = results.find(p => p.port === 5432);
|
||
if (pgPort?.alive) {
|
||
try {
|
||
const envPath = path.join(ROOT, 'backend', '.env');
|
||
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);
|
||
const mPass = envContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
|
||
const mDb = envContent.match(/^POSTGRES_DB=(.+)$/m);
|
||
if (mUser) pgUser = mUser[1];
|
||
if (mPass) pgPass = mPass[1];
|
||
if (mDb) pgDb = mDb[1];
|
||
}
|
||
const out = execSync(
|
||
`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 };
|
||
} catch { /* pg query failed */ }
|
||
}
|
||
|
||
res.json({
|
||
timestamp: Date.now(),
|
||
ports: results,
|
||
allAlive,
|
||
aliveCount,
|
||
totalPorts: DB_PORTS.length,
|
||
pgDetails,
|
||
});
|
||
});
|
||
|
||
// ---- 数据库控制 (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', async (_req, res) => {
|
||
try {
|
||
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() });
|
||
}
|
||
});
|
||
|
||
// POST /api/db/start
|
||
app.post('/api/db/start', (_req, res) => {
|
||
try {
|
||
const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||
encoding: 'utf-8',
|
||
timeout: 60000,
|
||
stdio: 'pipe',
|
||
});
|
||
res.json({ success: true, action: 'start', output: out.trim() });
|
||
} catch (err) {
|
||
const stderr = err.stderr?.toString() || err.message;
|
||
res.status(500).json({ success: false, action: 'start', error: stderr });
|
||
}
|
||
});
|
||
|
||
// POST /api/db/stop
|
||
app.post('/api/db/stop', (_req, res) => {
|
||
try {
|
||
const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, {
|
||
encoding: 'utf-8',
|
||
timeout: 30000,
|
||
stdio: 'pipe',
|
||
});
|
||
res.json({ success: true, action: 'stop', output: out.trim() });
|
||
} catch (err) {
|
||
const stderr = err.stderr?.toString() || err.message;
|
||
res.status(500).json({ success: false, action: 'stop', error: stderr });
|
||
}
|
||
});
|
||
|
||
// POST /api/db/restart
|
||
app.post('/api/db/restart', (_req, res) => {
|
||
try {
|
||
const downOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, {
|
||
encoding: 'utf-8',
|
||
timeout: 30000,
|
||
stdio: 'pipe',
|
||
});
|
||
const upOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||
encoding: 'utf-8',
|
||
timeout: 60000,
|
||
stdio: 'pipe',
|
||
});
|
||
res.json({
|
||
success: true,
|
||
action: 'restart',
|
||
output: `down: ${downOut.trim()}\nup: ${upOut.trim()}`,
|
||
});
|
||
} catch (err) {
|
||
const stderr = err.stderr?.toString() || err.message;
|
||
res.status(500).json({ success: false, action: 'restart', error: stderr });
|
||
}
|
||
});
|
||
|
||
// ========== 启动 ==========
|
||
// 启动性能监控
|
||
performanceMonitor.start();
|
||
|
||
// 确保日志目录存在
|
||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||
|
||
server.listen(DEVTOOLS_PORT, () => {
|
||
console.log(`🛠️ Cyrene DevTools 已启动: http://localhost:${DEVTOOLS_PORT}`);
|
||
console.log(` API: http://localhost:${DEVTOOLS_PORT}/api/health`);
|
||
console.log(` WebSocket: ws://localhost:${DEVTOOLS_PORT}/ws`);
|
||
console.log(` Web控制台: http://localhost:${DEVTOOLS_PORT}`);
|
||
console.log('');
|
||
console.log(' 可用服务:');
|
||
for (const [id, svc] of Object.entries(SERVICES)) {
|
||
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
|
||
}
|
||
});
|
||
|
||
// 优雅退出
|
||
process.on('SIGINT', async () => {
|
||
console.log('\n关闭所有服务...');
|
||
await processManager.stopAll();
|
||
performanceMonitor.stop();
|
||
process.exit(0);
|
||
});
|
||
|
||
process.on('SIGTERM', async () => {
|
||
await processManager.stopAll();
|
||
performanceMonitor.stop();
|
||
process.exit(0);
|
||
});
|