diff --git a/docs/web-panel.md b/docs/web-panel.md index 3a009aba..f01eca02 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -217,7 +217,7 @@ Current in-memory history caps are: | `4 MB` to less than `8 MB` PSRAM | `480` | `192` | About `8` hours | | `8 MB` PSRAM or more | `720` | `288` | About `12` hours | -On boards with `4 MB` PSRAM or more, stats history starts capturing from boot when `web.stats` is enabled, even if `/stats` has not been opened yet. +On boards with roughly `2 MB` PSRAM or more, stats history starts capturing from boot when `web.stats` is enabled, even if `/stats` has not been opened yet. Archive-backed restore requires `web.stats` enabled plus a mounted SD card on boards that support the EastMesh archive path. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1630b867..0e07aaf6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1856,19 +1856,18 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply web.formatWebStatusReply(reply, 160); } else if (strcmp(command, "get web.stats.status") == 0) { snprintf(reply, 160, - "> enabled:%s history:%s psram:%s degraded:%s mode:%s samples:%u/%u events:%u/%u archive:%s logical:%s path:%s", + "> enabled:%s history:%s mode:%s psram:%s psram_bytes:%lu boot_auto:%s samples:%u/%u events:%u/%u archive:%s", web.isWebStatsEnabled() ? "on" : "off", (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "active" : "inactive", - _stats_history.isPsramBacked() ? "yes" : "no", - _stats_history.isDegraded() ? "yes" : "no", _stats_history.isLiveOnly() ? "live" : "full", + _stats_history.isPsramBacked() ? "yes" : "no", + static_cast(_stats_history.getDetectedPsramSizeBytes()), + _stats_history.isBootAutoCaptureExpected() ? "yes" : "no", static_cast(_stats_history.getSampleCount()), static_cast(_stats_history.getSampleCapacity()), static_cast(_stats_history.getEventCount()), static_cast(_stats_history.getEventCapacity()), - (_archive != nullptr && _archive->isMounted()) ? "mounted" : "unavailable", - (_archive != nullptr) ? _archive->getLogicalName() : "archive", - (_archive != nullptr) ? _archive->getLogicalStatsPath() : "archive:/stats"); + (_archive != nullptr && _archive->isMounted()) ? "mounted" : "unavailable"); #endif #if defined(ESP_PLATFORM) } else if (memcmp(command, "get wifi.status", 15) == 0) { @@ -2250,6 +2249,12 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { const char* archive_path = (_archive != nullptr) ? _archive->getLogicalStatsPath() : "archive:/stats"; const char* archive_type = (_archive != nullptr) ? _archive->getCardTypeName() : "unavailable"; _stats_history.noteAccess(millis()); + const uint32_t heap_free = ESP.getFreeHeap(); + const uint32_t heap_min = ESP.getMinFreeHeap(); + const uint32_t heap_max = ESP.getMaxAllocHeap(); + const uint32_t psram_free = ESP.getFreePsram(); + const uint32_t psram_min = ESP.getMinFreePsram(); + const uint32_t psram_max = ESP.getMaxAllocPsram(); size_t offset = 0; offset += snprintf(&reply[offset], reply_size - offset, @@ -2309,12 +2314,12 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { static_cast(((SimpleMeshTables *)getTables())->getNumDirectDups()), static_cast(((SimpleMeshTables *)getTables())->getNumFloodDups()), static_cast(getNeighbourCount()), - ESP.getFreeHeap(), - ESP.getMinFreeHeap(), - ESP.getMaxAllocHeap(), - ESP.getFreePsram(), - ESP.getMinFreePsram(), - ESP.getMaxAllocPsram(), + heap_free, + heap_min, + heap_max, + psram_free, + psram_min, + psram_max, wifi_ssid, wifi_status, network.isWifiConnected() ? "true" : "false", diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index a37d4193..888fa4e6 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -28,6 +28,7 @@ constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL; constexpr size_t kMaxSeriesPoints = 64; constexpr size_t kSummaryRestoreWindowBytes = 16384; constexpr size_t kEventsRestoreWindowBytes = 4096; +constexpr uint32_t kBootAutoCapturePsramMinBytes = 2000000UL; constexpr size_t kLiveOnlySampleCapacity = 24; constexpr size_t kLiveOnlyEventCapacity = 8; @@ -66,7 +67,7 @@ bool shouldUseLiveOnlyStats() { bool hasBootAutoCapturePsram() { #if defined(ESP32) - return psramFound() && ESP.getPsramSize() >= (4UL * 1024UL * 1024UL); + return psramFound() && ESP.getPsramSize() >= kBootAutoCapturePsramMinBytes; #else return false; #endif @@ -415,6 +416,18 @@ bool StatsHistory::shouldAutoActivateFromBoot() const { return _enabled && !_live_only && hasBootAutoCapturePsram(); } +bool StatsHistory::isBootAutoCaptureExpected() const { + return shouldAutoActivateFromBoot(); +} + +uint32_t StatsHistory::getDetectedPsramSizeBytes() const { +#if defined(ESP32) + return psramFound() ? ESP.getPsramSize() : 0; +#else + return 0; +#endif +} + bool StatsHistory::isAccessActive(uint32_t now_ms) const { if (!_live_only) { return true; diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h index 976578aa..baa6a20a 100644 --- a/src/helpers/StatsHistory.h +++ b/src/helpers/StatsHistory.h @@ -81,6 +81,8 @@ public: bool isLiveOnly() const { return _live_only; } bool isArchiveAvailable() const; bool hasArchiveRestore() const { return _restored_sample_count > 0; } + bool isBootAutoCaptureExpected() const; + uint32_t getDetectedPsramSizeBytes() const; size_t getSampleCapacity() const { return _sample_capacity; } size_t getSampleCount() const { return _sample_count; } diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index d53b865e..23d2b73a 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -478,6 +478,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .hud-sub { font-size:12px; color:var(--text-muted); } .meter { height:10px; background:var(--background); border:1px solid var(--border); border-radius:999px; overflow:hidden; } .meter-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); } + .meter-fill.ok { background:linear-gradient(90deg,#6ea43f,#8cb857); } .meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); } .meter-fill.bad { background:linear-gradient(90deg,#bf4b4b,#dd6a6a); } .metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } @@ -1001,6 +1002,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const replyEl = document.getElementById("reply"); const themeToggleEl = document.getElementById("themeToggle"); const rootEl = document.documentElement; + function updatePanelTitle(nameValue) { + const fallbackTitle = "Repeater Config"; + const trimmedName = String(nameValue == null ? "" : nameValue).trim(); + document.title = trimmedName ? trimmedName : fallbackTitle; + } function rememberCurrentPage() { localStorage.setItem(LAST_PAGE_KEY, isStatsPage ? "/stats" : "/app"); } @@ -1327,6 +1333,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (value <= yellowMax) return "warn"; return "bad"; } + function toneForHeapFreePercent(percent) { + if (!Number.isFinite(percent)) return "bad"; + if (percent >= 75) return ""; + if (percent >= 55) return "ok"; + if (percent >= 35) return "warn"; + return "bad"; + } + function colorForHeapFreePercent(percent) { + const tone = toneForHeapFreePercent(percent); + if (tone === "ok") return "#6ea43f"; + if (tone === "warn") return "#d7a531"; + if (tone === "bad") return "#d14343"; + return "#2f8f4e"; + } function renderMeter(label, value, percent, note, toneOrInvert, invertFill = false) { const pct = clamp(Math.round(percent), 0, 100); const tone = typeof toneOrInvert === "string" ? toneOrInvert : toneForPercent(pct, !!toneOrInvert); @@ -1490,8 +1510,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const heapMax = memory.heap_max || 0; const psramFree = memory.psram_free || 0; const psramMax = memory.psram_max || 0; + const heapFreePct = pctRange(heapFree, 0, 128 * 1024); return `

Memory

+ ${renderMeter("Heap Free", formatBytes(heapFree), heapFreePct, "total free heap", toneForHeapFreePercent(heapFreePct))} ${renderMeter("Heap Largest Block", formatBytes(heapMax), pctRatio(heapMax, heapFree), "largest alloc vs free", false)} ${renderMeter("PSRAM Largest Block", formatBytes(psramMax), pctRatio(psramMax, psramFree), "largest alloc vs free", false)}
@@ -1687,14 +1709,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (key === "noise_floor") return "#94a3b8"; if (key === "memory") { const values = Array.isArray(points) ? points.map((item) => item && item[1]).filter((value) => Number.isFinite(value)) : []; - const current = values.length ? values[values.length - 1] : NaN; - const minVisible = values.length ? Math.min(...values) : NaN; - const lowThreshold = 32 * 1024; - const warningThreshold = 64 * 1024; - if (Number.isFinite(current) && current <= lowThreshold) return "#d14343"; - if (Number.isFinite(minVisible) && minVisible <= lowThreshold) return "#d14343"; - if (Number.isFinite(current) && current <= warningThreshold) return "#d97706"; - if (Number.isFinite(minVisible) && minVisible <= warningThreshold) return "#d97706"; + const recentValues = values.slice(-5); + const minRecent = recentValues.length ? Math.min(...recentValues) : NaN; + const percent = pctRange(minRecent, 0, 128 * 1024); + return colorForHeapFreePercent(percent); } return "#2f8f4e"; } @@ -1918,6 +1936,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const passwordEl = document.getElementById("password"); if (passwordEl) passwordEl.value = ""; if (statusEl) statusEl.textContent = ""; + updatePanelTitle(); const summaryEl = document.getElementById("statsSummary"); if (summaryEl) summaryEl.innerHTML = '
Loading summary...
'; const trendsEl = document.getElementById("statsTrends"); @@ -1953,13 +1972,17 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( return { ok, text }; }); } - function runPrefixed(prefix, inputId) { + async function runPrefixed(prefix, inputId) { const input = document.getElementById(inputId); if (!input) return; const maxLength = Number.isFinite(input.maxLength) && input.maxLength > 0 ? input.maxLength : null; const value = maxLength ? input.value.slice(0, maxLength) : input.value; if (value !== input.value) input.value = value; - runCommand(prefix + value); + const result = await runCommand(prefix + value); + if (!result.ok) return; + if (inputId === "nodeName") { + await loadField("get name", "nodeName", null, { recordHistory:false, updateInput:false }); + } } async function loadField(cmd, inputId, format, options = {}) { const result = await runCommand(cmd, options); @@ -1971,6 +1994,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( value = value.toUpperCase(); } document.getElementById(inputId).value = value; + if (inputId === "nodeName") { + updatePanelTitle(value); + } } async function copyToClipboard(value, successMessage) { try {