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
+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>