Files
Cyrene/devtools/public/index.html
T
AskaEth d00a8313ad fix: 第三轮修复 — 前端Session切换、DevTools UI刷新保持、头像背景替换
1. 修复前端清空对话无反应 (clearMainSessionMessages 链路)
2. 修复清除所有对话后侧边栏残留 + 重复新增按钮
3. 修复侧边栏点击无法切换会话 (Zustand 竞态 + URL hash)
4. 修复 URL 不显示 session ID (hash 同步链)
5. DevTools 会话监看刷新保持展开/折叠状态
6. 首页性能仪表盘去重 + 资源使用卡片 60s sparkline
7. DevTools 全局刷新改为 DOM 局部增量更新
8. 替换前端昔涟头像、聊天背景、用户头像为实际图片
9. 修复图片文件名 (双.png + 目录拼写)
2026-05-17 20:32:42 +08:00

1993 lines
84 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); }
/* 性能仪表盘进度条 */
.perf-dashboard { display: flex; flex-direction: column; gap: 14px; }
.perf-row { display: flex; align-items: center; gap: 12px; }
.perf-label { min-width: 60px; font-size: 12px; color: var(--text2); }
.perf-value { min-width: 52px; font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; text-align: right; }
.perf-bar-wrap { flex: 1; background: var(--bg); border-radius: 4px; height: 10px; overflow: hidden; }
.perf-bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.perf-bar.cpu-low, .perf-bar.cpu-mid, .perf-bar.cpu-high, .perf-bar.mem-low, .perf-bar.mem-mid, .perf-bar.mem-high { background: var(--blue); }
.perf-bar.cpu-mid, .perf-bar.mem-mid { background: var(--yellow); }
.perf-bar.cpu-high, .perf-bar.mem-high { background: var(--red); }
.perf-bar.mem-low { background: var(--green); }
.perf-stat { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.perf-stat:last-child { border-bottom: none; }
.perf-stat-icon { font-size: 16px; width: 24px; text-align: center; }
.perf-stat-label { font-size: 12px; color: var(--text2); flex: 1; }
.perf-stat-value { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; }
.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; }
/* 数据库监看 */
.db-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.db-port-card {
background: var(--bg3); border-radius: var(--radius-sm); padding: 12px;
display: flex; align-items: center; gap: 10px; transition: all .2s;
border: 1px solid transparent;
}
.db-port-card.alive { border-color: var(--green); background: var(--green-bg); }
.db-port-card.dead { border-color: var(--red); background: var(--red-bg); opacity: .7; }
.db-port-card .db-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.db-port-card.alive .db-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.db-port-card.dead .db-dot { background: var(--red); }
.db-port-card .db-info { flex: 1; min-width: 0; }
.db-port-card .db-name { font-size: 12px; font-weight: 600; }
.db-port-card .db-port-label { font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
.db-summary { display: flex; gap: 20px; align-items: center; padding: 12px 0; }
.db-summary-stat { text-align: center; }
.db-summary-stat .val { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.db-summary-stat .lbl { font-size: 10px; color: var(--text2); }
.tunnel-log {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
max-height: 200px; overflow-y: auto; padding: 8px; margin-top: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
white-space: pre-wrap; word-break: break-all; color: var(--text2);
}
/* IoT 设备控制面板 */
.iot-device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.iot-device-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; transition: border-color .2s;
}
.iot-device-card:hover { border-color: var(--accent); }
.iot-device-card.on { border-color: var(--green); }
.iot-device-card.off { border-color: var(--border2); opacity: .85; }
.iot-device-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
}
.iot-device-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.iot-device-type { font-size: 10px; color: var(--text2); text-transform: uppercase; }
.iot-device-status { display: flex; align-items: center; gap: 6px; }
.iot-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.iot-status-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.iot-status-dot.off { background: var(--text3); }
.iot-device-props { margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
.iot-prop-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; padding: 4px 0;
}
.iot-prop-label { color: var(--text2); min-width: 50px; }
.iot-prop-value {
font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 45px; text-align: right;
font-size: 12px;
}
.iot-prop-control { display: flex; align-items: center; gap: 6px; flex: 1; justify-content: flex-end; }
.iot-prop-control input[type="range"] { width: 100px; accent-color: var(--accent); }
.iot-device-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.iot-toggle-btn {
padding: 5px 14px; border-radius: var(--radius-sm); border: 1px solid;
cursor: pointer; font-size: 12px; font-weight: 600; transition: all .15s;
font-family: inherit;
}
.iot-toggle-btn.on { background: var(--green-bg); color: var(--green); border-color: var(--green); }
.iot-toggle-btn.on:hover { background: var(--green); color: #000; }
.iot-toggle-btn.off { background: var(--red-bg); color: var(--red); border-color: var(--red); }
.iot-toggle-btn.off:hover { background: var(--red); color: #fff; }
.iot-mode-btn {
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 11px; background: var(--bg3); color: var(--text);
transition: all .15s; font-family: inherit;
}
.iot-mode-btn:hover { background: var(--bg4); border-color: var(--text2); }
.iot-mode-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
.iot-color-btn {
width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border);
cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.iot-color-btn:hover { border-color: var(--text2); transform: scale(1.15); }
.iot-color-btn.active { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
.iot-history-panel {
margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px;
}
.iot-history-item {
font-size: 11px; color: var(--text2); padding: 3px 0; display: flex; gap: 10px;
font-family: 'JetBrains Mono', monospace;
}
.iot-history-item .iot-hist-time { color: var(--text3); min-width: 60px; }
.iot-history-item .iot-hist-action { color: var(--accent); }
.iot-history-item .iot-hist-detail { color: var(--text2); }
.iot-refresh-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.iot-last-update { font-size: 11px; color: var(--text3); }
</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>
<button class="nav-item" data-panel="iot">
<span class="nav-icon">🏠</span><span class="nav-label">IoT 设备</span>
<span class="nav-badge" id="iot-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="database">
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
<span class="nav-badge" id="db-badge" style="display:none"></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>
<!-- IoT 设备控制 -->
<div class="panel" id="panel-iot"></div>
<!-- 性能监控 -->
<div class="panel" id="panel-performance"></div>
<!-- 数据库监看 -->
<div class="panel" id="panel-database"></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': [], 'iot-debug-service': [] },
maxLogLines: 500,
logLayout: 'tabs',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
// 计时器
dashboardInterval: null,
statusInterval: null,
dbInterval: null,
// 仪表盘增量刷新 (Bug 7)
dashboardRenderCount: 0,
// 资源使用 60s 滑动窗口历史 (Bug 6)
resourceHistory: {},
};
// ========== 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 drawSparkline(canvas, data, color) {
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - ((data[i] - min) / range) * (h - 4) - 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
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', 'iot-debug-service': 'IoT Debug' };
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 parsed;
try { parsed = JSON.parse(text); } catch { parsed = null; }
return {
error: parsed?.error || text,
errorType: parsed?.errorType || null,
hint: parsed?.hint || null,
status: res.status,
};
}
const body = await res.json();
// 即使 HTTP 200body 中也可能包含 error 字段(如 Gateway 代理失败返回的 502
if (body && body.error) {
return { ...body, status: res.status };
}
return body;
} catch (err) {
return { error: err.message, status: 0 };
}
}
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: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
};
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(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); 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);
}
function startDbAutoRefresh() {
stopDbAutoRefresh();
STATE.dbInterval = setInterval(() => {
if (STATE.activePanel === 'database') renderDatabasePanel();
}, 5000);
}
function stopDbAutoRefresh() {
if (STATE.dbInterval) { clearInterval(STATE.dbInterval); STATE.dbInterval = null; }
}
// ========== 面板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>';
STATE.dashboardRenderCount = 0;
return;
}
STATE.dashboardData = data;
const svcs = data.services?.list || {};
const runningCount = data.services?.running || 0;
const totalSvcs = data.services?.total || Object.keys(svcs).length;
const isFirstRender = STATE.dashboardRenderCount === 0;
// Bug 7: 首次渲染完整 DOM,后续只做增量更新
if (isFirstRender) {
document.getElementById('panel-dashboard').innerHTML =
'<!-- 概览统计 -->' +
'<div class="cards-grid cards-4" style="margin-bottom:16px">' +
'<div class="stat-card green">' +
'<div class="stat-value" id="stat-running">' + runningCount + '/' + totalSvcs + '</div>' +
'<div class="stat-label">服务运行中</div>' +
'</div>' +
'<div class="stat-card blue">' +
'<div class="stat-value" id="stat-sessions">' + (data.sessions?.active ?? '—') + '</div>' +
'<div class="stat-label">活跃会话</div>' +
'</div>' +
'<div class="stat-card accent">' +
'<div class="stat-value" id="stat-memory">' + (data.memory?.total ?? '—') + '</div>' +
'<div class="stat-label">记忆条目</div>' +
'</div>' +
'<div class="stat-card orange">' +
'<div class="stat-value" id="stat-heap">' + (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-4" id="dashboard-svc-cards"></div>' +
'</div>' +
'<!-- 数据库状态卡片 -->' +
'<div class="card" id="db-card">' +
'<div class="card-header">' +
'<span class="card-title">🗄️ 数据库</span>' +
'<span class="badge badge-stopped" id="db-status-badge">检查中...</span>' +
'</div>' +
'<div class="metrics">' +
'<div class="metric"><div class="value" id="db-type-display">PostgreSQL</div><div class="label">类型</div></div>' +
'<div class="metric"><div class="value" id="db-port-display">5432</div><div class="label">端口</div></div>' +
'<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
'</div>' +
'<div class="btn-group" style="margin-top:10px">' +
'<button class="btn btn-xs btn-green" onclick="controlDB(\'start\')">▶ 启动</button>' +
'<button class="btn btn-xs btn-red" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
'<button class="btn btn-xs" onclick="controlDB(\'restart\')">🔄 重启</button>' +
'<a href="#" onclick="switchPanel(\'database\');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>' +
'</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 id="performance-dashboard">' +
'<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>' +
'</div>' +
'</div>' +
'</div>' +
'<!-- 系统信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">💻 系统信息</span></div>' +
'<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap" id="sys-info-row">' +
'<div><span style="color:var(--text2)">运行时间:</span> <span id="sys-uptime">' + formatUptime((data.system?.uptime || 0) * 1000) + '</span></div>' +
'<div><span style="color:var(--text2)">堆内存:</span> <span id="sys-heap">' + (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB</span></div>' +
'<div><span style="color:var(--text2)">总消息数:</span> <span id="sys-msgs">' + (data.sessions?.totalMessages ?? 0) + '</span></div>' +
'<div><span style="color:var(--text2)">更新时间:</span> <span id="sys-time">' + formatTime(data.timestamp) + '</span></div>' +
'</div>' +
'</div>';
} else {
// Bug 7: 增量更新 — 只更新动态数值,不重建整个 DOM
var el;
el = document.getElementById('stat-running'); if (el) el.textContent = runningCount + '/' + totalSvcs;
el = document.getElementById('stat-sessions'); if (el) el.textContent = data.sessions?.active ?? '—';
el = document.getElementById('stat-memory'); if (el) el.textContent = data.memory?.total ?? '—';
el = document.getElementById('stat-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB';
// 系统信息
el = document.getElementById('sys-uptime'); if (el) el.textContent = formatUptime((data.system?.uptime || 0) * 1000);
el = document.getElementById('sys-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB';
el = document.getElementById('sys-msgs'); if (el) el.textContent = data.sessions?.totalMessages ?? 0;
el = document.getElementById('sys-time'); if (el) el.textContent = formatTime(data.timestamp);
}
// 渲染服务卡片
renderDashboardSvcCards(svcs);
// 渲染数据库卡片 (renderDBCard 本身就只更新 textContent,见 Bug 7 fix)
renderDBCard();
// Bug 6: 渲染资源使用卡片 (增量更新 + sparkline)
renderResourceUsage(data.performance?.perService || {});
// 渲染性能仪表盘 (updatePerformanceDashboard 内联更新)
updatePerformanceDashboard(data.performance?.perService || {});
STATE.dashboardRenderCount++;
}
// ========== 资源使用卡片渲染 (Bug 6: sparkline + 增量更新) ==========
function renderResourceUsage(perfData) {
const container = document.getElementById('dashboard-perf');
if (!container) return;
const entries = Object.entries(perfData);
const MAX_HISTORY = 60;
const firstRender = STATE.dashboardRenderCount === 0;
// 首次渲染: 创建完整 DOM 结构 (含 canvas)
if (firstRender || entries.length > 0 && !container.querySelector('.resource-row')) {
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
return;
}
container.innerHTML = entries.map(function (kv) {
const id = kv[0], p = kv[1];
return '<div class="resource-row" data-svc="' + id + '" style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);gap:8px">' +
'<span style="font-weight:500;min-width:70px">' + escapeId(id) + '</span>' +
'<canvas id="sparkline-' + id + '" width="120" height="28" style="flex-shrink:0"></canvas>' +
'<span class="resource-val" style="font-family:\'JetBrains Mono\',monospace;font-size:12px;color:var(--text2);min-width:110px;text-align:right">' +
'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB' +
'</span>' +
'</div>';
}).join('');
}
// 增量更新: 只更新数值和 sparkline
const rows = container.querySelectorAll('.resource-row');
rows.forEach(function (row) {
const svcId = row.getAttribute('data-svc');
const p = perfData[svcId];
if (!p) return;
// 更新数值
const valEl = row.querySelector('.resource-val');
if (valEl) valEl.textContent = 'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB';
// 更新 60s 滑动窗口历史
if (!STATE.resourceHistory[svcId]) STATE.resourceHistory[svcId] = { cpu: [], mem: [] };
var h = STATE.resourceHistory[svcId];
h.cpu.push(p.cpu || 0);
h.mem.push(p.mem || 0);
if (h.cpu.length > MAX_HISTORY) h.cpu.shift();
if (h.mem.length > MAX_HISTORY) h.mem.shift();
// 绘制 CPU sparkline
var cpuCanvas = document.getElementById('sparkline-' + svcId);
if (cpuCanvas) drawSparkline(cpuCanvas, h.cpu, '#3b82f6');
});
}
// ========== 性能仪表盘渲染 ==========
async function updatePerformanceDashboard(perfData) {
const container = document.getElementById('performance-dashboard');
if (!container) return; // 静默跳过:用户在其他页面
const entries = Object.entries(perfData);
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>';
return;
}
// 聚合数据
let totalCpu = 0, totalMem = 0, activeCount = 0;
for (const [, p] of entries) {
totalCpu += p.cpu || 0;
totalMem += p.mem || 0;
if (p.pid) activeCount++;
}
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
const cpuLevel = avgCpu > 80 ? 'cpu-high' : avgCpu > 50 ? 'cpu-mid' : 'cpu-low';
const memLevel = totalMem > 1024 ? 'mem-high' : totalMem > 512 ? 'mem-mid' : 'mem-low';
// 计算平均延迟 (基于活跃连接和服务数估算,或使用 perf 数据中的 elapsed)
let avgLatency = '—';
let totalElapsed = 0, elapsedCount = 0;
for (const [, p] of entries) {
if (p.elapsed && p.elapsed > 0) { totalElapsed += p.elapsed; elapsedCount++; }
}
if (elapsedCount > 0) {
avgLatency = Math.round(totalElapsed / elapsedCount) + 'ms';
}
// 获取趋势数据 (从性能仪表盘 API)
let trendCpu = '→', trendMem = '→';
try {
const dashResp = await api('/api/performance/dashboard');
if (!dashResp.error && dashResp.summary?.trend) {
const t = dashResp.summary.trend;
trendCpu = t.cpu === 'up' ? '↑' : t.cpu === 'down' ? '↓' : '→';
trendMem = t.mem === 'up' ? '↑' : t.mem === 'down' ? '↓' : '→';
// 使用 API 返回的更精确的延迟数据
if (dashResp.summary.avgLatencyMs != null) {
avgLatency = dashResp.summary.avgLatencyMs + 'ms';
}
}
} catch { /* 忽略: 使用本地计算的数据 */ }
// Bug 7: 增量更新 — 首次创建 DOM 结构,后续只更新数值
const isFirstRender = !container.querySelector('.perf-dashboard');
if (isFirstRender) {
container.innerHTML =
'<div class="perf-dashboard">' +
'<!-- CPU 使用率 -->' +
'<div class="perf-row">' +
'<span class="perf-label">🖥 CPU</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + cpuLevel + '" id="perf-cpu-bar" style="width:' + Math.min(avgCpu, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-cpu-val">' + avgCpu + '% ' + trendCpu + '</span>' +
'</div>' +
'<!-- 内存使用 -->' +
'<div class="perf-row">' +
'<span class="perf-label">💾 内存</span>' +
'<div class="perf-bar-wrap">' +
'<div class="perf-bar ' + memLevel + '" id="perf-mem-bar" style="width:' + Math.min(totalMem / 1024 * 100, 100) + '%"></div>' +
'</div>' +
'<span class="perf-value" id="perf-mem-val">' + Math.round(totalMem) + ' MB ' + trendMem + '</span>' +
'</div>' +
'<!-- 详细统计 -->' +
'<div style="margin-top:8px">' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">⏱</span>' +
'<span class="perf-stat-label">平均请求延迟</span>' +
'<span class="perf-stat-value" id="perf-latency" style="color:var(--yellow)">' + avgLatency + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">🔗</span>' +
'<span class="perf-stat-label">活跃连接数</span>' +
'<span class="perf-stat-value" id="perf-conns" style="color:var(--accent)">' + activeCount + '</span>' +
'</div>' +
'<div class="perf-stat">' +
'<span class="perf-stat-icon">📦</span>' +
'<span class="perf-stat-label">监控服务数</span>' +
'<span class="perf-stat-value" id="perf-svcs" style="color:var(--blue)">' + entries.length + '</span>' +
'</div>' +
'</div>' +
'</div>';
} else {
// 增量更新: 只更新数值
var cpuBar = document.getElementById('perf-cpu-bar');
if (cpuBar) {
cpuBar.className = 'perf-bar ' + cpuLevel;
cpuBar.style.width = Math.min(avgCpu, 100) + '%';
}
var cpuVal = document.getElementById('perf-cpu-val');
if (cpuVal) cpuVal.textContent = avgCpu + '% ' + trendCpu;
var memBar = document.getElementById('perf-mem-bar');
if (memBar) {
memBar.className = 'perf-bar ' + memLevel;
memBar.style.width = Math.min(totalMem / 1024 * 100, 100) + '%';
}
var memVal = document.getElementById('perf-mem-val');
if (memVal) memVal.textContent = Math.round(totalMem) + ' MB ' + trendMem;
var latEl = document.getElementById('perf-latency');
if (latEl) latEl.textContent = avgLatency;
var connEl = document.getElementById('perf-conns');
if (connEl) connEl.textContent = activeCount;
var svcEl = document.getElementById('perf-svcs');
if (svcEl) svcEl.textContent = entries.length;
}
}
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 style="display:flex;align-items:center;gap:8px">
<select id="mem-sort-order" onchange="sortAndRenderMemories()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px">
<option value="desc">🕐 最新优先</option>
<option value="asc">🕐 最早优先</option>
</select>
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span>
</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>话题 (会话)</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead>
<tbody id="mem-table-body">
<tr><td colspan="7"><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) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
}
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div></td></tr>`;
countEl.textContent = '';
STATE.memoryCache = [];
return;
}
// 兼容不同返回格式
let memories = [];
if (Array.isArray(data)) memories = data;
else if (data.memories) memories = data.memories;
else if (data.results) memories = data.results;
// 缓存记忆数据用于排序
STATE.memoryCache = memories;
sortAndRenderMemories();
}
function sortAndRenderMemories() {
const tbody = document.getElementById('mem-table-body');
const countEl = document.getElementById('mem-result-count');
const sortOrder = document.getElementById('mem-sort-order')?.value || 'desc';
let memories = [...(STATE.memoryCache || [])];
// 按创建时间排序
memories.sort((a, b) => {
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return sortOrder === 'asc' ? ta - tb : tb - ta;
});
countEl.textContent = `${memories.length}`;
if (memories.length === 0) {
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
return;
}
tbody.innerHTML = memories.map(m => {
// 会话ID 简短显示
const sid = m.session_id || '—';
const sidShort = sid.length > 16 ? sid.substring(0, 14) + '…' : sid;
return `
<tr>
<td style="max-width:260px;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" title="${escHtml(sid)}">${escHtml(sidShort)}</td>
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
<td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></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();
}
async function deleteMemory(memoryId) {
if (!memoryId) { showToast('无效的记忆ID', 'error'); return; }
if (!confirm(`确定要删除记忆 ${memoryId.substring(0, 8)}... 吗?`)) return;
const data = await api(`/api/memory/${encodeURIComponent(memoryId)}`, { method: 'DELETE' });
if (data.error) { showToast(`删除失败: ${data.error}`, 'error'); return; }
showToast('记忆删除成功!', 'success');
// 自动刷新列表
listMemory();
}
// ========== 面板3: 会话监看 ==========
function renderSessionsPanel() {
document.getElementById('panel-actions').innerHTML = `
<button class="btn btn-sm" onclick="fetchActiveSessions()" 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 id="sessions-grouped-container">
<div class="empty-state"><div class="icon">💬</div>加载中...</div>
</div>
</div>
`;
fetchActiveSessions();
}
async function fetchActiveSessions() {
const btn = document.getElementById('sessions-refresh-btn');
if (btn) btn.classList.add('spinning');
const data = await api('/api/sessions/active');
if (btn) btn.classList.remove('spinning');
const users = data.users || {};
// 计算总会话数
let totalSessions = 0;
const flatSessions = [];
for (const [userID, sessions] of Object.entries(users)) {
totalSessions += sessions.length;
for (const s of sessions) {
flatSessions.push({ ...s, _userID: userID });
}
}
STATE.sessionsData = flatSessions;
// 更新侧边栏徽章
const badge = document.getElementById('sessions-badge');
badge.textContent = totalSessions;
badge.style.display = totalSessions > 0 ? 'inline-block' : 'none';
// 更新计数
const countEl = document.getElementById('sessions-count');
if (countEl) countEl.textContent = `${Object.keys(users).length} 个用户,${totalSessions} 个活跃会话`;
const container = document.getElementById('sessions-grouped-container');
if (!container) return;
if (data.error) {
const errMsg = escHtml(data.error);
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 服务已启动</span>';
}
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${errMsg}${hint}</div>`;
return;
}
if (Object.keys(users).length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
STATE.expandedSessions = [];
return;
}
// Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复
const previouslyExpanded = [];
const existingDetails = container.querySelectorAll('[id^="session-detail-"]');
existingDetails.forEach(function(el) {
if (el.style.display !== 'none') {
const match = el.id.match(/^session-detail-(\d+)$/);
if (match) previouslyExpanded.push(parseInt(match[1]));
}
});
let html = '';
let globalIndex = 0;
const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开
for (const [userID, sessions] of Object.entries(users)) {
html += `<div style="margin-bottom:16px">`;
html += `<div style="font-weight:600;font-size:14px;padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;color:var(--accent)">👤 User: ${escHtml(userID)}</div>`;
for (const s of sessions) {
const idx = globalIndex++;
flatSessionMap.push({ index: idx, session: s, userID: userID });
html += `
<div style="padding:6px 0 6px 20px">
<div id="session-row-${idx}" class="session-row" data-index="${idx}" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm)" onclick="toggleSessionDetail(${idx})">
<span class="collapse-arrow" id="session-arrow-${idx}">▶</span>
<span style="flex:1">💬 <strong>Session:</strong> <code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 24))}${(s.session_id || '').length > 24 ? '...' : ''}</code></span>
<span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span>
<span style="font-size:11px;color:var(--text2)">最近活动: ${timeAgo(s.last_activity)}</span>
</div>
<div id="session-detail-${idx}" style="display:none;margin-top:4px;margin-left:20px">
<div class="session-detail" id="session-detail-content-${idx}">
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
</div>
</div>
</div>`;
}
html += `</div>`;
}
container.innerHTML = html;
// Bug 5: 恢复之前展开的 session (通过 session_id 匹配新旧 index)
if (previouslyExpanded.length > 0) {
// 构建 session_id -> 旧 index 的映射
const oldSessionIdToIndex = {};
STATE.sessionsData.forEach(function(s, i) { oldSessionIdToIndex[s.session_id] = i; });
// 对每个之前展开的 index,找到对应的 session_id,再找到新的 index
const toExpandNewIndices = [];
previouslyExpanded.forEach(function(oldIdx) {
const oldSession = STATE.sessionsData[oldIdx];
if (oldSession) {
const sid = oldSession.session_id;
// 在新的 flatSessionMap 中按 session_id 查找
for (let j = 0; j < flatSessionMap.length; j++) {
if (flatSessionMap[j].session.session_id === sid) {
toExpandNewIndices.push(j);
break;
}
}
}
});
// 恢复展开
toExpandNewIndices.forEach(function(newIdx) {
restoreSessionExpand(newIdx);
});
}
}
// Bug 5 helper: 恢复展开的 session UI 并自动加载详情
async function restoreSessionExpand(index) {
const detailRow = document.getElementById('session-detail-' + index);
const arrow = document.getElementById('session-arrow-' + index);
if (!detailRow || !arrow) return;
detailRow.style.display = '';
arrow.classList.add('open');
// 直接加载详情内容 (不调用 toggleSessionDetail 以避免 flip-flop)
const session = STATE.sessionsData[index];
if (!session) return;
const contentEl = document.getElementById('session-detail-content-' + index);
if (!contentEl) return;
await loadSessionDetailContent(session, contentEl);
}
// 提取 session 详情加载逻辑为独立函数 (Bug 5 复用)
async function loadSessionDetailContent(session, contentEl) {
const data = await api('/api/sessions/' + session.session_id);
if (data.error) {
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
}
contentEl.innerHTML = '<div style="color:var(--red);text-align:center">' + escHtml(data.error) + hint + '</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(function(m) {
return '<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>');
}
// 保留旧 loadSessions 兼容其他调用
async function loadSessions() {
fetchActiveSessions();
}
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;
// 委托给共用函数
await loadSessionDetailContent(session, contentEl);
}
// ========== 面板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-4" 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-4">
<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-iot-debug-service" style="height:280px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</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', 'iot-debug-service', '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', 'iot-debug-service', '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', 'iot-debug-service', '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-4" 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', 'iot-debug-service', 'frontend'];
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
const isFirstRender = !container.querySelector('.perf-card');
if (isFirstRender) {
container.innerHTML = ids.map(function(id) {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
return '<div class="card perf-card" style="margin:0" data-svc="' + id + '">' +
'<div class="card-header">' +
'<span class="card-title">' + escapeId(id) + '</span>' +
'<span style="font-size:11px;color:var(--text2)" class="perf-snap-val">CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB</span>' +
'</div>' +
'<div class="chart-container">' +
'<svg viewBox="0 0 300 120" class="chart-svg" id="perf-chart-' + id + '">' +
drawChart(STATE.perfHistory[id] || []) +
'</svg>' +
'</div>' +
'</div>';
}).join('');
} else {
// 增量更新: 只更新 SVG 图表内容和快照数值
ids.forEach(function(id) {
var card = container.querySelector('.perf-card[data-svc="' + id + '"]');
if (!card) return;
var s = snap[id] || { pid: null, cpu: 0, mem: 0 };
var valEl = card.querySelector('.perf-snap-val');
if (valEl) valEl.textContent = 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB';
var svg = card.querySelector('.chart-svg');
if (svg) svg.innerHTML = drawChart(STATE.perfHistory[id] || []);
});
}
}
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"/>
`;
}
// ========== 面板: 数据库监看 ==========
async function fetchDatabaseStatus() {
return await api('/api/database/status');
}
async function renderDatabasePanel() {
const data = await fetchDatabaseStatus();
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>' +
'<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>';
const panel = document.getElementById('panel-database');
if (data.error) {
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
STATE.dbInitialized = false;
return;
}
const ports = data.ports || [];
const tunnelRunning = data.tunnelRunning;
const allAlive = data.allAlive;
const aliveCount = data.aliveCount;
const totalPorts = data.totalPorts;
const pg = data.pgDetails;
// 更新侧边栏数据库徽章
const badge = document.getElementById('db-badge');
if (badge) {
if (allAlive) {
badge.style.display = 'inline';
badge.style.color = 'var(--green)';
} else if (aliveCount > 0) {
badge.style.display = 'inline';
badge.style.color = 'var(--yellow)';
} else {
badge.style.display = 'none';
}
}
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素
const isFirstRender = !STATE.dbInitialized;
if (isFirstRender) {
panel.innerHTML =
'<!-- 概览 -->' +
'<div class="card">' +
'<div class="card-header">' +
'<span class="card-title">🔌 SSH 隧道状态</span>' +
'<span class="badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped') + '" id="db-tunnel-badge">' + (tunnelRunning ? '运行中' : '未运行') + '</span>' +
'</div>' +
'<div class="db-summary">' +
'<div class="db-summary-stat">' +
'<div class="val" id="db-alive-count" style="color:' + (allAlive ? 'var(--green)' : 'var(--red)') + '">' + aliveCount + '/' + totalPorts + '</div>' +
'<div class="lbl">数据库端口通联</div>' +
'</div>' +
(pg ?
'<div class="db-summary-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">' + (pg.memories ?? '—') + '</div>' +
'<div class="lbl">记忆条目 (' + escHtml(pg.database || '') + ')</div>' +
'</div>'
: '<div class="db-summary-stat" style="display:none" id="db-mem-stat">' +
'<div class="val" id="db-mem-count" style="color:var(--blue)">—</div>' +
'<div class="lbl">记忆条目</div>' +
'</div>') +
'<div class="db-summary-stat">' +
'<div class="val" id="db-check-time" style="color:var(--text2)">' + formatTime(data.timestamp) + '</div>' +
'<div class="lbl">最后检查时间</div>' +
'</div>' +
'</div>' +
'<div class="db-grid" id="db-ports-grid">' +
ports.map(function(p) {
return '<div class="db-port-card ' + (p.alive ? 'alive' : 'dead') + '" data-port="' + p.port + '">' +
'<div class="db-dot"></div>' +
'<div class="db-info">' +
'<div class="db-name">' + escHtml(p.name) + '</div>' +
'<div class="db-port-label">:' + p.port + ' ' + (p.alive ? '✅' : '❌') + '</div>' +
'</div>' +
'</div>';
}).join('') +
'</div>' +
'</div>' +
'<!-- 隧道操作 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>' +
'<div class="btn-group" style="margin-bottom:8px">' +
'<button class="btn btn-green btn-sm" id="db-tunnel-start" onclick="tunnelAction(\'start\')"' + (tunnelRunning && allAlive ? ' disabled' : '') + '>▶ 启动隧道</button>' +
'<button class="btn btn-red btn-sm" id="db-tunnel-stop" onclick="tunnelAction(\'stop\')"' + (!tunnelRunning ? ' disabled' : '') + '>⏹ 停止隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'restart\')">🔄 重启隧道</button>' +
'<button class="btn btn-sm" onclick="tunnelAction(\'status\')">📋 查看状态</button>' +
'</div>' +
'<div id="db-zombie-warn" style="font-size:11px;color:var(--yellow);margin-bottom:8px;display:' + (tunnelRunning && !allAlive ? 'block' : 'none') + '">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' +
'<div id="tunnel-log-container" style="display:none">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
'<button class="btn btn-xs" onclick="document.getElementById(\'tunnel-log-container\').style.display=\'none\'">✕</button>' +
'</div>' +
'<div class="tunnel-log" id="tunnel-log"></div>' +
'</div>' +
'</div>' +
'<!-- 数据库连接信息 -->' +
'<div class="card">' +
'<div class="card-header"><span class="card-title">📋 连接说明</span></div>' +
'<div style="font-size:12px;color:var(--text2);line-height:1.8">' +
'<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>' +
'<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>' +
'<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>' +
'</div>' +
'</div>';
STATE.dbInitialized = true;
} else {
// Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间
var el;
// 隧道状态徽章
el = document.getElementById('db-tunnel-badge');
if (el) {
el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped');
el.textContent = tunnelRunning ? '运行中' : '未运行';
}
// 端口通联计数
el = document.getElementById('db-alive-count');
if (el) {
el.textContent = aliveCount + '/' + totalPorts;
el.style.color = allAlive ? 'var(--green)' : 'var(--red)';
}
// 记忆条目
var memStat = document.getElementById('db-mem-stat');
el = document.getElementById('db-mem-count');
if (pg) {
if (memStat) memStat.style.display = '';
if (el) el.textContent = pg.memories ?? '—';
} else {
if (memStat) memStat.style.display = 'none';
}
// 检查时间
el = document.getElementById('db-check-time');
if (el) el.textContent = formatTime(data.timestamp);
// 更新端口卡片
var grid = document.getElementById('db-ports-grid');
if (grid) {
ports.forEach(function(p) {
var card = grid.querySelector('.db-port-card[data-port="' + p.port + '"]');
if (card) {
card.className = 'db-port-card ' + (p.alive ? 'alive' : 'dead');
var lbl = card.querySelector('.db-port-label');
if (lbl) lbl.textContent = ':' + p.port + ' ' + (p.alive ? '✅' : '❌');
}
});
}
// 更新按钮 disable 状态
el = document.getElementById('db-tunnel-start');
if (el) el.disabled = !!(tunnelRunning && allAlive);
el = document.getElementById('db-tunnel-stop');
if (el) el.disabled = !tunnelRunning;
// 僵尸警告
el = document.getElementById('db-zombie-warn');
if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none';
}
}
function refreshDatabasePanel() {
renderDatabasePanel();
}
async function tunnelAction(action) {
showToast(`正在执行: ${action} 隧道...`, 'info');
const logContainer = document.getElementById('tunnel-log-container');
const logEl = document.getElementById('tunnel-log');
logEl.textContent = '执行中...';
logContainer.style.display = 'block';
const data = await api(`/api/tunnel/${action}`, { method: 'POST' });
if (data.error && !data.output) {
logEl.textContent = `错误: ${data.error}`;
showToast(`操作失败: ${data.error}`, 'error');
} else {
logEl.textContent = data.output || data.error || '(无输出)';
if (data.success) {
showToast(`${action} 隧道完成`, 'success');
} else {
showToast(`${action} 完成 (查看日志)`, 'info');
}
setTimeout(refreshDatabasePanel, 1500);
}
}
// ========== 数据库卡片控制 ==========
async function renderDBCard() {
const data = await api('/api/db/status');
const badge = document.getElementById('db-status-badge');
const typeDisplay = document.getElementById('db-type-display');
const portDisplay = document.getElementById('db-port-display');
const uptimeDisplay = document.getElementById('db-uptime-display');
if (data.error) {
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
return;
}
const online = data.online;
if (badge) {
badge.textContent = online ? '🟢 在线' : '🔴 离线';
badge.className = 'badge ' + (online ? 'badge-running' : 'badge-error');
}
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
if (portDisplay) portDisplay.textContent = data.port || 5432;
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
}
async function controlDB(action) {
showToast('正在' + action + '数据库...', 'info');
const data = await api('/api/db/' + action, { method: 'POST' });
if (data.error) {
showToast('操作失败: ' + data.error, 'error');
} else {
showToast('数据库 ' + action + ' 完成', 'success');
// 等待2秒后刷新状态
setTimeout(renderDBCard, 2000);
}
}
</script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
connectWS();
refreshStatus();
renderDashboard();
// 全局状态定时刷新
STATE.statusInterval = setInterval(refreshStatus, 5000);
</script>
</body>
</html>