Files
ESP32_COMM/tools/js/common/connection.js
T
小龙虾 ef6d3a4be3 feat(can): 添加跨通道CAN突发发送功能支持多电机同步驱动
- 新增 /can/send_burst HTTP接口,支持一次SPI事务批量发送多帧CAN消息
- 实现canFdSendBurst函数,通过0xC7 SPI类型实现背靠背装载各通道邮箱
- 添加canBurstCapacity函数计算单次突发最大帧数限制
- 突发发送期间暂停can_feedback_task避免SPI抢占冲突

refactor(can_feedback_task): 改造为周期查询+事件消费模式

- 0xC4/0xC5从GD32主动推送改为ESP32周期查询(250ms/1s)
- 0xC6/0xD2仍保留事件驱动推送模式,由任务消费处理
- 消除OTA期间GD32悬空按铃导致的超时刷屏问题
- 电机OTA期间也暂停任务避免与0xD1抢夺SPI资源

perf(ota_relay): 增加重发次数上限提升传输可靠性

- 将kMaxDataSends从3次增加到5次,提高数据包传输成功率

chore(version): 更新固件版本号至0.3.78

- 版本查询checkVersionWait移到tickInFlight释放后异步触发
- 避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
2026-08-10 00:54:12 +08:00

1168 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* connection.js - 连接管理 + 系统状态显示
*
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应
* window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取)
* window.lastRelayState — 上次成功的 relay_state(polling.js 读取)
* window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取)
*
* 复用的函数(通过 window.* 引用):
* window.getBaseUrl / window.updateSystemStatusUi / window.formatPercent
*/
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';
// ==================== 渲染缓存(性能) ====================
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
// CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。
// 这里统一三条策略:静态元素只查一次、文本/宽度/类名写前比较、列表增量更新。
var elCache = {}; // id -> element(页面静态节点,仅首次 getElementById)
function getEl(id) {
var el = elCache[id];
if (el === undefined || el === null) {
el = document.getElementById(id);
if (el) elCache[id] = el; // 未找到不缓存,等节点出现后再查
}
return el || null;
}
/** 写前比较的 textContent 更新:值未变则不触碰 DOM,避免无谓样式失效。 */
function setTextById(id, value) {
var el = getEl(id);
if (!el) return;
var v = String(value);
if (el.textContent !== v) el.textContent = v;
}
function setClassIfChanged(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setTextIfChanged(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
/** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */
function setWidthIfChanged(el, widthStr) {
if (el && el._lastW !== widthStr) {
el._lastW = widthStr;
el.style.width = widthStr;
}
}
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = getEl('pillConn');
var pct = getEl('pillConnTxt');
if (pc) {
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
if (pct) setTextIfChanged(pct, label);
}
// 系统状态卡徽章
var bc = getEl('bConn');
if (bc) {
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
}
}
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (freeHeap !== undefined) {
setTextById('mHeap', formatBytes(freeHeap));
}
var mh = getEl('mHealth');
if (mh) {
setTextIfChanged(mh, health);
setClassIfChanged(mh, 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'));
}
ensureHealthBootToggleBound();
}
/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */
function ensureHealthBootToggleBound() {
var badge = document.getElementById('healthBadge');
var panel = document.getElementById('healthBootPanel');
if (!badge || !panel || badge.dataset.bootToggleBound === '1') return;
badge.dataset.bootToggleBound = '1';
function toggle() {
var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden');
if (open) {
panel.hidden = false;
panel.classList.remove('collapsed');
badge.classList.add('is-open');
badge.setAttribute('aria-expanded', 'true');
} else {
panel.hidden = true;
panel.classList.add('collapsed');
badge.classList.remove('is-open');
badge.setAttribute('aria-expanded', 'false');
}
}
badge.addEventListener('click', function (e) {
e.preventDefault();
toggle();
});
badge.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
/** 启动自检渲染签名:boot 数据启动后不变,签名相同就跳过整段 DOM 重建。 */
var bootUiSig = null;
function updateBootInitUi(data) {
var boot = data && data.boot ? data.boot : null;
/* 签名 = 影响渲染的全部字段;每秒轮询里 boot 几乎永远不变,直接短路 */
var sig = boot
? [boot.mask, boot.critical_ok, boot.all_ok, boot.summary, boot.failed,
Array.isArray(boot.modules) ? boot.modules.length : 0].join('|')
: 'none';
if (sig === bootUiSig) return;
bootUiSig = sig;
var resultEl = getEl('mBootResult');
var failedEl = getEl('mBootFailed');
var listEl = getEl('bootModuleList');
if (!boot) {
if (resultEl) {
resultEl.textContent = '--';
resultEl.className = 'health-boot-summary';
}
if (failedEl) {
failedEl.textContent = '--';
failedEl.className = '';
}
if (listEl) listEl.innerHTML = '';
return;
}
var criticalOk = !!boot.critical_ok;
var allOk = !!boot.all_ok;
var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL'));
var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : '';
var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err');
if (resultEl) {
resultEl.textContent = summary;
resultEl.className = 'health-boot-summary ' + tone;
resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16);
}
if (failedEl) {
failedEl.textContent = failed || (allOk ? '无' : '--');
failedEl.className = failed ? tone : 'ok';
}
if (listEl) {
var modules = Array.isArray(boot.modules) ? boot.modules : [];
if (!modules.length) {
listEl.innerHTML = '';
} else {
listEl.innerHTML = modules.map(function (m) {
var ok = !!m.ok;
var critical = !!m.critical;
var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional'));
var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键');
return '<span class="' + cls + '" title="' + escapeHtml(tip) + '">' +
'<span class="boot-mod-dot"></span>' +
escapeHtml(m.name || '?') +
'</span>';
}).join('');
}
}
if (!window._bootInitLogged) {
window._bootInitLogged = true;
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
}
ensureHealthBootToggleBound();
}
/* ==================== 状态任务列表:增量渲染 ====================
* innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光
* 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。
* 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅更新 CSS order。
* 行结构统一:head(名称/占比) + bar + 3 项 meta。
* items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }]
*/
function buildStatusTaskRowHtml(it) {
return '' +
'<div class="' + it.rowCls + '">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(it.key) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(it.pctText) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="' + it.fillCls + '" style="width:' + it.width + '"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>' + it.metaLabels[0] + '</span><b>' + escapeHtml(it.metas[0]) + '</b></div>' +
'<div><span>' + it.metaLabels[1] + '</span><b>' + escapeHtml(it.metas[1]) + '</b></div>' +
'<div><span>' + it.metaLabels[2] + '</span><b>' + escapeHtml(it.metas[2]) + '</b></div>' +
'</div>' +
'</div>';
}
function updateStatusTaskRow(row, it) {
var refs = row._refs;
if (!refs) {
refs = row._refs = {
ratio: row.querySelector('.status-task-ratio'),
fill: row.querySelector('.status-task-bar > div'),
metas: row.querySelectorAll('.status-task-meta b')
};
}
setTextIfChanged(refs.ratio, it.pctText);
setClassIfChanged(refs.fill, it.fillCls);
setWidthIfChanged(refs.fill, it.width);
setTextIfChanged(refs.metas[0], it.metas[0]);
setTextIfChanged(refs.metas[1], it.metas[1]);
setTextIfChanged(refs.metas[2], it.metas[2]);
}
/** 空态提示:内容未变不重写,并清掉行缓存让下次有数据时重建。 */
function setListEmpty(listEl, html) {
var cache = listEl._rowCache;
if (cache) { cache.rows = {}; cache.keys = null; }
if (listEl._emptyHtml !== html) {
listEl._emptyHtml = html;
listEl.innerHTML = html;
}
}
function renderStatusTaskList(listEl, items) {
listEl._emptyHtml = null;
var cache = listEl._rowCache || (listEl._rowCache = { keys: null, rows: {} });
var keys = [];
var seen = {};
var hasDup = false;
for (var i = 0; i < items.length; i++) {
var k = items[i].key;
if (seen[k]) hasDup = true;
seen[k] = true;
keys.push(k);
}
var keyStr = keys.join('\u0001');
var sameSet = !hasDup && keys.length === Object.keys(cache.rows).length &&
keys.every(function (k2) { return !!cache.rows[k2]; });
if (!sameSet) {
/* 集合变化(或同名任务无法索引):一次性重建并缓存行引用 */
listEl.innerHTML = items.map(buildStatusTaskRowHtml).join('');
cache.rows = {};
cache.keys = null;
if (!hasDup) {
for (var c = 0; c < listEl.children.length; c++) cache.rows[keys[c]] = listEl.children[c];
cache.keys = keyStr;
}
return;
}
if (keyStr !== cache.keys) {
/* 集合相同仅排序变化:保留节点位置,避免带动画的光泽伪元素重启 */
cache.keys = keyStr;
}
for (var u = 0; u < items.length; u++) {
var row = cache.rows[keys[u]];
var order = String(u);
if (row.style.order !== order) row.style.order = order;
updateStatusTaskRow(row, items[u]);
}
}
/* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排;
* 展开瞬间用 window.latestSystemStatus 补渲染,行为与之前一致。 */
var statusJsonBound = false;
function renderStatusJsonIfVisible(data) {
var statusJson = getEl('statusJson');
if (!statusJson) return;
var fold = getEl('statusJsonFold');
if (!statusJsonBound && fold) {
statusJsonBound = true;
var head = fold.querySelector('.status-subfold-head');
var renderLatest = function () {
/* 内联 onclick/onkeydown 已先切换 collapsed,这里只补展开后的内容 */
if (!fold.classList.contains('collapsed') && window.latestSystemStatus) {
statusJson.textContent = JSON.stringify(window.latestSystemStatus, null, 2);
}
};
if (head) {
head.addEventListener('click', renderLatest);
head.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') renderLatest();
});
}
}
if (fold && fold.classList.contains('collapsed')) return;
statusJson.textContent = JSON.stringify(data, null, 2);
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var formatPercent = window.formatPercent;
var ota = data.ota || {};
var setText = function (id, value) { setTextById(id, value); };
updateBootInitUi(data);
// ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) {
var el = getEl(id);
if (!el) return;
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
setClassIfChanged(el, 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : ''));
}
function setBar(usageId, fillId, pctId, free, total) {
if (free === undefined || total === undefined) {
setText(usageId, '--'); setFill(fillId, 0); setText(pctId, '--');
return;
}
var used = total - free;
var pct = total > 0 ? (used * 100 / total) : 0;
setText(usageId, '已用 ' + formatBytes(used) + ' / 总计 ' + formatBytes(total));
setFill(fillId, pct);
setText(pctId, pct.toFixed(1) + '%');
}
function setCpuFill(pct) {
var el = getEl('cpuUsageFill');
if (!el) return;
var safePct = Math.max(0, Math.min(100, Number(pct) || 0));
setWidthIfChanged(el, safePct + '%');
setClassIfChanged(el, 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''));
}
function updateCpuUsageUi(cpuData) {
cpuData = cpuData || {};
var taskList = getEl('cpuTaskList');
if (!cpuData.available) {
setText('cpuUsageText', '未启用 FreeRTOS 统计');
setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--');
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) setListEmpty(taskList, '<div class="status-empty">固件未启用 CPU runtime stats</div>');
return;
}
if (!cpuData.valid) {
setText('cpuUsageText', '等待下一次采样...');
setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--');
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) setListEmpty(taskList, '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>');
return;
}
var usagePct = Number(cpuData.usage_pct) || 0;
var idlePct = Number(cpuData.idle_pct) || 0;
var sampleMs = Number(cpuData.sample_ms) || 0;
var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : [];
/* 按占用率降序;占比相同再按任务号,保证排序稳定。
* renderStatusTaskList 在集合不变时只更新 CSS order,不移动或重建 DOM。 */
tasks.sort(function (a, b) {
var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
if (d !== 0) return d;
return (Number(a.task_number) || 0) - (Number(b.task_number) || 0);
});
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
setText('cpuUsagePct', formatPercent(usagePct));
setText('cpuIdlePct', formatPercent(idlePct));
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
setText('cpuTaskCount', String(tasks.length));
setCpuFill(usagePct);
if (taskList) {
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动。
* 增量更新:集合不变时只改数字/宽度,避免每秒 innerHTML 全量重建 */
if (!tasks.length) {
setListEmpty(taskList, '<div class="status-empty">当前采样没有任务增量</div>');
} else {
renderStatusTaskList(taskList, tasks.map(function (task) {
var pct = Number(task.usage_pct) || 0;
var delta = Number(task.runtime_delta) || 0;
return {
key: String(task.name || 'task'),
rowCls: 'status-task cpu-task',
pctText: formatPercent(pct),
width: Math.max(0, Math.min(100, pct)) + '%',
fillCls: 'status-task-bar-fill' + (pct >= 85 ? ' err' : pct >= 70 ? ' warn' : ''),
metaLabels: ['任务号', '增量', '占比'],
metas: [String(task.task_number), String(delta), formatPercent(pct)]
};
}));
}
}
}
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 = getEl(id);
if (el) setWidthIfChanged(el, '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 = getEl(id);
if (!el) return;
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
setWidthIfChanged(el, 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;
setText('sStatusUpdated', formatTime(new Date()));
var taskList = getEl('taskStackList');
if (taskList) {
var taskStack = data.task_stack || {};
var names = Object.keys(taskStack);
if (!names.length) {
setListEmpty(taskList, '<div class="status-empty">当前状态未包含任务栈信息</div>');
} else {
/* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */
names.sort(function (a, b) {
var ia = taskStack[a] || {};
var ib = taskStack[b] || {};
var sa = Number(ia.stack_size_bytes) || 0;
var sb = Number(ib.stack_size_bytes) || 0;
var pa = sa > 0 ? ((Number(ia.peak_used_bytes) || 0) * 100 / sa) : 0;
var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0;
return pb - pa;
});
/* 增量更新:任务集合稳定时仅改变化的数值,不重建 DOM */
renderStatusTaskList(taskList, names.map(function (name) {
var item = taskStack[name] || {};
var stackSize = Number(item.stack_size_bytes) || 0;
var watermark = Number(item.high_watermark_bytes) || 0;
var peakUsed = Number(item.peak_used_bytes) || 0;
var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0;
return {
key: name,
rowCls: 'status-task',
pctText: formatPercent(usagePct),
width: Math.max(0, Math.min(100, usagePct)) + '%',
fillCls: 'status-task-bar-fill' + (usagePct >= 85 ? ' err' : usagePct >= 70 ? ' warn' : ''),
metaLabels: ['栈大小', '剩余水位', '峰值占用'],
metas: [formatBytes(stackSize), formatBytes(watermark), formatBytes(peakUsed)]
};
}));
}
}
/* 原始 JSON:折叠时跳过序列化与大段文本写入(展开瞬间自动补渲染) */
renderStatusJsonIfVisible(data);
}
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
function getGatewayIp() {
var parts = (window.getEspIp ? window.getEspIp() : '192.168.31.127').split('.');
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1';
return '192.168.31.1';
}
/**
* 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通。
* 放在 connection.js,避免与 polling.js 形成循环 import。
*/
export async function diagnoseNetwork() {
var result = [];
if (navigator.onLine === false) {
result.push('浏览器无网络连接');
return result.join(';');
}
var gwIp = getGatewayIp();
var gwOk = false;
try {
await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
gwOk = true;
} catch (_) {}
if (!gwOk) {
try {
await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) });
gwOk = true;
} catch (_) {}
}
if (!gwOk) {
result.push('网关 ' + gwIp + ' 不可达(本地网络不通)');
} else {
result.push('网关 ' + gwIp + ' 可达');
var espIp = window.getEspIp ? window.getEspIp() : '设备';
result.push('设备 ' + espIp + ' 不响应(可能断电、重启中或 WiFi 断开)');
}
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, forceLayout) {
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try {
var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal });
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.status = resp.status;
throw err;
}
return await resp.json();
} finally {
clearTimeout(timer);
}
}
/** @description 测试与 ESP32 的连接(GET /status)。 */
export async function testConnection() {
try {
var data = await fetchSystemStatus(3000);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
var wifi = data.wifi || {};
addLog('I', '连接', wifi.connected
? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
: '未连接:' + (wifi.mode || 'STA'));
showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
wifi.connected ? 'success' : 'warning');
} catch (e) {
if (e && e.status) {
addLog('E', '连接', 'HTTP ' + e.status);
showToast('设备响应异常: HTTP ' + e.status, 'error');
return;
}
addLog('E', '连接', '连接失败: ' + e.message);
// 连接失败时自动诊断
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
showToast('无法连接: ' + diag, 'error');
}
}
export async function refreshSystemStatusNow(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
/* 手动刷新强制带 layout,便于换固件后更新静态段 */
var data = await fetchSystemStatus(3000, true);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0;
addLog('I', '状态', '已手动刷新 /system/status');
} catch (e) {
if (e && e.status) {
addLog('E', '状态', '刷新失败: HTTP ' + e.status);
showToast('状态刷新失败: HTTP ' + e.status, 'error');
return;
}
addLog('E', '状态', '刷新失败: ' + e.message);
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_est_free_after) && Number(r.hook_est_free_after) > 0 ? formatBytes(r.hook_est_free_after) : (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--')) + '</b>' + (r.hook_confidence === 'est_low_watermark' ? ' <span class="heap-low-caller-hint">(estimated 低水位近似)</span>' : '') + '</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);
}