feat: DevTools DOM 增量更新优化 — 减少全量重建

三层优化:
- 第1层:所有自动刷新面板加入 djb2 哈希跳过,数据未变不重建 DOM
- 第2层:Tool Calls/STT/Timeline 展开状态保存/恢复,重建后自动恢复
- 第3层:Services/Dashboard/Sessions 面板就地更新属性值,不重建卡片

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 21:00:21 +08:00
parent 85f7f90318
commit 61284c9c6a
2 changed files with 246 additions and 26 deletions
+242 -26
View File
@@ -955,8 +955,25 @@ const STATE = {
fetchedModels: [],
llmCallsData: [],
expandedThinkingLogs: {},
// DOM 增量更新优化
renderHashes: {},
expandedToolCalls: {},
expandedSTTRows: {},
expandedTimelineItems: {},
servicesCardsRendered: false,
sessionsFirstRender: true,
prevSessionIds: [],
};
// 简单哈希函数 (djb2),用于判断数据是否变化
function simpleHash(str) {
var hash = 5381;
for (var i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
}
return hash;
}
// ========== WebSocket ==========
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws = null, wsRetryTimer = null;
@@ -1272,6 +1289,10 @@ async function renderDashboard() {
STATE.dashboardRenderCount = 0;
return;
}
// 数据未变则跳过 DOM 更新
var dataHash = simpleHash(JSON.stringify(data));
if (STATE.renderHashes['dashboard'] === dataHash && STATE.dashboardRenderCount > 0) return;
STATE.renderHashes['dashboard'] = dataHash;
STATE.dashboardData = data;
const svcs = data.services?.list || {};
@@ -1554,20 +1575,24 @@ async function updatePerformanceDashboard(perfData) {
function renderDashboardSvcCards(svcs) {
const container = document.getElementById('dashboard-svc-cards');
if (!container) return;
container.innerHTML = Object.entries(svcs).map(([id, svc]) => {
const isDocker = svc.source === 'docker';
return `
<div class="card" style="margin:0">
const entries = Object.entries(svcs);
const isFirstRender = STATE.dashboardRenderCount === 0;
if (isFirstRender) {
container.innerHTML = entries.map(([id, svc]) => {
const isDocker = svc.source === 'docker';
return `
<div class="card" style="margin:0" data-dash-svc="${id}">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}
<span data-dash-svc-status="${id}">${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}</span>
</div>
<div class="metrics">
<div class="metric"><div class="value">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : '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 class="metric"><div class="value" data-dash-svc-pid="${id}">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
<div class="metric"><div class="value" data-dash-svc-port="${id}">${svc.port}</div><div class="label">端口</div></div>
<div class="metric"><div class="value" data-dash-svc-uptime="${id}">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
<div class="btn-group" style="margin-top:10px" data-dash-svc-actions="${id}">
${isDocker
? '<span class="docker-hint">🐳 Docker 管理</span>'
: `${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')"></button>` : ''}
@@ -1578,6 +1603,36 @@ function renderDashboardSvcCards(svcs) {
</div>
</div>
`}).join('');
} else {
entries.forEach(function([id, svc]) {
var isDocker = svc.source === 'docker';
// 更新状态 badge
var statusEl = document.querySelector('[data-dash-svc-status="' + id + '"]');
if (statusEl) {
statusEl.innerHTML = isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : '<span class="badge ' + statusBadge(svc.status) + '">' + svc.status + '</span>';
}
// 更新 PID/容器名
var pidEl = document.querySelector('[data-dash-svc-pid="' + id + '"]');
if (pidEl) pidEl.textContent = isDocker ? (svc.containerName || '—') : (svc.pid || '—');
// 更新端口
var portEl = document.querySelector('[data-dash-svc-port="' + id + '"]');
if (portEl) portEl.textContent = svc.port;
// 更新运行时间
var uptimeEl = document.querySelector('[data-dash-svc-uptime="' + id + '"]');
if (uptimeEl) uptimeEl.textContent = formatUptime(svc.uptime);
// 更新按钮组
var actionsEl = document.querySelector('[data-dash-svc-actions="' + id + '"]');
if (actionsEl) {
actionsEl.innerHTML = isDocker
? '<span class="docker-hint">🐳 Docker 管理</span>'
: (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>' : '') +
(svc.healthUrl ? '<button class="btn btn-xs" onclick="checkHealth(\'' + id + '\')">❤️</button>' : '');
}
});
}
}
// ========== 记忆分类颜色映射 ==========
@@ -2112,6 +2167,11 @@ async function fetchActiveSessions() {
if (btn) btn.classList.remove('spinning');
// 数据未变则跳过 DOM 更新
var dataHash = simpleHash(JSON.stringify(data));
if (STATE.renderHashes['sessions'] === dataHash) return;
STATE.renderHashes['sessions'] = dataHash;
const users = data.users || {};
// 计算总会话数
@@ -2154,9 +2214,38 @@ async function fetchActiveSessions() {
if (Object.keys(users).length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
STATE.expandedSessions = [];
STATE.sessionsFirstRender = true;
return;
}
// 尝试增量更新: 如果 session_id 集合未变,仅更新状态和最近活动时间
if (!STATE.sessionsFirstRender && flatSessions.length > 0) {
var prevIds = (STATE.prevSessionIds || []).slice().sort().join(',');
var currIds = flatSessions.map(function(s) { return s.session_id; }).sort().join(',');
if (prevIds === currIds) {
// 仅更新状态 badge 和最近活动时间
flatSessions.forEach(function(s, i) {
var row = document.getElementById('session-row-' + i);
if (!row) return;
// 更新状态 badge
var badgeEl = row.querySelector('.badge');
if (badgeEl) {
badgeEl.className = 'badge ' + statusBadge(s.state || 'idle');
badgeEl.textContent = s.state || 'idle';
}
// 更新 last_activity
var lastActEl = row.querySelector('span:last-child');
if (lastActEl) {
lastActEl.textContent = '最近活动: ' + timeAgo(s.last_activity);
}
});
STATE.prevSessionIds = flatSessions.map(function(s) { return s.session_id; });
return;
}
}
STATE.prevSessionIds = flatSessions.map(function(s) { return s.session_id; });
STATE.sessionsFirstRender = false;
// Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复
const previouslyExpanded = [];
const existingDetails = container.querySelectorAll('[id^="session-detail-"]');
@@ -2384,25 +2473,27 @@ function renderServiceCards() {
const status = STATE.serviceStatus;
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' };
const isDocker = svc.source === 'docker';
const isRunning = svc.status === 'running';
const isStarting = svc.status === 'starting' || svc.status === 'building';
const isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
// 首次渲染使用 innerHTML 构建完整 DOM
if (!STATE.servicesCardsRendered) {
container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' };
const isDocker = svc.source === 'docker';
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">
return `
<div class="card" style="margin:0" data-svc="${id}">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-weight:600">${svc.name}</span>
${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}
<span style="font-weight:600" data-svc-name="${id}">${svc.name}</span>
<span class="svc-status-badge" data-svc-status="${id}">${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}</span>
</div>
<div class="metrics">
<div class="metric"><div class="value">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : '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 class="metric"><div class="value" data-svc-pid="${id}">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
<div class="metric"><div class="value" data-svc-port="${id}">${svc.port}</div><div class="label">端口</div></div>
<div class="metric"><div class="value" data-svc-uptime="${id}">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
</div>
<div class="btn-group" style="margin-top:10px">
<div class="btn-group" style="margin-top:10px" data-svc-actions="${id}">
${isDocker
? '<span class="docker-hint">🐳 Docker 管理 — 请使用 docker compose</span>'
: `${isStopped ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶ 启动</button>` : ''}
@@ -2412,7 +2503,52 @@ function renderServiceCards() {
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
</div>
</div>`;
}).join('');
}).join('');
STATE.servicesCardsRendered = true;
return;
}
// 后续调用: 增量更新每个卡片的属性值
ids.forEach(function(id) {
updateServiceCardInPlace(id, status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' });
});
}
function updateServiceCardInPlace(id, svc) {
var isDocker = svc.source === 'docker';
var isRunning = svc.status === 'running';
var isStarting = svc.status === 'starting' || svc.status === 'building';
var isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
// 更新状态 badge
var statusEl = document.querySelector('[data-svc-status="' + id + '"]');
if (statusEl) {
statusEl.innerHTML = isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : '<span class="badge ' + statusBadge(svc.status) + '">' + svc.status + '</span>';
}
// 更新 PID/容器名
var pidEl = document.querySelector('[data-svc-pid="' + id + '"]');
if (pidEl) pidEl.textContent = isDocker ? (svc.containerName || '—') : (svc.pid || '—');
// 更新端口
var portEl = document.querySelector('[data-svc-port="' + id + '"]');
if (portEl) portEl.textContent = svc.port;
// 更新运行时间
var uptimeEl = document.querySelector('[data-svc-uptime="' + id + '"]');
if (uptimeEl) uptimeEl.textContent = formatUptime(svc.uptime);
// 更新按钮组
var actionsEl = document.querySelector('[data-svc-actions="' + id + '"]');
if (actionsEl) {
actionsEl.innerHTML = isDocker
? '<span class="docker-hint">🐳 Docker 管理 — 请使用 docker compose</span>'
: (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>' : '');
}
}
// ---- 日志功能 ----
@@ -2618,6 +2754,12 @@ async function refreshPerf() {
function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels');
if (!container) return;
// 数据未变则跳过更新
var dataHash = simpleHash(JSON.stringify(snap));
if (STATE.renderHashes['perfPanels'] === dataHash) return;
STATE.renderHashes['perfPanels'] = dataHash;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ALL_SVC_IDS;
// Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值
@@ -2699,6 +2841,11 @@ async function renderDatabasePanel() {
return;
}
// 数据未变则跳过 DOM 更新
var dataHash = simpleHash(JSON.stringify(data));
if (STATE.renderHashes['database'] === dataHash && STATE.dbInitialized) return;
STATE.renderHashes['database'] = dataHash;
const ports = data.ports || [];
const allAlive = data.allAlive;
const aliveCount = data.aliveCount;
@@ -3007,6 +3154,11 @@ async function renderToolCallsPanel() {
return;
}
// 数据未变且页码/筛选未变则跳过
var toolCallsKey = STATE.toolCallsPage + '|' + STATE.toolCallsFilter + '|' + simpleHash(JSON.stringify(callsData));
if (STATE.renderHashes['toolCalls'] === toolCallsKey) return;
STATE.renderHashes['toolCalls'] = toolCallsKey;
var totalCalls = statsData ? statsData.total_calls : callsData.total;
var successRate = statsData ? (statsData.success_rate || 0).toFixed(1) : 0;
var avgDuration = statsData ? (statsData.avg_duration_ms || 0).toFixed(0) : 0;
@@ -3113,12 +3265,27 @@ async function renderToolCallsPanel() {
}
container.innerHTML = statsCardsHtml + filterHtml + distHtml + tableHtml + paginationHtml;
// 恢复展开的调用详情
Object.keys(STATE.expandedToolCalls).forEach(function(callId) {
if (STATE.expandedToolCalls[callId]) {
var expandRow = document.getElementById(callId + '-expand');
if (expandRow) expandRow.style.display = '';
}
});
}
function toggleToolCallExpand(callId) {
var expandRow = document.getElementById(callId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
var isExpanded = expandRow.style.display !== 'none';
if (isExpanded) {
expandRow.style.display = 'none';
STATE.expandedToolCalls[callId] = false;
} else {
expandRow.style.display = '';
STATE.expandedToolCalls[callId] = true;
}
}
}
@@ -3174,6 +3341,11 @@ async function renderSTTPanel() {
return;
}
// 数据未变则跳过
var sttHash = simpleHash(JSON.stringify(data));
if (STATE.renderHashes['stt'] === sttHash) return;
STATE.renderHashes['stt'] = sttHash;
STATE.sttLogs = data.logs || [];
updateSTTBadge();
@@ -3272,6 +3444,14 @@ async function renderSTTPanel() {
var recorderHtml = buildVoiceRecorderCard();
container.innerHTML = statsHtml + recorderHtml + tableHtml + voiceStatusHtml;
// 恢复展开的STT行
Object.keys(STATE.expandedSTTRows).forEach(function(rowId) {
if (STATE.expandedSTTRows[rowId]) {
var expandRow = document.getElementById(rowId + '-expand');
if (expandRow) expandRow.style.display = '';
}
});
}
// ---- 语音录制测试 ----
@@ -3391,7 +3571,14 @@ function prependSTTTableRow(entry) {
function toggleSTTExpand(rowId) {
var expandRow = document.getElementById(rowId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
var isExpanded = expandRow.style.display !== 'none';
if (isExpanded) {
expandRow.style.display = 'none';
STATE.expandedSTTRows[rowId] = false;
} else {
expandRow.style.display = '';
STATE.expandedSTTRows[rowId] = true;
}
}
}
@@ -3457,6 +3644,11 @@ async function renderThinkingPanel() {
return;
}
// 数据未变且页码/用户未变则跳过
var thinkingKey = STATE.thinkingPage + '|' + STATE.thinkingUserId + '|' + simpleHash(JSON.stringify(logsData));
if (STATE.renderHashes['thinking'] === thinkingKey) return;
STATE.renderHashes['thinking'] = thinkingKey;
var totalLogs = statsData ? statsData.total_logs : (logsData.total || 0);
var totalToolCalls = statsData ? statsData.total_tool_calls : 0;
var avgContentLen = statsData ? (statsData.avg_content_length || 0).toFixed(0) : 0;
@@ -3628,6 +3820,11 @@ async function renderTimelinePanel() {
return;
}
// 数据未变且筛选条件未变则跳过
var tlKey = userId + '|' + STATE.timelineFilterType + '|' + simpleHash(JSON.stringify(data));
if (STATE.renderHashes['timeline'] === tlKey) return;
STATE.renderHashes['timeline'] = tlKey;
var timeline = data.timeline || [];
var stats = data.stats || {};
STATE.timelineData = timeline;
@@ -3762,6 +3959,14 @@ async function renderTimelinePanel() {
: (timeline.length > 0 ? '<div style="text-align:center;padding:16px;color:var(--text3)">已加载全部条目</div>' : '');
container.innerHTML = statsCardsHtml + filterHtml + timelineHtml + loadMoreHtml;
// 恢复展开的时间线详情
Object.keys(STATE.expandedTimelineItems).forEach(function(itemId) {
if (STATE.expandedTimelineItems[itemId]) {
var detail = document.getElementById(itemId + '-detail');
if (detail) detail.classList.add('open');
}
});
}
async function loadMoreTimeline() {
@@ -3926,6 +4131,7 @@ function toggleTimelineDetail(itemId) {
var detail = document.getElementById(itemId + '-detail');
if (detail) {
detail.classList.toggle('open');
STATE.expandedTimelineItems[itemId] = detail.classList.contains('open');
}
}
@@ -5217,6 +5423,11 @@ async function renderLlmCallsPanel() {
var calls = resp.calls || [];
STATE.llmCallsData = calls;
// 数据未变则跳过
var llmHash = simpleHash(JSON.stringify(calls));
if (STATE.renderHashes['llmCalls'] === llmHash) return;
STATE.renderHashes['llmCalls'] = llmHash;
if (calls.length === 0) {
panel.innerHTML = '<div class="empty-state">暂无 LLM 调用记录<br><small>发送一条消息后刷新查看</small></div>';
return;
@@ -5333,6 +5544,11 @@ async function renderVMMonitorPanel() {
return;
}
// 数据未变则跳过
var vmHash = simpleHash(JSON.stringify(statusData));
if (STATE.renderHashes['vmMonitor'] === vmHash) return;
STATE.renderHashes['vmMonitor'] = vmHash;
if (statusData.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(statusData.error) + '</div>';
return;
+4
View File
@@ -53,7 +53,11 @@ async function renderIoTPanel() {
return;
}
// 数据未变则跳过 (设备列表不变)
var devices = result.devices;
var iotHash = simpleHash(JSON.stringify(devices));
if (STATE.renderHashes && STATE.renderHashes['iot'] === iotHash) return;
if (STATE.renderHashes) STATE.renderHashes['iot'] = iotHash;
// 固定设备排列顺序: 先按类型,同类型再按 device_id
var typeOrder = { 'sensor': 1, 'ac': 2, 'light': 3, 'curtain': 4, 'lock': 5, 'camera': 6, 'speaker': 7, 'thermostat': 8 };