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:
小龙虾
2026-06-20 00:46:04 +08:00
parent 59c729d77a
commit 3fff959a2b
18 changed files with 701 additions and 103 deletions
+176 -30
View File
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
}