This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/devtools/public/index.html
T
AskaEth d15acf587c feat: DevTools综合升级 — 记忆查询 + 会话监看 + WebUI侧边栏重构
- docs: 17个文件重命名为 YYYY-MM-DD.HH-mm-SS-内容.md 格式
- config: 管理员凭据移至 backend/.env (ADMIN_USERNAME/PASSWORD)
- gateway: 新增 SessionState 会话追踪 + GET /api/v1/admin/sessions
- devtools: 新增7个代理端点 (dashboard/sessions/memory)
- devtools: WebUI重构为侧边栏 + 5面板 (仪表盘/记忆/会话/服务/性能)
2026-05-16 15:02:44 +08:00

1208 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
/* ========== CSS Variables (深色主题) ========== */
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #252833; --bg4: #2d3140;
--border: #2d3140; --border2: #383d4a;
--text: #c9d1d9; --text2: #8b949e; --text3: #5d6470;
--accent: #f472b6; --accent2: #ec4899; --accent-bg: rgba(244,114,182,.12);
--green: #22c55e; --green-bg: rgba(34,197,94,.12);
--red: #ef4444; --red-bg: rgba(239,68,68,.12);
--yellow: #eab308; --yellow-bg: rgba(234,179,8,.12);
--blue: #3b82f6; --blue-bg: rgba(59,130,246,.12);
--orange: #f97316; --orange-bg: rgba(249,115,22,.12);
--sidebar-w: 220px; --sidebar-collapsed: 52px;
--radius: 10px; --radius-sm: 6px;
--transition: 0.2s ease;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5;
display: flex; height: 100vh; overflow: hidden;
}
/* ========== 侧边栏 ========== */
#sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-collapsed);
background: var(--bg2); border-right: 1px solid var(--border);
display: flex; flex-direction: column; transition: width var(--transition);
overflow: hidden; z-index: 10;
}
#sidebar.collapsed { width: var(--sidebar-collapsed); }
#sidebar.collapsed .nav-label, #sidebar.collapsed .sidebar-title, #sidebar.collapsed .sidebar-footer-text { display: none; }
#sidebar.collapsed .nav-item { justify-content: center; padding: 10px 0; }
#sidebar.collapsed .nav-icon { margin-right: 0; }
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px; border-bottom: 1px solid var(--border); min-height: 56px;
}
.sidebar-title {
font-size: 14px; font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--blue));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
white-space: nowrap;
}
#toggle-sidebar {
background: none; border: none; color: var(--text2); cursor: pointer;
font-size: 18px; padding: 2px 6px; border-radius: 4px; line-height: 1;
}
#toggle-sidebar:hover { color: var(--text); background: var(--bg3); }
.sidebar-nav { flex: 1; padding: 8px; display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border-radius: var(--radius-sm); cursor: pointer; color: var(--text2);
transition: all var(--transition); white-space: nowrap; border: none;
background: none; width: 100%; text-align: left; font-size: 13px;
}
.nav-item:hover { background: var(--bg3); color: var(--text); }
.nav-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
.nav-icon { font-size: 18px; width: 22px; text-align: center; flex-shrink: 0; }
.nav-label { flex: 1; }
.nav-badge {
background: var(--accent); color: #fff; font-size: 10px; padding: 1px 6px;
border-radius: 10px; font-weight: 600; display: none;
}
.sidebar-footer {
padding: 12px 16px; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
.sidebar-footer-text { font-size: 11px; color: var(--text3); white-space: nowrap; }
#ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
#ws-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
#ws-dot.disconnected { background: var(--red); }
/* ========== 主内容区 ========== */
#main {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
}
.main-header {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
background: var(--bg2); min-height: 48px;
}
.main-header h2 { font-size: 15px; font-weight: 600; }
.main-header-actions { display: flex; gap: 8px; align-items: center; }
#panel-container {
flex: 1; overflow-y: auto; padding: 20px;
}
.panel { display: none; }
.panel.active { display: block; }
/* ========== 通用组件 ========== */
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; margin-bottom: 16px;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.card-title { font-weight: 600; font-size: 14px; }
.cards-grid { display: grid; gap: 14px; }
.cards-2 { grid-template-columns: 1fr 1fr; }
.cards-3 { grid-template-columns: 1fr 1fr 1fr; }
.cards-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.stat-card {
background: var(--bg3); border-radius: var(--radius-sm); padding: 14px;
display: flex; flex-direction: column; gap: 4px;
}
.stat-card .stat-value { font-size: 22px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.stat-card .stat-label { font-size: 11px; color: var(--text2); }
.stat-card.accent .stat-value { color: var(--accent); }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.blue .stat-value { color: var(--blue); }
.stat-card.orange .stat-value { color: var(--orange); }
.btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm);
cursor: pointer; font-size: 12px; font-weight: 500; background: var(--bg3);
color: var(--text); transition: all .15s; white-space: nowrap;
font-family: inherit;
}
.btn:hover { background: var(--bg4); border-color: var(--text2); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-xs { padding: 2px 8px; font-size: 10px; border-radius: 4px; }
.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-green:hover { opacity: .9; }
.btn-red { background: var(--red); color: #fff; border-color: var(--red); }
.btn-red:hover { opacity: .9; }
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
/* 表格 */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
th { color: var(--text2); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
tr:hover td { background: rgba(255,255,255,.02); }
tr.expanded td { background: var(--bg3); }
/* 状态徽章 */
.badge {
display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; font-weight: 500;
}
.badge-running, .badge-idle { background: var(--green-bg); color: var(--green); }
.badge-stopped, .badge-error { background: var(--red-bg); color: var(--red); }
.badge-starting, .badge-building, .badge-thinking { background: var(--blue-bg); color: var(--blue); }
.badge-streaming { background: var(--yellow-bg); color: var(--yellow); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
@keyframes bluePulse { 0%,100%{box-shadow:0 0 4px var(--blue)} 50%{box-shadow:0 0 12px var(--blue)} }
.badge-thinking { animation: bluePulse 1.5s infinite; }
/* 表单 */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px; font-weight: 500; }
.form-row { display: flex; gap: 10px; }
.form-row > * { flex: 1; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent);
}
textarea { resize: vertical; min-height: 70px; }
input[type="range"] { accent-color: var(--accent); padding: 0; }
/* 日志容器 */
.log-container {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
height: 280px; overflow-y: auto; padding: 10px;
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.6;
}
.log-line { padding: 1px 0; word-break: break-all; }
.log-line .ts { color: var(--text3); margin-right: 6px; }
.log-line.system { color: var(--blue); }
.log-line.stderr { color: var(--red); }
.log-line.error { color: var(--red); font-weight: 600; }
/* 日志标签 */
.log-tabs { display: flex; gap: 0; margin-bottom: 8px; }
.log-tab {
padding: 5px 14px; cursor: pointer; font-size: 12px; font-weight: 500;
color: var(--text2); border-bottom: 2px solid transparent; transition: all .15s;
background: none; border-top: none; border-left: none; border-right: none;
font-family: inherit;
}
.log-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.log-tab:hover { color: var(--text); }
/* 可折叠 */
.collapsible-header {
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none;
}
.collapsible-header:hover { color: var(--text); }
.collapsible-body { display: none; margin-top: 12px; }
.collapsible-body.open { display: block; }
.collapse-arrow { transition: transform .2s; font-size: 12px; }
.collapse-arrow.open { transform: rotate(90deg); }
/* 图表 */
.chart-container { width: 100%; height: 140px; 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: 14px; font-size: 11px; color: var(--text2); }
.legend-item { display: flex; align-items: center; gap: 5px; }
.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: 36px; margin-bottom: 8px; }
/* 会话详情展开 */
.session-detail {
background: var(--bg); border: 1px solid var(--border2); border-radius: var(--radius-sm);
padding: 12px; margin-top: 8px;
}
.session-detail .detail-row { display: flex; gap: 20px; margin-bottom: 6px; font-size: 12px; }
.session-detail .detail-label { color: var(--text2); min-width: 80px; }
.msg-list { margin-top: 8px; }
.msg-item {
padding: 6px 10px; background: var(--bg3); border-radius: var(--radius-sm);
margin-bottom: 4px; font-size: 12px;
}
.msg-item .role { font-weight: 600; margin-right: 8px; }
.msg-item .role.user { color: var(--blue); }
.msg-item .role.assistant { color: var(--green); }
.msg-item .role.system { color: var(--yellow); }
/* Toast */
#toast {
position: fixed; bottom: 20px; right: 20px; z-index: 100;
padding: 10px 20px; border-radius: var(--radius-sm); font-size: 13px;
opacity: 0; transform: translateY(10px); transition: all .3s;
pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }
#toast.success { background: var(--green); color: #000; }
#toast.error { background: var(--red); color: #fff; }
#toast.info { background: var(--blue); color: #fff; }
/* 响应式 */
@media (max-width: 900px) {
.cards-2, .cards-3, .cards-4 { grid-template-columns: 1fr; }
#sidebar { width: var(--sidebar-collapsed); }
#sidebar .nav-label, #sidebar .sidebar-title, #sidebar .sidebar-footer-text { display: none; }
#sidebar .nav-item { justify-content: center; padding: 10px 0; }
#sidebar .nav-icon { margin-right: 0; }
}
/* 服务卡片内的指标 */
.metrics { display: flex; gap: 10px; }
.metric { flex: 1; text-align: center; padding: 6px; background: var(--bg3); border-radius: var(--radius-sm); }
.metric .value { font-size: 16px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.metric .label { font-size: 10px; color: var(--text2); margin-top: 2px; }
/* 仪表盘快速操作 */
.quick-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
/* 刷新按钮旋转 */
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spinning { animation: spin 1s linear infinite; }
</style>
</head>
<body>
<!-- ========== 侧边栏 ========== -->
<aside id="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">🛠️ DevTools</span>
<button id="toggle-sidebar" title="折叠侧边栏"></button>
</div>
<nav class="sidebar-nav">
<button class="nav-item active" data-panel="dashboard">
<span class="nav-icon">🏠</span><span class="nav-label">仪表盘</span>
</button>
<button class="nav-item" data-panel="memory">
<span class="nav-icon">🧠</span><span class="nav-label">记忆管理</span>
</button>
<button class="nav-item" data-panel="sessions">
<span class="nav-icon">💬</span><span class="nav-label">会话监看</span>
<span class="nav-badge" id="sessions-badge">0</span>
</button>
<button class="nav-item" data-panel="services">
<span class="nav-icon">🖥</span><span class="nav-label">服务管理</span>
</button>
<button class="nav-item" data-panel="performance">
<span class="nav-icon">📊</span><span class="nav-label">性能监控</span>
</button>
</nav>
<div class="sidebar-footer">
<span id="ws-dot" class="disconnected"></span>
<span class="sidebar-footer-text" id="ws-status-text">未连接</span>
</div>
</aside>
<!-- ========== 主内容区 ========== -->
<div id="main">
<div class="main-header">
<h2 id="panel-title">🏠 仪表盘</h2>
<div class="main-header-actions" id="panel-actions"></div>
</div>
<div id="panel-container">
<!-- 仪表盘 -->
<div class="panel active" id="panel-dashboard"></div>
<!-- 记忆管理 -->
<div class="panel" id="panel-memory"></div>
<!-- 会话监看 -->
<div class="panel" id="panel-sessions"></div>
<!-- 服务管理 -->
<div class="panel" id="panel-services"></div>
<!-- 性能监控 -->
<div class="panel" id="panel-performance"></div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<script>
// ========== 全局状态 ==========
const STATE = {
activePanel: 'dashboard',
sidebarCollapsed: false,
// 仪表盘
dashboardData: null,
// 服务
serviceStatus: {},
// 日志
activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [] },
maxLogLines: 500,
logLayout: 'tabs',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'frontend': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
// 计时器
dashboardInterval: null,
statusInterval: null,
};
// ========== WebSocket ==========
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws = null, wsRetryTimer = null;
function connectWS() {
if (ws && ws.readyState === WebSocket.OPEN) return;
try {
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'connected';
document.getElementById('ws-status-text').textContent = '已连接';
};
ws.onclose = () => {
document.getElementById('ws-dot').className = 'disconnected';
document.getElementById('ws-status-text').textContent = '断开(重连中)';
wsRetryTimer = setTimeout(connectWS, 3000);
};
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'log') handleWSLog(msg.data);
if (msg.type === 'status') {
STATE.serviceStatus = msg.data;
if (STATE.activePanel === 'services') renderServiceCards();
if (STATE.activePanel === 'dashboard') renderDashboard();
}
};
} catch(e) { setTimeout(connectWS, 3000); }
}
function handleWSLog(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.activePanel === 'services') {
if (STATE.logLayout === 'tabs' && service === STATE.activeLogTab) renderServiceLog();
else if (STATE.logLayout === 'grid') renderGridLog(service);
}
}
// ========== 工具函数 ==========
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function formatUptime(ms) {
if (!ms || ms < 0) return '—';
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`;
}
function formatTime(ts) {
if (!ts) return '—';
const d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
function timeAgo(ts) {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}秒前`;
if (s < 3600) return `${Math.floor(s/60)}分钟前`;
if (s < 86400) return `${Math.floor(s/3600)}小时前`;
return `${Math.floor(s/86400)}天前`;
}
function statusBadge(status) {
const map = {
running: 'badge-running', idle: 'badge-idle',
stopped: 'badge-stopped', error: 'badge-error',
starting: 'badge-starting', building: 'badge-building',
thinking: 'badge-thinking', streaming: 'badge-streaming',
};
return map[status] || 'badge-stopped';
}
function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend' };
return map[id] || id;
}
async function api(url, opts = {}) {
try {
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!res.ok) {
const text = await res.text();
let err;
try { err = JSON.parse(text).error || text; } catch { err = text; }
return { error: err, status: res.status };
}
return await res.json();
} catch (err) {
return { error: err.message };
}
}
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(t._timeout);
t._timeout = setTimeout(() => { t.className = ''; }, 3000);
}
// ========== 侧边栏导航 ==========
const panels = {};
document.querySelectorAll('.panel').forEach(p => { panels[p.id.replace('panel-','')] = p; });
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => switchPanel(btn.dataset.panel));
});
document.getElementById('toggle-sidebar').addEventListener('click', () => {
STATE.sidebarCollapsed = !STATE.sidebarCollapsed;
document.getElementById('sidebar').classList.toggle('collapsed', STATE.sidebarCollapsed);
});
function switchPanel(name) {
STATE.activePanel = name;
// 更新侧边栏
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-item[data-panel="${name}"]`);
if (navBtn) navBtn.classList.add('active');
// 更新标题
const titles = {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', performance: '📊 性能监控',
};
document.getElementById('panel-title').textContent = titles[name] || name;
// 切换面板
Object.values(panels).forEach(p => p.classList.remove('active'));
if (panels[name]) panels[name].classList.add('active');
// 清除面板操作区
document.getElementById('panel-actions').innerHTML = '';
// 渲染面板
switch (name) {
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
}
}
function stopDashboardAutoRefresh() {
if (STATE.dashboardInterval) { clearInterval(STATE.dashboardInterval); STATE.dashboardInterval = null; }
}
function stopSessionsAutoRefresh() {
if (STATE.sessionsAutoRefresh) { clearInterval(STATE.sessionsAutoRefresh); STATE.sessionsAutoRefresh = null; }
}
function startDashboardAutoRefresh() {
stopDashboardAutoRefresh();
STATE.dashboardInterval = setInterval(() => {
if (STATE.activePanel === 'dashboard') renderDashboard();
}, 5000);
}
function startSessionsAutoRefresh() {
stopSessionsAutoRefresh();
STATE.sessionsAutoRefresh = setInterval(() => {
if (STATE.activePanel === 'sessions') loadSessions();
}, 5000);
}
// ========== 面板1: 仪表盘 ==========
async function renderDashboard() {
const data = await api('/api/dashboard');
if (data.error) {
document.getElementById('panel-dashboard').innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div>`;
return;
}
STATE.dashboardData = data;
const svcs = data.services?.list || {};
const runningCount = data.services?.running || 0;
const totalSvcs = data.services?.total || Object.keys(svcs).length;
document.getElementById('panel-dashboard').innerHTML = `
<!-- 概览统计 -->
<div class="cards-grid cards-4" style="margin-bottom:16px">
<div class="stat-card green">
<div class="stat-value">${runningCount}/${totalSvcs}</div>
<div class="stat-label">服务运行中</div>
</div>
<div class="stat-card blue">
<div class="stat-value">${data.sessions?.active ?? '—'}</div>
<div class="stat-label">活跃会话</div>
</div>
<div class="stat-card accent">
<div class="stat-value">${data.memory?.total ?? '—'}</div>
<div class="stat-label">记忆条目</div>
</div>
<div class="stat-card orange">
<div class="stat-value">${data.system?.heapUsedMB ?? '—'} MB</div>
<div class="stat-label">DevTools 内存</div>
</div>
</div>
<!-- 服务状态卡片 -->
<div class="card">
<div class="card-header">
<span class="card-title">📡 服务状态</span>
<div class="quick-actions">
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
<button class="btn btn-sm" onclick="svcAction('start-all-fresh')">🔄 强制重启全部</button>
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
</div>
</div>
<div class="cards-grid cards-3" id="dashboard-svc-cards"></div>
</div>
<!-- 性能快照 + 系统信息 -->
<div class="cards-grid cards-2">
<div class="card">
<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>
<div id="dashboard-perf"></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">💻 系统信息</span></div>
<div style="display:flex;flex-direction:column;gap:8px;font-size:12px">
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
<div><span style="color:var(--text2)">堆内存:</span> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</div>
<div><span style="color:var(--text2)">总消息数:</span> ${data.sessions?.totalMessages ?? 0}</div>
<div><span style="color:var(--text2)">更新时间:</span> ${formatTime(data.timestamp)}</div>
</div>
</div>
</div>
`;
// 渲染服务卡片
renderDashboardSvcCards(svcs);
// 渲染性能快照
const perfContainer = document.getElementById('dashboard-perf');
const perf = data.performance?.perService || {};
perfContainer.innerHTML = Object.entries(perf).map(([id, p]) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="font-weight:500">${escapeId(id)}</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text2)">
CPU ${p.cpu || 0}% | MEM ${p.mem || 0}MB
</span>
</div>
`).join('') || '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
}
function renderDashboardSvcCards(svcs) {
const container = document.getElementById('dashboard-svc-cards');
if (!container) return;
container.innerHTML = Object.entries(svcs).map(([id, svc]) => `
<div class="card" style="margin:0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
<span class="badge ${statusBadge(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">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶</button>` : ''}
${svc.status === 'running' ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄</button>` : ''}
${svc.status === 'running' || svc.status === 'starting' ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹</button>` : ''}
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨</button>` : ''}
</div>
</div>
`).join('');
}
// ========== 面板2: 记忆管理 ==========
function renderMemoryPanel() {
document.getElementById('panel-memory').innerHTML = `
<div class="cards-grid cards-2">
<!-- 搜索区域 -->
<div class="card">
<div class="card-header"><span class="card-title">🔍 搜索记忆</span></div>
<div class="form-row">
<div class="form-group"><label>用户ID</label><input type="text" id="mem-user-id" placeholder="admin_admin" value="admin_admin"></div>
<div class="form-group"><label>关键词</label><input type="text" id="mem-search-q" placeholder="输入搜索关键词..."></div>
</div>
<div class="btn-group" style="margin-top:4px">
<button class="btn btn-accent btn-sm" onclick="searchMemory()">🔍 搜索</button>
<button class="btn btn-sm" onclick="listMemory()">📋 列表全部</button>
</div>
</div>
<!-- 添加记忆 -->
<div class="card">
<div class="card-header"><span class="card-title"> 添加记忆</span></div>
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin_admin" value="admin_admin"></div>
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..."></textarea></div>
<div class="form-row">
<div class="form-group">
<label>分类</label>
<select id="mem-add-category">
<option value="preference">偏好</option>
<option value="fact">事实</option>
<option value="experience">经验</option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group">
<label>优先级: <span id="mem-priority-val">3</span></label>
<input type="range" id="mem-add-priority" min="1" max="5" value="3" oninput="document.getElementById('mem-priority-val').textContent=this.value">
</div>
</div>
<button class="btn btn-accent btn-sm" onclick="addMemory()"> 添加</button>
</div>
</div>
<!-- 结果表格 -->
<div class="card">
<div class="card-header">
<span class="card-title">📋 记忆列表</span>
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>创建时间</th></tr></thead>
<tbody id="mem-table-body">
<tr><td colspan="5"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
</tbody>
</table>
</div>
</div>
`;
}
async function searchMemory() {
const userId = document.getElementById('mem-user-id').value.trim();
const q = document.getElementById('mem-search-q').value.trim();
if (!userId) { showToast('请输入用户ID', 'error'); return; }
if (!q) { showToast('请输入搜索关键词', 'error'); return; }
const data = await api(`/api/memory/search?user_id=${encodeURIComponent(userId)}&q=${encodeURIComponent(q)}`);
renderMemoryResults(data);
}
async function listMemory() {
const userId = document.getElementById('mem-user-id').value.trim();
if (!userId) { showToast('请输入用户ID', 'error'); return; }
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}`);
renderMemoryResults(data);
}
function renderMemoryResults(data) {
const tbody = document.getElementById('mem-table-body');
const countEl = document.getElementById('mem-result-count');
if (data.error) {
tbody.innerHTML = `<tr><td colspan="5"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div></td></tr>`;
countEl.textContent = '';
return;
}
// 兼容不同返回格式
let memories = [];
if (Array.isArray(data)) memories = data;
else if (data.memories) memories = data.memories;
else if (data.results) memories = data.results;
countEl.textContent = `${memories.length}`;
if (memories.length === 0) {
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
return;
}
tbody.innerHTML = memories.map(m => `
<tr>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(m.content || '')}">${escHtml((m.content || '').substring(0, 80))}</td>
<td><span class="badge badge-idle">${escHtml(m.category || 'other')}</span></td>
<td>${m.priority ?? 1}</td>
<td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td>
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
</tr>
`).join('');
}
async function addMemory() {
const user_id = document.getElementById('mem-add-user-id').value.trim();
const content = document.getElementById('mem-add-content').value.trim();
const category = document.getElementById('mem-add-category').value;
const priority = parseInt(document.getElementById('mem-add-priority').value);
if (!user_id || !content) { showToast('请填写用户ID和内容', 'error'); return; }
const data = await api('/api/memory/add', {
method: 'POST',
body: JSON.stringify({ user_id, content, category, priority }),
});
if (data.error) { showToast(`添加失败: ${data.error}`, 'error'); return; }
showToast('记忆添加成功!', 'success');
document.getElementById('mem-add-content').value = '';
// 自动刷新列表
listMemory();
}
// ========== 面板3: 会话监看 ==========
function renderSessionsPanel() {
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm" onclick="loadSessions()" id="sessions-refresh-btn">🔄 刷新</button>
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
`;
document.getElementById('panel-sessions').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">💬 活跃 WebSocket 会话</span>
<span id="sessions-count" style="font-size:12px;color:var(--text2)"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th style="width:30px"></th><th>Session ID</th><th>User ID</th><th>状态</th><th>消息数</th><th>连接时间</th><th>最后活跃</th></tr></thead>
<tbody id="sessions-table-body">
<tr><td colspan="7"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr>
</tbody>
</table>
</div>
</div>
`;
loadSessions();
}
async function loadSessions() {
const btn = document.getElementById('sessions-refresh-btn');
if (btn) btn.classList.add('spinning');
const data = await api('/api/sessions');
if (btn) btn.classList.remove('spinning');
const sessions = data.sessions || [];
STATE.sessionsData = sessions;
// 更新侧边栏徽章
const badge = document.getElementById('sessions-badge');
badge.textContent = sessions.length;
badge.style.display = sessions.length > 0 ? 'inline-block' : 'none';
// 更新计数
const countEl = document.getElementById('sessions-count');
if (countEl) countEl.textContent = `${sessions.length} 个活跃会话`;
const tbody = document.getElementById('sessions-table-body');
if (!tbody) return;
if (data.error) {
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}<br><span style="font-size:11px">请确认 Gateway 服务已启动</span></div></td></tr>`;
return;
}
if (sessions.length === 0) {
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div></td></tr>';
return;
}
tbody.innerHTML = sessions.map((s, i) => `
<tr id="session-row-${i}" class="session-row" data-index="${i}" style="cursor:pointer" onclick="toggleSessionDetail(${i})">
<td><span class="collapse-arrow" id="session-arrow-${i}">▶</span></td>
<td><code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 20))}${(s.session_id || '').length > 20 ? '...' : ''}</code></td>
<td>${escHtml(s.user_id || '—')}</td>
<td><span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span></td>
<td>${s.message_count || 0}</td>
<td style="font-size:11px;color:var(--text2)">${timeAgo(s.connected_at)}</td>
<td style="font-size:11px;color:var(--text2)">${timeAgo(s.last_activity)}</td>
</tr>
<tr id="session-detail-${i}" style="display:none">
<td colspan="7">
<div class="session-detail" id="session-detail-content-${i}">
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
</div>
</td>
</tr>
`).join('');
}
async function toggleSessionDetail(index) {
const detailRow = document.getElementById(`session-detail-${index}`);
const arrow = document.getElementById(`session-arrow-${index}`);
const contentEl = document.getElementById(`session-detail-content-${index}`);
if (detailRow.style.display !== 'none') {
// 折叠
detailRow.style.display = 'none';
arrow.classList.remove('open');
return;
}
// 展开
detailRow.style.display = '';
arrow.classList.add('open');
const session = STATE.sessionsData[index];
if (!session) return;
// 获取详情
const data = await api(`/api/sessions/${session.session_id}`);
if (data.error) {
contentEl.innerHTML = `<div style="color:var(--red);text-align:center">${escHtml(data.error)}</div>`;
return;
}
const messages = data.recent_messages || [];
contentEl.innerHTML = `
<div class="detail-row">
<span class="detail-label">会话ID:</span>
<code style="font-size:11px">${escHtml(data.session_id || session.session_id)}</code>
</div>
<div class="detail-row">
<span class="detail-label">用户ID:</span>
<span>${escHtml(data.user_id || session.user_id)}</span>
</div>
<div class="detail-row">
<span class="detail-label">状态:</span>
<span class="badge ${statusBadge(data.state || 'idle')}">${data.state || 'idle'}</span>
</div>
<div class="detail-row">
<span class="detail-label">消息数:</span>
<span>${data.message_count || 0}</span>
</div>
<div class="detail-row">
<span class="detail-label">连接时间:</span>
<span>${formatTime(data.connected_at)}</span>
</div>
<div class="detail-row">
<span class="detail-label">最后活跃:</span>
<span>${formatTime(data.last_activity)}</span>
</div>
${messages.length > 0 ? `
<div style="margin-top:10px;font-weight:600;font-size:12px;color:var(--text2)">📝 最近消息 (${messages.length})</div>
<div class="msg-list">
${messages.map(m => `
<div class="msg-item">
<span class="role ${m.role}">${m.role}</span>
<span style="color:var(--text2);font-size:10px;margin-right:6px">${formatTime(m.timestamp)}</span>
${escHtml(m.content || '')}
</div>
`).join('')}
</div>
` : '<div style="margin-top:8px;color:var(--text2);font-size:12px">暂无消息记录</div>'}
`;
}
// ========== 面板4: 服务管理 ==========
function renderServicesPanel() {
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm btn-accent" onclick="svcAction('start-all')">▶ 一键启动</button>
<button class="btn btn-sm" onclick="svcAction('start-all-fresh')">🔄 强制重启全部</button>
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
`;
document.getElementById('panel-services').innerHTML = `
<!-- 服务状态卡片 -->
<div class="card">
<div class="card-header"><span class="card-title">📡 服务管理</span></div>
<div class="cards-grid cards-3" id="services-svc-cards"></div>
</div>
<!-- 实时日志 -->
<div class="card">
<div class="card-header">
<span class="card-title">📋 实时日志</span>
<div class="btn-group">
<div class="log-tabs" id="services-log-tabs" style="margin:0"></div>
<button class="btn btn-xs" onclick="toggleSvcLogLayout()" id="btn-svc-log-layout">📐 并列</button>
<button class="btn btn-xs" onclick="clearSvcLogs()">🗑 清空</button>
</div>
</div>
<div id="services-log-tabs-panel">
<div class="log-container" id="services-log-panel">
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
</div>
</div>
<div id="services-log-grid" style="display:none">
<div class="cards-grid cards-3">
<div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
<div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
<div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
</div>
</div>
</div>
`;
renderServiceCards();
initSvcLogTabs();
renderServiceLog();
}
function renderServiceCards() {
const container = document.getElementById('services-svc-cards');
if (!container) return;
const status = STATE.serviceStatus;
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ['ai-core', 'gateway', 'frontend'];
container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
const isRunning = svc.status === 'running';
const isStarting = svc.status === 'starting' || svc.status === 'building';
const isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
return `
<div class="card" style="margin:0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
<span class="badge ${statusBadge(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">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
${isStopped ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶ 启动</button>` : ''}
${isStopped || isStarting ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨 编译</button>` : ''}
${isRunning ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄 重启</button>` : ''}
${isRunning || isStarting ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹ 停止</button>` : ''}
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
</div>
</div>`;
}).join('');
}
// ---- 日志功能 ----
function initSvcLogTabs() {
const tabs = document.getElementById('services-log-tabs');
if (!tabs) return;
tabs.innerHTML = ['ai-core', 'gateway', 'frontend'].map(id =>
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
).join('');
}
function switchSvcLogTab(id) {
STATE.activeLogTab = id;
initSvcLogTabs();
renderServiceLog();
}
function renderServiceLog() {
const panel = document.getElementById('services-log-panel');
if (!panel) return;
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 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 toggleSvcLogLayout() {
const btn = document.getElementById('btn-svc-log-layout');
const tabsPanel = document.getElementById('services-log-tabs-panel');
const gridPanel = document.getElementById('services-log-grid');
const logTabs = document.getElementById('services-log-tabs');
if (STATE.logLayout === 'tabs') {
STATE.logLayout = 'grid';
tabsPanel.style.display = 'none';
gridPanel.style.display = '';
logTabs.style.display = 'none';
btn.textContent = '📋 标签页';
['ai-core', 'gateway', 'frontend'].forEach(id => renderGridLog(id));
} else {
STATE.logLayout = 'tabs';
tabsPanel.style.display = '';
gridPanel.style.display = 'none';
logTabs.style.display = '';
btn.textContent = '📐 并列';
renderServiceLog();
}
}
async function clearSvcLogs() {
const id = STATE.activeLogTab;
await api(`/api/logs/${id}`, { method: 'DELETE' });
STATE.logLines[id] = [];
renderServiceLog();
}
// ---- 服务操作 ----
async function svcAction(cmd, serviceId) {
let url;
if (cmd === 'start-all') url = '/api/services/start-all';
else if (cmd === 'start-all-fresh') url = '/api/services/start-all-fresh';
else if (cmd === 'stop-all') url = '/api/services/stop-all';
else url = `/api/services/${serviceId}/${cmd}`;
const method = ['start','stop','restart','build','start-all','start-all-fresh','stop-all'].includes(cmd) ? 'POST' : 'GET';
const res = await api(url, { method });
showToast(res.message || res.error || `${cmd} 完成`, res.error ? 'error' : 'success');
refreshStatus();
}
async function checkHealth(id) {
const res = await api(`/api/proxy/${id}/health`);
if (res.error) {
showToast(`${id}: ${res.error}`, 'error');
} else {
showToast(`${id}: ${res.status} - ${JSON.stringify(res.data).substring(0, 100)}`, 'success');
}
}
async function refreshStatus() {
const status = await api('/api/services');
if (!status.error) {
STATE.serviceStatus = status;
if (STATE.activePanel === 'services') renderServiceCards();
if (STATE.activePanel === 'dashboard') renderDashboard();
}
if (STATE.activePanel === 'performance') refreshPerf();
}
// ========== 面板5: 性能监控 ==========
function renderPerformancePanel() {
document.getElementById('panel-performance').innerHTML = `
<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="cards-grid cards-3" id="perf-panels"></div>
</div>
`;
refreshPerf();
}
async function refreshPerf() {
const [snap, history] = await Promise.all([
api('/api/performance'),
api('/api/performance/history'),
]);
if (history && !history.error) {
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');
if (!container) return;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'frontend'];
container.innerHTML = ids.map(id => {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return `
<div class="card" style="margin:0">
<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 || 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} `;
}
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"/>
`;
}
// ========== 初始化 ==========
connectWS();
refreshStatus();
renderDashboard();
// 全局状态定时刷新
STATE.statusInterval = setInterval(refreshStatus, 5000);
</script>
</body>
</html>