diff --git a/README.md b/README.md index b1376bc..5f153c4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ TianshanOS 是 RM-01 的设备管理系统,运行在 ESP32 带外管理控制 2. 在同一网络中的电脑上打开浏览器,输入设备的 IP 地址。 3. 使用设备账号登录,在首页查看状态并打开需要的功能页面。 -终端、指令和自动化页面需要 root 权限。配置保存、SD 卡备份和固件更新的操作步骤,请参阅用户说明。更新 0.6.1 固件时,应用固件和 Web 界面资源应使用同一版本。 +终端、指令和自动化页面需要 root 权限。配置保存、SD 卡备份和固件更新的操作步骤,请参阅用户说明。更新 0.6.2 固件时,应用固件和 Web 界面资源应使用同一版本。 ## 用户说明 diff --git a/README_EN.md b/README_EN.md index 355fa58..4c6c197 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,7 +6,7 @@ TianshanOS is the device management system for RM-01. It runs on the ESP32 out-of-band management controller. Use a browser to check device status, manage power, fans and lighting, configure networking, and set up SSH commands and automation rules. -**Current version: 0.6.1** · Web interface in Chinese and English · [0.6.1 release notes (Chinese)](docs/releases/v0.6.1.md) +**Current version: 0.6.2** · Web interface in Chinese and English · [0.6.2 release notes (Chinese)](docs/releases/v0.6.2.md) ## Features @@ -25,7 +25,7 @@ TianshanOS is the device management system for RM-01. It runs on the ESP32 out-o 2. Open a browser on a computer connected to the same network and enter the device's IP address. 3. Sign in with your device account. Check status on the dashboard and open the page you need. -The Terminal, SSH Commands and Automation pages require root access. See the user guide for configuration saving, SD card backups and firmware updates. When updating to 0.6.1, use matching versions of the application firmware and Web interface resources. +The Terminal, SSH Commands and Automation pages require root access. See the user guide for configuration saving, SD card backups and firmware updates. When updating to 0.6.2, use matching versions of the application firmware and Web interface resources. ## User guides diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin index a75f629..86014c9 100644 Binary files a/build/TianShanOS.bin and b/build/TianShanOS.bin differ diff --git a/build/bootloader/bootloader.bin b/build/bootloader/bootloader.bin index 1aa132c..cc201e2 100644 Binary files a/build/bootloader/bootloader.bin and b/build/bootloader/bootloader.bin differ diff --git a/build/firmware-manifest.json b/build/firmware-manifest.json index e5942cb..3108017 100644 --- a/build/firmware-manifest.json +++ b/build/firmware-manifest.json @@ -1,17 +1,17 @@ { - "version": "0.6.1", - "build_version": "0.6.1+73069d24d.10090811", + "version": "0.6.2", + "build_version": "0.6.2+774c3a97.10130158", "idf_version": "5.5.2", - "source_base_commit": "73069d24da043a124d89028d25a5e4659d736652", - "source_inputs_sha256": "62e8ba896b9dff39133cecebf677da7c662b850d18981bd1323c0b053e7b1f2d", + "source_base_commit": "774c3a975311604fe629c34ed6600d7fcebcc93b", + "source_inputs_sha256": "8a4a1fdf9f1f6bd492866af1d88620502e2965ce31bdb512798196d5dc8e3836", "files": { "TianShanOS.bin": { - "bytes": 2202336, - "sha256": "15f99d11092b91255d8fa24b632316b217d6efeb3acc3e20ed290e0e9fd54378" + "bytes": 2226000, + "sha256": "064564898345b1bc2adaac04650fe5290a74a0ef5e5c1d1953d0c5d7a06ab17f" }, "www.bin": { "bytes": 3145728, - "sha256": "feb4bc6f737aff611e534ca439990c99233b7a8879485d65a6e47e82bf075111" + "sha256": "a33d3be59eb2dd293735c633052e53aa14fd0cddc62f621a49326f0056c23a3b" }, "ota_data_initial.bin": { "bytes": 8192, @@ -23,7 +23,7 @@ }, "bootloader/bootloader.bin": { "bytes": 22496, - "sha256": "ac6d47f0b046fe2cb91b0bc86f9924885c8ac96d20da87b4d9841ec8c14ee3d8" + "sha256": "106e473278fbefdce277341a2e77a097deea877ccc525ec2b7623a650397b851" }, "partition_table/partition-table.bin": { "bytes": 3072, diff --git a/build/www.bin b/build/www.bin index 8479d82..0280142 100644 Binary files a/build/www.bin and b/build/www.bin differ diff --git a/components/ts_api/include/ts_api.h b/components/ts_api/include/ts_api.h index e9c3801..8143bb8 100644 --- a/components/ts_api/include/ts_api.h +++ b/components/ts_api/include/ts_api.h @@ -216,6 +216,21 @@ void ts_api_result_ok(ts_api_result_t *result, cJSON *data); void ts_api_result_error(ts_api_result_t *result, ts_api_result_code_t code, const char *message); +/** + * @brief Set result error with a reason the Web UI can state exactly + * + * The reason token (lower_snake, e.g. "ssh_auth_failed") becomes the message: the page translates it into the user's + * language. `detail` (optional) goes to data.detail and is only shown in the page's folded details. The numeric `code` + * is still the nearest error class, so an older page degrades to the class sentence. + * + * @param result Result structure + * @param code Nearest error class + * @param token Reason token + * @param detail Optional technical detail (the device's own text), may be NULL + */ +void ts_api_result_reason(ts_api_result_t *result, ts_api_result_code_t code, + const char *token, const char *detail); + /** * @brief Convert result to JSON * diff --git a/components/ts_api/src/ts_api.c b/components/ts_api/src/ts_api.c index 9bf9b8b..c48f4b8 100644 --- a/components/ts_api/src/ts_api.c +++ b/components/ts_api/src/ts_api.c @@ -449,6 +449,20 @@ void ts_api_result_error(ts_api_result_t *result, ts_api_result_code_t code, } } +void ts_api_result_reason(ts_api_result_t *result, ts_api_result_code_t code, + const char *token, const char *detail) +{ + ts_api_result_error(result, code, token); + if (result && detail && detail[0]) { + cJSON *data = cJSON_CreateObject(); + if (data && cJSON_AddStringToObject(data, "detail", detail)) { + result->data = data; + } else { + cJSON_Delete(data); + } + } +} + cJSON *ts_api_result_to_json(const ts_api_result_t *result) { if (result == NULL) { diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index a8c42cb..2ca6da3 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -29,6 +29,7 @@ #include "ts_temp_source.h" #include "ts_action_manager.h" #include "ts_config_pack.h" +#include "ts_api_pack.h" #include "ts_cert.h" #include "ts_log.h" #include "cJSON.h" @@ -37,6 +38,7 @@ #include "esp_log.h" #include "esp_timer.h" #include +#include #include #include #include @@ -122,6 +124,32 @@ static esp_err_t api_automation_status(const cJSON *params, ts_api_result_t *res /* Control API */ /*===========================================================================*/ +/** The remote server answered the proxied request with an error status: the page gets the status as a number and the start + * of the body (a few lines at most) as detail for the folded details. */ +static void proxy_http_error(ts_api_result_t *result, int status, const char *body) +{ + char detail[161] = {0}; + if (body) snprintf(detail, sizeof(detail), "%.160s", body); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "proxy_http_error", detail); + if (!result->data) result->data = cJSON_CreateObject(); + if (result->data) cJSON_AddNumberToObject(result->data, "status", status); +} + +static ts_automation_state_t automation_state(void) +{ + ts_automation_status_t status = {0}; + return ts_automation_get_status(&status) == ESP_OK ? status.state : TS_AUTO_STATE_UNINITIALIZED; +} + +/* Why the engine refuses a start (or, with `pausing`, a pause) in its current state. A paused engine cannot be started + * again until it is stopped, and a stopped one cannot be paused. */ +static const char *engine_refusal_token(ts_automation_state_t state, bool pausing) +{ + if (state == TS_AUTO_STATE_PAUSED) return "engine_paused"; + if (pausing && state != TS_AUTO_STATE_RUNNING) return "engine_not_running"; + return "engine_unavailable"; /* stopping, still busy with actions, or not initialized */ +} + /** * @brief automation.start - Start the automation engine */ @@ -133,8 +161,9 @@ static esp_err_t api_automation_start(const cJSON *params, ts_api_result_t *resu result->code = TS_API_OK; result->message = strdup("Automation engine started"); } else if (ret == ESP_ERR_INVALID_STATE) { - result->code = TS_API_ERR_INVALID_ARG; - result->message = strdup("Engine is stopping, busy, or not initialized"); + ts_api_result_reason(result, TS_API_ERR_BUSY, engine_refusal_token(automation_state(), false), NULL); + } else if (ret == ESP_ERR_NO_MEM) { + ts_api_result_reason(result, TS_API_ERR_NO_MEM, "no_memory", NULL); } else { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Failed to start automation engine"); @@ -153,11 +182,11 @@ static esp_err_t api_automation_stop(const cJSON *params, ts_api_result_t *resul if (ret == ESP_OK) { result->code = TS_API_OK; result->message = strdup("Automation engine stopped"); + } else if (ret == ESP_ERR_TIMEOUT) { + ts_api_result_reason(result, TS_API_ERR_BUSY, "engine_stop_timeout", NULL); } else { result->code = TS_API_ERR_INTERNAL; - result->message = strdup(ret == ESP_ERR_TIMEOUT - ? "Automation is stopping; retry after active operations finish" - : "Failed to stop automation engine"); + result->message = strdup("Failed to stop automation engine"); } return ESP_OK; @@ -173,6 +202,8 @@ static esp_err_t api_automation_pause(const cJSON *params, ts_api_result_t *resu if (ret == ESP_OK) { result->code = TS_API_OK; result->message = strdup("Automation engine paused"); + } else if (ret == ESP_ERR_INVALID_STATE) { + ts_api_result_reason(result, TS_API_ERR_BUSY, engine_refusal_token(automation_state(), true), NULL); } else { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Failed to pause automation engine"); @@ -213,6 +244,12 @@ static esp_err_t api_automation_reload(const cJSON *params, ts_api_result_t *res if (ret == ESP_OK) { result->code = TS_API_OK; result->message = strdup("Configuration reloaded"); + } else if (ret == ESP_ERR_INVALID_STATE) { + ts_api_result_reason(result, TS_API_ERR_BUSY, "engine_unavailable", NULL); + } else if (ret == ESP_ERR_INVALID_SIZE || ret == ESP_ERR_INVALID_ARG) { /* the engine settings file is empty, too big or not JSON */ + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "config_file_invalid", esp_err_to_name(ret)); + } else if (ret == ESP_ERR_NO_MEM) { + ts_api_result_reason(result, TS_API_ERR_NO_MEM, "no_memory", NULL); } else { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Failed to reload configuration"); @@ -697,6 +734,37 @@ static esp_err_t api_service_stop(const cJSON *params, ts_api_result_t *result) return service_request(params, result, true); } +/* Reason tokens for a refused manual trigger; the Web UI translates them (failure.reasons.*). */ +static const char *trigger_reject_token(ts_rule_reject_t why) +{ + switch (why) { + case TS_RULE_REJECT_NOT_ACCEPTING: return "engine_not_accepting"; + case TS_RULE_REJECT_DISABLED: return "rule_disabled"; + case TS_RULE_REJECT_MANUAL_NOT_ALLOWED: return "manual_not_allowed"; + case TS_RULE_REJECT_NOT_LOADED: return "config_loading"; + case TS_RULE_REJECT_RECOVERY: return "recovery_required"; + case TS_RULE_REJECT_CONFIG_BUSY: return "configuration_busy"; + case TS_RULE_REJECT_RUNNING: return "execution_busy"; + case TS_RULE_REJECT_SERVICE_STATE: return "service_not_admissible"; + case TS_RULE_REJECT_CHANGED: return "rule_changed"; + case TS_RULE_REJECT_PENDING_DELETE: return "rule_pending_delete"; + default: return NULL; + } +} + +/* "Try again later" refusals are BUSY, so an older Web UI still says so; the others carry no better class than INTERNAL. */ +static ts_api_result_code_t trigger_reject_code(ts_rule_reject_t why) +{ + switch (why) { + case TS_RULE_REJECT_NOT_ACCEPTING: + case TS_RULE_REJECT_NOT_LOADED: + case TS_RULE_REJECT_CONFIG_BUSY: + case TS_RULE_REJECT_RUNNING: + case TS_RULE_REJECT_CHANGED: return TS_API_ERR_BUSY; + default: return TS_API_ERR_INTERNAL; + } +} + static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result_t *result) { cJSON *id_param = cJSON_GetObjectItem(params, "id"); @@ -707,8 +775,9 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result } ESP_LOGI("api_auto", "Calling ts_rule_trigger('%s')", id_param->valuestring); - esp_err_t ret = ts_rule_trigger(id_param->valuestring); - ESP_LOGI("api_auto", "ts_rule_trigger returned: %s", esp_err_to_name(ret)); + ts_rule_reject_t why = TS_RULE_REJECT_NONE; + esp_err_t ret = ts_rule_trigger_ex(id_param->valuestring, &why); + ESP_LOGI("api_auto", "ts_rule_trigger returned: %s (refusal %d)", esp_err_to_name(ret), (int)why); if (ret == ESP_OK) { result->code = TS_API_OK; @@ -716,6 +785,9 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result } else if (ret == ESP_ERR_NOT_FOUND) { result->code = TS_API_ERR_NOT_FOUND; result->message = strdup("Rule not found"); + } else if (trigger_reject_token(why)) { + result->code = trigger_reject_code(why); + result->message = strdup(trigger_reject_token(why)); } else { result->code = TS_API_ERR_INTERNAL; result->data=cJSON_CreateObject(); @@ -861,6 +933,25 @@ static ts_auto_source_type_t parse_source_type(const char *type_str) return TS_AUTO_SRC_REST; } +/** The reply to registering a data source. ts_source_register() updates a source that already has this id, so the only + * refusals are a full table and a manager that is not initialized yet. */ +static void source_register_reply(ts_api_result_t *result, esp_err_t ret, const ts_auto_source_t *source) +{ + if (ret == ESP_OK) { + result->code = TS_API_OK; + result->message = strdup("Source created successfully"); + result->data = cJSON_CreateObject(); + cJSON_AddStringToObject(result->data, "id", source->id); + } else if (ret == ESP_ERR_NO_MEM) { + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "source_capacity", NULL); + } else if (ret == ESP_ERR_INVALID_STATE) { + ts_api_result_reason(result, TS_API_ERR_BUSY, "engine_unavailable", NULL); + } else { + result->code = TS_API_ERR_INTERNAL; + result->message = strdup("Failed to create source"); + } +} + /** * @brief automation.sources.add - Add a new data source */ @@ -1291,22 +1382,7 @@ static esp_err_t api_automation_sources_add(const cJSON *params, ts_api_result_t } esp_err_t ret = ts_source_register(source); - - if (ret == ESP_OK) { - result->code = TS_API_OK; - result->message = strdup("Source created successfully"); - result->data = cJSON_CreateObject(); - cJSON_AddStringToObject(result->data, "id", source->id); - } else if (ret == ESP_ERR_NO_MEM) { - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("No memory for new source"); - } else if (ret == ESP_ERR_INVALID_STATE) { - result->code = TS_API_ERR_INVALID_ARG; - result->message = strdup("Source with this ID already exists"); - } else { - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to create source"); - } + source_register_reply(result, ret, source); free(source); return ESP_OK; @@ -1856,6 +1932,29 @@ static esp_err_t api_automation_actions_list(const cJSON *params, ts_api_result_ return ESP_OK; } +/** The reply to creating / updating an action template, from what the template store returned. */ +static void action_template_write_reply(ts_api_result_t *result, esp_err_t ret, bool update) +{ + if (ret == ESP_OK) { + result->code = TS_API_OK; + result->message = strdup(update ? "Action template updated" : "Action template created"); + } else if (ret == ESP_ERR_NO_MEM) { /* the store is full (TS_ACTION_TEMPLATE_MAX), not out of memory */ + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "template_capacity", NULL); + } else if (ret == ESP_ERR_INVALID_STATE) { + if (update) { + ts_api_result_reason(result, TS_API_ERR_BUSY, "service_delete_protected", NULL); + } else { + ts_api_result_reason(result, TS_API_ERR_INVALID_ARG, "template_exists", NULL); + } + } else if (ret == ESP_ERR_NOT_FOUND) { + result->code = TS_API_ERR_NOT_FOUND; + result->message = strdup("Template not found"); + } else { + result->code = TS_API_ERR_INTERNAL; + result->message = strdup(update ? "Failed to update template" : "Failed to create template"); + } +} + /** * @brief automation.actions.add - Add a new action template */ @@ -1990,7 +2089,7 @@ static esp_err_t api_automation_actions_write(const cJSON *params, ts_api_result cJSON *filter = cJSON_GetObjectItem(led, "filter"); if (ts_action_filter_decode(cJSON_GetObjectItemCaseSensitive(led, "filter_params"), &tpl.action.led.filter_params) != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid LED filter parameters"); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "led_filter_invalid"); return ESP_ERR_INVALID_ARG; } if (filter && cJSON_IsString(filter)) { @@ -2182,23 +2281,7 @@ static esp_err_t api_automation_actions_write(const cJSON *params, ts_api_result } esp_err_t ret = update ? ts_action_template_update(tpl.id, &tpl) : ts_action_template_add(&tpl); - - if (ret == ESP_OK) { - result->code = TS_API_OK; - result->message = strdup(update ? "Action template updated" : "Action template created"); - } else if (ret == ESP_ERR_NO_MEM) { - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Max templates reached"); - } else if (ret == ESP_ERR_INVALID_STATE) { - result->code = update ? TS_API_ERR_BUSY : TS_API_ERR_INVALID_ARG; - result->message = strdup(update ? "service_delete_protected" : "Template ID already exists"); - } else if (ret == ESP_ERR_NOT_FOUND) { - result->code = TS_API_ERR_NOT_FOUND; - result->message = strdup("Template not found"); - } else { - result->code = TS_API_ERR_INTERNAL; - result->message = strdup(update ? "Failed to update template" : "Failed to create template"); - } + action_template_write_reply(result, ret, update); return ESP_OK; } @@ -2420,6 +2503,32 @@ static esp_err_t api_automation_actions_delete(const cJSON *params, ts_api_resul return ESP_OK; } +/** The reply when an action template did not run to success. The page gets a reason; whatever the action itself printed + * (or, without output, the status) goes to data.detail for the folded details, and data.output as before. */ +static void action_execute_failure(ts_api_result_t *result, esp_err_t ret, const ts_action_result_t *exec) +{ + char fallback[128]; + const char *detail = exec->output; + if (ret == ESP_ERR_NOT_FOUND) { + result->code = TS_API_ERR_NOT_FOUND; + free(result->message); + result->message = strdup("Template not found"); + return; + } + if (!detail[0]) { + snprintf(fallback, sizeof(fallback), "Action failed: %s (status=%d)", esp_err_to_name(ret), exec->status); + detail = fallback; + } + result->code = TS_API_ERR_INTERNAL; + free(result->message); + result->message = strdup(exec->status == TS_ACTION_STATUS_TIMEOUT ? "action_timeout" : "action_failed"); + if (!result->data) result->data = cJSON_CreateObject(); + if (result->data) { + if (exec->output[0]) cJSON_AddStringToObject(result->data, "output", exec->output); + cJSON_AddStringToObject(result->data, "detail", detail); + } +} + /** * @brief automation.actions.execute - Execute an action template */ @@ -2458,16 +2567,7 @@ static esp_err_t api_automation_actions_execute(const cJSON *params, ts_api_resu cJSON_AddStringToObject(result->data, "output", exec_result.output); } } else { - result->code = TS_API_ERR_INTERNAL; - if (exec_result.output[0]) { - result->message = strdup(exec_result.output); - cJSON_AddStringToObject(result->data, "output", exec_result.output); - } else { - char err_msg[128]; - snprintf(err_msg, sizeof(err_msg), "Action failed: %s (status=%d)", - esp_err_to_name(ret), exec_result.status); - result->message = strdup(err_msg); - } + action_execute_failure(result, ret, &exec_result); } return ESP_OK; @@ -2638,8 +2738,7 @@ static esp_err_t api_automation_proxy_fetch(const cJSON *params, ts_api_result_t esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) { - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to init HTTP client"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "proxy_init_failed", NULL); return ESP_OK; } @@ -2657,10 +2756,7 @@ static esp_err_t api_automation_proxy_fetch(const cJSON *params, ts_api_result_t esp_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { esp_http_client_cleanup(client); - result->code = TS_API_ERR_INTERNAL; - char msg[128]; - snprintf(msg, sizeof(msg), "HTTP connection failed: %s", esp_err_to_name(err)); - result->message = strdup(msg); + ts_api_result_reason(result, TS_API_ERR_CONNECTION, "proxy_connect_failed", esp_err_to_name(err)); return ESP_OK; } @@ -2705,10 +2801,7 @@ static esp_err_t api_automation_proxy_fetch(const cJSON *params, ts_api_result_t result->message = strdup("Request successful"); result->data = data; } else { - result->code = TS_API_ERR_INTERNAL; - char msg[256]; - snprintf(msg, sizeof(msg), "HTTP %d: %s", status_code, body ? body : "(empty)"); - result->message = strdup(msg); + proxy_http_error(result, status_code, body); } if (body) free(body); @@ -2907,7 +3000,7 @@ static void ws_test_handler(void *handler_args, esp_event_base_t base, int32_t e case WEBSOCKET_EVENT_ERROR: TS_LOGE(TAG, "WS test: error"); - ctx->error = strdup("WebSocket error occurred"); + ctx->error = strdup("ws_connect_failed"); // 释放所有信号量 if (ctx->connect_sem) xSemaphoreGive(ctx->connect_sem); if (ctx->data_sem) xSemaphoreGive(ctx->data_sem); @@ -2966,8 +3059,8 @@ static esp_err_t api_automation_proxy_ws_test(const cJSON *params, ts_api_result if (!ctx.connect_sem || !ctx.data_sem) { if (ctx.connect_sem) vSemaphoreDelete(ctx.connect_sem); if (ctx.data_sem) vSemaphoreDelete(ctx.data_sem); - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to create semaphores"); + result->code = TS_API_ERR_NO_MEM; + result->message = strdup("no_memory"); return ESP_OK; } @@ -2984,7 +3077,7 @@ static esp_err_t api_automation_proxy_ws_test(const cJSON *params, ts_api_result vSemaphoreDelete(ctx.connect_sem); vSemaphoreDelete(ctx.data_sem); result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to init WebSocket client"); + result->message = strdup("proxy_init_failed"); return ESP_OK; } @@ -2996,10 +3089,7 @@ static esp_err_t api_automation_proxy_ws_test(const cJSON *params, ts_api_result esp_websocket_client_destroy(client); vSemaphoreDelete(ctx.connect_sem); vSemaphoreDelete(ctx.data_sem); - result->code = TS_API_ERR_INTERNAL; - char msg[128]; - snprintf(msg, sizeof(msg), "WebSocket start failed: %s", esp_err_to_name(err)); - result->message = strdup(msg); + ts_api_result_reason(result, TS_API_ERR_CONNECTION, "ws_start_failed", esp_err_to_name(err)); return ESP_OK; } @@ -3016,7 +3106,7 @@ static esp_err_t api_automation_proxy_ws_test(const cJSON *params, ts_api_result result->message = ctx.error; ctx.error = NULL; } else { - result->message = strdup("WebSocket connection timeout"); + result->message = strdup("ws_connect_timeout"); } cJSON_AddStringToObject(data, "error", result->message); result->data = data; @@ -3071,7 +3161,7 @@ static esp_err_t api_automation_proxy_ws_test(const cJSON *params, ts_api_result } else { // 未收到数据 result->code = TS_API_ERR_INTERNAL; - result->message = strdup("WebSocket connected but no data received"); + result->message = strdup("ws_no_data"); cJSON_AddStringToObject(data, "error", "No data received within timeout"); } @@ -3386,8 +3476,8 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ if (ctx.connect_sem) vSemaphoreDelete(ctx.connect_sem); if (ctx.upgrade_sem) vSemaphoreDelete(ctx.upgrade_sem); if (ctx.data_sem) vSemaphoreDelete(ctx.data_sem); - result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to create semaphores"); + result->code = TS_API_ERR_NO_MEM; + result->message = strdup("no_memory"); return ESP_OK; } @@ -3407,7 +3497,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ esp_http_client_handle_t http_client = esp_http_client_init(&http_config); if (!http_client) { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to init HTTP client"); + result->message = strdup("proxy_init_failed"); goto cleanup; } @@ -3415,7 +3505,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ if (err != ESP_OK) { esp_http_client_cleanup(http_client); result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Socket.IO polling request failed"); + result->message = strdup("proxy_connect_failed"); cJSON_AddBoolToObject(result_data, "connected", false); cJSON_AddStringToObject(result_data, "error", "HTTP polling failed"); result->data = result_data; @@ -3457,7 +3547,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ if (!ctx.sid) { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to get Socket.IO session"); + result->message = strdup("sio_no_session"); cJSON_AddBoolToObject(result_data, "connected", false); cJSON_AddStringToObject(result_data, "error", "No session ID received"); result->data = result_data; @@ -3490,7 +3580,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ esp_websocket_client_handle_t ws_client = esp_websocket_client_init(&ws_cfg); if (!ws_client) { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to init WebSocket client"); + result->message = strdup("proxy_init_failed"); cJSON_AddBoolToObject(result_data, "connected", false); result->data = result_data; goto cleanup; @@ -3502,7 +3592,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ if (err != ESP_OK) { esp_websocket_client_destroy(ws_client); result->code = TS_API_ERR_INTERNAL; - result->message = strdup("WebSocket start failed"); + result->message = strdup("ws_start_failed"); cJSON_AddBoolToObject(result_data, "connected", false); result->data = result_data; goto cleanup; @@ -3515,7 +3605,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ esp_websocket_client_stop(ws_client); esp_websocket_client_destroy(ws_client); result->code = TS_API_ERR_INTERNAL; - result->message = strdup("WebSocket connection timeout"); + result->message = strdup("ws_connect_timeout"); cJSON_AddBoolToObject(result_data, "connected", false); cJSON_AddStringToObject(result_data, "error", "WebSocket connection failed"); result->data = result_data; @@ -3595,7 +3685,7 @@ static esp_err_t api_automation_proxy_socketio_test(const cJSON *params, ts_api_ result->message = strdup("Socket.IO test successful"); } else { result->code = TS_API_ERR_TIMEOUT; - result->message = strdup("Socket.IO event timeout"); + result->message = strdup("sio_event_timeout"); cJSON_AddStringToObject(result_data, "error", target_event ? "Target event not received" : "No event received"); } @@ -3712,8 +3802,7 @@ static esp_err_t api_automation_sources_export(const cJSON *params, ts_api_resul } if (!ts_config_pack_can_export()) { - ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, - "This device is not authorized to export config packs"); + ts_api_result_reason(result, TS_API_ERR_NO_PERMISSION, "pack_not_allowed", NULL); return ESP_OK; } @@ -3735,7 +3824,7 @@ static esp_err_t api_automation_sources_export(const cJSON *params, ts_api_resul char *json_str = cJSON_PrintUnformatted(export_json); cJSON_Delete(export_json); if (!json_str) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize config"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); /* the serialized text could not be built */ return ESP_OK; } @@ -3752,7 +3841,7 @@ static esp_err_t api_automation_sources_export(const cJSON *params, ts_api_resul cert_pem = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM); if (!cert_pem) { cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } cert_len = 4096; @@ -3760,7 +3849,7 @@ static esp_err_t api_automation_sources_export(const cJSON *params, ts_api_resul if (ret != ESP_OK) { free(cert_pem); cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get device certificate"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "device_cert_unavailable", esp_err_to_name(ret)); return ESP_OK; } free_cert = true; @@ -3782,7 +3871,7 @@ static esp_err_t api_automation_sources_export(const cJSON *params, ts_api_resul if (free_cert) free(cert_pem); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config pack"); + pack_failure(result, pack_result); return ESP_OK; } @@ -3826,13 +3915,7 @@ static esp_err_t api_automation_sources_import(const cJSON *params, ts_api_resul tscfg->valuestring, strlen(tscfg->valuestring), &sig_info); if (pack_result != TS_CONFIG_PACK_OK) { - const char *err_msg = "Failed to verify config pack"; - if (pack_result == TS_CONFIG_PACK_ERR_RECIPIENT) { - err_msg = "This config pack is not for this device"; - } else if (pack_result == TS_CONFIG_PACK_ERR_SIGNATURE) { - err_msg = "Invalid signature"; - } - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, err_msg); + pack_failure(result, pack_result); return ESP_OK; } @@ -3898,7 +3981,7 @@ static esp_err_t api_automation_sources_import(const cJSON *params, ts_api_resul snprintf(filepath, sizeof(filepath), "%s/%s.tscfg", SOURCES_SDCARD_DIR, config_id); FILE *f = fopen(filepath, "w"); if (!f) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } @@ -3907,7 +3990,7 @@ static esp_err_t api_automation_sources_import(const cJSON *params, ts_api_resul if (written != strlen(tscfg->valuestring)) { unlink(filepath); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to write config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", "short write"); return ESP_OK; } @@ -3955,8 +4038,7 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ } if (!ts_config_pack_can_export()) { - ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, - "This device is not authorized to export config packs"); + ts_api_result_reason(result, TS_API_ERR_NO_PERMISSION, "pack_not_allowed", NULL); return ESP_OK; } @@ -3983,7 +4065,7 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ char *json_str = cJSON_PrintUnformatted(export_json); cJSON_Delete(export_json); if (!json_str) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize config"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); /* the serialized text could not be built */ return ESP_OK; } @@ -4000,7 +4082,7 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ cert_pem = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM); if (!cert_pem) { cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } cert_len = 4096; @@ -4008,7 +4090,7 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ if (ret != ESP_OK) { free(cert_pem); cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get device certificate"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "device_cert_unavailable", esp_err_to_name(ret)); return ESP_OK; } free_cert = true; @@ -4030,7 +4112,7 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ if (free_cert) free(cert_pem); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config pack"); + pack_failure(result, pack_result); return ESP_OK; } @@ -4200,8 +4282,7 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul } if (!ts_config_pack_can_export()) { - ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, - "This device is not authorized to export config packs"); + ts_api_result_reason(result, TS_API_ERR_NO_PERMISSION, "pack_not_allowed", NULL); return ESP_OK; } @@ -4225,7 +4306,7 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul char *json_str = cJSON_PrintUnformatted(export_json); cJSON_Delete(export_json); if (!json_str) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize config"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); /* the serialized text could not be built */ return ESP_OK; } @@ -4242,7 +4323,7 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul cert_pem = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM); if (!cert_pem) { cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } cert_len = 4096; @@ -4250,7 +4331,7 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul if (ret != ESP_OK) { free(cert_pem); cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get device certificate"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "device_cert_unavailable", esp_err_to_name(ret)); return ESP_OK; } free_cert = true; @@ -4272,7 +4353,7 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul if (free_cert) free(cert_pem); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config pack"); + pack_failure(result, pack_result); return ESP_OK; } @@ -4316,13 +4397,7 @@ static esp_err_t api_automation_actions_import(const cJSON *params, ts_api_resul tscfg->valuestring, strlen(tscfg->valuestring), &sig_info); if (pack_result != TS_CONFIG_PACK_OK) { - const char *err_msg = "Failed to verify config pack"; - if (pack_result == TS_CONFIG_PACK_ERR_RECIPIENT) { - err_msg = "This config pack is not for this device"; - } else if (pack_result == TS_CONFIG_PACK_ERR_SIGNATURE) { - err_msg = "Invalid signature"; - } - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, err_msg); + pack_failure(result, pack_result); return ESP_OK; } @@ -4387,7 +4462,7 @@ static esp_err_t api_automation_actions_import(const cJSON *params, ts_api_resul snprintf(filepath, sizeof(filepath), "%s/%s.tscfg", ACTIONS_SDCARD_DIR, config_id); FILE *f = fopen(filepath, "w"); if (!f) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } @@ -4396,7 +4471,7 @@ static esp_err_t api_automation_actions_import(const cJSON *params, ts_api_resul if (written != strlen(tscfg->valuestring)) { unlink(filepath); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to write config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", "short write"); return ESP_OK; } diff --git a/components/ts_api/src/ts_api_config_pack.c b/components/ts_api/src/ts_api_config_pack.c index 7705899..acc3cbe 100644 --- a/components/ts_api/src/ts_api_config_pack.c +++ b/components/ts_api/src/ts_api_config_pack.c @@ -11,6 +11,7 @@ #include "ts_api.h" #include "ts_config_pack.h" +#include "ts_api_pack.h" #include "ts_cert.h" #include "ts_log.h" #include "ts_storage.h" @@ -175,6 +176,7 @@ static esp_err_t api_config_pack_verify(const cJSON *params, ts_api_result_t *re cJSON_AddBoolToObject(data, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *sig = cJSON_CreateObject(); @@ -232,6 +234,7 @@ static esp_err_t api_config_pack_import(const cJSON *params, ts_api_result_t *re cJSON_AddBoolToObject(data, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); cJSON_AddStringToObject(data, "path", path->valuestring); if (pack_result == TS_CONFIG_PACK_OK) { @@ -297,6 +300,7 @@ static esp_err_t api_config_pack_apply(const cJSON *params, ts_api_result_t *res cJSON_AddBoolToObject(data, "success", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); cJSON_AddStringToObject(data, "path", path->valuestring); if (pack_result == TS_CONFIG_PACK_OK && applied_modules) { @@ -398,8 +402,7 @@ static esp_err_t api_config_pack_export(const cJSON *params, ts_api_result_t *re free(json_content); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - ts_config_pack_strerror(pack_result)); + pack_failure(result, pack_result); return ESP_FAIL; } @@ -576,8 +579,7 @@ static esp_err_t api_config_pack_content(const cJSON *params, ts_api_result_t *r ); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - ts_config_pack_strerror(pack_result)); + pack_failure(result, pack_result); return ESP_FAIL; } diff --git a/components/ts_api/src/ts_api_pack.h b/components/ts_api/src/ts_api_pack.h new file mode 100644 index 0000000..ea961ad --- /dev/null +++ b/components/ts_api/src/ts_api_pack.h @@ -0,0 +1,21 @@ +/** + * @file ts_api_pack.h + * @brief Shared reply for a failed config pack (.tscfg) operation, used by every API that exports or imports packs + */ +#ifndef TS_API_PACK_H +#define TS_API_PACK_H + +#include "ts_api.h" +#include "ts_config_pack.h" + +/** A config pack operation failed: the reason token for the page, the library's own English text for the folded details. */ +static inline void pack_failure(ts_api_result_t *result, ts_config_pack_result_t pack_result) +{ + ts_api_result_code_t code = pack_result == TS_CONFIG_PACK_ERR_NO_MEM ? TS_API_ERR_NO_MEM + : pack_result == TS_CONFIG_PACK_ERR_PERMISSION ? TS_API_ERR_NO_PERMISSION + : (pack_result == TS_CONFIG_PACK_ERR_IO || pack_result == TS_CONFIG_PACK_ERR_NOT_INIT) ? TS_API_ERR_INTERNAL + : TS_API_ERR_INVALID_ARG; + ts_api_result_reason(result, code, ts_config_pack_reason(pack_result), ts_config_pack_strerror(pack_result)); +} + +#endif /* TS_API_PACK_H */ diff --git a/components/ts_api/src/ts_api_ssh.c b/components/ts_api/src/ts_api_ssh.c index 656568b..abdb798 100644 --- a/components/ts_api/src/ts_api_ssh.c +++ b/components/ts_api/src/ts_api_ssh.c @@ -31,12 +31,14 @@ #include "ts_ssh_hosts_config.h" #include "ts_ssh_commands_config.h" #include "ts_config_pack.h" +#include "ts_api_pack.h" #include "ts_cert.h" #include "ts_webui.h" #include "ts_log.h" #include "esp_log.h" #include "esp_heap_caps.h" #include +#include #include #include #include @@ -64,6 +66,22 @@ static void cleanup_key_buffer(ssh_request_key_t *key) { } } +/* Failure with the reason token as the error text and {stage, detail} as data (data is delivered with failures). */ +static void ssh_connect_error(ts_api_result_t *result, ts_ssh_session_t session) +{ + const char *stage = "connect"; + const char *reason = ts_ssh_connect_reason(session, &stage); + const char *detail = ts_ssh_get_error(session); + ts_api_result_error(result, TS_API_ERR_CONNECTION, reason); + cJSON *data = cJSON_CreateObject(); + if (data && cJSON_AddStringToObject(data, "stage", stage) && + (!detail || !detail[0] || cJSON_AddStringToObject(data, "detail", detail))) { + result->data = data; + } else { + cJSON_Delete(data); + } +} + /** * @brief 从参数中配置 SSH 会话 */ @@ -153,7 +171,7 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, esp_err_t ret = ts_known_hosts_verify(session, &verify_result, &host_info); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to verify host fingerprint"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "known_host_failed", esp_err_to_name(ret)); return ret; } @@ -184,7 +202,7 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, host_info.host, host_info.port, host_info.fingerprint); return ESP_OK; } else { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to save host key"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "known_host_failed", esp_err_to_name(ret)); return ret; } } else { @@ -197,7 +215,7 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, cJSON_AddStringToObject(data, "message", "New host - set trust_new=true or use hosts.add to trust this host"); result->code = TS_API_ERR_HOST_NEW; - result->message = TS_STRDUP_PSRAM("New host requires confirmation"); + result->message = TS_STRDUP_PSRAM("ssh_host_key_unknown"); result->data = data; return ESP_ERR_INVALID_STATE; } @@ -211,7 +229,7 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, if (ret == ESP_OK) { return ESP_OK; } else { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to update host key"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "known_host_failed", esp_err_to_name(ret)); return ret; } } else { @@ -232,13 +250,13 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, "WARNING: Host key has changed! This could indicate a man-in-the-middle attack. " "Set accept_changed=true only if you are sure the server was reinstalled."); result->code = TS_API_ERR_HOST_MISMATCH; - result->message = TS_STRDUP_PSRAM("Host key mismatch - possible MITM attack"); + result->message = TS_STRDUP_PSRAM("ssh_host_key_changed"); result->data = data; return ESP_ERR_INVALID_STATE; } default: - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Host verification error"); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "known_host_failed"); return ESP_FAIL; } } @@ -309,7 +327,7 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) ts_ssh_config_t config = TS_SSH_DEFAULT_CONFIG(); esp_err_t ret = configure_ssh_from_params(params, &config, &request_key); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid SSH configuration"); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "ssh_config_invalid"); cleanup_key_buffer(&request_key); return ret; } @@ -324,7 +342,7 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) ts_ssh_session_t session = NULL; ret = ts_ssh_session_create(&config, &session); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create session"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); cleanup_key_buffer(&request_key); return ret; } @@ -332,9 +350,8 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) /* 连接(TCP 层) */ ret = connect_verified(session, params, result, false); if (ret != ESP_OK) { - const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) - ts_api_result_error(result, TS_API_ERR_CONNECTION, err ? err : "Failed to connect"); + ssh_connect_error(result, session); ts_ssh_session_destroy(session); cleanup_key_buffer(&request_key); return ret; @@ -378,7 +395,7 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) ts_api_result_ok(result, data); ts_ssh_exec_result_free(&exec_result); } else { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Command execution failed"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "ssh_exec_failed", ts_ssh_get_error(session)); } ts_ssh_disconnect(session); @@ -512,11 +529,9 @@ static esp_err_t api_ssh_exec_stream(const cJSON *params, ts_api_result_t *resul if (ret != ESP_OK) { if (ret == ESP_ERR_INVALID_STATE) { - ts_api_result_error(result, TS_API_ERR_BUSY, - "Another SSH exec session is running"); + ts_api_result_error(result, TS_API_ERR_BUSY, "ssh_exec_busy"); } else { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - "Failed to start SSH exec session"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "ssh_start_failed", esp_err_to_name(ret)); } return ret; } @@ -554,13 +569,13 @@ static esp_err_t api_ssh_cancel(const cJSON *params, ts_api_result_t *result) /* Exact instance identity; a status wildcard is never a control target. */ if (!cJSON_IsNumber(sid) || sid->valuedouble < 1 || sid->valuedouble > UINT32_MAX || sid->valuedouble != (double)(uint32_t)sid->valuedouble) { - ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "No running session"); + ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "ssh_no_session"); return ESP_ERR_NOT_FOUND; } esp_err_t ret = ts_webui_ssh_exec_cancel((uint32_t)sid->valuedouble); if (ret != ESP_OK) { ts_api_result_error(result, ret == ESP_ERR_INVALID_STATE ? TS_API_ERR_NOT_FOUND : TS_API_ERR_INTERNAL, - ret == ESP_ERR_INVALID_STATE ? "No running session" : "Failed to request cancellation"); + ret == ESP_ERR_INVALID_STATE ? "ssh_no_session" : "ssh_cancel_failed"); return ret; } /* Existing wire field means accepted intent, not confirmed remote exit. */ @@ -604,7 +619,7 @@ static esp_err_t api_ssh_test(const cJSON *params, ts_api_result_t *result) ts_ssh_config_t config = TS_SSH_DEFAULT_CONFIG(); esp_err_t ret = configure_ssh_from_params(params, &config, &request_key); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid SSH configuration"); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "ssh_config_invalid"); cleanup_key_buffer(&request_key); return ret; } @@ -613,7 +628,7 @@ static esp_err_t api_ssh_test(const cJSON *params, ts_api_result_t *result) ts_ssh_session_t session = NULL; ret = ts_ssh_session_create(&config, &session); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create session"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); cleanup_key_buffer(&request_key); return ret; } @@ -628,9 +643,13 @@ static esp_err_t api_ssh_test(const cJSON *params, ts_api_result_t *result) return ESP_OK; } const char *err = ts_ssh_get_error(session); + const char *stage = "connect"; + const char *reason = ts_ssh_connect_reason(session, &stage); cJSON *data = cJSON_CreateObject(); cJSON_AddBoolToObject(data, "success", false); cJSON_AddStringToObject(data, "error", err ? err : "Connection failed"); + cJSON_AddStringToObject(data, "reason", reason); /* additive: older clients keep reading `error` */ + cJSON_AddStringToObject(data, "stage", stage); ts_api_result_ok(result, data); ts_ssh_session_destroy(session); cleanup_key_buffer(&request_key); @@ -740,7 +759,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) size_t pubkey_len = 0; esp_err_t ret = ts_keystore_load_public_key(keyid->valuestring, &pubkey_data, &pubkey_len); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Key not found in keystore"); + ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "key_not_found"); return ret; } @@ -757,7 +776,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) ts_ssh_session_t session = NULL; ret = ts_ssh_session_create(&config, &session); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create session"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); free(pubkey_data); return ret; } @@ -765,9 +784,8 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) /* 首次部署自动保存新主机指纹;已知主机指纹变化仍在认证前拒绝。 */ ret = connect_verified(session, params, result, true); if (ret != ESP_OK) { - const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) - ts_api_result_error(result, TS_API_ERR_CONNECTION, err ? err : "Failed to connect"); + ssh_connect_error(result, session); ts_ssh_session_destroy(session); free(pubkey_data); return ret; @@ -788,7 +806,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) size_t deploy_capacity = strlen(pubkey_data) * 4 + 256; char *deploy_cmd = TS_MALLOC_PSRAM(deploy_capacity); if (!deploy_cmd) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); ts_ssh_disconnect(session); ts_ssh_session_destroy(session); free(pubkey_data); @@ -800,7 +818,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) ts_ssh_disconnect(session); ts_ssh_session_destroy(session); free(pubkey_data); - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Cannot construct public key deployment command"); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "pubkey_invalid"); return ESP_ERR_INVALID_ARG; } @@ -821,8 +839,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) ts_ssh_session_destroy(session); if (!deploy_ok) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - stderr_msg ? stderr_msg : "Deploy command failed"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "ssh_deploy_failed", stderr_msg); free(stderr_msg); free(pubkey_data); return ESP_FAIL; @@ -969,7 +986,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) size_t pubkey_len = 0; esp_err_t ret = ts_keystore_load_public_key(keyid->valuestring, &pubkey_data, &pubkey_len); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Key not found in keystore"); + ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "key_not_found"); return ret; } @@ -977,7 +994,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) char *pubkey_copy = TS_STRDUP_PSRAM(pubkey_data); if (!pubkey_copy) { free(pubkey_data); - ts_api_result_error(result, TS_API_ERR_NO_MEM, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_ERR_NO_MEM; } @@ -987,7 +1004,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) if (!key_type || !key_data) { free(pubkey_data); free(pubkey_copy); - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid public key format"); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "pubkey_invalid"); return ESP_ERR_INVALID_ARG; } @@ -1009,7 +1026,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) ts_ssh_session_t session = NULL; ret = ts_ssh_session_create(&config, &session); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create session"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); free(pubkey_data); free(pubkey_copy); return ret; @@ -1017,9 +1034,8 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) ret = connect_verified(session, params, result, false); if (ret != ESP_OK) { - const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) - ts_api_result_error(result, TS_API_ERR_CONNECTION, err ? err : "Failed to connect"); + ssh_connect_error(result, session); ts_ssh_session_destroy(session); free(pubkey_data); free(pubkey_copy); @@ -1041,7 +1057,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) /* 1. 检查密钥是否存在 */ char *check_cmd = TS_MALLOC_PSRAM(512); if (!check_cmd) { - ts_api_result_error(result, TS_API_ERR_NO_MEM, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); ts_ssh_disconnect(session); ts_ssh_session_destroy(session); free(pubkey_data); @@ -1062,7 +1078,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) free(check_cmd); if (ret != ESP_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to check key"); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "ssh_revoke_check_failed"); ts_ssh_exec_result_free(&exec_result); ts_ssh_disconnect(session); ts_ssh_session_destroy(session); @@ -1092,7 +1108,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) /* 2. 执行删除操作 */ char *revoke_cmd = TS_MALLOC_PSRAM(512); if (!revoke_cmd) { - ts_api_result_error(result, TS_API_ERR_NO_MEM, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); ts_ssh_disconnect(session); ts_ssh_session_destroy(session); free(pubkey_data); @@ -1121,7 +1137,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) free(pubkey_copy); if (!revoke_ok) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to revoke key"); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "ssh_revoke_failed"); return ESP_FAIL; } @@ -1163,7 +1179,7 @@ static esp_err_t api_ssh_keygen(const cJSON *params, ts_api_result_t *result) /* 检查密钥是否已存在 */ if (ts_keystore_key_exists(id->valuestring)) { - ts_api_result_error(result, TS_API_ERR_BUSY, "Key already exists"); + ts_api_result_error(result, TS_API_ERR_BUSY, "key_exists"); return ESP_ERR_INVALID_STATE; } @@ -1357,7 +1373,7 @@ static esp_err_t api_ssh_hosts_add(const cJSON *params, ts_api_result_t *result) cJSON_AddStringToObject(data, "id", config.id); ts_api_result_ok(result, data); } else if (ret == ESP_ERR_NO_MEM) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Max hosts reached"); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "host_capacity"); } else { ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to add host"); } @@ -1468,8 +1484,7 @@ static esp_err_t api_ssh_hosts_export(const cJSON *params, ts_api_result_t *resu /* 检查导出权限 */ if (!ts_config_pack_can_export()) { - ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, - "This device is not authorized to export config packs"); + ts_api_result_reason(result, TS_API_ERR_NO_PERMISSION, "pack_not_allowed", NULL); return ESP_OK; } @@ -1497,7 +1512,7 @@ static esp_err_t api_ssh_hosts_export(const cJSON *params, ts_api_result_t *resu char *json_str = cJSON_PrintUnformatted(host_json); cJSON_Delete(host_json); if (!json_str) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize config"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } @@ -1514,7 +1529,7 @@ static esp_err_t api_ssh_hosts_export(const cJSON *params, ts_api_result_t *resu cert_pem = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM); if (!cert_pem) { cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } cert_len = 4096; @@ -1522,7 +1537,7 @@ static esp_err_t api_ssh_hosts_export(const cJSON *params, ts_api_result_t *resu if (ret != ESP_OK) { free(cert_pem); cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get device certificate"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "device_cert_unavailable", esp_err_to_name(ret)); return ESP_OK; } } @@ -1546,7 +1561,7 @@ static esp_err_t api_ssh_hosts_export(const cJSON *params, ts_api_result_t *resu } if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config pack"); + pack_failure(result, pack_result); return ESP_OK; } @@ -1602,13 +1617,7 @@ static esp_err_t api_ssh_hosts_import(const cJSON *params, ts_api_result_t *resu tscfg->valuestring, strlen(tscfg->valuestring), &sig_info); if (pack_result != TS_CONFIG_PACK_OK) { - const char *err_msg = "Failed to verify config pack"; - if (pack_result == TS_CONFIG_PACK_ERR_RECIPIENT) { - err_msg = "This config pack is not for this device"; - } else if (pack_result == TS_CONFIG_PACK_ERR_SIGNATURE) { - err_msg = "Invalid signature"; - } - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, err_msg); + pack_failure(result, pack_result); return ESP_OK; } @@ -1665,7 +1674,7 @@ static esp_err_t api_ssh_hosts_import(const cJSON *params, ts_api_result_t *resu /* 确保目录存在 */ if (stat(TS_SSH_HOSTS_SDCARD_DIR, &st) != 0) { if (mkdir(TS_SSH_HOSTS_SDCARD_DIR, 0755) != 0) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create hosts directory"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } } @@ -1674,7 +1683,7 @@ static esp_err_t api_ssh_hosts_import(const cJSON *params, ts_api_result_t *resu snprintf(filepath, sizeof(filepath), "%s/%s.tscfg", TS_SSH_HOSTS_SDCARD_DIR, config_id); FILE *f = fopen(filepath, "w"); if (!f) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } @@ -1683,7 +1692,7 @@ static esp_err_t api_ssh_hosts_import(const cJSON *params, ts_api_result_t *resu if (written != strlen(tscfg->valuestring)) { unlink(filepath); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to write config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", "short write"); return ESP_OK; } @@ -2013,7 +2022,7 @@ static esp_err_t api_ssh_commands_add(const cJSON *params, ts_api_result_t *resu cJSON_AddStringToObject(data, "name", config.name); ts_api_result_ok(result, data); } else if (ret == ESP_ERR_NO_MEM) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Max commands reached"); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "command_capacity"); } else { ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to add command"); } @@ -2152,8 +2161,7 @@ static esp_err_t api_ssh_commands_export(const cJSON *params, ts_api_result_t *r /* 检查导出权限 */ if (!ts_config_pack_can_export()) { - ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, - "This device is not authorized to export config packs"); + ts_api_result_reason(result, TS_API_ERR_NO_PERMISSION, "pack_not_allowed", NULL); return ESP_OK; } @@ -2220,7 +2228,7 @@ static esp_err_t api_ssh_commands_export(const cJSON *params, ts_api_result_t *r char *json_str = cJSON_PrintUnformatted(export_json); cJSON_Delete(export_json); if (!json_str) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize config"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } @@ -2237,7 +2245,7 @@ static esp_err_t api_ssh_commands_export(const cJSON *params, ts_api_result_t *r cert_pem = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM); if (!cert_pem) { cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "no_memory"); return ESP_OK; } cert_len = 4096; @@ -2245,7 +2253,7 @@ static esp_err_t api_ssh_commands_export(const cJSON *params, ts_api_result_t *r if (ret != ESP_OK) { free(cert_pem); cJSON_free(json_str); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get device certificate"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "device_cert_unavailable", esp_err_to_name(ret)); return ESP_OK; } free_cert = true; @@ -2268,7 +2276,7 @@ static esp_err_t api_ssh_commands_export(const cJSON *params, ts_api_result_t *r if (free_cert) free(cert_pem); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config pack"); + pack_failure(result, pack_result); return ESP_OK; } @@ -2330,13 +2338,7 @@ static esp_err_t api_ssh_commands_import(const cJSON *params, ts_api_result_t *r tscfg->valuestring, strlen(tscfg->valuestring), &sig_info); if (pack_result != TS_CONFIG_PACK_OK) { - const char *err_msg = "Failed to verify config pack"; - if (pack_result == TS_CONFIG_PACK_ERR_RECIPIENT) { - err_msg = "This config pack is not for this device"; - } else if (pack_result == TS_CONFIG_PACK_ERR_SIGNATURE) { - err_msg = "Invalid signature"; - } - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, err_msg); + pack_failure(result, pack_result); return ESP_OK; } @@ -2394,7 +2396,7 @@ static esp_err_t api_ssh_commands_import(const cJSON *params, ts_api_result_t *r /* 确保目录存在 */ if (stat(TS_SSH_COMMANDS_SDCARD_DIR, &st) != 0) { if (mkdir(TS_SSH_COMMANDS_SDCARD_DIR, 0755) != 0) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create commands directory"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } } @@ -2403,7 +2405,7 @@ static esp_err_t api_ssh_commands_import(const cJSON *params, ts_api_result_t *r snprintf(filepath, sizeof(filepath), "%s/%s.tscfg", TS_SSH_COMMANDS_SDCARD_DIR, config_id); FILE *f = fopen(filepath, "w"); if (!f) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to create config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", strerror(errno)); return ESP_OK; } @@ -2412,7 +2414,7 @@ static esp_err_t api_ssh_commands_import(const cJSON *params, ts_api_result_t *r if (written != strlen(tscfg->valuestring)) { unlink(filepath); - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to write config file"); + ts_api_result_reason(result, TS_API_ERR_INTERNAL, "storage_failed", "short write"); return ESP_OK; } @@ -2456,7 +2458,8 @@ esp_err_t ts_api_service_control(const cJSON *params, ts_api_result_t *result, t if (ret != ESP_OK) { cJSON_Delete(data); ts_api_result_error(result, ret == ESP_ERR_INVALID_STATE ? TS_API_ERR_BUSY : - ret == ESP_ERR_NO_MEM ? TS_API_ERR_NO_MEM : TS_API_ERR_INTERNAL, esp_err_to_name(ret)); + ret == ESP_ERR_NO_MEM ? TS_API_ERR_NO_MEM : TS_API_ERR_INTERNAL, + ret == ESP_ERR_INVALID_STATE ? "service_not_admissible" : ret == ESP_ERR_NO_MEM ? "no_memory" : esp_err_to_name(ret)); return ret; } cJSON_SetNumberValue(operation, operation_id); diff --git a/components/ts_automation/include/ts_rule_engine.h b/components/ts_automation/include/ts_rule_engine.h index 1194262..081b16b 100644 --- a/components/ts_automation/include/ts_rule_engine.h +++ b/components/ts_automation/include/ts_rule_engine.h @@ -132,6 +132,33 @@ esp_err_t ts_rule_evaluate(const char *id, bool *triggered); */ esp_err_t ts_rule_trigger(const char *id); +/** + * @brief Why a manual trigger was refused (ts_rule_trigger_ex). TS_RULE_REJECT_NONE when the trigger was accepted, + * or failed for a reason that is not an admission decision (not found, out of memory, ...). + */ +typedef enum { + TS_RULE_REJECT_NONE = 0, + TS_RULE_REJECT_NOT_ACCEPTING, /**< the action manager takes no new work (reloading or stopped) */ + TS_RULE_REJECT_DISABLED, /**< the rule is disabled */ + TS_RULE_REJECT_MANUAL_NOT_ALLOWED, /**< allow_manual_trigger is off */ + TS_RULE_REJECT_NOT_LOADED, /**< the configuration is not loaded yet */ + TS_RULE_REJECT_RECOVERY, /**< the configuration needs a recovery check */ + TS_RULE_REJECT_CONFIG_BUSY, /**< the configuration is being saved */ + TS_RULE_REJECT_RUNNING, /**< the rule is already executing */ + TS_RULE_REJECT_SERVICE_STATE, /**< a bound service cannot be started in its current state */ + TS_RULE_REJECT_CHANGED, /**< the rule was changed or removed while the trigger was admitted */ + TS_RULE_REJECT_PENDING_DELETE /**< the rule's deletion is saved and takes effect after a restart */ +} ts_rule_reject_t; + +/** + * @brief ts_rule_trigger() that also reports why a manual trigger was refused + * + * @param id Rule ID + * @param why Optional; receives the refusal reason (TS_RULE_REJECT_NONE if the call was not refused) + * @return Same as ts_rule_trigger() + */ +esp_err_t ts_rule_trigger_ex(const char *id, ts_rule_reject_t *why); + /*===========================================================================*/ /* Condition Evaluation */ /*===========================================================================*/ diff --git a/components/ts_automation/src/ts_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index 5b22813..e4396a1 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -940,8 +940,13 @@ bool ts_rule_eval_condition_group(const ts_auto_condition_group_t *group) /* 规则评估 */ /*===========================================================================*/ -static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { - if (!ts_action_manager_accepting()) return ESP_ERR_INVALID_STATE; +static esp_err_t refuse(ts_rule_reject_t *why, ts_rule_reject_t reason) { + if (why) *why = reason; + return ESP_ERR_INVALID_STATE; +} +static esp_err_t execute_rule(const char *id, bool manual, bool *triggered, ts_rule_reject_t *why) { + if (why) *why = TS_RULE_REJECT_NONE; + if (!ts_action_manager_accepting()) return refuse(why, TS_RULE_REJECT_NOT_ACCEPTING); *triggered = false; ts_auto_rule_t r; esp_err_t ret = ts_rule_acquire(id, &r); @@ -950,8 +955,9 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { if (manual && (r.reference_unresolved || r.presentation_fields != 3)) ts_rule_resolve_presentation(&r); if (!r.enabled || (!manual && r.manual_trigger) || (manual && !r.allow_manual_trigger)) { + ts_rule_reject_t reason = !r.enabled ? TS_RULE_REJECT_DISABLED : TS_RULE_REJECT_MANUAL_NOT_ALLOWED; ts_rule_release(&r); - return manual ? ESP_ERR_INVALID_STATE : ESP_OK; + return manual ? refuse(why, reason) : ESP_OK; } if (!manual && !ts_rule_eval_condition_group(&r.conditions)) { ts_rule_release(&r); @@ -959,22 +965,25 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { } xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int preliminary = find_rule_index(id); - bool unavailable = preliminary < 0 || !s_rule_ctx.loaded || s_rule_ctx.recovery_error || - s_rule_ctx.meta[preliminary].pending_delete || - s_rule_ctx.meta[preliminary].committing || - s_rule_ctx.meta[preliminary].executing || + ts_rule_reject_t busy = preliminary < 0 ? TS_RULE_REJECT_CHANGED + : !s_rule_ctx.loaded ? TS_RULE_REJECT_NOT_LOADED + : s_rule_ctx.recovery_error ? TS_RULE_REJECT_RECOVERY + : s_rule_ctx.meta[preliminary].pending_delete ? TS_RULE_REJECT_PENDING_DELETE + : s_rule_ctx.meta[preliminary].committing ? TS_RULE_REJECT_CONFIG_BUSY + : s_rule_ctx.meta[preliminary].executing ? TS_RULE_REJECT_RUNNING : TS_RULE_REJECT_NONE; + bool unavailable = busy != TS_RULE_REJECT_NONE || (!manual && r.cooldown_ms && r.last_trigger_ms && esp_timer_get_time() / 1000 - r.last_trigger_ms < r.cooldown_ms); xSemaphoreGive(s_rule_ctx.mutex); if (unavailable) { ts_rule_release(&r); - return manual ? ESP_ERR_INVALID_STATE : ESP_OK; + return manual ? refuse(why, busy) : ESP_OK; } for (unsigned n = 0; n < r.action_count; n++) { if (r.actions[n].type == TS_AUTO_ACT_SSH_CMD_REF && !ts_ssh_service_start_admissible(r.actions[n].ssh_ref.cmd_id)) { ts_rule_release(&r); - return manual ? ESP_ERR_INVALID_STATE : ESP_OK; + return manual ? refuse(why, TS_RULE_REJECT_SERVICE_STATE) : ESP_OK; } } /* Freeze all referenced actions/SSH bindings once at admission. No allocation @@ -1008,6 +1017,12 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { !s_rule_ctx.meta[i].committing && !s_rule_ctx.meta[i].executing && s_rule_ctx.rules[i].revision == r.revision && s_rule_ctx.rules[i].instance == r.instance; + ts_rule_reject_t late = valid ? TS_RULE_REJECT_NONE + : i < 0 ? TS_RULE_REJECT_CHANGED + : !s_rule_ctx.loaded ? TS_RULE_REJECT_NOT_LOADED + : s_rule_ctx.recovery_error ? TS_RULE_REJECT_RECOVERY + : s_rule_ctx.meta[i].committing ? TS_RULE_REJECT_CONFIG_BUSY + : s_rule_ctx.meta[i].executing ? TS_RULE_REJECT_RUNNING : TS_RULE_REJECT_CHANGED; if (valid && !manual && r.cooldown_ms && s_rule_ctx.rules[i].last_trigger_ms && now - s_rule_ctx.rules[i].last_trigger_ms < r.cooldown_ms) valid = false; @@ -1022,7 +1037,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { ts_action_snapshot_release(&frozen[n]); free(frozen); ts_rule_release(&r); - return manual ? ESP_ERR_INVALID_STATE : ESP_OK; + return manual ? refuse(why, late) : ESP_OK; } int success = 0, failed = 0; ret = execute_actions_with_stats(frozen, r.action_count, &success, &failed); @@ -1048,7 +1063,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { esp_err_t ts_rule_evaluate(const char *id, bool *triggered) { if (!triggered) return ESP_ERR_INVALID_ARG; - return execute_rule(id, false, triggered); + return execute_rule(id, false, triggered, NULL); } int ts_rule_evaluate_all(void) @@ -1091,9 +1106,13 @@ int ts_rule_evaluate_all(void) return triggered; } -esp_err_t ts_rule_trigger(const char *id) { +esp_err_t ts_rule_trigger_ex(const char *id, ts_rule_reject_t *why) { bool triggered; - return execute_rule(id, true, &triggered); + return execute_rule(id, true, &triggered, why); +} + +esp_err_t ts_rule_trigger(const char *id) { + return ts_rule_trigger_ex(id, NULL); } /*===========================================================================*/ diff --git a/components/ts_config_pack/include/ts_config_pack.h b/components/ts_config_pack/include/ts_config_pack.h index e50ef0d..0dbd2ce 100644 --- a/components/ts_config_pack/include/ts_config_pack.h +++ b/components/ts_config_pack/include/ts_config_pack.h @@ -349,6 +349,11 @@ ts_config_pack_result_t ts_config_pack_apply_file( */ const char *ts_config_pack_strerror(ts_config_pack_result_t result); +/** + * @brief Reason token for a result, for the Web UI to translate (e.g. "pack_wrong_device"); never NULL + */ +const char *ts_config_pack_reason(ts_config_pack_result_t result); + /** * @brief Export device certificate for config encryption * diff --git a/components/ts_config_pack/src/ts_config_pack.c b/components/ts_config_pack/src/ts_config_pack.c index 10af8da..0047ca8 100644 --- a/components/ts_config_pack/src/ts_config_pack.c +++ b/components/ts_config_pack/src/ts_config_pack.c @@ -188,6 +188,31 @@ const char *ts_config_pack_strerror(ts_config_pack_result_t result) return s_error_messages[result]; } +/** The reason token the Web UI translates (the page keeps ts_config_pack_strerror()'s English only for its folded details). */ +const char *ts_config_pack_reason(ts_config_pack_result_t result) +{ + switch (result) { + case TS_CONFIG_PACK_ERR_PARSE: return "pack_parse_failed"; + case TS_CONFIG_PACK_ERR_VERSION: return "pack_version_unsupported"; + case TS_CONFIG_PACK_ERR_RECIPIENT: return "pack_wrong_device"; + case TS_CONFIG_PACK_ERR_CERT_CHAIN: return "pack_cert_invalid"; + case TS_CONFIG_PACK_ERR_SIGNATURE: return "pack_signature_invalid"; + case TS_CONFIG_PACK_ERR_DECRYPT: return "pack_decrypt_failed"; + case TS_CONFIG_PACK_ERR_INTEGRITY: return "pack_corrupted"; + case TS_CONFIG_PACK_ERR_EXPIRED: return "pack_expired"; + case TS_CONFIG_PACK_ERR_NO_MEM: return "no_memory"; + case TS_CONFIG_PACK_ERR_IO: return "pack_io_failed"; + case TS_CONFIG_PACK_ERR_PERMISSION: return "pack_not_allowed"; + case TS_CONFIG_PACK_ERR_NOT_INIT: return "pack_not_ready"; + case TS_CONFIG_PACK_ERR_INVALID_ARG: return "invalid_argument"; + case TS_CONFIG_PACK_ERR_TIME_UNVERIFIED: return "time_unverified"; + case TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED: return "signing_trust_unconfigured"; + case TS_CONFIG_PACK_ERR_SIGNER_ROLE: return "signer_unauthorized"; + case TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED: return "credential_changed"; + default: return "pack_verify_failed"; + } +} + /*===========================================================================*/ /* Initialization */ /*===========================================================================*/ diff --git a/components/ts_security/include/ts_ssh_client.h b/components/ts_security/include/ts_ssh_client.h index 0f4c6c3..e018f81 100644 --- a/components/ts_security/include/ts_ssh_client.h +++ b/components/ts_security/include/ts_ssh_client.h @@ -209,6 +209,37 @@ void ts_ssh_exec_result_free(ts_ssh_exec_result_t *result); */ const char *ts_ssh_get_error(ts_ssh_session_t session); +/** Where the last failed ts_ssh_connect*() call stopped. The error text stays the same for all of them. */ +typedef enum { + TS_SSH_FAIL_NONE = 0, + TS_SSH_FAIL_DNS, /**< the host name could not be resolved */ + TS_SSH_FAIL_REFUSED, /**< the host refused the TCP connection (nothing listens on the port) */ + TS_SSH_FAIL_UNREACHABLE, /**< no route to the host or network */ + TS_SSH_FAIL_TIMEOUT, /**< the deadline expired while connecting, in the handshake or while authenticating */ + TS_SSH_FAIL_CONNECT, /**< another socket failure while connecting */ + TS_SSH_FAIL_HANDSHAKE, /**< the SSH handshake failed (not an SSH server, no common algorithms, ...) */ + TS_SSH_FAIL_HOST_KEY, /**< the host key was rejected (unknown or changed) */ + TS_SSH_FAIL_AUTH, /**< the server rejected the credentials */ + TS_SSH_FAIL_OTHER /**< anything else (out of memory, invalid configuration, ...) */ +} ts_ssh_fail_t; + +/** + * @brief Get the stage at which the last connection attempt failed + * + * @param session Session handle + * @return TS_SSH_FAIL_NONE if the last attempt did not fail + */ +ts_ssh_fail_t ts_ssh_get_fail_stage(ts_ssh_session_t session); + +/** + * @brief Reason token for a failed connection attempt; the Web UI turns it into a sentence (failure.reasons.ssh_*) + * + * @param session SSH session whose connection failed + * @param stage Output: short name of the stage that failed ("dns", "refused", "timeout", ...), never NULL after the call + * @return Reason token (never NULL) + */ +const char *ts_ssh_connect_reason(ts_ssh_session_t session, const char **stage); + /** * @brief Get the remote host address * diff --git a/components/ts_security/src/ts_ssh_client.c b/components/ts_security/src/ts_ssh_client.c index 7b199c0..a8326a2 100644 --- a/components/ts_security/src/ts_ssh_client.c +++ b/components/ts_security/src/ts_ssh_client.c @@ -51,6 +51,7 @@ struct ts_ssh_session_s { char *password_copy; /**< 密码副本 */ atomic_bool abort_flag; int64_t deadline_us; /**< 中止标志 */ + ts_ssh_fail_t fail; /**< where the last connect attempt failed */ }; /** 全局初始化标志 */ @@ -237,6 +238,23 @@ esp_err_t ts_ssh_session_destroy(ts_ssh_session_t session) { esp_err_t ts_ssh_connect(ts_ssh_session_t s) { return ts_ssh_connect_with_verifier(s, NULL, NULL); } +/* Which stage a failed TCP connect belongs to, from the socket error. */ +static ts_ssh_fail_t connect_failure(int err) { + switch (err) { + case ECONNREFUSED: + return TS_SSH_FAIL_REFUSED; + case EHOSTUNREACH: + case ENETUNREACH: + case EHOSTDOWN: + case ENETDOWN: + return TS_SSH_FAIL_UNREACHABLE; + case ETIMEDOUT: + return TS_SSH_FAIL_TIMEOUT; + default: + return TS_SSH_FAIL_CONNECT; + } +} + esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t verify, void *context) { if (!s) @@ -247,24 +265,31 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve ts_ssh_disconnect(s); s->deadline_us = esp_timer_get_time() + (int64_t)s->config.timeout_ms * 1000; s->state = TS_SSH_STATE_CONNECTING; + s->fail = TS_SSH_FAIL_NONE; esp_err_t ret = ESP_FAIL; struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(s->config.port)}; if (inet_pton(AF_INET, s->config.host, &addr.sin_addr) != 1) { /* lwIP DNS is synchronous; cancellation is observed immediately after * its configured resolver timeout. No extra resolver task is created. */ struct hostent *host = gethostbyname(s->config.host); - if (!host || host->h_length != sizeof(addr.sin_addr)) + if (!host || host->h_length != sizeof(addr.sin_addr)) { + s->fail = TS_SSH_FAIL_DNS; goto failed; + } memcpy(&addr.sin_addr, host->h_addr, sizeof(addr.sin_addr)); } if (operation_expired(s)) goto timeout; s->sock = socket(AF_INET, SOCK_STREAM, 0); - if (s->sock < 0 || fcntl(s->sock, F_SETFL, O_NONBLOCK) < 0) + if (s->sock < 0 || fcntl(s->sock, F_SETFL, O_NONBLOCK) < 0) { + s->fail = TS_SSH_FAIL_CONNECT; goto failed; + } int rc = connect(s->sock, (struct sockaddr *)&addr, sizeof(addr)); - if (rc && errno != EINPROGRESS) + if (rc && errno != EINPROGRESS) { + s->fail = connect_failure(errno); goto failed; + } while (rc) { int ready = wait_socket(s); if (ready < 0) @@ -273,8 +298,10 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve continue; int error = 0; socklen_t size = sizeof(error); - if (getsockopt(s->sock, SOL_SOCKET, SO_ERROR, &error, &size) || error) + if (getsockopt(s->sock, SOL_SOCKET, SO_ERROR, &error, &size) || error) { + s->fail = connect_failure(error ? error : errno); goto failed; + } break; } s->session = libssh2_session_init(); @@ -286,8 +313,10 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve while ((rc = libssh2_session_handshake(s->session, s->sock)) == LIBSSH2_ERROR_EAGAIN) if (wait_socket(s) < 0) goto timeout; - if (rc) + if (rc) { + s->fail = TS_SSH_FAIL_HANDSHAKE; goto failed; + } if (verify) ret = verify(s, context); else { @@ -298,8 +327,10 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve : status == TS_HOST_VERIFY_MISMATCH ? TS_SSH_ERR_HOST_CHANGED : ESP_FAIL; } - if (ret != ESP_OK) + if (ret != ESP_OK) { + s->fail = TS_SSH_FAIL_HOST_KEY; goto failed; + } if (operation_expired(s)) goto timeout; s->state = TS_SSH_STATE_AUTHENTICATING; @@ -328,6 +359,7 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve } while (rc == LIBSSH2_ERROR_EAGAIN); if (rc) { ret = ESP_ERR_INVALID_STATE; + s->fail = TS_SSH_FAIL_AUTH; goto failed; } if (operation_expired(s)) @@ -336,15 +368,33 @@ esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s, ts_ssh_verify_cb_t ve return ESP_OK; timeout: ret = ESP_ERR_TIMEOUT; + s->fail = TS_SSH_FAIL_TIMEOUT; failed: if (ret == ESP_OK) ret = ESP_FAIL; + if (s->fail == TS_SSH_FAIL_NONE) + s->fail = TS_SSH_FAIL_OTHER; set_error(s, "SSH connection failed (code=%d)", ret); ts_ssh_disconnect(s); s->state = TS_SSH_STATE_ERROR; return ret; } +ts_ssh_fail_t ts_ssh_get_fail_stage(ts_ssh_session_t s) { return s ? s->fail : TS_SSH_FAIL_NONE; } + +const char *ts_ssh_connect_reason(ts_ssh_session_t session, const char **stage) +{ + switch (ts_ssh_get_fail_stage(session)) { + case TS_SSH_FAIL_DNS: *stage = "dns"; return "ssh_dns_failed"; + case TS_SSH_FAIL_REFUSED: *stage = "refused"; return "ssh_port_refused"; + case TS_SSH_FAIL_UNREACHABLE: *stage = "unreachable"; return "ssh_host_unreachable"; + case TS_SSH_FAIL_TIMEOUT: *stage = "timeout"; return "ssh_connect_timeout"; + case TS_SSH_FAIL_HANDSHAKE: *stage = "handshake"; return "ssh_handshake_failed"; + case TS_SSH_FAIL_AUTH: *stage = "auth"; return "ssh_auth_failed"; + default: *stage = "connect"; return "ssh_connect_failed"; + } +} + esp_err_t ts_ssh_disconnect(ts_ssh_session_t s) { if (!s) return ESP_ERR_INVALID_ARG; diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index eb5afc6..fffdb5e 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -116,7 +116,7 @@ static esp_err_t check_auth(ts_http_request_t *req, uint32_t *session_id, ts_per #endif } -/* This endpoint retains storage failure details; other response contracts stay unchanged. */ +/* key.generate: same envelope as the generic path (failure details included) plus per-request stage logging. */ static esp_err_t send_key_generate_result(ts_http_request_t *req, ts_api_result_t *result, const char *request_id, int64_t started) { @@ -347,10 +347,21 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) // Return the actual error from the API handler with HTTP 200 // API errors (like "no data") are business logic errors, not HTTP errors cJSON *response = cJSON_CreateObject(); - cJSON_AddNumberToObject(response, "code", result.code); - cJSON_AddStringToObject(response, "error", result.message ? result.message : "Internal error"); - - char *json = cJSON_PrintUnformatted(response); + /* code and error are the failure itself: if either cannot be written, send HTTP 500 rather + * than a document the client would misread. */ + bool built = response && + cJSON_AddNumberToObject(response, "code", result.code) && + cJSON_AddStringToObject(response, "error", result.message ? result.message : "Internal error"); + /* A failing handler may attach details the client must act on, e.g. the host-key + * fingerprint ssh.exec reports with code 1001/1002 so the UI can ask the user to + * confirm it. Dropping them leaves a bare error and no way to continue. Handlers + * that fail without details leave data NULL, so their responses do not change. */ + if (built && result.data) { + cJSON *data = cJSON_Duplicate(result.data, true); + if (data && !cJSON_AddItemToObject(response, "data", data)) cJSON_Delete(data); + } + + char *json = built ? cJSON_PrintUnformatted(response) : NULL; cJSON_Delete(response); ts_api_result_free(&result); @@ -628,6 +639,7 @@ static esp_err_t file_upload_handler(ts_http_request_t *req, void *user_data) cJSON_AddBoolToObject(validation, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(validation, "result_code", pack_result); cJSON_AddStringToObject(validation, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(validation, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *sig = cJSON_CreateObject(); @@ -648,6 +660,7 @@ static esp_err_t file_upload_handler(ts_http_request_t *req, void *user_data) pack_result == TS_CONFIG_PACK_OK ? "success" : "error"); cJSON_AddNumberToObject(ws_data, "result_code", pack_result); cJSON_AddStringToObject(ws_data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(ws_data, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *ws_sig = cJSON_CreateObject(); diff --git a/components/ts_webui/src/ts_webui_ws.c b/components/ts_webui/src/ts_webui_ws.c index 769c430..6cd6239 100644 --- a/components/ts_webui/src/ts_webui_ws.c +++ b/components/ts_webui/src/ts_webui_ws.c @@ -1632,7 +1632,7 @@ static void ssh_exec_task(void *arg) bool encoded=msg!=NULL; encoded = (cJSON_AddStringToObject(msg, "type", "ssh_exec_error"))!=NULL && encoded; encoded = (cJSON_AddNumberToObject(msg, "session_id", session_id))!=NULL && encoded; - encoded = (cJSON_AddStringToObject(msg, "error", "Failed to load SSH key"))!=NULL && encoded; + encoded = (cJSON_AddStringToObject(msg, "error", "key_not_found"))!=NULL && encoded; char *json = encoded?cJSON_PrintUnformatted(msg):NULL; cJSON_Delete(msg); ts_ws_op_close_finish(params->op,json); free(json); @@ -1656,7 +1656,7 @@ static void ssh_exec_task(void *arg) bool encoded=msg!=NULL; encoded = (cJSON_AddStringToObject(msg, "type", "ssh_exec_error"))!=NULL && encoded; encoded = (cJSON_AddNumberToObject(msg, "session_id", session_id))!=NULL && encoded; - encoded = (cJSON_AddStringToObject(msg, "error", "Failed to create SSH session"))!=NULL && encoded; + encoded = (cJSON_AddStringToObject(msg, "error", "no_memory"))!=NULL && encoded; char *json = encoded?cJSON_PrintUnformatted(msg):NULL; cJSON_Delete(msg); ts_ws_op_close_finish(params->op,json); free(json); @@ -1676,7 +1676,11 @@ static void ssh_exec_task(void *arg) bool encoded=msg!=NULL; encoded = (cJSON_AddStringToObject(msg, "type", "ssh_exec_error"))!=NULL && encoded; encoded = (cJSON_AddNumberToObject(msg, "session_id", session_id))!=NULL && encoded; - encoded = (cJSON_AddStringToObject(msg, "error", err ? err : "Connection failed"))!=NULL && encoded; + const char *stage = "connect"; + const char *reason = ts_ssh_connect_reason(params->session, &stage); + encoded = (cJSON_AddStringToObject(msg, "error", reason))!=NULL && encoded; + encoded = (cJSON_AddStringToObject(msg, "stage", stage))!=NULL && encoded; + if (err && err[0]) encoded = (cJSON_AddStringToObject(msg, "detail", err))!=NULL && encoded; char *json = encoded?cJSON_PrintUnformatted(msg):NULL; cJSON_Delete(msg); ts_ws_op_close_finish(params->op,json); free(json); diff --git a/components/ts_webui/src/ts_ws_operation.c b/components/ts_webui/src/ts_ws_operation.c index 6b5968c..f66032e 100644 --- a/components/ts_webui/src/ts_ws_operation.c +++ b/components/ts_webui/src/ts_ws_operation.c @@ -105,7 +105,8 @@ static void fallback(ts_ws_operation_t *op,char *text,size_t size,bool failed) { const char *reason=failed ? "SSH output delivery incomplete; remote command result is not confirmed" : "SSH result delivery failed; result is unknown"; if(op->kind==TS_WS_OP_SHELL && !failed)reason="SSH status delivery failed"; if(op->kind==TS_WS_OP_SHELL)snprintf(text,size,"{\"type\":\"ssh_status\",\"status\":\"error\",\"message\":\"%s\"}",reason); - else snprintf(text,size,"{\"type\":\"ssh_exec_error\",\"session_id\":%lu,\"error\":\"%s\"}",(unsigned long)ts_ws_op_id(op),reason); + else snprintf(text,size,"{\"type\":\"ssh_exec_error\",\"session_id\":%lu,\"error\":\"%s\"}",(unsigned long)ts_ws_op_id(op), + failed ? "ssh_output_incomplete" : "ssh_result_unknown"); /* reason tokens: the page translates them */ } void ts_ws_op_close_finish(ts_ws_operation_t *op,const char *json) { char text[256];fallback(op,text,sizeof(text),false); diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index ea6f7f2..9b86965 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -200,6 +200,18 @@ input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer- .toast-success::before{background:var(--ok-dot)} .toast-warning::before{background:var(--warn-dot)} .toast-error::before{background:var(--bad-dot)} +.toast.rich.show{pointer-events:auto;flex-wrap:wrap;align-items:flex-start;row-gap:8px} +.toast.rich::before{margin-top:7px} +.toast .toast-msg{flex:1 1 220px;min-width:0;white-space:pre-wrap;overflow-wrap:anywhere} +.toast .toast-more,.toast .toast-close{flex:none;border:0;background:none;padding:0 4px;font:inherit;color:var(--ink-2);cursor:pointer;border-radius:6px} +.toast .toast-more{color:var(--accent-text);text-decoration:underline;text-underline-offset:2px} +.toast .toast-close{font-size:18px;line-height:20px} +.toast .toast-more:hover,.toast .toast-close:hover{color:var(--ink)} +.toast .toast-more:focus-visible,.toast .toast-close:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(0,122,255,.38)} +.note-more{margin-left:4px;color:var(--accent-text);text-decoration:underline;text-underline-offset:2px;cursor:pointer;font-size:12px} +.note-detail{display:block;margin-top:6px;padding:8px 10px;border-radius:10px;background:var(--fill);font:12px/18px var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;user-select:text;color:var(--ink-2)} +.note-detail[hidden]{display:none} +.toast .toast-detail{flex-basis:100%;margin:0;padding:8px 10px;border-radius:10px;background:rgba(0,0,0,.05);font:12px/18px var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;user-select:text} .sheet{border-radius:32px;padding:24px} .sheet .sh{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px} .confirm-sheet .st,.confirm-sheet .t-body{min-width:0;overflow-wrap:anywhere} diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index a18d968..ebb8bc2 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -7,8 +7,8 @@ - - + + @@ -88,7 +88,7 @@ - + - + diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index 22dfef8..40a78d1 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -87,6 +87,103 @@ function requireApiSuccess(result, operation = '') { } return result; } +// A problem the page found by itself (nothing was sent, or an answer cannot be used): carries a catalogue reason for describeFailure. +function pageFailure(token, params = {}) { + return Object.assign(new Error(token), {name: 'PageFailure', failureToken: token, failureParams: params}); +} + +// --------------------------------------------------------------------------------------------------------------------- +// User-facing failure text: ": . " +// op key under failure.ops, e.g. 'rule.enable' (.failed, or .unknown when the outcome is not confirmed) +// params values for the {name} / {host} ... placeholders of the operation and reason texts +// result API envelope {code, error | rawMessage, data}; error an ApiOperationError; token a reason the UI detected itself (an error made by pageFailure(token, params) carries one) +// write true for an operation that changes something: an exception without an answer is then "unconfirmed" +// The device reports the reason as a stable token in `error` (e.g. 'execution_busy'); the texts live in the language packs +// (failure.reasons..{why,hint}). A device text the catalogue does not know never reaches the user: it falls back to an +// honest generic reason chosen by the error class, and the original text is kept in the folded details. +// --------------------------------------------------------------------------------------------------------------------- +const FAILURE_LEGACY_TEXT = { // English texts the device sends without a reason token + 'Memory allocation failed': 'no_memory', 'Rule not found': 'rule_not_found', 'Request timeout': 'timeout', 'Failed to fetch': 'network_lost', + 'SD card not mounted': 'sd_unavailable', + 'Invalid username or password': 'credentials_invalid', 'Invalid credentials': 'credentials_invalid', 'Account locked due to too many failed attempts': 'account_locked', + 'Password must be 4-64 characters': 'password_length', 'Old password is incorrect': 'old_password_wrong', 'Root permission required': 'root_required', + 'Missing token': 'auth_required', 'Invalid or expired token': 'auth_required', 'Session expired': 'auth_required', + 'Cannot delete root directory': 'root_directory_protected', 'File not found': 'file_not_found', 'Source file not found': 'file_not_found', 'Directory not found': 'directory_not_found', + 'SD card already mounted': 'sd_already_mounted', 'Failed to mount SD card': 'sd_mount_failed', 'Failed to unmount SD card': 'sd_unmount_failed', + 'Failed to create directory': 'storage_failed', 'Failed to rename': 'storage_failed', 'Failed to delete': 'storage_failed', + 'USB MUX not configured': 'usb_mux_unconfigured', 'WiFi STA not connected': 'wifi_sta_not_connected', + 'Too many curve points': 'fan_curve_too_many', 'Invalid duty limits (0-100, min <= max)': 'fan_limits_invalid', + 'Too many variables (max 8)': 'temp_too_many_variables', 'Each variable needs a name': 'temp_name_missing', 'At least one variable must have a positive weight': 'temp_weight_required', + 'No private key exists. Generate key pair first.': 'cert_no_keypair', 'Key pair already exists. Use force=true to overwrite.': 'cert_keypair_exists', 'No certificate installed': 'cert_not_installed', + 'CSR fields must be strings': 'csr_invalid', 'Export requires Developer device (OU=Developer)': 'pack_not_allowed', 'Key not found': 'key_not_found', + 'Device not found': 'led_device_not_found', 'Effect not found': 'led_effect_not_found', 'Filter not found': 'led_filter_not_found', 'Font not found': 'led_font_not_found', + 'Image only supported on matrix': 'led_matrix_only', 'QR code only supported on matrix': 'led_matrix_only', 'Text only supported on matrix': 'led_matrix_only', + 'Text too long for QR code': 'qr_text_too_long', 'Failed to load image': 'led_image_unreadable', 'Failed to load background image': 'led_image_unreadable', + '无可用回滚分区': 'ota_no_rollback', '缺少 url 参数且未设置 OTA 服务器': 'ota_server_not_set', 'URL 格式无效': 'url_invalid', 'URL 过长': 'url_invalid', '文件不存在': 'file_not_found', '内存不足': 'no_memory', 'WWW OTA 已在运行': 'busy' +}; +const FAILURE_LEGACY_PREFIX = [['AP config requires', 'ap_mode_required'], ['AP stations requires', 'ap_mode_required'], ['WiFi scan requires', 'sta_mode_required'], ['CSR device ID must fit', 'csr_invalid']]; +const legacyToken = raw => FAILURE_LEGACY_TEXT[raw] || (FAILURE_LEGACY_PREFIX.find(([prefix]) => raw.startsWith(prefix)) || [])[1]; +const FAILURE_CODE_TOKEN = {1: 'invalid_request', 2: 'not_found', 3: 'no_permission', 4: 'busy', 5: 'timeout', 6: 'no_memory', + 7: 'no_detail', 8: 'not_supported', 9: 'hardware_failed', 10: 'target_unreachable', 11: 'auth_required'}; +const FAILURE_CODE_NAME = {INVALID_ARG: 1, NOT_FOUND: 2, NO_PERMISSION: 3, BUSY: 4, TIMEOUT: 5, NO_MEM: 6, INTERNAL: 7, + NOT_SUPPORTED: 8, HARDWARE: 9, CONNECTION: 10, AUTH: 11}; +const FAILURE_UNCONFIRMED = new Set(['result_unknown', 'commit_unknown']); +function describeFailure({op, phase = 'failed', params = {}, result = null, error = null, token = null, endpoint = '', write} = {}) { + const ready = typeof i18n !== 'undefined' && typeof t === 'function'; + // numbers the device sends in `data` (e.g. an HTTP status) can fill the placeholders of the reason's text; the caller's params win + const body = result?.data ?? error?.data; // an error made from an answer carries the answer's data + const sent = Object.fromEntries(Object.entries(body && typeof body === 'object' ? body : {}).filter(([, value]) => typeof value === 'number')); + params = {...error?.failureParams, ...sent, ...params}; + const has = (key, values = params) => ready && i18n.complete(key, values); + const anything = new Proxy({}, {get: () => 'x'}); + const exists = key => ready && i18n.complete(key, anything); // the text exists, whatever its placeholders need + const raw = String((error ? error.rawMessage : apiErrorReason(result || {})) || '').trim(); + const rawCode = error ? error.code : result?.code; + const numeric = typeof rawCode === 'number' ? rawCode : FAILURE_CODE_NAME[rawCode]; + const part = key => !exists(key) || has(key); // a part that exists must also be fillable from `params` + const known = tok => !!tok && /^[a-z][a-z0-9_]*$/.test(tok) && + (exists(`failure.reasons.${tok}.why`) || exists(`failure.reasons.${tok}.hint`)) && + part(`failure.reasons.${tok}.why`) && part(`failure.reasons.${tok}.hint`); + // Every failed request is an ApiOperationError; any other exception comes from the page's own code. + const pageError = !!error && error.name !== 'ApiOperationError'; + let tok = token || error?.failureToken || null; + // (a request the page sends as GET can still change something: the caller says so with `write`) + // write: true = changes something (unconfirmed when no answer came); false = a read or a test even if it was sent as POST; unset = by the request method + const writeMayHaveHappened = write === true || (write === undefined && error?.uncertain); + if (!tok && error?.kind) tok = writeMayHaveHappened ? 'result_unknown' : ({network: 'network_lost', timeout: 'timeout', format: 'invalid_response'})[error.kind]; + // A write that ended in an exception without an answer from the device may or may not have happened. + if (!tok && write && error && !(typeof error.code === 'number' && error.code !== 0)) tok = 'result_unknown'; + if (!tok && pageError) tok = 'page_error'; + if (!tok && known(raw)) tok = raw; + if (!tok && known(legacyToken(raw))) tok = legacyToken(raw); + if (!tok) tok = FAILURE_CODE_TOKEN[numeric] || 'no_detail'; + if (!known(tok)) tok = 'no_detail'; // e.g. a reason text whose placeholders the caller could not fill + const unconfirmed = FAILURE_UNCONFIRMED.has(tok); + const kind = unconfirmed ? 'unknown' : phase; // phase: 'failed', or 'blocked' when nothing was sent + const actionKey = [`failure.ops.${op}.${kind}`, `failure.ops.${op}.failed`, `failure.ops.generic.${unconfirmed ? 'unknown' : 'failed'}`].find(key => has(key)); + const action = actionKey ? t(actionKey, params) : ''; + const why = !unconfirmed && has(`failure.reasons.${tok}.why`) ? t(`failure.reasons.${tok}.why`, params) : ''; + const hint = has(`failure.reasons.${tok}.hint`) ? t(`failure.reasons.${tok}.hint`, params) : ''; + let message = ''; + if (action) { + message = why ? (hint ? t('failure.format', {action, why, hint}) : t('failure.formatNoHint', {action, why})) + : (hint ? t('failure.formatUnknown', {action, hint}) : action); + } + if (!message) message = ready ? apiErrorMessage({...(result || {}), rawMessage: raw, code: rawCode}, endpoint) : raw || 'Operation failed'; // language pack not usable + const line = (label, value) => value ? t('failure.details.line', {label: t(`failure.details.${label}`), value}) : ''; + const device = raw && raw !== tok ? raw : ''; + const detail = device || (typeof body?.detail === 'string' ? body.detail : ''); + const stage = typeof body?.stage === 'string' ? body.stage : ''; + // the name of the call, the same way everywhere: storage.mkdir (not /storage/mkdir, not storageMkdir) + const api = String(endpoint || error?.operation || '').replace(/^\/+/, '').replace(/\//g, '.').replace(/([a-z0-9])([A-Z])/g, (_, a, b) => a + '.' + b.toLowerCase()); + const page = pageError && !error.failureToken && typeof error.message === 'string' ? error.message : ''; + // Details are for whoever has to look into it: a reason the page worked out by itself (nothing was sent) has none. + const lines = !ready || !(api || detail || stage || page) ? [] : [ + line('code', tok), line('api', api), line('device', detail), line('stage', stage), line('page', page), + line('time', new Date().toLocaleTimeString([], {hour12: false})) + ].filter(Boolean); + return {message, details: lines.join('\n'), token: tok, uncertain: unconfirmed, raw}; +} class TianShanAPI { constructor() { diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 054dfed..ddd0f96 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -3,19 +3,10 @@ function runtimeText(key) { const translated = typeof t === 'function' ? t('runtimeRepair.' + key) : ''; return translated && translated !== 'runtimeRepair.' + key ? translated : key; } -function runtimeSaveError(result) { - const code = result.data?.error_code || result.rawMessage || result.error || result.message; - const known = ['restart_pending', 'revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; - if (code === 'action_missing') { - const name = result.missingTemplateName || result.data?.missing_template_id; - return name ? t('deleteProtection.actionMissing', {name}) : runtimeText('saveFailed'); - } - if (code === 'template_lookup_failed') return runtimeText('saveFailed'); - return known.includes(code) ? runtimeText(code) : (typeof code === 'string' ? code : runtimeText('saveFailed')); -} async function ruleWriteWithRevision(method, id) { const current = await api.call('automation.rules.get', { id }); - if (current.code !== 0 || !Number.isInteger(current.data?.revision)) return current; + if (current.code !== 0) return {...current, endpoint: 'automation.rules.get'}; // the read failed, not the write + if (!Number.isInteger(current.data?.revision)) return current; return api.call(method, { id, expected_revision: current.data.revision }); } /** @@ -307,17 +298,15 @@ document.getElementById('login-form')?.addEventListener('submit', async (e) => { } else { // 显示错误信息 if (errorEl) { - errorEl.textContent = result.message || t('login.loginFailed'); + errorEl.textContent = failureText({ op: 'account.login', result, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - showToast(result.message || t('login.loginFailed'), 'error'); } } catch (error) { if (errorEl) { - errorEl.textContent = error.message || t('login.networkError'); + errorEl.textContent = failureText({ op: 'account.login', error, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - showToast(t('login.loginFailed') + ': ' + error.message, 'error'); } finally { submitBtn.disabled = false; submitBtn.textContent = t('login.loginButton'); @@ -382,11 +371,11 @@ async function submitPasswordChange() { closePasswordChangeModal(); showToast(t('login.passwordChanged'), 'success'); } else { - errorEl.textContent = result.message || t('toast.saveFailed'); + errorEl.textContent = failureText({ op: 'account.changepassword', result, endpoint: 'auth.change_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || t('login.networkError'); + errorEl.textContent = failureText({ op: 'account.changepassword', error, endpoint: 'auth.change_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -1046,12 +1035,12 @@ async function toggleProtection() { } else { // 恢复原状态 updateProtectionUI(currentRunning); - showToast(t('system.switchFailed') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.protection', result, write: true }); } } catch (e) { // 恢复原状态 updateProtectionUI(currentRunning); - showToast(t('system.switchFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.protection', error: e, write: true }); } finally { if (icon) icon.style.pointerEvents = 'auto'; } @@ -1078,10 +1067,10 @@ async function toggleUsbMux() { updateUsbMuxButton(); showToast(t('system.usbSwitchedTo', { name: displayName }), 'success'); } else { - showToast(t('system.switchFailed') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.usbmux', result, write: true }); } } catch (e) { - showToast(t('system.switchFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.usbmux', error: e, write: true }); } } @@ -1126,10 +1115,10 @@ async function toggleAgxPower() { updateAgxPowerButton(); showToast(toastSuccess, 'success'); } else { - showToast(t('system.agxPowerFail') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.agxpower', result, write: true }); } } catch (e) { - showToast(t('system.agxPowerFail') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.agxpower', error: e, write: true }); } } @@ -1159,10 +1148,10 @@ async function toggleLpmuPower() { // 启动状态检测(传入之前的状态) startLpmuStatePolling(wasOnline); } else { - showToast(t('system.lpmuTriggerFail') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.lpmupower', result, write: true }); } } catch (e) { - showToast(t('system.lpmuTriggerFail') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.lpmupower', error: e, write: true }); } } @@ -1607,11 +1596,11 @@ async function applyTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - showToast((typeof t === 'function' ? t('fan.setFailed', { msg: result.message }) : `设置失败: ${result.message}`), 'error'); + reportFailure({ op: 'fan.settest', result, write: true }); } } catch (e) { console.error('设置测试温度失败:', e); - showToast((typeof t === 'function' ? t('fan.setFailed', { msg: e.message }) : `设置失败: ${e.message}`), 'error'); + reportFailure({ op: 'fan.settest', error: e, write: true }); } } @@ -1629,11 +1618,11 @@ async function clearTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - showToast((typeof t === 'function' ? t('fan.clearFailed', { msg: result.message }) : `清除失败: ${result.message}`), 'error'); + reportFailure({ op: 'fan.cleartest', result, write: true }); } } catch (e) { console.error('清除测试温度失败:', e); - showToast((typeof t === 'function' ? t('fan.clearFailed', { msg: e.message }) : `清除失败: ${e.message}`), 'error'); + reportFailure({ op: 'fan.cleartest', error: e, write: true }); } } @@ -1649,7 +1638,7 @@ async function setFanSpeed(id, speed) { const result = await api.fanSet(id, requested); if (!result || result.code !== 0 || (result.httpStatus && result.httpStatus >= 400)) { console.error('fan.set rejected', result); - showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + reportFailure({ op: 'fan.setspeed', params: { id }, result, endpoint: 'fan.set', write: true }); } else { showToast(t(result.data?.enabled === false ? 'fan.settingDisabled' : 'fan.speedSet', {id, speed: requested}), 'success'); } @@ -1659,11 +1648,11 @@ async function setFanSpeed(id, speed) { console.error('fan.set result unavailable', e); if (fanSpeedDrafts.get(id) === draft) fanSpeedDrafts.delete(id); if (e instanceof ApiOperationError && e.code !== undefined && !e.uncertain) { - showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + reportFailure({ op: 'fan.setspeed', params: { id }, error: e, endpoint: 'fan.set', write: true }); await refreshFans(); return; } - showToast(t('fan.settingUnknown'), 'warning'); + reportFailure({ op: 'fan.setspeed', params: { id }, error: e, endpoint: 'fan.set', write: true }, { type: 'warning' }); // Do not repeat a command whose outcome is unknown. A later status poll // supplies a current snapshot; it cannot prove the outcome of this command. markFanOutputsUnknown(id); @@ -1675,7 +1664,7 @@ async function setFanMode(id, mode) { requireApiSuccess(await api.call('fan.mode', { id: id, mode: mode }), 'call'); showToast(typeof t === 'function' ? t('fan.modeSwitch', { id, mode: t({off:'common.disabled', manual:'common.manual', auto:'fanPage.modeSmart', curve:'fan.curve'}[mode] || 'common.unknown') }) : `风扇 ${id} 模式已切换为 ${mode}`, 'success'); await refreshFans(); - } catch (e) { showToast((typeof t === 'function' ? t('fan.setFanModeFailed', { msg: e.message }) : '设置风扇模式失败: ' + e.message), 'error'); } + } catch (e) { reportFailure({ op: 'fan.setmode', error: e, write: true }); } } function markFanOutputsUnknown(id = null) { @@ -2294,11 +2283,11 @@ async function bindTempVariable() { await loadTempSourceStatus(); } else { - showToast(t('fanPage.bindFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'fanpage.bind', result, write: true }); } } catch (e) { console.error('绑定温度变量失败:', e); - showToast(t('fanPage.bindFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.bind', error: e, write: true }); } } @@ -2315,22 +2304,14 @@ async function unbindTempVariable() { await loadTempSourceStatus(); } else { - showToast(t('fanPage.unbindFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'fanpage.unbind', result, write: true }); } } catch (e) { console.error('解绑温度变量失败:', e); - showToast(t('fanPage.unbindFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.unbind', error: e, write: true }); } } -/** - * 保存 AGX 服务器配置 (保留用于兼容) - */ -async function saveAgxConfig() { - showToast((typeof t === 'function' ? t('toast.agxMovedToBinding') : 'AGX 配置已移至变量绑定'), 'info'); - await loadVariableBindStatus(); -} - /** * 应用风扇曲线 */ @@ -2374,9 +2355,7 @@ async function applyFanCurve() { max_duty: maxDuty }); - if (limitsResult.code !== 0) { - throw new Error(limitsResult.message || t('dataWidget.setDutyLimitFailed')); - } + requireApiSuccess(limitsResult, 'fan.limits'); // 2. 设置曲线(同时传递 hysteresis 和 min_interval,会自动保存到 NVS) const curveResult = await api.call('fan.curve', { @@ -2386,9 +2365,7 @@ async function applyFanCurve() { min_interval: minInterval }); - if (curveResult.code !== 0) { - throw new Error(curveResult.message || t('dataWidget.setCurveFailed')); - } + requireApiSuccess(curveResult, 'fan.curve'); // 3. 切换到曲线模式 const modeResult = await api.call('fan.mode', { @@ -2396,9 +2373,7 @@ async function applyFanCurve() { mode: 'curve' }); - if (modeResult.code !== 0) { - throw new Error(modeResult.message || t('promptRepair.modeFailed')); - } + requireApiSuccess(modeResult, 'fan.mode'); showToast(typeof t === 'function' ? t('fanPage.curveApplied', { id: fanId }) : `风扇 ${fanId} 曲线已应用并保存`, 'success'); closeFanCurveModal(); @@ -2408,7 +2383,7 @@ async function applyFanCurve() { } catch (e) { console.error('应用曲线失败:', e); - showToast((typeof t === 'function' ? t('fanPage.applyCurveFailed') : '应用曲线失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.applycurve', error: e, write: true }); } } @@ -2454,7 +2429,7 @@ async function exportFanCurveConfig() { await api.fileUpload(sdcardPath, blob); showToast(typeof t === 'function' ? t('fanPage.curveExported', { id: fanId, path: sdcardPath }) : `风扇 ${fanId} 曲线配置已导出(本地 + SD 卡 ${sdcardPath})`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('fanPage.curveExportSdFailed', { id: fanId, msg: e.message }) : `风扇 ${fanId} 曲线已保存到本地,SD 卡保存失败: ${e.message}`, 'warning'); + reportFailure({ op: 'fanpage.curveexportsdfailed', params: { id: fanId }, error: e, write: false }, { type: 'warning' }); } } @@ -2519,7 +2494,7 @@ function importFanCurveConfig() { showToast(typeof t === 'function' ? t('fanPage.configImported', { name: file.name }) : `已导入配置文件: ${file.name}`, 'success'); } catch (err) { console.error('导入配置失败:', err); - showToast((typeof t === 'function' ? t('fanPage.importConfigFailed') : '导入配置失败') + ': ' + err.message, 'error'); + reportFailure({ op: 'fanpage.importcurve', error: err, write: true }); } }; @@ -2578,7 +2553,7 @@ async function serviceAction(name, action) { showToast(typeof t === 'function' ? t({start:'promptRepair.serviceStarted', stop:'promptRepair.serviceStopped', restart:'promptRepair.serviceRestarted'}[action], {name}) : `服务 ${name} ${action} 成功`, 'success'); await refreshSystemPage(); } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); + reportFailure({ op: 'service.' + action, params: { name }, error: e, endpoint: 'service.' + action, write: true }); } } @@ -2592,7 +2567,7 @@ async function confirmReboot() { }) .catch((err) => { console.error('Reboot failed:', err); - showToast(t('system.rebootFailed') + ': ' + err.message, 'error'); + reportFailure({ op: 'device.reboot', error: err, endpoint: 'system.reboot', write: true }); }); } } @@ -2643,7 +2618,7 @@ async function refreshSystemLeds() { } catch (e) { if (!pageCurrent()) return; console.error('LED list error:', e); - container.innerHTML = `

