feat(stats): add dashboard analytics
This commit is contained in:
@@ -72,9 +72,11 @@ ADD_EXECUTABLE(${BUILD_TARGET_NAME}
|
||||
src/generator/config/ruleconvert.cpp
|
||||
src/generator/config/subexport.cpp
|
||||
src/generator/template/templates.cpp
|
||||
src/handler/dashboard_page.cpp
|
||||
src/handler/inspect_page.cpp
|
||||
src/handler/interfaces.cpp
|
||||
src/handler/multithread.cpp
|
||||
src/handler/statistics.cpp
|
||||
src/handler/upload.cpp
|
||||
src/handler/version_page.cpp
|
||||
src/handler/webget.cpp
|
||||
|
||||
@@ -164,6 +164,18 @@ profile=lan
|
||||
;Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
|
||||
allow_public_upload=false
|
||||
|
||||
[statistics]
|
||||
;Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled.
|
||||
enabled=false
|
||||
;Put this directory on a Docker volume if statistics should survive restarts.
|
||||
data_dir=stats
|
||||
;Minimum seconds between persistence writes.
|
||||
flush_interval=5
|
||||
;header uses country-only headers such as CF-IPCountry and never stores IPs.
|
||||
;none records all countries as unknown.
|
||||
geo_provider=header
|
||||
country_headers=CF-IPCountry,X-Geo-Country,X-Vercel-IP-Country,CloudFront-Viewer-Country
|
||||
|
||||
[emojis]
|
||||
add_emoji=false
|
||||
remove_old_emoji=true
|
||||
|
||||
@@ -183,6 +183,21 @@ profile = "lan"
|
||||
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
|
||||
allow_public_upload = false
|
||||
|
||||
[statistics]
|
||||
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
|
||||
# and avoids registering the dashboard or statistics-enabled request handler.
|
||||
enabled = false
|
||||
# Put this directory on a Docker volume if statistics should survive restarts.
|
||||
data_dir = "stats"
|
||||
# Minimum seconds between persistence writes.
|
||||
flush_interval = 5
|
||||
|
||||
[statistics.geo]
|
||||
# header uses country-only headers such as CF-IPCountry and never stores IPs.
|
||||
# none records all countries as unknown.
|
||||
provider = "header"
|
||||
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
|
||||
[emojis]
|
||||
add_emoji = false
|
||||
remove_old_emoji = true
|
||||
|
||||
@@ -83,6 +83,19 @@ security:
|
||||
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
|
||||
allow_public_upload: false
|
||||
|
||||
statistics:
|
||||
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled.
|
||||
enabled: false
|
||||
# Put this directory on a Docker volume if statistics should survive restarts.
|
||||
data_dir: stats
|
||||
# Minimum seconds between persistence writes.
|
||||
flush_interval: 5
|
||||
geo:
|
||||
# header uses country-only headers such as CF-IPCountry and never stores IPs.
|
||||
# none records all countries as unknown.
|
||||
provider: header
|
||||
country_headers: ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
|
||||
emojis:
|
||||
add_emoji: false
|
||||
remove_old_emoji: true
|
||||
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
MANAGED_CONFIG_PREFIX: "http://your-domain-or-ip:25500"
|
||||
volumes:
|
||||
- "./base/pref.toml:/base/pref.toml:ro"
|
||||
# 可选:持久化 /dashboard 统计数据;删除该目录即可清空统计。
|
||||
# Optional: persist /dashboard statistics; delete this directory to reset.
|
||||
- "./stats:/base/stats"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
||||
@@ -158,7 +158,7 @@ static std::string transformRuleToCommon(string_view_array &temp, const std::str
|
||||
return strLine;
|
||||
}
|
||||
|
||||
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name)
|
||||
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats)
|
||||
{
|
||||
string_array allRules;
|
||||
std::string rule_group, retrieved_rules, strLine;
|
||||
@@ -187,6 +187,8 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
|
||||
strLine = appendClashRuleTarget(strLine, rule_group);
|
||||
allRules.emplace_back(strLine);
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
|
||||
@@ -212,6 +214,9 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
|
||||
}
|
||||
strLine = appendClashRuleTarget(strLine, rule_group);
|
||||
allRules.emplace_back(strLine);
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +228,7 @@ void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_
|
||||
base_rule[field_name] = rules;
|
||||
}
|
||||
|
||||
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name)
|
||||
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats)
|
||||
{
|
||||
std::string rule_group, retrieved_rules, strLine;
|
||||
std::stringstream strStrm;
|
||||
@@ -255,6 +260,8 @@ std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent>
|
||||
strLine = appendClashRuleTarget(strLine, rule_group);
|
||||
output_content += " - " + strLine + "\n";
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
|
||||
@@ -282,12 +289,14 @@ std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent>
|
||||
strLine = appendClashRuleTarget(strLine, rule_group);
|
||||
output_content += " - " + strLine + "\n";
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
}
|
||||
return output_content;
|
||||
}
|
||||
|
||||
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string &remote_path_prefix)
|
||||
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string &remote_path_prefix, RuleConversionStats *stats)
|
||||
{
|
||||
string_array allRules;
|
||||
std::string rule_group, rule_path, rule_path_typed, retrieved_rules, strLine;
|
||||
@@ -352,6 +361,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
strLine = replaceAllDistinct(strLine, ",,", ",");
|
||||
allRules.emplace_back(strLine);
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else
|
||||
@@ -360,6 +371,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
{
|
||||
strLine = rule_path + ", tag=" + rule_group + ", force-policy=" + rule_group + ", enabled=true";
|
||||
base_rule.set("filter_remote", "{NONAME}", strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
if(fileExist(rule_path))
|
||||
@@ -370,6 +383,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
if(x.update_interval)
|
||||
strLine += ",update-interval=" + std::to_string(x.update_interval);
|
||||
allRules.emplace_back(strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else if(surge_ver == -1 && !remote_path_prefix.empty())
|
||||
@@ -377,12 +392,16 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
strLine = remote_path_prefix + "/getruleset?type=2&url=" + urlSafeBase64Encode(rule_path_typed) + "&group=" + urlSafeBase64Encode(rule_group);
|
||||
strLine += ", tag=" + rule_group + ", enabled=true";
|
||||
base_rule.set("filter_remote", "{NONAME}", strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else if(surge_ver == -4 && !remote_path_prefix.empty())
|
||||
{
|
||||
strLine = remote_path_prefix + "/getruleset?type=1&url=" + urlSafeBase64Encode(rule_path_typed) + "," + rule_group;
|
||||
base_rule.set("Remote Rule", "{NONAME}", strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -404,6 +423,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
strLine += ",update-interval=" + std::to_string(x.update_interval);
|
||||
|
||||
allRules.emplace_back(strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else if(surge_ver == -1 && !remote_path_prefix.empty())
|
||||
@@ -411,12 +432,16 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
strLine = remote_path_prefix + "/getruleset?type=2&url=" + urlSafeBase64Encode(rule_path_typed) + "&group=" + urlSafeBase64Encode(rule_group);
|
||||
strLine += ", tag=" + rule_group + ", enabled=true";
|
||||
base_rule.set("filter_remote", "{NONAME}", strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else if(surge_ver == -4)
|
||||
{
|
||||
strLine = rule_path + "," + rule_group;
|
||||
base_rule.set("Remote Rule", "{NONAME}", strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -491,6 +516,8 @@ void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_c
|
||||
}
|
||||
allRules.emplace_back(strLine);
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,18 +554,18 @@ static rapidjson::Value transformRuleToSingBox(std::vector<std::string_view> &ar
|
||||
return rule_obj;
|
||||
}
|
||||
|
||||
static void appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Value &rules, const std::string& rule, rapidjson::MemoryPoolAllocator<>& allocator)
|
||||
static bool appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Value &rules, const std::string& rule, rapidjson::MemoryPoolAllocator<>& allocator)
|
||||
{
|
||||
using namespace rapidjson_ext;
|
||||
args.clear();
|
||||
split(args, rule, ',');
|
||||
if (args.size() < 2) return;
|
||||
if (args.size() < 2) return false;
|
||||
auto type = args[0];
|
||||
// std::string_view option;
|
||||
// if (args.size() >= 3) option = args[2];
|
||||
|
||||
if (none_of(SingBoxRuleTypes, [&](const std::string& t){ return type == t; }))
|
||||
return;
|
||||
return false;
|
||||
|
||||
auto realType = toLower(std::string(type));
|
||||
auto value = toLower(std::string(args[1]));
|
||||
@@ -546,9 +573,10 @@ static void appendSingBoxRule(std::vector<std::string_view> &args, rapidjson::Va
|
||||
realType = replaceAllDistinct(realType, "ip_cidr6", "ip_cidr");
|
||||
|
||||
rules | AppendToArray(realType.c_str(), rapidjson::Value(value.c_str(), value.size(), allocator), allocator);
|
||||
return true;
|
||||
}
|
||||
|
||||
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules)
|
||||
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, RuleConversionStats *stats)
|
||||
{
|
||||
using namespace rapidjson_ext;
|
||||
std::string rule_group, retrieved_rules, strLine, final;
|
||||
@@ -596,6 +624,8 @@ void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent
|
||||
}
|
||||
rules.PushBack(transformRuleToSingBox(temp, strLine, rule_group, allocator), allocator);
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
retrieved_rules = convertRuleset(retrieved_rules, x.rule_type);
|
||||
@@ -620,7 +650,12 @@ void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent
|
||||
strLine.erase(strLine.find("//"));
|
||||
strLine = trimWhitespace(strLine);
|
||||
}
|
||||
appendSingBoxRule(temp, rule, strLine, allocator);
|
||||
if (appendSingBoxRule(temp, rule, strLine, allocator))
|
||||
{
|
||||
total_rules++;
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
}
|
||||
if (rule.ObjectEmpty()) continue;
|
||||
rule.AddMember("outbound", rapidjson::Value(rule_group.c_str(), allocator), allocator);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <future>
|
||||
#include <cstdint>
|
||||
|
||||
#include <yaml-cpp/yaml.h>
|
||||
#include <rapidjson/document.h>
|
||||
@@ -29,11 +30,17 @@ struct RulesetContent
|
||||
int update_interval = 0;
|
||||
};
|
||||
|
||||
struct RuleConversionStats
|
||||
{
|
||||
uint64_t rules = 0;
|
||||
void add(uint64_t count = 1) { rules += count; }
|
||||
};
|
||||
|
||||
std::string convertRuleset(const std::string &content, int type);
|
||||
std::string appendClashRuleTarget(const std::string &rule, const std::string &target, bool no_resolve_only = false);
|
||||
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name);
|
||||
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name);
|
||||
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string& remote_path_prefix);
|
||||
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules);
|
||||
void rulesetToClash(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr);
|
||||
std::string rulesetToClashStr(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, bool new_field_name, RuleConversionStats *stats = nullptr);
|
||||
void rulesetToSurge(INIReader &base_rule, std::vector<RulesetContent> &ruleset_content_array, int surge_ver, bool overwrite_original_rules, const std::string& remote_path_prefix, RuleConversionStats *stats = nullptr);
|
||||
void rulesetToSingBox(rapidjson::Document &base_rule, std::vector<RulesetContent> &ruleset_content_array, bool overwrite_original_rules, RuleConversionStats *stats = nullptr);
|
||||
|
||||
#endif // RULECONVERT_H_INCLUDED
|
||||
|
||||
@@ -1207,7 +1207,7 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
|
||||
/*
|
||||
if(ext.enable_rule_generator)
|
||||
rulesetToClash(yamlnode, ruleset_content_array,
|
||||
ext.overwrite_original_rules, ext.clash_new_field_name);
|
||||
ext.overwrite_original_rules, ext.clash_new_field_name, ext.rule_stats);
|
||||
|
||||
return YAML::Dump(yamlnode);
|
||||
*/
|
||||
@@ -1235,7 +1235,7 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
|
||||
renderClashScript(yamlnode, ruleset_content_array,
|
||||
ext.managed_config_prefix, ext.clash_script,
|
||||
ext.overwrite_original_rules,
|
||||
ext.clash_classical_ruleset);
|
||||
ext.clash_classical_ruleset, ext.rule_stats);
|
||||
std::string result = YAML::Dump(yamlnode);
|
||||
bool has_providers = !proxy_providers_yaml.empty();
|
||||
if (has_providers) {
|
||||
@@ -1250,7 +1250,8 @@ std::string proxyToClash(std::vector<Proxy> &nodes,
|
||||
|
||||
std::string output_content =
|
||||
rulesetToClashStr(yamlnode, ruleset_content_array,
|
||||
ext.overwrite_original_rules, ext.clash_new_field_name);
|
||||
ext.overwrite_original_rules, ext.clash_new_field_name,
|
||||
ext.rule_stats);
|
||||
|
||||
// 提取 proxy-providers,手动控制输出顺序
|
||||
// 使用之前在 998-1002 行已提取的 proxy_providers_yaml
|
||||
@@ -1700,7 +1701,8 @@ std::string proxyToSurge(std::vector<Proxy> &nodes,
|
||||
|
||||
if (ext.enable_rule_generator)
|
||||
rulesetToSurge(ini, ruleset_content_array, surge_ver,
|
||||
ext.overwrite_original_rules, ext.managed_config_prefix);
|
||||
ext.overwrite_original_rules, ext.managed_config_prefix,
|
||||
ext.rule_stats);
|
||||
|
||||
return ini.to_string();
|
||||
}
|
||||
@@ -2195,7 +2197,7 @@ void proxyToQuan(std::vector<Proxy> &nodes, INIReader &ini,
|
||||
|
||||
if (ext.enable_rule_generator)
|
||||
rulesetToSurge(ini, ruleset_content_array, -2, ext.overwrite_original_rules,
|
||||
"");
|
||||
"", ext.rule_stats);
|
||||
}
|
||||
|
||||
std::string proxyToQuanX(std::vector<Proxy> &nodes,
|
||||
@@ -2482,7 +2484,7 @@ void proxyToQuanX(std::vector<Proxy> &nodes, INIReader &ini,
|
||||
|
||||
if (ext.enable_rule_generator)
|
||||
rulesetToSurge(ini, ruleset_content_array, -1, ext.overwrite_original_rules,
|
||||
ext.managed_config_prefix);
|
||||
ext.managed_config_prefix, ext.rule_stats);
|
||||
}
|
||||
|
||||
std::string proxyToSSD(std::vector<Proxy> &nodes, std::string &group,
|
||||
@@ -2745,7 +2747,7 @@ void proxyToMellow(std::vector<Proxy> &nodes, INIReader &ini,
|
||||
|
||||
if (ext.enable_rule_generator)
|
||||
rulesetToSurge(ini, ruleset_content_array, 0, ext.overwrite_original_rules,
|
||||
"");
|
||||
"", ext.rule_stats);
|
||||
}
|
||||
|
||||
std::string proxyToLoon(std::vector<Proxy> &nodes, const std::string &base_conf,
|
||||
@@ -3049,7 +3051,7 @@ std::string proxyToLoon(std::vector<Proxy> &nodes, const std::string &base_conf,
|
||||
|
||||
if (ext.enable_rule_generator)
|
||||
rulesetToSurge(ini, ruleset_content_array, -4, ext.overwrite_original_rules,
|
||||
ext.managed_config_prefix);
|
||||
ext.managed_config_prefix, ext.rule_stats);
|
||||
|
||||
return ini.to_string();
|
||||
}
|
||||
@@ -3703,7 +3705,8 @@ std::string proxyToSingBox(std::vector<Proxy> &nodes,
|
||||
if (ext.nodelist || !ext.enable_rule_generator)
|
||||
return json | SerializeObject();
|
||||
|
||||
rulesetToSingBox(json, ruleset_content_array, ext.overwrite_original_rules);
|
||||
rulesetToSingBox(json, ruleset_content_array, ext.overwrite_original_rules,
|
||||
ext.rule_stats);
|
||||
|
||||
return json | SerializeObject();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ struct extra_settings {
|
||||
bool provider_proxy_direct = true; // proxy-provider 默认使用 DIRECT 更新
|
||||
std::vector<ProxyProvider> providers; // provider 列表
|
||||
bool authorized = false;
|
||||
RuleConversionStats *rule_stats = nullptr;
|
||||
|
||||
extra_settings() = default;
|
||||
extra_settings(const extra_settings &) = delete;
|
||||
|
||||
@@ -397,7 +397,7 @@ std::string findFileName(const std::string &path)
|
||||
return path.substr(pos + 1, pos2 - pos - 1);
|
||||
}
|
||||
|
||||
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classical_ruleset)
|
||||
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classical_ruleset, RuleConversionStats *stats)
|
||||
{
|
||||
nlohmann::json data;
|
||||
std::string match_group, geoips, retrieved_rules;
|
||||
@@ -439,6 +439,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
strLine = "MATCH";
|
||||
strLine = appendClashRuleTarget(strLine, rule_group);
|
||||
rules.emplace_back(std::move(strLine));
|
||||
if(stats)
|
||||
stats->add();
|
||||
continue;
|
||||
}
|
||||
else
|
||||
@@ -466,7 +468,11 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
break;
|
||||
}
|
||||
if(!script)
|
||||
{
|
||||
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
groups.emplace_back(rule_name);
|
||||
continue;
|
||||
}
|
||||
@@ -486,7 +492,11 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
if(clash_classical_ruleset)
|
||||
{
|
||||
if(!script)
|
||||
{
|
||||
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
groups.emplace_back(rule_name);
|
||||
continue;
|
||||
}
|
||||
@@ -545,6 +555,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
strLine += "," + vArray[2];
|
||||
}
|
||||
rules.emplace_back(strLine);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
}
|
||||
else if(!has_domain[rule_name] && (startsWith(strLine, "DOMAIN,") || startsWith(strLine, "DOMAIN-SUFFIX,")))
|
||||
@@ -557,16 +569,26 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
}
|
||||
}
|
||||
if(has_domain[rule_name] && !script)
|
||||
{
|
||||
rules.emplace_back("RULE-SET," + rule_name + " (Domain)," + rule_group);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
if(has_ipcidr[rule_name] && !script)
|
||||
{
|
||||
if(has_no_resolve)
|
||||
rules.emplace_back("RULE-SET," + rule_name + " (IP-CIDR)," + rule_group + ",no-resolve");
|
||||
else
|
||||
rules.emplace_back("RULE-SET," + rule_name + " (IP-CIDR)," + rule_group);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
if(!has_domain[rule_name] && !has_ipcidr[rule_name] && !script)
|
||||
{
|
||||
rules.emplace_back("RULE-SET," + rule_name + "," + rule_group);
|
||||
if(stats)
|
||||
stats->add();
|
||||
}
|
||||
if(std::find(groups.begin(), groups.end(), rule_name) == groups.end())
|
||||
groups.emplace_back(rule_name);
|
||||
}
|
||||
@@ -622,6 +644,8 @@ int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &rulese
|
||||
}
|
||||
if(script)
|
||||
{
|
||||
if(stats)
|
||||
stats->add();
|
||||
std::string json_path = "rules." + std::to_string(index) + ".";
|
||||
parse_json_pointer(data, json_path + "has_domain", group_has_domain ? "true" : "false");
|
||||
parse_json_pointer(data, json_path + "has_ipcidr", group_has_ipcidr ? "true" : "false");
|
||||
|
||||
@@ -20,6 +20,6 @@ int render_template(const std::string &content, const template_args &vars,
|
||||
std::string &output,
|
||||
const std::string &include_scope = "templates",
|
||||
FetchContext context = FetchContext::TrustedConfig);
|
||||
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classic_ruleset);
|
||||
int renderClashScript(YAML::Node &base_rule, std::vector<RulesetContent> &ruleset_content_array, const std::string &remote_path_prefix, bool script, bool overwrite_original_rules, bool clash_classic_ruleset, RuleConversionStats *stats = nullptr);
|
||||
|
||||
#endif // TEMPLATES_H_INCLUDED
|
||||
|
||||
623
src/handler/dashboard_page.cpp
Normal file
623
src/handler/dashboard_page.cpp
Normal file
@@ -0,0 +1,623 @@
|
||||
#include "handler/dashboard_page.h"
|
||||
|
||||
namespace dashboard_page {
|
||||
|
||||
std::string page(Request &, Response &response) {
|
||||
response.headers["X-Robots-Tag"] =
|
||||
"noindex, nofollow, noarchive, nosnippet, noimageindex";
|
||||
|
||||
return R"html(<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>SubConverter-Extended Dashboard</title>
|
||||
<script>
|
||||
(function () {
|
||||
var saved = localStorage.getItem("sce-dashboard-lang");
|
||||
if (saved) {
|
||||
document.documentElement.lang = saved;
|
||||
return;
|
||||
}
|
||||
var languages = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
|
||||
document.documentElement.lang = languages.some(function (language) { return /^zh\b/i.test(language); }) ? "zh-CN" : "en";
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/version/favicon-dark.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="/version/favicon-light.svg" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" type="image/svg+xml" href="/version/favicon-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-gradient: linear-gradient(135deg, #f8fafc 0%, #eef2f7 48%, #e2e8f0 100%);
|
||||
--bg-grid: rgba(15, 23, 42, 0.055);
|
||||
--bg-sheen: linear-gradient(115deg, transparent 0%, transparent 33%, rgba(14, 165, 233, 0.11) 48%, rgba(132, 204, 22, 0.09) 58%, transparent 73%, transparent 100%);
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--surface-border: rgba(15, 23, 42, 0.1);
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #4a5568;
|
||||
--text-muted: #64748b;
|
||||
--accent: #0284c7;
|
||||
--accent-2: #65a30d;
|
||||
--accent-gradient: linear-gradient(135deg, #0284c7 0%, #0891b2 45%, #65a30d 100%);
|
||||
--shadow: 0 28px 70px rgba(15, 23, 42, 0.13);
|
||||
--control-bg: rgba(255, 255, 255, 0.72);
|
||||
--control-hover: rgba(255, 255, 255, 0.92);
|
||||
--control-border: rgba(26, 32, 44, 0.12);
|
||||
--map-fill: #dbe6ef;
|
||||
--map-stroke: rgba(255, 255, 255, 0.85);
|
||||
--map-empty: #d4dde8;
|
||||
--danger: #dc2626;
|
||||
--warn: #b45309;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-gradient: linear-gradient(135deg, #05070b 0%, #0d111a 46%, #111827 100%);
|
||||
--bg-grid: rgba(148, 163, 184, 0.075);
|
||||
--bg-sheen: linear-gradient(115deg, transparent 0%, transparent 31%, rgba(34, 211, 238, 0.12) 47%, rgba(132, 204, 22, 0.08) 58%, transparent 74%, transparent 100%);
|
||||
--surface: rgba(15, 23, 42, 0.72);
|
||||
--surface-strong: rgba(21, 29, 43, 0.9);
|
||||
--surface-border: rgba(148, 163, 184, 0.18);
|
||||
--text-primary: #f8f9fa;
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #7f8ea3;
|
||||
--accent: #38bdf8;
|
||||
--accent-2: #84cc16;
|
||||
--accent-gradient: linear-gradient(135deg, #38bdf8 0%, #22d3ee 42%, #84cc16 100%);
|
||||
--shadow: 0 32px 80px rgba(0, 0, 0, 0.62);
|
||||
--control-bg: rgba(20, 24, 33, 0.7);
|
||||
--control-hover: rgba(35, 42, 56, 0.86);
|
||||
--control-border: rgba(255, 255, 255, 0.16);
|
||||
--map-fill: #243245;
|
||||
--map-stroke: rgba(2, 6, 23, 0.9);
|
||||
--map-empty: #253244;
|
||||
--danger: #f87171;
|
||||
--warn: #fbbf24;
|
||||
}
|
||||
}
|
||||
|
||||
html[lang^="zh"] [data-lang="en"],
|
||||
html:not([lang^="zh"]) [data-lang="zh"] { display: none; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-gradient);
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
body::before {
|
||||
background-image: linear-gradient(var(--bg-grid) 1px, transparent 1px), linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px);
|
||||
background-size: 36px 36px;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||||
opacity: 0.58;
|
||||
}
|
||||
body::after { background: var(--bg-sheen); opacity: 0.82; }
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 42px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
min-height: 40px;
|
||||
padding: 9px 13px;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
button:hover { background: var(--control-hover); transform: translateY(-1px); }
|
||||
button:focus-visible { outline: 3px solid rgba(99, 179, 237, 0.35); outline-offset: 2px; }
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--surface-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 28px;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.metric {
|
||||
min-height: 126px;
|
||||
padding: 17px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.metric { background: rgba(255, 255, 255, 0.045); }
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 12px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.metric-sub {
|
||||
margin-top: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
|
||||
gap: 18px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
.section {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.state-line {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.map-wrap {
|
||||
position: relative;
|
||||
min-height: 430px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 20px;
|
||||
background: var(--surface-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
#world-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
display: block;
|
||||
}
|
||||
.country {
|
||||
fill: var(--map-empty);
|
||||
stroke: var(--map-stroke);
|
||||
stroke-width: 0.45;
|
||||
transition: fill 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
.country.has-data { cursor: pointer; }
|
||||
.country:hover { opacity: 0.82; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
min-width: 170px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-strong);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22);
|
||||
transform: translate(-50%, calc(-100% - 12px));
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
font-size: 0.86rem;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.show { opacity: 1; }
|
||||
.tooltip-title { font-weight: 800; margin-bottom: 4px; }
|
||||
.tooltip-row { color: var(--text-secondary); display: flex; justify-content: space-between; gap: 14px; }
|
||||
.chart {
|
||||
height: 170px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 5px;
|
||||
padding: 18px 14px 12px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 20px;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.bar {
|
||||
flex: 1 1 0;
|
||||
min-width: 4px;
|
||||
border-radius: 5px 5px 2px 2px;
|
||||
background: var(--accent-gradient);
|
||||
opacity: 0.88;
|
||||
}
|
||||
.country-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
.country-table th,
|
||||
.country-table td {
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.country-table th {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.country-table tr:last-child td { border-bottom: 0; }
|
||||
.country-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
margin-right: 8px;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1;
|
||||
vertical-align: -0.08em;
|
||||
}
|
||||
.code-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-2);
|
||||
}
|
||||
.empty {
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-weight: 650;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.hero { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.content { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.shell { width: min(100% - 20px, 1180px); padding-top: 16px; }
|
||||
.topbar { align-items: flex-start; flex-direction: column; }
|
||||
.hero { grid-template-columns: 1fr 1fr; padding: 14px; }
|
||||
.metric { min-height: 112px; padding: 14px; }
|
||||
.metric-value { font-size: 1.42rem; }
|
||||
.content { padding: 0 14px 14px; }
|
||||
#world-map, .map-wrap { min-height: 310px; height: 310px; }
|
||||
h1 { font-size: 1.45rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
|
||||
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
|
||||
</picture>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="subtitle">
|
||||
<span data-lang="en">Runtime conversion statistics</span>
|
||||
<span data-lang="zh">运行期转换统计</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="refresh-button">
|
||||
<span data-lang="en">Refresh</span><span data-lang="zh">刷新</span>
|
||||
</button>
|
||||
<button type="button" id="lang-toggle">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="hero" id="metrics"></div>
|
||||
<div class="content">
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2><span data-lang="en">Country Distribution</span><span data-lang="zh">国家分布</span></h2>
|
||||
<span class="status"><span class="status-dot"></span><span id="country-status">-</span></span>
|
||||
</div>
|
||||
<div class="map-wrap">
|
||||
<svg id="world-map" role="img" aria-label="World map"></svg>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2><span data-lang="en">Last 24 Hours</span><span data-lang="zh">最近 24 小时</span></h2>
|
||||
<span class="state-line" id="updated-at">-</span>
|
||||
</div>
|
||||
<div class="chart" id="chart"></div>
|
||||
<div style="height: 16px"></div>
|
||||
<table class="country-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span data-lang="en">Country</span><span data-lang="zh">国家</span></th>
|
||||
<th><span data-lang="en">Requests</span><span data-lang="zh">请求</span></th>
|
||||
<th><span data-lang="en">Rules</span><span data-lang="zh">规则</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="country-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var ISO_N3 = {
|
||||
"004":"AF","008":"AL","010":"AQ","012":"DZ","016":"AS","020":"AD","024":"AO","028":"AG","031":"AZ","032":"AR","036":"AU","040":"AT","044":"BS","048":"BH","050":"BD","051":"AM","052":"BB","056":"BE","060":"BM","064":"BT","068":"BO","070":"BA","072":"BW","074":"BV","076":"BR","084":"BZ","086":"IO","090":"SB","092":"VG","096":"BN","100":"BG","104":"MM","108":"BI","112":"BY","116":"KH","120":"CM","124":"CA","132":"CV","136":"KY","140":"CF","144":"LK","148":"TD","152":"CL","156":"CN","158":"TW","162":"CX","166":"CC","170":"CO","174":"KM","175":"YT","178":"CG","180":"CD","184":"CK","188":"CR","191":"HR","192":"CU","196":"CY","203":"CZ","204":"BJ","208":"DK","212":"DM","214":"DO","218":"EC","222":"SV","226":"GQ","231":"ET","232":"ER","233":"EE","234":"FO","238":"FK","239":"GS","242":"FJ","246":"FI","248":"AX","250":"FR","254":"GF","258":"PF","260":"TF","262":"DJ","266":"GA","268":"GE","270":"GM","275":"PS","276":"DE","288":"GH","292":"GI","296":"KI","300":"GR","304":"GL","308":"GD","312":"GP","316":"GU","320":"GT","324":"GN","328":"GY","332":"HT","334":"HM","336":"VA","340":"HN","344":"HK","348":"HU","352":"IS","356":"IN","360":"ID","364":"IR","368":"IQ","372":"IE","376":"IL","380":"IT","384":"CI","388":"JM","392":"JP","398":"KZ","400":"JO","404":"KE","408":"KP","410":"KR","414":"KW","417":"KG","418":"LA","422":"LB","426":"LS","428":"LV","430":"LR","434":"LY","438":"LI","440":"LT","442":"LU","446":"MO","450":"MG","454":"MW","458":"MY","462":"MV","466":"ML","470":"MT","474":"MQ","478":"MR","480":"MU","484":"MX","492":"MC","496":"MN","498":"MD","499":"ME","500":"MS","504":"MA","508":"MZ","512":"OM","516":"NA","520":"NR","524":"NP","528":"NL","531":"CW","533":"AW","534":"SX","535":"BQ","540":"NC","548":"VU","554":"NZ","558":"NI","562":"NE","566":"NG","570":"NU","574":"NF","578":"NO","580":"MP","581":"UM","583":"FM","584":"MH","585":"PW","586":"PK","591":"PA","598":"PG","600":"PY","604":"PE","608":"PH","612":"PN","616":"PL","620":"PT","624":"GW","626":"TL","630":"PR","634":"QA","638":"RE","642":"RO","643":"RU","646":"RW","652":"BL","654":"SH","659":"KN","660":"AI","662":"LC","663":"MF","666":"PM","670":"VC","674":"SM","678":"ST","682":"SA","686":"SN","688":"RS","690":"SC","694":"SL","702":"SG","703":"SK","704":"VN","705":"SI","706":"SO","710":"ZA","716":"ZW","724":"ES","728":"SS","729":"SD","732":"EH","740":"SR","744":"SJ","748":"SZ","752":"SE","756":"CH","760":"SY","762":"TJ","764":"TH","768":"TG","772":"TK","776":"TO","780":"TT","784":"AE","788":"TN","792":"TR","795":"TM","796":"TC","798":"TV","800":"UG","804":"UA","807":"MK","818":"EG","826":"GB","831":"GG","832":"JE","833":"IM","834":"TZ","840":"US","850":"VI","854":"BF","858":"UY","860":"UZ","862":"VE","876":"WF","882":"WS","887":"YE","894":"ZM"
|
||||
};
|
||||
var metricsEl = document.getElementById("metrics");
|
||||
var countryBody = document.getElementById("country-body");
|
||||
var countryStatus = document.getElementById("country-status");
|
||||
var updatedAt = document.getElementById("updated-at");
|
||||
var chart = document.getElementById("chart");
|
||||
var tooltip = document.getElementById("tooltip");
|
||||
var mapData = null;
|
||||
var latest = null;
|
||||
var countryMap = new Map();
|
||||
|
||||
function isZh() { return /^zh\b/i.test(document.documentElement.lang); }
|
||||
function text(en, zh) { return isZh() ? zh : en; }
|
||||
function number(value) { return new Intl.NumberFormat(isZh() ? "zh-CN" : "en").format(value || 0); }
|
||||
function countryName(code) {
|
||||
if (code === "ZZ" || code === "XX") return text("Unknown", "未知");
|
||||
if (code === "T1") return text("Tor network", "Tor 网络");
|
||||
try { return new Intl.DisplayNames([isZh() ? "zh-CN" : "en"], { type: "region" }).of(code) || code; }
|
||||
catch (error) { return code; }
|
||||
}
|
||||
function countryIcon(code) {
|
||||
if (!/^[A-Z]{2}$/.test(code) || code === "ZZ" || code === "XX") return String.fromCodePoint(0x25CC);
|
||||
return String.fromCodePoint(code.charCodeAt(0) + 127397, code.charCodeAt(1) + 127397);
|
||||
}
|
||||
function fmtTime(seconds) {
|
||||
if (!seconds) return "-";
|
||||
return new Intl.DateTimeFormat(isZh() ? "zh-CN" : "en", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(seconds * 1000));
|
||||
}
|
||||
function metric(labelEn, labelZh, data) {
|
||||
var total = data || {};
|
||||
var el = document.createElement("article");
|
||||
el.className = "metric";
|
||||
el.innerHTML = '<div class="metric-label">' + text(labelEn, labelZh) + '</div>' +
|
||||
'<div class="metric-value">' + number(total.subscription_requests) + '</div>' +
|
||||
'<div class="metric-sub">' + text("Rules ", "规则 ") + number(total.rule_conversions) + '</div>';
|
||||
return el;
|
||||
}
|
||||
function renderMetrics(data) {
|
||||
var windows = data.windows || {};
|
||||
metricsEl.textContent = "";
|
||||
[
|
||||
["Since Start", "本次启动", windows.startup],
|
||||
["1 Hour", "1 小时", windows.hour],
|
||||
["1 Day", "1 天", windows.day],
|
||||
["7 Days", "7 天", windows.seven_days],
|
||||
["30 Days", "30 天", windows.thirty_days],
|
||||
["Lifetime", "历史总计", windows.lifetime]
|
||||
].forEach(function (item) { metricsEl.appendChild(metric(item[0], item[1], item[2])); });
|
||||
}
|
||||
function renderChart(series) {
|
||||
chart.textContent = "";
|
||||
var max = Math.max(1, ...series.map(function (item) { return item.subscription_requests || 0; }));
|
||||
series.forEach(function (item) {
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "bar";
|
||||
bar.style.height = Math.max(4, Math.round(((item.subscription_requests || 0) / max) * 132)) + "px";
|
||||
bar.title = fmtTime(item.time) + " / " + number(item.subscription_requests);
|
||||
chart.appendChild(bar);
|
||||
});
|
||||
}
|
||||
function renderCountries(countries) {
|
||||
countryMap = new Map();
|
||||
countries.forEach(function (item) { countryMap.set(item.code, item); });
|
||||
countryStatus.textContent = text("Countries ", "国家 ") + number(countries.filter(function (item) { return item.code !== "ZZ" && item.code !== "XX"; }).length);
|
||||
countryBody.textContent = "";
|
||||
if (!countries.length) {
|
||||
var row = document.createElement("tr");
|
||||
var cell = document.createElement("td");
|
||||
cell.colSpan = 3;
|
||||
cell.className = "empty";
|
||||
cell.textContent = text("No conversion data yet", "暂无转换数据");
|
||||
row.appendChild(cell);
|
||||
countryBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
countries.slice(0, 12).forEach(function (item) {
|
||||
var row = document.createElement("tr");
|
||||
var name = document.createElement("td");
|
||||
name.innerHTML = '<span class="country-icon">' + countryIcon(item.code) + '</span><span class="code-badge">' + item.code + '</span>' + countryName(item.code);
|
||||
var req = document.createElement("td");
|
||||
req.textContent = number(item.subscription_requests);
|
||||
var rules = document.createElement("td");
|
||||
rules.textContent = number(item.rule_conversions);
|
||||
row.appendChild(name);
|
||||
row.appendChild(req);
|
||||
row.appendChild(rules);
|
||||
countryBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
function renderMap() {
|
||||
if (!mapData || !window.d3 || !window.topojson) return;
|
||||
var svg = d3.select("#world-map");
|
||||
var node = svg.node();
|
||||
var width = node.clientWidth || 800;
|
||||
var height = node.clientHeight || 430;
|
||||
svg.attr("viewBox", "0 0 " + width + " " + height);
|
||||
svg.selectAll("*").remove();
|
||||
var projection = d3.geoNaturalEarth1().fitSize([width, height], { type: "Sphere" });
|
||||
var path = d3.geoPath(projection);
|
||||
var features = topojson.feature(mapData, mapData.objects.countries).features;
|
||||
var max = Math.max(1, ...Array.from(countryMap.values()).map(function (item) { return item.subscription_requests || 0; }));
|
||||
var color = d3.scaleSequential([0, Math.log10(max + 1)], d3.interpolateYlGnBu);
|
||||
svg.append("path").datum({ type: "Sphere" }).attr("d", path).attr("fill", "transparent");
|
||||
svg.selectAll("path.country")
|
||||
.data(features)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", function (d) {
|
||||
var code = ISO_N3[String(d.id).padStart(3, "0")];
|
||||
return "country" + (countryMap.has(code) ? " has-data" : "");
|
||||
})
|
||||
.attr("d", path)
|
||||
.attr("fill", function (d) {
|
||||
var code = ISO_N3[String(d.id).padStart(3, "0")];
|
||||
var item = countryMap.get(code);
|
||||
if (!item) return "var(--map-empty)";
|
||||
return color(Math.log10((item.subscription_requests || 0) + 1));
|
||||
})
|
||||
.on("mousemove", function (event, d) {
|
||||
var code = ISO_N3[String(d.id).padStart(3, "0")] || "ZZ";
|
||||
var item = countryMap.get(code) || { subscription_requests: 0, rule_conversions: 0 };
|
||||
tooltip.innerHTML = '<div class="tooltip-title"><span class="country-icon">' + countryIcon(code) + '</span>' + countryName(code) + ' · ' + code + '</div>' +
|
||||
'<div class="tooltip-row"><span>' + text("Requests", "请求") + '</span><strong>' + number(item.subscription_requests) + '</strong></div>' +
|
||||
'<div class="tooltip-row"><span>' + text("Rules", "规则") + '</span><strong>' + number(item.rule_conversions) + '</strong></div>';
|
||||
tooltip.style.left = event.offsetX + "px";
|
||||
tooltip.style.top = event.offsetY + "px";
|
||||
tooltip.classList.add("show");
|
||||
})
|
||||
.on("mouseleave", function () { tooltip.classList.remove("show"); });
|
||||
}
|
||||
function render(data) {
|
||||
latest = data;
|
||||
renderMetrics(data);
|
||||
renderChart(data.series || []);
|
||||
renderCountries(data.countries || []);
|
||||
updatedAt.textContent = text("Updated ", "更新 ") + fmtTime(data.generated_at);
|
||||
renderMap();
|
||||
}
|
||||
async function refresh() {
|
||||
var response = await fetch("/dashboard/data", { cache: "no-store", headers: { "Accept": "application/json" } });
|
||||
render(await response.json());
|
||||
}
|
||||
document.getElementById("lang-toggle").addEventListener("click", function () {
|
||||
document.documentElement.lang = isZh() ? "en" : "zh-CN";
|
||||
localStorage.setItem("sce-dashboard-lang", document.documentElement.lang);
|
||||
document.getElementById("lang-toggle").textContent = isZh() ? "中" : "EN";
|
||||
if (latest) render(latest);
|
||||
});
|
||||
document.getElementById("refresh-button").addEventListener("click", refresh);
|
||||
document.getElementById("lang-toggle").textContent = isZh() ? "中" : "EN";
|
||||
Promise.all([
|
||||
fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json").then(function (response) { return response.json(); }).catch(function () { return null; }),
|
||||
refresh()
|
||||
]).then(function (values) {
|
||||
mapData = values[0];
|
||||
renderMap();
|
||||
});
|
||||
window.addEventListener("resize", function () { renderMap(); });
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>)html";
|
||||
}
|
||||
|
||||
} // namespace dashboard_page
|
||||
14
src/handler/dashboard_page.h
Normal file
14
src/handler/dashboard_page.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef DASHBOARD_PAGE_H_INCLUDED
|
||||
#define DASHBOARD_PAGE_H_INCLUDED
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "server/webserver.h"
|
||||
|
||||
namespace dashboard_page {
|
||||
|
||||
std::string page(Request &request, Response &response);
|
||||
|
||||
} // namespace dashboard_page
|
||||
|
||||
#endif // DASHBOARD_PAGE_H_INCLUDED
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "script/script_quickjs.h"
|
||||
#include "server/webserver.h"
|
||||
#include "settings.h"
|
||||
#include "statistics.h"
|
||||
#include "upload.h"
|
||||
#include "utils/time_compat.h"
|
||||
|
||||
@@ -613,7 +614,8 @@ static std::string sanitizeProviderName(const std::string &input) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS);
|
||||
static std::string subconverter_impl(Request &request, Response &response,
|
||||
RuleConversionStats *rule_stats = nullptr);
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -622,6 +624,7 @@ struct CoalescedResponse {
|
||||
std::string content_type;
|
||||
string_icase_map headers;
|
||||
std::string body;
|
||||
uint64_t rule_conversions = 0;
|
||||
};
|
||||
|
||||
struct InflightSubRequest {
|
||||
@@ -980,12 +983,14 @@ static void copyCoalescedToResponse(const CoalescedResponse &result,
|
||||
}
|
||||
|
||||
static CoalescedResponse makeCoalescedResult(const std::string &body,
|
||||
const Response &response) {
|
||||
const Response &response,
|
||||
uint64_t rule_conversions) {
|
||||
CoalescedResponse result;
|
||||
result.status_code = response.status_code;
|
||||
result.content_type = response.content_type;
|
||||
result.headers = response.headers;
|
||||
result.body = body;
|
||||
result.rule_conversions = rule_conversions;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1041,11 +1046,16 @@ static void storeCachedSubResponse(const std::string &key,
|
||||
}
|
||||
|
||||
static std::string runSubconverterImplWithRetry(const Request &original,
|
||||
Response &response) {
|
||||
Response &response,
|
||||
RuleConversionStats *stats) {
|
||||
Request first_request = original;
|
||||
Response first_response;
|
||||
std::string body = subconverter_impl(first_request, first_response);
|
||||
RuleConversionStats first_stats;
|
||||
std::string body = subconverter_impl(first_request, first_response,
|
||||
stats ? &first_stats : nullptr);
|
||||
if (first_response.status_code < 500 || !global.coalesceRetryOn5xx) {
|
||||
if (stats)
|
||||
*stats = first_stats;
|
||||
response = first_response;
|
||||
return body;
|
||||
}
|
||||
@@ -1055,30 +1065,57 @@ static std::string runSubconverterImplWithRetry(const Request &original,
|
||||
LOG_LEVEL_WARNING);
|
||||
Request retry_request = original;
|
||||
Response retry_response;
|
||||
std::string retry_body = subconverter_impl(retry_request, retry_response);
|
||||
RuleConversionStats retry_stats;
|
||||
std::string retry_body = subconverter_impl(retry_request, retry_response,
|
||||
stats ? &retry_stats : nullptr);
|
||||
if (retry_response.status_code < 500) {
|
||||
if (stats)
|
||||
*stats = retry_stats;
|
||||
response = retry_response;
|
||||
return retry_body;
|
||||
}
|
||||
|
||||
if (stats)
|
||||
*stats = first_stats;
|
||||
response = first_response;
|
||||
return body;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
static void recordTrackedSubRequest(bool track, const Request &request,
|
||||
const Response &response,
|
||||
uint64_t rule_conversions) {
|
||||
if (!track)
|
||||
return;
|
||||
if (response.status_code < 200 || response.status_code >= 300)
|
||||
return;
|
||||
statistics::recordSubscriptionConversion(request, rule_conversions);
|
||||
}
|
||||
|
||||
std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
if (!shouldCoalesceSubRequest(request))
|
||||
return subconverter_impl(request, response);
|
||||
static std::string subconverterEntry(Request &request, Response &response,
|
||||
bool track) {
|
||||
if (!shouldCoalesceSubRequest(request)) {
|
||||
RuleConversionStats stats;
|
||||
std::string body =
|
||||
subconverter_impl(request, response, track ? &stats : nullptr);
|
||||
recordTrackedSubRequest(track, request, response, stats.rules);
|
||||
return body;
|
||||
}
|
||||
|
||||
std::string key = buildSubRequestKey(request);
|
||||
if (key.empty())
|
||||
return subconverter_impl(request, response);
|
||||
if (key.empty()) {
|
||||
RuleConversionStats stats;
|
||||
std::string body =
|
||||
subconverter_impl(request, response, track ? &stats : nullptr);
|
||||
recordTrackedSubRequest(track, request, response, stats.rules);
|
||||
return body;
|
||||
}
|
||||
|
||||
CoalescedResponse cached_result;
|
||||
if (getCachedSubResponse(key, cached_result)) {
|
||||
writeLog(0, "/sub 响应微缓存命中。", LOG_LEVEL_DEBUG);
|
||||
copyCoalescedToResponse(cached_result, response);
|
||||
recordTrackedSubRequest(track, request, response,
|
||||
cached_result.rule_conversions);
|
||||
return cached_result.body;
|
||||
}
|
||||
|
||||
@@ -1104,6 +1141,8 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
if (call->exception)
|
||||
std::rethrow_exception(call->exception);
|
||||
copyCoalescedToResponse(call->result, response);
|
||||
recordTrackedSubRequest(track, request, response,
|
||||
call->result.rule_conversions);
|
||||
return call->result.body;
|
||||
}
|
||||
|
||||
@@ -1111,9 +1150,11 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
writeLog(0, "/sub 请求成为同 key 转换 owner。", LOG_LEVEL_DEBUG);
|
||||
Request original_request = request;
|
||||
Response owner_response;
|
||||
std::string body =
|
||||
runSubconverterImplWithRetry(original_request, owner_response);
|
||||
CoalescedResponse result = makeCoalescedResult(body, owner_response);
|
||||
RuleConversionStats stats;
|
||||
std::string body = runSubconverterImplWithRetry(
|
||||
original_request, owner_response, track ? &stats : nullptr);
|
||||
CoalescedResponse result =
|
||||
makeCoalescedResult(body, owner_response, stats.rules);
|
||||
response = owner_response;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(call->mutex);
|
||||
@@ -1126,6 +1167,7 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
}
|
||||
storeCachedSubResponse(key, result);
|
||||
call->cv.notify_all();
|
||||
recordTrackedSubRequest(track, request, response, result.rule_conversions);
|
||||
return body;
|
||||
} catch (...) {
|
||||
{
|
||||
@@ -1142,7 +1184,18 @@ std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
}
|
||||
}
|
||||
|
||||
static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
|
||||
} // namespace
|
||||
|
||||
std::string subconverter(RESPONSE_CALLBACK_ARGS) {
|
||||
return subconverterEntry(request, response, false);
|
||||
}
|
||||
|
||||
std::string subconverterTracked(RESPONSE_CALLBACK_ARGS) {
|
||||
return subconverterEntry(request, response, true);
|
||||
}
|
||||
|
||||
static std::string subconverter_impl(Request &request, Response &response,
|
||||
RuleConversionStats *rule_stats) {
|
||||
auto &argument = request.argument;
|
||||
int *status_code = &response.status_code;
|
||||
|
||||
@@ -1261,6 +1314,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
|
||||
lExcludeRemarks = global.excludeRemarks;
|
||||
std::vector<RulesetContent> lRulesetContent;
|
||||
extra_settings ext;
|
||||
ext.rule_stats = rule_stats;
|
||||
std::string subInfo, dummy;
|
||||
int interval = !argUpdateInterval.empty()
|
||||
? to_int(argUpdateInterval, global.updateInterval)
|
||||
|
||||
@@ -23,6 +23,7 @@ std::string getProfile(RESPONSE_CALLBACK_ARGS);
|
||||
std::string getRuleset(RESPONSE_CALLBACK_ARGS);
|
||||
|
||||
std::string subconverter(RESPONSE_CALLBACK_ARGS);
|
||||
std::string subconverterTracked(RESPONSE_CALLBACK_ARGS);
|
||||
std::string simpleToClashR(RESPONSE_CALLBACK_ARGS);
|
||||
std::string surgeConfToClash(RESPONSE_CALLBACK_ARGS);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <filesystem>
|
||||
@@ -684,6 +685,21 @@ void readYAMLConf(YAML::Node &node) {
|
||||
node["advanced"]["coalesce_retry_on_5xx"] >> global.coalesceRetryOn5xx;
|
||||
node["advanced"]["response_cache_ttl"] >> global.responseCacheTtl;
|
||||
}
|
||||
if (node["statistics"].IsDefined()) {
|
||||
YAML::Node stats = node["statistics"];
|
||||
stats["enabled"] >> global.statisticsEnabled;
|
||||
stats["data_dir"] >> global.statisticsDataDir;
|
||||
stats["flush_interval"] >> global.statisticsFlushInterval;
|
||||
if (stats["geo"].IsDefined()) {
|
||||
stats["geo"]["provider"] >> global.statisticsGeoProvider;
|
||||
if (stats["geo"]["country_headers"].IsSequence()) {
|
||||
string_array country_headers;
|
||||
stats["geo"]["country_headers"] >> country_headers;
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node["security"].IsDefined()) {
|
||||
node["security"]["profile"] >> global.securityProfile;
|
||||
node["security"]["allow_public_upload"] >> global.allowPublicUpload;
|
||||
@@ -894,6 +910,20 @@ void readTOMLConf(toml::value &root) {
|
||||
global.cacheSubscription = global.cacheConfig = global.cacheRuleset = 0;
|
||||
}
|
||||
|
||||
auto section_statistics =
|
||||
toml::find_or(root, "statistics", toml::value(toml::table()));
|
||||
find_if_exist(section_statistics, "enabled", global.statisticsEnabled,
|
||||
"data_dir", global.statisticsDataDir, "flush_interval",
|
||||
global.statisticsFlushInterval);
|
||||
auto section_statistics_geo =
|
||||
toml::find_or(section_statistics, "geo", toml::value(toml::table()));
|
||||
find_if_exist(section_statistics_geo, "provider",
|
||||
global.statisticsGeoProvider);
|
||||
string_array country_headers = toml::find_or<string_array>(
|
||||
section_statistics_geo, "country_headers", string_array{});
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
|
||||
auto section_security =
|
||||
toml::find_or(root, "security", toml::value(toml::table()));
|
||||
find_if_exist(section_security, "profile", global.securityProfile,
|
||||
@@ -912,6 +942,13 @@ void readConf() {
|
||||
eraseElements(global.includeRemarks);
|
||||
eraseElements(global.customProxyGroups);
|
||||
eraseElements(global.customRulesets);
|
||||
global.statisticsEnabled = false;
|
||||
global.statisticsDataDir = "stats";
|
||||
global.statisticsFlushInterval = 5;
|
||||
global.statisticsGeoProvider = "header";
|
||||
global.statisticsCountryHeaders = {"CF-IPCountry", "X-Geo-Country",
|
||||
"X-Vercel-IP-Country",
|
||||
"CloudFront-Viewer-Country"};
|
||||
|
||||
try {
|
||||
std::string prefdata = fileGet(global.prefPath, false);
|
||||
@@ -1184,6 +1221,25 @@ void readConf() {
|
||||
ini.get_bool_if_exist("coalesce_retry_on_5xx", global.coalesceRetryOn5xx);
|
||||
ini.get_int_if_exist("response_cache_ttl", global.responseCacheTtl);
|
||||
|
||||
if (ini.section_exist("statistics")) {
|
||||
ini.enter_section("statistics");
|
||||
ini.get_bool_if_exist("enabled", global.statisticsEnabled);
|
||||
ini.get_if_exist("data_dir", global.statisticsDataDir);
|
||||
ini.get_int_if_exist("flush_interval", global.statisticsFlushInterval);
|
||||
ini.get_if_exist("geo_provider", global.statisticsGeoProvider);
|
||||
if (ini.item_exist("country_headers")) {
|
||||
string_array country_headers = split(ini.get("country_headers"), ",");
|
||||
for (std::string &header : country_headers)
|
||||
header = trimWhitespace(header, true, true);
|
||||
country_headers.erase(
|
||||
std::remove_if(country_headers.begin(), country_headers.end(),
|
||||
[](const std::string &value) { return value.empty(); }),
|
||||
country_headers.end());
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
}
|
||||
}
|
||||
|
||||
if (ini.section_exist("security")) {
|
||||
ini.enter_section("security");
|
||||
ini.get_if_exist("profile", global.securityProfile);
|
||||
|
||||
@@ -82,6 +82,15 @@ struct Settings {
|
||||
int responseCacheTtl = 0;
|
||||
unsigned long long configGeneration = 0;
|
||||
|
||||
// opt-in privacy-preserving statistics and dashboard
|
||||
bool statisticsEnabled = false;
|
||||
std::string statisticsDataDir = "stats";
|
||||
int statisticsFlushInterval = 5;
|
||||
std::string statisticsGeoProvider = "header";
|
||||
string_array statisticsCountryHeaders = {
|
||||
"CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country",
|
||||
"CloudFront-Viewer-Country"};
|
||||
|
||||
// limits
|
||||
size_t maxAllowedRulesets = 64, maxAllowedRules = 32768;
|
||||
bool scriptCleanContext = false;
|
||||
|
||||
438
src/handler/statistics.cpp
Normal file
438
src/handler/statistics.cpp
Normal file
@@ -0,0 +1,438 @@
|
||||
#include "handler/statistics.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <direct.h>
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "handler/settings.h"
|
||||
#include "utils/file.h"
|
||||
#include "utils/logger.h"
|
||||
#include "utils/string.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
constexpr size_t kBucketCount = 30 * 24 * 60;
|
||||
|
||||
struct Counters {
|
||||
uint64_t subscription_requests = 0;
|
||||
uint64_t rule_conversions = 0;
|
||||
};
|
||||
|
||||
struct Bucket {
|
||||
int64_t minute = 0;
|
||||
Counters counters;
|
||||
};
|
||||
|
||||
struct CountryCounters {
|
||||
uint64_t subscription_requests = 0;
|
||||
uint64_t rule_conversions = 0;
|
||||
};
|
||||
|
||||
struct SnapshotCountry {
|
||||
std::string code;
|
||||
CountryCounters counters;
|
||||
};
|
||||
|
||||
struct State {
|
||||
bool initialized = false;
|
||||
bool dirty = false;
|
||||
int64_t started_at = 0;
|
||||
int64_t last_flush = 0;
|
||||
Counters startup;
|
||||
Counters lifetime;
|
||||
std::array<Bucket, kBucketCount> buckets;
|
||||
std::map<std::string, CountryCounters> countries;
|
||||
};
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::unique_ptr<State> g_state;
|
||||
|
||||
int64_t nowSeconds() { return static_cast<int64_t>(std::time(nullptr)); }
|
||||
|
||||
std::string normalizePath(std::string path) {
|
||||
for (char &ch : path) {
|
||||
if (ch == '\\')
|
||||
ch = '/';
|
||||
}
|
||||
while (path.size() > 1 && path.back() == '/')
|
||||
path.pop_back();
|
||||
return path;
|
||||
}
|
||||
|
||||
bool pathIsSafe(const std::string &path) {
|
||||
if (path.empty())
|
||||
return false;
|
||||
if (path.find("..") != std::string::npos)
|
||||
return false;
|
||||
#ifdef _WIN32
|
||||
if (path.size() > 1 && path[1] == ':')
|
||||
return false;
|
||||
#else
|
||||
if (!path.empty() && path[0] == '/')
|
||||
return false;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ensureDirectory(const std::string &raw_path) {
|
||||
std::string path = normalizePath(raw_path);
|
||||
if (!pathIsSafe(path))
|
||||
return false;
|
||||
|
||||
std::string current;
|
||||
size_t pos = 0;
|
||||
while (pos <= path.size()) {
|
||||
size_t next = path.find('/', pos);
|
||||
std::string part =
|
||||
path.substr(pos, next == std::string::npos ? path.size() - pos
|
||||
: next - pos);
|
||||
if (!part.empty()) {
|
||||
if (!current.empty())
|
||||
current += '/';
|
||||
current += part;
|
||||
#ifdef _WIN32
|
||||
if (_mkdir(current.c_str()) != 0 && errno != EEXIST)
|
||||
return false;
|
||||
#else
|
||||
if (mkdir(current.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) != 0 &&
|
||||
errno != EEXIST)
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
if (next == std::string::npos)
|
||||
break;
|
||||
pos = next + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool writeTextFile(const std::string &path, const std::string &content) {
|
||||
std::FILE *fp = std::fopen(path.c_str(), "wb");
|
||||
if (!fp)
|
||||
return false;
|
||||
size_t written = std::fwrite(content.c_str(), 1, content.size(), fp);
|
||||
std::fclose(fp);
|
||||
return written == content.size();
|
||||
}
|
||||
|
||||
std::string dataFilePath() {
|
||||
std::string dir = normalizePath(global.statisticsDataDir);
|
||||
if (dir.empty())
|
||||
dir = "stats";
|
||||
return dir + "/statistics.json";
|
||||
}
|
||||
|
||||
bool validCountryCode(const std::string &value) {
|
||||
if (value == "T1" || value == "XX")
|
||||
return true;
|
||||
if (value.size() != 2)
|
||||
return false;
|
||||
return std::isalpha(static_cast<unsigned char>(value[0])) &&
|
||||
std::isalpha(static_cast<unsigned char>(value[1]));
|
||||
}
|
||||
|
||||
std::string normalizeCountryCode(std::string value) {
|
||||
value = toUpper(trimWhitespace(value, true, true));
|
||||
if (!validCountryCode(value))
|
||||
return "ZZ";
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string countryFromHeaders(const Request &request) {
|
||||
if (toLower(global.statisticsGeoProvider) == "none")
|
||||
return "ZZ";
|
||||
|
||||
for (const std::string &header : global.statisticsCountryHeaders) {
|
||||
auto iter = request.headers.find(header);
|
||||
if (iter == request.headers.end())
|
||||
continue;
|
||||
std::string code = normalizeCountryCode(iter->second);
|
||||
if (code != "ZZ")
|
||||
return code;
|
||||
}
|
||||
return "ZZ";
|
||||
}
|
||||
|
||||
void addCounters(Counters &target, uint64_t requests, uint64_t rules) {
|
||||
target.subscription_requests += requests;
|
||||
target.rule_conversions += rules;
|
||||
}
|
||||
|
||||
Counters windowCountersLocked(int64_t now_minute, int minutes) {
|
||||
Counters result;
|
||||
if (!g_state)
|
||||
return result;
|
||||
int64_t earliest = now_minute - minutes + 1;
|
||||
for (const Bucket &bucket : g_state->buckets) {
|
||||
if (bucket.minute >= earliest && bucket.minute <= now_minute) {
|
||||
addCounters(result, bucket.counters.subscription_requests,
|
||||
bucket.counters.rule_conversions);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<Counters> hourlySeriesLocked(int64_t now_minute, int hours) {
|
||||
std::vector<Counters> result(static_cast<size_t>(hours));
|
||||
if (!g_state)
|
||||
return result;
|
||||
int64_t current_hour = now_minute / 60;
|
||||
int64_t first_hour = current_hour - hours + 1;
|
||||
for (const Bucket &bucket : g_state->buckets) {
|
||||
if (bucket.minute <= 0)
|
||||
continue;
|
||||
int64_t hour = bucket.minute / 60;
|
||||
if (hour < first_hour || hour > current_hour)
|
||||
continue;
|
||||
size_t index = static_cast<size_t>(hour - first_hour);
|
||||
addCounters(result[index], bucket.counters.subscription_requests,
|
||||
bucket.counters.rule_conversions);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
json countersJson(const Counters &counters) {
|
||||
return json{{"subscription_requests", counters.subscription_requests},
|
||||
{"rule_conversions", counters.rule_conversions}};
|
||||
}
|
||||
|
||||
void loadLocked() {
|
||||
std::string content = fileGet(dataFilePath(), false);
|
||||
if (content.empty())
|
||||
return;
|
||||
|
||||
try {
|
||||
json root = json::parse(content);
|
||||
if (root.value("schema", 0) != 1)
|
||||
return;
|
||||
|
||||
auto lifetime = root.value("lifetime", json::object());
|
||||
g_state->lifetime.subscription_requests =
|
||||
lifetime.value("subscription_requests", 0ULL);
|
||||
g_state->lifetime.rule_conversions =
|
||||
lifetime.value("rule_conversions", 0ULL);
|
||||
|
||||
auto countries = root.value("countries", json::object());
|
||||
for (auto iter = countries.begin(); iter != countries.end(); ++iter) {
|
||||
std::string code = normalizeCountryCode(iter.key());
|
||||
CountryCounters counters;
|
||||
counters.subscription_requests =
|
||||
iter.value().value("subscription_requests", 0ULL);
|
||||
counters.rule_conversions = iter.value().value("rule_conversions", 0ULL);
|
||||
if (counters.subscription_requests || counters.rule_conversions)
|
||||
g_state->countries[code] = counters;
|
||||
}
|
||||
|
||||
auto buckets = root.value("buckets", json::array());
|
||||
for (const auto &item : buckets) {
|
||||
int64_t minute = item.value("minute", 0LL);
|
||||
if (minute <= 0)
|
||||
continue;
|
||||
size_t index = static_cast<size_t>(minute % kBucketCount);
|
||||
g_state->buckets[index].minute = minute;
|
||||
g_state->buckets[index].counters.subscription_requests =
|
||||
item.value("subscription_requests", 0ULL);
|
||||
g_state->buckets[index].counters.rule_conversions =
|
||||
item.value("rule_conversions", 0ULL);
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
writeLog(0, "统计数据加载失败:" + std::string(e.what()), LOG_LEVEL_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
bool flushLocked() {
|
||||
std::string path = dataFilePath();
|
||||
std::string dir = normalizePath(global.statisticsDataDir);
|
||||
if (dir.empty())
|
||||
dir = "stats";
|
||||
if (!ensureDirectory(dir)) {
|
||||
writeLog(0, "无法创建统计数据目录:" + dir, LOG_LEVEL_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
json root;
|
||||
root["schema"] = 1;
|
||||
root["updated_at"] = nowSeconds();
|
||||
root["lifetime"] = countersJson(g_state->lifetime);
|
||||
|
||||
json countries = json::object();
|
||||
for (const auto &entry : g_state->countries) {
|
||||
countries[entry.first] = {
|
||||
{"subscription_requests", entry.second.subscription_requests},
|
||||
{"rule_conversions", entry.second.rule_conversions}};
|
||||
}
|
||||
root["countries"] = countries;
|
||||
|
||||
json buckets = json::array();
|
||||
for (const Bucket &bucket : g_state->buckets) {
|
||||
if (bucket.minute <= 0)
|
||||
continue;
|
||||
if (!bucket.counters.subscription_requests &&
|
||||
!bucket.counters.rule_conversions)
|
||||
continue;
|
||||
buckets.push_back({{"minute", bucket.minute},
|
||||
{"subscription_requests",
|
||||
bucket.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
bucket.counters.rule_conversions}});
|
||||
}
|
||||
root["buckets"] = buckets;
|
||||
|
||||
std::string tmp = path + ".tmp";
|
||||
if (!writeTextFile(tmp, root.dump()))
|
||||
return false;
|
||||
std::remove(path.c_str());
|
||||
if (std::rename(tmp.c_str(), path.c_str()) != 0) {
|
||||
std::remove(tmp.c_str());
|
||||
return false;
|
||||
}
|
||||
g_state->dirty = false;
|
||||
g_state->last_flush = nowSeconds();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace statistics {
|
||||
|
||||
void initialize() {
|
||||
if (!global.statisticsEnabled)
|
||||
return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
if (g_state && g_state->initialized)
|
||||
return;
|
||||
g_state.reset(new State());
|
||||
g_state->initialized = true;
|
||||
g_state->started_at = nowSeconds();
|
||||
loadLocked();
|
||||
writeLog(0, "统计数据已启用,数据目录:" + global.statisticsDataDir,
|
||||
LOG_LEVEL_INFO);
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
if (!global.statisticsEnabled)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
if (g_state && g_state->initialized && g_state->dirty)
|
||||
flushLocked();
|
||||
}
|
||||
|
||||
bool isEnabled() { return global.statisticsEnabled; }
|
||||
|
||||
void recordSubscriptionConversion(const Request &request,
|
||||
uint64_t rule_conversions) {
|
||||
if (!global.statisticsEnabled || request.method != "GET")
|
||||
return;
|
||||
|
||||
int64_t now = nowSeconds();
|
||||
int64_t minute = now / 60;
|
||||
std::string country = countryFromHeaders(request);
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
if (!g_state || !g_state->initialized)
|
||||
return;
|
||||
|
||||
addCounters(g_state->startup, 1, rule_conversions);
|
||||
addCounters(g_state->lifetime, 1, rule_conversions);
|
||||
|
||||
size_t index = static_cast<size_t>(minute % kBucketCount);
|
||||
if (g_state->buckets[index].minute != minute) {
|
||||
g_state->buckets[index].minute = minute;
|
||||
g_state->buckets[index].counters = Counters();
|
||||
}
|
||||
addCounters(g_state->buckets[index].counters, 1, rule_conversions);
|
||||
|
||||
CountryCounters &country_counters = g_state->countries[country];
|
||||
country_counters.subscription_requests++;
|
||||
country_counters.rule_conversions += rule_conversions;
|
||||
|
||||
g_state->dirty = true;
|
||||
int flush_interval = std::max(1, global.statisticsFlushInterval);
|
||||
if (now - g_state->last_flush >= flush_interval)
|
||||
flushLocked();
|
||||
}
|
||||
|
||||
std::string dashboardData(RESPONSE_CALLBACK_ARGS) {
|
||||
response.headers["Cache-Control"] = "no-store";
|
||||
response.headers["X-Robots-Tag"] =
|
||||
"noindex, nofollow, noarchive, nosnippet, noimageindex";
|
||||
response.content_type = "application/json; charset=utf-8";
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
int64_t now = nowSeconds();
|
||||
int64_t now_minute = now / 60;
|
||||
|
||||
json root;
|
||||
root["enabled"] = global.statisticsEnabled;
|
||||
root["generated_at"] = now;
|
||||
root["started_at"] = g_state ? g_state->started_at : 0;
|
||||
root["windows"] = {
|
||||
{"startup", countersJson(g_state ? g_state->startup : Counters())},
|
||||
{"hour", countersJson(windowCountersLocked(now_minute, 60))},
|
||||
{"day", countersJson(windowCountersLocked(now_minute, 24 * 60))},
|
||||
{"seven_days", countersJson(windowCountersLocked(now_minute, 7 * 24 * 60))},
|
||||
{"thirty_days",
|
||||
countersJson(windowCountersLocked(now_minute, 30 * 24 * 60))},
|
||||
{"lifetime", countersJson(g_state ? g_state->lifetime : Counters())}};
|
||||
|
||||
std::vector<SnapshotCountry> country_list;
|
||||
country_list.reserve(g_state ? g_state->countries.size() : 0);
|
||||
if (g_state) {
|
||||
for (const auto &entry : g_state->countries)
|
||||
country_list.push_back({entry.first, entry.second});
|
||||
}
|
||||
std::sort(country_list.begin(), country_list.end(),
|
||||
[](const SnapshotCountry &lhs, const SnapshotCountry &rhs) {
|
||||
if (lhs.counters.subscription_requests !=
|
||||
rhs.counters.subscription_requests)
|
||||
return lhs.counters.subscription_requests >
|
||||
rhs.counters.subscription_requests;
|
||||
return lhs.code < rhs.code;
|
||||
});
|
||||
|
||||
json countries = json::array();
|
||||
for (const SnapshotCountry &country : country_list) {
|
||||
countries.push_back({{"code", country.code},
|
||||
{"subscription_requests",
|
||||
country.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
country.counters.rule_conversions}});
|
||||
}
|
||||
root["countries"] = countries;
|
||||
|
||||
json series = json::array();
|
||||
std::vector<Counters> hourly = hourlySeriesLocked(now_minute, 24);
|
||||
int64_t current_hour = now_minute / 60;
|
||||
int64_t first_hour = current_hour - 24 + 1;
|
||||
for (size_t i = 0; i < hourly.size(); ++i) {
|
||||
int64_t hour = first_hour + static_cast<int64_t>(i);
|
||||
series.push_back({{"time", hour * 3600},
|
||||
{"subscription_requests",
|
||||
hourly[i].subscription_requests},
|
||||
{"rule_conversions", hourly[i].rule_conversions}});
|
||||
}
|
||||
root["series"] = series;
|
||||
|
||||
return root.dump();
|
||||
}
|
||||
|
||||
} // namespace statistics
|
||||
22
src/handler/statistics.h
Normal file
22
src/handler/statistics.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#ifndef STATISTICS_H_INCLUDED
|
||||
#define STATISTICS_H_INCLUDED
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "server/webserver.h"
|
||||
|
||||
namespace statistics {
|
||||
|
||||
void initialize();
|
||||
void shutdown();
|
||||
bool isEnabled();
|
||||
|
||||
void recordSubscriptionConversion(const Request &request,
|
||||
uint64_t rule_conversions);
|
||||
|
||||
std::string dashboardData(RESPONSE_CALLBACK_ARGS);
|
||||
|
||||
} // namespace statistics
|
||||
|
||||
#endif // STATISTICS_H_INCLUDED
|
||||
19
src/main.cpp
19
src/main.cpp
@@ -7,9 +7,11 @@
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "config/ruleset.h"
|
||||
#include "handler/dashboard_page.h"
|
||||
#include "handler/inspect_page.h"
|
||||
#include "handler/interfaces.h"
|
||||
#include "handler/settings.h"
|
||||
#include "handler/statistics.h"
|
||||
#include "handler/version_page.h"
|
||||
#include "handler/webget.h"
|
||||
#include "script/cron.h"
|
||||
@@ -145,6 +147,7 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
SetConsoleTitle("SubConverter-Extended " VERSION);
|
||||
readConf();
|
||||
statistics::initialize();
|
||||
// vfs::vfs_read("vfs.ini");
|
||||
if (!global.updateRulesetOnRequest)
|
||||
refreshRulesets(global.customRulesets, global.rulesetsContent);
|
||||
@@ -197,6 +200,13 @@ int main(int argc, char *argv[]) {
|
||||
version_page::page);
|
||||
webServer.append_response("GET", "/inspect", "text/html; charset=utf-8",
|
||||
inspect_page::page);
|
||||
if (global.statisticsEnabled) {
|
||||
webServer.append_response("GET", "/dashboard", "text/html; charset=utf-8",
|
||||
dashboard_page::page);
|
||||
webServer.append_response("GET", "/dashboard/data",
|
||||
"application/json; charset=utf-8",
|
||||
statistics::dashboardData);
|
||||
}
|
||||
|
||||
webServer.append_response(
|
||||
"GET", "/robots.txt", "text/plain; charset=utf-8",
|
||||
@@ -204,6 +214,7 @@ int main(int argc, char *argv[]) {
|
||||
return "User-agent: *\n"
|
||||
"Disallow: /version\n"
|
||||
"Disallow: /inspect\n"
|
||||
"Disallow: /dashboard\n"
|
||||
"Disallow: /v\n";
|
||||
});
|
||||
|
||||
@@ -267,9 +278,12 @@ int main(int argc, char *argv[]) {
|
||||
*/
|
||||
|
||||
webServer.append_response("GET", "/sub", "text/plain;charset=utf-8",
|
||||
subconverter);
|
||||
global.statisticsEnabled ? subconverterTracked
|
||||
: subconverter);
|
||||
|
||||
webServer.append_response("HEAD", "/sub", "text/plain", subconverter);
|
||||
webServer.append_response("HEAD", "/sub", "text/plain",
|
||||
global.statisticsEnabled ? subconverterTracked
|
||||
: subconverter);
|
||||
|
||||
/*
|
||||
webServer.append_response("GET", "/sub2clashr", "text/plain;charset=utf-8",
|
||||
@@ -331,6 +345,7 @@ int main(int argc, char *argv[]) {
|
||||
std::to_string(global.listenPort),
|
||||
LOG_LEVEL_INFO);
|
||||
int ret = webServer.start_web_server_multi(&args);
|
||||
statistics::shutdown();
|
||||
|
||||
#ifdef _WIN32
|
||||
WSACleanup();
|
||||
|
||||
@@ -14,6 +14,8 @@ struct Request
|
||||
{
|
||||
std::string method;
|
||||
std::string url;
|
||||
std::string remote_addr;
|
||||
int remote_port = 0;
|
||||
string_multimap argument;
|
||||
string_icase_map headers;
|
||||
std::string postdata;
|
||||
|
||||
@@ -40,6 +40,8 @@ static httplib::Server::Handler makeHandler(const responseRoute &rr) {
|
||||
Response resp;
|
||||
req.method = request.method;
|
||||
req.url = request.path;
|
||||
req.remote_addr = request.remote_addr;
|
||||
req.remote_port = request.remote_port;
|
||||
for (auto &h : request.headers) {
|
||||
if (startsWith(h.first, "LOCAL_") || startsWith(h.first, "REMOTE_") ||
|
||||
is_request_header_blacklisted(h.first)) {
|
||||
|
||||
Reference in New Issue
Block a user