This commit is contained in:
MichaelDvP
2025-12-11 16:26:16 +01:00
6 changed files with 288 additions and 19 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -1766,8 +1766,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 = '_';
}
}

View File

@@ -1470,6 +1470,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<JsonObject>();
@@ -1553,6 +1563,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<std::string, bool> seen_metrics;
// get system data
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
(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<void(const JsonObject &, const std::string &)> process_object =
[&](const JsonObject & obj, const std::string & prefix) {
std::vector<std::pair<std::string, std::string>> 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<JsonObject>()) {
// recursive call for nested objects
has_nested_objects = true;
process_object(p.value().as<JsonObject>(), metric_name);
} else if (p.value().is<JsonArray>()) {
// handle arrays (devices)
if (key == "devices") {
JsonArray devices = p.value().as<JsonArray>();
for (JsonObject device : devices) {
std::vector<std::pair<std::string, std::string>> 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<const char *>()) {
std::string val = dp.value().as<const char *>();
if (!val.empty()) {
device_labels.push_back({to_lowercase(dkey), val});
}
}
}
}
// create productID metric
if (device.containsKey("productID") && device["productID"].is<int>()) {
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<int>()) + "\n";
}
// create entities metric
if (device.containsKey("entities") && device["entities"].is<int>()) {
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<int>()) + "\n";
}
}
}
} else {
// handle primitive values
bool is_number = p.value().is<int>() || p.value().is<float>();
bool is_bool = p.value().is<bool>();
bool is_string = p.value().is<const char *>();
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<bool>() ? "1" : "0";
} else if (p.value().is<int>()) {
result += std::to_string(p.value().as<int>());
} else {
char val_str[30];
snprintf(val_str, sizeof(val_str), "%.2f", p.value().as<float>());
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<const char *>();
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) {

View File

@@ -94,6 +94,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);

View File

@@ -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");