${escapeHtml(typeof t === 'function' ? t('common.loadFailedMsg', { msg: e.message }) : '加载失败: ' + e.message)}

`; + container.innerHTML = `

${failureNote({ op: 'led.list', error: e, endpoint: 'led.list', write: false })}

`; } } @@ -2992,29 +2967,6 @@ function evaluateExpression(expression, variables) { } } -/** - * 格式化显示值 - * @param {any} value - 原始值 - * @param {object} config - 格式化配置 { decimals, prefix, suffix, format } - */ -function formatDisplayValue(value, config) { - if (value === null || value === undefined) return '-'; - - const { decimals = 1, prefix = '', suffix = '', format } = config || {}; - - // 自定义格式 - if (format) { - return format.replace('{value}', value).replace('{prefix}', prefix).replace('{suffix}', suffix); - } - - // 数字格式化 - if (typeof value === 'number') { - return prefix + value.toFixed(decimals) + suffix; - } - - return prefix + String(value) + suffix; -} - /** * 校验并安全化组件图标 HTML * 防止损坏的 标签吞噬后续文本(如标签名称) @@ -3420,7 +3372,7 @@ async function refreshLogOnce(widgetId) { } catch (e) { if (!pageCurrent()) return; console.warn('获取日志变量失败:', e); - container.innerHTML = '
' + (typeof t === 'function' ? t('fanPage.readFailed') : '读取失败') + '
'; + container.innerHTML = '
' + failureNote({ op: 'fanpage.readlog', error: e, write: false }) + '
'; } } @@ -3888,6 +3840,7 @@ async function refreshQuickActions() { id="quick-action-${escapeHtml(rule.id)}" data-rule-id="${escapeHtml(rule.id)}" data-allowed="${rule.enabled && rule.allow_manual_trigger && !rule.reference_unresolved && !nohupInfo?.unresolved && rule.pending_change !== 'delete' && rule.runtime_active !== false}" + data-block="${rule.pending_change === 'delete' ? 'rule_pending_delete' : !rule.enabled ? 'rule_disabled' : !rule.allow_manual_trigger ? 'manual_not_allowed' : rule.reference_unresolved ? 'reference_unresolved' : nohupInfo?.unresolved ? 'rule_details_unavailable' : rule.runtime_active === false ? 'rule_not_active' : ''}" data-pending-change="${escapeHtml(rule.pending_change || 'none')}" aria-disabled="${rule.pending_change === 'delete' || rule.runtime_active === false}" data-service="${nohupInfo?.serviceMode ? escapeHtml(nohupInfo.commandId) : ''}" @@ -3910,12 +3863,12 @@ async function refreshQuickActions() { renderQuickActionNotice(container, 'empty'); } } else { - container.innerHTML = '

' + (typeof t === 'function' ? (t('automationPage.loadQuickActionsFailed') || '无法加载快捷操作') : '无法加载快捷操作') + '

'; + container.innerHTML = '

' + failureNote({ op: 'home.quicklist', result, endpoint: 'automation.rules.list', write: false }) + '

'; } } catch (e) { if (!pageCurrent()) return; console.error('Quick actions error:', e); - container.innerHTML = '

' + (typeof t === 'function' ? t('filePage.loadFailed') : '加载失败') + '

'; + container.innerHTML = '

' + failureNote({ op: 'home.quicklist', error: e, endpoint: 'automation.rules.list', write: false }) + '

'; } } @@ -4045,7 +3998,7 @@ async function verifyServiceState(commandId) { } catch (e) { if (!isCurrent()) return; renderServiceCommand(commandId, {}); - showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); + reportFailure({ op: 'service.verify', error: e, write: true }, { type: 'warning' }); } finally { advanceServiceState(commandId); serviceVerifyInFlight.delete(commandId); } } @@ -4084,6 +4037,13 @@ window.stopSystemPageTimers = function() { * 触发快捷操作 * @param {string} ruleId - 规则 ID */ +// Why a quick-action card cannot be started right now ('' when it can): the card knows its rule's state and its service's state. +function quickActionBlockReason(card) { + if (card.dataset.allowed !== 'true') return card.dataset.block || 'rule_details_unavailable'; + if (!card.dataset.service) return ''; + return ({stopped: '', running: 'service_running', ready: 'service_running', starting: 'service_starting', + checking: 'service_checking', stopping: 'service_stopping'})[card.dataset.state] ?? 'service_not_confirmed_stopped'; +} async function triggerQuickAction(ruleId) { const card = document.getElementById(`quick-action-${ruleId}`); if (!card) { @@ -4099,10 +4059,11 @@ async function triggerQuickAction(ruleId) { const now = Date.now(); if (now < _quickActionTriggerCooldownUntil && ruleId !== _quickActionLastTriggeredId) { - showToast((typeof t === 'function' ? t('toast.waitBeforeTrigger') : '请等待几秒后再触发其他模型'), 'warning'); + showToast(t('failure.ops.quick.wait'), 'warning'); return; } + const name = card.querySelector('.quick-action-name')?.textContent.trim() || ruleId; try { // Keep verification and triggering within the same in-flight operation. if (card.dataset.service) advanceServiceState(card.dataset.service); @@ -4118,8 +4079,10 @@ async function triggerQuickAction(ruleId) { card.dataset.state = checked.state || 'unknown'; } catch (_) { card.dataset.state = 'unknown'; } } - if (card.dataset.allowed !== 'true' || (card.dataset.service && card.dataset.state !== 'stopped')) { - showToast(card.dataset.pendingChange === 'delete' ? t('rulePack.pendingDelete') : runtimeText('startBlocked'), 'warning'); return; + const blocked = quickActionBlockReason(card); + if (blocked) { + reportFailure({ op: 'quick.start', phase: 'blocked', params: { name }, token: blocked }, { type: 'warning' }); + return; } if (card.dataset.service) card.dataset.state = 'starting'; @@ -4140,7 +4103,7 @@ async function triggerQuickAction(ruleId) { card.classList.add('is-running'); setTimeout(() => refreshQuickActions(), 2500); } else { - showToast((result.message || (typeof t === 'function' ? t('toast.execFailed') : '执行失败')), 'error'); + reportFailure({ op: 'quick.start', params: { name }, result, endpoint: 'automation.rules.trigger' }); // 恢复原始图标 if (iconEl && originalIcon) { iconEl.innerHTML = originalIcon; @@ -4149,7 +4112,7 @@ async function triggerQuickAction(ruleId) { } catch (e) { console.error('triggerQuickAction error:', e); - showToast(runtimeText('outcomeUnknown'), 'error'); + reportFailure({ op: 'quick.start', params: { name }, error: e, endpoint: 'automation.rules.trigger', write: true }); if (card?.dataset.service) card.dataset.state = 'unknown'; } finally { if (card.dataset.service) advanceServiceState(card.dataset.service); @@ -4326,7 +4289,7 @@ async function quickActionRefreshLog(logFile, hostId) { }); if (!contentEl.isConnected || document.getElementById('quick-log-content') !== contentEl) return; if (result.code !== 0 || !result.data) { - contentEl.textContent = (typeof t === 'function' ? t('automationPage.logFetchFailed') : '[获取失败]') + ' ' + (result.message || 'code=' + result.code); + contentEl.textContent = failureText({ op: 'automationpage.logfetchfailed', result, write: false }); return; } const _empty = typeof t === 'function' ? t('automationPage.logEmptyBracket') : '[空]'; @@ -4338,9 +4301,8 @@ async function quickActionRefreshLog(logFile, hostId) { } } catch (e) { if (!contentEl.isConnected || document.getElementById('quick-log-content') !== contentEl) return; - const _err = typeof t === 'function' ? t('automationPage.logError') : '[错误]'; const _retry = typeof t === 'function' ? t('automationPage.deviceBusyRetry') : '若设备繁忙可稍后重试。'; - contentEl.textContent = _err + ' ' + e.message + '\n\n' + _retry; + contentEl.textContent = failureText({ op: 'home.quicklog', error: e, endpoint: 'ssh.exec', write: false }) + '\n\n' + _retry; } finally { contentEl.dataset.loading = 'false'; } @@ -4475,7 +4437,7 @@ async function quickActionStopProcess(commandId, automatic) { const stopped = data.state === 'stopped'; showToast(runtimeText(stopped ? 'stopped' : 'outcomeUnknown'), stopped ? 'success' : 'warning'); } catch (e) { - if (isCurrent()) { renderServiceCommand(commandId, {}); showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); } + if (isCurrent()) { renderServiceCommand(commandId, {}); reportFailure({ op: 'service.stopprocess', error: e, write: true }, { type: 'warning' }); } } finally { advanceServiceState(commandId); serviceStopInFlight.delete(commandId); await updateQuickActionServiceStatus(); } } @@ -4498,23 +4460,10 @@ async function syncTimeFromBrowser(silent = false) { console.error('Failed to refresh time info:', e); } } else { - if (!silent) showToast(t('toast.timeSyncFailed'), 'error'); - } - } catch (e) { - if (!silent) showToast(t('toast.syncFailed') + ': ' + e.message, 'error'); - } -} - -async function forceNtpSync() { - try { - showToast(t('toast.ntpSyncing'), 'info'); - const result = await api.timeForceSync(); - if (result.data?.syncing) { - showToast(t('toast.ntpStarted'), 'success'); - setTimeout(refreshSystemPage, 3000); + if (!silent) reportFailure({ op: 'system.timesync', result, endpoint: 'time.sync', write: true }); } } catch (e) { - showToast(t('toast.ntpFailed') + ': ' + e.message, 'error'); + if (!silent) reportFailure({ op: 'system.timesync', error: e, write: true }); } } @@ -4563,7 +4512,7 @@ async function applyTimezone() { await refreshSystemPage(); } } catch (e) { - showToast((typeof t === 'function' ? t('toast.setFailed') : '设置失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.timezone', error: e, write: true }); } } @@ -4575,80 +4524,6 @@ async function applyTimezone() { let ledDevices = {}; let ledEffects = []; -async function loadLedPage() { - clearInterval(refreshInterval); - - // 取消系统页面的订阅 - - const content = document.getElementById('page-content'); - content.innerHTML = ` -
-
-

