常规更新
This commit is contained in:
@@ -81,6 +81,7 @@ struct Auto_Switch_Config {
|
||||
};
|
||||
struct Table_Layout_Config {
|
||||
std::unordered_map<std::string, std::vector<std::string>> column_orders;
|
||||
std::unordered_map<std::string, std::vector<std::string>> sticky_columns;
|
||||
};
|
||||
struct Service_Config {
|
||||
std::string listen_host = "127.0.0.1";
|
||||
@@ -152,6 +153,7 @@ struct Proxy_Group_Def {
|
||||
struct Group_Switch_State {
|
||||
std::string name;
|
||||
std::string type;
|
||||
std::vector<std::string> proxies;
|
||||
std::vector<std::string> candidates;
|
||||
std::string current;
|
||||
std::string best_candidate;
|
||||
@@ -298,6 +300,7 @@ public:
|
||||
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<std::string>& column_order);
|
||||
void update_table_sticky_columns(Service_State& service, const std::string& table_id, const std::vector<std::string>& sticky_columns);
|
||||
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();
|
||||
|
||||
@@ -15,6 +15,7 @@ std::set<std::string> strategy_parameter_keys() {
|
||||
}
|
||||
json switch_strategy_definitions_to_json() {
|
||||
json result = json::array();
|
||||
result.push_back(strategy_def("none", "不操作", "不执行自动切换,只保留当前选择。", "", json::array()));
|
||||
result.push_back(strategy_def("stability", "稳定性优先", "只按节点稳定性评分选择候选节点。", R"LATEX(S = stability)LATEX", json::array()));
|
||||
json parameters = json::array();
|
||||
parameters.push_back(strategy_parameter_def("stability_weight", "稳定性权重", 1.0, 0.0, 10.0, 0.1, "", "稳定性评分权重。"));
|
||||
@@ -74,7 +75,11 @@ std::string group_switch_strategy(const Auto_Switch_Config& config, const std::s
|
||||
return normalize_switch_strategy(config.default_strategy);
|
||||
}
|
||||
double node_switch_score(const Auto_Switch_Config& config, const Node_State& node, const std::string& strategy) {
|
||||
if (normalize_switch_strategy(strategy) == "stability") {
|
||||
const auto normalized = normalize_switch_strategy(strategy);
|
||||
if (normalized == "none") {
|
||||
return 0.0;
|
||||
}
|
||||
if (normalized == "stability") {
|
||||
return node.stability_score;
|
||||
}
|
||||
const double risk = node.risk < 0 ? 50.0 : static_cast<double>(node.risk);
|
||||
@@ -149,6 +154,21 @@ void perform_auto_switch(Service_State& service) {
|
||||
service_log(std::format("代理组 {}:{}", group.name, group.last_decision));
|
||||
continue;
|
||||
}
|
||||
if (group.strategy == "none") {
|
||||
try {
|
||||
group.current = request_group_current(config, group.name);
|
||||
group.last_decision = "策略不操作,保持当前选择";
|
||||
reset_better_tracking(group);
|
||||
}
|
||||
catch (const std::exception& error) {
|
||||
group.last_error = error_text_to_utf8(error.what());
|
||||
group.last_decision = "策略不操作,但读取当前选择失败";
|
||||
}
|
||||
std::lock_guard lock(service.mutex);
|
||||
note_group_decision(service, group);
|
||||
service_log(std::format("代理组 {}:{}", group.name, group.last_decision));
|
||||
continue;
|
||||
}
|
||||
if (!group.switchable) {
|
||||
group.last_decision = "没有可切换的直接节点候选";
|
||||
group.last_error = "代理组 proxies 里没有直接节点名";
|
||||
|
||||
@@ -99,31 +99,48 @@ 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}};
|
||||
result[table_id]["column_order"] = order;
|
||||
}
|
||||
for (const auto& [table_id, columns] : config.sticky_columns) {
|
||||
result[table_id]["sticky_columns"] = columns;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::vector<std::string> load_string_array(const json& value) {
|
||||
std::vector<std::string> result;
|
||||
if (!value.is_array()) {
|
||||
return result;
|
||||
}
|
||||
for (const auto& item : value) {
|
||||
if (!item.is_string()) {
|
||||
continue;
|
||||
}
|
||||
auto id = trim(item.get<std::string>());
|
||||
if (!id.empty()) {
|
||||
result.push_back(std::move(id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void load_table_layout_config(Table_Layout_Config& config, const json& data) {
|
||||
config.column_orders.clear();
|
||||
config.sticky_columns.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()) {
|
||||
if (!table.is_object()) {
|
||||
continue;
|
||||
}
|
||||
std::vector<std::string> order;
|
||||
for (const auto& column : table["column_order"]) {
|
||||
if (column.is_string()) {
|
||||
auto id = trim(column.get<std::string>());
|
||||
if (!id.empty()) {
|
||||
order.push_back(std::move(id));
|
||||
}
|
||||
if (table.contains("column_order")) {
|
||||
auto order = load_string_array(table["column_order"]);
|
||||
if (!order.empty()) {
|
||||
config.column_orders[item.key()] = std::move(order);
|
||||
}
|
||||
}
|
||||
if (!order.empty()) {
|
||||
config.column_orders[item.key()] = std::move(order);
|
||||
if (table.contains("sticky_columns")) {
|
||||
config.sticky_columns[item.key()] = load_string_array(table["sticky_columns"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ json group_to_json(const Group_Switch_State& group) {
|
||||
json result;
|
||||
result["name"] = group.name;
|
||||
result["type"] = group.type;
|
||||
result["proxies"] = group.proxies;
|
||||
result["candidates"] = group.candidates;
|
||||
result["current"] = group.current;
|
||||
result["best_candidate"] = group.best_candidate;
|
||||
@@ -180,8 +181,8 @@ std::string enum_label(const json& options, std::string_view name, std::string_v
|
||||
}
|
||||
json enum_options_to_json() {
|
||||
json result;
|
||||
result["alive_category"] = json::array({enum_item("alive", "alive"), enum_item("dead", "dead")});
|
||||
result["stability_category"] = json::array({enum_item("excellent", "稳定 ≥ 80"), enum_item("normal", "稳定 50~80"), enum_item("poor", "稳定 < 50")});
|
||||
result["alive_category"] = json::array({enum_item("alive", "alive"), enum_item("dead", "dead"), enum_item("special", "特殊")});
|
||||
result["stability_category"] = json::array({enum_item("excellent", "稳定 ≥ 80"), enum_item("normal", "稳定 50~80"), enum_item("poor", "稳定 < 50"), enum_item("unknown", "特殊/未知")});
|
||||
result["rate_category"] = json::array({enum_item("excellent", "通过率 ≥ 95%"), enum_item("usable", "通过率 80~95%"), enum_item("poor", "通过率 < 80%"), enum_item("unknown", "未检测")});
|
||||
result["ai_category"] = json::array({enum_item("high", "AI 4~5 星"), enum_item("usable", "AI 3 星"), enum_item("weak", "AI < 3 星"), enum_item("unknown", "AI 未知")});
|
||||
result["ip_type_category"] = json::array({enum_item("idc", "IDC/机房"), enum_item("residential", "家庭宽带"), enum_item("mobile", "移动网络"), enum_item("education", "教育网络"), enum_item("other", "其他"), enum_item("unknown", "未知")});
|
||||
@@ -503,6 +504,7 @@ void reload_switch_groups_locked(Service_State& service) {
|
||||
}
|
||||
group.name = def.name;
|
||||
group.type = def.type;
|
||||
group.proxies = def.proxies;
|
||||
group.managed = should_manage_group(service.config.auto_switch, def);
|
||||
group.strategy = group_switch_strategy(service.config.auto_switch, group.name);
|
||||
group.candidates.clear();
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 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, std::string_view sticky = "", int sticky_order = -1) {
|
||||
json result;
|
||||
result["id"] = std::string(id);
|
||||
result["label"] = std::string(label);
|
||||
@@ -22,6 +22,10 @@ json column(std::string_view id, std::string_view label, std::string_view type,
|
||||
if (!label_field.empty()) {
|
||||
result["label_field"] = std::string(label_field);
|
||||
}
|
||||
if (!sticky.empty()) {
|
||||
result["sticky"] = std::string(sticky);
|
||||
result["sticky_order"] = sticky_order;
|
||||
}
|
||||
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) {
|
||||
@@ -33,6 +37,8 @@ json table(std::string_view id, std::string_view name, std::string_view row_id_f
|
||||
result["columns"] = std::move(columns);
|
||||
result["keyword_fields"] = std::move(keyword_fields);
|
||||
result["column_order_editable"] = true;
|
||||
result["sticky_editable"] = true;
|
||||
result["layout"] = json{{"sticky_header", true}, {"top_scrollbar", true}};
|
||||
return result;
|
||||
}
|
||||
void apply_column_order(json& table, const Table_Layout_Config& layout) {
|
||||
@@ -65,6 +71,25 @@ void apply_column_order(json& table, const Table_Layout_Config& layout) {
|
||||
}
|
||||
table["columns"] = std::move(ordered);
|
||||
}
|
||||
void apply_sticky_columns(json& table, const Table_Layout_Config& layout) {
|
||||
const auto table_id = table.value("id", std::string{});
|
||||
const auto item = layout.sticky_columns.find(table_id);
|
||||
if (item == layout.sticky_columns.end() || !table.contains("columns") || !table["columns"].is_array()) {
|
||||
return;
|
||||
}
|
||||
std::set<std::string> sticky_ids(item->second.begin(), item->second.end());
|
||||
int order = 0;
|
||||
for (auto& column : table["columns"]) {
|
||||
const auto id = column.value("id", std::string{});
|
||||
if (!sticky_ids.contains(id)) {
|
||||
column.erase("sticky");
|
||||
column.erase("sticky_order");
|
||||
continue;
|
||||
}
|
||||
column["sticky"] = "left";
|
||||
column["sticky_order"] = order++;
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -115,6 +140,7 @@ json group_row_values(const Group_Switch_State& group) {
|
||||
const auto raw = group_to_json(group);
|
||||
json values = raw;
|
||||
values["id"] = group.name;
|
||||
values["proxy_count"] = group.proxies.size();
|
||||
values["candidate_count"] = group.candidates.size();
|
||||
values["managed_category"] = group.managed ? "managed" : "unmanaged";
|
||||
values["managed_category_label"] = group.managed ? "受控" : "未受控";
|
||||
@@ -166,8 +192,8 @@ json protocol_enums(const Service_State* service) {
|
||||
}
|
||||
json proxy_node_columns() {
|
||||
return json::array({
|
||||
column("action", "操作", "action", 150, true, false, false),
|
||||
column("name", "节点", "text", 420, true, true, false, "locale_asc"),
|
||||
column("action", "操作", "action", 150, true, false, false, "none", "none", "", "", "left", false, "left", 0),
|
||||
column("name", "节点", "text", 420, true, true, false, "locale_asc", "none", "", "", "left", false, "left", 1),
|
||||
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"),
|
||||
@@ -195,14 +221,15 @@ json proxy_node_columns() {
|
||||
}
|
||||
json proxy_group_columns() {
|
||||
return json::array({
|
||||
column("action", "操作", "action", 120, true, false, false),
|
||||
column("name", "代理组", "text", 260, true, true, false, "locale_asc"),
|
||||
column("action", "操作", "action", 120, true, false, false, "none", "none", "", "", "left", false, "left", 0),
|
||||
column("name", "代理组", "text", 260, true, true, false, "locale_asc", "none", "", "", "left", false, "left", 1),
|
||||
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("proxy_count", "成员", "integer", 90, true, true, false, "number_desc", "none", "", "", "right"),
|
||||
column("candidate_count", "自动候选", "integer", 110, 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"),
|
||||
@@ -406,6 +433,7 @@ json ordered_protocol(Service_State const* service) {
|
||||
if (service) {
|
||||
for (auto& item : result["tables"].items()) {
|
||||
apply_column_order(item.value(), service->config.table_layout);
|
||||
apply_sticky_columns(item.value(), service->config.table_layout);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -489,6 +517,12 @@ void update_table_column_order(Service_State& service, const std::string& table_
|
||||
save_runtime_config(service.config);
|
||||
save_service_index(service);
|
||||
}
|
||||
void update_table_sticky_columns(Service_State& service, const std::string& table_id, const std::vector<std::string>& sticky_columns) {
|
||||
std::lock_guard lock(service.mutex);
|
||||
service.config.table_layout.sticky_columns[table_id] = sticky_columns;
|
||||
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") {
|
||||
|
||||
@@ -124,6 +124,28 @@ void start_web_server(Service_State& service, asio::io_context& io, httplib::Ser
|
||||
res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8");
|
||||
}
|
||||
});
|
||||
server.Post("/api/table/sticky", [&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::string>();
|
||||
std::vector<std::string> columns;
|
||||
for (const auto& item : body.at("sticky_columns")) {
|
||||
if (item.is_string()) {
|
||||
auto id = trim(item.get<std::string>());
|
||||
if (!id.empty()) {
|
||||
columns.push_back(std::move(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
update_table_sticky_columns(service, table_id, columns);
|
||||
service_log(std::format("表格固定列已保存:{},{} 项", table_id, columns.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);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
saveConfig,
|
||||
updateTableCell,
|
||||
updateTableOrder,
|
||||
updateTableSticky,
|
||||
} from "./api.tsx";
|
||||
import { ConfigPage } from "./pages/ConfigPage.tsx";
|
||||
import { GroupsPage } from "./pages/GroupsPage.tsx";
|
||||
@@ -306,6 +307,17 @@ export default function App() {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const editTableSticky = async (table: string, stickyColumns: string[]) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await updateTableSticky(table, stickyColumns);
|
||||
setNotice(result.message || "表格固定列已保存");
|
||||
if (result.state) setState(result.state);
|
||||
else await reload();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const runSwitch = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -343,6 +355,7 @@ export default function App() {
|
||||
detailCollapsed={nodeDetailCollapsed}
|
||||
onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))}
|
||||
onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))}
|
||||
onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))}
|
||||
/>
|
||||
) : null}
|
||||
{page === "groups" ? (
|
||||
@@ -357,6 +370,7 @@ export default function App() {
|
||||
groupListCollapsed={groupListCollapsed}
|
||||
onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))}
|
||||
onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))}
|
||||
onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))}
|
||||
onSwitchGroupNode={(group,node) => safeRun(() => switchGroupNode(group,node))}
|
||||
switchingNodes={switchingNodes}
|
||||
/>
|
||||
@@ -383,6 +397,7 @@ export default function App() {
|
||||
setAutoRefresh={setAutoRefresh}
|
||||
onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))}
|
||||
onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))}
|
||||
onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -42,6 +42,10 @@ export async function updateTableOrder(table:string,columnOrder:string[]):Promis
|
||||
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 updateTableSticky(table:string,stickyColumns:string[]):Promise<ApiResult> {
|
||||
const response=await fetch('/api/table/sticky',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,sticky_columns:stickyColumns})});
|
||||
return await readJsonResponse(response);
|
||||
}
|
||||
export async function updateTableCell(table:string,row:string,column:string,value:unknown):Promise<ApiResult> {
|
||||
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);
|
||||
|
||||
@@ -6,16 +6,16 @@ import {switchStrategyLabel,tableProtocol,tableRows,timeText} from '../utils.tsx
|
||||
function GroupRefreshButton({name,onRefresh,refreshing}:{name?:string;onRefresh:(name?:string)=>void;refreshing:boolean}) {
|
||||
return <Button size="small" variant="outlined" disabled={refreshing} onClick={(event)=>{event.stopPropagation();onRefresh(name);}}>{refreshing ? '刷新中' : '刷新状态'}</Button>;
|
||||
}
|
||||
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<string>;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) {
|
||||
export function GroupListPanel({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;selectedGroup?:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set<string>;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) {
|
||||
const protocol=tableProtocol(state,'proxy_groups');
|
||||
const rows=tableRows<ProxyGroup>(state,'proxy_groups');
|
||||
if (!protocol) {
|
||||
return <Paper variant="outlined" sx={{p:2,height:'100%'}}><Typography color="text.secondary">后端没有返回代理组表格协议。</Typography></Paper>;
|
||||
}
|
||||
return <Paper variant="outlined" sx={{overflow:'hidden',height:'100%',display:'flex',flexDirection:'column'}}><ProtocolObjectTable<ProxyGroup> state={state} protocol={protocol} rows={rows} selectedId={selectedGroup?.name} onSelectRow={(row)=>setSelectedGroup(row.raw)} actionRenderer={(row:TableRowObject<ProxyGroup>)=><GroupRefreshButton name={row.id} onRefresh={onRefreshGroup} refreshing={refreshingGroups.has(row.id)}/>} storageKey="node_service_group_table_widths" title="代理组表格" description="代理组字段、过滤、排序和策略编辑同样由后端协议定义。" fillHeight extraToolbar={<GroupRefreshButton onRefresh={onRefreshGroup} refreshing={refreshingGroups.has('__all__')}/>} onEditCell={(row:TableRowObject<ProxyGroup>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/></Paper>;
|
||||
return <Paper variant="outlined" sx={{overflow:'hidden',height:'100%',display:'flex',flexDirection:'column'}}><ProtocolObjectTable<ProxyGroup> state={state} protocol={protocol} rows={rows} selectedId={selectedGroup?.name} onSelectRow={(row)=>setSelectedGroup(row.raw)} actionRenderer={(row:TableRowObject<ProxyGroup>)=><GroupRefreshButton name={row.id} onRefresh={onRefreshGroup} refreshing={refreshingGroups.has(row.id)}/>} storageKey="node_service_group_table_widths" title="代理组表格" description="代理组字段、过滤、排序和策略编辑同样由后端协议定义。" fillHeight extraToolbar={<GroupRefreshButton onRefresh={onRefreshGroup} refreshing={refreshingGroups.has('__all__')}/>} onEditCell={(row:TableRowObject<ProxyGroup>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/></Paper>;
|
||||
}
|
||||
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 <Paper variant="outlined" sx={{p:2}}><Typography variant="h6">未选择代理组</Typography><Typography color="text.secondary">从右侧代理组表格选择一个代理组。</Typography></Paper>;
|
||||
const options=state?.switch_strategy_definitions || [];
|
||||
return <Paper variant="outlined" sx={{p:2,flexShrink:0}}><Stack direction="row" flexWrap="wrap" useFlexGap spacing={1.5} alignItems="center"><Typography variant="h6">{group.name}</Typography><Chip label={`当前 ${group.current || '-'}`}/><Select size="small" value={group.strategy || state?.auto_switch?.default_strategy || 'stability'} onChange={event=>onEditStrategy?.(String(event.target.value))} sx={{minWidth:190}}>{options.map(option=><MenuItem key={option.id} value={option.id}>{option.name}</MenuItem>)}</Select><Chip label={`候选 ${group.candidates?.length || 0}`}/><Chip label={`最佳 ${group.best_candidate || '-'}`}/><Chip label={`确认 ${group.better_rounds || 0}`}/><Chip label={`切换 ${group.switch_count || 0}`}/><Chip label={`上次 ${timeText(group.last_switch_time)}`}/><Button size="small" variant="outlined" disabled={!group.current} onClick={onLocateCurrent}>定位当前节点</Button>{onRefreshGroup ? <Button size="small" variant="outlined" onClick={()=>onRefreshGroup(group.name)}>刷新代理组状态</Button> : null}</Stack><Typography variant="body2" color="text.secondary" sx={{mt:1,wordBreak:'break-all'}}>决策:{group.last_decision || '等待自动切换判定'}{group.last_error ? `,错误:${group.last_error}` : ''}</Typography><Typography variant="caption" color="text.secondary">策略选择是代理组页顶部快捷控件;代理组表格里的策略列仍可编辑并持久化。</Typography></Paper>;
|
||||
return <Paper variant="outlined" sx={{p:2,flexShrink:0}}><Stack direction="row" flexWrap="wrap" useFlexGap spacing={1.5} alignItems="center"><Typography variant="h6">{group.name}</Typography><Chip label={`当前 ${group.current || '-'}`}/><Select size="small" value={group.strategy || state?.auto_switch?.default_strategy || 'stability'} onChange={event=>onEditStrategy?.(String(event.target.value))} sx={{minWidth:190}}>{options.map(option=><MenuItem key={option.id} value={option.id}>{option.name}</MenuItem>)}</Select><Chip label={`成员 ${group.proxies?.length ?? group.candidates?.length ?? 0}`}/><Chip label={`自动候选 ${group.candidates?.length || 0}`}/><Chip label={`最佳 ${group.best_candidate || '-'}`}/><Chip label={`确认 ${group.better_rounds || 0}`}/><Chip label={`切换 ${group.switch_count || 0}`}/><Chip label={`上次 ${timeText(group.last_switch_time)}`}/><Button size="small" variant="outlined" disabled={!group.current} onClick={onLocateCurrent}>定位当前节点</Button>{onRefreshGroup ? <Button size="small" variant="outlined" onClick={()=>onRefreshGroup(group.name)}>刷新代理组状态</Button> : null}</Stack><Typography variant="body2" color="text.secondary" sx={{mt:1,wordBreak:'break-all'}}>决策:{group.last_decision || '等待自动切换判定'}{group.last_error ? `,错误:${group.last_error}` : ''}</Typography><Typography variant="caption" color="text.secondary">策略选择是代理组页顶部快捷控件;代理组表格里的策略列仍可编辑并持久化。</Typography></Paper>;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,36 @@ 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 <Stack direction="row" spacing={0.75} alignItems="center" onClick={event=>event.stopPropagation()}><NodeRefreshButton node={node} onRefresh={onRefreshNode} refreshing={refreshing}/>{onSwitchNode ? <Button size="small" variant={isCurrent ? 'contained' : 'outlined'} color={isCurrent ? 'success' : 'primary'} disabled={!!switching || !!isCurrent} onClick={()=>onSwitchNode(node.name)}>{switching ? '切换中' : isCurrent ? '当前' : '选择'}</Button> : null}</Stack>;
|
||||
const refreshable=!node.special_proxy_kind;
|
||||
return <Stack direction="row" spacing={0.75} alignItems="center" onClick={event=>event.stopPropagation()}>{refreshable ? <NodeRefreshButton node={node} onRefresh={onRefreshNode} refreshing={refreshing}/> : null}{onSwitchNode ? <Button size="small" variant={isCurrent ? 'contained' : 'outlined'} color={isCurrent ? 'success' : 'primary'} disabled={!!switching || !!isCurrent} onClick={()=>onSwitchNode(node.name)}>{switching ? '切换中' : isCurrent ? '当前' : '选择'}</Button> : null}</Stack>;
|
||||
}
|
||||
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<string>;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<string>}) {
|
||||
function specialRow(name:string,state:ServiceState):TableRowObject<NodeState> {
|
||||
const group=(state.groups || []).find(item=>item.name === name);
|
||||
const kind=name === 'DIRECT' ? 'direct' : group ? 'group' : 'unknown';
|
||||
const label=kind === 'direct' ? 'DIRECT' : group ? '代理组' : '特殊项';
|
||||
const type=kind === 'direct' ? 'DIRECT' : group ? `proxy_group:${group.type || 'unknown'}` : 'unknown';
|
||||
const raw:NodeState={name,special_proxy_kind:kind,type,provider:label,alive:true,stability_score:0,success_rate:0,recent_success_rate:0,check_count:0,pass_count:0,recent_check_count:0,recent_pass_count:0,probes:[]};
|
||||
const values={...raw,id:name,alive_category:'special',alive_category_label:label,stability_category:'unknown',stability_category_label:label,total_rate_category:'unknown',total_rate_category_label:label,recent_rate_category:'unknown',recent_rate_category_label:label,ai_category:'unknown',ai_category_label:'-',ip_type_category:'unknown',ip_type_category_label:label,risk_category:'unknown',risk_category_label:'-',shared_category:'unknown',shared_category_label:'-',native_category:'unknown',native_category_label:'-',ip_version_category:'none',ip_version_category_label:'-',risk_text:'-',ai_text:'-',rate_summary:{check_count:0,pass_count:0,success_rate:0,recent_check_count:0,recent_pass_count:0,recent_success_rate:0},proxy_groups:group ? [group.name] : [],probe_summary:[]};
|
||||
return {id:name,values,raw};
|
||||
}
|
||||
function orderedRows(state:ServiceState,rows:TableRowObject<NodeState>[],names:string[]):TableRowObject<NodeState>[] {
|
||||
const rowMap=new Map(rows.map(row=>[row.id,row]));
|
||||
const result:TableRowObject<NodeState>[]=[];
|
||||
const used=new Set<string>();
|
||||
for (const name of names) {
|
||||
if (used.has(name)) continue;
|
||||
used.add(name);
|
||||
result.push(rowMap.get(name) || specialRow(name,state));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function NodeTable({state,sourceNodes,sourceNames,selected,setSelected,onRefreshNode,refreshingNodes,maxHeight='calc(100vh - 330px)',fillHeight=true,storageKey='node_service_node_table_widths',title='节点表格',onEditTableCell,onEditTableOrder,onEditTableSticky,highlightedName,scrollToName,scrollSignal,onSwitchNode,switchingNodes}:{state:ServiceState;sourceNodes:NodeState[];sourceNames?:string[];selected?:NodeState | null;setSelected?:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set<string>;maxHeight?:string;fillHeight?:boolean;storageKey?:string;title?:string;onEditTableCell?:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder?:(table:string,columnOrder:string[])=>void;onEditTableSticky?:(table:string,stickyColumns:string[])=>void;highlightedName?:string | null;scrollToName?:string | null;scrollSignal?:number;onSwitchNode?:(name:string)=>void;switchingNodes?:Set<string>}) {
|
||||
const protocol=tableProtocol(state,'proxy_nodes');
|
||||
const rows=tableRows<NodeState>(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]);
|
||||
const names=useMemo(()=>sourceNames || sourceNodes.map(node=>node.name),[sourceNames,sourceNodes]);
|
||||
const filteredRows=useMemo(()=>orderedRows(state,rows,names),[state,rows,names]);
|
||||
if (!protocol) {
|
||||
return <Paper variant="outlined" sx={{p:2}}><Typography color="text.secondary">后端没有返回代理节点表格协议。</Typography></Paper>;
|
||||
}
|
||||
return <ProtocolObjectTable<NodeState> state={state} protocol={protocol} rows={filteredRows} selectedId={selected?.name} highlightedId={highlightedName} scrollToId={scrollToName} scrollSignal={scrollSignal} onSelectRow={(row)=>setSelected?.(row.raw)} actionRenderer={(row:TableRowObject<NodeState>)=><NodeActionCell node={row.raw} onRefreshNode={onRefreshNode} refreshing={refreshingNodes.has(row.id)} onSwitchNode={onSwitchNode} switching={switchingNodes?.has(row.id)} isCurrent={highlightedName === row.id}/>} storageKey={storageKey} title={title} description="表格列、枚举、过滤、排序规则都由后端协议定义。" fillHeight={fillHeight} maxHeight={maxHeight} onEditCell={onEditTableCell ? (row:TableRowObject<NodeState>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value) : undefined} onColumnOrderChange={onEditTableOrder}/>;
|
||||
return <ProtocolObjectTable<NodeState> state={state} protocol={protocol} rows={filteredRows} selectedId={selected?.name} highlightedId={highlightedName} scrollToId={scrollToName} scrollSignal={scrollSignal} onSelectRow={(row)=>setSelected?.(row.raw)} actionRenderer={(row:TableRowObject<NodeState>)=><NodeActionCell node={row.raw} onRefreshNode={onRefreshNode} refreshing={refreshingNodes.has(row.id)} onSwitchNode={onSwitchNode} switching={switchingNodes?.has(row.id)} isCurrent={highlightedName === row.id}/>} storageKey={storageKey} title={title} description="表格列、枚举、过滤、排序规则都由后端协议定义。" fillHeight={fillHeight} maxHeight={maxHeight} onEditCell={onEditTableCell ? (row:TableRowObject<NodeState>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value) : undefined} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 {HeaderResizeGrip,ResizableObjectTable,stickyOffsets,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<string,number> {
|
||||
const result:Record<string,number>={};
|
||||
@@ -145,12 +145,23 @@ function orderedIdsAfterMove(protocol:TableProtocol,columnId:string,direction:-1
|
||||
[ids[index],ids[next]]=[ids[next],ids[index]];
|
||||
return ids;
|
||||
}
|
||||
export function ProtocolObjectTable<T>({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<T>[];selectedId?:string | null;highlightedId?:string | null;scrollToId?:string | null;scrollSignal?:number;onSelectRow?:(row:TableRowObject<T>)=>void;actionRenderer?:(row:TableRowObject<T>)=>React.ReactNode;storageKey:string;title?:string;description?:string;fillHeight?:boolean;maxHeight?:string;extraToolbar?:React.ReactNode;onEditCell?:(row:TableRowObject<T>,column:TableColumnProtocol,value:unknown)=>void;onColumnOrderChange?:(tableId:string,columnOrder:string[])=>void}) {
|
||||
function stickyIdsAfterToggle(protocol:TableProtocol,columnId:string):string[] {
|
||||
const enabled=new Set(protocol.columns.filter(column=>column.sticky === 'left').map(column=>column.id));
|
||||
if (enabled.has(columnId)) enabled.delete(columnId);
|
||||
else enabled.add(columnId);
|
||||
return protocol.columns.filter(column=>column.visible !== false && enabled.has(column.id)).map(column=>column.id);
|
||||
}
|
||||
function stickyColumnCount(protocol:TableProtocol):number {
|
||||
return protocol.columns.filter(column=>column.visible !== false && column.sticky === 'left').length;
|
||||
}
|
||||
export function ProtocolObjectTable<T>({state,protocol,rows,selectedId,highlightedId,scrollToId,scrollSignal,onSelectRow,actionRenderer,storageKey,title,description,fillHeight=true,maxHeight='calc(100vh - 320px)',extraToolbar,onEditCell,onColumnOrderChange,onColumnStickyChange}:{state:ServiceState;protocol:TableProtocol;rows:TableRowObject<T>[];selectedId?:string | null;highlightedId?:string | null;scrollToId?:string | null;scrollSignal?:number;onSelectRow?:(row:TableRowObject<T>)=>void;actionRenderer?:(row:TableRowObject<T>)=>React.ReactNode;storageKey:string;title?:string;description?:string;fillHeight?:boolean;maxHeight?:string;extraToolbar?:React.ReactNode;onEditCell?:(row:TableRowObject<T>,column:TableColumnProtocol,value:unknown)=>void;onColumnOrderChange?:(tableId:string,columnOrder:string[])=>void;onColumnStickyChange?:(tableId:string,stickyColumns:string[])=>void}) {
|
||||
const [keyword,setKeyword]=useState('');
|
||||
const [filters,setFilters]=useState<Record<string,string>>({});
|
||||
const [sortValue,setSortValue]=useState(defaultSortValue(protocol));
|
||||
const [filterColumn,setFilterColumn]=useState<TableColumnProtocol | null>(null);
|
||||
const [filterAnchor,setFilterAnchor]=useState<HTMLElement | null>(null);
|
||||
const [stickyEditorOpen,setStickyEditorOpen]=useState(false);
|
||||
const [orderEditorOpen,setOrderEditorOpen]=useState(false);
|
||||
const rowRefs=useRef<Record<string,HTMLTableRowElement | null>>({});
|
||||
const visibleColumns=useMemo(()=>protocol.columns.filter(column=>column.visible !== false),[protocol]);
|
||||
const sortedRows=useMemo(()=>{
|
||||
@@ -159,6 +170,7 @@ export function ProtocolObjectTable<T>({state,protocol,rows,selectedId,highlight
|
||||
},[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));
|
||||
const offsets=useMemo(()=>stickyOffsets(visibleColumns,widths),[visibleColumns,widths]);
|
||||
useEffect(()=>{
|
||||
if (!scrollToId) return;
|
||||
const row=rowRefs.current[scrollToId];
|
||||
@@ -183,13 +195,22 @@ export function ProtocolObjectTable<T>({state,protocol,rows,selectedId,highlight
|
||||
if (!protocol.column_order_editable || !onColumnOrderChange) return;
|
||||
onColumnOrderChange(protocol.id,orderedIdsAfterMove(protocol,column.id,direction));
|
||||
};
|
||||
const toggleSticky=(event:React.MouseEvent<HTMLElement>,column:TableColumnProtocol)=>{
|
||||
event.stopPropagation();
|
||||
if (!protocol.sticky_editable || !onColumnStickyChange) return;
|
||||
onColumnStickyChange(protocol.id,stickyIdsAfterToggle(protocol,column.id));
|
||||
};
|
||||
const clearSticky=()=>{
|
||||
if (!protocol.sticky_editable || !onColumnStickyChange) return;
|
||||
onColumnStickyChange(protocol.id,[]);
|
||||
};
|
||||
const headerCell=(input:TableColumn<any>)=>{
|
||||
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 <TableCell key={column.id} align={column.align} onClick={()=>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}}><Stack direction="row" spacing={0.4} alignItems="center" sx={{minWidth:0}}>{protocol.column_order_editable && onColumnOrderChange ? <Button size="small" variant="text" onClick={event=>moveColumn(event,column,-1)} sx={{minWidth:18,px:0}}>‹</Button> : null}<Box sx={{overflow:'hidden',textOverflow:'ellipsis'}}>{column.label}{sortMark ? <Typography component="span" color="primary" sx={{ml:0.4,fontWeight:900}}>{sortMark}</Typography> : null}{column.editable ? <Typography component="span" color="warning.main" sx={{ml:0.4,fontSize:12}}>可编辑</Typography> : null}</Box>{column.filterable ? <Button size="small" variant={activeFilter ? 'contained' : 'text'} onClick={event=>openFilter(event,column)} sx={{minWidth:activeFilter ? 52 : 32,px:0.6,py:0.1,fontSize:12}}>{activeFilterLabel(column,filters[column.id],state)}</Button> : null}{protocol.column_order_editable && onColumnOrderChange ? <Button size="small" variant="text" onClick={event=>moveColumn(event,column,1)} sx={{minWidth:18,px:0}}>›</Button> : null}</Stack><HeaderResizeGrip column={column} widths={widths} setWidth={setWidth}/></TableCell>;
|
||||
return <TableCell key={column.id} align={column.align} onClick={()=>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},offsets[column.id],true)}}><Stack direction="row" spacing={0.4} alignItems="center" sx={{minWidth:0}}>{orderEditorOpen && protocol.column_order_editable && onColumnOrderChange ? <Button size="small" variant="text" onClick={event=>moveColumn(event,column,-1)} sx={{minWidth:18,px:0}}>‹</Button> : null}<Box sx={{overflow:'hidden',textOverflow:'ellipsis'}}>{column.label}{sortMark ? <Typography component="span" color="primary" sx={{ml:0.4,fontWeight:900}}>{sortMark}</Typography> : null}{column.editable ? <Typography component="span" color="warning.main" sx={{ml:0.4,fontSize:12}}>可编辑</Typography> : null}</Box>{stickyEditorOpen && protocol.sticky_editable && onColumnStickyChange ? <Button size="small" variant={column.sticky === 'left' ? 'contained' : 'text'} color={column.sticky === 'left' ? 'secondary' : 'inherit'} onClick={event=>toggleSticky(event,column)} sx={{minWidth:column.sticky === 'left' ? 40 : 32,px:0.6,py:0.1,fontSize:12}}>{column.sticky === 'left' ? '取消固定' : '固定'}</Button> : null}{column.filterable ? <Button size="small" variant={activeFilter ? 'contained' : 'text'} onClick={event=>openFilter(event,column)} sx={{minWidth:activeFilter ? 52 : 32,px:0.6,py:0.1,fontSize:12}}>{activeFilterLabel(column,filters[column.id],state)}</Button> : null}{orderEditorOpen && protocol.column_order_editable && onColumnOrderChange ? <Button size="small" variant="text" onClick={event=>moveColumn(event,column,1)} sx={{minWidth:18,px:0}}>›</Button> : null}</Stack><HeaderResizeGrip column={column} widths={widths} setWidth={setWidth}/></TableCell>;
|
||||
};
|
||||
const filterOptions=filterColumn ? enumOptions(state,filterColumn.enum) : [];
|
||||
return <Box sx={{overflow:'hidden',display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined,height:fillHeight ? '100%' : undefined}}><Stack direction="row" justifyContent="space-between" alignItems="center" spacing={1.5} sx={{p:2,pb:1,flexShrink:0,flexWrap:'wrap'}}><Box sx={{minWidth:220}}><Typography variant="h6">{title || protocol.name}</Typography>{description ? <Typography variant="body2" color="text.secondary">{description}</Typography> : null}<Typography variant="caption" color="text.secondary">显示行:{sortedRows.length}</Typography></Box><Stack direction="row" spacing={1} alignItems="center" sx={{flex:1,justifyContent:'flex-end',minWidth:360,flexWrap:'wrap'}}>{extraToolbar}<TextField size="small" label={`搜索${protocol.name}`} value={keyword} onChange={event=>setKeyword(event.target.value)} sx={{minWidth:280,flexBasis:360,flexGrow:1,maxWidth:520}}/><Button variant="outlined" onClick={clearFilters}>清空过滤</Button><Button variant="text" onClick={()=>reset(autoWidths)}>重置列宽</Button></Stack></Stack><ResizableObjectTable columns={visibleColumns} widths={widths} setWidth={setWidth} maxHeight={maxHeight} fillHeight={fillHeight} rows={sortedRows} emptyText="没有匹配数据" renderHeaderCell={headerCell} renderRow={(row)=><TableRow key={row.id} ref={element=>{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=><TableCell key={`${row.id}-${column.id}`} align={column.align} sx={tableCellSx(column,widths,column.type === 'form' ? {overflow:'visible',whiteSpace:'normal',py:1.25} : {})} title={typeof rowValue(row,column) === 'string' ? rowValue(row,column) : undefined}><EditableCell row={row} column={column} state={state} onEditCell={onEditCell as any} actionRenderer={actionRenderer}/></TableCell>)}</TableRow>}/><Menu anchorEl={filterAnchor} open={!!filterAnchor && !!filterColumn} onClose={closeFilter}>{filterColumn && (filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? [<MenuItem key="all" selected={!filters[filterColumn.id] || filters[filterColumn.id] === 'all'} onClick={()=>{setFilter(filterColumn.id,'all');closeFilter();}}>全部</MenuItem>,...filterOptions.map(option=><MenuItem key={option.value} selected={filters[filterColumn.id] === option.value} onClick={()=>{setFilter(filterColumn.id,option.value);closeFilter();}}>{option.label}</MenuItem>)] : null}{filterColumn && !(filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? <Box sx={{p:1.5,width:260}}><TextField autoFocus fullWidth size="small" label={`${filterColumn.label}包含`} value={filters[filterColumn.id] || ''} onChange={event=>setFilter(filterColumn.id,event.target.value)}/><Stack direction="row" spacing={1} justifyContent="flex-end" sx={{mt:1}}><Button size="small" onClick={()=>setFilter(filterColumn.id,'all')}>清空</Button><Button size="small" variant="contained" onClick={closeFilter}>确定</Button></Stack></Box> : null}</Menu></Box>;
|
||||
return <Box sx={{overflow:'hidden',display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined,height:fillHeight ? '100%' : undefined}}><Stack direction="row" justifyContent="space-between" alignItems="center" spacing={1.5} sx={{p:2,pb:1,flexShrink:0,flexWrap:'wrap'}}><Box sx={{minWidth:220}}><Typography variant="h6">{title || protocol.name}</Typography>{description ? <Typography variant="body2" color="text.secondary">{description}</Typography> : null}<Typography variant="caption" color="text.secondary">显示行:{sortedRows.length}</Typography></Box><Stack direction="row" spacing={1} alignItems="center" sx={{flex:1,justifyContent:'flex-end',minWidth:360,flexWrap:'wrap'}}>{extraToolbar}<TextField size="small" label={`搜索${protocol.name}`} value={keyword} onChange={event=>setKeyword(event.target.value)} sx={{minWidth:280,flexBasis:360,flexGrow:1,maxWidth:520}}/><Button variant="outlined" onClick={clearFilters}>清空过滤</Button><Button variant="text" onClick={()=>reset(autoWidths)}>重置列宽</Button>{protocol.sticky_editable && onColumnStickyChange ? <Button variant={stickyEditorOpen ? 'contained' : 'outlined'} onClick={()=>setStickyEditorOpen(value=>!value)}>固定列 {stickyColumnCount(protocol)}</Button> : null}{stickyEditorOpen && protocol.sticky_editable && onColumnStickyChange ? <Button variant="text" onClick={clearSticky}>取消全部固定</Button> : null}{protocol.column_order_editable && onColumnOrderChange ? <Button variant={orderEditorOpen ? 'contained' : 'outlined'} onClick={()=>setOrderEditorOpen(value=>!value)}>列顺序</Button> : null}</Stack></Stack><ResizableObjectTable columns={visibleColumns} widths={widths} setWidth={setWidth} maxHeight={maxHeight} fillHeight={fillHeight} stickyHeader={protocol.layout?.sticky_header !== false} topScrollbar={protocol.layout?.top_scrollbar !== false} rows={sortedRows} emptyText="没有匹配数据" renderHeaderCell={headerCell} renderRow={(row)=><TableRow key={row.id} ref={element=>{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=><TableCell key={`${row.id}-${column.id}`} align={column.align} sx={tableCellSx(column,widths,column.type === 'form' ? {overflow:'visible',whiteSpace:'normal',py:1.25} : {},offsets[column.id],false)} title={typeof rowValue(row,column) === 'string' ? rowValue(row,column) : undefined}><EditableCell row={row} column={column} state={state} onEditCell={onEditCell as any} actionRenderer={actionRenderer}/></TableCell>)}</TableRow>}/><Menu anchorEl={filterAnchor} open={!!filterAnchor && !!filterColumn} onClose={closeFilter}>{filterColumn && (filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? [<MenuItem key="all" selected={!filters[filterColumn.id] || filters[filterColumn.id] === 'all'} onClick={()=>{setFilter(filterColumn.id,'all');closeFilter();}}>全部</MenuItem>,...filterOptions.map(option=><MenuItem key={option.value} selected={filters[filterColumn.id] === option.value} onClick={()=>{setFilter(filterColumn.id,option.value);closeFilter();}}>{option.label}</MenuItem>)] : null}{filterColumn && !(filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? <Box sx={{p:1.5,width:260}}><TextField autoFocus fullWidth size="small" label={`${filterColumn.label}包含`} value={filters[filterColumn.id] || ''} onChange={event=>setFilter(filterColumn.id,event.target.value)}/><Stack direction="row" spacing={1} justifyContent="flex-end" sx={{mt:1}}><Button size="small" onClick={()=>setFilter(filterColumn.id,'all')}>清空</Button><Button size="small" variant="contained" onClick={closeFilter}>确定</Button></Stack></Box> : null}</Menu></Box>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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<string,number>;
|
||||
export type StickyOffsets=Record<string,number>;
|
||||
export function usePersistentColumns(key:string,defaults:ColumnWidths) {
|
||||
const [widths,setWidths]=useState<ColumnWidths>(()=>{
|
||||
try {
|
||||
@@ -20,9 +21,23 @@ export function usePersistentColumns(key:string,defaults:ColumnWidths) {
|
||||
export function columnWidth<T>(columns:TableColumn<T>[],widths:ColumnWidths):number {
|
||||
return columns.reduce((sum,column)=>sum + Number(widths[column.id] || column.width || 120),0);
|
||||
}
|
||||
export function tableCellSx<T>(column:TableColumn<T>,widths:ColumnWidths,extra:SxProps<Theme>={}):SxProps<Theme> {
|
||||
export function stickyOffsets<T>(columns:TableColumn<T>[],widths:ColumnWidths):StickyOffsets {
|
||||
const result:StickyOffsets={};
|
||||
let left=0;
|
||||
for (const column of columns) {
|
||||
if (column.sticky !== 'left') continue;
|
||||
result[column.id]=left;
|
||||
left += Number(widths[column.id] || column.width || 120);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function tableCellSx<T>(column:TableColumn<T>,widths:ColumnWidths,extra:SxProps<Theme>={},stickyLeft?:number,isHeader=false):SxProps<Theme> {
|
||||
const width=Number(widths[column.id] || column.width || 120);
|
||||
return {width,minWidth:width,maxWidth:width,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',...extra};
|
||||
const sx:SxProps<Theme>={width,minWidth:width,maxWidth:width,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',...extra};
|
||||
if (column.sticky === 'left' && stickyLeft !== undefined) {
|
||||
return {...sx,position:'sticky',left:stickyLeft,zIndex:isHeader ? 9 : 4,bgcolor:isHeader ? '#f8fafc' : 'background.paper',boxShadow:'2px 0 0 rgba(148,163,184,0.18)'};
|
||||
}
|
||||
return sx;
|
||||
}
|
||||
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'}};
|
||||
@@ -41,16 +56,18 @@ export function HeaderResizeGrip<T>({column,widths,setWidth}:{column:TableColumn
|
||||
document.addEventListener('mousemove',move);
|
||||
document.addEventListener('mouseup',up);
|
||||
};
|
||||
if (column.resizable === false) return null;
|
||||
return <Box onMouseDown={start} sx={{position:'absolute',right:0,top:0,width:8,height:'100%',cursor:'col-resize','&:hover':{bgcolor:'primary.light',opacity:0.45}}}/>;
|
||||
}
|
||||
function ResizeHeaderCell<T>({column,widths,setWidth}:{column:TableColumn<T>;widths:ColumnWidths;setWidth:(id:string,width:number)=>void}) {
|
||||
return <TableCell align={column.align} sx={{...tableCellSx(column,widths),position:'relative',fontWeight:900,userSelect:'none'}}>{column.label}<HeaderResizeGrip column={column} widths={widths} setWidth={setWidth}/></TableCell>;
|
||||
function ResizeHeaderCell<T>({column,widths,setWidth,stickyLeft}:{column:TableColumn<T>;widths:ColumnWidths;setWidth:(id:string,width:number)=>void;stickyLeft?:number}) {
|
||||
return <TableCell align={column.align} sx={{...tableCellSx(column,widths,{position:'relative',fontWeight:900,userSelect:'none'},stickyLeft,true)}}>{column.label}<HeaderResizeGrip column={column} widths={widths} setWidth={setWidth}/></TableCell>;
|
||||
}
|
||||
function TopScrollTable<T>({columns,widths,setWidth,fillHeight,maxHeight,children,renderHeaderCell}:{columns:TableColumn<T>[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode;renderHeaderCell?:(column:TableColumn<T>)=>React.ReactNode}) {
|
||||
function TopScrollTable<T>({columns,widths,setWidth,fillHeight,maxHeight,children,renderHeaderCell,stickyHeader=true,topScrollbar=true}:{columns:TableColumn<T>[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode;renderHeaderCell?:(column:TableColumn<T>)=>React.ReactNode;stickyHeader?:boolean;topScrollbar?:boolean}) {
|
||||
const topRef=useRef<HTMLDivElement | null>(null);
|
||||
const bodyRef=useRef<HTMLDivElement | null>(null);
|
||||
const syncing=useRef(false);
|
||||
const minWidth=columnWidth(columns,widths);
|
||||
const offsets=stickyOffsets(columns,widths);
|
||||
const sync=(from:'top'|'body')=>{
|
||||
if (syncing.current) return;
|
||||
const source=from === 'top' ? topRef.current : bodyRef.current;
|
||||
@@ -60,8 +77,8 @@ function TopScrollTable<T>({columns,widths,setWidth,fillHeight,maxHeight,childre
|
||||
target.scrollLeft=source.scrollLeft;
|
||||
requestAnimationFrame(()=>{syncing.current=false;});
|
||||
};
|
||||
return <Box sx={{overflow:'hidden',display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined}}><Box ref={topRef} onScroll={()=>sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}><Box sx={{width:minWidth,height:20}}/></Box><Box ref={bodyRef} onScroll={()=>sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}><Table stickyHeader size="small" sx={{width:minWidth,tableLayout:'fixed'}}><TableHead><TableRow>{columns.map(column=>renderHeaderCell ? renderHeaderCell(column) : <ResizeHeaderCell key={column.id} column={column} widths={widths} setWidth={setWidth}/>)}</TableRow></TableHead>{children}</Table></Box></Box>;
|
||||
return <Box sx={{overflow:'hidden',display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined}}>{topScrollbar ? <Box ref={topRef} onScroll={()=>sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}><Box sx={{width:minWidth,height:20}}/></Box> : null}<Box ref={bodyRef} onScroll={()=>sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}><Table stickyHeader={stickyHeader} size="small" sx={{width:minWidth,tableLayout:'fixed'}}><TableHead><TableRow>{columns.map(column=>renderHeaderCell ? renderHeaderCell(column) : <ResizeHeaderCell key={column.id} column={column} widths={widths} setWidth={setWidth} stickyLeft={offsets[column.id]}/>)}</TableRow></TableHead>{children}</Table></Box></Box>;
|
||||
}
|
||||
export function ResizableObjectTable<T>({columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,renderHeaderCell}:{columns:TableColumn<T>[];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<T>)=>React.ReactNode}) {
|
||||
return <Box sx={{display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined}}><TopScrollTable columns={columns} widths={widths} setWidth={setWidth} maxHeight={maxHeight} fillHeight={fillHeight} renderHeaderCell={renderHeaderCell}><TableBody>{rows.length ? rows.map(renderRow) : <TableRow><TableCell colSpan={columns.length} sx={{py:4,textAlign:'center',color:'text.secondary'}}>{emptyText || '没有数据'}</TableCell></TableRow>}</TableBody></TopScrollTable></Box>;
|
||||
export function ResizableObjectTable<T>({columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,renderHeaderCell,stickyHeader=true,topScrollbar=true}:{columns:TableColumn<T>[];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<T>)=>React.ReactNode;stickyHeader?:boolean;topScrollbar?:boolean}) {
|
||||
return <Box sx={{display:'flex',flexDirection:'column',minHeight:0,flex:fillHeight ? 1 : undefined}}><TopScrollTable columns={columns} widths={widths} setWidth={setWidth} maxHeight={maxHeight} fillHeight={fillHeight} renderHeaderCell={renderHeaderCell} stickyHeader={stickyHeader} topScrollbar={topScrollbar}><TableBody>{rows.length ? rows.map(renderRow) : <TableRow><TableCell colSpan={columns.length} sx={{py:4,textAlign:'center',color:'text.secondary'}}>{emptyText || '没有数据'}</TableCell></TableRow>}</TableBody></TopScrollTable></Box>;
|
||||
}
|
||||
|
||||
@@ -14,16 +14,16 @@ function ServicePanel({state,notice,busy,fullRefreshRequesting,onRefresh,onReloa
|
||||
const progress=total > 0 ? Math.min(100,(done * 100) / total) : 0;
|
||||
return <Paper variant="outlined" sx={{p:2}}><Stack direction={{xs:"column",lg:"row"}} spacing={2} justifyContent="space-between"><div><Typography variant="h6">服务状态</Typography><Typography variant="body2" color="text.secondary">轮次 {state.round ?? 0} | {state.status_text || "等待服务状态"} | 更新时间 {timeText(state.updated_time)}</Typography><Typography variant="body2" color="text.secondary">配置 {state.config_path || "-"} | 状态目录 {state.state_dir || "-"}</Typography><Typography variant="body2" color="text.secondary">最近请求 {timeText(state.last_refresh_request_time)} | 开始 {timeText(state.last_refresh_begin_time)} | 结束 {timeText(state.last_refresh_end_time)}</Typography></div><Stack direction="row" spacing={1} alignItems="center"><FormControlLabel control={<Switch checked={autoRefresh} onChange={(event)=>setAutoRefresh(event.target.checked)}/>} label="页面自动刷新"/><Button variant="outlined" disabled={busy} onClick={onReload}>刷新页面数据</Button><Button variant="contained" disabled={fullRefreshRequesting} onClick={onRefresh}>{fullRefreshRequesting ? "请求中" : state.checking ? `再次请求全量刷新 ${done}/${total}` : "刷新全部节点"}</Button></Stack></Stack><LinearProgress variant={total > 0 ? "determinate" : "indeterminate"} value={progress} sx={{mt:2}}/><Typography variant="caption" color="text.secondary">{notice}</Typography></Paper>;
|
||||
}
|
||||
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}) {
|
||||
function AutoSwitchProtocolTable({state,tableId,storageKey,title,description,busy,onRun,onEditTableCell,onEditTableOrder,onEditTableSticky}:{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;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) {
|
||||
const protocol=tableProtocol(state,tableId);
|
||||
const rows=tableRows(state,tableId);
|
||||
if (!protocol) return <Paper variant="outlined" sx={{p:2}}><Typography color="text.secondary">后端没有返回 {title} 协议。</Typography></Paper>;
|
||||
return <Paper variant="outlined" sx={{overflow:'hidden'}}><ProtocolObjectTable state={state} protocol={protocol} rows={rows} storageKey={storageKey} title={title} description={description} fillHeight={false} maxHeight="520px" extraToolbar={onRun ? <Button variant="outlined" disabled={busy} onClick={onRun}>立即执行切换判定</Button> : null} onEditCell={(row:TableRowObject<any>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/></Paper>;
|
||||
return <Paper variant="outlined" sx={{overflow:'hidden'}}><ProtocolObjectTable state={state} protocol={protocol} rows={rows} storageKey={storageKey} title={title} description={description} fillHeight={false} maxHeight="520px" extraToolbar={onRun ? <Button variant="outlined" disabled={busy} onClick={onRun}>立即执行切换判定</Button> : null} onEditCell={(row:TableRowObject<any>,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/></Paper>;
|
||||
}
|
||||
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 <Stack spacing={2}><AutoSwitchProtocolTable state={state} tableId="switch_global_settings" storageKey="node_service_switch_global_table_widths" title="自动切换全局表单" description="后端定义任意表单控件协议,前端只解析控件并提交整行表单值。" busy={busy} onRun={onRun} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder}/><AutoSwitchProtocolTable state={state} tableId="switch_strategies" storageKey="node_service_switch_strategy_table_widths" title="策略配置表格" description="每个策略占一行,策略参数作为通用表单控件在一行内编辑;策略名称、公式、参数控件全部由后端协议定义。" onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder}/></Stack>;
|
||||
function AutoSwitchProtocolPanel({state,busy,onRun,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;busy:boolean;onRun:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) {
|
||||
return <Stack spacing={2}><AutoSwitchProtocolTable state={state} tableId="switch_global_settings" storageKey="node_service_switch_global_table_widths" title="自动切换全局表单" description="后端定义任意表单控件协议,前端只解析控件并提交整行表单值。" busy={busy} onRun={onRun} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky}/><AutoSwitchProtocolTable state={state} tableId="switch_strategies" storageKey="node_service_switch_strategy_table_widths" title="策略配置表格" description="每个策略占一行,策略参数作为通用表单控件在一行内编辑;策略名称、公式、参数控件全部由后端协议定义。" onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky}/></Stack>;
|
||||
}
|
||||
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}) {
|
||||
export function ConfigPage({state,notice,intervalValue,setIntervalValue,httpLog,setHttpLog,onSaveInterval,onSaveHttpLog,busy,onSaveSwitch,onRunSwitch,onRefresh,fullRefreshRequesting,onReload,autoRefresh,setAutoRefresh,onEditTableCell,onEditTableOrder,onEditTableSticky}:{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;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) {
|
||||
void onSaveSwitch;
|
||||
return <Stack spacing={2}><Summary state={state}/><ServicePanel state={state} notice={notice} busy={busy} fullRefreshRequesting={fullRefreshRequesting} onRefresh={onRefresh} onReload={onReload} autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}/><Paper variant="outlined" sx={{p:2}}><Typography variant="h6">综合配置</Typography><Grid container spacing={2} sx={{mt:0.5}}><Grid item xs={12} md={3}><TextField fullWidth size="small" label="内核全量 delay 间隔" type="number" value={intervalValue} onChange={(event)=>setIntervalValue(event.target.value)} inputProps={{min:5,max:3600,step:1}}/></Grid><Grid item xs={12} md={3}><Button fullWidth variant="contained" disabled={busy} onClick={onSaveInterval}>保存间隔</Button></Grid><Grid item xs={12} md={3}><FormControlLabel control={<Switch checked={!!httpLog} onChange={(event)=>{setHttpLog(event.target.checked);onSaveHttpLog(event.target.checked);}}/>} label="后台 HTTP 日志"/></Grid><Grid item xs={12} md={3}><Typography variant="body2" color="text.secondary">服务配置:{state.service_config_path || "-"}</Typography></Grid></Grid><Box sx={{mt:1}}><Typography variant="body2" color="text.secondary">全局统计、服务状态、刷新全部节点和策略表格都放在综合配置页。</Typography></Box></Paper><AutoSwitchProtocolPanel state={state} busy={busy} onRun={onRunSwitch} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder}/></Stack>;
|
||||
return <Stack spacing={2}><Summary state={state}/><ServicePanel state={state} notice={notice} busy={busy} fullRefreshRequesting={fullRefreshRequesting} onRefresh={onRefresh} onReload={onReload} autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}/><Paper variant="outlined" sx={{p:2}}><Typography variant="h6">综合配置</Typography><Grid container spacing={2} sx={{mt:0.5}}><Grid item xs={12} md={3}><TextField fullWidth size="small" label="内核全量 delay 间隔" type="number" value={intervalValue} onChange={(event)=>setIntervalValue(event.target.value)} inputProps={{min:5,max:3600,step:1}}/></Grid><Grid item xs={12} md={3}><Button fullWidth variant="contained" disabled={busy} onClick={onSaveInterval}>保存间隔</Button></Grid><Grid item xs={12} md={3}><FormControlLabel control={<Switch checked={!!httpLog} onChange={(event)=>{setHttpLog(event.target.checked);onSaveHttpLog(event.target.checked);}}/>} label="后台 HTTP 日志"/></Grid><Grid item xs={12} md={3}><Typography variant="body2" color="text.secondary">服务配置:{state.service_config_path || "-"}</Typography></Grid></Grid><Box sx={{mt:1}}><Typography variant="body2" color="text.secondary">全局统计、服务状态、刷新全部节点和策略表格都放在综合配置页。</Typography></Box></Paper><AutoSwitchProtocolPanel state={state} busy={busy} onRun={onRunSwitch} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky}/></Stack>;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ 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,onEditTableCell,onEditTableOrder,onSwitchGroupNode,switchingNodes}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set<string>;onRefreshNode:(name:string)=>void;refreshingNodes:Set<string>;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<string>}) {
|
||||
export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onRefreshNode,refreshingNodes,groupListCollapsed,onEditTableCell,onEditTableOrder,onEditTableSticky,onSwitchGroupNode,switchingNodes}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set<string>;onRefreshNode:(name:string)=>void;refreshingNodes:Set<string>;groupListCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void;onSwitchGroupNode:(group:string,node:string)=>void;switchingNodes:Set<string>}) {
|
||||
const [selectedNode,setSelectedNode]=useState<NodeState | null>(null);
|
||||
const [scrollSignal,setScrollSignal]=useState(0);
|
||||
const groupNodes=useMemo(()=>{
|
||||
const groupNodeNames=useMemo(()=>{
|
||||
if (!selectedGroup) return [];
|
||||
const allowed=new Set(selectedGroup.candidates || []);
|
||||
const names=selectedGroup.proxies?.length ? selectedGroup.proxies : selectedGroup.candidates || [];
|
||||
return names.filter((name,index)=>!!name && names.indexOf(name) === index);
|
||||
},[selectedGroup]);
|
||||
const groupNodes=useMemo(()=>{
|
||||
const allowed=new Set(groupNodeNames);
|
||||
return (state.nodes || []).filter(node=>allowed.has(node.name));
|
||||
},[state.nodes,selectedGroup]);
|
||||
},[state.nodes,groupNodeNames]);
|
||||
useEffect(()=>{
|
||||
if (!selectedGroup?.current) return;
|
||||
const current=(state.nodes || []).find(node=>node.name === selectedGroup.current);
|
||||
@@ -19,13 +23,11 @@ export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,
|
||||
},[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 <Grid container spacing={2} sx={{height:'100%',minHeight:0,overflow:'hidden'}}><Grid item xs={12} lg={groupListCollapsed ? 12 : 9} sx={{height:'100%',minHeight:0}}><Stack spacing={1} sx={{height:'100%',minHeight:0}}><SelectedGroupHeader group={selectedGroup} state={state} onEditStrategy={(strategy)=>selectedGroup && onEditTableCell('proxy_groups',selectedGroup.name,'strategy',strategy)} onLocateCurrent={locateCurrent} onRefreshGroup={onRefreshGroup}/><NodeTable state={state} sourceNodes={groupNodes} selected={selectedNode} setSelected={setSelectedNode} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes} title={selectedGroup ? `${selectedGroup.name} 的节点` : '代理组节点'} storageKey="node_service_group_node_table_widths" fillHeight onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} highlightedName={selectedGroup?.current || null} scrollToName={selectedGroup?.current || null} scrollSignal={scrollSignal} onSwitchNode={switchNode} switchingNodes={switchingNodes}/></Stack></Grid>{!groupListCollapsed ? <Grid item xs={12} lg={3} sx={{height:'100%',minHeight:0}}><GroupListPanel state={state} selectedGroup={selectedGroup} setSelectedGroup={setSelectedGroup} onRefreshGroup={onRefreshGroup} refreshingGroups={refreshingGroups} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder}/></Grid> : null}</Grid>;
|
||||
return <Grid container spacing={2} sx={{height:'100%',minHeight:0,overflow:'hidden'}}><Grid item xs={12} lg={groupListCollapsed ? 12 : 9} sx={{height:'100%',minHeight:0}}><Stack spacing={1} sx={{height:'100%',minHeight:0}}><SelectedGroupHeader group={selectedGroup} state={state} onEditStrategy={(strategy)=>selectedGroup && onEditTableCell('proxy_groups',selectedGroup.name,'strategy',strategy)} onLocateCurrent={locateCurrent} onRefreshGroup={onRefreshGroup}/><NodeTable state={state} sourceNodes={groupNodes} sourceNames={groupNodeNames} selected={selectedNode} setSelected={setSelectedNode} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes} title={selectedGroup ? `${selectedGroup.name} 的节点` : '代理组节点'} storageKey="node_service_group_node_table_widths" fillHeight onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky} highlightedName={selectedGroup?.current || null} scrollToName={selectedGroup?.current || null} scrollSignal={scrollSignal} onSwitchNode={switchNode} switchingNodes={switchingNodes}/></Stack></Grid>{!groupListCollapsed ? <Grid item xs={12} lg={3} sx={{height:'100%',minHeight:0}}><GroupListPanel state={state} selectedGroup={selectedGroup} setSelectedGroup={setSelectedGroup} onRefreshGroup={onRefreshGroup} refreshingGroups={refreshingGroups} onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky}/></Grid> : null}</Grid>;
|
||||
}
|
||||
|
||||
@@ -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,onEditTableCell,onEditTableOrder}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set<string>;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) {
|
||||
return <Grid container spacing={2} sx={{height:'100%',minHeight:0,overflow:'hidden'}}><Grid item xs={12} lg={detailCollapsed ? 12 : 9} sx={{height:'100%',minHeight:0}}><Stack spacing={1} sx={{height:'100%',minHeight:0}}><Stack direction="row" justifyContent="flex-end" sx={{flexShrink:0}}><Button variant="contained" disabled={fullRefreshRequesting} onClick={onRefreshAll}>{fullRefreshRequesting ? '请求中' : state.checking ? '再次请求全量刷新' : '刷新全部节点'}</Button></Stack><NodeTable state={state} sourceNodes={state.nodes || []} selected={selected} setSelected={setSelected} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes} title="节点表格" storageKey="node_service_node_table_widths" fillHeight onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder}/></Stack></Grid>{!detailCollapsed ? <Grid item xs={12} lg={3} sx={{height:'100%',minHeight:0}}><NodeDetailPanel node={selected} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes}/></Grid> : null}</Grid>;
|
||||
export function NodesPage({state,selected,setSelected,onRefreshNode,refreshingNodes,onRefreshAll,fullRefreshRequesting,detailCollapsed,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set<string>;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) {
|
||||
return <Grid container spacing={2} sx={{height:'100%',minHeight:0,overflow:'hidden'}}><Grid item xs={12} lg={detailCollapsed ? 12 : 9} sx={{height:'100%',minHeight:0}}><Stack spacing={1} sx={{height:'100%',minHeight:0}}><Stack direction="row" justifyContent="flex-end" sx={{flexShrink:0}}><Button variant="contained" disabled={fullRefreshRequesting} onClick={onRefreshAll}>{fullRefreshRequesting ? '请求中' : state.checking ? '再次请求全量刷新' : '刷新全部节点'}</Button></Stack><NodeTable state={state} sourceNodes={state.nodes || []} selected={selected} setSelected={setSelected} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes} title="节点表格" storageKey="node_service_node_table_widths" fillHeight onEditTableCell={onEditTableCell} onEditTableOrder={onEditTableOrder} onEditTableSticky={onEditTableSticky}/></Stack></Grid>{!detailCollapsed ? <Grid item xs={12} lg={3} sx={{height:'100%',minHeight:0}}><NodeDetailPanel node={selected} onRefreshNode={onRefreshNode} refreshingNodes={refreshingNodes}/></Grid> : null}</Grid>;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ export type StrategyParameterDef={key:string;label:string;type:'number'|'boolean
|
||||
export type StrategyDefinition={id:string;name:string;description?:string;formula_latex?:string;parameters:StrategyParameterDef[]};
|
||||
export type ProbeResult={success?:boolean;delay_ms?:number;http_status?:number;error?:string};
|
||||
export type Probe={target:string;url?:string;success?:boolean;delay_ms?:number;error?:string;result?:ProbeResult};
|
||||
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 NodeState={name:string;special_proxy_kind?:'direct'|'group'|'unknown';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;proxies?: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<string,number>;managed_groups:string[];group_strategies:Record<string,string>};
|
||||
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};
|
||||
@@ -13,12 +13,12 @@ export type FormProtocol={id:string;title?:string;layout?:'inline'|'grid';contro
|
||||
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 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;sticky?:'left'|'none';sticky_order?:number;resizable?:boolean};
|
||||
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;sticky_editable?:boolean;layout?:{sticky_header?:boolean;top_scrollbar?:boolean}};
|
||||
export type TableProtocolBundle={version:number;enums:Record<string,EnumOption[]>;tables:Record<string,TableProtocol>};
|
||||
export type TableRowObject<T=unknown>={id:string;values:Record<string,any> & {_edit?:Record<string,TableCellEditSchema>;_forms?:Record<string,FormProtocol>};raw:T};
|
||||
export type TableRowsBundle={proxy_nodes?:TableRowObject<NodeState>[];proxy_groups?:TableRowObject<ProxyGroup>[];[key:string]:TableRowObject<any>[] | 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<string,EnumOption[]>;dynamic_filter_options?:Record<string,EnumOption[]>;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<ServiceState>;[key:string]:unknown};
|
||||
export type PageName='nodes'|'groups'|'config';
|
||||
export type TableColumn<T=unknown>={id:string;label:string;width?:number;align?:'left'|'center'|'right';render?:(row:T)=>React.ReactNode};
|
||||
export type TableColumn<T=unknown>={id:string;label:string;width?:number;align?:'left'|'center'|'right';sticky?:'left'|'none';sticky_order?:number;resizable?:boolean;render?:(row:T)=>React.ReactNode};
|
||||
|
||||
Reference in New Issue
Block a user