/** * connection.js - 连接管理 + 系统状态显示 * * 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #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'; // ==================== 连接管理 ==================== 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 ? '在线' : '离线'; bc.className = 'v ' + (connected ? 'ok' : 'err'); } } 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; } if (freeHeap !== undefined) { var mHeapEl = document.getElementById('mHeap'); if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap); } var mh = document.getElementById('mHealth'); if (mh) { mh.textContent = health; mh.className = '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);挂在健康状态展开区,非常驻。 */ function updateBootInitUi(data) { var boot = data && data.boot ? data.boot : null; var resultEl = document.getElementById('mBootResult'); var failedEl = document.getElementById('mBootFailed'); var listEl = document.getElementById('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 '' + '' + escapeHtml(m.name || '?') + ''; }).join(''); } } if (!window._bootInitLogged) { window._bootInitLogged = true; var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : ''); addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine); } ensureHealthBootToggleBound(); } export function updateHeapAndTaskStatusUi(data) { if (!data) return; var formatPercent = window.formatPercent; var ota = data.ota || {}; var setText = function (id, value) { var el = document.getElementById(id); if (el) el.textContent = value; }; updateBootInitUi(data); // ---- 堆概况:已用 / 总计 + 占用条 ---- function setFill(id, pct) { var el = document.getElementById(id); if (!el) return; el.style.width = Math.max(0, Math.min(100, pct)) + '%'; el.className = '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 = document.getElementById('cpuUsageFill'); if (!el) return; var safePct = Math.max(0, Math.min(100, Number(pct) || 0)); el.style.width = safePct + '%'; el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''); } function updateCpuUsageUi(cpuData) { cpuData = cpuData || {}; var taskList = document.getElementById('cpuTaskList'); if (!cpuData.available) { setText('cpuUsageText', '未启用 FreeRTOS 统计'); setText('cpuUsagePct', '--'); setText('cpuIdlePct', '--'); setText('cpuSampleMs', '--'); setText('cpuTaskCount', '--'); setCpuFill(0); if (taskList) taskList.innerHTML = '
固件未启用 CPU runtime stats
'; return; } if (!cpuData.valid) { setText('cpuUsageText', '等待下一次采样...'); setText('cpuUsagePct', '--'); setText('cpuIdlePct', '--'); setText('cpuSampleMs', '--'); setText('cpuTaskCount', '--'); setCpuFill(0); if (taskList) taskList.innerHTML = '
首次刷新只建立基线,下一次刷新后显示 CPU 占用
'; 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() : []; tasks.sort(function (a, b) { return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 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 条,其余滚动 */ if (!tasks.length) { taskList.innerHTML = '
当前采样没有任务增量
'; } else { taskList.innerHTML = tasks.map(function (task) { var pct = Number(task.usage_pct) || 0; var delta = Number(task.runtime_delta) || 0; var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : ''; return '' + '
' + '
' + '
' + escapeHtml(task.name || 'task') + '
' + '
' + escapeHtml(formatPercent(pct)) + '
' + '
' + '
' + '
' + '
任务号' + escapeHtml(task.task_number) + '
' + '
增量' + escapeHtml(delta) + '
' + '
占比' + escapeHtml(formatPercent(pct)) + '
' + '
' + '
'; }).join(''); } } } updateCpuUsageUi(data.cpu); 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); setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--'); 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'); if (taskList) { var taskStack = data.task_stack || {}; var names = Object.keys(taskStack); if (!names.length) { taskList.innerHTML = '
当前状态未包含任务栈信息
'; } 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; }); taskList.innerHTML = 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; var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : ''; return '' + '
' + '
' + '
' + escapeHtml(name) + '
' + '
' + escapeHtml(formatPercent(usagePct)) + '
' + '
' + '
' + '
' + '
栈大小' + escapeHtml(formatBytes(stackSize)) + '
' + '
剩余水位' + escapeHtml(formatBytes(watermark)) + '
' + '
峰值占用' + escapeHtml(formatBytes(peakUsed)) + '
' + '
' + '
'; }).join(''); } } var statusJson = document.getElementById('statusJson'); if (statusJson) { statusJson.textContent = JSON.stringify(data, null, 2); } } /** 从设备 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 抓取器:独立 AbortController,超时后中断, * 避免与递归调度的 pollStatus 互相干扰。 * 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。 */ async function fetchSystemStatus(timeoutMs) { var ac = new AbortController(); var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000); try { var resp = await fetch(window.getBaseUrl() + '/system/status', { 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 { var data = await fetchSystemStatus(3000); 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'); } }