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 + 目录拼写)
This commit is contained in:
+513
-279
@@ -491,6 +491,10 @@ const STATE = {
|
||||
dashboardInterval: null,
|
||||
statusInterval: null,
|
||||
dbInterval: null,
|
||||
// 仪表盘增量刷新 (Bug 7)
|
||||
dashboardRenderCount: 0,
|
||||
// 资源使用 60s 滑动窗口历史 (Bug 6)
|
||||
resourceHistory: {},
|
||||
};
|
||||
|
||||
// ========== WebSocket ==========
|
||||
@@ -543,6 +547,26 @@ 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);
|
||||
@@ -701,7 +725,8 @@ function stopDbAutoRefresh() {
|
||||
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>`;
|
||||
document.getElementById('panel-dashboard').innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||||
STATE.dashboardRenderCount = 0;
|
||||
return;
|
||||
}
|
||||
STATE.dashboardData = data;
|
||||
@@ -709,106 +734,167 @@ async function renderDashboard() {
|
||||
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;
|
||||
|
||||
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>
|
||||
// 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">' +
|
||||
'<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="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="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">
|
||||
<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 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();
|
||||
|
||||
// 渲染性能快照
|
||||
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>';
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 性能仪表盘渲染 ==========
|
||||
@@ -859,59 +945,73 @@ async function updatePerformanceDashboard(perfData) {
|
||||
}
|
||||
} catch { /* 忽略: 使用本地计算的数据 */ }
|
||||
|
||||
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}" style="width:${Math.min(avgCpu, 100)}%"></div>
|
||||
</div>
|
||||
<span class="perf-value">${avgCpu}% ${trendCpu}</span>
|
||||
</div>
|
||||
// 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}" style="width:${Math.min(totalMem / 1024 * 100, 100)}%"></div>
|
||||
</div>
|
||||
<span class="perf-value">${Math.round(totalMem)} MB ${trendMem}</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" 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" 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" style="color:var(--blue)">${entries.length}</span>
|
||||
</div>
|
||||
</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;
|
||||
|
||||
<!-- 各服务简要 -->
|
||||
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px">
|
||||
${entries.map(([id, p]) => `
|
||||
<div class="perf-stat">
|
||||
<span class="perf-stat-icon" style="font-size:12px">${p.pid ? '🟢' : '🔴'}</span>
|
||||
<span class="perf-stat-label">${escapeId(id)}</span>
|
||||
<span class="perf-stat-value" style="font-size:11px;color:var(--text2)">
|
||||
CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
||||
@@ -1192,17 +1292,30 @@ async function fetchActiveSessions() {
|
||||
|
||||
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})">
|
||||
@@ -1223,6 +1336,104 @@ async function fetchActiveSessions() {
|
||||
}
|
||||
|
||||
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 兼容其他调用
|
||||
@@ -1231,9 +1442,9 @@ async function loadSessions() {
|
||||
}
|
||||
|
||||
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}`);
|
||||
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') {
|
||||
// 折叠
|
||||
@@ -1249,61 +1460,8 @@ async function toggleSessionDetail(index) {
|
||||
const session = STATE.sessionsData[index];
|
||||
if (!session) return;
|
||||
|
||||
// 获取详情
|
||||
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(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>'}
|
||||
`;
|
||||
// 委托给共用函数
|
||||
await loadSessionDetailContent(session, contentEl);
|
||||
}
|
||||
|
||||
// ========== 面板4: 服务管理 ==========
|
||||
@@ -1529,21 +1687,36 @@ function renderPerfPanels(snap) {
|
||||
if (!container) return;
|
||||
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', '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('');
|
||||
// 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) {
|
||||
@@ -1582,14 +1755,14 @@ async function fetchDatabaseStatus() {
|
||||
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>
|
||||
`;
|
||||
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>`;
|
||||
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||||
STATE.dbInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1614,71 +1787,132 @@ async function renderDatabasePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<!-- 概览 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">🔌 SSH 隧道状态</span>
|
||||
<span class="badge ${tunnelRunning ? 'badge-running' : 'badge-stopped'}">${tunnelRunning ? '运行中' : '未运行'}</span>
|
||||
</div>
|
||||
<div class="db-summary">
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:${allAlive ? 'var(--green)' : 'var(--red)'}">${aliveCount}/${totalPorts}</div>
|
||||
<div class="lbl">数据库端口通联</div>
|
||||
</div>
|
||||
${pg ? `
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:var(--blue)">${pg.memories ?? '—'}</div>
|
||||
<div class="lbl">记忆条目 (${escHtml(pg.database || '')})</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:var(--text2)">${formatTime(data.timestamp)}</div>
|
||||
<div class="lbl">最后检查时间</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-grid">
|
||||
${ports.map(p => `
|
||||
<div class="db-port-card ${p.alive ? 'alive' : 'dead'}">
|
||||
<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>
|
||||
// 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" onclick="tunnelAction('start')" ${tunnelRunning && allAlive ? 'disabled' : ''}>▶ 启动隧道</button>
|
||||
<button class="btn btn-red btn-sm" 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>
|
||||
${tunnelRunning && !allAlive ? '<div style="font-size:11px;color:var(--yellow);margin-bottom:8px">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</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 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>
|
||||
`;
|
||||
'<!-- 数据库连接信息 -->' +
|
||||
'<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() {
|
||||
|
||||
+131
-10
@@ -35,19 +35,21 @@ var IOT_COLOR_OPTIONS = [
|
||||
];
|
||||
|
||||
async function renderIoTPanel() {
|
||||
var result = await fetchIoTDevices();
|
||||
var panel = document.getElementById('panel-iot');
|
||||
|
||||
// 更新操作栏 (只更新时间戳文本)
|
||||
document.getElementById('panel-actions').innerHTML =
|
||||
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
|
||||
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
||||
|
||||
var result = await fetchIoTDevices();
|
||||
var panel = document.getElementById('panel-iot');
|
||||
|
||||
if (result.error) {
|
||||
var hint = '';
|
||||
if (result.error.errorType === 'iot_not_running') {
|
||||
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
|
||||
}
|
||||
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
|
||||
STATE.iotInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,14 +70,133 @@ async function renderIoTPanel() {
|
||||
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
var html = '<div class="iot-refresh-bar">' +
|
||||
'<span style="font-weight:600;font-size:14px">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
||||
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
||||
'</div>';
|
||||
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
|
||||
var grid = document.getElementById('iot-device-grid');
|
||||
var firstRender = !STATE.iotInitialized;
|
||||
|
||||
panel.innerHTML = html;
|
||||
if (firstRender || !grid) {
|
||||
// 首次渲染: 创建完整结构
|
||||
var html = '<div class="iot-refresh-bar">' +
|
||||
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
||||
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
||||
'</div>';
|
||||
panel.innerHTML = html;
|
||||
STATE.iotInitialized = true;
|
||||
} else {
|
||||
// 增量更新: 只更新设备数量、最后更新时间
|
||||
var countEl = document.getElementById('iot-device-count');
|
||||
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
|
||||
|
||||
// 对每个已有设备卡片做增量更新
|
||||
devices.forEach(function(device) {
|
||||
updateIoTDeviceCardInPlace(device);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM
|
||||
function updateIoTDeviceCardInPlace(device) {
|
||||
var card = document.getElementById('iot-card-' + device.id);
|
||||
if (!card) return;
|
||||
|
||||
var isOn = device.status === 'on';
|
||||
|
||||
// 更新卡片 class (on/off 边框)
|
||||
card.className = 'iot-device-card ' + (isOn ? 'on' : 'off');
|
||||
|
||||
// 更新状态圆点和文字
|
||||
var statusDot = card.querySelector('.iot-status-dot');
|
||||
if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off');
|
||||
var statusText = card.querySelector('.iot-device-status span:last-child');
|
||||
if (statusText) {
|
||||
statusText.textContent = isOn ? '开启' : '关闭';
|
||||
statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)';
|
||||
}
|
||||
|
||||
// 更新开关按钮
|
||||
var toggleBtn = card.querySelector('.iot-toggle-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
|
||||
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
|
||||
}
|
||||
|
||||
// 更新设备属性值 (温度、亮度、位置等)
|
||||
var propValues = card.querySelectorAll('.iot-prop-value');
|
||||
var type = device.type;
|
||||
|
||||
if (type === 'ac') {
|
||||
// AC: 温度 slider + 模式按钮
|
||||
var tempSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (tempSlider) {
|
||||
tempSlider.value = device.temperature || 26;
|
||||
var tempVal = tempSlider.nextElementSibling;
|
||||
if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C';
|
||||
}
|
||||
// 更新模式按钮
|
||||
var modeBtns = card.querySelectorAll('.iot-mode-btn');
|
||||
modeBtns.forEach(function(btn) {
|
||||
var btnMode = btn.textContent.trim();
|
||||
if (btnMode === (device.mode || 'cool')) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
} else if (type === 'light') {
|
||||
var brightSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (brightSlider) {
|
||||
brightSlider.value = device.brightness || 80;
|
||||
var brightVal = brightSlider.nextElementSibling;
|
||||
if (brightVal) brightVal.textContent = (device.brightness || 80) + '%';
|
||||
}
|
||||
// 更新颜色按钮
|
||||
var colorBtns = card.querySelectorAll('.iot-color-btn');
|
||||
colorBtns.forEach(function(btn) {
|
||||
// 颜色值从 onclick 属性解析
|
||||
var onclick = btn.getAttribute('onclick') || '';
|
||||
var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/);
|
||||
if (match && match[1] === (device.color || 'warm_white')) {
|
||||
btn.classList.add('active');
|
||||
} else if (!match || match[1] !== (device.color || 'warm_white')) {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
} else if (type === 'curtain') {
|
||||
var posSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (posSlider) {
|
||||
posSlider.value = device.position != null ? device.position : 100;
|
||||
var posVal = posSlider.nextElementSibling;
|
||||
if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%';
|
||||
}
|
||||
} else if (device.temperature != null) {
|
||||
// sensor/thermostat: 只读温度
|
||||
if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C');
|
||||
}
|
||||
|
||||
// 更新电量
|
||||
var batteryEls = card.querySelectorAll('.iot-prop-value');
|
||||
batteryEls.forEach(function(el) {
|
||||
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
|
||||
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
|
||||
if (device.battery != null) el.textContent = device.battery + '%';
|
||||
}
|
||||
});
|
||||
|
||||
// 更新 AC 温度按钮的 onclick 引用
|
||||
if (type === 'ac') {
|
||||
var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs');
|
||||
acBtns.forEach(function(btn) {
|
||||
var text = btn.textContent.trim();
|
||||
var currentTemp = device.temperature || 26;
|
||||
if (text === '⬇ -2°C') {
|
||||
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
||||
} else if (text === '⬆ +2°C') {
|
||||
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderIoTDeviceCard(device) {
|
||||
|
||||
Reference in New Issue
Block a user