${t('nav.led')}

-
- - - -
-
-
-
${typeof t === 'function' ? t('ledPage.loadingDevices') : '加载设备中...'}
-
-
- `; - - await refreshLedPage(); -} - -async function refreshLedPage() { - const container = document.getElementById('led-devices-grid'); - - try { - const result = await api.ledList(); - - if (result.data && result.data.devices && result.data.devices.length > 0) { - // 存储设备信息 - result.data.devices.forEach(dev => { - ledDevices[dev.name] = dev; - if (dev.current && dev.current.animation) { - selectedEffects[dev.name] = dev.current.animation; - } - // 初始化 LED 状态 - if (dev.current) { - ledStates[dev.name] = dev.current.on || false; - } - }); - - window.ledDevicesCache = result.data.devices; - - // 渲染设备卡片 - container.innerHTML = result.data.devices.map(dev => generateLedDeviceCard(dev)).join(''); - - // 加载字体列表 & 显示色彩校正按钮 - if (result.data.devices.some(d => d.name === 'matrix' || d.layout === 'matrix')) { - loadFontList(); - const ccBtn = document.getElementById('led-page-cc-btn'); - if (ccBtn) ccBtn.style.display = ''; - } - } else { - container.innerHTML = ` -
-
-

${t('ledPage.ledNotFound')}

