feat: DevTools调试工具 + 前端样式修复 + 管理员登录系统

DevTools (新增):
- 进程管理器: 启动/停止/重启/编译 + 端口自动释放
- 服务接管 (tryAdopt): 检测已运行服务,健康检查通过则直接接管
- 一键启动 (startAllSequential): 按 ai-core→gateway→frontend 顺序启动
- 日志布局切换: 标签页模式 ↔ 三栏并列模式
- 性能监控: CPU/内存采样 + SVG 折线图
- Web UI + WebSocket 实时推送

前端修复:
- tailwind.config.ts: 修复空配置导致 CSS 不加载 (增加 content/colors/fontFamily)
- postcss.config.js: 新建缺失的 PostCSS 配置
- App.tsx: 移除注册功能,仅保留管理员登录 (admin / cyrene-dev-admin)

后端新增:
- config.go: AdminUsername/AdminPassword/RegistrationEnabled 环境变量
- auth_handler.go: 管理员登录 + 注册邮箱验证码 + 注册开关控制
- 管理员凭据: admin / cyrene-dev-admin (默认)

其他:
- .gitignore: 新增 devtools/node_modules/ devtools/logs/ devtools/package-lock.json
- devtools.sh: DevTools 一键启动脚本
This commit is contained in:
2026-05-16 10:49:43 +08:00
parent 86b70b1613
commit cd60b01cf3
32 changed files with 4569 additions and 2845 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "cyrene-devtools",
"version": "1.0.0",
"description": "Cyrene AI 开发调试工具 - 服务管理、日志监控、性能分析",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.21.0",
"ws": "^8.18.1",
"pidusage": "^4.0.0",
"chokidar": "^4.0.3"
}
}
+432
View File
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyrene DevTools</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0f1117;--bg2:#1a1d27;--bg3:#252833;--border:#2d3140;
--text:#c9d1d9;--text2:#8b949e;--accent:#f472b6;--accent2:#ec4899;
--green:#22c55e;--red:#ef4444;--yellow:#eab308;--blue:#3b82f6;
--orange:#f97316;
}
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
.container{max-width:1400px;margin:0 auto;padding:16px}
header{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:24px}
header h1{font-size:20px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
header .subtitle{font-size:12px;color:var(--text2)}
.grid{display:grid;gap:16px}
.grid-2{grid-template-columns:1fr 1fr}
.grid-3{grid-template-columns:1fr 1fr 1fr}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:16px}
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
.card-title{font-weight:600;font-size:14px}
.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.status-dot.running{background:var(--green);box-shadow:0 0 6px var(--green)}
.status-dot.stopped{background:var(--red)}
.status-dot.starting,.status-dot.building{background:var(--yellow);animation:pulse 1s infinite}
.status-dot.error{background:var(--red);box-shadow:0 0 6px var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.btn{padding:6px 14px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;font-weight:500;background:var(--bg3);color:var(--text);transition:all .15s}
.btn:hover{background:var(--border);border-color:var(--text2)}
.btn-sm{padding:4px 10px;font-size:11px}
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn-accent:hover{background:var(--accent2);border-color:var(--accent2)}
.btn-green{background:var(--green);color:#000;border-color:var(--green)}
.btn-red{background:var(--red);color:#fff;border-color:var(--red)}
.btn-group{display:flex;gap:6px}
.metrics{display:flex;gap:16px}
.metric{flex:1;text-align:center;padding:8px;background:var(--bg3);border-radius:8px}
.metric .value{font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace}
.metric .label{font-size:11px;color:var(--text2);margin-top:2px}
.log-container{background:var(--bg);border:1px solid var(--border);border-radius:8px;height:300px;overflow-y:auto;padding:12px;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6}
.log-line{padding:1px 0;word-break:break-all}
.log-line .ts{color:var(--text2);margin-right:8px}
.log-line.system{color:var(--blue)}
.log-line.stderr{color:var(--red)}
.log-line.error{color:var(--red);font-weight:600}
.tabs{display:flex;gap:0;margin-bottom:12px;border-bottom:1px solid var(--border)}
.tab{padding:8px 16px;cursor:pointer;font-size:13px;font-weight:500;color:var(--text2);border-bottom:2px solid transparent;transition:all .15s}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab:hover{color:var(--text)}
.chart-container{width:100%;height:160px;position:relative}
.chart-svg{width:100%;height:100%}
.chart-line{fill:none;stroke-width:2}
.chart-line.cpu{stroke:var(--blue)}
.chart-line.mem{stroke:var(--green)}
.chart-area{opacity:.15}
.chart-area.cpu{fill:var(--blue)}
.chart-area.mem{fill:var(--green)}
.legend{display:flex;gap:12px;font-size:11px;color:var(--text2)}
.legend-item{display:flex;align-items:center;gap:4px}
.legend-dot{width:10px;height:10px;border-radius:50%}
.legend-dot.cpu{background:var(--blue)}
.legend-dot.mem{background:var(--green)}
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:32px;color:var(--text2)}
.empty-state .icon{font-size:40px;margin-bottom:8px}
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:500}
.badge-running{background:rgba(34,197,94,.15);color:var(--green)}
.badge-stopped{background:rgba(239,68,68,.15);color:var(--red)}
.badge-starting{background:rgba(234,179,8,.15);color:var(--yellow)}
#ws-indicator{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}
#ws-indicator.connected{color:var(--green)}
#ws-indicator.disconnected{color:var(--red)}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>🛠️ Cyrene DevTools</h1>
<div class="subtitle">开发调试控制台 — 服务管理 · 日志监控 · 性能分析</div>
</div>
<div id="ws-indicator" class="disconnected">⚫ 未连接</div>
</header>
<!-- 服务管理 -->
<section>
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">📡 服务管理</span>
<div class="btn-group">
<button class="btn btn-sm btn-accent" onclick="action('start-all')" title="按顺序启动 (ai-core → gateway → frontend),如已运行则接管">▶ 一键启动</button>
<button class="btn btn-sm btn-red" onclick="action('stop-all')">⏹ 全部停止</button>
</div>
</div>
<div class="grid grid-3" id="service-cards"></div>
</div>
</section>
<!-- 日志区域 -->
<section>
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">📋 实时日志</span>
<div class="btn-group">
<div class="tabs" id="log-tabs" style="margin:0;border:none"></div>
<button class="btn btn-sm" onclick="toggleLogLayout()" id="btn-layout" title="切换标签页/并列布局">📐 并列布局</button>
<button class="btn btn-sm" onclick="clearLogs()">🗑 清空</button>
</div>
</div>
<!-- 标签页模式 -->
<div class="log-container" id="log-panel">
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
</div>
<!-- 并列布局模式 (默认隐藏) -->
<div class="grid grid-3" id="log-grid" style="display:none;gap:8px">
<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 class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
<div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
</div>
</div>
</section>
<!-- 性能分析 -->
<section>
<div class="card">
<div class="card-header">
<span class="card-title">📊 性能分析</span>
<div class="legend">
<span class="legend-item"><span class="legend-dot cpu"></span> CPU %</span>
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
</div>
</div>
<div class="grid grid-3" id="perf-panels"></div>
</div>
</section>
</div>
<script>
// ========== 状态 ==========
const STATE = {
activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [] },
maxLogLines: 500,
perfHistory: { 'ai-core': [], 'gateway': [], 'frontend': [] },
logLayout: 'tabs', // 'tabs' | 'grid'
};
// ========== WebSocket ==========
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
let ws = null, wsRetryTimer = null;
function connectWS() {
if (ws && ws.readyState === WebSocket.OPEN) return;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
document.getElementById('ws-indicator').className = 'connected';
document.getElementById('ws-indicator').innerHTML = '🟢 已连接';
};
ws.onclose = () => {
document.getElementById('ws-indicator').className = 'disconnected';
document.getElementById('ws-indicator').innerHTML = '🔴 已断开 (3秒后重连)';
wsRetryTimer = setTimeout(connectWS, 3000);
};
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'log') handleLog(msg.data);
if (msg.type === 'status') updateServiceCards(msg.data);
};
} catch(e) { setTimeout(connectWS, 3000); }
}
function handleLog(data) {
const { service, stream, text } = data;
if (!STATE.logLines[service]) STATE.logLines[service] = [];
const now = new Date();
const ts = now.toTimeString().slice(0,8);
STATE.logLines[service].push({ ts, stream, text });
if (STATE.logLines[service].length > STATE.maxLogLines) {
STATE.logLines[service].splice(0, STATE.logLines[service].length - STATE.maxLogLines);
}
if (STATE.logLayout === 'tabs' && service === STATE.activeLogTab) {
renderLog();
} else if (STATE.logLayout === 'grid') {
renderGridLog(service);
}
}
function renderGridLog(service) {
const panel = document.getElementById(`log-panel-${service}`);
if (!panel) return;
const lines = STATE.logLines[service] || [];
if (lines.length === 0) {
panel.innerHTML = `<div class="empty-state"><div class="icon">📝</div>${escapeId(service)}</div>`;
return;
}
panel.innerHTML = lines.map(l =>
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
).join('');
panel.scrollTop = panel.scrollHeight;
}
function renderLog() {
const panel = document.getElementById('log-panel');
const lines = STATE.logLines[STATE.activeLogTab] || [];
if (lines.length === 0) {
panel.innerHTML = '<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>';
return;
}
panel.innerHTML = lines.map(l =>
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
).join('');
panel.scrollTop = panel.scrollHeight;
}
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// ========== API 调用 ==========
async function api(url, opts = {}) {
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
return res.json();
}
async function action(cmd, serviceId) {
let url;
if (cmd === 'start-all') url = '/api/services/start-all';
else if (cmd === 'stop-all') url = '/api/services/stop-all';
else url = `/api/services/${serviceId}/${cmd}`;
const method = ['start','stop','restart','build','start-all','stop-all'].includes(cmd) ? 'POST' : 'GET';
const res = await api(url, { method });
console.log(cmd, res);
refreshStatus();
}
async function refreshStatus() {
const status = await api('/api/services');
updateServiceCards(status);
refreshPerf();
}
function updateServiceCards(status) {
const container = document.getElementById('service-cards');
container.innerHTML = Object.entries(status).map(([id, svc]) => {
const isRunning = svc.status === 'running';
const isStarting = svc.status === 'starting' || svc.status === 'building';
const isStopped = svc.status === 'stopped' || svc.status === 'error';
const uptime = svc.uptime > 0 ? formatUptime(svc.uptime) : '—';
return `
<div class="card">
<div class="card-header">
<span>
<span class="status-dot ${svc.status}"></span>
<span class="card-title">${svc.name}</span>
</span>
<span class="badge badge-${svc.status}">${svc.status}</span>
</div>
<div class="metrics">
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
<div class="metric"><div class="value">${uptime}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:12px">
${isStopped ? `<button class="btn btn-sm btn-green" onclick="action('start','${id}')">▶ 启动</button>` : ''}
${isStopped || isStarting ? `<button class="btn btn-sm btn-accent" onclick="action('build','${id}')">🔨 编译</button>` : ''}
${isRunning ? `<button class="btn btn-sm" onclick="action('restart','${id}')">🔄 重启</button>` : ''}
${isRunning || isStarting ? `<button class="btn btn-sm btn-red" onclick="action('stop','${id}')">⏹ 停止</button>` : ''}
${svc.healthUrl ? `<button class="btn btn-sm" onclick="checkHealth('${id}')">❤️ 健康检查</button>` : ''}
</div>
</div>`;
}).join('');
}
async function checkHealth(id) {
const res = await api(`/api/proxy/${id}/health`);
alert(`${id} 健康检查:\n${JSON.stringify(res, null, 2)}`);
}
function formatUptime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m%60}m`;
if (m > 0) return `${m}m ${s%60}s`;
return `${s}s`;
}
// ========== 性能面板 ==========
async function refreshPerf() {
const [snap, history] = await Promise.all([
api('/api/performance'),
api('/api/performance/history'),
]);
// 更新历史
if (history) {
for (const [id, h] of Object.entries(history)) {
if (h && h.length > 0) STATE.perfHistory[id] = h.slice(-60);
}
}
renderPerfPanels(snap);
}
function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels');
const ids = ['ai-core', 'gateway', 'frontend'];
container.innerHTML = ids.map(id => {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return `
<div class="card">
<div class="card-header">
<span class="card-title">${escapeId(id)}</span>
<span style="font-size:11px;color:var(--text2)">CPU ${s.cpu}% | MEM ${s.mem}MB</span>
</div>
<div class="chart-container">
<svg viewBox="0 0 300 120" class="chart-svg">
${drawChart(STATE.perfHistory[id] || [])}
</svg>
</div>
</div>`;
}).join('');
}
function drawChart(history) {
if (history.length < 2) return '<text x="150" y="65" text-anchor="middle" fill="#8b949e" font-size="11">等待数据...</text>';
const w = 300, h = 120, pad = 10;
const maxCpu = Math.max(5, ...history.map(d => d.cpu));
const maxMem = Math.max(10, ...history.map(d => d.mem));
let cpuArea = '', cpuLine = '', memArea = '', memLine = '';
for (let i = 0; i < history.length; i++) {
const x = pad + (i / Math.max(1, history.length - 1)) * (w - 2*pad);
const cpuY = h - pad - (history[i].cpu / maxCpu) * (h - 2*pad);
const memY = h - pad - (history[i].mem / maxMem) * (h - 2*pad);
cpuLine += `${i===0?'M':'L'} ${x} ${cpuY} `;
memLine += `${i===0?'M':'L'} ${x} ${memY} `;
}
// Area fills
cpuArea = cpuLine + `L ${pad+(history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
memArea = memLine + `L ${pad+(history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
return `
<path d="${cpuArea}" class="chart-area cpu"/>
<path d="${cpuLine}" class="chart-line cpu"/>
<path d="${memArea}" class="chart-area mem"/>
<path d="${memLine}" class="chart-line mem"/>
`;
}
function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend' };
return map[id] || id;
}
// ========== 日志布局切换 ==========
function toggleLogLayout() {
const btnLayout = document.getElementById('btn-layout');
const logPanel = document.getElementById('log-panel');
const logGrid = document.getElementById('log-grid');
const logTabs = document.getElementById('log-tabs');
if (STATE.logLayout === 'tabs') {
// 切换到并列布局
STATE.logLayout = 'grid';
logPanel.style.display = 'none';
logGrid.style.display = '';
logTabs.style.display = 'none';
btnLayout.innerHTML = '📋 标签页布局';
btnLayout.title = '切换为标签页布局';
// 渲染所有三个服务的日志
['ai-core', 'gateway', 'frontend'].forEach(id => renderGridLog(id));
} else {
// 切换到标签页布局
STATE.logLayout = 'tabs';
logPanel.style.display = '';
logGrid.style.display = 'none';
logTabs.style.display = '';
btnLayout.innerHTML = '📐 并列布局';
btnLayout.title = '切换为并列布局';
renderLog();
}
}
// ========== 日志标签切换 ==========
function initLogTabs() {
const tabs = document.getElementById('log-tabs');
tabs.innerHTML = ['ai-core', 'gateway', 'frontend'].map(id =>
`<div class="tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchLogTab('${id}')">${escapeId(id)}</div>`
).join('');
}
function switchLogTab(id) {
STATE.activeLogTab = id;
initLogTabs();
renderLog();
}
function clearLogs() {
const id = STATE.activeLogTab;
api(`/api/logs/${id}`, { method: 'DELETE' });
STATE.logLines[id] = [];
renderLog();
}
// ========== 启动 ==========
connectWS();
initLogTabs();
refreshStatus();
// 定期刷新状态和性能
setInterval(refreshStatus, 5000);
</script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
/**
* 调试工具配置
* 定义各服务的启动参数、端口、健康检查等
*/
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../..');
export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
export const LOGS_DIR = path.resolve(__dirname, '../logs');
export const SERVICES = {
'ai-core': {
name: 'AI-Core',
cwd: path.join(ROOT, 'backend/ai-core'),
command: './main',
env: {
AI_CORE_PORT: '8081',
LLM_API_URL: process.env.LLM_API_URL || 'https://api.openai.com/v1',
LLM_API_KEY: process.env.LLM_API_KEY || '',
LLM_MODEL: process.env.LLM_MODEL || 'gpt-4o',
PERSONA_DIR: './internal/persona',
},
healthUrl: 'http://localhost:8081/api/v1/health',
port: 8081,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
},
gateway: {
name: 'Gateway',
cwd: path.join(ROOT, 'backend/gateway'),
command: './main',
env: {
GATEWAY_PORT: '8080',
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
AI_CORE_URL: 'http://localhost:8081',
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'false',
},
healthUrl: 'http://localhost:8080/api/v1/health',
port: 8080,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
},
frontend: {
name: 'Frontend',
cwd: path.join(ROOT, 'frontend/web'),
command: 'npx',
args: ['vite', '--host', '0.0.0.0'],
env: {
PATH: process.env.PATH,
},
healthUrl: 'http://localhost:5173',
port: 5173,
nodeBin: '/usr/local/node/bin/node',
npmBin: '/usr/local/node/bin/npx',
// frontend不需要预编译,dev server即可
buildCommand: null,
},
};
/** 各服务默认的日志文件路径 */
export function logFile(serviceId) {
return path.join(LOGS_DIR, `${serviceId}.log`);
}
+298
View File
@@ -0,0 +1,298 @@
/**
* 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 { processManager } from './process-manager.js';
import { performanceMonitor } from './performance.js';
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile } from './config.js';
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());
}
});
// ========== REST API 路由 ==========
// ---- 健康检查 ----
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
service: 'cyrene-devtools',
uptime: process.uptime(),
wsClients: wsClients.size,
});
});
// ---- 服务状态 ----
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/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 });
}
});
// ---- 健康检查代理 ----
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 });
}
});
// ========== 启动 ==========
// 启动性能监控
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);
});
+108
View File
@@ -0,0 +1,108 @@
/**
* 性能监控模块
* 监控各服务进程的 CPU、内存使用情况
*/
import pidusage from 'pidusage';
import { processManager } from './process-manager.js';
import { SERVICES } from './config.js';
class PerformanceMonitor {
constructor() {
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
this.history = new Map();
this.interval = null;
for (const id of Object.keys(SERVICES)) {
this.history.set(id, []);
}
}
/**
* 开始定期采样 (每3秒)
*/
start() {
if (this.interval) return;
this.interval = setInterval(() => this.sample(), 3000);
this.interval.unref(); // 不阻止进程退出
}
/**
* 停止采样
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* 采样一次
*/
async sample() {
for (const [id, info] of processManager.processes) {
if (!info.pid) continue;
try {
const stats = await pidusage(info.pid);
const history = this.history.get(id);
history.push({
ts: Date.now(),
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
});
// 保留最近300条 (约15分钟)
if (history.length > 300) {
history.splice(0, history.length - 300);
}
} catch {
// 进程可能已退出
}
}
}
/**
* 获取当前性能快照
*/
async getSnapshot() {
const result = {};
for (const [id, info] of processManager.processes) {
if (!info.pid) {
result[id] = { pid: null, cpu: 0, mem: 0 };
continue;
}
try {
const stats = await pidusage(info.pid);
result[id] = {
pid: info.pid,
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
elapsed: stats.elapsed,
};
} catch {
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
}
}
return result;
}
/**
* 获取历史数据
*/
getHistory(serviceId) {
return this.history.get(serviceId) || [];
}
/**
* 获取所有服务的历史数据
*/
getAllHistory() {
const result = {};
for (const id of this.history.keys()) {
result[id] = this.history.get(id);
}
return result;
}
}
export const performanceMonitor = new PerformanceMonitor();
+384
View File
@@ -0,0 +1,384 @@
/**
* 进程管理器
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
*/
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
import fs from 'fs';
import net from 'net';
import { SERVICES, logFile } from './config.js';
/**
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
*/
function releasePort(port) {
return new Promise((resolve) => {
const sock = new net.Socket();
sock.setTimeout(1000);
sock.on('connect', () => {
sock.destroy();
// 端口被占用,尝试释放
try {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
} catch { /* ignore */ }
setTimeout(resolve, 500);
});
sock.on('error', () => {
sock.destroy();
resolve(); // 端口空闲
});
sock.on('timeout', () => {
sock.destroy();
resolve();
});
sock.connect(port, '127.0.0.1');
});
}
class ProcessManager extends EventEmitter {
constructor() {
super();
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
this.processes = new Map();
for (const id of Object.keys(SERVICES)) {
this.processes.set(id, {
process: null,
status: 'stopped',
startTime: null,
pid: null,
buildLog: [],
});
}
}
/**
* 启动服务
*/
async start(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
const procInfo = this.processes.get(serviceId);
if (procInfo.process) {
throw new Error(`${svc.name} 已在运行中`);
}
// 启动前释放端口,避免 "address already in use"
if (svc.port) {
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
await releasePort(svc.port);
}
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
procInfo.status = 'starting';
procInfo.buildLog = [];
// 确保日志目录存在
const logPath = logFile(serviceId);
const logDir = logPath.substring(0, logPath.lastIndexOf('/'));
fs.mkdirSync(logDir, { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
// 确定二进制路径或命令
let command, args;
if (svc.command === './main') {
command = svc.command;
args = svc.args || [];
} else if (svc.command === 'npx') {
command = svc.npmBin || 'npx';
args = svc.args || [];
} else {
command = svc.command;
args = svc.args || [];
}
const env = { ...process.env, ...svc.env };
const child = spawn(command, args, {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
child.stdout.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stdout', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stderr', text);
});
// spawn() 返回后进程已启动,立即记录 PID 和状态
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
procInfo.pid = child.pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = child;
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
child.on('error', (err) => {
const msg = `进程错误: ${err.message}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'error', msg);
procInfo.status = 'error';
procInfo.process = null;
procInfo.pid = null;
});
child.on('close', (code) => {
const msg = `进程退出,退出码: ${code}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'system', msg);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
logStream.end();
});
return { success: true, message: `${svc.name} 启动中...` };
}
/**
* 停止服务
*/
async stop(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
const procInfo = this.processes.get(serviceId);
if (!procInfo.process) {
// 可能已经崩溃了,重置状态
procInfo.status = 'stopped';
return { success: true, message: `${svc.name} 未在运行` };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
// 强制杀死
if (procInfo.process) {
procInfo.process.kill('SIGKILL');
}
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已强制停止` });
}, 5000);
procInfo.process.on('close', () => {
clearTimeout(timeout);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已停止` });
});
procInfo.process.kill('SIGTERM');
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
});
}
/**
* 重启服务
*/
async restart(serviceId) {
await this.stop(serviceId);
// 等待一小段时间确保端口释放
await new Promise((r) => setTimeout(r, 1000));
return this.start(serviceId);
}
/**
* 构建服务 (Go服务需要预编译)
*/
async build(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
if (!svc.buildCommand) {
return { success: false, message: `${svc.name} 不需要预编译` };
}
const procInfo = this.processes.get(serviceId);
procInfo.status = 'building';
procInfo.buildLog = [];
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
return new Promise((resolve) => {
const buildCmd = svc.goBin || svc.buildCommand;
const buildArgs = svc.buildArgs || [];
const child = spawn(buildCmd, buildArgs, {
cwd: svc.cwd,
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
procInfo.status = 'stopped';
procInfo.buildLog = [
...stdout.split('\n').filter(Boolean),
...stderr.split('\n').filter(Boolean),
];
if (code === 0) {
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
resolve({ success: true, message: `${svc.name} 编译成功` });
} else {
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
}
});
child.on('error', (err) => {
procInfo.status = 'stopped';
resolve({ success: false, message: `编译错误: ${err.message}` });
});
});
}
/**
* 获取所有服务状态
*/
getStatus() {
const result = {};
for (const [id, info] of this.processes) {
const svc = SERVICES[id];
result[id] = {
name: svc.name,
status: info.status,
pid: info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
};
}
return result;
}
/**
* 获取单个服务状态
*/
getServiceStatus(serviceId) {
const info = this.processes.get(serviceId);
if (!info) return null;
const svc = SERVICES[serviceId];
return {
name: svc.name,
status: info.status,
pid: info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
};
}
/**
* 停止所有服务
*/
async stopAll() {
const results = [];
for (const id of Object.keys(SERVICES)) {
try {
const r = await this.stop(id);
results.push({ id, ...r });
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
/**
* 尝试接管已运行的服务 (通过健康检查端点)
* 如果服务已在运行,直接标记为 running 而不是杀死重启
*/
async tryAdopt(serviceId) {
const svc = SERVICES[serviceId];
if (!svc || !svc.healthUrl) return false;
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const procInfo = this.processes.get(serviceId);
// 尝试通过 fuser 获取 PID
let pid = null;
try {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
} catch { /* ignore */ }
procInfo.pid = pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = null; // 不是我们的子进程,但标记为已接管
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
return true;
}
} catch { /* 未运行或不可达 */ }
return false;
}
/**
* 按顺序启动所有服务 (ai-core → gateway → frontend)
* 每步等待健康检查通过后再启动下一个
*/
async startAllSequential() {
const order = ['ai-core', 'gateway', 'frontend'];
const results = [];
for (const id of order) {
const svc = SERVICES[id];
// 先尝试接管已运行的服务
const adopted = await this.tryAdopt(id);
if (adopted) {
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
continue;
}
// 启动服务
try {
const r = await this.start(id);
results.push({ id, ...r });
// 等待健康检查通过
if (svc.healthUrl) {
let healthy = false;
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000));
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
if (resp.ok) { healthy = true; break; }
} catch { /* continue waiting */ }
}
if (!healthy) {
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
} else {
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
}
}
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
}
export const processManager = new ProcessManager();