refactor(debug): 重构调试日志环形缓冲区管理

- 将日志环形缓冲区从静态数组改为动态堆分配
- 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制
- 添加日志环满时优先保留错误和警告信息的清理策略
- 优化内存使用,在日志为空时避免不必要的内存分配
- 移除调试日志中的冗余字段显示

feat(api): 更新CAN接口为批量发送模式

- 将 `/can/send` 接口替换为 `/can/send_batch`,支持1到N帧的批量发送
- 更新API文档中的接口描述和使用示例
- 修改请求参数格式为 `{"ch":0,"frames":[{...}]}` 返回格式为 `{"status":"ok","results":[...]}`
- 单帧发送也需要通过send_batch接口实现

feat(monitor): 新增GD32 ADC调试反馈处理功能

- 添加gd32_adc_feedback组件用于解析0xC4类型的SPI反馈
- 实现ADC数据解析包括温度、电压等参数的计算和转换
- 在can_feedback_task中集成ADC反馈分发逻辑
- 添加线程安全的数据获取和年龄检查接口

refactor(config): 优化任务栈大小配置并调整OTA块大小

- 重新评估各任务栈使用情况,优化HTTP、WiFi、SPI等工作任务的栈大小
- 将HTTP服务器栈从8KB调整至7KB,WiFi OTA任务栈从4KB调整至3KB
- OTA块大小从2KB调整回4KB以提升传输吞吐性能
- 重新整理配置文件结构,移动WiFi模式配置位置
This commit is contained in:
小龙虾
2026-07-20 03:10:42 +08:00
parent b807645a20
commit 00b716c8db
66 changed files with 4803 additions and 1074 deletions
+616 -20
View File
@@ -1,7 +1,7 @@
/**
* connection.js - 连接管理 + 系统状态显示
*
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应
@@ -15,23 +15,21 @@
import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js';
import { addLog } from './log.js';
import { showToast } from './toast.js';
import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js';
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// header pill
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) {
pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label;
}
// connection card indicator
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (cst) cst.textContent = connected ? label : '未连接';
// primary badge
// 系统状态卡徽章
var bc = document.getElementById('bConn');
if (bc) {
bc.textContent = connected ? '在线' : '离线';
@@ -41,10 +39,7 @@ export function updateConnectionUi(connected, text) {
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
var connStatusTxt = document.getElementById('connStatusTxt');
if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) {
connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
}
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
@@ -252,22 +247,167 @@ export function updateHeapAndTaskStatusUi(data) {
}
}
updateCpuUsageUi(data.cpu);
/* 合并 memory:layout(?layout=1,缓存)+ 动态 heap_*(每次轮询) */
function mergeMemoryView(rawMem) {
rawMem = rawMem || {};
if (rawMem.layout && typeof rawMem.layout === 'object') {
window._memLayout = rawMem.layout;
}
var L = window._memLayout || {};
/* 兼容旧固件:静态字段仍在 memory 根上 */
function pick(key) {
if (rawMem[key] !== undefined) return rawMem[key];
if (L[key] !== undefined) return L[key];
return undefined;
}
return {
chip: pick('chip'),
sram_total: pick('sram_total'),
sram_soc: pick('sram_soc'),
static_reserved: pick('static_reserved'),
reserved_tail: pick('reserved_tail'),
iram: pick('iram'),
data: pick('data'),
bss: pick('bss'),
heap_total: pick('heap_total'),
dma_total: pick('dma_total'),
rtc_total: pick('rtc_total'),
task_stack_total: pick('task_stack_total'),
default_heap_total: pick('default_heap_total'),
heap_used: rawMem.heap_used,
heap_free: rawMem.heap_free,
heap_min_free: rawMem.heap_min_free,
heap_largest_free: rawMem.heap_largest_free,
alloc_blocks: rawMem.alloc_blocks,
free_blocks: rawMem.free_blocks,
dma_free: rawMem.dma_free,
rtc_free: rawMem.rtc_free,
default_heap_free: rawMem.default_heap_free
};
}
/* 芯片 SRAM 全景:IRAM + .data + .bss + 堆已用 + 堆空闲(链接符号拆分静态段) */
function updateSramOverview(mem, fallback) {
mem = mergeMemoryView(mem);
var sramTotal = Number(mem.sram_total);
var sramSoc = Number(mem.sram_soc);
var staticRes = Number(mem.static_reserved);
var iram = Number(mem.iram);
var dataSz = Number(mem.data);
var bss = Number(mem.bss);
var heapTotal = Number(mem.heap_total);
var heapUsed = Number(mem.heap_used);
var heapFree = Number(mem.heap_free);
var reservedTail = Number(mem.reserved_tail);
var hasSections = Number.isFinite(iram) || Number.isFinite(dataSz) || Number.isFinite(bss);
/* 旧固件无 memory 字段时,用 internal_* 兜底 */
if (!Number.isFinite(sramTotal) || sramTotal <= 0) {
heapFree = Number(fallback.internal_free_heap);
heapTotal = Number(fallback.internal_heap_total);
if (!Number.isFinite(heapTotal) || heapTotal <= 0) {
setText('memSramUsage', '--');
setText('memSramPct', '--');
setText('mIram', '--');
setText('mData', '--');
setText('mBss', '--');
setText('mHeapUsed', '--');
setText('mHeapFree', '--');
setText('mSramSoc', '--');
setText('mReservedTail', '--');
setText('mTaskStackTotal', '--');
setText('mDmaFree', '--');
setText('mRtcFree', '--');
setText('mAllocBlocks', '--');
['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.width = '0%';
});
return;
}
heapUsed = heapTotal - heapFree;
sramTotal = heapTotal;
staticRes = 0;
iram = dataSz = bss = 0;
hasSections = false;
}
if (!Number.isFinite(heapUsed) || heapUsed < 0) {
heapUsed = Math.max(0, (Number.isFinite(heapTotal) ? heapTotal : 0) - (Number.isFinite(heapFree) ? heapFree : 0));
}
if (!Number.isFinite(heapFree) || heapFree < 0) heapFree = 0;
if (!Number.isFinite(staticRes) || staticRes < 0) staticRes = 0;
if (!Number.isFinite(heapTotal) || heapTotal < 0) heapTotal = heapUsed + heapFree;
if (!Number.isFinite(iram) || iram < 0) iram = 0;
if (!Number.isFinite(dataSz) || dataSz < 0) dataSz = 0;
if (!Number.isFinite(bss) || bss < 0) bss = 0;
/* 无分段字段时,整块静态灰条仍可用 static_reserved */
if (!hasSections && staticRes > 0) {
bss = staticRes;
}
var usedAll = staticRes + heapUsed;
if (!Number.isFinite(staticRes) || staticRes <= 0) {
usedAll = iram + dataSz + bss + heapUsed;
staticRes = iram + dataSz + bss;
}
var pctAll = sramTotal > 0 ? (usedAll * 100 / sramTotal) : 0;
setText('memSramUsage', '已占 ' + formatBytes(usedAll) + ' / 应用 ' + formatBytes(sramTotal));
setText('memSramPct', pctAll.toFixed(1) + '%');
setText('mIram', formatBytes(iram));
setText('mData', formatBytes(dataSz));
setText('mBss', formatBytes(bss));
setText('mHeapUsed', formatBytes(heapUsed));
setText('mHeapFree', formatBytes(heapFree));
setText('mSramSoc', Number.isFinite(sramSoc) && sramSoc > 0 ? formatBytes(sramSoc) : '--');
setText('mReservedTail', Number.isFinite(reservedTail) ? formatBytes(reservedTail) : '--');
setText('mTaskStackTotal', Number.isFinite(Number(mem.task_stack_total))
? formatBytes(Number(mem.task_stack_total)) : '--');
function setSeg(id, bytes) {
var el = document.getElementById(id);
if (!el) return;
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
setSeg('memSegIram', iram);
setSeg('memSegData', dataSz);
setSeg('memSegBss', bss);
setSeg('memSegUsed', heapUsed);
setSeg('memSegFree', heapFree);
if (Number.isFinite(Number(mem.dma_free)) && Number.isFinite(Number(mem.dma_total))) {
setText('mDmaFree', formatBytes(Number(mem.dma_free)) + ' / ' + formatBytes(Number(mem.dma_total)));
} else {
setText('mDmaFree', '--');
}
if (Number.isFinite(Number(mem.rtc_free)) && Number.isFinite(Number(mem.rtc_total))) {
var rt = Number(mem.rtc_total);
setText('mRtcFree', rt > 0
? (formatBytes(Number(mem.rtc_free)) + ' / ' + formatBytes(rt))
: '未并入堆');
} else {
setText('mRtcFree', '--');
}
setText('mAllocBlocks', Number.isFinite(Number(mem.alloc_blocks)) ? String(Number(mem.alloc_blocks)) : '--');
}
updateSramOverview(data.memory, data);
setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--');
setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--');
setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--');
setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total);
/* 最低空闲再创新低 → 尽快把设备 1 槽快照拉进 IndexedDB(多条历史靠浏览器攒) */
onStatusHeapMinMaybeChanged(data);
setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--');
bindHeapLowTooltip();
setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : ''));
setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--');
if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code;
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
var storedText = '--';
if (ota.total_size || ota.stored_size) {
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
}
setText('sOtaStored', storedText);
setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList');
@@ -355,16 +495,28 @@ export async function diagnoseNetwork() {
return result.join(';');
}
/**
* /system/status URL:尚无缓存布局时带 ?layout=1(静态 IRAM/.data/.bss/堆容量只取一次)。
* 设备重启/换固件后清 window._memLayout 再拉。
*/
export function systemStatusUrl(forceLayout) {
var base = (window.getBaseUrl ? window.getBaseUrl() : '') + '/system/status';
if (forceLayout || !window._memLayout) {
return base + (base.indexOf('?') >= 0 ? '&' : '?') + 'layout=1';
}
return base;
}
/**
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
* 避免与递归调度的 pollStatus 互相干扰。
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
*/
async function fetchSystemStatus(timeoutMs) {
async function fetchSystemStatus(timeoutMs, forceLayout) {
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal });
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.status = resp.status;
@@ -407,7 +559,8 @@ export async function refreshSystemStatusNow(event) {
event.stopPropagation();
}
try {
var data = await fetchSystemStatus(3000);
/* 手动刷新强制带 layout,便于换固件后更新静态段 */
var data = await fetchSystemStatus(3000, true);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0;
@@ -422,3 +575,446 @@ export async function refreshSystemStatusNow(event) {
showToast('状态刷新失败', 'error');
}
}
// ============================================================
// 最低空闲 hover 弹窗:拉 /system/heap_low_log(设备 1 条)+ IndexedDB 历史
// ============================================================
// 行为:鼠标移到 #mInternalMinHeap 上时浮现 tooltip,离开时关闭。
// 首次进入懒加载一次接口,1s 内复用缓存防鼠标抖动反复请求。
// 弹窗固定在元素右下,越界时自动翻到左侧/上侧。
var heapLowLogCache = null; // null=未加载,[]/data=已加载
var heapLowLogLoading = false;
var heapLowLogLoadAt = 0; // 上次成功加载时间戳(ms)
var heapLowTooltipEl = null; // 当前显示中的 tooltip 元素
var HEAP_LOW_STATE_NAMES = ['IDLE','GD32_STORING','GD32_STORED','GD32_RELAYING','GD32_COMPLETE','ESP32_RECEIVING','ESP32_FINALIZING','ESP32_REBOOTING','ERROR'];
var HEAP_LOW_CHANNEL_NAMES = ['WIFI','BLE'];
function fmtUptime(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '--';
var s = Math.floor(ms / 1000);
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
if (h > 0) return h + 'h' + String(m).padStart(2,'0') + 'm' + String(sec).padStart(2,'0') + 's';
if (m > 0) return m + 'm' + String(sec).padStart(2,'0') + 's';
return sec + 's';
}
/** OTA 忙态:HTTP socket/堆紧张,勿后台拉 heap_low_log(与 status 抢缓冲/超时 abort)。 */
function isOtaBusyForHeapLowPoll() {
if (window.pollingPausedForUpload) return true;
var c = Number(window.lastSysStateCode);
/* STORING=1 RELAYING=3 RECEIVING=5 FINALIZING=6 REBOOTING=7 */
return c === 1 || c === 3 || c === 5 || c === 6 || c === 7;
}
function isFetchAbortError(e) {
if (!e) return false;
if (e.name === 'AbortError') return true;
var msg = String(e.message || e);
return /aborted|AbortError/i.test(msg);
}
/** 拉取 /system/heap_low_log。点击触发频率低,不缓存,保证每次看到最新数据
* (与每秒轮询的 #mInternalMinHeap 表格保持一致)。仅对"请求中"去重避免重复并发。
* 拉到后增量写入 IndexedDB 持久化,突破设备仅 1 条槽位的限制。 */
async function loadHeapLowLog(force) {
if (heapLowLogLoading) return heapLowLogCache;
/* 用户点开 tooltip 时 force=true 仍允许;后台/创新低轮询勿在 OTA 中抢 HTTP */
if (!force && isOtaBusyForHeapLowPoll()) {
return heapLowLogCache;
}
heapLowLogLoading = true;
try {
var ac = new AbortController();
var timer = setTimeout(function(){ ac.abort(); }, 3000);
var resp;
try {
resp = await fetch(window.getBaseUrl() + '/system/heap_low_log', { signal: ac.signal });
} finally { clearTimeout(timer); }
if (!resp.ok) throw new Error('HTTP ' + resp.status);
heapLowLogCache = await resp.json();
heapLowLogLoadAt = Date.now();
// 增量持久化:把设备当前记录与 IndexedDB 已有的去重后写入。
// 失败/不可用静默降级(appendHeapLowRecords 内部已 catch)。
if (heapLowLogCache && Array.isArray(heapLowLogCache.records) && heapLowLogCache.records.length) {
appendHeapLowRecords(heapLowLogCache.records).then(function (n) {
if (n > 0) {
addLog('I', '内存', '堆低水位历史已存 ' + n + ' 条新记录');
}
});
}
} catch (e) {
/* 超时 abort / 导航取消:OTA 或 socket 紧时常见,不刷 WARN */
if (!isFetchAbortError(e)) {
addLog('W', '内存', '拉取 /system/heap_low_log 失败: ' + (e && e.message ? e.message : e));
}
// 失败时不清缓存,避免下次再次进入就重打接口
} finally {
heapLowLogLoading = false;
}
return heapLowLogCache;
}
function buildHeapLowRowHtml(r, idx, segTag) {
var ts = fmtUptime(r.ts_ms);
var state = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state);
var conn = '';
if (r.ble_connected) conn += 'BLE ';
if (r.wifi_connected) conn += 'WiFi';
if (!conn) conn = '--';
var otaProgress = '--';
if (r.ota_total_size > 0) {
otaProgress = formatBytes(r.ota_stored_size) + '/' + formatBytes(r.ota_total_size);
}
var curTask = escapeHtml(r.current_task || 'health_mon');
// 排除法诊断:OTA 资源优先;否则按 hook 最后一击类别(勿把「连接=WiFi」当触发任务)
var diag = '--';
var diagCls = '';
var suspects = [];
if (Number(r.ble_data_queue_depth) > 0) suspects.push('BLE队列:' + r.ble_data_queue_depth);
if (Number(r.ble_reorder_cached) > 0) suspects.push('reorder:' + r.ble_reorder_cached);
if (Number(r.spi_pending_jobs) > 0) suspects.push('SPI队列:' + r.spi_pending_jobs);
var hookCls = (r.hook_task || '').toString();
var hookSz = Number(r.hook_alloc_size) || 0;
if (suspects.length) {
diag = escapeHtml(suspects.join(' '));
diagCls = 'has-suspect';
} else if (Number(r.hook_fired) === 1 && (hookCls === 'net?' || (hookSz >= 48 && hookSz < 2048))) {
// 最后一击是小/中块:HTTP 回包 / UDP 日志 send 前 lwip 会再 alloc pbuf,常被误当成 WiFi 主因
diag = '最后一击≈网络发送';
diagCls = 'idle-suspect';
} else if (Number(r.hook_fired) === 1 && (hookCls === 'bulk' || hookSz >= 2048)) {
diag = '最后一击≈大块alloc';
diagCls = 'has-suspect';
} else {
var stateName = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state);
if (stateName === 'IDLE' || stateName === 'GD32_COMPLETE' || stateName === 'GD32_STORED' || stateName === 'ERROR') {
diag = '空闲期(主因未定位)';
diagCls = 'idle-suspect';
} else {
diag = '非数据路径';
diagCls = 'idle-suspect';
}
}
// 任务栈子表(默认隐藏,点击主行展开)
var stacks = Array.isArray(r.task_stacks) ? r.task_stacks : [];
var stackRows = stacks.map(function(s) {
var nm = escapeHtml(s.name || '?');
var rem = Number.isFinite(s.remaining) ? formatBytes(s.remaining) : '--';
var cls = (Number(s.remaining) < 1024) ? 'crit' :
(Number(s.remaining) < 2048) ? 'warn' : '';
return '<tr class="heap-low-sub-row ' + cls + '"><td colspan="8" class="heap-low-sub-cell">' +
nm + '<span class="heap-low-stack-rem">' + rem + '</span></td></tr>';
}).join('');
var stackSummary = stacks.length
? (stacks[0].name + ' ' + formatBytes(stacks[0].remaining) + ' <span class="heap-low-expand-hint">▾' + stacks.length + '</span>')
: '--';
// ★ 行 id 用 segTag 前缀避免设备当前段与历史段 idx 冲突
var rowId = segTag + '_' + idx;
return '<tr class="heap-low-main-row" data-idx="' + rowId + '" data-seg="' + segTag + '">' +
'<td>' + ts + '</td>' +
'<td>' + formatBytes(r.min_free_heap) + '</td>' +
'<td>' + formatBytes(r.free_heap) + '</td>' +
'<td>' + formatBytes(r.largest_free_block) + '</td>' +
'<td>' + state + '</td>' +
'<td>' + conn + '</td>' +
'<td>' + otaProgress + '</td>' +
'<td>' + formatBytes(r.default_free_heap) + '</td>' +
'<td class="' + diagCls + '">' + diag + '</td>' +
'<td>' + stackSummary + '</td>' +
'</tr>' +
'<tr class="heap-low-stack-detail" data-idx="' + rowId + '" hidden><td colspan="10">' +
'<div class="heap-low-detail-meta">' +
'<span>采样任务: <b>' + curTask + '</b> <span class="heap-low-caller-hint">(巡检发现者,不是 alloc 触发者)</span></span>' +
'<span>内部堆分配数: <b>' + (Number.isFinite(r.alloc_count) ? r.alloc_count : '--') + '</b></span>' +
'<span>IP事件计数: <b>' + (Number.isFinite(r.ip_event_count) ? r.ip_event_count : '--') + '</b></span>' +
(r.saved_at_ms ? '<span>保存于: <b>' + formatTime(new Date(r.saved_at_ms)) + '</b></span>' : '') +
'</div>' +
'<div class="heap-low-hook-meta">' +
'<div class="heap-low-hook-title">heap hook:破新低时的「最后一击」alloc' +
(Number(r.hook_fired) === 1
? ''
: ' <span class="heap-low-caller-hint">(hook 未命中)</span>') +
'</div>' +
'<span>分配类别: <b>' + escapeHtml(r.hook_task || '--') + '</b> <span class="heap-low-caller-hint">net?=小中块/疑网络 · bulk=大块 · tiny=更小</span></span>' +
'<span>本次alloc: <b>' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '</b></span>' +
'<span>alloc后est空闲: <b>' + (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--') + '</b></span>' +
'<span>调用者地址: <b class="heap-low-caller">0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '</b>' +
' <span class="heap-low-caller-hint">(addr2line)</span></span>' +
'</div>' +
'<table class="heap-low-stack-table"><thead><tr><th>任务</th><th>剩余栈</th></tr></thead>' +
'<tbody>' + (stackRows || '<tr><td colspan="2">无</td></tr>') + '</tbody>' +
'</table>' +
'</td></tr>';
}
function buildHeapLowSectionHtml(records, segTag, title, totalCount) {
if (!records || !records.length) return '';
var rows = records.map(function (r, i) { return buildHeapLowRowHtml(r, i, segTag); }).join('');
return '<div class="heap-low-section heap-low-section-' + segTag + '">' +
'<div class="heap-low-section-title">' + title +
' <span class="heap-low-sub">' + records.length + ' 条' +
(totalCount !== undefined && totalCount > records.length ? ' / 共 ' + totalCount : '') +
'</span>' +
'</div>' +
'<table class="heap-low-table">' +
'<thead><tr>' +
'<th>启动后</th><th>最低空闲</th><th>当时空闲</th><th>最大块</th>' +
'<th>OTA状态</th><th>连接</th><th>OTA进度</th><th>默认堆</th>' +
'<th>诊断</th><th>最危险栈 ▾</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
}
function buildHeapLowTooltipHtml(data, history) {
var live = (data && Array.isArray(data.records)) ? data.records : [];
var hist = Array.isArray(history) ? history : [];
var bodyHtml;
if (!live.length && !hist.length) {
bodyHtml = '<div class="heap-low-empty">尚无"最低空闲创新低"记录</div>';
} else {
// 设备当前段:live 已是最新在前
var liveHtml = buildHeapLowSectionHtml(live, 'live', '设备当前(仅最新 1 条)', hist.length + live.length);
// 历史段:hist 升序(旧→新),显示时反转成新的在前
var histDesc = hist.slice().reverse();
var liveKeys = {};
live.forEach(function (r) {
liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)] = true;
});
var histOnly = histDesc.filter(function (r) {
return !liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)];
});
var histHtml = histOnly.length
? buildHeapLowSectionHtml(histOnly, 'hist', '前端累积历史', hist.length)
: '';
bodyHtml = liveHtml + histHtml;
}
return '' +
'<div class="heap-low-title">堆"最低空闲"创新低记录' +
'<span class="heap-low-sub">设备 ' + live.length + ' 条 + 历史 ' + hist.length + ' 条</span>' +
'<button class="heap-low-clear" type="button" title="清空浏览器历史(不访问设备)">清空</button>' +
'<button class="heap-low-close" type="button" aria-label="关闭" title="关闭">&times;</button>' +
'</div>' +
'<div class="heap-low-table-wrap">' + bodyHtml + '</div>' +
'<div class="heap-low-tip">设备只保留最新 1 条;多条历史在浏览器 IndexedDB(最多 '
+ HEAP_HISTORY_MAX
+ ' 条,约 5s 拉取 + status 新低时立即拉取)。「清空」只清本地历史,不访问设备。状态栏「最低空闲」需重启才清。</div>';
}
function positionHeapLowTooltip(anchorEl, tipEl) {
// 默认放右下;越界时翻左侧/上侧。tip 先 measure 再定位。
tipEl.style.visibility = 'hidden';
tipEl.style.left = '0px';
tipEl.style.top = '0px';
document.body.appendChild(tipEl);
var r = anchorEl.getBoundingClientRect();
var tw = tipEl.offsetWidth;
var th = tipEl.offsetHeight;
var pad = 8;
var left = r.right + pad;
if (left + tw > window.innerWidth - 8) left = r.left - tw - pad;
if (left < 8) left = Math.max(8, r.right - tw);
var top = r.bottom + pad;
if (top + th > window.innerHeight - 8) top = r.top - th - pad;
if (top < 8) top = 8;
tipEl.style.left = left + 'px';
tipEl.style.top = top + 'px';
tipEl.style.visibility = 'visible';
}
function showHeapLowTooltip(anchorEl) {
// 并行加载:设备当前 1 条 + 浏览器 IndexedDB 累积历史
// force=true:用户主动查看,OTA 期也允许试一次(失败静默)
Promise.all([
loadHeapLowLog(true).catch(function () { return { records: [] }; }),
loadHeapLowHistory().catch(function () { return []; })
]).then(function (results) {
var data = results[0] || { records: [] };
var history = results[1] || [];
if (heapLowTooltipEl) heapLowTooltipEl.remove();
var el = document.createElement('div');
el.className = 'heap-low-tooltip';
el.innerHTML = buildHeapLowTooltipHtml(data, history);
heapLowTooltipEl = el;
positionHeapLowTooltip(anchorEl, el);
// 关闭按钮:显式关闭并阻止 click 冒泡触发"点击外部关闭"
var closeBtn = el.querySelector('.heap-low-close');
if (closeBtn) {
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
hideHeapLowTooltip();
});
}
var clearBtn = el.querySelector('.heap-low-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
clearHeapLowRecordsUi(clearBtn, anchorEl);
});
}
// 点击主行展开/收起对应任务栈详情子表
var mainRows = el.querySelectorAll('.heap-low-main-row');
mainRows.forEach(function(row) {
row.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var idx = row.getAttribute('data-idx');
if (idx === null) return;
var detail = el.querySelector('.heap-low-stack-detail[data-idx="' + idx + '"]');
if (detail) detail.hidden = !detail.hidden;
});
});
});
}
/**
* 清空浏览器侧堆低水位历史(IndexedDB)。
* 不再 POST 设备:设备仅 1 槽,多条历史全在本地;状态栏 min_free 仍要重启才清。
*/
async function clearHeapLowRecordsUi(btn, anchorEl) {
if (btn && btn.disabled) return;
if (!window.confirm(
'清空浏览器中的堆低水位历史(IndexedDB,最多 '
+ HEAP_HISTORY_MAX
+ ' 条)?\n\n不会访问设备。状态栏「最低空闲」芯片累计需重启才清零。'
)) {
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = '清空中…';
}
var localOk = false;
try {
localOk = !!(await clearHeapLowHistory());
} catch (e2) {
localOk = false;
}
heapLowLogCache = null;
heapLowLogLoadAt = 0;
lastSeenInternalMinHeap = null;
if (localOk) {
addLog('I', '内存', '已清空堆低水位本地历史');
showToast('已清空本地历史', 'success');
} else {
addLog('E', '内存', '清空本地历史失败');
showToast('清空失败', 'error');
if (btn) {
btn.disabled = false;
btn.textContent = '清空';
}
return;
}
if (anchorEl) {
showHeapLowTooltip(anchorEl);
} else {
hideHeapLowTooltip();
}
}
function hideHeapLowTooltip() {
if (heapLowTooltipEl) { heapLowTooltipEl.remove(); heapLowTooltipEl = null; }
}
/** 绑定到 #mInternalMinHeap 元素(仅绑一次)。click 切换显示/隐藏。 */
export function bindHeapLowTooltip() {
var el = document.getElementById('mInternalMinHeap');
if (!el || el.dataset.heapLowBound === '1') return;
el.dataset.heapLowBound = '1';
el.style.cursor = 'pointer';
el.title = '点击查看堆低水位创新低记录';
// 点击触发:已显示则关闭,未显示则拉接口显示
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (heapLowTooltipEl) {
hideHeapLowTooltip();
} else {
showHeapLowTooltip(el);
}
});
// 点击 tooltip 外部关闭(点击 tooltip 自身或其子元素不关闭)
document.addEventListener('click', function(e) {
if (!heapLowTooltipEl) return;
if (e.target === el) return; // 触发元素自身已单独处理
var node = e.target;
while (node && node !== document) {
if (node === heapLowTooltipEl) return;
node = node.parentNode;
}
hideHeapLowTooltip();
}, true);
// 页面整体滚动/resize 时关闭(tooltip 内部滚动不触发,由 e.target 父链判断)
window.addEventListener('scroll', function(e) {
if (!heapLowTooltipEl) return;
var node = e.target;
while (node && node !== document) {
if (node === heapLowTooltipEl) return;
node = node.parentNode;
}
hideHeapLowTooltip();
}, true);
window.addEventListener('resize', function() {
if (heapLowTooltipEl) hideHeapLowTooltip();
});
// 后台拉取 heap_low_log → IndexedDB 多条历史(设备仅 1 槽)
startHeapLowBackgroundPoll();
}
// ==================== 堆低水位历史:后台拉取 + 弹窗合并显示 ====================
var heapLowBgTimerId = null;
/** 与 health_mon 5s 巡检对齐,尽量在下一次创新低覆盖前把当前槽拉进 IDB */
var HEAP_LOW_BG_INTERVAL_MS = 5000;
/** 上次从 /system/status 见到的 min_free,用于检测新低并立刻拉 log */
var lastSeenInternalMinHeap = null;
/**
* status 轮询里调用:若 internal_min_free_heap 再创新低,立刻拉设备槽写入 IDB。
* 这样不必等 5s 后台定时,连续创新低时也能多攒几条(每条仍须设备侧先记一次)。
*/
export function onStatusHeapMinMaybeChanged(data) {
if (!data) return;
var minH = data.internal_min_free_heap;
if (minH === undefined) minH = data.min_free_heap;
minH = Number(minH);
if (!Number.isFinite(minH)) return;
if (lastSeenInternalMinHeap === null) {
lastSeenInternalMinHeap = minH;
return;
}
if (minH < lastSeenInternalMinHeap) {
lastSeenInternalMinHeap = minH;
/* force=false:OTA 忙态跳过,避免与上传/status 抢 socket 后 abort 刷屏 */
if (!document.hidden && !isOtaBusyForHeapLowPoll()) {
loadHeapLowLog(false).catch(function () {});
}
} else if (minH > lastSeenInternalMinHeap) {
/* 设备重启后 min 回升:同步基线,不强制拉 */
lastSeenInternalMinHeap = minH;
}
}
/** 启动后台拉取。多次调用幂等。 */
function startHeapLowBackgroundPoll() {
if (heapLowBgTimerId) return;
function tick() {
heapLowBgTimerId = null;
if (!document.hidden && !isOtaBusyForHeapLowPoll()) {
loadHeapLowLog(false).catch(function () {});
}
heapLowBgTimerId = setTimeout(tick, HEAP_LOW_BG_INTERVAL_MS);
}
heapLowBgTimerId = setTimeout(tick, 3000);
}
+196
View File
@@ -0,0 +1,196 @@
/**
* heap-low-history.js - 堆"最低空闲创新低"记录的浏览器端持久化
*
* 目的:设备 RAM 只保留最新 1 条创新低;长时间挂机早期记录会被覆盖。
* 前端定时拉 /system/heap_low_log,发现新记录就追加到 IndexedDB,
* 弹窗里合并显示「设备当前 1 条 + 浏览器累积历史」,掉电/重启不丢本地史。
*
* 存储设计:
* - 单 objectStore 'heap_low_records',keyPath 'id'(自增)
* - 每条记录附前端写入时的时间戳 saved_at_ms(wall clock)
* - 设备重启会重置 ts_ms(启动后毫秒)和 min_free_heap(水位清零),
* 所以去重 key 用 (ts_ms, min_free_heap, free_heap, alloc_count) 组合,
* 重启后第一条 min_free_heap 必然高于上一次会话最低值 → 自然不会误并入。
* - 上限 HEAP_HISTORY_MAX 条,超出按 saved_at_ms 升序删最早的。
*/
var IDB_NAME = 'esp_dcu_heap_low';
var IDB_STORE = 'heap_low_records';
var IDB_VERSION = 1; // 独立库,与 file-handle.js 的 dcu_ota_fw_v1 不冲突
/** 浏览器可保存的创新低条数上限(设备仅 1 槽;多条全靠这里)。 */
export var HEAP_HISTORY_MAX = 500;
var _dbPromise = null;
function openDb() {
if (!_dbPromise) {
_dbPromise = new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
var req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
var store = db.createObjectStore(IDB_STORE, { keyPath: 'id', autoIncrement: true });
// 去重查询索引:同一会话内 (ts_ms,min_free_heap,free_heap,alloc_count) 唯一
store.createIndex('dedup', ['ts_ms', 'min_free_heap', 'free_heap', 'alloc_count'], { unique: false });
store.createIndex('saved_at', 'saved_at_ms', { unique: false });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
_dbPromise.catch(function () { _dbPromise = null; });
}
return _dbPromise;
}
/** 计算单条记录的去重 key 字符串(用于 in-memory 比对,避免每次都走 IDB 索引) */
function dedupKey(r) {
return r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0);
}
/** 把设备拉到的一批 records(最新在前)增量写入 IndexedDB,去重。
* 返回 Promise<number>:本次实际新增的条数。 */
export function appendHeapLowRecords(records) {
if (!Array.isArray(records) || !records.length) return Promise.resolve(0);
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var store = tx.objectStore(IDB_STORE);
var idx = store.index('dedup');
var newCount = 0;
var savedAt = Date.now();
// 顺序处理:每条先查 dedup 索引,不存在则 put
var i = 0;
function next() {
if (i >= records.length) {
// 全部处理完后再裁剪到上限
pruneToMax(store, function () {
tx.oncomplete ? null : null;
});
return;
}
var r = records[i++];
var key = [r.ts_ms, r.min_free_heap, r.free_heap, r.alloc_count || 0];
var getReq = idx.get(key);
getReq.onsuccess = function () {
if (!getReq.result) {
// 新记录:写入
var rec = Object.assign({}, r, { saved_at_ms: savedAt });
var putReq = store.put(rec);
putReq.onsuccess = function () { newCount++; next(); };
putReq.onerror = function () { next(); }; // 单条失败不阻断
} else {
next(); // 已存在跳过
}
};
getReq.onerror = function () { next(); };
}
next();
tx.oncomplete = function () { resolve(newCount); };
tx.onerror = function () { reject(tx.error); };
tx.onabort = function () { reject(tx.error || new Error('tx aborted')); };
});
}).catch(function (e) {
// IndexedDB 不可用(无痕模式等)静默降级
return 0;
});
}
/** 删最早的记录直到条数 <= HEAP_HISTORY_MAX。需在 readwrite tx 内调用。 */
function pruneToMax(store, done) {
var countReq = store.count();
countReq.onsuccess = function () {
var total = countReq.result;
if (total <= HEAP_HISTORY_MAX) { done(); return; }
var toRemove = total - HEAP_HISTORY_MAX;
// 按 saved_at 索引升序游标删前 toRemove 条
var idx = store.index('saved_at');
var curReq = idx.openCursor();
var removed = 0;
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor || removed >= toRemove) { done(); return; }
var delReq = store.delete(cursor.primaryKey);
delReq.onsuccess = function () { removed++; cursor.continue(); };
delReq.onerror = function () { cursor.continue(); };
};
curReq.onerror = function () { done(); };
};
countReq.onerror = function () { done(); };
}
/** 读全部累积历史,按设备时间 ts_ms 升序(旧的在前),便于弹窗"历史段"显示。
* 返回 Promise<Array>。IndexedDB 不可用时返回 []。 */
export function loadHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readonly');
var store = tx.objectStore(IDB_STORE);
var all = [];
var curReq = store.openCursor();
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor) return;
all.push(cursor.value);
cursor.continue();
};
curReq.onerror = function () { reject(curReq.error); };
tx.oncomplete = function () {
// 按 ts_ms 升序;同 ts_ms 时按 saved_at 升序(保证稳定)
all.sort(function (a, b) {
if (a.ts_ms !== b.ts_ms) return a.ts_ms - b.ts_ms;
return (a.saved_at_ms || 0) - (b.saved_at_ms || 0);
});
resolve(all);
};
tx.onerror = function () { reject(tx.error); };
});
}).catch(function () { return []; });
}
/** 清空全部历史(用于"清空"按钮,可选实现)。返回 Promise<boolean>。 */
export function clearHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var req = tx.objectStore(IDB_STORE).clear();
req.onsuccess = function () { resolve(true); };
req.onerror = function () { resolve(false); };
});
}).catch(function () { return false; });
}
/** 返回当前累积条数(仅用于 UI 提示,不需要数据)。返回 Promise<number>。 */
export function countHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readonly');
var req = tx.objectStore(IDB_STORE).count();
req.onsuccess = function () { resolve(req.result || 0); };
req.onerror = function () { resolve(0); };
});
}).catch(function () { return 0; });
}
/** 计算给定 records 数组(设备拉到的最新在前)与参考集合(已存历史)相比的新条数。
* 纯内存比对,不写库,用于先判断是否需要写。 */
export function diffNewCount(records, existingHistory) {
if (!Array.isArray(records) || !records.length) return 0;
var seen = {};
if (Array.isArray(existingHistory)) {
for (var i = 0; i < existingHistory.length; ++i) {
seen[dedupKey(existingHistory[i])] = true;
}
}
var n = 0;
for (var j = 0; j < records.length; ++j) {
if (!seen[dedupKey(records[j])]) ++n;
}
return n;
}
+32 -9
View File
@@ -156,6 +156,8 @@ function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
/* 重启后重新拉 memory.layout(换固件后静态段可能变化) */
window._memLayout = null;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
@@ -187,7 +189,11 @@ export async function pollStatus() {
statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
/* 首次/无缓存:?layout=1 取静态内存布局;之后只轮询动态字段 */
var statusUrl = (window.systemStatusUrl
? window.systemStatusUrl(false)
: (window.getBaseUrl() + '/system/status' + (window._memLayout ? '' : '?layout=1')));
var resp = await fetch(statusUrl, {
signal: statusAbortCtrl.signal
});
if (resp.ok) {
@@ -229,24 +235,41 @@ export async function pollStatus() {
clearTimeout(pollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return;
// 先判定 OTA 是否进行中:OTA 期间所有失败都不计入 consecutivePollFailures,
// 也不触发诊断。避免"上传完成→finalize→重启"窗口期失败计数累积,
// 切回 IDLE 瞬间立即触发 5-fail 诊断,干扰进度显示。
var lastDeviceBusy0 = isLastDeviceBusy();
var sessionActive0 = window.otaSessionActive();
var sessionFailed0 = window.otaSessionState === window.OtaSessionState.FAILED;
var otaOngoing0 = window.pollingPausedForUpload ||
sessionActive0 || sessionFailed0 ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy0;
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++;
var lastDeviceBusy = isLastDeviceBusy();
// OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。
if (pageVisible && !otaOngoing0) window.consecutivePollFailures++;
var lastDeviceBusy = lastDeviceBusy0;
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
var sessionActive = window.otaSessionActive();
var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED;
if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) {
var sessionActive = sessionActive0;
var sessionFailed = sessionFailed0;
// ★ OTA 期间(含暂停轮询、会话活跃、设备忙、正在重启、FAILED、当前 OTA 目标存在)
// 一律不触发诊断日志/UI 覆盖。这些 W 级日志会刷在日志区,与"上传 xx/xx" "重启中 Xs"
// "中继 N/M" 等进度行混在一起,用户反馈干扰 OTA 进度显示。
// 之前条件漏了 pollingPausedForUpload,且 sessionActive 可能在 finalize/reboot 短窗口
// 翻 false,导致 5 次失败后误触发诊断。这里把所有 OTA 相关态统一兜底。
var otaOngoing = window.pollingPausedForUpload ||
sessionActive || sessionFailed ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
updateConnectionUi(false, '未连接');
updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState();
var cst = document.getElementById('connStatusTxt');
if (cst) cst.textContent = '诊断中: ' + diag;
addLog('W', '诊断', '诊断结果: ' + diag);
}
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) {