From 351eab714c6c5643109e3b72c62cbd90956dc25e Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Wed, 22 Jul 2026 09:48:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=B8=E8=A7=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CMakeLists.txt | 16 +- src/node_service/Node_Service.h | 23 + src/node_service/Node_Service_Config.cpp | 36 + src/node_service/Node_Service_State.cpp | 2 + src/node_service/Node_Service_Table.cpp | 533 ++++++++++++++ src/node_service/Node_Service_Web.cpp | 92 +++ src/node_service/web/src/App.tsx | 60 ++ src/node_service/web/src/api.tsx | 15 + .../web/src/components/GroupListPanel.tsx | 182 +---- .../web/src/components/NodeFilters.tsx | 11 +- .../web/src/components/NodeTable.tsx | 44 +- .../src/components/ProtocolObjectTable.tsx | 195 ++++++ .../src/components/ResizableObjectTable.tsx | 21 +- src/node_service/web/src/pages/ConfigPage.tsx | 663 +----------------- src/node_service/web/src/pages/GroupsPage.tsx | 25 +- src/node_service/web/src/pages/NodesPage.tsx | 4 +- src/node_service/web/src/types.tsx | 15 +- src/node_service/web/src/utils.tsx | 127 ++-- src/node_service/web/tsconfig.json | 27 + src/node_service/web/vite.config.ts | 7 + 21 files changed, 1195 insertions(+), 905 deletions(-) create mode 100644 src/node_service/Node_Service_Table.cpp create mode 100644 src/node_service/web/src/components/ProtocolObjectTable.tsx create mode 100644 src/node_service/web/tsconfig.json create mode 100644 src/node_service/web/vite.config.ts diff --git a/.gitignore b/.gitignore index e5801e1..11240ae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ /src/node_service/web/dist/ /src/node_service/web/node_modules/ /serer_state/ +/src/old/ +/out_yml/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e8c9346..0870de4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,12 +72,12 @@ foreach (cur_dir ${list}) file(GLOB_RECURSE cur_srcs CONFIGURE_DEPENDS "${cur_dir}/*.h" "${cur_dir}/*.cpp") add_executable(${target} ${cur_srcs}) target_link_libraries(${target} PRIVATE Base) - if (target STREQUAL "node_service") - add_custom_command(TARGET ${target} POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy_directory - "${cur_dir}/web" - "$/../node_service_web" - VERBATIM - ) - endif () +# if (target STREQUAL "node_service") +# add_custom_command(TARGET ${target} POST_BUILD +# COMMAND "${CMAKE_COMMAND}" -E copy_directory +# "${cur_dir}/web" +# "$/../node_service_web" +# VERBATIM +# ) +# endif () endforeach () diff --git a/src/node_service/Node_Service.h b/src/node_service/Node_Service.h index 5d886d8..046e435 100644 --- a/src/node_service/Node_Service.h +++ b/src/node_service/Node_Service.h @@ -79,6 +79,9 @@ struct Auto_Switch_Config { std::vector managed_groups; std::unordered_map group_strategies; }; +struct Table_Layout_Config { + std::unordered_map> column_orders; +}; struct Service_Config { std::string listen_host = "127.0.0.1"; int listen_port = 18088; @@ -93,6 +96,7 @@ struct Service_Config { int first_delay_ms = 1000; size_t worker_threads = 16; Auto_Switch_Config auto_switch; + Table_Layout_Config table_layout; std::vector targets; }; struct Delay_Result { @@ -222,6 +226,8 @@ json switch_strategy_definitions_to_json(); json switch_strategy_options_to_json(); json auto_switch_config_to_json(const Auto_Switch_Config& config); void load_auto_switch_config(Auto_Switch_Config& config, const json& data); +json table_layout_config_to_json(const Table_Layout_Config& config); +void load_table_layout_config(Table_Layout_Config& config, const json& data); json runtime_config_to_json(const Service_Config& config); void load_runtime_config(Service_Config& config); void save_runtime_config(const Service_Config& config); @@ -276,6 +282,23 @@ int current_check_interval_seconds(Service_State& service); asio::awaitable check_all_nodes_once(Service_State& service, asio::thread_pool& pool); asio::awaitable run_auto_switch_once(Service_State& service, asio::thread_pool& pool); asio::awaitable monitor_refresh_loop(Service_State& service, asio::thread_pool& pool); + +class Table_Protocol_Registry { +public: + static Table_Protocol_Registry& instance(); + json protocol() const; + json protocol(const Service_State& service) const; + json rows(const Service_State& service) const; + json proxy_node_rows(const Service_State& service) const; + json proxy_group_rows(const Service_State& service) const; + json switch_global_setting_rows(const Service_State& service) const; + json switch_strategy_rows(const Service_State& service) const; + json switch_strategy_setting_rows(const Service_State& service) const; +}; +json table_protocol_snapshot(Service_State& service); +json table_rows_snapshot(Service_State& service); +void update_table_column_order(Service_State& service, const std::string& table_id, const std::vector& column_order); +void update_table_cell(Service_State& service, const std::string& table_id, const std::string& row_id, const std::string& column_id, const json& value); json state_snapshot(Service_State& service); std::string html_page(); void start_web_server(Service_State& service, asio::io_context& io, httplib::Server& server); diff --git a/src/node_service/Node_Service_Config.cpp b/src/node_service/Node_Service_Config.cpp index 93d4af1..e9b846f 100644 --- a/src/node_service/Node_Service_Config.cpp +++ b/src/node_service/Node_Service_Config.cpp @@ -95,6 +95,38 @@ void load_auto_switch_config(Auto_Switch_Config& config, const json& data) { } } } + +json table_layout_config_to_json(const Table_Layout_Config& config) { + json result = json::object(); + for (const auto& [table_id, order] : config.column_orders) { + result[table_id] = json{{"column_order", order}}; + } + return result; +} +void load_table_layout_config(Table_Layout_Config& config, const json& data) { + config.column_orders.clear(); + if (!data.is_object()) { + return; + } + for (const auto& item : data.items()) { + const auto& table = item.value(); + if (!table.is_object() || !table.contains("column_order") || !table["column_order"].is_array()) { + continue; + } + std::vector order; + for (const auto& column : table["column_order"]) { + if (column.is_string()) { + auto id = trim(column.get()); + if (!id.empty()) { + order.push_back(std::move(id)); + } + } + } + if (!order.empty()) { + config.column_orders[item.key()] = std::move(order); + } + } +} json runtime_config_to_json(const Service_Config& config) { json result; result["listen_host"] = config.listen_host; @@ -110,6 +142,7 @@ json runtime_config_to_json(const Service_Config& config) { result["first_delay_ms"] = config.first_delay_ms; result["worker_threads"] = config.worker_threads; result["auto_switch"] = auto_switch_config_to_json(config.auto_switch); + result["table_layouts"] = table_layout_config_to_json(config.table_layout); result["switch_strategy_definitions"] = switch_strategy_definitions_to_json(); result["targets"] = json::array(); for (const auto& target : config.targets) { @@ -134,6 +167,9 @@ void load_runtime_config(Service_Config& config) { if (data.contains("auto_switch")) { load_auto_switch_config(config.auto_switch, data["auto_switch"]); } + if (data.contains("table_layouts")) { + load_table_layout_config(config.table_layout, data["table_layouts"]); + } } void save_runtime_config(const Service_Config& config) { save_json_file(config.service_config_path, runtime_config_to_json(config)); diff --git a/src/node_service/Node_Service_State.cpp b/src/node_service/Node_Service_State.cpp index b8d8951..ccd42e4 100644 --- a/src/node_service/Node_Service_State.cpp +++ b/src/node_service/Node_Service_State.cpp @@ -367,6 +367,8 @@ void save_service_index(Service_State& service) { result["enum_options"] = enum_options_to_json(); result["dynamic_filter_options"] = dynamic_filter_options_to_json(service); result["auto_switch"] = auto_switch_config_to_json(service.config.auto_switch); + result["table_protocol"] = Table_Protocol_Registry::instance().protocol(service); + result["table_rows"] = Table_Protocol_Registry::instance().rows(service); result["groups"] = json::array(); for (const auto& name : service.group_order) { const auto item = service.groups.find(name); diff --git a/src/node_service/Node_Service_Table.cpp b/src/node_service/Node_Service_Table.cpp new file mode 100644 index 0000000..76f2385 --- /dev/null +++ b/src/node_service/Node_Service_Table.cpp @@ -0,0 +1,533 @@ +#include "Node_Service.h" +namespace { +json enum_item(std::string_view value, std::string_view label) { + return json{{"value", std::string(value)}, {"label", std::string(label)}}; +} +json column(std::string_view id, std::string_view label, std::string_view type, int width, bool visible, bool sortable, bool filterable, std::string_view sort_rule = "none", std::string_view filter_type = "none", std::string_view enum_name = "", std::string_view label_field = "", std::string_view align = "left", bool editable = false) { + json result; + result["id"] = std::string(id); + result["label"] = std::string(label); + result["type"] = std::string(type); + result["width"] = width; + result["visible"] = visible; + result["sortable"] = sortable; + result["filterable"] = filterable; + result["sort_rule"] = std::string(sort_rule); + result["filter_type"] = std::string(filter_type); + result["align"] = std::string(align); + result["editable"] = editable; + if (!enum_name.empty()) { + result["enum"] = std::string(enum_name); + } + if (!label_field.empty()) { + result["label_field"] = std::string(label_field); + } + return result; +} +json table(std::string_view id, std::string_view name, std::string_view row_id_field, std::string_view default_sort_column, std::string_view default_sort_direction, json columns, json keyword_fields) { + json result; + result["id"] = std::string(id); + result["name"] = std::string(name); + result["row_id_field"] = std::string(row_id_field); + result["default_sort"] = json{{"column", std::string(default_sort_column)}, {"direction", std::string(default_sort_direction)}}; + result["columns"] = std::move(columns); + result["keyword_fields"] = std::move(keyword_fields); + result["column_order_editable"] = true; + return result; +} +void apply_column_order(json& table, const Table_Layout_Config& layout) { + const auto table_id = table.value("id", std::string{}); + const auto item = layout.column_orders.find(table_id); + if (item == layout.column_orders.end() || !table.contains("columns") || !table["columns"].is_array()) { + return; + } + std::unordered_map columns; + for (const auto& column : table["columns"]) { + const auto id = column.value("id", std::string{}); + if (!id.empty()) { + columns[id] = column; + } + } + json ordered = json::array(); + std::set used; + for (const auto& id : item->second) { + const auto found = columns.find(id); + if (found != columns.end()) { + ordered.push_back(found->second); + used.insert(id); + } + } + for (const auto& column : table["columns"]) { + const auto id = column.value("id", std::string{}); + if (!id.empty() && !used.contains(id)) { + ordered.push_back(column); + } + } + table["columns"] = std::move(ordered); +} +json edit_schema(std::string_view type, std::string_view enum_name = "", double min_value = 0.0, double max_value = 0.0, double step = 1.0) { + json result; + result["type"] = std::string(type); + if (!enum_name.empty()) { + result["enum"] = std::string(enum_name); + } + if (type == "number") { + result["min"] = min_value; + result["max"] = max_value; + result["step"] = step; + } + return result; +} +std::string risk_text_from_node(const json& node) { + const int risk = node.value("risk", -1); + const auto level = node.value("risk_level", std::string{}); + if (risk < 0) { + return level.empty() ? std::string("-") : level; + } + return level.empty() ? std::format("{}", risk) : std::format("{} {}", risk, level); +} +std::string ai_text_from_node(const json& node) { + const int score = node.value("ai_score", -1); + return score < 0 ? std::string("-") : std::format("{} 星", score); +} +json rate_value_from_node(const json& node) { + json result; + result["check_count"] = node.value("check_count", node.value("samples", 0)); + result["pass_count"] = node.value("pass_count", node.value("success_count", 0)); + result["success_rate"] = node.value("success_rate", 0.0); + result["recent_check_count"] = node.value("recent_check_count", 0); + result["recent_pass_count"] = node.value("recent_pass_count", 0); + result["recent_success_rate"] = node.value("recent_success_rate", 0.0); + return result; +} +json node_row_values(const Node_State& node, const std::vector& groups) { + const auto raw = node_to_json(node); + json values = raw; + values["id"] = node.name; + values["risk_text"] = risk_text_from_node(raw); + values["ai_text"] = ai_text_from_node(raw); + values["rate_summary"] = rate_value_from_node(raw); + values["proxy_groups"] = groups; + values["probe_summary"] = raw.value("probes", json::array()); + return values; +} +json group_row_values(const Group_Switch_State& group) { + const auto raw = group_to_json(group); + json values = raw; + values["id"] = group.name; + values["candidate_count"] = group.candidates.size(); + values["managed_category"] = group.managed ? "managed" : "unmanaged"; + values["managed_category_label"] = group.managed ? "受控" : "未受控"; + values["switchable_category"] = group.switchable ? "switchable" : "not_switchable"; + values["switchable_category_label"] = group.switchable ? "可切换" : "不可切换"; + values["type_category"] = group.type.empty() ? "unknown" : group.type; + values["type_category_label"] = group.type.empty() ? "未知" : group.type; + values["_edit"] = json::object(); + values["_edit"]["strategy"] = edit_schema("enum", "switch_strategy"); + return values; +} +json row(std::string_view id, json values, json raw) { + json result; + result["id"] = std::string(id); + result["values"] = std::move(values); + result["raw"] = std::move(raw); + return result; +} +std::unordered_map> make_node_group_map(const Service_State& service) { + std::unordered_map> result; + for (const auto& group_name : service.group_order) { + const auto group_item = service.groups.find(group_name); + if (group_item == service.groups.end()) { + continue; + } + for (const auto& node_name : group_item->second.candidates) { + result[node_name].push_back(group_item->second.name); + } + } + return result; +} +json proxy_group_options(const Service_State& service) { + json result = json::array(); + for (const auto& name : service.group_order) { + result.push_back(enum_item(name, name)); + } + return result; +} +json protocol_enums(const Service_State* service) { + json result = enum_options_to_json(); + result["rate_category"] = result.value("rate_category", json::array()); + result["proxy_group"] = service ? proxy_group_options(*service) : json::array(); + result["group_managed_category"] = json::array({enum_item("managed", "受控"), enum_item("unmanaged", "未受控")}); + result["group_switchable_category"] = json::array({enum_item("switchable", "可切换"), enum_item("not_switchable", "不可切换")}); + result["proxy_group_type"] = json::array({enum_item("select", "select"), enum_item("url-test", "url-test"), enum_item("fallback", "fallback"), enum_item("load-balance", "load-balance"), enum_item("unknown", "未知")}); + result["switch_strategy"] = switch_strategy_options_to_json(); + result["switch_setting_type"] = json::array({enum_item("basic", "基础"), enum_item("decision", "切换判定"), enum_item("parameter", "策略参数")}); + return result; +} +json proxy_node_columns() { + return json::array({ + column("action", "操作", "action", 150, true, false, false), + column("name", "节点", "text", 420, true, true, false, "locale_asc"), + column("alive_category", "存活", "enum", 92, true, true, true, "enum_order", "enum", "alive_category", "alive_category_label"), + column("exit_ipv4", "IPv4", "text", 150, true, true, false, "locale_asc"), + column("exit_ipv6", "IPv6", "text", 220, true, true, false, "locale_asc"), + column("ip_version_category", "IP版本", "enum", 120, true, true, true, "enum_order", "enum", "ip_version_category", "ip_version_category_label"), + column("ai_category", "AI", "enum", 120, true, true, true, "enum_order", "enum", "ai_category", "ai_category_label"), + column("ip_type_category", "IDC属性", "enum", 150, true, true, true, "enum_order", "enum", "ip_type_category", "ip_type_category_label"), + column("risk_category", "风控", "enum", 150, true, true, true, "enum_order", "enum", "risk_category", "risk_category_label"), + column("multiplier", "倍率", "number", 110, true, true, false, "number_asc", "none", "", "", "right"), + column("shared_category", "共享", "enum", 140, true, true, true, "enum_order", "enum", "shared_category", "shared_category_label"), + column("native_category", "原生", "enum", 130, true, true, true, "enum_order", "enum", "native_category", "native_category_label"), + column("stability_score", "稳定性", "score", 140, true, true, false, "number_desc", "none", "", "", "right"), + column("stability_category", "稳定性", "enum", 150, false, true, true, "enum_order", "enum", "stability_category", "stability_category_label"), + column("rate_summary", "通过率", "rate_summary", 240, true, true, false, "number_desc"), + column("recent_rate_category", "近期通过率", "enum", 170, false, true, true, "enum_order", "enum", "rate_category", "recent_rate_category_label"), + column("srtt_ms", "SRTT", "ms", 90, true, true, false, "number_asc", "none", "", "", "right"), + column("rttvar_ms", "RTTVAR", "ms", 90, true, true, false, "number_asc", "none", "", "", "right"), + column("last_delay_ms", "最新延迟", "ms", 100, true, true, false, "number_asc", "none", "", "", "right"), + column("consecutive_failures", "连续失败", "integer", 100, true, true, false, "number_desc", "none", "", "", "right"), + column("probe_summary", "探测", "probe_icons", 150, true, false, false), + column("updated_time", "更新时间", "datetime", 190, true, true, false, "locale_desc"), + column("country", "国家", "text", 130, false, true, true, "locale_asc", "dynamic_enum", "country"), + column("provider", "Provider", "text", 150, false, true, true, "locale_asc", "dynamic_enum", "provider"), + column("proxy_groups", "代理组", "multi_enum", 180, false, false, true, "none", "multi_enum", "proxy_group") + }); +} +json proxy_group_columns() { + return json::array({ + column("action", "操作", "action", 120, true, false, false), + column("name", "代理组", "text", 260, true, true, false, "locale_asc"), + column("type_category", "类型", "enum", 120, true, true, true, "enum_order", "enum", "proxy_group_type", "type_category_label"), + column("managed_category", "管理", "enum", 110, true, true, true, "enum_order", "enum", "group_managed_category", "managed_category_label"), + column("switchable_category", "可切换", "enum", 110, true, true, true, "enum_order", "enum", "group_switchable_category", "switchable_category_label"), + column("strategy", "策略", "enum", 190, true, true, true, "enum_order", "enum", "switch_strategy", "", "left", true), + column("current", "当前选择", "text", 300, true, true, false, "locale_asc"), + column("candidate_count", "候选", "integer", 90, true, true, false, "number_desc", "none", "", "", "right"), + column("best_candidate", "最佳候选", "text", 300, true, true, false, "locale_asc"), + column("better_rounds", "更好确认", "integer", 120, true, true, false, "number_desc", "none", "", "", "right"), + column("switch_count", "切换次数", "integer", 100, true, true, false, "number_desc", "none", "", "", "right"), + column("last_switch_time", "上次切换", "datetime", 190, true, true, false, "locale_desc"), + column("last_decision", "决策", "text", 460, true, false, false), + column("last_error", "错误", "text", 300, true, false, false) + }); +} +json form_control(std::string_view key, std::string_view label, std::string_view type, json value, std::string_view help = "", std::string_view enum_name = "", double min_value = 0.0, double max_value = 0.0, double step = 1.0, std::string_view unit = "") { + json result; + result["key"] = std::string(key); + result["label"] = std::string(label); + result["type"] = std::string(type); + result["value"] = std::move(value); + result["help"] = std::string(help); + result["unit"] = std::string(unit); + if (!enum_name.empty()) { + result["enum"] = std::string(enum_name); + } + if (type == "number") { + result["min"] = min_value; + result["max"] = max_value; + result["step"] = step; + } + return result; +} +json form_schema(std::string_view id, std::string_view title, json controls, std::string_view layout = "inline") { + json result; + result["id"] = std::string(id); + result["title"] = std::string(title); + result["layout"] = std::string(layout); + result["controls"] = std::move(controls); + return result; +} +json switch_global_setting_columns() { + return json::array({ + column("name", "表单", "text", 180, true, true, false, "locale_asc"), + column("settings", "配置项", "form", 1120, true, false, false, "none", "none", "", "", "left", true), + column("description", "说明", "text", 420, true, false, false) + }); +} +json switch_strategy_columns() { + return json::array({ + column("name", "策略", "text", 220, true, true, false, "locale_asc"), + column("formula_latex", "LaTeX公式", "text", 420, true, false, false), + column("settings", "策略参数", "form", 760, true, false, false, "none", "none", "", "", "left", true), + column("description", "说明", "text", 460, true, false, false) + }); +} +json switch_global_setting_table() { + return table("switch_global_settings", "自动切换全局配置", "id", "name", "asc", switch_global_setting_columns(), json::array({"name", "description"})); +} +json switch_strategy_table() { + return table("switch_strategies", "策略配置", "id", "name", "asc", switch_strategy_columns(), json::array({"name", "formula_latex", "description"})); +} +json bool_label_json(bool value) { + return value ? json("是") : json("否"); +} +json switch_global_form(const Auto_Switch_Config& config) { + json controls = json::array(); + controls.push_back(form_control("enabled", "启用自动切换", "boolean", config.enabled, "控制自动切换是否执行。")); + controls.push_back(form_control("skip_direct", "跳过 DIRECT", "boolean", config.skip_direct, "自动选择候选时跳过 DIRECT。")); + controls.push_back(form_control("default_strategy", "默认策略", "enum", config.default_strategy, "代理组没有单独指定策略时使用这个策略。", "switch_strategy")); + controls.push_back(form_control("switch_cooldown_seconds", "切换冷却秒数", "number", config.switch_cooldown_seconds, "切换之后多久内不再切换。", "", 0, 3600, 1, "秒")); + controls.push_back(form_control("fail_switch_count", "连续失败切换次数", "number", config.fail_switch_count, "当前节点连续失败达到该值后切换。", "", 1, 100, 1)); + controls.push_back(form_control("better_confirm_rounds", "更好节点确认轮数", "number", config.better_confirm_rounds, "更好候选连续成立多少轮才切换。", "", 1, 100, 1)); + controls.push_back(form_control("keep_score_threshold", "当前节点不切阈值", "number", config.keep_score_threshold, "当前节点分数不低于该值时保持不切。", "", 0, 100, 0.1)); + controls.push_back(form_control("weak_score_threshold", "弱节点阈值", "number", config.weak_score_threshold, "当前节点低于该值时允许按优势切换。", "", 0, 100, 0.1)); + controls.push_back(form_control("weak_improve_margin", "弱节点切换优势", "number", config.weak_improve_margin, "弱节点场景下候选需要高出多少分。", "", 0, 100, 0.1)); + controls.push_back(form_control("better_improve_margin", "明显更好优势", "number", config.better_improve_margin, "正常场景下候选需要高出多少分。", "", 0, 100, 0.1)); + return form_schema("auto_switch.global", "自动切换全局配置", std::move(controls)); +} +json strategy_form(const Auto_Switch_Config& config, const json& definition) { + json controls = json::array(); + const auto strategy_id = definition.value("id", std::string{}); + for (const auto& parameter : definition.value("parameters", json::array())) { + const auto key = parameter.value("key", std::string{}); + if (key.empty()) { + continue; + } + const auto value = switch_strategy_parameter(config, key); + controls.push_back(form_control(key, parameter.value("label", key), parameter.value("type", std::string("number")), value, parameter.value("help", std::string{}), parameter.value("enum", std::string{}), parameter.value("min", 0.0), parameter.value("max", 100.0), parameter.value("step", 0.1), parameter.value("unit", std::string{}))); + } + return form_schema(std::format("strategy.{}", strategy_id), "策略参数", std::move(controls)); +} +json form_values_from_schema(const json& form) { + json result = json::object(); + for (const auto& control : form.value("controls", json::array())) { + const auto key = control.value("key", std::string{}); + if (!key.empty()) { + result[key] = control.value("value", json{}); + } + } + return result; +} +json switch_global_setting_row(const Auto_Switch_Config& config) { + auto form = switch_global_form(config); + json values; + values["id"] = "global"; + values["name"] = "自动切换全局配置"; + values["settings"] = form_values_from_schema(form); + values["description"] = "开关、默认策略、冷却时间和切换判定阈值。"; + values["_forms"] = json::object({{"settings", std::move(form)}}); + return row("global", values, values); +} +json switch_strategy_row(const Auto_Switch_Config& config, const json& definition) { + const auto id = definition.value("id", std::string{}); + auto form = strategy_form(config, definition); + json values; + values["id"] = id; + values["name"] = definition.value("name", id); + values["formula_latex"] = definition.value("formula_latex", std::string{}); + values["settings"] = form_values_from_schema(form); + values["description"] = definition.value("description", std::string{}); + values["_forms"] = json::object({{"settings", std::move(form)}}); + return row(id, values, definition); +} +std::optional json_to_bool(const json& value) { + if (value.is_boolean()) { + return value.get(); + } + if (value.is_string()) { + const auto text = trim(value.get()); + if (text == "true" || text == "1" || text == "是") { + return true; + } + if (text == "false" || text == "0" || text == "否") { + return false; + } + } + return std::nullopt; +} +int json_to_int(const json& value) { + if (value.is_number_integer()) { + return value.get(); + } + if (value.is_number()) { + return static_cast(value.get()); + } + return std::stoi(value.get()); +} +double json_to_double(const json& value) { + if (value.is_number()) { + return value.get(); + } + return std::stod(value.get()); +} +void apply_global_form_value(Auto_Switch_Config& config, const std::string& key, const json& value) { + if (key == "enabled") { + if (auto parsed = json_to_bool(value)) { + config.enabled = *parsed; + } + } + else if (key == "skip_direct") { + if (auto parsed = json_to_bool(value)) { + config.skip_direct = *parsed; + } + } + else if (key == "default_strategy") { + config.default_strategy = normalize_switch_strategy(value.is_string() ? value.get() : std::string{}); + } + else if (key == "switch_cooldown_seconds") { + config.switch_cooldown_seconds = normalize_positive_int(json_to_int(value), 0, 3600); + } + else if (key == "fail_switch_count") { + config.fail_switch_count = normalize_positive_int(json_to_int(value), 1, 100); + } + else if (key == "better_confirm_rounds") { + config.better_confirm_rounds = normalize_positive_int(json_to_int(value), 1, 100); + } + else if (key == "keep_score_threshold") { + config.keep_score_threshold = normalize_score_threshold(json_to_double(value)); + } + else if (key == "weak_score_threshold") { + config.weak_score_threshold = normalize_score_threshold(json_to_double(value)); + } + else if (key == "weak_improve_margin") { + config.weak_improve_margin = normalize_score_threshold(json_to_double(value)); + } + else if (key == "better_improve_margin") { + config.better_improve_margin = normalize_score_threshold(json_to_double(value)); + } +} +void apply_strategy_form_value(Auto_Switch_Config& config, const std::string& key, const json& value) { + config.strategy_parameters[key] = json_to_double(value); + normalize_switch_strategy_parameters(config); +} +json generic_form_protocol() { + return json{{"version", 1}, {"control_types", json::array({"number", "boolean", "string", "enum"})}, {"submit", "cell"}}; +} +json ordered_protocol(Service_State const* service) { + json result; + result["version"] = 1; + result["enums"] = protocol_enums(service); + result["form_protocol"] = generic_form_protocol(); + result["tables"] = json::object(); + result["tables"]["proxy_nodes"] = table("proxy_nodes", "代理节点", "id", "stability_score", "desc", proxy_node_columns(), json::array({"name", "exit_ipv4", "exit_ipv6", "ip_type", "risk_level", "native_type", "shared_users", "provider", "country", "ai_category_label", "ip_type_category_label", "risk_category_label", "shared_category_label", "native_category_label", "ip_version_category_label"})); + result["tables"]["proxy_groups"] = table("proxy_groups", "代理组", "id", "name", "asc", proxy_group_columns(), json::array({"name", "type", "current", "best_candidate", "strategy", "last_decision", "last_error"})); + result["tables"]["switch_global_settings"] = switch_global_setting_table(); + result["tables"]["switch_strategies"] = switch_strategy_table(); + if (service) { + for (auto& item : result["tables"].items()) { + apply_column_order(item.value(), service->config.table_layout); + } + } + return result; +} +} +Table_Protocol_Registry& Table_Protocol_Registry::instance() { + static Table_Protocol_Registry registry; + return registry; +} +json Table_Protocol_Registry::protocol() const { + return ordered_protocol(nullptr); +} +json Table_Protocol_Registry::protocol(const Service_State& service) const { + return ordered_protocol(&service); +} +json Table_Protocol_Registry::rows(const Service_State& service) const { + json result; + result["proxy_nodes"] = proxy_node_rows(service); + result["proxy_groups"] = proxy_group_rows(service); + result["switch_global_settings"] = switch_global_setting_rows(service); + result["switch_strategies"] = switch_strategy_rows(service); + return result; +} +json Table_Protocol_Registry::proxy_node_rows(const Service_State& service) const { + const auto group_map = make_node_group_map(service); + json result = json::array(); + for (const auto& name : service.node_order) { + const auto item = service.nodes.find(name); + if (item == service.nodes.end()) { + continue; + } + const auto groups = group_map.contains(name) ? group_map.at(name) : std::vector{}; + result.push_back(row(name, node_row_values(item->second, groups), node_to_json(item->second))); + } + return result; +} +json Table_Protocol_Registry::proxy_group_rows(const Service_State& service) const { + json result = json::array(); + for (const auto& name : service.group_order) { + const auto item = service.groups.find(name); + if (item == service.groups.end()) { + continue; + } + result.push_back(row(name, group_row_values(item->second), group_to_json(item->second))); + } + return result; +} +json Table_Protocol_Registry::switch_global_setting_rows(const Service_State& service) const { + json result = json::array(); + result.push_back(switch_global_setting_row(service.config.auto_switch)); + return result; +} +json Table_Protocol_Registry::switch_strategy_rows(const Service_State& service) const { + json result = json::array(); + for (const auto& definition : switch_strategy_definitions_to_json()) { + result.push_back(switch_strategy_row(service.config.auto_switch, definition)); + } + return result; +} +json Table_Protocol_Registry::switch_strategy_setting_rows(const Service_State& service) const { + json result = json::array(); + for (const auto& row : switch_global_setting_rows(service)) { + result.push_back(row); + } + for (const auto& row : switch_strategy_rows(service)) { + result.push_back(row); + } + return result; +} +json table_protocol_snapshot(Service_State& service) { + std::lock_guard lock(service.mutex); + return Table_Protocol_Registry::instance().protocol(service); +} +json table_rows_snapshot(Service_State& service) { + std::lock_guard lock(service.mutex); + return Table_Protocol_Registry::instance().rows(service); +} +void update_table_column_order(Service_State& service, const std::string& table_id, const std::vector& column_order) { + std::lock_guard lock(service.mutex); + service.config.table_layout.column_orders[table_id] = column_order; + save_runtime_config(service.config); + save_service_index(service); +} +void update_table_cell(Service_State& service, const std::string& table_id, const std::string& row_id, const std::string& column_id, const json& value) { + std::lock_guard lock(service.mutex); + if (table_id == "proxy_groups" && column_id == "strategy") { + service.config.auto_switch.group_strategies[row_id] = normalize_switch_strategy(value.is_string() ? value.get() : std::string{}); + reload_switch_groups_locked(service); + } + else if (table_id == "switch_global_settings" && row_id == "global" && column_id == "settings") { + if (!value.is_object()) { + throw std::runtime_error("自动切换全局配置表单值必须是对象"); + } + auto& config = service.config.auto_switch; + for (const auto& item : value.items()) { + apply_global_form_value(config, item.key(), item.value()); + } + reload_switch_groups_locked(service); + } + else if (table_id == "switch_strategies" && column_id == "settings") { + if (!value.is_object()) { + throw std::runtime_error("策略参数表单值必须是对象"); + } + auto& config = service.config.auto_switch; + for (const auto& item : value.items()) { + apply_strategy_form_value(config, item.key(), item.value()); + } + reload_switch_groups_locked(service); + } + else if (table_id == "switch_strategy_settings" && column_id == "value") { + auto& config = service.config.auto_switch; + if (row_id == "enabled" || row_id == "skip_direct" || row_id == "default_strategy" || row_id == "switch_cooldown_seconds" || row_id == "fail_switch_count" || row_id == "better_confirm_rounds" || row_id == "keep_score_threshold" || row_id == "weak_score_threshold" || row_id == "weak_improve_margin" || row_id == "better_improve_margin") { + apply_global_form_value(config, row_id, value); + } + else if (starts_with(row_id, "param:")) { + apply_strategy_form_value(config, row_id.substr(6), value); + } + reload_switch_groups_locked(service); + } + else { + throw std::runtime_error(std::format("不支持编辑表格单元:{}.{}.{}", table_id, row_id, column_id)); + } + save_runtime_config(service.config); + save_service_index(service); +} diff --git a/src/node_service/Node_Service_Web.cpp b/src/node_service/Node_Service_Web.cpp index a7f5f6f..23516f8 100644 --- a/src/node_service/Node_Service_Web.cpp +++ b/src/node_service/Node_Service_Web.cpp @@ -30,6 +30,8 @@ json state_snapshot(Service_State& service) { result["dynamic_filter_options"] = dynamic_filter_options_to_json(service); result["auto_switch"] = auto_switch_config_to_json(service.config.auto_switch); result["switch_strategy_definitions"] = switch_strategy_definitions_to_json(); + result["table_protocol"] = Table_Protocol_Registry::instance().protocol(service); + result["table_rows"] = Table_Protocol_Registry::instance().rows(service); result["groups"] = json::array(); for (const auto& name : service.group_order) { const auto item = service.groups.find(name); @@ -93,6 +95,50 @@ void start_web_server(Service_State& service, asio::io_context& io, httplib::Ser server.Get("/api/strategies", [](const httplib::Request&, httplib::Response& res) { res.set_content(json{{"strategies", switch_strategy_definitions_to_json()}, {"options", switch_strategy_options_to_json()}}.dump(), "application/json; charset=utf-8"); }); + server.Get("/api/table-protocol", [&service](const httplib::Request&, httplib::Response& res) { + res.set_content(table_protocol_snapshot(service).dump(), "application/json; charset=utf-8"); + }); + server.Get("/api/table-rows", [&service](const httplib::Request&, httplib::Response& res) { + res.set_content(table_rows_snapshot(service).dump(), "application/json; charset=utf-8"); + }); + + server.Post("/api/table/order", [&service](const httplib::Request& req, httplib::Response& res) { + try { + const auto body = json::parse(req.body, nullptr, true, true); + const auto table_id = body.at("table").get(); + std::vector order; + for (const auto& item : body.at("column_order")) { + if (item.is_string()) { + auto id = trim(item.get()); + if (!id.empty()) { + order.push_back(std::move(id)); + } + } + } + update_table_column_order(service, table_id, order); + service_log(std::format("表格列顺序已保存:{},{} 项", table_id, order.size())); + res.set_content(json{{"success", true}, {"message", "表格列顺序已保存"}, {"state", state_snapshot(service)}}.dump(), "application/json; charset=utf-8"); + } + catch (const std::exception& error) { + res.status = 400; + res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8"); + } + }); + server.Post("/api/table/cell", [&service](const httplib::Request& req, httplib::Response& res) { + try { + const auto body = json::parse(req.body, nullptr, true, true); + const auto table_id = body.at("table").get(); + const auto row_id = body.at("row").get(); + const auto column_id = body.at("column").get(); + update_table_cell(service, table_id, row_id, column_id, body.at("value")); + service_log(std::format("表格单元已保存:{}.{}.{}", table_id, row_id, column_id)); + res.set_content(json{{"success", true}, {"message", "表格单元已保存"}, {"state", state_snapshot(service)}}.dump(), "application/json; charset=utf-8"); + } + catch (const std::exception& error) { + res.status = 400; + res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8"); + } + }); server.Post("/api/config", [&service](const httplib::Request& req, httplib::Response& res) { try { const auto body = json::parse(req.body, nullptr, true, true); @@ -110,6 +156,9 @@ void start_web_server(Service_State& service, asio::io_context& io, httplib::Ser load_auto_switch_config(service.config.auto_switch, body["auto_switch"]); reload_switch_groups_locked(service); } + if (body.contains("table_layouts")) { + load_table_layout_config(service.config.table_layout, body["table_layouts"]); + } service.status_text = "服务配置已保存"; save_runtime_config(service.config); save_service_index(service); @@ -173,6 +222,49 @@ void start_web_server(Service_State& service, asio::io_context& io, httplib::Ser res.set_content(json{{"success", false}, {"error", text}}.dump(), "application/json; charset=utf-8"); } }); + server.Post("/api/group/switch", [&service](const httplib::Request& req, httplib::Response& res) { + try { + const auto body = json::parse(req.body, nullptr, true, true); + const auto group_name = trim(body.at("group").get()); + const auto node_name = trim(body.at("node").get()); + if (group_name.empty() || node_name.empty()) { + throw std::runtime_error("代理组名和节点名不能为空"); + } + Service_Config config; + { + std::lock_guard lock(service.mutex); + const auto group = service.groups.find(group_name); + if (group == service.groups.end()) { + throw std::runtime_error("代理组不存在:" + group_name); + } + if (!contains_name(group->second.candidates, node_name)) { + throw std::runtime_error("节点不在该代理组候选列表:" + node_name); + } + config = service.config; + } + service_log(std::format("手动切换代理组开始:{} -> {}", group_name, node_name)); + switch_group_to_node(config, group_name, node_name); + refresh_switch_group_status(service, std::optional{group_name}); + { + std::lock_guard lock(service.mutex); + auto& group = service.groups[group_name]; + group.current = node_name; + group.last_error.clear(); + group.last_decision = "手动切换到 " + node_name; + group.last_switch_unix = unix_seconds(); + group.last_switch_time = now_string(); + ++group.switch_count; + save_service_index(service); + } + service_log(std::format("手动切换代理组完成:{} -> {}", group_name, node_name)); + res.set_content(json{{"success", true}, {"message", "代理组已手动切换"}, {"state", state_snapshot(service)}}.dump(), "application/json; charset=utf-8"); + } + catch (const std::exception& error) { + res.status = 400; + res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8"); + } + }); + server.Post("/api/groups/refresh", [&service](const httplib::Request& req, httplib::Response& res) { try { std::optional name; diff --git a/src/node_service/web/src/App.tsx b/src/node_service/web/src/App.tsx index 45aaa41..981bcb5 100644 --- a/src/node_service/web/src/App.tsx +++ b/src/node_service/web/src/App.tsx @@ -25,8 +25,11 @@ import { requestAutoSwitch, requestFullDelay, requestGroupRefresh, + requestGroupSwitch, requestNodeRefresh, saveConfig, + updateTableCell, + updateTableOrder, } from "./api.tsx"; import { ConfigPage } from "./pages/ConfigPage.tsx"; import { GroupsPage } from "./pages/GroupsPage.tsx"; @@ -107,6 +110,9 @@ export default function App() { const [refreshingGroups, setRefreshingGroups] = useState>( new Set(), ); + const [switchingNodes, setSwitchingNodes] = useState>( + new Set(), + ); const [notice, setNotice] = useState("等待操作"); const [error, setError] = useState(""); const reload = async () => { @@ -239,6 +245,29 @@ export default function App() { }); } }; + + const switchGroupNode = async (group: string, node: string) => { + const key = `${group}\n${node}`; + setSwitchingNodes((prev) => new Set([...prev, node])); + setNotice(`正在切换代理组:${group} -> ${node}`); + try { + const result = await requestGroupSwitch(group, node); + setNotice(result.message || `代理组已切换:${group}`); + if (result.state) { + setState(result.state); + setSelected((result.state.nodes || []).find((item) => item.name === node) || null); + setSelectedGroup((result.state.groups || []).find((item) => item.name === group) || null); + } else await reload(); + } finally { + setSwitchingNodes((prev) => { + const next = new Set(prev); + next.delete(node); + return next; + }); + void key; + } + }; + const saveSwitch = async (autoSwitch: AutoSwitchConfig) => { setBusy(true); try { @@ -254,6 +283,29 @@ export default function App() { setBusy(false); } }; + + const editTableCell = async (table: string, row: string, column: string, value: unknown) => { + setBusy(true); + try { + const result = await updateTableCell(table, row, column, value); + setNotice(result.message || "表格单元已保存"); + if (result.state) setState(result.state); + else await reload(); + } finally { + setBusy(false); + } + }; + const editTableOrder = async (table: string, columnOrder: string[]) => { + setBusy(true); + try { + const result = await updateTableOrder(table, columnOrder); + setNotice(result.message || "表格列顺序已保存"); + if (result.state) setState(result.state); + else await reload(); + } finally { + setBusy(false); + } + }; const runSwitch = async () => { setBusy(true); try { @@ -289,6 +341,8 @@ export default function App() { onRefreshAll={() => safeRun(runRefresh)} fullRefreshRequesting={fullRefreshRequesting} detailCollapsed={nodeDetailCollapsed} + onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} + onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} /> ) : null} {page === "groups" ? ( @@ -301,6 +355,10 @@ export default function App() { onRefreshNode={(name) => safeRun(() => refreshNode(name))} refreshingNodes={refreshingNodes} groupListCollapsed={groupListCollapsed} + onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} + onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} + onSwitchGroupNode={(group,node) => safeRun(() => switchGroupNode(group,node))} + switchingNodes={switchingNodes} /> ) : null} {page === "config" ? ( @@ -323,6 +381,8 @@ export default function App() { onReload={() => safeRun(reload)} autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh} + onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} + onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} /> ) : null} diff --git a/src/node_service/web/src/api.tsx b/src/node_service/web/src/api.tsx index 3340a23..eceda79 100644 --- a/src/node_service/web/src/api.tsx +++ b/src/node_service/web/src/api.tsx @@ -27,7 +27,22 @@ export async function requestGroupRefresh(name?:string):Promise { const response=await fetch('/api/groups/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body}); return await readJsonResponse(response); } + +export async function requestGroupSwitch(group:string,node:string):Promise { + const response=await fetch('/api/group/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({group,node})}); + return await readJsonResponse(response); +} + export async function requestAutoSwitch():Promise { const response=await fetch('/api/switch',{method:'POST'}); return await readJsonResponse(response); } + +export async function updateTableOrder(table:string,columnOrder:string[]):Promise { + const response=await fetch('/api/table/order',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,column_order:columnOrder})}); + return await readJsonResponse(response); +} +export async function updateTableCell(table:string,row:string,column:string,value:unknown):Promise { + const response=await fetch('/api/table/cell',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,row,column,value})}); + return await readJsonResponse(response); +} diff --git a/src/node_service/web/src/components/GroupListPanel.tsx b/src/node_service/web/src/components/GroupListPanel.tsx index e4b72cb..e61112c 100644 --- a/src/node_service/web/src/components/GroupListPanel.tsx +++ b/src/node_service/web/src/components/GroupListPanel.tsx @@ -1,167 +1,21 @@ -import React from "react"; -import { - Box, - Button, - Chip, - List, - ListItemButton, - ListItemText, - Paper, - Stack, - Typography, -} from "@mui/material"; -import type { ProxyGroup } from "../types.tsx"; -import { switchStrategyLabel } from "../utils.tsx"; -function GroupRefreshButton({ - name, - onRefresh, - refreshing, -}: { - name?: string; - onRefresh: (name?: string) => void; - refreshing: boolean; -}) { - return ( - - ); +import React from 'react'; +import {Button,Chip,MenuItem,Paper,Select,Stack,Typography} from '@mui/material'; +import type {ProxyGroup,ServiceState,TableColumnProtocol,TableRowObject} from '../types.tsx'; +import {ProtocolObjectTable} from './ProtocolObjectTable.tsx'; +import {switchStrategyLabel,tableProtocol,tableRows,timeText} from '../utils.tsx'; +function GroupRefreshButton({name,onRefresh,refreshing}:{name?:string;onRefresh:(name?:string)=>void;refreshing:boolean}) { + return ; } -export function GroupListPanel({ - groups, - selectedGroup, - setSelectedGroup, - onRefreshGroup, - refreshingGroups, -}: { - groups: ProxyGroup[]; - selectedGroup?: ProxyGroup | null; - setSelectedGroup: (group: ProxyGroup) => void; - onRefreshGroup: (name?: string) => void; - refreshingGroups: Set; -}) { - return ( - - - - 代理组列表 - - 显示所有代理组当前选择,点击代理组后左侧显示该组的所有节点。 - - - - - - - - {groups.map((group) => ( - setSelectedGroup(group)} - sx={{ borderRadius: 2, mb: 0.5, alignItems: "flex-start" }} - > - - {group.name} - {group.managed ? ( - - ) : ( - - )} - - - } - secondary={ - - 当前:{group.current || "-"} - - 候选:{group.candidates?.length || 0} | 最佳: - {group.best_candidate || "-"} - - 决策:{group.last_decision || "-"} - {group.last_error ? ( - 错误:{group.last_error} - ) : null} - - } - /> - - - ))} - - - ); +export function GroupListPanel({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onEditTableCell,onEditTableOrder}:{state:ServiceState;selectedGroup?:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { + const protocol=tableProtocol(state,'proxy_groups'); + const rows=tableRows(state,'proxy_groups'); + if (!protocol) { + return 后端没有返回代理组表格协议。; + } + return state={state} protocol={protocol} rows={rows} selectedId={selectedGroup?.name} onSelectRow={(row)=>setSelectedGroup(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey="node_service_group_table_widths" title="代理组表格" description="代理组字段、过滤、排序和策略编辑同样由后端协议定义。" fillHeight extraToolbar={} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/>; } -export function SelectedGroupHeader({ group }: { group?: ProxyGroup | null }) { - if (!group) - return ( - - 未选择代理组 - - 从右侧代理组列表选择一个代理组。 - - - ); - return ( - - - {group.name} - - - - - - - - - - 决策:{group.last_decision || "等待自动切换判定"} - {group.last_error ? `,错误:${group.last_error}` : ""} - - - ); +export function SelectedGroupHeader({group,state,onEditStrategy,onLocateCurrent,onRefreshGroup}:{group?:ProxyGroup | null;state?:ServiceState;onEditStrategy?:(strategy:string)=>void;onLocateCurrent?:()=>void;onRefreshGroup?:(name:string)=>void}) { + if (!group) return 未选择代理组从右侧代理组表格选择一个代理组。; + const options=state?.switch_strategy_definitions || []; + return {group.name}{onRefreshGroup ? : null}决策:{group.last_decision || '等待自动切换判定'}{group.last_error ? `,错误:${group.last_error}` : ''}策略选择是代理组页顶部快捷控件;代理组表格里的策略列仍可编辑并持久化。; } diff --git a/src/node_service/web/src/components/NodeFilters.tsx b/src/node_service/web/src/components/NodeFilters.tsx index 492475d..be4d286 100644 --- a/src/node_service/web/src/components/NodeFilters.tsx +++ b/src/node_service/web/src/components/NodeFilters.tsx @@ -1,11 +1,4 @@ import React from 'react'; -import {Button,FormControl,InputLabel,MenuItem,Select,Stack,TextField} from '@mui/material'; -import type {NodeFilters as NodeFilterState,ServiceState,SortMode} from '../types.tsx'; -import {dynamicList,emptyFilters,enumList} from '../utils.tsx'; -function EnumSelect({label,value,onChange,options,minWidth=150}:{label:string;value:string;onChange:(value:string)=>void;options:{value:string;label:string}[];minWidth?:number}) { - return {label}; -} -export function NodeFilters({state,keyword,setKeyword,filters,setFilters,groupFilter,setGroupFilter,sortBy,setSortBy,showGroupFilter=true}:{state:ServiceState;keyword:string;setKeyword:(value:string)=>void;filters:NodeFilterState;setFilters:(value:NodeFilterState | ((prev:NodeFilterState)=>NodeFilterState))=>void;groupFilter:string;setGroupFilter:(value:string)=>void;sortBy:SortMode;setSortBy:(value:SortMode)=>void;showGroupFilter?:boolean}) { - const setFilter=(key:keyof NodeFilterState,value:string)=>setFilters(prev=>({...prev,[key]:value})); - return setKeyword(event.target.value)} sx={{minWidth:360,flexGrow:1}}/>{showGroupFilter ? ({value:group.name,label:group.name}))} minWidth={190}/> : null}setSortBy(value as SortMode)} options={[{value:'score',label:'按稳定性降序'},{value:'recent_rate',label:'按近期通过率'},{value:'total_rate',label:'按总通过率'},{value:'delay',label:'按最新延迟'},{value:'failure',label:'按连续失败'},{value:'name',label:'按名称'}]} minWidth={190}/>setFilter('alive_category',value)} options={enumList(state,'alive_category')}/>setFilter('stability_category',value)} options={enumList(state,'stability_category')}/>setFilter('recent_rate_category',value)} options={enumList(state,'rate_category')}/>setFilter('ai_category',value)} options={enumList(state,'ai_category')}/>setFilter('ip_type_category',value)} options={enumList(state,'ip_type_category')}/>setFilter('risk_category',value)} options={enumList(state,'risk_category')}/>setFilter('shared_category',value)} options={enumList(state,'shared_category')}/>setFilter('native_category',value)} options={enumList(state,'native_category')}/>setFilter('ip_version_category',value)} options={enumList(state,'ip_version_category')}/>setFilter('country',value)} options={dynamicList(state,'country')}/>setFilter('provider',value)} options={dynamicList(state,'provider')}/>; +export function NodeFilters() { + return <>; } diff --git a/src/node_service/web/src/components/NodeTable.tsx b/src/node_service/web/src/components/NodeTable.tsx index b6f01bd..c097328 100644 --- a/src/node_service/web/src/components/NodeTable.tsx +++ b/src/node_service/web/src/components/NodeTable.tsx @@ -1,29 +1,19 @@ -import React,{useMemo,useState} from 'react'; -import {Box,Paper,Stack,TableCell,TableRow,Typography} from '@mui/material'; -import type {NodeFilters as NodeFilterState,NodeState,ServiceState,SortMode,TableColumn} from '../types.tsx'; -import {NodeRefreshButton,CategoryChip,ProbeIcons,ScoreCell} from './Display.tsx'; -import {ResizableObjectTable,tableCellSx,usePersistentColumns} from './ResizableObjectTable.tsx'; -import {NodeFilters} from './NodeFilters.tsx'; -import {aiText,emptyFilters,groupNodeSet,matchEnumFilters,matchKeyword,msText,multiplierText,nodeCheckCount,nodePassCount,nodeRecentCheckCount,nodeRecentPassCount,nodeRecentRate,nodeTotalRate,nodeColumnDefaults,rateText,riskText,sortNodes,timeText,valueText} from '../utils.tsx'; -function renderRate(node:NodeState) { - return 总 {nodePassCount(node)}/{nodeCheckCount(node)} {rateText(nodeTotalRate(node))}近 {nodeRecentPassCount(node)}/{nodeRecentCheckCount(node)} {rateText(nodeRecentRate(node))}; +import React,{useMemo} from 'react'; +import {Button,Paper,Stack,Typography} from '@mui/material'; +import type {NodeState,ServiceState,TableColumnProtocol,TableRowObject} from '../types.tsx'; +import {NodeRefreshButton} from './Display.tsx'; +import {ProtocolObjectTable} from './ProtocolObjectTable.tsx'; +import {tableProtocol,tableRows} from '../utils.tsx'; +function NodeActionCell({node,onRefreshNode,refreshing,onSwitchNode,switching,isCurrent}:{node:NodeState;onRefreshNode:(name:string)=>void;refreshing:boolean;onSwitchNode?:(name:string)=>void;switching?:boolean;isCurrent?:boolean}) { + return event.stopPropagation()}>{onSwitchNode ? : null}; } -function nodeColumns(compact=false):TableColumn[] { - const columns:TableColumn[]=[{id:'action',label:'操作'},{id:'name',label:'节点'},{id:'alive',label:'alive'},{id:'ipv4',label:'IPv4'},{id:'ipv6',label:'IPv6'},{id:'ipVersion',label:'IP版本'},{id:'ai',label:'AI'},{id:'ipType',label:'IDC属性'},{id:'risk',label:'风控'},{id:'multiplier',label:'倍率'},{id:'shared',label:'共享'},{id:'native',label:'原生'},{id:'score',label:'稳定性'},{id:'rates',label:'通过率'},{id:'srtt',label:'SRTT'},{id:'rttvar',label:'RTTVAR'},{id:'delay',label:'最新延迟'},{id:'failures',label:'连续失败'},{id:'probes',label:'探测'},{id:'updated',label:'更新时间'}]; - return compact ? columns.filter(column=>['action','name','alive','ipv4','ai','risk','multiplier','score','rates','delay'].includes(column.id)) : columns; -} -export function filterAndSortNodes(sourceNodes:NodeState[],state:ServiceState,keyword:string,filters:NodeFilterState,groupFilter:string,sortBy:SortMode):NodeState[] { - const allowed=groupFilter === 'all' ? null : groupNodeSet(state.groups,groupFilter); - return sortNodes(sourceNodes.filter(node=>!allowed || allowed.has(node.name)).filter(node=>matchEnumFilters(node,filters)).filter(node=>matchKeyword(node,keyword)),sortBy); -} -export function NodeTable({state,sourceNodes,selected,setSelected,onRefreshNode,refreshingNodes,compact=false,maxHeight='calc(100vh - 330px)',fillHeight=true,storageKey='node_service_node_table_widths',title='节点表格',enableControls=true,showGroupFilter=true}:{state:ServiceState;sourceNodes:NodeState[];selected?:NodeState | null;setSelected?:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;compact?:boolean;maxHeight?:string;fillHeight?:boolean;storageKey?:string;title?:string;enableControls?:boolean;showGroupFilter?:boolean}) { - const [keyword,setKeyword]=useState(''); - const [filters,setFilters]=useState(emptyFilters); - const [groupFilter,setGroupFilter]=useState('all'); - const [sortBy,setSortBy]=useState('score'); - const nodes=useMemo(()=>filterAndSortNodes(sourceNodes,state,keyword,filters,groupFilter,sortBy),[sourceNodes,state,keyword,filters,groupFilter,sortBy]); - const columns=nodeColumns(compact); - const {widths,setWidth,reset}=usePersistentColumns(storageKey,nodeColumnDefaults); - const cell=(id:string)=>columns.find(column=>column.id === id) || {id,label:id}; - return {title}输入代理节点对象列表后复用同一套过滤、排序、列宽和刷新逻辑。{enableControls ? : null}setSelected?.(node)} sx={{cursor:setSelected ? 'pointer' : 'default'}}>{node.name}{!compact ? {valueText(node.exit_ipv4)} : null}{!compact ? {valueText(node.exit_ipv6)} : null}{!compact ? : null}{!compact ? : null}{multiplierText(node)}{!compact ? : null}{!compact ? : null}{renderRate(node)}{!compact ? {msText(node.srtt_ms)} : null}{!compact ? {msText(node.rttvar_ms)} : null}{msText(node.last_delay_ms)}{!compact ? {node.consecutive_failures || 0} : null}{!compact ? : null}{!compact ? {timeText(node.updated_time)} : null}}/>; +export function NodeTable({state,sourceNodes,selected,setSelected,onRefreshNode,refreshingNodes,maxHeight='calc(100vh - 330px)',fillHeight=true,storageKey='node_service_node_table_widths',title='节点表格',onEditTableCell,onEditTableOrder,highlightedName,scrollToName,scrollSignal,onSwitchNode,switchingNodes}:{state:ServiceState;sourceNodes:NodeState[];selected?:NodeState | null;setSelected?:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;maxHeight?:string;fillHeight?:boolean;storageKey?:string;title?:string;onEditTableCell?:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder?:(table:string,columnOrder:string[])=>void;highlightedName?:string | null;scrollToName?:string | null;scrollSignal?:number;onSwitchNode?:(name:string)=>void;switchingNodes?:Set}) { + const protocol=tableProtocol(state,'proxy_nodes'); + const rows=tableRows(state,'proxy_nodes'); + const sourceNames=useMemo(()=>new Set(sourceNodes.map(node=>node.name)),[sourceNodes]); + const filteredRows=useMemo(()=>rows.filter(row=>sourceNames.has(row.id)),[rows,sourceNames]); + if (!protocol) { + return 后端没有返回代理节点表格协议。; + } + return state={state} protocol={protocol} rows={filteredRows} selectedId={selected?.name} highlightedId={highlightedName} scrollToId={scrollToName} scrollSignal={scrollSignal} onSelectRow={(row)=>setSelected?.(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey={storageKey} title={title} description="表格列、枚举、过滤、排序规则都由后端协议定义。" fillHeight={fillHeight} maxHeight={maxHeight} onEditCell={onEditTableCell ? (row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value) : undefined} onColumnOrderChange={onEditTableOrder}/>; } diff --git a/src/node_service/web/src/components/ProtocolObjectTable.tsx b/src/node_service/web/src/components/ProtocolObjectTable.tsx new file mode 100644 index 0000000..d2ced5c --- /dev/null +++ b/src/node_service/web/src/components/ProtocolObjectTable.tsx @@ -0,0 +1,195 @@ +import React,{useEffect,useMemo,useRef,useState} from 'react'; +import {Box,Button,Chip,FormControlLabel,Menu,MenuItem,Select,Stack,Switch,TableCell,TableRow,TextField,Typography} from '@mui/material'; +import type {FormControlProtocol,FormProtocol,ServiceState,TableCellEditSchema,TableColumn,TableColumnProtocol,TableProtocol,TableRowObject,TableSortDirection} from '../types.tsx'; +import {CategoryChip,ProbeIcons,ScoreCell} from './Display.tsx'; +import {HeaderResizeGrip,ResizableObjectTable,tableCellSx,usePersistentColumns} from './ResizableObjectTable.tsx'; +import {columnDefaultDirection,defaultSortValue,enumOptions,msText,protocolFilterMatch,protocolKeywordMatch,rateText,rowLabel,rowValue,sortProtocolRows,timeText,valueText} from '../utils.tsx'; +function widthDefaults(protocol:TableProtocol):Record { + const result:Record={}; + for (const column of protocol.columns) result[column.id]=Number(column.width || 120); + return result; +} +function splitSort(value:string):{column:string;direction:TableSortDirection} { + const [column,direction]=value.split(':'); + return {column,direction:direction === 'desc' ? 'desc' : 'asc'}; +} +let measureCanvas:HTMLCanvasElement | undefined; +function measureText(text:string):number { + if (typeof document === 'undefined') return text.length * 12; + const canvas=measureCanvas || (measureCanvas=document.createElement('canvas')); + const context=canvas.getContext('2d'); + if (!context) return text.length * 12; + context.font='14px Arial,"Microsoft YaHei",sans-serif'; + return context.measureText(text).width; +} +function cellWidthText(row:TableRowObject,column:TableColumnProtocol,state:ServiceState):string { + const value=rowValue(row,column); + if (column.type === 'action') return '刷新 选择 当前'; + if (column.type === 'rate_summary') return `总 ${Number(value?.pass_count || 0)}/${Number(value?.check_count || 0)} ${rateText(value?.success_rate)} 近 ${Number(value?.recent_pass_count || 0)}/${Number(value?.recent_check_count || 0)} ${rateText(value?.recent_success_rate)}`; + if (column.type === 'probe_icons') return Array.isArray(value) ? value.map(item=>item?.success ? '成功' : '失败').join(' ') : '-'; + if (column.type === 'ms') return msText(value); + if (column.type === 'percent') return rateText(value); + if (column.type === 'datetime') return timeText(value); + if (column.type === 'form') return rowLabel(row,column,state); + return rowLabel(row,column,state); +} +function autoColumnWidths(protocol:TableProtocol,columns:TableColumnProtocol[],rows:TableRowObject[],state:ServiceState):Record { + const result:Record={}; + for (const column of columns) { + const header=measureText(column.label) + 100; + const maxCell=rows.reduce((max,row)=>Math.max(max,measureText(cellWidthText(row,column,state)) + 48),0); + const base=Number(column.width || 120); + const max=Math.max(header,maxCell,base); + result[column.id]=Math.max(70,Math.min(column.type === 'form' ? 1100 : column.type === 'text' ? 620 : 420,Math.ceil(max))); + } + for (const column of protocol.columns) if (!result[column.id]) result[column.id]=Number(column.width || 120); + return result; +} +function renderRateSummary(value:any) { + return 总 {Number(value?.pass_count || 0)}/{Number(value?.check_count || 0)} {rateText(value?.success_rate)}近 {Number(value?.recent_pass_count || 0)}/{Number(value?.recent_check_count || 0)} {rateText(value?.recent_success_rate)}; +} +function enumChip(row:TableRowObject,column:TableColumnProtocol,state:ServiceState) { + const value=String(rowValue(row,column) ?? ''); + return ; +} +function multiEnumCell(row:TableRowObject,column:TableColumnProtocol,state:ServiceState) { + const values=rowValue(row,column); + if (!Array.isArray(values) || !values.length) return <>-; + const options=enumOptions(state,column.enum); + return {values.slice(0,3).map((value:string)=>option.value === value)?.label || value}/>) }{values.length > 3 ? : null}; +} +function renderCell(row:TableRowObject,column:TableColumnProtocol,state:ServiceState,actionRenderer?:((row:TableRowObject)=>React.ReactNode)) { + const value=rowValue(row,column); + if (column.type === 'action') return actionRenderer ? actionRenderer(row) : null; + if (column.type === 'enum') return enumChip(row,column,state); + if (column.type === 'multi_enum') return multiEnumCell(row,column,state); + if (column.type === 'score') return ; + if (column.type === 'ms') return msText(value); + if (column.type === 'percent') return rateText(value); + if (column.type === 'datetime') return timeText(value); + if (column.type === 'form') return rowLabel(row,column,state); + if (column.type === 'rate_summary') return renderRateSummary(value); + if (column.type === 'probe_icons') return ; + if (column.type === 'boolean') return value ? '是' : '否'; + if (column.type === 'integer') return Number(value || 0); + if (column.type === 'number') return value === undefined || value === null || value === '' ? '-' : Number(value).toString(); + return valueText(rowLabel(row,column,state)); +} +function activeFilterLabel(column:TableColumnProtocol,value:string | undefined,state:ServiceState):string { + if (!value || value === 'all') return '过滤'; + if ((column.filter_type === 'enum' || column.filter_type === 'dynamic_enum' || column.filter_type === 'multi_enum') && column.enum) return enumOptions(state,column.enum).find(option=>option.value === value)?.label || value; + return value; +} +function editSchema(row:TableRowObject,column:TableColumnProtocol):TableCellEditSchema | undefined { + if (row.values?._edit?.[column.id]) return row.values._edit[column.id]; + if (!column.editable) return undefined; + if (column.type === 'enum') return {type:'enum',enum:column.enum}; + if (column.type === 'boolean') return {type:'boolean'}; + if (column.type === 'number' || column.type === 'integer' || column.type === 'score') return {type:'number'}; + return {type:'string'}; +} +function controlValue(values:Record,control:FormControlProtocol):unknown { + return values?.[control.key] ?? control.value ?? ''; +} +function nextFormValues(values:Record,control:FormControlProtocol,value:unknown):Record { + return {...(values || {}),[control.key]:value}; +} +function ProtocolForm({form,values,state,onChange}:{form:FormProtocol;values:Record;state:ServiceState;onChange:(value:Record)=>void}) { + if (!form.controls?.length) return 无需参数; + const controlSx={minWidth:120,maxWidth:180}; + const numberSx={width:120}; + const renderControl=(control:FormControlProtocol)=>{ + const value=controlValue(values,control); + if (control.type === 'enum') { + const options=enumOptions(state,control.enum); + return ; + } + if (control.type === 'boolean') { + return event.stopPropagation()} control={onChange(nextFormValues(values,control,event.target.checked))}/>} label={control.label} sx={{mr:1,whiteSpace:'nowrap'}}/>; + } + if (control.type === 'number') { + return event.stopPropagation()} onChange={event=>{const next=Number(event.target.value);if (Number.isFinite(next)) onChange(nextFormValues(values,control,next));}} sx={numberSx}/>; + } + return event.stopPropagation()} onChange={event=>onChange(nextFormValues(values,control,event.target.value))} sx={controlSx}/>; + }; + return {form.controls.map(renderControl)}; +} +function EditableCell({row,column,state,onEditCell,actionRenderer}:{row:TableRowObject;column:TableColumnProtocol;state:ServiceState;onEditCell?:(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>void;actionRenderer?:((row:TableRowObject)=>React.ReactNode)}) { + const schema=editSchema(row,column); + const raw=rowValue(row,column); + const [value,setValue]=useState(String(raw ?? '')); + useEffect(()=>setValue(String(raw ?? '')),[raw]); + if (column.type === 'form') { + const form=row.values?._forms?.[column.id]; + if (!form || !onEditCell) return <>{renderCell(row,column,state,actionRenderer)}; + return } state={state} onChange={next=>onEditCell(row,column,next)}/>; + } + if (!schema || !onEditCell) return <>{renderCell(row,column,state,actionRenderer)}; + if (schema.type === 'enum') { + const options=enumOptions(state,schema.enum || column.enum); + return ; + } + if (schema.type === 'boolean') { + return ; + } + if (schema.type === 'number') { + return event.stopPropagation()} onChange={event=>setValue(event.target.value)} onBlur={()=>{const next=Number(value);if (Number.isFinite(next) && next !== Number(raw ?? 0)) onEditCell(row,column,next);}} onKeyDown={event=>{if (event.key === 'Enter') (event.target as HTMLInputElement).blur();}} sx={{width:130}}/>; + } + return event.stopPropagation()} onChange={event=>setValue(event.target.value)} onBlur={()=>{if (value !== String(raw ?? '')) onEditCell(row,column,value);}} onKeyDown={event=>{if (event.key === 'Enter') (event.target as HTMLInputElement).blur();}}/>; +} +function orderedIdsAfterMove(protocol:TableProtocol,columnId:string,direction:-1 | 1):string[] { + const ids=protocol.columns.map(column=>column.id); + const index=ids.indexOf(columnId); + const next=index + direction; + if (index < 0 || next < 0 || next >= ids.length) return ids; + [ids[index],ids[next]]=[ids[next],ids[index]]; + return ids; +} +export function ProtocolObjectTable({state,protocol,rows,selectedId,highlightedId,scrollToId,scrollSignal,onSelectRow,actionRenderer,storageKey,title,description,fillHeight=true,maxHeight='calc(100vh - 320px)',extraToolbar,onEditCell,onColumnOrderChange}:{state:ServiceState;protocol:TableProtocol;rows:TableRowObject[];selectedId?:string | null;highlightedId?:string | null;scrollToId?:string | null;scrollSignal?:number;onSelectRow?:(row:TableRowObject)=>void;actionRenderer?:(row:TableRowObject)=>React.ReactNode;storageKey:string;title?:string;description?:string;fillHeight?:boolean;maxHeight?:string;extraToolbar?:React.ReactNode;onEditCell?:(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>void;onColumnOrderChange?:(tableId:string,columnOrder:string[])=>void}) { + const [keyword,setKeyword]=useState(''); + const [filters,setFilters]=useState>({}); + const [sortValue,setSortValue]=useState(defaultSortValue(protocol)); + const [filterColumn,setFilterColumn]=useState(null); + const [filterAnchor,setFilterAnchor]=useState(null); + const rowRefs=useRef>({}); + const visibleColumns=useMemo(()=>protocol.columns.filter(column=>column.visible !== false),[protocol]); + const sortedRows=useMemo(()=>{ + const {column,direction}=splitSort(sortValue); + return sortProtocolRows(rows.filter(row=>protocolKeywordMatch(row,protocol,keyword,state)).filter(row=>protocolFilterMatch(row,filters,protocol,state)),protocol,state,column,direction); + },[rows,protocol,keyword,filters,sortValue,state]); + const autoWidths=useMemo(()=>autoColumnWidths(protocol,visibleColumns,rows,state),[protocol,visibleColumns,rows,state]); + const {widths,setWidth,reset}=usePersistentColumns(storageKey,widthDefaults(protocol)); + useEffect(()=>{ + if (!scrollToId) return; + const row=rowRefs.current[scrollToId]; + if (row) row.scrollIntoView({block:'center',inline:'nearest',behavior:'smooth'}); + },[scrollToId,scrollSignal,sortedRows.length]); + const setFilter=(id:string,value:string)=>setFilters(prev=>({...prev,[id]:value})); + const clearFilters=()=>{setKeyword('');setFilters({});}; + const openFilter=(event:React.MouseEvent,column:TableColumnProtocol)=>{ + event.stopPropagation(); + setFilterColumn(column); + setFilterAnchor(event.currentTarget); + }; + const closeFilter=()=>{setFilterColumn(null);setFilterAnchor(null);}; + const toggleSort=(column:TableColumnProtocol)=>{ + if (!column.sortable) return; + const current=splitSort(sortValue); + const direction=current.column === column.id ? (current.direction === 'desc' ? 'asc' : 'desc') : columnDefaultDirection(column); + setSortValue(`${column.id}:${direction}`); + }; + const moveColumn=(event:React.MouseEvent,column:TableColumnProtocol,direction:-1 | 1)=>{ + event.stopPropagation(); + if (!protocol.column_order_editable || !onColumnOrderChange) return; + onColumnOrderChange(protocol.id,orderedIdsAfterMove(protocol,column.id,direction)); + }; + const headerCell=(input:TableColumn)=>{ + const column=input as TableColumnProtocol; + const current=splitSort(sortValue); + const activeFilter=filters[column.id] && filters[column.id] !== 'all'; + const sortMark=current.column === column.id ? (current.direction === 'desc' ? '↓' : '↑') : ''; + return toggleSort(column)} sx={{...tableCellSx(column,widths),position:'relative',fontWeight:900,userSelect:'none',cursor:column.sortable ? 'pointer' : 'default',bgcolor:activeFilter ? 'rgba(37,99,235,0.08)' : undefined}}>{protocol.column_order_editable && onColumnOrderChange ? : null}{column.label}{sortMark ? {sortMark} : null}{column.editable ? 可编辑 : null}{column.filterable ? : null}{protocol.column_order_editable && onColumnOrderChange ? : null}; + }; + const filterOptions=filterColumn ? enumOptions(state,filterColumn.enum) : []; + return {title || protocol.name}{description ? {description} : null}显示行:{sortedRows.length}{extraToolbar}setKeyword(event.target.value)} sx={{minWidth:280,flexBasis:360,flexGrow:1,maxWidth:520}}/>{rowRefs.current[row.id]=element;}} hover selected={selectedId === row.id} onClick={()=>onSelectRow?.(row)} sx={{cursor:onSelectRow ? 'pointer' : 'default',bgcolor:selectedId === row.id ? undefined : highlightedId === row.id ? 'rgba(16,185,129,0.12)' : undefined,'&:hover':{bgcolor:highlightedId === row.id ? 'rgba(16,185,129,0.18)' : undefined},'&.Mui-selected':{bgcolor:'rgba(37,99,235,0.14)'},'&.Mui-selected:hover':{bgcolor:'rgba(37,99,235,0.22)'}}}>{visibleColumns.map(column=>)}}/>{filterColumn && (filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? [{setFilter(filterColumn.id,'all');closeFilter();}}>全部,...filterOptions.map(option=>{setFilter(filterColumn.id,option.value);closeFilter();}}>{option.label})] : null}{filterColumn && !(filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? setFilter(filterColumn.id,event.target.value)}/> : null}; +} diff --git a/src/node_service/web/src/components/ResizableObjectTable.tsx b/src/node_service/web/src/components/ResizableObjectTable.tsx index 1c240e4..a7d6272 100644 --- a/src/node_service/web/src/components/ResizableObjectTable.tsx +++ b/src/node_service/web/src/components/ResizableObjectTable.tsx @@ -1,5 +1,5 @@ import React,{useEffect,useRef,useState} from 'react'; -import {Box,Button,Stack,Table,TableBody,TableCell,TableHead,TableRow,Typography} from '@mui/material'; +import {Box,Table,TableBody,TableCell,TableHead,TableRow} from '@mui/material'; import type {SxProps,Theme} from '@mui/material/styles'; import type {TableColumn} from '../types.tsx'; export type ColumnWidths=Record; @@ -13,8 +13,8 @@ export function usePersistentColumns(key:string,defaults:ColumnWidths) { } }); useEffect(()=>{localStorage.setItem(key,JSON.stringify(widths));},[key,widths]); - const setWidth=(id:string,width:number)=>setWidths(prev=>({...prev,[id]:Math.max(70,Math.min(900,Math.round(width)))})); - const reset=()=>setWidths({...defaults}); + const setWidth=(id:string,width:number)=>setWidths(prev=>({...prev,[id]:Math.max(70,Math.min(1200,Math.round(width)))})); + const reset=(next?:ColumnWidths)=>setWidths({...next || defaults}); return {widths,setWidth,reset}; } export function columnWidth(columns:TableColumn[],widths:ColumnWidths):number { @@ -27,7 +27,7 @@ export function tableCellSx(column:TableColumn,widths:ColumnWidths,extra:S function scrollbarSx() { return {'&::-webkit-scrollbar':{height:14,width:14},'&::-webkit-scrollbar-track':{backgroundColor:'#eef2f7',borderRadius:8},'&::-webkit-scrollbar-thumb':{backgroundColor:'#94a3b8',borderRadius:8,border:'3px solid #eef2f7'},'&::-webkit-scrollbar-thumb:hover':{backgroundColor:'#64748b'}}; } -function ResizeHeaderCell({column,widths,setWidth}:{column:TableColumn;widths:ColumnWidths;setWidth:(id:string,width:number)=>void}) { +export function HeaderResizeGrip({column,widths,setWidth}:{column:TableColumn;widths:ColumnWidths;setWidth:(id:string,width:number)=>void}) { const start=(event:React.MouseEvent)=>{ event.preventDefault(); event.stopPropagation(); @@ -41,9 +41,12 @@ function ResizeHeaderCell({column,widths,setWidth}:{column:TableColumn;wid document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }; - return {column.label}; + return ; } -function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,children}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode}) { +function ResizeHeaderCell({column,widths,setWidth}:{column:TableColumn;widths:ColumnWidths;setWidth:(id:string,width:number)=>void}) { + return {column.label}; +} +function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,children,renderHeaderCell}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode;renderHeaderCell?:(column:TableColumn)=>React.ReactNode}) { const topRef=useRef(null); const bodyRef=useRef(null); const syncing=useRef(false); @@ -57,8 +60,8 @@ function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,childre target.scrollLeft=source.scrollLeft; requestAnimationFrame(()=>{syncing.current=false;}); }; - return sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}>sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}>{columns.map(column=>)}{children}
; + return sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}>sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}>{columns.map(column=>renderHeaderCell ? renderHeaderCell(column) : )}{children}
; } -export function ResizableObjectTable({title,count,columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,onResetColumns}:{title?:string;count?:number;columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;maxHeight?:string;fillHeight?:boolean;rows:T[];renderRow:(row:T,index:number)=>React.ReactNode;emptyText?:string;onResetColumns?:()=>void}) { - return {title || '表格'}{typeof count === 'number' ? `:${count}` : ''}{onResetColumns ? : null}{rows.length ? rows.map(renderRow) : {emptyText || '没有数据'}}; +export function ResizableObjectTable({columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,renderHeaderCell}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;maxHeight?:string;fillHeight?:boolean;rows:T[];renderRow:(row:T,index:number)=>React.ReactNode;emptyText?:string;renderHeaderCell?:(column:TableColumn)=>React.ReactNode}) { + return {rows.length ? rows.map(renderRow) : {emptyText || '没有数据'}}; } diff --git a/src/node_service/web/src/pages/ConfigPage.tsx b/src/node_service/web/src/pages/ConfigPage.tsx index 94fdbe6..6e5c779 100644 --- a/src/node_service/web/src/pages/ConfigPage.tsx +++ b/src/node_service/web/src/pages/ConfigPage.tsx @@ -1,644 +1,29 @@ -import React, { useEffect, useState } from "react"; -import { - Box, - Button, - Card, - CardContent, - Chip, - Divider, - FormControl, - FormControlLabel, - Grid, - InputLabel, - LinearProgress, - MenuItem, - Paper, - Select, - Stack, - Switch, - TextField, - Typography, -} from "@mui/material"; -import type { AutoSwitchConfig, ServiceState, StrategyDefinition, StrategyParameterDef } from "../types.tsx"; -import { StatCard } from "../components/Display.tsx"; -import { - metricValues, - normalizeAutoSwitch, - rateText, - scoreLevel, - scoreText, - strategyDefinitions, - switchStrategyLabel, - timeText, -} from "../utils.tsx"; -function Summary({ state }: { state: ServiceState }) { - const metrics = metricValues(state); - return ( - - - - - - - - - - - - - - - - - - = 95 - ? "success.main" - : metrics.recentRate >= 80 - ? "warning.main" - : "error.main" - } - /> - - - ); +import React from "react"; +import {Box,Button,FormControlLabel,Grid,LinearProgress,Paper,Stack,Switch,TextField,Typography} from "@mui/material"; +import type {AutoSwitchConfig,ServiceState,TableColumnProtocol,TableRowObject} from "../types.tsx"; +import {StatCard} from "../components/Display.tsx"; +import {ProtocolObjectTable} from "../components/ProtocolObjectTable.tsx"; +import {metricValues,rateText,scoreLevel,scoreText,tableProtocol,tableRows,timeText} from "../utils.tsx"; +function Summary({state}:{state:ServiceState}) { + const metrics=metricValues(state); + return = 95 ? "success.main" : metrics.recentRate >= 80 ? "warning.main" : "error.main"}/>; } -function ServicePanel({ - state, - notice, - busy, - fullRefreshRequesting, - onRefresh, - onReload, - autoRefresh, - setAutoRefresh, -}: { - state: ServiceState; - notice: string; - busy: boolean; - fullRefreshRequesting: boolean; - onRefresh: () => void; - onReload: () => void; - autoRefresh: boolean; - setAutoRefresh: (value: boolean) => void; -}) { - const done = Number(state.refresh_done_nodes || 0); - const total = Number(state.refresh_total_nodes || 0); - const progress = total > 0 ? Math.min(100, (done * 100) / total) : 0; - return ( - - -
- 服务状态 - - 轮次 {state.round ?? 0} | {state.status_text || "等待服务状态"} | - 更新时间 {timeText(state.updated_time)} - - - 配置 {state.config_path || "-"} | 状态目录 {state.state_dir || "-"} - - - 最近请求 {timeText(state.last_refresh_request_time)} | 开始{" "} - {timeText(state.last_refresh_begin_time)} | 结束{" "} - {timeText(state.last_refresh_end_time)} - -
- - setAutoRefresh(event.target.checked)} - /> - } - label="页面自动刷新" - /> - - - -
- 0 ? "determinate" : "indeterminate"} - value={progress} - sx={{ mt: 2 }} - /> - - {notice} - -
- ); +function ServicePanel({state,notice,busy,fullRefreshRequesting,onRefresh,onReload,autoRefresh,setAutoRefresh}:{state:ServiceState;notice:string;busy:boolean;fullRefreshRequesting:boolean;onRefresh:()=>void;onReload:()=>void;autoRefresh:boolean;setAutoRefresh:(value:boolean)=>void}) { + const done=Number(state.refresh_done_nodes || 0); + const total=Number(state.refresh_total_nodes || 0); + const progress=total > 0 ? Math.min(100,(done * 100) / total) : 0; + return
服务状态轮次 {state.round ?? 0} | {state.status_text || "等待服务状态"} | 更新时间 {timeText(state.updated_time)}配置 {state.config_path || "-"} | 状态目录 {state.state_dir || "-"}最近请求 {timeText(state.last_refresh_request_time)} | 开始 {timeText(state.last_refresh_begin_time)} | 结束 {timeText(state.last_refresh_end_time)}
setAutoRefresh(event.target.checked)}/>} label="页面自动刷新"/>
0 ? "determinate" : "indeterminate"} value={progress} sx={{mt:2}}/>{notice}
; } -function strategyParameterValue(config: AutoSwitchConfig, parameter: StrategyParameterDef) { - const value = config.strategy_parameters?.[parameter.key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - return typeof parameter.default === "number" ? parameter.default : 0; +function AutoSwitchProtocolTable({state,tableId,storageKey,title,description,busy,onRun,onEditTableCell,onEditTableOrder}:{state:ServiceState;tableId:string;storageKey:string;title:string;description:string;busy?:boolean;onRun?:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { + const protocol=tableProtocol(state,tableId); + const rows=tableRows(state,tableId); + if (!protocol) return 后端没有返回 {title} 协议。; + return 立即执行切换判定 : null} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/>; } -function StrategyDefinitionCard({ - definition, - config, - onParameterChange, -}: { - definition: StrategyDefinition; - config: AutoSwitchConfig; - onParameterChange: (key: string, value: number) => void; -}) { - return ( - - {definition.name} - - {definition.description || "-"} - - - {definition.formula_latex || "-"} - - {definition.parameters.length ? ( - - {definition.parameters.map((parameter) => ( - - - onParameterChange(parameter.key, Number(event.target.value)) - } - /> - - ))} - - ) : ( - - 这个策略没有可调参数。 - - )} - - ); +function AutoSwitchProtocolPanel({state,busy,onRun,onEditTableCell,onEditTableOrder}:{state:ServiceState;busy:boolean;onRun:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { + return ; } -function AutoSwitchPanel({ - state, - onSave, - onRun, - busy, -}: { - state: ServiceState; - onSave: (config: AutoSwitchConfig) => void; - onRun: () => void; - busy: boolean; -}) { - const [config, setConfig] = useState( - normalizeAutoSwitch(state.auto_switch), - ); - useEffect( - () => setConfig(normalizeAutoSwitch(state.auto_switch)), - [state.auto_switch], - ); - const definitions = strategyDefinitions(state); - const strategyOptions = definitions.map((definition) => ({ - value: definition.id, - label: definition.name, - })); - const update = (key: keyof AutoSwitchConfig, value: any) => - setConfig((prev) => ({ ...prev, [key]: value })); - const updateStrategyParameter = (key: string, value: number) => - setConfig((prev) => ({ - ...prev, - strategy_parameters: { - ...(prev.strategy_parameters || {}), - [key]: Number.isFinite(value) ? value : 0, - }, - })); - const updateGroupStrategy = (groupName: string, strategy: string) => { - const next = { - ...config, - group_strategies: { - ...(config.group_strategies || {}), - [groupName]: strategy, - }, - }; - setConfig(next); - onSave(next); - }; - return ( - - -
- 自动切换策略 - - 策略定义、显示名称、LaTeX 公式和参数编辑器全部来自后端 JSON 协议,前端只负责解析渲染。 - -
- - - - -
- - - update("enabled", event.target.checked)} - /> - } - label="启用自动切换" - /> - - - - update("skip_direct", event.target.checked) - } - /> - } - label="跳过 DIRECT" - /> - - - - 默认策略 - - - - - - update("switch_cooldown_seconds", Number(event.target.value)) - } - /> - - - - update("fail_switch_count", Number(event.target.value)) - } - /> - - - - update("keep_score_threshold", Number(event.target.value)) - } - /> - - - - update("weak_score_threshold", Number(event.target.value)) - } - /> - - - - update("weak_improve_margin", Number(event.target.value)) - } - /> - - - - update("better_improve_margin", Number(event.target.value)) - } - /> - - - - update("better_confirm_rounds", Number(event.target.value)) - } - /> - - - - update( - "managed_groups", - event.target.value - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - ) - } - /> - - - - - 策略协议 - - - {definitions.map((definition) => ( - - - - ))} - - - - 代理组策略 - - - {(state.groups || []).map((group) => ( - - - - - {group.name} - - - 当前:{group.current || "-"} | 后端: - {switchStrategyLabel(group.strategy, definitions)} - - - - 策略 - - - - - ))} - - - - - -
- ); -} -export function ConfigPage({ - state, - notice, - intervalValue, - setIntervalValue, - httpLog, - setHttpLog, - onSaveInterval, - onSaveHttpLog, - busy, - onSaveSwitch, - onRunSwitch, - onRefresh, - fullRefreshRequesting, - onReload, - autoRefresh, - setAutoRefresh, -}: { - state: ServiceState; - notice: string; - intervalValue: string; - setIntervalValue: (value: string) => void; - httpLog: boolean; - setHttpLog: (value: boolean) => void; - onSaveInterval: () => void; - onSaveHttpLog: (value: boolean) => void; - busy: boolean; - onSaveSwitch: (config: AutoSwitchConfig) => void; - onRunSwitch: () => void; - onRefresh: () => void; - fullRefreshRequesting: boolean; - onReload: () => void; - autoRefresh: boolean; - setAutoRefresh: (value: boolean) => void; -}) { - return ( - - - - - 综合配置 - - - setIntervalValue(event.target.value)} - inputProps={{ min: 5, max: 3600, step: 1 }} - /> - - - - - - { - setHttpLog(event.target.checked); - onSaveHttpLog(event.target.checked); - }} - /> - } - label="后台 HTTP 日志" - /> - - - - 服务配置:{state.service_config_path || "-"} - - - - - - 全局统计、服务状态、刷新全部节点和自动切换配置都放在综合配置页。 - - - - - ); +export function ConfigPage({state,notice,intervalValue,setIntervalValue,httpLog,setHttpLog,onSaveInterval,onSaveHttpLog,busy,onSaveSwitch,onRunSwitch,onRefresh,fullRefreshRequesting,onReload,autoRefresh,setAutoRefresh,onEditTableCell,onEditTableOrder}:{state:ServiceState;notice:string;intervalValue:string;setIntervalValue:(value:string)=>void;httpLog:boolean;setHttpLog:(value:boolean)=>void;onSaveInterval:()=>void;onSaveHttpLog:(value:boolean)=>void;busy:boolean;onSaveSwitch:(config:AutoSwitchConfig)=>void;onRunSwitch:()=>void;onRefresh:()=>void;fullRefreshRequesting:boolean;onReload:()=>void;autoRefresh:boolean;setAutoRefresh:(value:boolean)=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { + void onSaveSwitch; + return 综合配置setIntervalValue(event.target.value)} inputProps={{min:5,max:3600,step:1}}/>{setHttpLog(event.target.checked);onSaveHttpLog(event.target.checked);}}/>} label="后台 HTTP 日志"/>服务配置:{state.service_config_path || "-"}全局统计、服务状态、刷新全部节点和策略表格都放在综合配置页。; } diff --git a/src/node_service/web/src/pages/GroupsPage.tsx b/src/node_service/web/src/pages/GroupsPage.tsx index 565f5c9..e506ef0 100644 --- a/src/node_service/web/src/pages/GroupsPage.tsx +++ b/src/node_service/web/src/pages/GroupsPage.tsx @@ -1,14 +1,31 @@ -import React,{useMemo,useState} from 'react'; -import {Button,Grid,Stack} from '@mui/material'; +import React,{useEffect,useMemo,useState} from 'react'; +import {Grid,Stack} from '@mui/material'; import type {NodeState,ProxyGroup,ServiceState} from '../types.tsx'; import {GroupListPanel,SelectedGroupHeader} from '../components/GroupListPanel.tsx'; import {NodeTable} from '../components/NodeTable.tsx'; -export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onRefreshNode,refreshingNodes,groupListCollapsed}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onRefreshNode:(name:string)=>void;refreshingNodes:Set;groupListCollapsed:boolean}) { +export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onRefreshNode,refreshingNodes,groupListCollapsed,onEditTableCell,onEditTableOrder,onSwitchGroupNode,switchingNodes}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onRefreshNode:(name:string)=>void;refreshingNodes:Set;groupListCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onSwitchGroupNode:(group:string,node:string)=>void;switchingNodes:Set}) { const [selectedNode,setSelectedNode]=useState(null); + const [scrollSignal,setScrollSignal]=useState(0); const groupNodes=useMemo(()=>{ if (!selectedGroup) return []; const allowed=new Set(selectedGroup.candidates || []); return (state.nodes || []).filter(node=>allowed.has(node.name)); },[state.nodes,selectedGroup]); - return {!groupListCollapsed ? : null}; + useEffect(()=>{ + if (!selectedGroup?.current) return; + const current=(state.nodes || []).find(node=>node.name === selectedGroup.current); + if (current) setSelectedNode(current); + setScrollSignal(value=>value + 1); + },[selectedGroup?.name,selectedGroup?.current,state.nodes]); + const locateCurrent=()=>{ + if (!selectedGroup?.current) return; + const current=(state.nodes || []).find(node=>node.name === selectedGroup.current); + if (current) setSelectedNode(current); + setScrollSignal(value=>value + 1); + }; + const switchNode=(node:string)=>{ + if (!selectedGroup) return; + onSwitchGroupNode(selectedGroup.name,node); + }; + return selectedGroup && onEditTableCell('proxy_groups',selectedGroup.name,'strategy',strategy)} onLocateCurrent={locateCurrent} onRefreshGroup={onRefreshGroup}/>{!groupListCollapsed ? : null}; } diff --git a/src/node_service/web/src/pages/NodesPage.tsx b/src/node_service/web/src/pages/NodesPage.tsx index 5a58585..3ec06c6 100644 --- a/src/node_service/web/src/pages/NodesPage.tsx +++ b/src/node_service/web/src/pages/NodesPage.tsx @@ -3,6 +3,6 @@ import {Button,Grid,Stack} from '@mui/material'; import type {NodeState,ServiceState} from '../types.tsx'; import {NodeTable} from '../components/NodeTable.tsx'; import {NodeDetailPanel} from '../components/NodeDetailPanel.tsx'; -export function NodesPage({state,selected,setSelected,onRefreshNode,refreshingNodes,onRefreshAll,fullRefreshRequesting,detailCollapsed}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean}) { - return {!detailCollapsed ? : null}; +export function NodesPage({state,selected,setSelected,onRefreshNode,refreshingNodes,onRefreshAll,fullRefreshRequesting,detailCollapsed,onEditTableCell,onEditTableOrder}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { + return {!detailCollapsed ? : null}; } diff --git a/src/node_service/web/src/types.tsx b/src/node_service/web/src/types.tsx index d3ec27d..e0ae3ca 100644 --- a/src/node_service/web/src/types.tsx +++ b/src/node_service/web/src/types.tsx @@ -7,9 +7,18 @@ export type Probe={target:string;url?:string;success?:boolean;delay_ms?:number;e export type NodeState={name:string;type?:string;provider?:string;exit_ipv4?:string;exit_ipv6?:string;ai_score?:number;risk?:number;risk_level?:string;multiplier?:number;multiplier_text?:string;shared_users?:string;ip_type?:string;native_type?:string;country?:string;alive?:boolean;alive_category?:string;alive_category_label?:string;stability_category?:string;stability_category_label?:string;total_rate_category?:string;total_rate_category_label?:string;recent_rate_category?:string;recent_rate_category_label?:string;ai_category?:string;ai_category_label?:string;ip_type_category?:string;ip_type_category_label?:string;risk_category?:string;risk_category_label?:string;shared_category?:string;shared_category_label?:string;native_category?:string;native_category_label?:string;ip_version_category?:string;ip_version_category_label?:string;srtt_ms?:number;rttvar_ms?:number;samples?:number;check_count?:number;success_count?:number;pass_count?:number;failure_count?:number;consecutive_failures?:number;recent_results?:number[];success_rate?:number;recent_check_count?:number;recent_pass_count?:number;recent_success_rate?:number;last_delay_ms?:number;last_success_delay_ms?:number;stability_score?:number;last_error?:string;last_check_time?:string;updated_time?:string;probes?:Probe[]}; export type ProxyGroup={name:string;type?:string;candidates?:string[];current?:string;best_candidate?:string;better_rounds?:number;switch_count?:number;last_switch_unix?:number;last_switch_time?:string;last_decision?:string;last_error?:string;switchable?:boolean;managed?:boolean;strategy?:string}; export type AutoSwitchConfig={enabled:boolean;skip_direct:boolean;switch_cooldown_seconds:number;fail_switch_count:number;better_confirm_rounds:number;keep_score_threshold:number;weak_score_threshold:number;weak_improve_margin:number;better_improve_margin:number;default_strategy:string;strategy_parameters:Record;managed_groups:string[];group_strategies:Record}; -export type ServiceState={started_time?:string;updated_time?:string;controller_origin?:string;config_path?:string;state_dir?:string;web_dir?:string;service_config_path?:string;http_request_log_enabled?:boolean;check_interval_seconds?:number;delay_refresh_interval_seconds?:number;status_text?:string;refresh_done_nodes?:number;refresh_total_nodes?:number;refresh_request_count?:number;last_refresh_request_time?:string;last_refresh_begin_time?:string;last_refresh_end_time?:string;checking?:boolean;force_refresh?:boolean;round?:number;enum_options?:Record;dynamic_filter_options?:Record;switch_strategy_definitions?:StrategyDefinition[];auto_switch?:AutoSwitchConfig;groups?:ProxyGroup[];nodes?:NodeState[]}; +export type FormControlType='number'|'boolean'|'string'|'enum'; +export type FormControlProtocol={key:string;label:string;type:FormControlType;value?:unknown;enum?:string;min?:number;max?:number;step?:number;unit?:string;help?:string}; +export type FormProtocol={id:string;title?:string;layout?:'inline'|'grid';controls:FormControlProtocol[]}; +export type TableColumnType='action'|'text'|'number'|'integer'|'boolean'|'enum'|'multi_enum'|'score'|'ms'|'percent'|'datetime'|'rate_summary'|'probe_icons'|'form'; +export type TableCellEditSchema={type:'number'|'boolean'|'string'|'enum';enum?:string;min?:number;max?:number;step?:number}; +export type TableSortDirection='asc'|'desc'; +export type TableColumnProtocol={id:string;label:string;type:TableColumnType;enum?:string;label_field?:string;width?:number;visible?:boolean;sortable?:boolean;filterable?:boolean;sort_rule?:string;filter_type?:string;align?:'left'|'center'|'right';editable?:boolean;edit_type?:string}; +export type TableProtocol={id:string;name:string;row_id_field:string;default_sort?:{column:string;direction:TableSortDirection};keyword_fields?:string[];columns:TableColumnProtocol[];column_order_editable?:boolean}; +export type TableProtocolBundle={version:number;enums:Record;tables:Record}; +export type TableRowObject={id:string;values:Record & {_edit?:Record;_forms?:Record};raw:T}; +export type TableRowsBundle={proxy_nodes?:TableRowObject[];proxy_groups?:TableRowObject[];[key:string]:TableRowObject[] | undefined}; +export type ServiceState={started_time?:string;updated_time?:string;controller_origin?:string;config_path?:string;state_dir?:string;web_dir?:string;service_config_path?:string;http_request_log_enabled?:boolean;check_interval_seconds?:number;delay_refresh_interval_seconds?:number;status_text?:string;refresh_done_nodes?:number;refresh_total_nodes?:number;refresh_request_count?:number;last_refresh_request_time?:string;last_refresh_begin_time?:string;last_refresh_end_time?:string;checking?:boolean;force_refresh?:boolean;round?:number;enum_options?:Record;dynamic_filter_options?:Record;switch_strategy_definitions?:StrategyDefinition[];auto_switch?:AutoSwitchConfig;groups?:ProxyGroup[];nodes?:NodeState[];table_protocol?:TableProtocolBundle;table_rows?:TableRowsBundle}; export type ApiResult={success?:boolean;message?:string;error?:string;state?:ServiceState;config?:Partial;[key:string]:unknown}; -export type NodeFilters={alive_category:string;stability_category:string;recent_rate_category:string;ai_category:string;ip_type_category:string;risk_category:string;shared_category:string;native_category:string;ip_version_category:string;country:string;provider:string}; -export type SortMode='score'|'recent_rate'|'total_rate'|'delay'|'failure'|'name'; export type PageName='nodes'|'groups'|'config'; export type TableColumn={id:string;label:string;width?:number;align?:'left'|'center'|'right';render?:(row:T)=>React.ReactNode}; diff --git a/src/node_service/web/src/utils.tsx b/src/node_service/web/src/utils.tsx index ce5dba4..47adfcc 100644 --- a/src/node_service/web/src/utils.tsx +++ b/src/node_service/web/src/utils.tsx @@ -1,8 +1,5 @@ -import type {EnumOption,NodeFilters,NodeState,Probe,ProbeResult,ProxyGroup,ServiceState,SortMode,StrategyDefinition} from './types.tsx'; +import type {EnumOption,NodeState,Probe,ProbeResult,ProxyGroup,ServiceState,StrategyDefinition,TableColumnProtocol,TableProtocol,TableProtocolBundle,TableRowObject,TableSortDirection} from './types.tsx'; export const emptySwitchConfig={enabled:true,skip_direct:true,switch_cooldown_seconds:120,fail_switch_count:2,better_confirm_rounds:3,keep_score_threshold:75,weak_score_threshold:60,weak_improve_margin:15,better_improve_margin:10,default_strategy:'stability',strategy_parameters:{stability_weight:1,risk_penalty_weight:0.5,multiplier_penalty_weight:10,ai_bonus_weight:2},managed_groups:[],group_strategies:{}}; -export const emptyFilters:NodeFilters={alive_category:'all',stability_category:'all',recent_rate_category:'all',ai_category:'all',ip_type_category:'all',risk_category:'all',shared_category:'all',native_category:'all',ip_version_category:'all',country:'all',provider:'all'}; -export const nodeColumnDefaults:Record={action:88,name:420,alive:92,ipv4:150,ipv6:220,ipVersion:120,ai:120,ipType:150,risk:150,multiplier:110,shared:140,native:130,score:140,rates:240,srtt:90,rttvar:90,delay:100,failures:100,probes:150,updated:190}; -export const groupColumnDefaults:Record={action:120,name:260,current:300,candidates:90,best:300,better:120,switchCount:100,lastSwitch:190,decision:460}; export function scoreLevel(score:number):'success'|'warning'|'error' { if (score >= 80) return 'success'; if (score >= 50) return 'warning'; @@ -67,41 +64,6 @@ export function nodeRecentPassCount(node:NodeState):number { export function rateSummaryText(node:NodeState):string { return `总 ${nodePassCount(node)}/${nodeCheckCount(node)} ${rateText(nodeTotalRate(node))},近 ${nodeRecentPassCount(node)}/${nodeRecentCheckCount(node)} ${rateText(nodeRecentRate(node))}`; } -export function groupNodeSet(groups:ProxyGroup[] | undefined,groupName:string):Set { - const group=(groups || []).find(item=>item.name === groupName); - return new Set(group?.candidates || []); -} -export function enumList(state:ServiceState | null | undefined,name:string):EnumOption[] { - return state?.enum_options?.[name] || []; -} -export function dynamicList(state:ServiceState | null | undefined,name:string):EnumOption[] { - return state?.dynamic_filter_options?.[name] || []; -} -export function matchKeyword(node:NodeState,keyword:string):boolean { - const text=[node.name,node.exit_ipv4,node.exit_ipv6,node.ip_type,node.risk_level,node.native_type,node.shared_users,node.provider,node.country,node.ai_category_label,node.ip_type_category_label,node.risk_category_label,node.shared_category_label,node.native_category_label,node.ip_version_category_label].join(' ').toLowerCase(); - return text.includes(keyword.toLowerCase()); -} -export function matchEnumFilters(node:NodeState,filters:NodeFilters):boolean { - for (const [key,value] of Object.entries(filters)) { - if (value === 'all') continue; - if (key === 'country' || key === 'provider') { - if (((node as any)[key] || '') !== value) return false; - } - else if (((node as any)[key] || 'unknown') !== value) return false; - } - return true; -} -export function sortNodes(nodes:NodeState[],sortBy:SortMode):NodeState[] { - const list=[...nodes]; - if (sortBy === 'recent_rate') list.sort((a,b)=>nodeRecentRate(b) - nodeRecentRate(a)); - else if (sortBy === 'total_rate') list.sort((a,b)=>nodeTotalRate(b) - nodeTotalRate(a)); - else if (sortBy === 'failure') list.sort((a,b)=>Number(b.consecutive_failures || 0) - Number(a.consecutive_failures || 0)); - else if (sortBy === 'delay') list.sort((a,b)=>Number(a.last_delay_ms || 0) - Number(b.last_delay_ms || 0)); - else if (sortBy === 'name') list.sort((a,b)=>String(a.name || '').localeCompare(String(b.name || ''),'zh-Hans-CN')); - else list.sort((a,b)=>Number(b.stability_score || 0) - Number(a.stability_score || 0)); - return list; -} - export function switchStrategyLabel(value:string | undefined,definitions?:StrategyDefinition[]):string { const item=(definitions || []).find(def=>def.id === value); if (item) return item.name; @@ -109,7 +71,7 @@ export function switchStrategyLabel(value:string | undefined,definitions?:Strate return '稳定性优先'; } export function strategyDefinitions(state:ServiceState):StrategyDefinition[] { - return state.switch_strategy_definitions || [{id:'stability',name:'稳定性优先',description:'只按稳定性评分选择。',formula_latex:'S = stability',parameters:[]},{id:'risk_multiplier',name:'稳定性 + 倍率 + 风控',description:'同时考虑稳定性、风控、倍率和 AI。',formula_latex:'S = stability \times w_s - risk \times w_r - multiplier \times w_m + ai \times w_a',parameters:[{key:'stability_weight',label:'稳定性权重',type:'number',default:1,min:0,max:10,step:0.1},{key:'risk_penalty_weight',label:'风控惩罚权重',type:'number',default:0.5,min:0,max:10,step:0.1},{key:'multiplier_penalty_weight',label:'倍率惩罚权重',type:'number',default:10,min:0,max:100,step:0.1},{key:'ai_bonus_weight',label:'AI星级奖励权重',type:'number',default:2,min:0,max:20,step:0.1}]}]; + return state.switch_strategy_definitions || [{id:'stability',name:'稳定性优先',description:'只按稳定性评分选择。',formula_latex:'S = stability',parameters:[]},{id:'risk_multiplier',name:'稳定性 + 倍率 + 风控',description:'同时考虑稳定性、风控、倍率和 AI。',formula_latex:'S = stability \\times w_s - risk \\times w_r - multiplier \\times w_m + ai \\times w_a',parameters:[{key:'stability_weight',label:'稳定性权重',type:'number',default:1,min:0,max:10,step:0.1},{key:'risk_penalty_weight',label:'风控惩罚权重',type:'number',default:0.5,min:0,max:10,step:0.1},{key:'multiplier_penalty_weight',label:'倍率惩罚权重',type:'number',default:10,min:0,max:100,step:0.1},{key:'ai_bonus_weight',label:'AI星级奖励权重',type:'number',default:2,min:0,max:20,step:0.1}]}]; } export function multiplierText(node:NodeState):string { if (node.multiplier_text) return node.multiplier_text; @@ -124,3 +86,88 @@ export function metricValues(state:ServiceState | null) { const recentRate=nodes.length ? nodes.reduce((sum,node)=>sum + nodeRecentRate(node),0) / nodes.length : 0; return {nodes:nodes.length,alive,good,bad,avg,recentRate}; } +export function tableProtocol(state:ServiceState,tableId:string):TableProtocol | null { + return state.table_protocol?.tables?.[tableId] || null; +} +export function tableRows(state:ServiceState,tableId:string):TableRowObject[] { + return (state.table_rows?.[tableId] || []) as TableRowObject[]; +} +export function tableEnums(state:ServiceState):Record { + return state.table_protocol?.enums || state.enum_options || {}; +} +export function enumOptions(state:ServiceState,enumName?:string):EnumOption[] { + if (!enumName) return []; + return tableEnums(state)[enumName] || state.dynamic_filter_options?.[enumName] || []; +} +export function rowValue(row:TableRowObject,column:TableColumnProtocol):any { + return row.values?.[column.id]; +} +export function rowLabel(row:TableRowObject,column:TableColumnProtocol,state:ServiceState):string { + if (column.label_field && row.values?.[column.label_field] !== undefined) return valueText(row.values[column.label_field]); + const value=rowValue(row,column); + if (column.type === 'form') { + const form=row.values?._forms?.[column.id]; + if (!form?.controls?.length) return '无需参数'; + return form.controls.map(control=>`${control.label}:${valueText((value || {})[control.key] ?? control.value)}`).join(' '); + } + if (column.type === 'enum' && column.enum) { + const found=enumOptions(state,column.enum).find(option=>option.value === value); + return found?.label || valueText(value); + } + if (Array.isArray(value)) return value.join(', '); + return valueText(value); +} +export function protocolKeywordMatch(row:TableRowObject,protocol:TableProtocol,keyword:string,state:ServiceState):boolean { + const q=keyword.trim().toLowerCase(); + if (!q) return true; + const fields=protocol.keyword_fields?.length ? protocol.keyword_fields : protocol.columns.map(column=>column.id); + const text=fields.map(field=>{ + const column=protocol.columns.find(item=>item.id === field) || {id:field,label:field,type:'text'} as TableColumnProtocol; + return rowLabel(row,column,state); + }).join(' ').toLowerCase(); + return text.includes(q); +} +export function protocolFilterMatch(row:TableRowObject,filters:Record,protocol:TableProtocol,state:ServiceState):boolean { + for (const [key,value] of Object.entries(filters)) { + if (!value || value === 'all') continue; + const column=protocol.columns.find(item=>item.id === key); + const cell=row.values?.[key]; + if (column?.type === 'multi_enum' || Array.isArray(cell)) { + if (!Array.isArray(cell) || !cell.includes(value)) return false; + } + else if (column?.filter_type === 'text') { + if (!rowLabel(row,column,state).toLowerCase().includes(value.toLowerCase())) return false; + } + else if (String(cell ?? 'unknown') !== value) return false; + } + return true; +} +function enumRank(state:ServiceState,column:TableColumnProtocol,value:any):number { + const options=enumOptions(state,column.enum); + const index=options.findIndex(option=>option.value === value); + return index < 0 ? options.length + 1 : index; +} +function comparable(row:TableRowObject,column:TableColumnProtocol,state:ServiceState):number | string { + const value=row.values?.[column.id]; + if (column.id === 'rate_summary') return Number(value?.recent_success_rate ?? 0); + if (column.type === 'enum') return enumRank(state,column,value); + if (column.type === 'number' || column.type === 'integer' || column.type === 'score' || column.type === 'ms' || column.type === 'percent') return Number(value ?? 0); + return rowLabel(row,column,state); +} +export function sortProtocolRows(rows:TableRowObject[],protocol:TableProtocol,state:ServiceState,sortColumn:string,sortDirection:TableSortDirection):TableRowObject[] { + const column=protocol.columns.find(item=>item.id === sortColumn) || protocol.columns[0]; + const direction=sortDirection === 'desc' ? -1 : 1; + return [...rows].sort((a,b)=>{ + const av=comparable(a,column,state); + const bv=comparable(b,column,state); + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * direction; + return String(av).localeCompare(String(bv),'zh-Hans-CN') * direction; + }); +} +export function defaultSortValue(protocol:TableProtocol):string { + return `${protocol.default_sort?.column || protocol.columns.find(column=>column.sortable)?.id || protocol.columns[0]?.id}:${protocol.default_sort?.direction || 'asc'}`; +} +export function columnDefaultDirection(column:TableColumnProtocol):TableSortDirection { + if (column.sort_rule?.includes('desc')) return 'desc'; + return 'asc'; +} diff --git a/src/node_service/web/tsconfig.json b/src/node_service/web/tsconfig.json new file mode 100644 index 0000000..95f0482 --- /dev/null +++ b/src/node_service/web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ES2021" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "allowImportingTsExtensions": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/src/node_service/web/vite.config.ts b/src/node_service/web/vite.config.ts new file mode 100644 index 0000000..fc6a0d4 --- /dev/null +++ b/src/node_service/web/vite.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +export default defineConfig({ + plugins:[react()], + build:{outDir:'dist',emptyOutDir:true,assetsDir:'assets'}, + server:{host:'127.0.0.1',port:5173,proxy:{'/api':'http://127.0.0.1:18088'}} +});