Merge pull request #32 from xJARiD/develop
fix: improve repeater stats UX and persist ESP32 fallback clock
Esse commit está contido em:
@@ -3,13 +3,27 @@ description: Setup Build Environment
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Init Cache
|
||||
- name: Restore uv Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
.venv
|
||||
key: ${{ runner.os }}-py311-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-uv-
|
||||
|
||||
- name: Restore PlatformIO Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
key: ${{ runner.os }}-pio
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
~/.platformio/lib
|
||||
key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini', 'variants/**/platformio.ini') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pio-
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "examples/**"
|
||||
- "variants/**"
|
||||
- "platformio.ini"
|
||||
- ".github/workflows/pr-build-check.yml"
|
||||
- ".github/workflows/push-build-check.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
+3
-3
@@ -187,9 +187,9 @@ The stats page is loaded separately from `/app` and is intended to keep the main
|
||||
|
||||
The `/stats` page currently shows:
|
||||
|
||||
- `Services`: MQTT, web, archive, card, neighbour count, and archive capacity
|
||||
- `Trends`: battery, heap free, packet activity, and signal
|
||||
- `Neighbours`: current neighbour table
|
||||
- `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity
|
||||
- `Trends`: battery, heap free, packet activity, signal, and noise floor
|
||||
- `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age
|
||||
- `Events`: current boot/session events
|
||||
|
||||
The trend graphs load sequentially rather than as one large payload:
|
||||
|
||||
@@ -1627,17 +1627,14 @@ bool MyMesh::appendJsonNeighbours(char* reply, size_t reply_size, size_t& offset
|
||||
mesh::Utils::toHex(full_hex, neighbour->id.pub_key, PUB_KEY_SIZE);
|
||||
const uint32_t heard_secs_ago = now_secs - neighbour->heard_timestamp;
|
||||
const uint32_t advert_secs_ago = now_secs - neighbour->advert_timestamp;
|
||||
ClientInfo* client = const_cast<ClientACL&>(acl).getClient(neighbour->id.pub_key, PUB_KEY_SIZE);
|
||||
const bool route_known = (client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN);
|
||||
offset += snprintf(&reply[offset], reply_size - offset,
|
||||
"%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f,\"route\":\"%s\"}",
|
||||
"%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f}",
|
||||
i == 0 ? "" : ",",
|
||||
hex,
|
||||
full_hex,
|
||||
static_cast<unsigned long>(heard_secs_ago),
|
||||
static_cast<unsigned long>(advert_secs_ago),
|
||||
static_cast<double>(neighbour->snr) / 4.0,
|
||||
route_known ? "known" : "unknown");
|
||||
static_cast<double>(neighbour->snr) / 4.0);
|
||||
if (offset >= reply_size) {
|
||||
return false;
|
||||
}
|
||||
@@ -2243,8 +2240,10 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
|
||||
const bool archive_available = (_archive != nullptr) && _archive->isMounted();
|
||||
#ifdef WITH_MQTT_UPLINK
|
||||
const bool mqtt_connected = mqtt.isAnyBrokerConnected();
|
||||
const char* mqtt_state = mqtt.getAggregateBrokerState();
|
||||
#else
|
||||
const bool mqtt_connected = false;
|
||||
const char* mqtt_state = "down";
|
||||
#endif
|
||||
const bool web_panel_up = web.isPanelRunning();
|
||||
const char* archive_name = (_archive != nullptr) ? _archive->getLogicalName() : "archive";
|
||||
@@ -2267,7 +2266,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
|
||||
"\"recv_errors\":%u,\"direct_dups\":%u,\"flood_dups\":%u,\"neighbors\":%u},"
|
||||
"\"memory\":{\"heap_free\":%u,\"heap_min\":%u,\"heap_max\":%u,\"psram_free\":%u,\"psram_min\":%u,\"psram_max\":%u},"
|
||||
"\"wifi\":{\"ssid\":\"%s\",\"status\":\"%s\",\"connected\":%s,\"state\":\"%s\",\"code\":%d,\"ip\":\"%s\",\"rssi\":%d,\"quality\":%d,\"signal\":\"%s\",\"powersave\":\"%s\"},"
|
||||
"\"services\":{\"mqtt_connected\":%s,\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\","
|
||||
"\"services\":{\"mqtt_connected\":%s,\"mqtt_state\":\"%s\",\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\","
|
||||
"\"archive_available\":%s}",
|
||||
(_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "true" : "false",
|
||||
_stats_history.isPsramBacked() ? "true" : "false",
|
||||
@@ -2327,6 +2326,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
|
||||
wifi_signal,
|
||||
wifi_powersave,
|
||||
mqtt_connected ? "true" : "false",
|
||||
mqtt_state,
|
||||
web.isWebEnabled() ? "true" : "false",
|
||||
web_panel_up ? "true" : "false",
|
||||
web.isPanelUnlocked() ? "unlocked" : "locked",
|
||||
|
||||
@@ -215,3 +215,33 @@ releases:
|
||||
area: web-panel
|
||||
text: "Refreshed the published web panel screenshots and docs to use separate light and dark mode captures."
|
||||
breaking_changes: []
|
||||
|
||||
- track: repeater-mqtt
|
||||
version: "1.3.2"
|
||||
tag: "repeater-mqtt-eastmesh-v1.3.2"
|
||||
date: "2026-04-17"
|
||||
previous_version: "1.3.1"
|
||||
summary: "Refined the repeater /stats UX, added better early-event and noise-floor handling, and persisted the ESP32 fallback clock across reboot."
|
||||
changes:
|
||||
- type: fixed
|
||||
area: stats
|
||||
text: "Removed the misleading neighbour Route column from /stats and cleaned up the neighbour table payload to match the stored data."
|
||||
- type: changed
|
||||
area: stats
|
||||
text: "Retuned the /stats radio noise-floor meter and added a dedicated noise-floor trend with clearer ranges, colour bands, and orientation."
|
||||
- type: changed
|
||||
area: web-panel
|
||||
text: "Reordered the /stats trend cards and changed Packet Activity to a bar chart for interval-based packet counts."
|
||||
- type: fixed
|
||||
area: stats
|
||||
text: "Captured boot/session events earlier on live-only non-PSRAM repeaters so the Events panel is populated before /stats is first opened."
|
||||
- type: fixed
|
||||
area: clock
|
||||
text: "Persisted the ESP32 fallback clock in NVS with immediate saves on sync and periodic checkpoints so boards without a hardware RTC recover a recent sane epoch after reboot."
|
||||
- type: changed
|
||||
area: web-panel
|
||||
text: "Remembered whether the local web UI was last on /app or /stats and returned to that page after unlocking again."
|
||||
- type: changed
|
||||
area: mqtt
|
||||
text: "Changed the Services MQTT status to report degraded when only some enabled MQTT brokers are connected instead of treating any single connected broker as fully up."
|
||||
breaking_changes: []
|
||||
|
||||
+51
-10
@@ -8,6 +8,7 @@
|
||||
#include <rom/rtc.h>
|
||||
#include <sys/time.h>
|
||||
#include <Wire.h>
|
||||
#include <Preferences.h>
|
||||
#include "driver/rtc_io.h"
|
||||
|
||||
class ESP32Board : public mesh::MainBoard {
|
||||
@@ -129,16 +130,55 @@ public:
|
||||
};
|
||||
|
||||
class ESP32RTCClock : public mesh::RTCClock {
|
||||
Preferences _prefs;
|
||||
bool _prefs_ready = false;
|
||||
uint32_t _last_persisted_time = 0;
|
||||
unsigned long _last_persist_ms = 0;
|
||||
|
||||
static constexpr uint32_t kDefaultEpoch = 1715770351; // 15 May 2024, 8:50pm
|
||||
static constexpr unsigned long kPersistIntervalMs = 15UL * 60UL * 1000UL;
|
||||
|
||||
void applyTime(uint32_t time) {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = time;
|
||||
tv.tv_usec = 0;
|
||||
settimeofday(&tv, NULL);
|
||||
}
|
||||
|
||||
void persistCurrentTime(bool force) {
|
||||
if (!_prefs_ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned long now_ms = millis();
|
||||
const uint32_t now = getCurrentTime();
|
||||
if (!force) {
|
||||
if (now == 0 || now == _last_persisted_time) {
|
||||
return;
|
||||
}
|
||||
if ((now_ms - _last_persist_ms) < kPersistIntervalMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_prefs.putULong("epoch", now);
|
||||
_last_persisted_time = now;
|
||||
_last_persist_ms = now_ms;
|
||||
}
|
||||
|
||||
public:
|
||||
ESP32RTCClock() { }
|
||||
void begin() {
|
||||
_prefs_ready = _prefs.begin("mesh-clock", false);
|
||||
const uint32_t persisted_epoch = _prefs_ready ? _prefs.getULong("epoch", 0) : 0;
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_POWERON) {
|
||||
if (persisted_epoch > kDefaultEpoch) {
|
||||
applyTime(persisted_epoch);
|
||||
_last_persisted_time = persisted_epoch;
|
||||
_last_persist_ms = millis();
|
||||
} else if (reason == ESP_RST_POWERON) {
|
||||
// start with some date/time in the recent past
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 1715770351; // 15 May 2024, 8:50pm
|
||||
tv.tv_usec = 0;
|
||||
settimeofday(&tv, NULL);
|
||||
applyTime(kDefaultEpoch);
|
||||
}
|
||||
}
|
||||
uint32_t getCurrentTime() override {
|
||||
@@ -146,11 +186,12 @@ public:
|
||||
time(&_now);
|
||||
return _now;
|
||||
}
|
||||
void setCurrentTime(uint32_t time) override {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = time;
|
||||
tv.tv_usec = 0;
|
||||
settimeofday(&tv, NULL);
|
||||
void setCurrentTime(uint32_t time) override {
|
||||
applyTime(time);
|
||||
persistCurrentTime(true);
|
||||
}
|
||||
void tick() override {
|
||||
persistCurrentTime(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -188,6 +188,9 @@ int buildPointValue(const HistorySample& sample, const HistorySample* previous,
|
||||
if (strcmp(series, "signal") == 0) {
|
||||
return static_cast<int>(sample.last_rssi_x4);
|
||||
}
|
||||
if (strcmp(series, "noise_floor") == 0) {
|
||||
return static_cast<int>(sample.noise_floor * 4);
|
||||
}
|
||||
if (strcmp(series, "packets") == 0) {
|
||||
if (previous == nullptr) {
|
||||
return 0;
|
||||
@@ -212,6 +215,9 @@ const char* seriesTitle(const char* series) {
|
||||
if (strcmp(series, "signal") == 0) {
|
||||
return "Signal";
|
||||
}
|
||||
if (strcmp(series, "noise_floor") == 0) {
|
||||
return "Noise Floor";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -228,6 +234,9 @@ const char* seriesUnit(const char* series) {
|
||||
if (strcmp(series, "signal") == 0) {
|
||||
return "rssi_x4";
|
||||
}
|
||||
if (strcmp(series, "noise_floor") == 0) {
|
||||
return "noise_floor_x4";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -329,6 +338,9 @@ bool StatsHistory::activate() {
|
||||
restoreEventsLog();
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < _pending_event_count; ++i) {
|
||||
storeEvent(_pending_events[i], false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -432,6 +444,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
|
||||
unsigned queue_len = 0;
|
||||
int last_rssi_x4 = 0;
|
||||
int last_snr_x4 = 0;
|
||||
int noise_floor = 0;
|
||||
unsigned long packets_sent = 0;
|
||||
unsigned long packets_recv = 0;
|
||||
unsigned long heap_free = 0;
|
||||
@@ -443,26 +456,58 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
|
||||
unsigned flood_dups = 0;
|
||||
unsigned flags = 0;
|
||||
|
||||
const int parsed = sscanf(line,
|
||||
"%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
|
||||
&epoch_secs,
|
||||
&uptime_secs,
|
||||
&battery_mv,
|
||||
&queue_len,
|
||||
&last_rssi_x4,
|
||||
&last_snr_x4,
|
||||
&packets_sent,
|
||||
&packets_recv,
|
||||
&heap_free,
|
||||
&psram_free,
|
||||
&error_flags,
|
||||
&recv_errors,
|
||||
&neighbour_count,
|
||||
&direct_dups,
|
||||
&flood_dups,
|
||||
&flags);
|
||||
if (parsed != 16) {
|
||||
return false;
|
||||
int parsed = sscanf(line,
|
||||
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
|
||||
&epoch_secs,
|
||||
&uptime_secs,
|
||||
&battery_mv,
|
||||
&queue_len,
|
||||
&last_rssi_x4,
|
||||
&last_snr_x4,
|
||||
&noise_floor,
|
||||
&packets_sent,
|
||||
&packets_recv,
|
||||
&heap_free,
|
||||
&psram_free,
|
||||
&error_flags,
|
||||
&recv_errors,
|
||||
&neighbour_count,
|
||||
&direct_dups,
|
||||
&flood_dups,
|
||||
&flags);
|
||||
if (parsed != 17) {
|
||||
noise_floor = 0;
|
||||
packets_sent = 0;
|
||||
packets_recv = 0;
|
||||
heap_free = 0;
|
||||
psram_free = 0;
|
||||
error_flags = 0;
|
||||
recv_errors = 0;
|
||||
neighbour_count = 0;
|
||||
direct_dups = 0;
|
||||
flood_dups = 0;
|
||||
flags = 0;
|
||||
parsed = sscanf(line,
|
||||
"%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
|
||||
&epoch_secs,
|
||||
&uptime_secs,
|
||||
&battery_mv,
|
||||
&queue_len,
|
||||
&last_rssi_x4,
|
||||
&last_snr_x4,
|
||||
&packets_sent,
|
||||
&packets_recv,
|
||||
&heap_free,
|
||||
&psram_free,
|
||||
&error_flags,
|
||||
&recv_errors,
|
||||
&neighbour_count,
|
||||
&direct_dups,
|
||||
&flood_dups,
|
||||
&flags);
|
||||
if (parsed != 16) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
memset(&sample, 0, sizeof(sample));
|
||||
@@ -483,6 +528,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
|
||||
sample.flood_dups = static_cast<uint16_t>(flood_dups);
|
||||
sample.last_rssi_x4 = static_cast<int16_t>(last_rssi_x4);
|
||||
sample.last_snr_x4 = static_cast<int16_t>(last_snr_x4);
|
||||
sample.noise_floor = static_cast<int16_t>(noise_floor);
|
||||
sample.flags = static_cast<uint8_t>(flags);
|
||||
sample.battery_pct = -1;
|
||||
return true;
|
||||
@@ -687,7 +733,7 @@ void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) {
|
||||
}
|
||||
|
||||
void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value) {
|
||||
if (!_enabled || !_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) {
|
||||
if (!_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -696,6 +742,11 @@ void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptim
|
||||
event.epoch_secs = epoch_secs;
|
||||
event.uptime_secs = uptime_secs;
|
||||
event.value = value;
|
||||
|
||||
if (!_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) {
|
||||
appendPendingEvent(event);
|
||||
return;
|
||||
}
|
||||
storeEvent(event, true);
|
||||
}
|
||||
|
||||
@@ -739,13 +790,14 @@ void StatsHistory::flushSummaryLog() {
|
||||
|
||||
char line[256];
|
||||
snprintf(line, sizeof(line),
|
||||
"%lu,%lu,%u,%u,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
|
||||
"%lu,%lu,%u,%u,%d,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
|
||||
static_cast<unsigned long>(latest.epoch_secs),
|
||||
static_cast<unsigned long>(latest.uptime_secs),
|
||||
static_cast<unsigned>(latest.battery_mv),
|
||||
static_cast<unsigned>(latest.queue_len),
|
||||
static_cast<int>(latest.last_rssi_x4),
|
||||
static_cast<int>(latest.last_snr_x4),
|
||||
static_cast<int>(latest.noise_floor),
|
||||
static_cast<unsigned>(latest.packets_sent),
|
||||
static_cast<unsigned>(latest.packets_recv),
|
||||
static_cast<unsigned>(latest.heap_free),
|
||||
|
||||
@@ -957,6 +957,29 @@ bool MQTTUplink::isAnyBrokerConnected() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* MQTTUplink::getAggregateBrokerState() const {
|
||||
uint8_t enabled_count = 0;
|
||||
uint8_t connected_count = 0;
|
||||
|
||||
for (const BrokerState& broker : _brokers) {
|
||||
if (broker.spec == nullptr || (broker.spec->bit & _prefs.enabled_mask) == 0) {
|
||||
continue;
|
||||
}
|
||||
enabled_count++;
|
||||
if (broker.connected) {
|
||||
connected_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled_count == 0 || connected_count == 0) {
|
||||
return "down";
|
||||
}
|
||||
if (connected_count < enabled_count) {
|
||||
return "degraded";
|
||||
}
|
||||
return "up";
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&)
|
||||
@@ -983,6 +1006,7 @@ bool MQTTUplink::setOwnerPublicKey(const char*) { return false; }
|
||||
bool MQTTUplink::setOwnerEmail(const char*) { return false; }
|
||||
bool MQTTUplink::sendStatusNow() { return false; }
|
||||
bool MQTTUplink::isAnyBrokerConnected() const { return false; }
|
||||
const char* MQTTUplink::getAggregateBrokerState() const { return "down"; }
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ public:
|
||||
const char* getOwnerEmail() const { return _prefs.owner_email; }
|
||||
bool sendStatusNow();
|
||||
bool isAnyBrokerConnected() const;
|
||||
const char* getAggregateBrokerState() const;
|
||||
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -256,6 +256,11 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
|
||||
</main>
|
||||
<script>
|
||||
const statusEl = document.getElementById("status");
|
||||
const LAST_PAGE_KEY = "repeater-last-page";
|
||||
function getPreferredPage() {
|
||||
const stored = localStorage.getItem(LAST_PAGE_KEY);
|
||||
return stored === "/stats" ? "/stats" : "/app";
|
||||
}
|
||||
async function login() {
|
||||
const pwd = document.getElementById("password").value;
|
||||
const res = await fetch("/login", { method:"POST", body: pwd });
|
||||
@@ -265,7 +270,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem("repeater-token", text.trim());
|
||||
window.location.replace("/app");
|
||||
window.location.replace(getPreferredPage());
|
||||
}
|
||||
document.getElementById("loginBtn").onclick = () => login();
|
||||
document.getElementById("password").addEventListener("keydown", (event) => {
|
||||
@@ -526,7 +531,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<h1>Repeater Config</h1>
|
||||
<p>Use the repeater admin password to unlock the command console.</p>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<input id="password" type="password" placeholder="Admin password">
|
||||
<input id="password" type="password" placeholder="Admin password" maxlength="15">
|
||||
<button id="loginBtn">Unlock</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
@@ -672,7 +677,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<div class="field-card">
|
||||
<label class="label" for="adminPassword">Admin Password</label>
|
||||
<div class="inline-actions">
|
||||
<input id="adminPassword" type="password" placeholder="new admin password">
|
||||
<input id="adminPassword" type="password" placeholder="new admin password" maxlength="15">
|
||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||
<button class="savebtn" data-prefix="password " data-input="adminPassword">Save</button>
|
||||
</div>
|
||||
@@ -680,7 +685,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<div class="field-card">
|
||||
<label class="label" for="guestPassword">Guest Password</label>
|
||||
<div class="inline-actions">
|
||||
<input id="guestPassword" type="password" placeholder="new guest password">
|
||||
<input id="guestPassword" type="password" placeholder="new guest password" maxlength="15">
|
||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
|
||||
</div>
|
||||
@@ -987,6 +992,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<script>
|
||||
const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config";
|
||||
const isStatsPage = window.location.pathname === "/stats";
|
||||
const LAST_PAGE_KEY = "repeater-last-page";
|
||||
let token = sessionStorage.getItem("repeater-token") || "";
|
||||
let commandQueue = Promise.resolve();
|
||||
let radioPresetEntries = [];
|
||||
@@ -995,11 +1001,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
const replyEl = document.getElementById("reply");
|
||||
const themeToggleEl = document.getElementById("themeToggle");
|
||||
const rootEl = document.documentElement;
|
||||
function rememberCurrentPage() {
|
||||
localStorage.setItem(LAST_PAGE_KEY, isStatsPage ? "/stats" : "/app");
|
||||
}
|
||||
function redirectToLogin() {
|
||||
rememberCurrentPage();
|
||||
sessionStorage.removeItem("repeater-token");
|
||||
token = "";
|
||||
window.location.replace("/");
|
||||
}
|
||||
rememberCurrentPage();
|
||||
function getPreferredTheme() {
|
||||
const saved = localStorage.getItem("repeater-theme");
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
@@ -1304,9 +1315,21 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
if (percent >= 40) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
function renderMeter(label, value, percent, note, invert) {
|
||||
function toneForThreshold(value, greenMin, yellowMin) {
|
||||
if (!Number.isFinite(value)) return "bad";
|
||||
if (value >= greenMin) return "";
|
||||
if (value >= yellowMin) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
function toneForThresholdDescending(value, greenMax, yellowMax) {
|
||||
if (!Number.isFinite(value)) return "bad";
|
||||
if (value <= greenMax) return "";
|
||||
if (value <= yellowMax) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
function renderMeter(label, value, percent, note, toneOrInvert, invertFill = false) {
|
||||
const pct = clamp(Math.round(percent), 0, 100);
|
||||
const tone = toneForPercent(pct, invert);
|
||||
const tone = typeof toneOrInvert === "string" ? toneOrInvert : toneForPercent(pct, !!toneOrInvert);
|
||||
return `<div class="hud-row">
|
||||
<div class="hud-kpi">
|
||||
<div>
|
||||
@@ -1315,7 +1338,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
</div>
|
||||
<div class="hud-sub">${escapeHtml(note)}</div>
|
||||
</div>
|
||||
<div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%"></div></div>
|
||||
<div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%;${invertFill ? "margin-left:auto;" : ""}"></div></div>
|
||||
</div>`;
|
||||
}
|
||||
function renderMetric(label, value) {
|
||||
@@ -1399,12 +1422,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
}
|
||||
function renderWifiCard(wifi, powersave) {
|
||||
const qualityPct = Number.isFinite(wifi.quality) ? clamp(wifi.quality, 0, 100) : pctRange(wifi.rssi, -100, -50);
|
||||
const rssiPct = pctRange(wifi.rssi, -100, -50);
|
||||
const rssiPct = pctRange(wifi.rssi, -90, -50);
|
||||
const statusNote = wifi.status === "connected" ? (wifi.signal || "linked") : (wifi.state || "idle");
|
||||
const qualityTone = Number.isFinite(wifi.rssi)
|
||||
? toneForThreshold(wifi.rssi, -67, -75)
|
||||
: toneForThreshold(qualityPct, 67, 40);
|
||||
const rssiTone = toneForThreshold(wifi.rssi, -67, -75);
|
||||
return `<section class="hud-card">
|
||||
<h3>Wi-Fi</h3>
|
||||
${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, false)}
|
||||
${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", false)}
|
||||
${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, qualityTone)}
|
||||
${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", rssiTone)}
|
||||
<div class="metric-grid">
|
||||
${renderMetric("Status", wifi.status || "--")}
|
||||
${renderMetric("State", wifi.state || "--")}
|
||||
@@ -1416,16 +1443,19 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
</section>`;
|
||||
}
|
||||
function renderRadioCard(radio) {
|
||||
const rssiPct = pctRange(radio.last_rssi, -130, -90);
|
||||
const snrPct = pctRange(radio.last_snr, -20, 20);
|
||||
const noisePct = pctRange(radio.noise_floor, -60, -113);
|
||||
const rssiPct = pctRange(radio.last_rssi, -125, -30);
|
||||
const snrPct = pctRange(radio.last_snr, -15, 15);
|
||||
const noisePct = 100 - pctRange(radio.noise_floor, -130, -50);
|
||||
const totalAir = (radio.tx_air_secs || 0) + (radio.rx_air_secs || 0);
|
||||
const txShare = pctRatio(radio.tx_air_secs || 0, totalAir);
|
||||
const rssiTone = toneForThreshold(radio.last_rssi, -90, -110);
|
||||
const snrTone = toneForThreshold(radio.last_snr, 5, -5);
|
||||
const noiseTone = toneForThresholdDescending(radio.noise_floor, -110, -95);
|
||||
return `<section class="hud-card">
|
||||
<h3>Radio</h3>
|
||||
${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", false)}
|
||||
${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", false)}
|
||||
${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", false)}
|
||||
${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", rssiTone)}
|
||||
${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", snrTone)}
|
||||
${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", noiseTone)}
|
||||
<div class="metric-grid">
|
||||
${renderMetric("TX Air", String(radio.tx_air_secs ?? 0) + " s")}
|
||||
${renderMetric("RX Air", String(radio.rx_air_secs ?? 0) + " s")}
|
||||
@@ -1499,17 +1529,19 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
const archiveUsed = archive.used_bytes || 0;
|
||||
const archiveFree = Math.max(0, archiveTotal - archiveUsed);
|
||||
const archiveFreePct = archiveTotal > 0 ? Math.round((archiveFree / archiveTotal) * 100) : 0;
|
||||
return `<section class="hud-card">
|
||||
<h3>Services</h3>
|
||||
<div class="metric-grid">
|
||||
${renderMetric("MQTT", services.mqtt_connected ? "up" : "down")}
|
||||
${renderMetric("Web", services.web_panel_up ? services.web_auth || "up" : "down")}
|
||||
${renderMetric("Archive", archive.available ? archive.logical || "archive" : "unavailable")}
|
||||
${renderMetric("Neighbours", packets.neighbors || 0)}
|
||||
const archiveMetrics = archive.available ? `
|
||||
${renderMetric("Card", archive.type || "--")}
|
||||
${renderMetric("Archive Total", formatArchiveGigabytes(archiveTotal))}
|
||||
${renderMetric("Archive Used", formatArchiveUsed(archiveUsed))}
|
||||
${renderMetric("Archive Free", archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--")}
|
||||
${renderMetric("Archive Free", archiveTotal > 0 ? (formatArchiveGigabytes(archiveFree) + " (" + archiveFreePct + "%)") : "--")}` : "";
|
||||
return `<section class="hud-card">
|
||||
<h3>Services</h3>
|
||||
<div class="metric-grid">
|
||||
${renderMetric("MQTT", services.mqtt_state || (services.mqtt_connected ? "up" : "down"))}
|
||||
${renderMetric("Web", services.web_panel_up ? services.web_auth || "up" : "down")}
|
||||
${renderMetric("Archive", archive.available ? archive.logical || "archive" : "unavailable")}
|
||||
${renderMetric("Neighbours", packets.neighbors || 0)}
|
||||
${archiveMetrics}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
@@ -1560,7 +1592,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<td>${Number.isFinite(neighbour.snr_db) ? neighbour.snr_db.toFixed(2) + " dB" : "--"}</td>
|
||||
<td>${escapeHtml(formatDuration(neighbour.heard_secs_ago || 0))}</td>
|
||||
<td>${escapeHtml(formatDuration(neighbour.advert_secs_ago || 0))}</td>
|
||||
<td>${escapeHtml(neighbour.route || "unknown")}</td>
|
||||
</tr>`).join("");
|
||||
return `<section class="hud-card">
|
||||
<h3>Neighbours</h3>
|
||||
@@ -1572,7 +1603,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<th>SNR</th>
|
||||
<th>Heard</th>
|
||||
<th>Advert</th>
|
||||
<th>Route</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -1632,6 +1662,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
if (key === "memory") return formatBytes(value);
|
||||
if (key === "packets") return Math.round(value) + " pkts";
|
||||
if (key === "signal") return (value / 4).toFixed(1) + " dBm";
|
||||
if (key === "noise_floor") return (value / 4).toFixed(1) + " dBm";
|
||||
return String(value);
|
||||
}
|
||||
function formatArchiveGigabytes(value) {
|
||||
@@ -1653,6 +1684,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
function sparkStrokeColor(key, points) {
|
||||
if (key === "packets") return "#d97706";
|
||||
if (key === "signal") return "#3b82f6";
|
||||
if (key === "noise_floor") return "#ef4444";
|
||||
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;
|
||||
@@ -1666,6 +1698,47 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
}
|
||||
return "#2f8f4e";
|
||||
}
|
||||
function sparkValueRange(key, values) {
|
||||
if (key === "signal") {
|
||||
return { min:(-125 * 4), max:(-30 * 4) };
|
||||
}
|
||||
if (key === "noise_floor") {
|
||||
return { min:(-130 * 4), max:(-50 * 4) };
|
||||
}
|
||||
let minValue = Math.min(...values);
|
||||
let maxValue = Math.max(...values);
|
||||
if (minValue === maxValue) {
|
||||
minValue -= 1;
|
||||
maxValue += 1;
|
||||
}
|
||||
return { min:minValue, max:maxValue };
|
||||
}
|
||||
function sparkGuideValues(key) {
|
||||
if (key === "signal") {
|
||||
return [-120, -110, -100, -90, -80, -70, -60, -50, -40].map((value) => value * 4);
|
||||
}
|
||||
if (key === "noise_floor") {
|
||||
return [-120, -110, -100, -90, -80, -70, -60, -50].map((value) => value * 4);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function sparkBands(key) {
|
||||
if (key === "signal") {
|
||||
return [
|
||||
{ from:(-125 * 4), to:(-110 * 4), color:"rgba(191,75,75,0.14)" },
|
||||
{ from:(-110 * 4), to:(-90 * 4), color:"rgba(215,165,49,0.14)" },
|
||||
{ from:(-90 * 4), to:(-30 * 4), color:"rgba(47,143,78,0.12)" }
|
||||
];
|
||||
}
|
||||
if (key === "noise_floor") {
|
||||
return [
|
||||
{ from:(-130 * 4), to:(-110 * 4), color:"rgba(47,143,78,0.12)" },
|
||||
{ from:(-110 * 4), to:(-95 * 4), color:"rgba(215,165,49,0.14)" },
|
||||
{ from:(-95 * 4), to:(-50 * 4), color:"rgba(191,75,75,0.14)" }
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function drawSparkline(canvas, points, key, hoverIndex) {
|
||||
if (!canvas || !canvas.getContext) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -1675,20 +1748,57 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
canvas.height = height * (window.devicePixelRatio || 1);
|
||||
ctx.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
if (!Array.isArray(points) || points.length < 2) return;
|
||||
if (!Array.isArray(points) || points.length < 1) return;
|
||||
const values = points.map((item) => item[1]).filter((value) => Number.isFinite(value));
|
||||
if (values.length < 2) return;
|
||||
let minValue = Math.min(...values);
|
||||
let maxValue = Math.max(...values);
|
||||
if (minValue === maxValue) {
|
||||
minValue -= 1;
|
||||
maxValue += 1;
|
||||
}
|
||||
if (values.length < 1) return;
|
||||
const range = sparkValueRange(key, values);
|
||||
const minValue = range.min;
|
||||
const maxValue = range.max;
|
||||
const plotLeft = 4;
|
||||
const plotRight = width - 4;
|
||||
const plotTop = 8;
|
||||
const plotBottom = height - 8;
|
||||
const scaleY = (value) => {
|
||||
const clamped = clamp(value, minValue, maxValue);
|
||||
if (key === "noise_floor") {
|
||||
return plotTop + (((clamped - minValue) / (maxValue - minValue)) * (plotBottom - plotTop));
|
||||
}
|
||||
return plotBottom - (((clamped - minValue) / (maxValue - minValue)) * (plotBottom - plotTop));
|
||||
};
|
||||
sparkBands(key).forEach((band) => {
|
||||
const y1 = scaleY(band.from);
|
||||
const y2 = scaleY(band.to);
|
||||
const top = Math.min(y1, y2);
|
||||
const bandHeight = Math.max(1, Math.abs(y2 - y1));
|
||||
ctx.fillStyle = band.color;
|
||||
ctx.fillRect(plotLeft, top, plotRight - plotLeft, bandHeight);
|
||||
});
|
||||
sparkGuideValues(key).forEach((guide) => {
|
||||
const y = scaleY(guide);
|
||||
ctx.strokeStyle = "rgba(75,85,99,0.18)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(plotLeft, y);
|
||||
ctx.lineTo(plotRight, y);
|
||||
ctx.stroke();
|
||||
});
|
||||
const coords = points.map((point, index) => ({
|
||||
x: (index / Math.max(1, points.length - 1)) * (width - 8) + 4,
|
||||
y: height - 8 - (((point[1] - minValue) / (maxValue - minValue)) * (height - 16))
|
||||
x: (index / Math.max(1, points.length - 1)) * (plotRight - plotLeft) + plotLeft,
|
||||
y: scaleY(point[1])
|
||||
}));
|
||||
const strokeColor = sparkStrokeColor(key, points);
|
||||
if (key === "packets") {
|
||||
const slotWidth = (plotRight - plotLeft) / Math.max(points.length, 1);
|
||||
const barWidth = Math.max(3, Math.min(18, slotWidth * 0.68));
|
||||
coords.forEach((point, index) => {
|
||||
const left = clamp(point.x - (barWidth / 2), plotLeft, plotRight - barWidth);
|
||||
const top = point.y;
|
||||
const barHeight = Math.max(1, plotBottom - top);
|
||||
ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? "#f59e0b" : strokeColor;
|
||||
ctx.fillRect(left, top, barWidth, barHeight);
|
||||
});
|
||||
return;
|
||||
}
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.beginPath();
|
||||
@@ -1707,7 +1817,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
}
|
||||
function bindSparkHover(canvas, points, key) {
|
||||
const tooltip = document.getElementById("tooltip-" + key);
|
||||
if (!canvas || !tooltip || !Array.isArray(points) || points.length < 2) return;
|
||||
if (!canvas || !tooltip || !Array.isArray(points) || points.length < 1) return;
|
||||
const updateHover = (index) => {
|
||||
drawSparkline(canvas, points, key, index);
|
||||
if (index == null || index < 0 || index >= points.length) {
|
||||
@@ -1786,7 +1896,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
function initTrendCards() {
|
||||
const trendsEl = document.getElementById("statsTrends");
|
||||
if (!trendsEl) return;
|
||||
trendsEl.innerHTML = ["battery", "memory", "packets", "signal"].map((key) =>
|
||||
trendsEl.innerHTML = ["battery", "memory", "signal", "noise_floor", "packets"].map((key) =>
|
||||
`<section class="trend-card" id="trend-${key}">
|
||||
<div class="trend-title">${escapeHtml(key)}</div>
|
||||
<div class="spark-axis"><span></span><span>Loading...</span></div>
|
||||
@@ -1844,7 +1954,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
});
|
||||
}
|
||||
function runPrefixed(prefix, inputId) {
|
||||
const value = document.getElementById(inputId).value;
|
||||
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);
|
||||
}
|
||||
async function loadField(cmd, inputId, format, options = {}) {
|
||||
@@ -2069,7 +2183,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
return;
|
||||
}
|
||||
|
||||
const seriesOrder = ["battery", "memory", "packets", "signal"];
|
||||
const seriesOrder = ["battery", "memory", "signal", "noise_floor", "packets"];
|
||||
for (const key of seriesOrder) {
|
||||
try {
|
||||
const payload = await fetchJson("/api/stats?series=" + encodeURIComponent(key));
|
||||
|
||||
Referência em uma Nova Issue
Bloquear um usuário