From d7b5c81b0ea8bd060064101debc2a44070572a35 Mon Sep 17 00:00:00 2001 From: proddy Date: Thu, 4 Dec 2025 20:29:20 +0100 Subject: [PATCH 1/3] package update --- interface/package.json | 2 +- interface/pnpm-lock.yaml | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/interface/package.json b/interface/package.json index 5375f5ba4..410927bd7 100644 --- a/interface/package.json +++ b/interface/package.json @@ -41,7 +41,7 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "react-icons": "^5.5.0", - "react-router": "^7.10.0", + "react-router": "^7.10.1", "react-toastify": "^11.0.5", "typesafe-i18n": "^5.26.2", "typescript": "^5.9.3" diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index da6aa727e..9f4300c11 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -63,8 +63,8 @@ importers: specifier: ^5.5.0 version: 5.5.0(react@19.2.1) react-router: - specifier: ^7.10.0 - version: 7.10.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^7.10.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react-toastify: specifier: ^11.0.5 version: 11.0.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -1027,8 +1027,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.0: - resolution: {integrity: sha512-Mh++g+2LPfzZToywfE1BUzvZbfOY52Nil0rn9H1CPC5DJ7fX+Vir7nToBeoiSbB1zTNeGYbELEvJESujgGrzXw==} + baseline-browser-mapping@2.9.2: + resolution: {integrity: sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==} hasBin: true bin-build@3.0.0: @@ -1337,8 +1337,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.263: - resolution: {integrity: sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg==} + electron-to-chromium@1.5.265: + resolution: {integrity: sha512-B7IkLR1/AE+9jR2LtVF/1/6PFhY5TlnEHnlrKmGk7PvkJibg5jr+mLXLLzq3QYl6PA1T/vLDthQPqIPAlS/PPA==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2528,8 +2528,8 @@ packages: react-is@19.2.1: resolution: {integrity: sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==} - react-router@7.10.0: - resolution: {integrity: sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw==} + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -2949,8 +2949,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - update-browserslist-db@1.2.0: - resolution: {integrity: sha512-Dn+NlSF/7+0lVSEZ57SYQg6/E44arLzsVOGgrElBn/BlG1B8WKdbLppOocFrXwRNTkNlgdGNaBgH1o0lggDPiw==} + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3962,7 +3962,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.9.0: {} + baseline-browser-mapping@2.9.2: {} bin-build@3.0.0: dependencies: @@ -4019,11 +4019,11 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.0 + baseline-browser-mapping: 2.9.2 caniuse-lite: 1.0.30001759 - electron-to-chromium: 1.5.263 + electron-to-chromium: 1.5.265 node-releases: 2.0.27 - update-browserslist-db: 1.2.0(browserslist@4.28.1) + update-browserslist-db: 1.2.2(browserslist@4.28.1) buffer-alloc-unsafe@1.1.0: {} @@ -4366,7 +4366,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.263: {} + electron-to-chromium@1.5.265: {} emoji-regex@8.0.0: {} @@ -5531,7 +5531,7 @@ snapshots: react-is@19.2.1: {} - react-router@7.10.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: cookie: 1.1.1 react: 19.2.1 @@ -5940,7 +5940,7 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.2.0(browserslist@4.28.1): + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 From a01f10b04211eacd53076559ce11236a4d571678 Mon Sep 17 00:00:00 2001 From: Jakob Date: Sun, 7 Dec 2025 11:31:55 +0100 Subject: [PATCH 2/3] feat: add /api/system/metrics endpoint --- src/core/emsdevice.cpp | 5 +- src/core/system.cpp | 237 +++++++++++++++++++++++++++++++++++++++++ src/core/system.h | 1 + 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/core/emsdevice.cpp b/src/core/emsdevice.cpp index 59d0a33cf..b5248c545 100644 --- a/src/core/emsdevice.cpp +++ b/src/core/emsdevice.cpp @@ -1773,8 +1773,11 @@ std::string EMSdevice::get_metrics_prometheus(const int8_t tag) { metric_name = metric_name.substr(last_dot + 1); } + // sanitize metric name: convert to lowercase and replace non-alphanumeric with underscores for (char & c : metric_name) { - if (!isalnum(c) && c != '_') { + if (isupper(c)) { + c = tolower(c); + } else if (!isalnum(c) && c != '_') { c = '_'; } } diff --git a/src/core/system.cpp b/src/core/system.cpp index 0d57db613..3f092ff21 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -1465,6 +1465,16 @@ bool System::get_value_info(JsonObject output, const char * cmd) { return command_info("", 0, output); } + // check for metrics + if (!strcmp(cmd, F_(metrics))) { + std::string metrics = get_metrics_prometheus(); + if (!metrics.empty()) { + output["api_data"] = metrics; + return true; + } + return false; + } + // fetch all the data from the system in a different json JsonDocument doc; JsonObject root = doc.to(); @@ -1548,6 +1558,233 @@ void System::get_value_json(JsonObject output, const std::string & circuit, cons } } +// generate Prometheus metrics format from system values +std::string System::get_metrics_prometheus() { + std::string result; + std::unordered_map seen_metrics; + + // get system data + JsonDocument doc; + JsonObject root = doc.to(); + (void)command_info("", 0, root); + + // helper function to escape Prometheus label values + auto escape_label = [](const std::string & str) -> std::string { + std::string escaped; + for (char c : str) { + if (c == '\\') { + escaped += "\\\\"; + } else if (c == '"') { + escaped += "\\\""; + } else if (c == '\n') { + escaped += "\\n"; + } else { + escaped += c; + } + } + return escaped; + }; + + // helper function to sanitize metric name (convert to lowercase and replace dots with underscores) + auto sanitize_name = [](const std::string & name) -> std::string { + std::string sanitized = name; + for (char & c : sanitized) { + if (c == '.') { + c = '_'; + } else if (isupper(c)) { + c = tolower(c); + } else if (!isalnum(c) && c != '_') { + c = '_'; + } + } + return sanitized; + }; + + // helper function to convert label name to lowercase + auto to_lowercase = [](const std::string & str) -> std::string { + std::string result = str; + for (char & c : result) { + if (isupper(c)) { + c = tolower(c); + } + } + return result; + }; + + // helper function to check if a field should be ignored + auto should_ignore = [](const std::string & path, const std::string & key) -> bool { + if (path == "system" && key == "uptime") { + return true; + } + if (path == "ntp" && key == "timestamp") { + return true; + } + if (path.find("devices[") != std::string::npos) { + if (key == "handlersReceived" || key == "handlersFetched" || key == "handlersPending" || key == "handlersIgnored") { + return true; + } + } + return false; + }; + + // helper function to process a JSON object recursively + std::function process_object = + [&](const JsonObject & obj, const std::string & prefix) { + std::vector> local_info_labels; + bool has_nested_objects = false; + + for (JsonPair p : obj) { + std::string key = p.key().c_str(); + std::string path = prefix.empty() ? key : prefix + "." + key; + std::string metric_name = prefix.empty() ? key : prefix + "_" + key; + + if (should_ignore(prefix, key)) { + continue; + } + + if (p.value().is()) { + // recursive call for nested objects + has_nested_objects = true; + process_object(p.value().as(), metric_name); + } else if (p.value().is()) { + // handle arrays (devices) + if (key == "devices") { + JsonArray devices = p.value().as(); + for (JsonObject device : devices) { + std::vector> device_labels; + + // collect labels from device object + for (JsonPair dp : device) { + std::string dkey = dp.key().c_str(); + if (dkey == "type" || dkey == "name" || dkey == "deviceID" || dkey == "brand" || dkey == "version") { + if (dp.value().is()) { + std::string val = dp.value().as(); + if (!val.empty()) { + device_labels.push_back({to_lowercase(dkey), val}); + } + } + } + } + + // create productID metric + if (device.containsKey("productID") && device["productID"].is()) { + std::string metric = "emsesp_device_productid"; + if (seen_metrics.find(metric) == seen_metrics.end()) { + result += "# HELP emsesp_device_productid productID\n"; + result += "# TYPE emsesp_device_productid gauge\n"; + seen_metrics[metric] = true; + } + + result += metric; + if (!device_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : device_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " " + std::to_string(device["productID"].as()) + "\n"; + } + + // create entities metric + if (device.containsKey("entities") && device["entities"].is()) { + std::string metric = "emsesp_device_entities"; + if (seen_metrics.find(metric) == seen_metrics.end()) { + result += "# HELP emsesp_device_entities entities\n"; + result += "# TYPE emsesp_device_entities gauge\n"; + seen_metrics[metric] = true; + } + + result += metric; + if (!device_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : device_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " " + std::to_string(device["entities"].as()) + "\n"; + } + } + } + } else { + // handle primitive values + bool is_number = p.value().is() || p.value().is(); + bool is_bool = p.value().is(); + bool is_string = p.value().is(); + + if (is_number || is_bool) { + // add metric + std::string full_metric_name = "emsesp_" + sanitize_name(metric_name); + if (seen_metrics.find(full_metric_name) == seen_metrics.end()) { + result += "# HELP emsesp_" + sanitize_name(metric_name) + " " + key + "\n"; + result += "# TYPE emsesp_" + sanitize_name(metric_name) + " gauge\n"; + seen_metrics[full_metric_name] = true; + } + + result += full_metric_name + " "; + if (is_bool) { + result += p.value().as() ? "1" : "0"; + } else if (p.value().is()) { + result += std::to_string(p.value().as()); + } else { + char val_str[30]; + snprintf(val_str, sizeof(val_str), "%.2f", p.value().as()); + result += val_str; + } + result += "\n"; + } else if (is_string) { + // collect string for info metric (skip dynamic strings like uptime and timestamp) + std::string val = p.value().as(); + if (!val.empty() && key != "uptime" && key != "timestamp") { + local_info_labels.push_back({to_lowercase(key), val}); + } + } + } + } + + // create _info metric for this object level if we have labels and this is a leaf node (no nested objects) + if (!local_info_labels.empty() && !prefix.empty() && !has_nested_objects) { + std::string info_metric = "emsesp_" + sanitize_name(prefix) + "_info"; + if (seen_metrics.find(info_metric) == seen_metrics.end()) { + result += "# HELP " + info_metric + " info\n"; + result += "# TYPE " + info_metric + " gauge\n"; + seen_metrics[info_metric] = true; + } + + result += info_metric; + if (!local_info_labels.empty()) { + result += "{"; + bool first = true; + for (const auto & label : local_info_labels) { + if (!first) { + result += ", "; + } + result += label.first + "=\"" + escape_label(label.second) + "\""; + first = false; + } + result += "}"; + } + result += " 1\n"; + } + }; + + // process root object + process_object(root, ""); + + return result; +} + // export status information including the device information // http://ems-esp/api/system/info bool System::command_info(const char * value, const int8_t id, JsonObject output) { diff --git a/src/core/system.h b/src/core/system.h index 796ca5c7b..31df1d0a0 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -93,6 +93,7 @@ class System { static bool get_value_info(JsonObject root, const char * cmd); static void get_value_json(JsonObject output, const std::string & circuit, const std::string & name, JsonVariant val); + static std::string get_metrics_prometheus(); #if defined(EMSESP_TEST) static bool command_test(const char * value, const int8_t id); From bffccc585a771692a18c3d11c295bcd840c5093a Mon Sep 17 00:00:00 2001 From: Jakob Date: Sun, 7 Dec 2025 11:32:37 +0100 Subject: [PATCH 3/3] test: add tests for /api/system/metrics endpoint --- test/test_api/test_api.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/test_api/test_api.cpp b/test/test_api/test_api.cpp index 66b90b754..2482f1dfb 100644 --- a/test/test_api/test_api.cpp +++ b/test/test_api/test_api.cpp @@ -318,6 +318,32 @@ void manual_test9() { } } +void manual_test10() { + const char * response = call_url("/api/system/metrics"); + + TEST_ASSERT_NOT_NULL(response); + TEST_ASSERT_TRUE(strlen(response) > 0); + + TEST_ASSERT_TRUE(strstr(response, "# HELP") != nullptr); + TEST_ASSERT_TRUE(strstr(response, "# TYPE") != nullptr); + TEST_ASSERT_TRUE(strstr(response, "emsesp_") != nullptr); + TEST_ASSERT_TRUE(strstr(response, " gauge") != nullptr); + + // Check for some expected system metrics + TEST_ASSERT_TRUE(strstr(response, "emsesp_system_") != nullptr || strstr(response, "emsesp_network_") != nullptr + || strstr(response, "emsesp_api_") != nullptr); + + // Check for _info metrics if present + if (strstr(response, "_info") != nullptr) { + TEST_ASSERT_TRUE(strstr(response, "_info{") != nullptr || strstr(response, "_info ") != nullptr); + } + + // Check for device metrics if devices are present + if (strstr(response, "device") != nullptr) { + TEST_ASSERT_TRUE(strstr(response, "emsesp_device_") != nullptr); + } +} + void run_manual_tests() { RUN_TEST(manual_test1); RUN_TEST(manual_test2); @@ -328,6 +354,7 @@ void run_manual_tests() { RUN_TEST(manual_test7); RUN_TEST(manual_test8); RUN_TEST(manual_test9); + RUN_TEST(manual_test10); } const char * run_console_command(const char * command) { @@ -411,6 +438,7 @@ void create_tests() { // system capture("/api/system"); capture("/api/system/info"); + capture("/api/system/metrics"); capture("/api/system/settings/locale"); capture("/api/system/fetch"); capture("/api/system/network/values");