feat(config): 优化NimBLE配置以节省内存并提升OTA性能
- 启用BLE连接事件长度优化,允许单CE内传输多包,提升OTA吞吐 - 缩减NimBLE mbuf池配置,单连接OTA场景节省约7KB内存 - 禁用未使用的标准GATT服务,仅保留自定义OTA service,节省8-15KB - 缩减NimBLE传输缓冲区配置以优化内存使用 refactor(debug_log): 重构环形日志缓冲区API以支持逐条弹出 - 新增ringPopOldest方法,支持弹出最旧日志并从缓冲区移除 - 重构ringDrainJson实现,基于新的逐条弹出机制 - 改进日志转义处理逻辑,支持更多特殊字符转义 perf(tasks): 优化各任务栈大小配置以节省内存 - 将ota_relay任务栈从12288字节缩减至6144字节 - 将ble_ota任务栈从8192字节缩减至4096字节 - 将ble_ota数据工作线程栈从8192字节缩减至4096字节 - 通过栈高水位监控验证实际使用情况,确保安全余量 feat(monitor): 添加任务句柄获取接口以支持系统状态监控 - 为HealthMonitor、OtaRelay、BleOta和WifiOta类添加任务句柄获取方法 - 在任务入口处记录当前任务句柄 - 便于系统状态监控和调试信息收集 feat(status): 扩展系统状态API以提供更详细的内存和任务信息 - 增加内部堆和默认堆的详细统计信息 - 添加各关键任务的栈使用情况监控 - 扩展/system/status端点返回更全面的系统资源使用情况 - 包含最大空闲块、最小空闲内存等关键指标
This commit is contained in:
+176
-30
@@ -14,6 +14,7 @@ let pageVisible = !document.hidden; // 页面可见性:切到后台/最小
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
let pollingPausedForUpload = false;
|
||||
|
||||
// ==================== 折叠面板 ====================
|
||||
function toggleCollapse(headEl) {
|
||||
@@ -172,6 +173,18 @@ function formatUptime(sec) {
|
||||
if (m > 0) return m + 'm ' + s + 's';
|
||||
return s + 's';
|
||||
}
|
||||
function escapeHtml(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
function formatPercent(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return value.toFixed(1).replace(/\.0$/, '') + '%';
|
||||
}
|
||||
function formatHttpErrorDetail(text) {
|
||||
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
|
||||
@@ -299,6 +312,7 @@ function resetStageFlow() {
|
||||
function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('pillIP').textContent = '--';
|
||||
document.getElementById('pillRSSI').textContent = '--';
|
||||
if (isUploading || currentOtaTarget) return;
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
@@ -354,7 +368,92 @@ function updateHealthUi(healthCode, freeHeap) {
|
||||
connStatusTxt.textContent = (latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
|
||||
}
|
||||
if (freeHeap !== undefined) {
|
||||
document.getElementById('mHeap').textContent = formatBytes(freeHeap);
|
||||
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');
|
||||
}
|
||||
}
|
||||
function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var ota = data.ota || {};
|
||||
var wifi = data.wifi || {};
|
||||
var targetHost = getBaseUrl().replace(/^http:\/\//, '');
|
||||
var setText = function (id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||||
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) + '%');
|
||||
}
|
||||
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) : '--');
|
||||
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 = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||||
} else {
|
||||
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 '' +
|
||||
'<div class="status-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
|
||||
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
|
||||
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
var statusJson = document.getElementById('statusJson');
|
||||
if (statusJson) {
|
||||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
// ==================== OTA 阶段驱动 (真实状态) ====================
|
||||
@@ -425,6 +524,7 @@ function updateStagesFromRelayState(rs) {
|
||||
function updateSystemStatusUi(data) {
|
||||
if (!data) return;
|
||||
latestSystemStatus = data;
|
||||
updateHeapAndTaskStatusUi(data);
|
||||
var wifi = data.wifi || {};
|
||||
var ota = data.ota || {};
|
||||
var healthCode = data.health;
|
||||
@@ -433,10 +533,12 @@ function updateSystemStatusUi(data) {
|
||||
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
|
||||
updateConnectionUi(connected, connText);
|
||||
updateHealthUi(healthCode, data.free_heap);
|
||||
// version
|
||||
if (data.version) {
|
||||
document.getElementById('pillVer').textContent = 'v' + data.version;
|
||||
document.getElementById('pillVer').dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
var pillVerEl = document.getElementById('pillVer');
|
||||
if (pillVerEl) {
|
||||
pillVerEl.textContent = 'v' + data.version;
|
||||
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : '';
|
||||
}
|
||||
// GD32 版本
|
||||
var mGd32App = document.getElementById('mGd32App');
|
||||
var mGd32Boot = document.getElementById('mGd32Boot');
|
||||
@@ -460,23 +562,20 @@ function updateSystemStatusUi(data) {
|
||||
}
|
||||
}
|
||||
// temperature
|
||||
document.getElementById('mTemp').textContent =
|
||||
var mTempEl = document.getElementById('mTemp');
|
||||
if (mTempEl) mTempEl.textContent =
|
||||
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
|
||||
// uptime
|
||||
var uptimeEl = document.getElementById('pillUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
|
||||
// wifi / signal
|
||||
document.getElementById('pillIP').textContent = wifi.ip || '--';
|
||||
var pillIPEl = document.getElementById('pillIP');
|
||||
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
|
||||
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
|
||||
document.getElementById('pillRSSI').textContent = rssi;
|
||||
// heap
|
||||
document.getElementById('mHeap').textContent =
|
||||
data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
|
||||
document.getElementById('mMinHeap').textContent =
|
||||
data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
|
||||
// ota cache
|
||||
var otaStored = ota.stored_size || 0;
|
||||
var pillRSSIEl = document.getElementById('pillRSSI');
|
||||
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
|
||||
var mOtaCache = document.getElementById('mOtaCache');
|
||||
var otaStored = ota.stored_size || 0;
|
||||
if (mOtaCache) {
|
||||
if (otaStored > 0) {
|
||||
mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB';
|
||||
@@ -501,14 +600,7 @@ function updateSystemStatusUi(data) {
|
||||
var total = ota.total_size || 0;
|
||||
var stored = ota.stored_size || 0;
|
||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
function setOtaProgressBadge(pct) {
|
||||
var safePct = Math.max(0, Math.min(100, Math.round(pct || 0)));
|
||||
if (bp) {
|
||||
bp.textContent = safePct + '%';
|
||||
bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 0 ? 'warn' : '');
|
||||
}
|
||||
}
|
||||
function setOtaProgressBadge() {} // OTA 进度卡片已移除,保留空壳避免调用报错
|
||||
// GD32 relay forwarding progress
|
||||
var relaySeq = ota.relay_seq || 0;
|
||||
var relayTotal = ota.relay_total_packets || 0;
|
||||
@@ -607,6 +699,32 @@ async function testConnection() {
|
||||
showToast('无法连接: ' + diag, 'error');
|
||||
}
|
||||
}
|
||||
async function refreshSystemStatusNow(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
|
||||
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
latestSystemStatus = data;
|
||||
updateSystemStatusUi(data);
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) restorePolling();
|
||||
addLog('I', '状态', '已手动刷新 /system/status');
|
||||
} catch (e) {
|
||||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||||
showToast('状态刷新失败', 'error');
|
||||
}
|
||||
}
|
||||
// ==================== CAN 帧发送 ====================
|
||||
async function sendCanFrame() {
|
||||
var ch = parseInt(document.getElementById('canChannel').value);
|
||||
@@ -984,6 +1102,7 @@ function clearFile(chip) {
|
||||
async function startUpload(chip) {
|
||||
var file = chip === 'esp32' ? esp32File : gd32File;
|
||||
if (!file || isUploading) return;
|
||||
pausePollingForUpload();
|
||||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||||
@@ -1008,12 +1127,14 @@ async function startUpload(chip) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ESP32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
if (chip === 'gd32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 GD32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1175,7 @@ async function startUpload(chip) {
|
||||
showToast('文件已变更,请重新选择', 'error');
|
||||
clearFile(chip);
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1135,17 +1257,14 @@ async function startUpload(chip) {
|
||||
if (chip === 'gd32') {
|
||||
var overallPct = Math.min(45, Math.round(pct * 0.45));
|
||||
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = overallPct + '%';
|
||||
} else {
|
||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function () {
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1177,6 +1296,7 @@ async function startUpload(chip) {
|
||||
});
|
||||
xhr.addEventListener('error', function () {
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1189,6 +1309,7 @@ async function startUpload(chip) {
|
||||
addLog('E', '上传', '上传失败: ' + e.message);
|
||||
showToast('上传失败', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
@@ -1253,7 +1374,7 @@ async function verifyEsp32Firmware() {
|
||||
addLog('E', 'OTA', '校验失败: ' + detail);
|
||||
showToast('校验失败: ' + detail, 'error');
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) { resultEl.textContent = '❌ ' + detail; resultEl.className = 'svi-val err'; }
|
||||
if (resultEl) { resultEl.textContent = '? ' + detail; resultEl.className = 'svi-val err'; }
|
||||
updateEsp32StagesError();
|
||||
showVerifyActions('retry');
|
||||
}
|
||||
@@ -1462,7 +1583,31 @@ function restorePolling() {
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
}
|
||||
function pausePollingForUpload() {
|
||||
if (pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = true;
|
||||
consecutivePollFailures = 0;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = null;
|
||||
logsTimerId = null;
|
||||
}
|
||||
function resumePollingAfterUpload() {
|
||||
if (!pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = false;
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) {
|
||||
statusTimerId = setInterval(pollStatus, 10000);
|
||||
logsTimerId = setInterval(pollLogs, 10000);
|
||||
} else {
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
pollStatus();
|
||||
pollLogs();
|
||||
}
|
||||
async function pollStatus() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
@@ -1485,14 +1630,14 @@ async function pollStatus() {
|
||||
addLog('I', '系统', 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
}
|
||||
updateSystemStatusUi(data);
|
||||
try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
}
|
||||
} catch (e) {
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) consecutivePollFailures++;
|
||||
// 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
@@ -1540,6 +1685,7 @@ document.addEventListener('visibilitychange', function () {
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
var lastLogTimestamp = 0;
|
||||
async function pollLogs() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
|
||||
+1
-1
@@ -1289,7 +1289,7 @@
|
||||
// ★ 等待设备端 reorder buffer flush + ACK 回传。
|
||||
// 设备端可能正在 flush 缺口包(getReceived 跳跃增长),
|
||||
// 留足时间让最新的 ACK 到达,避免重发已 flush 的包。
|
||||
await sleep(150);
|
||||
await sleep(10);
|
||||
|
||||
for (let seq = resumeSeq; seq <= endSeq; seq++) {
|
||||
const start = seq * payloadSize;
|
||||
|
||||
+50
-6
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260617a">
|
||||
<link rel="stylesheet" href="style.css?v=20260619c">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -248,8 +248,8 @@
|
||||
<div class="k">连接状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v warn" id="bOTAProgress">0%</div>
|
||||
<div class="k">OTA 进度</div>
|
||||
<div class="v" id="mHealth">--</div>
|
||||
<div class="k">健康状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v err" id="connBadge">offline</div>
|
||||
@@ -269,8 +269,6 @@
|
||||
<div class="card-body">
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
|
||||
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||||
@@ -278,6 +276,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="ico">🧾</span> 系统状态 (/system/status)
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-kv-grid">
|
||||
<div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div>
|
||||
<div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div>
|
||||
<div class="status-kv"><span class="k">缓存进度</span><b id="sOtaStored">--</b></div>
|
||||
<div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">任务栈水位</div>
|
||||
<div class="status-task-list" id="taskStackList">
|
||||
<div class="status-empty">等待状态刷新...</div>
|
||||
</div>
|
||||
<div class="status-section-title">原始 JSON</div>
|
||||
<pre class="status-json" id="statusJson">--</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN panel -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
@@ -369,7 +413,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260619c"></script>
|
||||
<script src="app.js?v=20260619e"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
|
||||
+212
@@ -116,6 +116,12 @@ body::before {
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
}
|
||||
.card-head .ico { font-size: 15px; }
|
||||
.card-head-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
.card-head { cursor: pointer; }
|
||||
@@ -326,6 +332,211 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
}
|
||||
.metric .v.highlight { color: var(--accent); }
|
||||
|
||||
.status-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.status-kv {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-kv .k {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.status-kv b {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ===== 堆概况 ===== */
|
||||
.heap-group {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.heap-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.heap-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 165px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heap-bar-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heap-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, var(--accent), #5eead4);
|
||||
transition: width .4s ease;
|
||||
.heap-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.heap-bar-fill.danger {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.heap-bar-pct {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.heap-detail-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heap-detail-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.heap-detail-row b {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-task-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-task-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-ratio {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #22d3ee, #06b6d4);
|
||||
}
|
||||
.status-task-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.status-task-bar-fill.err {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
.status-task-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task-meta span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.status-task-meta b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-json {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* === PRIMARY STATUS BADGES (large) === */
|
||||
.badge-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
||||
@@ -662,6 +873,7 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
.container { padding: 16px; }
|
||||
.header-bar { flex-direction: column; align-items: flex-start; }
|
||||
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user