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:
@@ -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>
|
||||
Reference in New Issue
Block a user