-

${t('ledPage.ledNotStartedHint')}

-
    -
  • ${t('promptRepair.ledServiceStatus')} (service --status)
  • -
  • ${t('ledPage.checkGpioConfig')}
  • -
-
- `; - } - } catch (e) { - console.error('LED list error:', e); - container.innerHTML = `
${escapeHtml(typeof t === 'function' ? t('common.loadFailedMsg', { msg: e.message }) : '加载失败: ' + e.message)}
`; - } -} - // 色块:[显示色, 下发给灯的颜色, 名称 key 后缀] const LED_PRESETS = [['#ff3b30', '#ff0000', 'Red'], ['#ff6b00', '#ff6600', 'Orange'], ['#ffd60a', '#ffd700', 'Yellow'], ['#00c766', '#00d26a', 'Green'], ['#00e5ff', '#00ffff', 'Cyan'], ['#2d7dff', '#2d7dff', 'Blue'], ['#d630ff', '#d630ff', 'Purple'], ['#ffffff', '#ffffff', 'White'], ['#ffcccc', '#ffcccc', 'Pink'], ['#e0e0e0', '#e0e0e0', 'Gray']]; @@ -4727,7 +4602,7 @@ async function fillColorFromPicker(device, color) { updateLedCardState(device, true); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.fill', error: e, write: true }); } } @@ -4740,19 +4615,7 @@ async function quickFillColor(device, color) { updateLedCardState(device, true, null); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -async function quickStartEffect(device, effect) { - try { - requireApiSuccess(await api.ledEffectStart(device, effect, { speed: 50 }), 'ledEffectStart'); - selectedEffects[device] = effect; - ledStates[device] = true; - updateLedCardState(device, true, effect); - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.fill', error: e, write: true }); } } @@ -4766,7 +4629,7 @@ async function allLedsOff() { ledStates[dev.name] = false; updateLedCardState(dev.name, false); } catch (e) { - failures.push({path: dev.name, message: e.message}); + failures.push({path: dev.name, message: failureText({ op: 'led.clear', error: e, endpoint: 'led.clear', write: true })}); } } showOperationSummary('promptRepair.ledSummary', success, failures); @@ -4798,90 +4661,6 @@ function updateLedCardState(device, isOn, effect = undefined) { }); } -// 颜色选择模态框 -function openColorModal(device) { - const deviceData = window.ledDevicesCache?.find(d => d.name === device); - const current = deviceData?.current || {}; - const currentColor = current.color || {r: 255, g: 0, b: 0}; - const colorHex = '#' + - currentColor.r.toString(16).padStart(2, '0') + - currentColor.g.toString(16).padStart(2, '0') + - currentColor.b.toString(16).padStart(2, '0'); - - const modal = document.getElementById('led-modal'); - const title = document.getElementById('led-modal-title'); - const body = document.getElementById('led-modal-body'); - - title.textContent = `${device} - ${typeof t === 'function' ? t('ui.colorSettings') : '颜色设置'}`; - body.innerHTML = ` - - `; - - modal.classList.remove('hidden'); -} - -async function applyColorFromModal(device) { - const color = document.getElementById(`modal-color-picker-${device}`)?.value || '#ffffff'; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -async function quickFillFromModal(device, color) { - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -// 更新开关按钮状态 -function updateToggleButton(device, isOn) { - // 更新新版卡片 - updateLedCardState(device, isOn); - - // 旧版兼容 - const btn = document.getElementById(`toggle-${device}`); - if (btn && !btn.classList.contains('led-power-btn')) { - if (isOn) { - btn.classList.add('on'); - btn.innerHTML = ' ' + (typeof t === 'function' ? t('ledPage.on') : '已开启'); - } else { - btn.classList.remove('on'); - btn.innerHTML = ' ' + (typeof t === 'function' ? t('ledPage.off') : '已关闭'); - } - } -} - // 生成 LED 模态框内容 // LED 弹窗小控件:滑块(--p 由 oninput 同步)、颜色色块、分段控件(选中值写进同名隐藏 input) const ledSlider = (id, min, max, val, on) => `
`; @@ -5091,7 +4870,7 @@ async function toggleLedFromModal(device, enabled) { // 更新卡片状态 updateLedCardState(device, ledStates[device], selectedEffects[device]); } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.toggle', error: e, write: true }); // 恢复 UI 状态 const cb = document.getElementById(`modal-device-enabled-${device}`); if (cb) cb.checked = ledStates[device]; @@ -5179,7 +4958,7 @@ async function applyEffectFromModal(device) { showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.starteffect', error: e, write: true }); } } @@ -5191,7 +4970,7 @@ async function stopEffectFromModal(device) { updateLedCardState(device, ledStates[device], null); showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStopFailed') + ': ' + e.message : `停止动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.stopeffect', error: e, write: true }); } } @@ -5209,7 +4988,7 @@ async function displayImageFromModal() { requireApiSuccess(await api.call('led.image', { device: 'matrix', path, center }), 'call'); showToast((typeof t === 'function' ? t('toast.imageDisplayed') : '图像已显示'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledImageFailed') + ': ' + e.message : `显示图像失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.image', error: e, write: true }); } } @@ -5229,7 +5008,7 @@ async function generateQrCodeFromModal() { requireApiSuccess(await api.call('led.qrcode', { device: 'matrix', text, ecc, fg_color: fg, bg_image: bgImage || undefined }), 'call'); showToast((typeof t === 'function' ? t('toast.qrGenerated') : 'QR 码已生成'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledQrFailed') + ': ' + e.message : `生成 QR 码失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.qr', error: e, write: true }); } } @@ -5316,7 +5095,7 @@ async function displayTextFromModal() { requireApiSuccess(await api.call('led.text', params), 'call'); showToast((typeof t === 'function' ? t('toast.textDisplayed') : '文本已显示'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextFailed') + ': ' + e.message : `显示文本失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.text', error: e, write: true }); } } @@ -5326,7 +5105,7 @@ async function stopTextFromModal() { requireApiSuccess(await api.call('led.text.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.textScrollStopped') : '文本滚动已停止'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextStopFailed') + ': ' + e.message : `停止文本失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.stoptext', error: e, write: true }); } } @@ -5390,7 +5169,7 @@ async function applyFilterFromModal() { requireApiSuccess(await api.call('led.filter.start', params), 'call'); showToast(typeof t === 'function' ? t('toast.filterApplied', { filter: filterDisplayName(selectedModalFilter) }) : `滤镜 ${selectedModalFilter} 已应用`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterApplyFailed') + ': ' + e.message : `应用滤镜失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.filterapply', error: e, write: true }); } } @@ -5400,7 +5179,7 @@ async function stopFilterFromModal() { requireApiSuccess(await api.call('led.filter.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.filterStopped') : '滤镜已停止'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterStopFailed') + ': ' + e.message : `停止滤镜失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.filterstop', error: e, write: true }); } } @@ -5534,10 +5313,10 @@ async function applyColorCorrection() { ccInitialConfig = JSON.parse(JSON.stringify(config)); showToast(t('ledPage.ccApplySuccess'), 'success'); } else { - showToast(t('ledPage.ccApplyFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'led.ccapply', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccApplyFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'led.ccapply', error: e, write: true }); } } @@ -5579,10 +5358,10 @@ async function resetColorCorrection() { if (result.code === 0) { showToast(t('ledPage.ccResetSuccess'), 'success'); } else { - showToast(t('ledPage.ccResetFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'led.ccreset', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccResetFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'led.ccreset', error: e, write: true }); } } @@ -5593,10 +5372,10 @@ async function ccExport() { if (result.code === 0) { showToast(t('ledPage.ccExportSuccess'), 'success'); } else { - showToast(t('ledPage.ccExportFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'ledpage.ccexportfailed', result, write: false }); } } catch (e) { - showToast(t('ledPage.ccExportFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'ledpage.ccexportfailed', error: e, write: false }); } } @@ -5609,33 +5388,18 @@ async function ccImport() { await loadColorCorrectionConfig(); showToast(t('ledPage.ccImportSuccess'), 'success'); } else { - showToast(t('ledPage.ccImportFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'led.ccimport', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccImportFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'led.ccimport', error: e, write: true }); } } -function getDeviceIcon(name) { - const icons = { - 'touch': '', - 'board': '', - 'matrix': '' - }; - return icons[name.toLowerCase()] || ''; -} - function getDeviceDescription(name) { const keys = { 'touch': 'ledPage.deviceTouch', 'board': 'ledPage.deviceBoard', 'matrix': 'ledPage.deviceMatrix' }; return t(keys[name.toLowerCase()] || 'ledPage.deviceDefault'); } -/** 程序动画模态框内使用的 RemixIcon(无 emoji,用已纳入 minimal 字体的 ri-play-line) */ -function getEffectIconRemix(name) { - if (!name) return ''; - return ' '; -} - /** 动画名称首字母大写(用于更多动画弹窗展示) */ function filterDisplayName(name) { const keys = {"pulse": "ledPage.filterPulse", "breathing": "ledPage.filterBreathing", "blink": "ledPage.filterBlink", "wave": "ledPage.filterWave", "scanline": "ledPage.filterScanline", "glitch": "ledPage.filterGlitch", "rainbow": "ledPage.filterRainbow", "sparkle": "ledPage.filterSparkle", "plasma": "ledPage.filterPlasma", "sepia": "ledPage.filterSepia", "posterize": "ledPage.filterPosterize", "contrast": "ledPage.filterContrast", "invert": "ledPage.filterInvert", "grayscale": "ledPage.filterGrayscale"}; @@ -5653,69 +5417,12 @@ const selectedEffects = {}; // 支持颜色参数的动画 const colorSupportedEffects = ['breathing', 'solid', 'rain']; -// 选择动画(旧版兼容,保留) -function selectEffect(device, effect, btn) { - selectedEffects[device] = effect; - - // 更新按钮状态 - const panel = btn.closest('.led-panel'); - panel.querySelectorAll('.effect-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); -} - -// 旧版 showEffectConfig 保持兼容 -function showEffectConfig(device, effect) { - selectedEffects[device] = effect; -} - -async function applyEffect(device) { - const effect = selectedEffects[device]; - if (!effect) { - showToast((typeof t === 'function' ? t('toast.selectAnimation') : '请先选择一个动画'), 'warning'); - return; - } - - const speed = parseInt(document.getElementById(`effect-speed-${device}`)?.value || '50'); - const color = document.getElementById(`effect-color-${device}`)?.value || '#ff0000'; - - try { - const params = { speed }; - // 只有支持颜色的动画才传递颜色参数 - if (colorSupportedEffects.includes(effect)) { - params.color = color; - } - requireApiSuccess(await api.ledEffectStart(device, effect, params), 'ledEffectStart'); - - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - const icon = btn.querySelector('.power-icon'); - if (icon) icon.innerHTML = ''; - } - - // 更新顶部当前动画显示 - const currentAnim = document.getElementById(`current-anim-${device}`); - if (currentAnim) currentAnim.textContent = `▶ ${effectDisplayName(effect)}`; - - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); - } -} - -function updateBrightnessLabel(device, value) { - const label = document.getElementById(`brightness-val-${device}`); - if (label) label.textContent = value; -} - async function setBrightness(device, value) { try { requireApiSuccess(await api.ledBrightness(device, parseInt(value)), 'ledBrightness'); showToast(typeof t === 'function' ? t('toast.ledBrightnessSet', { device: getDeviceDescription(device), value }) : `${device} 亮度: ${value}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledBrightnessFailed', { device: getDeviceDescription(device) }) + ': ' + e.message : `设置 ${device} 亮度失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.brightness', params: { device: getDeviceDescription(device) }, error: e, write: true }); } } @@ -5740,88 +5447,7 @@ async function toggleLed(device) { showToast(typeof t === 'function' ? t('toast.ledTurnedOn', { device: getDeviceDescription(device) }) : `${device} 已开启`, 'success'); } } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); - } -} - -async function ledOn(device, color = '#ffffff') { - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledTurnedOn', { device: getDeviceDescription(device) }) : `${device} 已开启`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledOnFailed') + ': ' + e.message : `开启失败: ${e.message}`, 'error'); - } -} - -async function fillColor(device) { - const color = document.getElementById(`color-${device}`).value; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `${device} 填充失败: ${e.message}`, 'error'); - } -} - -async function quickFill(device, color) { - document.getElementById(`color-${device}`).value = color; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -async function clearLed(device) { - try { - requireApiSuccess(await api.ledClear(device), 'ledClear'); - // 更新状态为关闭 - ledStates[device] = false; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.remove('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOnLight') : '开灯'; - } - showToast(typeof t === 'function' ? t('toast.ledTurnedOff', { device: getDeviceDescription(device) }) : `${device} 已关闭`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledOffFailed') + ': ' + e.message : `关闭失败: ${e.message}`, 'error'); - } -} - -async function startEffect(device, effect) { - try { - requireApiSuccess(await api.ledEffectStart(device, effect), 'ledEffectStart'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.toggle', error: e, write: true }); } } @@ -5837,7 +5463,7 @@ async function stopEffect(device) { delete selectedEffects[device]; showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStopFailed') + ': ' + e.message : `停止动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.stopeffect', error: e, write: true }); } } @@ -5851,7 +5477,7 @@ async function saveLedConfig(device) { showToast(typeof t === 'function' ? t('toast.ledConfigSaved', { device: getDeviceDescription(device) }) : `${device} 配置已保存`, 'success'); } } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledConfigSaveFailed') + ': ' + e.message : `保存配置失败: ${e.message}`, 'error'); + reportFailure({ op: 'led.saveconfig', error: e, write: true }); } } @@ -5927,7 +5553,7 @@ async function loadFilePickerDirectory(path) { listContainer.innerHTML = `
${t('filePage.dirNotExist')}
`; return; } - throw new Error(result.error); + throw new ApiOperationError(result, 'storage.list'); } const files = result.data?.entries || []; @@ -5960,7 +5586,7 @@ async function loadFilePickerDirectory(path) { return `
${escapeHtml(f.name)}
${isDir ? '-' : formatFileSize(f.size)}
`; }).join(''); } catch (e) { - listContainer.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(e.message)}
`; + listContainer.innerHTML = `
${failureNote({ op: 'file.list', error: e, write: false })}
`; } } @@ -5970,7 +5596,7 @@ async function createAndOpenDir(path) { requireApiSuccess(await api.storageMkdir(path), 'storageMkdir'); await loadFilePickerDirectory(path); } catch (e) { - showToast((typeof t === 'function' ? t('toast.createDirFailed', { msg: e.message }) : '创建目录失败: ' + e.message), 'error'); + reportFailure({ op: 'file.createdir', error: e, write: true }); } } @@ -5984,26 +5610,6 @@ function filePickerItemClick(element, path, isDir) { } } -// 文件选择器项目双击 -async function filePickerItemDblClick(path, isDir) { - if (isDir) { - await loadFilePickerDirectory(path); - } else { - // 双击文件直接确认 - filePickerSelectedFile = path; - confirmFilePicker(); - } -} - -// 文件选择器上级目录 -async function filePickerGoUp() { - if (filePickerCurrentPath === '/sdcard' || filePickerCurrentPath === '/') { - return; - } - const parentPath = filePickerCurrentPath.substring(0, filePickerCurrentPath.lastIndexOf('/')) || '/sdcard'; - await loadFilePickerDirectory(parentPath); -} - // 关闭文件选择器 function closeFilePicker() { document.getElementById('file-picker-modal').classList.add('hidden'); @@ -6019,62 +5625,6 @@ function confirmFilePicker() { closeFilePicker(); } -// 显示图像 -async function displayImage() { - const pathInput = document.getElementById('matrix-image-path'); - const centerCheckbox = document.getElementById('matrix-image-center'); - - const path = pathInput.value.trim(); - if (!path) { - showToast((typeof t === 'function' ? t('toast.enterImagePath') : '请输入图像路径'), 'error'); - return; - } - - try { - const result = requireApiSuccess(await api.ledImage(path, 'matrix', centerCheckbox.checked), 'ledImage'); - showToast(typeof t === 'function' ? t('toast.imageDisplayed') : '图像显示成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledImageFailed') + ': ' + e.message : `显示图像失败: ${e.message}`, 'error'); - } -} - -// 生成 QR 码 -async function generateQrCode() { - const textInput = document.getElementById('matrix-qr-text'); - const eccSelect = document.getElementById('matrix-qr-ecc'); - const fgColor = document.getElementById('matrix-qr-fg'); - const bgImageInput = document.getElementById('matrix-qr-bg-image'); - - const text = textInput.value.trim(); - if (!text) { - showToast((typeof t === 'function' ? t('toast.enterQrContent') : '请输入 QR 码内容'), 'error'); - return; - } - - const params = { - ecc: eccSelect.value, - color: fgColor.value - }; - - // 添加背景图(如果有) - const bgImage = bgImageInput.value.trim(); - if (bgImage) { - params.bg_image = bgImage; - } - - try { - const result = requireApiSuccess(await api.ledQrcode(text, params), 'ledQrcode'); - showToast(typeof t === 'function' ? t('toast.qrGenerated') : 'QR 码生成成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledQrFailed') + ': ' + e.message : `生成 QR 码失败: ${e.message}`, 'error'); - } -} - -// 清除 QR 码背景图 -function clearQrBgImage() { - document.getElementById('matrix-qr-bg-image').value = ''; -} - // 加载字体列表 async function loadFontList() { const fontSelect = document.getElementById('matrix-text-font'); @@ -6124,59 +5674,6 @@ async function loadFontList() { } } -// 显示文本 -async function displayText() { - const textInput = document.getElementById('matrix-text-content'); - const fontSelect = document.getElementById('matrix-text-font'); - const alignSelect = document.getElementById('matrix-text-align'); - const colorInput = document.getElementById('matrix-text-color'); - const xInput = document.getElementById('matrix-text-x'); - const yInput = document.getElementById('matrix-text-y'); - const autoPos = document.getElementById('matrix-text-auto-pos'); - const scrollSelect = document.getElementById('matrix-text-scroll'); - const speedInput = document.getElementById('matrix-text-speed'); - const loopCheckbox = document.getElementById('matrix-text-loop'); - - const text = textInput.value.trim(); - if (!text) { - showToast((typeof t === 'function' ? t('toast.enterDisplayText') : '请输入显示文本'), 'error'); - return; - } - - const params = { - device: 'matrix', - font: fontSelect.value, - align: alignSelect.value, - color: colorInput.value, - scroll: scrollSelect.value, // 滚动方向:none/left/right/up/down - speed: parseInt(speedInput.value), - loop: loopCheckbox.checked - }; - - // 添加坐标(如果不是自动定位) - if (!autoPos.checked) { - params.x = parseInt(xInput.value) || 0; - params.y = parseInt(yInput.value) || 0; - } - - try { - const result = requireApiSuccess(await api.ledText(text, params), 'ledText'); - showToast(typeof t === 'function' ? t('toast.textDisplayed') : '文本显示成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextFailed') + ': ' + e.message : `显示文本失败: ${e.message}`, 'error'); - } -} - -// 停止文本 -async function stopText() { - try { - requireApiSuccess(await api.ledTextStop('matrix'), 'ledTextStop'); - showToast((typeof t === 'function' ? t('toast.textStopped') : '文本已停止'), 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextStopFailed') + ': ' + e.message : `停止失败: ${e.message}`, 'error'); - } -} - // 滤镜配置:每个滤镜的参数列表和默认值 const filterConfig = { 'pulse': { params: ['speed'], defaults: { speed: 50 } }, @@ -6216,146 +5713,12 @@ const paramLabels = { 'amount': { get label() { return t('ledPage.paramAmount'); }, min: 0, max: 100, unit: '%' } }; -let selectedFilter = null; - function getParamLabel(paramKey) { const keyMap = { speed: 'ledPage.paramSpeed', intensity: 'ledPage.paramIntensity', wavelength: 'ledPage.paramWavelength', amplitude: 'ledPage.paramAmplitude', direction: 'ledPage.paramDirection', angle: 'ledPage.paramAngle', width: 'ledPage.paramWidth', frequency: 'ledPage.paramFrequency', saturation: 'ledPage.paramSaturation', density: 'ledPage.paramDensity', decay: 'ledPage.paramDecay', scale: 'ledPage.paramScale', levels: 'ledPage.paramLevels', amount: 'ledPage.paramAmount' }; const key = keyMap[paramKey]; return (typeof t === 'function' && key) ? t(key) : (paramLabels[paramKey]?.label || paramKey); } -// 选择滤镜 -function selectFilter(filterName, btnElement) { - selectedFilter = filterName; - - // 高亮当前选中的按钮 - document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('selected')); - if (btnElement) btnElement.classList.add('selected'); - - // 更新显示的滤镜名称 - const nameSpan = document.getElementById('selected-filter-name'); - if (nameSpan) nameSpan.textContent = typeof t === 'function' ? t('led.selectedFilter', { filter: filterDisplayName(filterName) }) : `已选择: ${filterName}`; - - // 启用应用按钮 - const applyBtn = document.getElementById('apply-filter-btn'); - if (applyBtn) applyBtn.disabled = false; - - // 动态生成参数控件 - const paramsDiv = document.getElementById('filter-params'); - const config = filterConfig[filterName]; - - if (config && config.params && config.params.length > 0) { - paramsDiv.style.display = 'block'; - paramsDiv.innerHTML = ''; // 清空现有控件 - - config.params.forEach(param => { - const paramInfo = paramLabels[param]; - const defaultValue = config.defaults[param] ?? 50; - - const row = document.createElement('div'); - row.className = 'config-row'; - row.style.cssText = 'display: flex; align-items: center; gap: 10px; margin-bottom: 10px;'; - - const label = document.createElement('label'); - label.textContent = getParamLabel(param); - label.style.minWidth = '60px'; - - const slider = document.createElement('input'); - slider.type = 'range'; - slider.id = `filter-${param}`; - slider.min = paramInfo.min; - slider.max = paramInfo.max; - slider.value = defaultValue; - slider.style.flex = '1'; - - const valueSpan = document.createElement('span'); - valueSpan.id = `filter-${param}-val`; - valueSpan.textContent = defaultValue + paramInfo.unit; - valueSpan.style.minWidth = '50px'; - valueSpan.style.textAlign = 'right'; - - slider.oninput = () => { - valueSpan.textContent = slider.value + paramInfo.unit; - }; - - row.appendChild(label); - row.appendChild(slider); - row.appendChild(valueSpan); - paramsDiv.appendChild(row); - }); - } else { - paramsDiv.style.display = 'none'; - paramsDiv.innerHTML = ''; - } -} - -// 应用选中的滤镜 -async function applySelectedFilter() { - if (!selectedFilter) { - showToast((typeof t === 'function' ? t('toast.selectFilter') : '请先选择滤镜'), 'error'); - return; - } - - // 收集所有参数 - const params = { device: 'matrix', filter: selectedFilter }; - const config = filterConfig[selectedFilter]; - - if (config && config.params) { - config.params.forEach(param => { - const input = document.getElementById(`filter-${param}`); - if (input) { - let value = parseInt(input.value); - // 根据参数类型转换值 - if (param === 'saturation' || param === 'frequency') { - value = Math.round(value * 2.55); // 0-100 转 0-255 - - } else if (param === 'amount') { - value = value - 50; // 0-100 转 -50 to +50 - } - params[param] = value; - } - }); - } - - try { - requireApiSuccess(await api.call('led.filter.start', params), 'call'); - showToast(typeof t === 'function' ? t('toast.filterApplied', { filter: filterDisplayName(selectedFilter) }) : `已应用滤镜: ${selectedFilter}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterApplyFailed') + ': ' + e.message : `应用滤镜失败: ${e.message}`, 'error'); - } -} - -// 应用滤镜(兼容旧接口) -async function applyFilter(filterName, btnElement) { - selectFilter(filterName, btnElement); - await applySelectedFilter(); -} - -// 停止滤镜 -async function stopFilter() { - try { - requireApiSuccess(await api.ledFilterStop('matrix'), 'ledFilterStop'); - showToast((typeof t === 'function' ? t('toast.filterStopped') : '滤镜已停止'), 'success'); - - // 移除滤镜按钮高亮和选中状态 - document.querySelectorAll('.filter-btn').forEach(btn => { - btn.classList.remove('active'); - btn.classList.remove('selected'); - }); - selectedFilter = null; - - // 重置 UI - const nameSpan = document.getElementById('selected-filter-name'); - if (nameSpan) nameSpan.textContent = typeof t === 'function' ? t('ui.noFilterSelected') : '未选择滤镜'; - const applyBtn = document.getElementById('apply-filter-btn'); - if (applyBtn) applyBtn.disabled = true; - const paramsDiv = document.getElementById('filter-params'); - if (paramsDiv) paramsDiv.style.display = 'none'; - } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterStopFailed') + ': ' + e.message : `停止滤镜失败: ${e.message}`, 'error'); - } -} - // ========================================================================= // 网络页面 // ========================================================================= @@ -6803,7 +6166,7 @@ function showNetworkLpmuAccessResult(info) { } } catch (error) { finished = true; // Reopening must not automatically retry an invalid transcript. - if (modal.isConnected) output.textContent = technicalDetail + t('networkPage.lpmuAccessLogFailed') + ': ' + error.message; + if (modal.isConnected) output.textContent = failureText({ op: 'network.lpmulog', error, endpoint: 'lpmu.access.log', write: false }); } finally { loading = false; } }; modal.querySelector('button').onclick = close; @@ -6859,16 +6222,16 @@ async function submitNetworkLpmuAccess() { if (!pageCurrent()) return; networkLpmuAccessSubmitting = null; stopNetworkLpmuAccessPolling(); - const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); + const spec = { op: 'network.lpmuaccess', error: e, endpoint: 'lpmu.access.start', write: true }; if (typeof e.code === 'number' && e.code !== 0 && !e.uncertain) { - showToast(t('networkPage.lpmuAccessStartFailed') + ': ' + msg, 'error', 5000); + reportFailure(spec); // A rejected start can mean another task is already active. Read // its state rather than inventing a failed remote execution. await refreshNetworkLpmuAccessStatus(false); if (!pageCurrent()) return; if (!networkLpmuAccessUncertain && networkLpmuAccessLastInfo.running) startNetworkLpmuAccessPolling(); } else { - showNetworkLpmuAccessStatusUnknown(msg); + showNetworkLpmuAccessStatusUnknown(failureText(spec)); } } finally { password = ''; @@ -6907,7 +6270,7 @@ async function refreshNetworkLpmuAccessStatus(notifyResult = true) { const result = await api.lpmuAccessStatus(); if (!requestCurrent()) return; if (!result || result.code !== 0) { - throw new Error(result?.message || (typeof t === 'function' ? t('networkPage.lpmuAccessStatusFailed') : '状态获取失败')); + throw new ApiOperationError(result || {}, 'lpmu.access.status'); } const info = renderNetworkLpmuAccessStatus(result.data || {}); @@ -6920,7 +6283,7 @@ async function refreshNetworkLpmuAccessStatus(notifyResult = true) { } catch (e) { if (!requestCurrent()) return; stopNetworkLpmuAccessPolling(); - const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); + const msg = failureText({ op: 'network.lpmuaccessstatus', error: e, endpoint: 'lpmu.access.status', write: false }); showNetworkLpmuAccessStatusUnknown(msg); } finally { if (networkLpmuAccessRequesting === request) networkLpmuAccessRequesting = null; @@ -7103,7 +6466,7 @@ async function setWifiMode() { showToast(typeof t === 'function' ? t('toast.wifiModeChanged', { mode: getWifiModeDisplay(mode) }) : `WiFi 模式已切换为 ${getWifiModeDisplay(mode)}`, 'success'); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.switchFailedMsg', { msg: e.message }) : '切换失败: ' + e.message), 'error'); + reportFailure({ op: 'wifi.mode', error: e, write: true }); } } @@ -7120,7 +6483,7 @@ async function setHostname() { document.getElementById('hostname-input').value = ''; await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.setFailedMsg', { msg: e.message }) : '设置失败: ' + e.message), 'error'); + reportFailure({ op: 'system.hostname', error: e, write: true }); } } @@ -7133,6 +6496,7 @@ async function showWifiScan() { try { const result = await api.wifiScan(); + requireApiSuccess(result, 'network.wifi.scan'); if (result.data && result.data.networks) { if (result.data.networks.length === 0) { container.innerHTML = '
' + t('networkPage.noNetwork') + '
'; @@ -7149,12 +6513,7 @@ async function showWifiScan() { `).join(''); } } catch (e) { - const errorMsg = e.message || ''; - if (errorMsg.includes('STA') || errorMsg.includes('APSTA') || errorMsg.includes('mode')) { - container.innerHTML = `
${t('networkPage.needStaMode')}
`; - } else { - container.innerHTML = `
${t('toast.scanFailed')}: ${escapeHtml(errorMsg)}
`; - } + container.innerHTML = `
${failureNote({ op: 'wifi.scan', error: e, endpoint: 'network.wifi.scan', write: false })}
`; } } @@ -7162,10 +6521,6 @@ function hideWifiScan() { document.getElementById('wifi-scan-section').classList.add('hidden'); } -function getSignalIcon(rssi) { - return ''; -} - function escapeHtml(str) { return String(str ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } @@ -7234,7 +6589,7 @@ function submitWifiConnect(ssid) { showToast((typeof t === 'function' ? t('toast.connecting') : '正在连接...'), 'info'); setTimeout(refreshNetworkPage, 3000); }) - .catch(e => showToast((typeof t === 'function' ? t('toast.connectFailedMsg', { msg: e.message }) : '连接失败: ' + e.message), 'error')); + .catch(e => reportFailure({ op: 'wifi.connect', error: e, write: true })); } async function disconnectWifi() { @@ -7243,7 +6598,7 @@ async function disconnectWifi() { showToast((typeof t === 'function' ? t('toast.wifiDisconnected') : '已断开 WiFi 连接'), 'success'); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.disconnectFailedMsg', { msg: e.message }) : '断开失败: ' + e.message), 'error'); + reportFailure({ op: 'wifi.disconnect', error: e, write: true }); } } @@ -7267,7 +6622,7 @@ async function showApStations() { `).join(''); } } catch (e) { - container.innerHTML = `
${t('toast.fetchFailed')}: ${escapeHtml(e.message)}
`; + container.innerHTML = `
${failureNote({ op: 'wifi.stations', error: e, write: false })}
`; } } @@ -7306,7 +6661,7 @@ async function applyApConfig() { hideApConfig(); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.configFailedMsg', { msg: e.message }) : '配置失败: ' + e.message), 'error'); + reportFailure({ op: 'wifi.applyap', error: e, write: true }); } } @@ -7341,7 +6696,7 @@ async function loadDhcpClients() { } } catch (e) { if (!pageCurrent()) return; - container.innerHTML = `
${t('toast.fetchFailed')}: ${escapeHtml(e.message)}
`; + container.innerHTML = `
${failureNote({ op: 'network.dhcpclients', error: e, write: false })}
`; } } @@ -7357,7 +6712,7 @@ async function toggleNat() { } await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : '操作失败: ' + e.message), 'error'); + reportFailure({ op: 'network.nattoggle', error: e, write: true }); } } @@ -7366,7 +6721,7 @@ async function saveNatConfig() { requireApiSuccess(await api.natSave(), 'natSave'); showToast(typeof t === 'function' ? t('toast.natConfigSaved') : 'NAT 配置已保存', 'success'); } catch (e) { - showToast((typeof t === 'function' ? t('toast.saveFailed') : '保存失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'network.savenat', error: e, write: true }); } } @@ -7538,7 +6893,7 @@ async function batchDelete() { successCount++; } catch (e) { console.error('Delete failed:', path, e); - failures.push({path, message: e.message}); + failures.push({path, message: failureText({ op: 'file.delete', error: e, endpoint: 'storage.delete', write: true })}); } } @@ -7574,7 +6929,7 @@ async function batchDownload() { for (const path of filesToDownload) { const result = await downloadFile(path, true); if (result.started) success++; - else failures.push({path, message: result.error.message}); + else failures.push({path, message: failureText({ op: 'file.download', error: result.error, endpoint: 'storage.download', write: false })}); } return showOperationSummary('promptRepair.downloadSummary', success, failures); } @@ -7587,7 +6942,7 @@ async function mountSdCard() { showToast(typeof t === 'function' ? t('filePage.mountSdSuccess') : 'SD 卡挂载成功', 'success'); await refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('filePage.mountSdFailed') : '挂载失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'file.mountsd', error: e, write: true }); } } @@ -7605,7 +6960,7 @@ async function unmountSdCard() { } await refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('filePage.unmountSdFailed') : '卸载失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'file.unmountsd', error: e, write: true }); } } @@ -7727,7 +7082,7 @@ async function loadDirectory(path) { `; } else { - listContainer.textContent = t('promptRepair.listRefreshFailed') + '\n' + e.message; + listContainer.textContent = failureText({ op: 'file.list', error: e, endpoint: 'storage.list', write: false }); } return {error: e}; } @@ -7818,10 +7173,6 @@ function navigateToPath(path) { loadDirectory(path); } -function getFileIcon(name) { - return ''; -} - function formatFileSize(bytes) { if (bytes === 0) return '0 B'; if (bytes === undefined) return '-'; @@ -7942,11 +7293,12 @@ async function uploadFiles() { apply.onclick = () => { if (verified()) showConfigPackApplyConfirm(path, pack, verified); }; row.appendChild(apply); } else if (pack && state) { - state.textContent = t('promptRepair.packInvalid') + '\n' + apiErrorMessage({error: pack.result_message}); + state.textContent = t('promptRepair.packInvalid') + '\n' + failureText({ op: 'pack.check', token: pack.reason || 'pack_verify_failed', result: { code: 7, message: pack.result_message }, write: false }); } } catch (error) { - failures.push({path, message: error.message}); - if (current() && state) state.textContent = error.message; + const message = failureText({ op: 'file.upload', error, endpoint: 'storage.upload', write: true }); + failures.push({path, message}); + if (current() && state) state.textContent = message; } finally { pending.delete(versions[index]); if (!pending.size) uploadPendingPaths.delete(path); @@ -7956,7 +7308,8 @@ async function uploadFiles() { const summary = showOperationSummary('promptRepair.uploadSummary', success, failures, {invalid}); // Refresh is a separate read: its failure must never change confirmed upload results. if (success && pageCurrent() && document.getElementById('page-content') === page && currentFilePath === destination && document.getElementById('file-list')) { - await refreshFilesPage(); + const refreshed = await refreshFilesPage(); + if (refreshed?.error) document.getElementById('operation-summary')?.append('\n' + t('promptRepair.listRefreshFailed')); } return summary; } @@ -7985,7 +7338,7 @@ async function createNewFolder() { closeNewFolderDialog(); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.createFailedMsg', { msg: e.message }) : '创建失败: ' + e.message), 'error'); + reportFailure({ op: 'file.createdir', error: e, write: true }); } } @@ -8020,7 +7373,7 @@ async function doRename() { closeRenameDialog(); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.renameFailedMsg', { msg: e.message }) : '重命名失败: ' + e.message), 'error'); + reportFailure({ op: 'file.rename', error: e, write: true }); } } @@ -8039,7 +7392,7 @@ async function downloadFile(path, silent = false) { if (!silent) showToast(t('promptRepair.downloadStarted'), 'success'); return {started: true}; } catch (error) { - if (!silent) showToast(t('toast.downloadFailedMsg', {msg: error.message}), 'error'); + if (!silent) reportFailure({ op: 'file.download', error: error, write: false }); return {started: false, error}; } finally { if (anchor) anchor.remove(); @@ -8059,7 +7412,7 @@ async function deleteFile(path) { showToast((typeof t === 'function' ? t('toast.deleteSuccess') : '删除成功'), 'success'); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.deleteFailedMsg', { msg: e.message }) : '删除失败: ' + e.message), 'error'); + reportFailure({ op: 'file.delete', error: e, write: true }); } } @@ -8294,7 +7647,7 @@ async function showDeleteProtection(result, context, pageCurrent) { const state = context.service; const key = state ? deleteServiceMessage(state) : null; bodyHtml = context.type === 'action' && (context.commandName || context.commandId) ? escapeHtml(t('deleteProtection.boundCommand', {command: context.commandName || context.commandId})) + '\n\n' : ''; - bodyHtml += escapeHtml(key ? t('deleteProtection.' + key, {command: context.commandName || context.commandId}) : t('promptRepair.serviceDeleteProtected')); + bodyHtml += escapeHtml(t('deleteProtection.' + (key || 'unconfirmed'), {command: context.commandName || context.commandId || context.name})); if (context.type === 'command') primary = t('common.close'); else { primary = t('deleteProtection.viewCommand'); target = '/commands'; } } else return false; @@ -8358,18 +7711,7 @@ async function verifyStoppedServiceForDelete(commandId, pageCurrent, context = { } async function deleteSshCommandFromBackend(cmdId) { - requireApiSuccess(await api.call('ssh.commands.remove', { id: cmdId }), 'call'); -} - -/** - * 预创建 SSH 命令相关的变量(保留用于兼容,实际由后端处理) - * 后端在保存指令时已自动创建变量 - * @param {string} varName - 变量名前缀(如 "ping_test") - */ -async function preCreateCommandVariables(varName) { - // 后端 ssh.commands.add API 在保存时已自动创建变量 - // 此函数保留作为兼容占位符 - console.debug(`Variables for ${escapeHtml(varName)}.* are managed by backend`); + requireApiSuccess(await api.call('ssh.commands.remove', { id: cmdId }), 'ssh.commands.remove'); } /** @@ -8540,6 +7882,10 @@ async function loadHostSelector() { try { const result = await api.call('ssh.hosts.list', {}); if (!pageCurrent()) return; + if (result.code !== 0) { // an error is not an empty list + container.innerHTML = `

${failureNote({ op: 'host.list', result, endpoint: 'ssh.hosts.list', write: false })}

`; + return; + } const hosts = result.data?.hosts || []; if (hosts.length === 0) { @@ -8588,7 +7934,7 @@ async function loadHostSelector() { } catch (e) { if (!pageCurrent()) return; - container.innerHTML = `

${t('common.loadFailed')}: ${escapeHtml(e.message)}

`; + container.innerHTML = `

${failureNote({ op: 'host.list', error: e, endpoint: 'ssh.hosts.list', write: false })}

`; } } @@ -8692,15 +8038,6 @@ async function updateServiceStatusInList() { if (document.querySelector('.service-mode-status')) startServiceStatusRefresh(); } -/** - * 获取服务状态显示文本 - */ -function getServiceStatusLabel(status) { - const keyMap = { 'ready': 'sshPage.statusReady', 'checking': 'sshPage.statusChecking', 'timeout': 'sshPage.statusTimeout', 'failed': 'sshPage.statusFailed', 'idle': 'sshPage.statusIdle', 'stopped': 'sshPage.statusStopped' }; - const key = keyMap[status]; - return key ? t(key) : t('common.unknown'); -} - function showAddCommandModal() { if (!selectedHostId) { showToast((typeof t === 'function' ? t('ssh.selectHostFirst') : '请先选择一个主机'), 'warning'); @@ -8817,10 +8154,10 @@ async function showCommandVariables(varName) { } renderSourceVarsTable(body, vars); } else { - setSourceVarsMessage(body, escapeHtml(result.message || t('sshPage.getVarFailed')), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', result, endpoint: 'automation.variables.list', write: false })), true); } } catch (error) { - setSourceVarsMessage(body, escapeHtml(error.message), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', error, endpoint: 'automation.variables.list', write: false })), true); } } @@ -9054,7 +8391,8 @@ async function saveCommand() { const readyInterval = parseInt(document.getElementById('cmd-ready-interval')?.value) || 5000; if (!name || !command) { - showToast((typeof t === 'function' ? t('toast.fillCommandNameAndCmd') : '请填写指令名称和命令'), 'warning'); + if (!name) fieldError('cmd-name', t('toast.fillCommandName')); + else fieldError('cmd-command', t('toast.fillCommandText')); return; } @@ -9078,23 +8416,21 @@ async function saveCommand() { /* ID 验证(必填) */ if (!cmdId) { - showToast((typeof t === 'function' ? t('toast.fillCommandId') : '请填写指令 ID'), 'warning'); - document.getElementById('cmd-edit-id').focus(); + fieldError('cmd-edit-id', t('toast.fillCommandId')); return; } if (!validateCommandId(document.getElementById('cmd-edit-id'))) { - showToast((typeof t === 'function' ? t('toast.commandIdInvalid') : '指令 ID 格式不正确'), 'warning'); - document.getElementById('cmd-edit-id').focus(); + fieldError('cmd-edit-id', t('toast.commandIdInvalid')); return; } // 服务模式验证 if (nohup && serviceMode && !readyPattern) { - showToast((typeof t === 'function' ? t('toast.serviceModeRequiresPattern') : '启用服务模式时必须设置就绪匹配模式'), 'warning'); + fieldError('cmd-ready-pattern', t('toast.serviceModeRequiresPattern')); return; } if (nohup && serviceMode && !varName) { - showToast((typeof t === 'function' ? t('toast.serviceModeRequiresVar') : '启用服务模式时必须设置变量名'), 'warning'); + fieldError('cmd-var-name', t('toast.serviceModeRequiresVar')); return; } @@ -9121,6 +8457,7 @@ async function saveCommand() { ...(nohup && serviceMode && readyInterval !== 5000 && { readyInterval }) }; + const params = { name }; try { /* * ID 由用户在前端输入,直接传给后端 @@ -9135,11 +8472,11 @@ async function saveCommand() { if (existingIdx >= 0) { sshCommands[selectedHostId][existingIdx] = cmdData; } - showToast((typeof t === 'function' ? t('toast.commandUpdated') : '指令已更新'), 'success'); + showToast(t('failure.ops.command.save.done', params), 'success'); } else { // 新建模式:添加到本地缓存 sshCommands[selectedHostId].push(cmdData); - showToast((typeof t === 'function' ? t('toast.commandCreated') : '指令已创建'), 'success'); + showToast(t('failure.ops.command.save.done', params), 'success'); } closeCommandModal(); @@ -9147,7 +8484,7 @@ async function saveCommand() { } catch (e) { console.error('Failed to save command:', e); - showToast((typeof t === 'function' ? t('toast.saveCommandFailedMsg', { msg: e.message }) : '保存指令失败: ' + e.message), 'error'); + reportFailure({ op: 'command.save', params, error: e, endpoint: 'ssh.commands.add', write: true }); } } @@ -9304,8 +8641,7 @@ async function doExportSshCommandFromModal(cmdId) { resultBox.textContent = (typeof t === 'function' ? t('toast.exportSuccess') : '导出成功!'); setTimeout(() => hideExportSshCommandModal(), 1000); } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; + packBox(resultBox, { op: 'pack.export', params: { kind: t('failure.kinds.sshCommand'), name: cmdId, file: '' }, error: e, endpoint: 'ssh.commands.export', write: false }); } finally { exportBtn.disabled = false; } @@ -9328,14 +8664,10 @@ async function doExportSshCommand(cmdId, recipientCert, includeHost) { const result = await api.call('ssh.commands.export', params); - if (result.code !== 0) { - throw new Error(result.message || (typeof t === 'function' ? t('toast.exportFailed') : '导出失败')); - } + if (result.code !== 0) throw new ApiOperationError(result, 'ssh.commands.export'); const data = result.data; - if (!data?.tscfg) { - throw new Error(typeof t === 'function' ? t('toast.invalidResponse') : '无效的响应数据'); - } + if (!data?.tscfg) throw new ApiOperationError({}, 'ssh.commands.export', {kind: 'format'}); // 下载文件 const blob = new Blob([data.tscfg], { type: 'application/json' }); @@ -9434,12 +8766,10 @@ async function previewSshCommandImport() { resultBox.textContent = (typeof t === 'function' ? t('ssh.signatureVerified') : '签名验证通过'); importBtn.disabled = false; } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('ssh.cannotVerifyPack') : '无法验证配置包')); + packBox(resultBox, { op: 'pack.verify', params: { kind: t('failure.kinds.sshCommand'), file: file.name }, result, endpoint: 'ssh.commands.import', write: false }); } } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; + packBox(resultBox, { op: 'pack.verify', params: { kind: t('failure.kinds.sshCommand'), file: file.name }, error: e, endpoint: 'ssh.commands.import', write: false }); } } @@ -9456,6 +8786,7 @@ async function confirmSshCommandImport() { return; } + const packParams = { kind: t('failure.kinds.sshCommand'), file: window._importSshCmdFilename || '' }; resultBox.classList.remove('hidden', 'success', 'error', 'warning'); resultBox.textContent = (typeof t === 'function' ? t('ssh.savingConfig') : '正在保存配置...'); importBtn.disabled = true; @@ -9478,18 +8809,16 @@ async function confirmSshCommandImport() { } else { resultBox.className = 'result-box success'; resultBox.innerHTML = `${typeof t === 'function' ? t('securityPage.savedConfig') : '已保存配置'}: ${escapeHtml(data?.id)}
${typeof t === 'function' ? t('ssh.restartToTakeEffect') : '重启系统后生效'}`; - showToast((typeof t === 'function' ? t('toast.importedRestartRequired') : '已导入配置,重启后生效'), 'success'); + showToast(t('failure.ops.pack.import.done', packParams), 'success'); // 不刷新列表,因为还没加载 setTimeout(() => hideImportSshCommandModal(), 2000); } } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('toast.importFailed') : '导入失败')); + packBox(resultBox, { op: 'pack.import', params: packParams, result, endpoint: 'ssh.commands.import' }); importBtn.disabled = false; } } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; + packBox(resultBox, { op: 'pack.import', params: packParams, error: e, endpoint: 'ssh.commands.import', write: true }); importBtn.disabled = false; } } @@ -9502,11 +8831,14 @@ async function deleteCommand(idx) { if (configurationDeletesInFlight.has(key)) return; configurationDeletesInFlight.add(key); const pageCurrent = capturePageValidity(); + const params = { name: cmd.name || cmd.id }; + let removing = false; // once the remove request is on its way, no answer means "unknown" try { if (!await confirmAction(typeof t === 'function' ? t('ui.confirmDeleteCmd', { name: cmd.name }) : `确定要删除指令「${cmd.name}」吗?`, { primary: t('common.delete'), tone: 'danger' }) || !pageCurrent()) return; if (cmd.id) { showToast(t('toast.processing'), 'info'); if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent, {type: 'command', name: cmd.name || cmd.id}) || !pageCurrent()) return; + removing = true; await deleteSshCommandFromBackend(cmd.id); } if (!pageCurrent()) return; @@ -9514,10 +8846,10 @@ async function deleteCommand(idx) { const currentIndex = commands?.findIndex(item => item === cmd || (cmd.id && item.id === cmd.id)); if (currentIndex >= 0) commands.splice(currentIndex, 1); if (selectedHostId === hostId) refreshCommandsList(); - showToast((typeof t === 'function' ? t('toast.commandDeleted') : '指令已删除'), 'success'); + showToast(t('failure.ops.command.delete.done', params), 'success'); } catch (e) { if (pageCurrent() && await showDeleteProtection(e, {type: 'command', name: cmd.name || cmd.id}, pageCurrent)) return; - if (pageCurrent()) showToast((typeof t === 'function' ? t('toast.deleteCommandFailedMsg', { msg: e.message }) : '删除指令失败: ' + e.message), 'error'); + if (pageCurrent()) reportFailure({ op: 'command.delete', params, error: e, endpoint: removing ? 'ssh.commands.remove' : (e.operation || 'ssh.commands.get'), write: removing }); } finally { configurationDeletesInFlight.delete(key); } @@ -9722,7 +9054,7 @@ async function nohupStopProcess() { async function executeNohupHelperCommand(command, info = currentNohupInfo) { const host = window._cmdHostsList?.find(h => h.id === info.hostId); if (!host) { - showToast((typeof t === 'function' ? t('sshPage.hostNotFound') : '主机信息不存在'), 'error'); + reportFailure({ op: 'host.lookup', phase: 'blocked', token: 'list_stale' }); return; } @@ -9742,6 +9074,11 @@ async function executeNohupHelperCommand(command, info = currentNohupInfo) { // ssh.exec 返回 stdout 和 stderr,不是 output if (!ownsExecDisplay(owner)) return; + if (result.code !== 0) { + resultPre.textContent += '\n' + failureText({ op: 'ssh.helper', params: { host: `${host.username}@${host.host}:${host.port}`, user: host.username, port: host.port }, result, endpoint: 'ssh.exec', write: false }); + resultPre.scrollTop = resultPre.scrollHeight; + return owner; + } const stdout = result.data?.stdout || ''; const stderr = result.data?.stderr || ''; if (stdout || stderr) { @@ -9752,7 +9089,7 @@ async function executeNohupHelperCommand(command, info = currentNohupInfo) { } } catch (e) { if (!ownsExecDisplay(owner)) return; - resultPre.textContent += t('promptRepair.executionFailed', {message: e.message}); + resultPre.textContent += '\n' + failureText({ op: 'ssh.helper', params: { host: `${host.username}@${host.host}:${host.port}`, user: host.username, port: host.port }, error: e, endpoint: 'ssh.exec', write: false }); } // 滚动到底部 @@ -9768,13 +9105,13 @@ async function executeNohupHelperCommand(command, info = currentNohupInfo) { async function viewServiceLog(idx, safeName) { const cmd = sshCommands[selectedHostId]?.[idx]; if (!cmd) { - showToast((typeof t === 'function' ? t('sshPage.cmdNotFound') : '命令不存在'), 'error'); + reportFailure({ op: 'command.lookup', phase: 'blocked', token: 'list_stale' }); return; } const host = window._cmdHostsList?.find(h => h.id === selectedHostId); if (!host) { - showToast((typeof t === 'function' ? t('sshPage.hostNotFound') : '主机信息不存在'), 'error'); + reportFailure({ op: 'host.lookup', phase: 'blocked', token: 'list_stale' }); return; } @@ -9802,6 +9139,10 @@ async function viewServiceLog(idx, safeName) { }); if (!ownsExecDisplay(owner)) return; + if (result.code !== 0) { + resultPre.textContent += failureText({ op: 'ssh.helper', params: { host: `${host.username}@${host.host}:${host.port}`, user: host.username, port: host.port }, result, endpoint: 'ssh.exec', write: false }); + return; + } const stdout = result.data?.stdout || ''; const stderr = result.data?.stderr || ''; @@ -9814,7 +9155,7 @@ async function viewServiceLog(idx, safeName) { } } catch (e) { if (!ownsExecDisplay(owner)) return; - resultPre.textContent += typeof t === 'function' ? t('sshPage.getLogFailedMsg', { msg: e.message }) : `获取日志失败: ${e.message}`; + resultPre.textContent += failureText({ op: 'ssh.helper', params: { host: `${host.username}@${host.host}:${host.port}`, user: host.username, port: host.port }, error: e, endpoint: 'ssh.exec', write: false }); } resultPre.scrollTop = resultPre.scrollHeight; @@ -9851,7 +9192,7 @@ async function executeManagedService(cmd, resultPre) { resultPre.textContent += runtimeText('launch_accepted'); } catch (e) { if (ownsExecDisplay(owner)) - resultPre.textContent += e.uncertain || e.kind === 'timeout' || e.code === 5 ? runtimeText('outcomeUnknown') : e.message; + resultPre.textContent += e.uncertain || e.kind === 'timeout' || e.code === 5 ? runtimeText('outcomeUnknown') : failureText({ op: 'service.start', params: { name: cmd.name }, error: e, endpoint: 'ssh.services.start', write: true }); } finally { advanceServiceState(cmd.id); serviceStartInFlight.delete(cmd.id); @@ -9865,13 +9206,13 @@ async function executeCommand(idx) { const host = window._cmdHostsList?.find(h => h.id === selectedHostId); if (!host) { - showToast((typeof t === 'function' ? t('sshPage.hostNotFound') : '主机信息不存在'), 'error'); + reportFailure({ op: 'host.lookup', phase: 'blocked', token: 'list_stale' }); return; } // 检查是否有正在运行的命令(nohup 模式不需要检查) if (currentExecSessionId && !cmd.nohup) { - showToast((typeof t === 'function' ? t('toast.commandRunning') : '有命令正在执行中,请先取消或等待完成'), 'warning'); + reportFailure({ op: 'ssh.run', phase: 'blocked', token: 'ssh_exec_busy', params: { name: cmd.name } }, { type: 'warning' }); return; } @@ -9898,6 +9239,7 @@ async function executeCommand(idx) { } const owner = claimExecDisplay('ssh', resultPre); + owner.target = { host: `${host.username}@${host.host}:${host.port}`, name: cmd.name, user: host.username, port: host.port }; // 对于 nohup 命令,包装命令以实现后台执行,并记录日志和 PID let actualCommand = cmd.command; @@ -9995,8 +9337,9 @@ async function executeCommand(idx) { cancelBtn.style.display = 'none'; return; } - resultPre.textContent = typeof t === 'function' ? t('sshPage.startExecFailedDetail', { msg: e.message }) : `启动执行失败\n\n${e.message}`; - showToast((typeof t === 'function' ? t('toast.startExecFailedMsg', { msg: e.message }) : '启动执行失败: ' + e.message), 'error'); + const failure = describeFailure({ op: 'ssh.run', params: { host: `${host.username}@${host.host}:${host.port}`, name: cmd.name, user: host.username, port: host.port }, error: e, endpoint: 'ssh.exec_stream', write: true }); + resultPre.textContent = failure.message; + showFailureToast(failure.message, failure.details, { type: failure.uncertain ? 'warning' : 'error' }); cancelBtn.style.display = 'none'; currentExecSessionId = null; } @@ -10015,12 +9358,12 @@ async function cancelExecution() { cancelBtn.textContent = typeof t === 'function' ? t('ui.cancelling') : '取消中...'; try { - requireApiSuccess(await api.call('ssh.cancel', { session_id: sessionId }), 'call'); + requireApiSuccess(await api.call('ssh.cancel', { session_id: sessionId }), 'ssh.cancel'); if (!ownsExecDisplay(owner)) return; showToast((typeof t === 'function' ? t('toast.cancelSent') : '取消请求已发送'), 'info'); } catch (e) { if (!ownsExecDisplay(owner)) return; - showToast((typeof t === 'function' ? t('toast.cancelFailedMsg', { msg: e.message }) : '取消失败: ' + e.message), 'error'); + reportFailure({ op: 'ssh.cancel', error: e, endpoint: 'ssh.cancel', write: true }); cancelBtn.disabled = false; cancelBtn.innerHTML = ' ' + (typeof t === 'function' ? t('sshPage.cancelEsc') : '取消 (Esc)'); } @@ -10166,12 +9509,14 @@ function handleSshExecMessage(msg) { case 'ssh_exec_error': if (msg.session_id === owner.sessionId) { - resultPre.textContent += t('promptRepair.sshError', {message: apiErrorMessage({error: msg.error})}); + // the device reports a reason token (and the connection stage / its own text); the page says it in words + const failure = describeFailure({ op: 'ssh.run', params: owner.target || {}, result: { code: 10, error: msg.error, data: { stage: msg.stage, detail: msg.detail } }, endpoint: 'ssh.exec_stream', write: false }); + resultPre.textContent += '\n' + failure.message + '\n'; if (cancelBtn) { cancelBtn.style.display = 'none'; } currentExecSessionId = null; - showToast((typeof t === 'function' ? t('toast.execErrorMsg', { msg: msg.error }) : '执行出错: ' + msg.error), 'error'); + showFailureToast(failure.message, failure.details, { type: 'error' }); } break; @@ -10311,11 +9656,11 @@ async function submitRootPasswordSet() { api.passwordChanged = true; showToast(typeof t === 'function' ? t('securityPage.rootPasswordSetSuccess') : 'root 密码已更新', 'success'); } else { - errorEl.textContent = result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '保存失败'); + errorEl.textContent = failureText({ op: 'account.setroot', result, endpoint: 'auth.root.set_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'); + errorEl.textContent = failureText({ op: 'account.setroot', error, endpoint: 'auth.root.set_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -10351,11 +9696,11 @@ async function submitAdminPasswordSet() { if (confirmPwdEl) confirmPwdEl.value = ''; showToast(typeof t === 'function' ? t('securityPage.adminPasswordSetSuccess') : 'admin 密码已更新', 'success'); } else { - errorEl.textContent = result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '保存失败'); + errorEl.textContent = failureText({ op: 'account.setadmin', result, endpoint: 'auth.admin.set_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'); + errorEl.textContent = failureText({ op: 'account.setadmin', error, endpoint: 'auth.admin.set_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -10372,10 +9717,10 @@ async function resetAdminPasswordToDefault() { if (result.success || result.code === 0 || result.code === 'OK') { showToast(typeof t === 'function' ? t('securityPage.adminPasswordResetSuccess') : 'admin 密码已重置为默认密码 rm01', 'success'); } else { - showToast(result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '操作失败'), 'error'); + reportFailure({ op: 'account.resetadmin', result, endpoint: 'auth.admin.reset_password', write: true }); } } catch (error) { - showToast(error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'), 'error'); + reportFailure({ op: 'account.resetadmin', error, endpoint: 'auth.admin.reset_password', write: true }); } } @@ -10640,18 +9985,7 @@ async function loadSecurityPage() {
${t('securityPage.revokeWarning')}
`, ``)} - - - - +