Compare commits

..

5 Commits

Author SHA1 Message Date
spiritlhl
7b74cbe1e7 fix: 增加参数设置和自动化评价总结功能 2026-04-16 21:51:37 +08:00
github-actions[bot]
8137a7ac6a chore: update ECS_VERSION to 0.1.118 in goecs.sh 2026-04-16 04:27:49 +00:00
spiritlhl
04725fef54 feat: 增加类GUI显示,增强参数解析 2026-04-16 12:13:06 +08:00
spiritlhl
4e868a384a fix: 修复readme的Action操作 2026-04-01 04:35:57 +00:00
github-actions[bot]
7a4885346b chore: update ECS_VERSION to 0.1.117 in goecs.sh 2026-04-01 04:24:16 +00:00
13 changed files with 1851 additions and 67 deletions

View File

@@ -40,7 +40,8 @@ jobs:
sed -i 's|security.*Enable/Disable security test (default true)|security Enable/Disable security test (default false)|g' README_EN.md
echo "已更新 README_EN.md"
fi
git add README.md README_EN.md
git add README.md
[ -f "README_EN.md" ] && git add README_EN.md || true
git commit -m "Auto update README files" || echo "No changes to commit"
git push origin ${{ github.ref_name }}
else

View File

@@ -32,8 +32,9 @@ jobs:
- name: Copy repository files
run: |
cp goecs.sh temp_repo/
cp README_EN.md temp_repo/
cp README.md temp_repo/
[ -f "README_EN.md" ] && cp README_EN.md temp_repo/ || true
[ -f "README_ZH.md" ] && cp README_ZH.md temp_repo/ || true
cp LICENSE temp_repo/
- name: Download release assets

View File

@@ -102,6 +102,13 @@ func WithEnableUpload(enable bool) ConfigOption {
}
}
// WithAnalyzeResult 设置是否启用测试后结果总结分析
func WithAnalyzeResult(enable bool) ConfigOption {
return func(c *Config) {
c.AnalyzeResult = enable
}
}
// WithEnableLogger 设置是否启用日志
func WithEnableLogger(enable bool) ConfigOption {
return func(c *Config) {

View File

@@ -28,12 +28,12 @@ func RunAllTests(preCheck utils.NetCheckResult, config *Config) *RunResult {
outputMutex sync.Mutex
infoMutex sync.Mutex
)
startTime := time.Now()
switch config.Language {
case "zh":
runner.RunChineseTests(preCheck, config, &wg1, &wg2, &wg3,
runner.RunChineseTests(preCheck, config, &wg1, &wg2, &wg3,
&basicInfo, &securityInfo, &emailInfo, &mediaInfo, &ptInfo,
&output, tempOutput, startTime, &outputMutex, &infoMutex)
case "en":
@@ -45,9 +45,12 @@ func RunAllTests(preCheck utils.NetCheckResult, config *Config) *RunResult {
&basicInfo, &securityInfo, &emailInfo, &mediaInfo, &ptInfo,
&output, tempOutput, startTime, &outputMutex, &infoMutex)
}
if config.AnalyzeResult {
output = runner.AppendAnalysisSummary(config, output, tempOutput, &outputMutex)
}
endTime := time.Now()
return &RunResult{
Output: output,
Duration: endTime.Sub(startTime),

14
go.mod
View File

@@ -3,6 +3,8 @@ module github.com/oneclickvirt/ecs
go 1.25.4
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/imroc/req/v3 v3.54.0
github.com/oneclickvirt/UnlockTests v0.0.35-20260207053956
github.com/oneclickvirt/backtrace v0.0.8-20251109090457
@@ -25,8 +27,14 @@ require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -48,15 +56,20 @@ require (
github.com/koron/go-ssdp v0.0.4 // indirect
github.com/libp2p/go-nat v0.2.0 // indirect
github.com/libp2p/go-netroute v0.2.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/miekg/dns v1.1.61 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nxtrace/NTrace-core v1.5.0 // indirect
github.com/oneclickvirt/dd v0.0.2-20250808062818 // indirect
github.com/oneclickvirt/fio v0.0.2-20250808045755 // indirect
@@ -94,6 +107,7 @@ require (
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/tsosunchia/powclient v0.2.0 // indirect
github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect

31
go.sum
View File

@@ -6,6 +6,20 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
@@ -16,6 +30,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -75,12 +91,16 @@ github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk=
github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk=
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
@@ -94,6 +114,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxtrace/NTrace-core v1.5.0 h1:n+a/FObw/+CcqvhuSQiWcm1q+ODtfo7Wt3VmaIx504I=
github.com/nxtrace/NTrace-core v1.5.0/go.mod h1:/jME48iJ7QaVTzsrTPQyTJ+yExhjeWjax2L6uBd4ckk=
github.com/oneclickvirt/UnlockTests v0.0.35-20260207053956 h1:yccGrw/sYOHZMaFJghPVN3Xn6JyTOXsEQc9v0I92k3M=
@@ -216,6 +242,8 @@ github.com/tsosunchia/powclient v0.2.0 h1:BDrI3O69CbzarbD+CnnY10Kuwn8xlmtQR0m5tB
github.com/tsosunchia/powclient v0.2.0/go.mod h1:fkb7tTW+HMH3ZWZzQUgwvvFKMj/8Ys+C8Sm/uGQzDA0=
github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f h1:glX3VZCYwW1/OmFxOjazfCtBLxXB3YNZk9LF2lYx+Lw=
github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f/go.mod h1:gh//RKyt2Gesx3eOj3ulzrSQ60ySj2UA4qnOdrtarvg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -238,6 +266,8 @@ golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -267,6 +297,7 @@ golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -27,7 +27,7 @@ import (
)
var (
ecsVersion = "v0.1.117" // 融合怪版本号
ecsVersion = "v0.1.119" // 融合怪版本号
configs = params.NewConfig(ecsVersion) // 全局配置实例
userSetFlags = make(map[string]bool) // 用于跟踪哪些参数是用户显式设置的
)
@@ -99,6 +99,9 @@ func main() {
default:
fmt.Println("Unsupported language")
}
if configs.AnalyzeResult {
output = runner.AppendAnalysisSummary(configs, output, tempOutput, &outputMutex)
}
if preCheck.Connected {
runner.HandleUploadResults(configs, output)
}

View File

@@ -152,7 +152,7 @@ goecs_check() {
os=$(uname -s 2>/dev/null || echo "Unknown")
arch=$(uname -m 2>/dev/null || echo "Unknown")
check_china
ECS_VERSION="0.1.116"
ECS_VERSION="0.1.118"
for api in \
"https://api.github.com/repos/oneclickvirt/ecs/releases/latest" \
"https://githubapi.spiritlhl.workers.dev/repos/oneclickvirt/ecs/releases/latest" \
@@ -164,8 +164,8 @@ goecs_check() {
sleep 1
done
if [ -z "$ECS_VERSION" ]; then
_yellow "Unable to get version info, using default version 0.1.116"
ECS_VERSION="0.1.116"
_yellow "Unable to get version info, using default version 0.1.118"
ECS_VERSION="0.1.118"
fi
version_output=""
for cmd_path in "goecs" "./goecs" "/usr/bin/goecs" "/usr/local/bin/goecs"; do

View File

@@ -0,0 +1,652 @@
package analysis
import (
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/imroc/req/v3"
"github.com/oneclickvirt/ecs/internal/params"
)
var (
mbpsRe = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*mbps`)
msRe = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*ms`)
cpuModelZhRe = regexp.MustCompile(`(?im)^\s*CPU\s*型号\s*[:]\s*(.+?)\s*$`)
cpuModelEnRe = regexp.MustCompile(`(?im)^\s*CPU\s*Model\s*[:]\s*(.+?)\s*$`)
threadScoreEnRe = regexp.MustCompile(`(?im)^\s*(\d+)\s*Thread\(s\)\s*Test\s*:\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*$`)
threadScoreZhRe = regexp.MustCompile(`(?im)^\s*(\d+)\s*线程测试\((?:单核|多核)\)得分\s*[:]\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*$`)
gbSingleRe = regexp.MustCompile(`(?im)^\s*Single-Core\s*Score\s*[:]\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*$`)
gbMultiRe = regexp.MustCompile(`(?im)^\s*Multi-Core\s*Score\s*[:]\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*$`)
alphaNumRe = regexp.MustCompile(`[a-z0-9]+`)
)
const (
cpuStatsPrimaryURL = "https://raw.githubusercontent.com/oneclickvirt/ecs/ranks/cpu_statistics.json"
cpuStatsFallbackURL = "https://github.com/oneclickvirt/ecs/raw/refs/heads/ranks/cpu_statistics.json"
cpuCDNProbeTestURL = "https://raw.githubusercontent.com/spiritLHLS/ecs/main/back/test"
cpuStatsCacheTTL = 30 * time.Minute
cpuStatsFailCacheTTL = 5 * time.Minute
cpuStatsRequestTimout = 6 * time.Second
)
var cpuStatsCDNList = []string{
"https://cdn.spiritlhl.net/",
"http://cdn3.spiritlhl.net/",
"http://cdn1.spiritlhl.net/",
"http://cdn2.spiritlhl.net/",
}
type cpuStatsEntry struct {
CPUPrefix string `json:"cpu_prefix"`
CPUModel string `json:"cpu_model"`
SampleCount int `json:"sample_count"`
MaxSingle float64 `json:"max_single_score"`
MaxMulti float64 `json:"max_multi_score"`
AvgSingle float64 `json:"avg_single_score"`
AvgMulti float64 `json:"avg_multi_score"`
Rank int `json:"rank"`
TypicalCores int `json:"typical_cores"`
TypicalThread int `json:"typical_threads"`
}
type cpuStatsPayload struct {
CPUStatistics []cpuStatsEntry `json:"cpu_statistics"`
}
var (
cpuStatsMu sync.Mutex
cachedCPUStats *cpuStatsPayload
cpuStatsExpireAt time.Time
)
func parseFloatsByRegex(content string, re *regexp.Regexp) []float64 {
matches := re.FindAllStringSubmatch(content, -1)
vals := make([]float64, 0, len(matches))
for _, m := range matches {
if len(m) < 2 {
continue
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
continue
}
vals = append(vals, v)
}
return vals
}
func parseFloatString(s string) (float64, bool) {
clean := strings.ReplaceAll(strings.TrimSpace(s), ",", "")
v, err := strconv.ParseFloat(clean, 64)
if err != nil {
return 0, false
}
return v, true
}
func extractCPUModel(output string) string {
for _, re := range []*regexp.Regexp{cpuModelZhRe, cpuModelEnRe} {
m := re.FindStringSubmatch(output)
if len(m) >= 2 {
model := strings.TrimSpace(m[1])
if model != "" {
return model
}
}
}
return ""
}
func extractCPUScores(output string) (single float64, singleOK bool, multi float64, multiOK bool) {
for _, re := range []*regexp.Regexp{threadScoreEnRe, threadScoreZhRe} {
matches := re.FindAllStringSubmatch(output, -1)
for _, m := range matches {
if len(m) < 3 {
continue
}
threads, err := strconv.Atoi(strings.TrimSpace(m[1]))
if err != nil {
continue
}
score, ok := parseFloatString(m[2])
if !ok {
continue
}
if threads == 1 {
single, singleOK = score, true
continue
}
if threads > 1 && (!multiOK || score > multi) {
multi, multiOK = score, true
}
}
}
if !singleOK {
if m := gbSingleRe.FindStringSubmatch(output); len(m) >= 2 {
if v, ok := parseFloatString(m[1]); ok {
single, singleOK = v, true
}
}
}
if !multiOK {
if m := gbMultiRe.FindStringSubmatch(output); len(m) >= 2 {
if v, ok := parseFloatString(m[1]); ok {
multi, multiOK = v, true
}
}
}
return
}
func normalizeCPUString(s string) string {
s = strings.ToLower(s)
b := strings.Builder{}
b.Grow(len(s))
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
func cpuTokens(s string) []string {
lower := strings.ToLower(s)
raw := alphaNumRe.FindAllString(lower, -1)
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, t := range raw {
if len(t) < 2 {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}
func fuzzyScoreCPUModel(model string, entry cpuStatsEntry) float64 {
nm := normalizeCPUString(model)
ne := normalizeCPUString(entry.CPUModel)
np := normalizeCPUString(entry.CPUPrefix)
if nm == "" || (ne == "" && np == "") {
return 0
}
if nm == ne || nm == np {
return 1
}
containsScore := 0.0
for _, candidate := range []string{ne, np} {
if candidate == "" {
continue
}
if strings.Contains(candidate, nm) || strings.Contains(nm, candidate) {
shortLen := len(nm)
if len(candidate) < shortLen {
shortLen = len(candidate)
}
longLen := len(nm)
if len(candidate) > longLen {
longLen = len(candidate)
}
if longLen > 0 {
ratio := float64(shortLen) / float64(longLen)
if ratio > containsScore {
containsScore = ratio
}
}
}
}
modelTokens := cpuTokens(model)
if len(modelTokens) == 0 {
return containsScore
}
entryTokenSet := make(map[string]struct{})
for _, t := range cpuTokens(entry.CPUModel + " " + entry.CPUPrefix) {
entryTokenSet[t] = struct{}{}
}
overlap := 0
for _, t := range modelTokens {
if _, ok := entryTokenSet[t]; ok {
overlap++
}
}
overlapScore := float64(overlap) / float64(len(modelTokens))
if containsScore > overlapScore {
return containsScore
}
return overlapScore
}
func loadCPUStats() *cpuStatsPayload {
cpuStatsMu.Lock()
defer cpuStatsMu.Unlock()
now := time.Now()
if now.Before(cpuStatsExpireAt) {
return cachedCPUStats
}
client := req.C()
client.SetTimeout(cpuStatsRequestTimout)
endpoints := []string{cpuStatsPrimaryURL, cpuStatsFallbackURL}
availableCDN := detectAvailableCPUCDN(client)
for _, endpoint := range endpoints {
urls := []string{}
if availableCDN != "" {
urls = append(urls, availableCDN+endpoint)
}
urls = append(urls, endpoint)
for _, u := range urls {
payload := tryDecodeCPUStatsFromURL(client, u)
if payload == nil {
continue
}
cachedCPUStats = payload
cpuStatsExpireAt = now.Add(cpuStatsCacheTTL)
return cachedCPUStats
}
}
cachedCPUStats = nil
cpuStatsExpireAt = now.Add(cpuStatsFailCacheTTL)
return nil
}
func detectAvailableCPUCDN(client *req.Client) string {
for _, baseURL := range cpuStatsCDNList {
if checkCPUCDN(client, baseURL) {
return baseURL
}
time.Sleep(500 * time.Millisecond)
}
return ""
}
func checkCPUCDN(client *req.Client, baseURL string) bool {
resp, err := client.R().SetHeader("User-Agent", "goecs-summary/1.0").Get(baseURL + cpuCDNProbeTestURL)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if err != nil {
return false
}
return strings.Contains(string(b), "success")
}
func tryDecodeCPUStatsFromURL(client *req.Client, u string) *cpuStatsPayload {
resp, err := client.R().SetHeader("User-Agent", "goecs-summary/1.0").Get(u)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var payload cpuStatsPayload
dec := json.NewDecoder(io.LimitReader(resp.Body, 8<<20))
if err := dec.Decode(&payload); err != nil {
return nil
}
if len(payload.CPUStatistics) == 0 {
return nil
}
return &payload
}
func matchCPUStatsEntry(model string, payload *cpuStatsPayload) *cpuStatsEntry {
if payload == nil || model == "" || len(payload.CPUStatistics) == 0 {
return nil
}
trimModel := strings.TrimSpace(model)
normModel := normalizeCPUString(trimModel)
for i := range payload.CPUStatistics {
entry := &payload.CPUStatistics[i]
if strings.EqualFold(strings.TrimSpace(entry.CPUModel), trimModel) {
return entry
}
}
for i := range payload.CPUStatistics {
entry := &payload.CPUStatistics[i]
if normModel == normalizeCPUString(entry.CPUModel) || normModel == normalizeCPUString(entry.CPUPrefix) {
return entry
}
}
bestIdx := -1
bestScore := 0.0
for i := range payload.CPUStatistics {
score := fuzzyScoreCPUModel(trimModel, payload.CPUStatistics[i])
if score > bestScore {
bestScore = score
bestIdx = i
continue
}
if score == bestScore && bestIdx >= 0 && payload.CPUStatistics[i].SampleCount > payload.CPUStatistics[bestIdx].SampleCount {
bestIdx = i
}
}
if bestIdx >= 0 && bestScore >= 0.45 {
return &payload.CPUStatistics[bestIdx]
}
return nil
}
func cpuTierText(score float64, lang string) string {
if lang == "zh" {
switch {
case score >= 5000:
return "按 README_NEW_USER 的 Sysbench 口径,单核 >5000 可视为高性能第一梯队。"
case score < 500:
return "按 README_NEW_USER 的 Sysbench 口径,单核 <500 属于偏弱性能。"
default:
return "按 README_NEW_USER 的 Sysbench 口径,可按每约 1000 分视作一个性能档位。"
}
}
switch {
case score >= 5000:
return "Per README_NEW_USER Sysbench guidance, single-core > 5000 is considered first-tier high performance."
case score < 500:
return "Per README_NEW_USER Sysbench guidance, single-core < 500 is considered weak performance."
default:
return "Per README_NEW_USER Sysbench guidance, roughly every 1000 points is about one performance tier."
}
}
func summarizeCPUWithRanking(finalOutput, lang string) []string {
model := extractCPUModel(finalOutput)
single, singleOK, multi, multiOK := extractCPUScores(finalOutput)
if !singleOK && !multiOK {
return nil
}
stats := loadCPUStats()
entry := matchCPUStatsEntry(model, stats)
var score float64
var avg float64
var max float64
kind := "single"
if singleOK && entry != nil && entry.AvgSingle > 0 && entry.MaxSingle > 0 {
score, avg, max = single, entry.AvgSingle, entry.MaxSingle
} else if multiOK && entry != nil && entry.AvgMulti > 0 && entry.MaxMulti > 0 {
score, avg, max = multi, entry.AvgMulti, entry.MaxMulti
kind = "multi"
} else if singleOK {
score = single
} else {
score = multi
kind = "multi"
}
lines := make([]string, 0, 4)
if lang == "zh" {
if kind == "single" {
lines = append(lines, fmt.Sprintf("CPU: 检测到单核得分 %.2f。", score))
} else {
lines = append(lines, fmt.Sprintf("CPU: 检测到多核得分 %.2f。", score))
}
} else {
if kind == "single" {
lines = append(lines, fmt.Sprintf("CPU: detected single-core score %.2f.", score))
} else {
lines = append(lines, fmt.Sprintf("CPU: detected multi-core score %.2f.", score))
}
}
if kind == "single" {
lines = append(lines, cpuTierText(score, lang))
}
if entry == nil || avg <= 0 || max <= 0 {
if lang == "zh" {
if model != "" {
lines = append(lines, fmt.Sprintf("CPU 对标: 未在在线榜单中稳定匹配到型号 \"%s\",已仅给出本机分数解读。", model))
} else {
lines = append(lines, "CPU 对标: 未提取到 CPU 型号,已仅给出本机分数解读。")
}
} else {
if model != "" {
lines = append(lines, fmt.Sprintf("CPU ranking: no reliable online match found for model \"%s\"; local score interpretation only.", model))
} else {
lines = append(lines, "CPU ranking: CPU model not found in output; local score interpretation only.")
}
}
return lines
}
reachAvg := score >= avg
gapToMax := max - score
fullBlood := false
if max > 0 {
ratioDiff := (score - max) / max
if ratioDiff < 0 {
ratioDiff = -ratioDiff
}
fullBlood = ratioDiff <= 0.05
}
pctOfAvg := score / avg * 100
pctOfMax := score / max * 100
if lang == "zh" {
lines = append(lines,
fmt.Sprintf("CPU 对标: 匹配 \"%s\"(样本 %d排名 #%d。", entry.CPUModel, entry.SampleCount, entry.Rank),
fmt.Sprintf("平均分达标: %s本机 %.2f,均值 %.2f,达成率 %.2f%%)。", map[bool]string{true: "是", false: "否"}[reachAvg], score, avg, pctOfAvg),
fmt.Sprintf("满血对比: 满血分 %.2f,本机为 %.2f%%,差值 %.2f。", max, pctOfMax, gapToMax),
fmt.Sprintf("满血判定(±5%%波动): %s。", map[bool]string{true: "是", false: "否"}[fullBlood]),
)
} else {
lines = append(lines,
fmt.Sprintf("CPU ranking: matched \"%s\" (samples %d, rank #%d).", entry.CPUModel, entry.SampleCount, entry.Rank),
fmt.Sprintf("Average-level check: %s (local %.2f vs avg %.2f, %.2f%% of avg).", map[bool]string{true: "pass", false: "below avg"}[reachAvg], score, avg, pctOfAvg),
fmt.Sprintf("Full-blood comparison: max %.2f, local is %.2f%% of max, gap %.2f.", max, pctOfMax, gapToMax),
fmt.Sprintf("Full-blood status (within ±5%%): %s.", map[bool]string{true: "yes", false: "no"}[fullBlood]),
)
}
return lines
}
func summarizeBandwidth(vals []float64, lang string) string {
if len(vals) == 0 {
if lang == "zh" {
return "测速: 未检测到有效 Mbps 数据。"
}
return "Speed: no valid Mbps values found."
}
sort.Float64s(vals)
maxV := vals[len(vals)-1]
if lang == "zh" {
switch {
case maxV >= 2000:
return fmt.Sprintf("测速: 峰值约 %.2f Mbps属于高带宽网络。", maxV)
case maxV >= 800:
return fmt.Sprintf("测速: 峰值约 %.2f Mbps带宽表现较好。", maxV)
case maxV >= 200:
return fmt.Sprintf("测速: 峰值约 %.2f Mbps带宽中等可用。", maxV)
default:
return fmt.Sprintf("测速: 峰值约 %.2f Mbps带宽偏低建议关注线路与机型。", maxV)
}
}
switch {
case maxV >= 2000:
return fmt.Sprintf("Speed: peak around %.2f Mbps, high-bandwidth profile.", maxV)
case maxV >= 800:
return fmt.Sprintf("Speed: peak around %.2f Mbps, strong bandwidth performance.", maxV)
case maxV >= 200:
return fmt.Sprintf("Speed: peak around %.2f Mbps, moderate and usable bandwidth.", maxV)
default:
return fmt.Sprintf("Speed: peak around %.2f Mbps, relatively limited bandwidth.", maxV)
}
}
func summarizeLatency(vals []float64, lang string) string {
if len(vals) == 0 {
if lang == "zh" {
return "延迟: 未检测到有效 ms 数据。"
}
return "Latency: no valid ms values found."
}
sort.Float64s(vals)
minV := vals[0]
if lang == "zh" {
switch {
case minV <= 15:
return fmt.Sprintf("延迟: 最优约 %.2f ms实时交互体验优秀。", minV)
case minV <= 45:
return fmt.Sprintf("延迟: 最优约 %.2f ms整体交互体验良好。", minV)
case minV <= 90:
return fmt.Sprintf("延迟: 最优约 %.2f ms可用但有一定时延。", minV)
default:
return fmt.Sprintf("延迟: 最优约 %.2f ms时延偏高建议优化线路。", minV)
}
}
switch {
case minV <= 15:
return fmt.Sprintf("Latency: best around %.2f ms, excellent for interactive workloads.", minV)
case minV <= 45:
return fmt.Sprintf("Latency: best around %.2f ms, generally responsive.", minV)
case minV <= 90:
return fmt.Sprintf("Latency: best around %.2f ms, usable with moderate delay.", minV)
default:
return fmt.Sprintf("Latency: best around %.2f ms, relatively high and may impact responsiveness.", minV)
}
}
func testedScopes(config *params.Config) []string {
scopes := make([]string, 0, 8)
if config.BasicStatus {
scopes = append(scopes, "basic")
}
if config.CpuTestStatus {
scopes = append(scopes, "cpu")
}
if config.MemoryTestStatus {
scopes = append(scopes, "memory")
}
if config.DiskTestStatus {
scopes = append(scopes, "disk")
}
if config.UtTestStatus {
scopes = append(scopes, "unlock")
}
if config.SecurityTestStatus {
scopes = append(scopes, "security")
}
if config.Nt3Status || config.BacktraceStatus || config.PingTestStatus || config.TgdcTestStatus || config.WebTestStatus {
scopes = append(scopes, "network")
}
if config.SpeedTestStatus {
scopes = append(scopes, "speed")
}
return scopes
}
func scopesText(scopes []string, lang string) string {
if len(scopes) == 0 {
if lang == "zh" {
return "无"
}
return "none"
}
labelsZh := map[string]string{
"basic": "系统基础", "cpu": "CPU", "memory": "内存", "disk": "磁盘", "unlock": "解锁", "security": "IP质量", "network": "网络路由", "speed": "带宽测速",
}
labelsEn := map[string]string{
"basic": "system basics", "cpu": "CPU", "memory": "memory", "disk": "disk", "unlock": "unlock", "security": "IP quality", "network": "network route", "speed": "bandwidth",
}
out := make([]string, 0, len(scopes))
for _, s := range scopes {
if lang == "zh" {
out = append(out, labelsZh[s])
} else {
out = append(out, labelsEn[s])
}
}
return strings.Join(out, ", ")
}
// GenerateSummary creates a concise post-test summary from final output.
func GenerateSummary(config *params.Config, finalOutput string) string {
lang := config.Language
scopes := testedScopes(config)
bandwidthVals := parseFloatsByRegex(finalOutput, mbpsRe)
latencyVals := parseFloatsByRegex(finalOutput, msRe)
cpuLines := summarizeCPUWithRanking(finalOutput, lang)
if lang == "zh" {
lines := []string{
"测试结果总结:",
fmt.Sprintf("- 本次覆盖: %s", scopesText(scopes, lang)),
}
for _, line := range cpuLines {
lines = append(lines, "- "+line)
}
if config.SpeedTestStatus {
lines = append(lines, "- "+summarizeBandwidth(bandwidthVals, lang))
lines = append(lines, "- 参考 README_NEW_USER: 一般境外机器带宽 100Mbps 起步,是否够用应以业务下载/传输需求为准。")
}
if config.PingTestStatus || config.TgdcTestStatus || config.WebTestStatus || config.BacktraceStatus || config.Nt3Status {
lines = append(lines, "- "+summarizeLatency(latencyVals, lang))
lines = append(lines, "- 参考 README_NEW_USER: 延迟 >= 9999ms 可视为目标不可用。")
}
lines = append(lines, "- 建议: 结合业务场景(高并发计算/存储/跨境网络)重点参考对应分项。")
return strings.Join(lines, "\n")
}
lines := []string{
"Test Summary:",
fmt.Sprintf("- Scope covered: %s", scopesText(scopes, lang)),
}
for _, line := range cpuLines {
lines = append(lines, "- "+line)
}
if config.SpeedTestStatus {
lines = append(lines, "- "+summarizeBandwidth(bandwidthVals, lang))
lines = append(lines, "- README_NEW_USER note: offshore servers commonly start around 100Mbps; evaluate against your actual workload needs.")
}
if config.PingTestStatus || config.TgdcTestStatus || config.WebTestStatus || config.BacktraceStatus || config.Nt3Status {
lines = append(lines, "- "+summarizeLatency(latencyVals, lang))
lines = append(lines, "- README_NEW_USER note: latency >= 9999ms should be treated as unavailable target.")
}
lines = append(lines, "- Suggestion: prioritize the metrics that match your workload (compute, storage, or cross-region networking).")
return strings.Join(lines, "\n")
}

View File

@@ -21,7 +21,7 @@ func GetMenuChoice(language string) string {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go func() {
select {
case <-sigChan:
@@ -35,7 +35,7 @@ func GetMenuChoice(language string) string {
return
}
}()
for {
var input string
if language == "zh" {
@@ -46,7 +46,7 @@ func GetMenuChoice(language string) string {
fmt.Scanln(&input)
input = strings.TrimSpace(input)
input = strings.TrimRight(input, "\n")
re := regexp.MustCompile(`^\d+$`)
if re.MatchString(input) {
inChoice := input
@@ -125,13 +125,13 @@ func PrintMenuOptions(preCheck utils.NetCheckResult, config *params.Config) {
}
fmt.Printf("使用统计: %s\n", statsInfo)
}
fmt.Println("1. 融合怪完全体(能测全测)")
fmt.Println("2. 极简版(系统信息+CPU+内存+磁盘+测速节点5个)")
fmt.Println("3. 精简版(系统信息+CPU+内存+磁盘+跨国平台解锁+路由+测速节点5个)")
fmt.Println("4. 精简网络版(系统信息+CPU+内存+磁盘+回程+路由+测速节点5个)")
fmt.Println("5. 精简解锁版(系统信息+CPU+内存+磁盘IO+跨国平台解锁+测速节点5个)")
fmt.Println("6. 网络单项(IP质量检测+上游及三网回程+广州三网回程详细路由+全国延迟+TGDC+网站延迟+测速节点11个)")
fmt.Println("7. 解锁单项(跨国平台解锁)")
fmt.Println("1. 融合怪完全体(能测全测)")
fmt.Println("2. 极简版(系统信息+CPU+内存+磁盘+测速节点5个)")
fmt.Println("3. 精简版(系统信息+CPU+内存+磁盘+跨国平台解锁+路由+测速节点5个)")
fmt.Println("4. 精简网络版(系统信息+CPU+内存+磁盘+回程+路由+测速节点5个)")
fmt.Println("5. 精简解锁版(系统信息+CPU+内存+磁盘IO+跨国平台解锁+测速节点5个)")
fmt.Println("6. 网络单项(IP质量检测+上游及三网回程+广州三网回程详细路由+全国延迟+TGDC+网站延迟+测速节点11个)")
fmt.Println("7. 解锁单项(跨国平台解锁)")
fmt.Println("8. 硬件单项(系统信息+CPU+dd磁盘测试+fio磁盘测试)")
fmt.Println("9. IP质量检测(15个数据库的IP质量检测+邮件端口检测)")
fmt.Println("10. 三网回程线路检测+三网回程详细路由(北京上海广州成都)+全国延迟+TGDC+网站延迟")
@@ -145,20 +145,20 @@ func PrintMenuOptions(preCheck utils.NetCheckResult, config *params.Config) {
}
fmt.Printf("%s\n", statsInfo)
}
fmt.Println("1. VPS Fusion Monster Test (Full Test)")
fmt.Println("2. Minimal Test Suite (System Info + CPU + Memory + Disk + 5 Speed Test Nodes)")
fmt.Println("3. Standard Test Suite (System Info + CPU + Memory + Disk + International Platform Unlock + Routing + 5 Speed Test Nodes)")
fmt.Println("4. Network-Focused Test Suite (System Info + CPU + Memory + Disk + Backtrace + Routing + 5 Speed Test Nodes)")
fmt.Println("5. Unlock-Focused Test Suite (System Info + CPU + Memory + Disk IO + International Platform Unlock + 5 Speed Test Nodes)")
fmt.Println("6. Network-Only Test (IP Quality Test + Upstream & 3-Network Backtrace + Guangzhou 3-Network Detailed Routing + National Latency + TGDC + Websites + 11 Speed Test Nodes)")
fmt.Println("7. Unlock-Only Test (International Platform Unlock)")
fmt.Println("8. Hardware-Only Test (System Info + CPU + Memory + dd Disk Test + fio Disk Test)")
fmt.Println("9. IP Quality Test (IP Test with 15 Databases + Email Port Test)")
fmt.Println("0. Exit Program")
fmt.Println("1. VPS Fusion Monster Test (Full Test)")
fmt.Println("2. Minimal Test Suite (System Info + CPU + Memory + Disk + 5 Speed Test Nodes)")
fmt.Println("3. Standard Test Suite (System Info + CPU + Memory + Disk + International Platform Unlock + Routing + 5 Speed Test Nodes)")
fmt.Println("4. Network-Focused Test Suite (System Info + CPU + Memory + Disk + Backtrace + Routing + 5 Speed Test Nodes)")
fmt.Println("5. Unlock-Focused Test Suite (System Info + CPU + Memory + Disk IO + International Platform Unlock + 5 Speed Test Nodes)")
fmt.Println("6. Network-Only Test (IP Quality Test + Upstream & 3-Network Backtrace + Guangzhou 3-Network Detailed Routing + National Latency + TGDC + Websites + 11 Speed Test Nodes)")
fmt.Println("7. Unlock-Only Test (International Platform Unlock)")
fmt.Println("8. Hardware-Only Test (System Info + CPU + Memory + dd Disk Test + fio Disk Test)")
fmt.Println("9. IP Quality Test (IP Test with 15 Databases + Email Port Test)")
fmt.Println("0. Exit Program")
}
}
// HandleMenuMode handles menu selection
// HandleMenuMode handles menu selection using the interactive TUI
func HandleMenuMode(preCheck utils.NetCheckResult, config *params.Config) {
savedParams := config.SaveUserSetParams()
config.BasicStatus = false
@@ -174,63 +174,48 @@ func HandleMenuMode(preCheck utils.NetCheckResult, config *params.Config) {
config.TgdcTestStatus = false
config.WebTestStatus = false
config.AutoChangeDiskMethod = true
PrintMenuOptions(preCheck, config)
Loop:
for {
config.Choice = GetMenuChoice(config.Language)
switch config.Choice {
result := RunTuiMenu(preCheck, config)
if result.quit {
os.Exit(0)
}
// Update language if changed by TUI selection
config.Language = result.language
if result.custom {
config.Choice = "custom"
applyCustomResult(result, preCheck, config)
if config.SpeedTestStatus {
config.OnlyChinaTest = utils.CheckChina(config.EnableLogger, config.Language)
}
} else {
config.Choice = result.choice
switch result.choice {
case "0":
os.Exit(0)
case "1":
SetFullTestStatus(preCheck, config)
config.OnlyChinaTest = utils.CheckChina(config.EnableLogger, config.Language)
break Loop
case "2":
SetMinimalTestStatus(preCheck, config)
break Loop
case "3":
SetStandardTestStatus(preCheck, config)
break Loop
case "4":
SetNetworkFocusedTestStatus(preCheck, config)
break Loop
case "5":
SetUnlockFocusedTestStatus(preCheck, config)
break Loop
case "6":
if !preCheck.Connected {
fmt.Println("Can not test without network connection!")
return
}
SetNetworkOnlyTestStatus(config)
break Loop
case "7":
if !preCheck.Connected {
fmt.Println("Can not test without network connection!")
return
}
SetUnlockOnlyTestStatus(config)
break Loop
case "8":
SetHardwareOnlyTestStatus(preCheck, config)
break Loop
case "9":
if !preCheck.Connected {
fmt.Println("Can not test without network connection!")
return
}
SetIPQualityTestStatus(config)
break Loop
case "10":
if !preCheck.Connected {
fmt.Println("Can not test without network connection!")
return
}
config.Nt3Location = "ALL"
SetRouteTestStatus(config)
break Loop
default:
PrintInvalidChoice(config.Language)
}
}
config.RestoreUserSetParams(savedParams)

999
internal/menu/tui.go Normal file
View File

@@ -0,0 +1,999 @@
package menu
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
textinput "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/oneclickvirt/ecs/internal/params"
"github.com/oneclickvirt/ecs/utils"
)
var (
tTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
tInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
tWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
tSelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120")).Bold(true)
tNormStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
tDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
tHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
tSectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
tChkOnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
tChkOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
tBtnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("120")).Bold(true).Padding(0, 2)
tBtnDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("238")).Padding(0, 2)
tPanelStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1)
)
type menuPhase int
const (
phaseLang menuPhase = iota
phaseMain
phaseCustom
)
type mainMenuItem struct {
id string
zh string
en string
descZh string
descEn string
needNet bool
}
type testToggle struct {
key string
nameZh string
nameEn string
descZh string
descEn string
enabled bool
needNet bool
}
type advOption struct {
value string
labelZh string
labelEn string
descZh string
descEn string
}
type advSetting struct {
key string
nameZh string
nameEn string
descZh string
descEn string
kind string // option | bool | text
options []advOption
current int
boolVal bool
textVal string
}
type tuiResult struct {
choice string
language string
quit bool
custom bool
toggles []testToggle
advanced []advSetting
}
type tuiModel struct {
phase menuPhase
config *params.Config
preCheck utils.NetCheckResult
langPreset bool
langCursor int
mainCursor int
mainItems []mainMenuItem
customCursor int
toggles []testToggle
advanced []advSetting
customTotal int
editingText bool
editingIdx int
textInput textinput.Model
statsTotal int
statsDaily int
hasStats bool
cmpVersion int
newVersion string
result tuiResult
width int
height int
}
func defaultMainItems() []mainMenuItem {
return []mainMenuItem{
{id: "1", zh: "融合怪完全体(能测全测)", en: "Full Test (All Available Tests)", descZh: "系统信息、CPU、内存、磁盘、解锁、IP质量、邮件端口、回程、NT3、测速、TGDC、网站延迟。", descEn: "Runs all available modules: system, compute, memory, disk, unlock, security, routing and speed.", needNet: false},
{id: "2", zh: "极简版", en: "Minimal Suite", descZh: "基础系统+CPU+内存+磁盘+5个测速节点适合快速健康检查。", descEn: "Basic system + CPU + memory + disk + 5 speed nodes for quick health checks.", needNet: false},
{id: "3", zh: "精简版", en: "Standard Suite", descZh: "在极简版基础上增加平台解锁与路由能力评估。", descEn: "Minimal suite plus unlock and routing capability checks.", needNet: false},
{id: "4", zh: "精简网络版", en: "Network Suite", descZh: "强调网络回程与路由质量,辅以基础硬件测试。", descEn: "Network-focused profile with backtrace/routing plus basic hardware checks.", needNet: false},
{id: "5", zh: "精简解锁版", en: "Unlock Suite", descZh: "以流媒体和平台解锁能力为主,附基础性能测试。", descEn: "Unlock-focused profile with essential compute/storage checks.", needNet: false},
{id: "6", zh: "网络单项", en: "Network Only", descZh: "仅网络维度IP质量、回程、NT3、延迟、TGDC、网站和测速。", descEn: "Network-only profile: IP quality, route, latency, TGDC, websites, speed.", needNet: true},
{id: "7", zh: "解锁单项", en: "Unlock Only", descZh: "仅进行跨国平台解锁与流媒体可用性检测。", descEn: "Unlock-only profile for cross-border media/service availability.", needNet: true},
{id: "8", zh: "硬件单项", en: "Hardware Only", descZh: "系统信息、CPU、内存、dd/fio 磁盘测试。", descEn: "Hardware-only profile with system, CPU, memory and disk tests.", needNet: false},
{id: "9", zh: "IP质量检测", en: "IP Quality", descZh: "15库 IP质量 + 邮件端口,适合网络身份风险评估。", descEn: "IP quality across multiple datasets plus email port checks.", needNet: true},
{id: "10", zh: "三网回程线路", en: "3-Network Route", descZh: "三网回程、NT3路由、延迟、TGDC、网站延迟专项。", descEn: "3-network backtrace + NT3 route + latency/TGDC/website checks.", needNet: true},
{id: "custom", zh: ">>> 高级自定义(全参数模式)", en: ">>> Advanced Custom (Full Parameters)", descZh: "按参数逐项配置,支持测试项、方法、路径、上传和结果分析。", descEn: "Configure per-parameter with test toggles, methods, paths, upload and analysis.", needNet: false},
{id: "0", zh: "退出程序", en: "Exit Program", descZh: "退出当前程序。", descEn: "Exit program.", needNet: false},
}
}
func defaultTestToggles() []testToggle {
return []testToggle{
{key: "basic", nameZh: "基础系统信息", nameEn: "Basic System Info", descZh: "操作系统、CPU型号、内核、虚拟化等基础信息。", descEn: "OS, CPU model, kernel, virtualization and base environment info.", enabled: true, needNet: false},
{key: "cpu", nameZh: "CPU测试", nameEn: "CPU Test", descZh: "按所选方法执行 CPU 计算性能测试。", descEn: "Run CPU compute benchmarks using selected method.", enabled: true, needNet: false},
{key: "memory", nameZh: "内存测试", nameEn: "Memory Test", descZh: "按所选方法测试内存吞吐和访问性能。", descEn: "Run memory throughput and access benchmarks by selected method.", enabled: true, needNet: false},
{key: "disk", nameZh: "磁盘测试", nameEn: "Disk Test", descZh: "按所选方法执行磁盘读写性能测试。", descEn: "Run disk read/write benchmark using selected method/path.", enabled: true, needNet: false},
{key: "ut", nameZh: "跨国平台解锁", nameEn: "Streaming Unlock", descZh: "检测多类海外流媒体与服务可用性。", descEn: "Check availability of cross-border streaming/services.", enabled: false, needNet: true},
{key: "security", nameZh: "IP质量检测", nameEn: "IP Quality Check", descZh: "多库 IP 信誉、风险和质量信息检测。", descEn: "IP reputation/risk/quality checks across multiple datasets.", enabled: false, needNet: true},
{key: "email", nameZh: "邮件端口检测", nameEn: "Email Port Check", descZh: "检查常见邮件相关端口连通能力。", descEn: "Check common mail-related port connectivity.", enabled: false, needNet: true},
{key: "backtrace", nameZh: "回程路由", nameEn: "Backtrace Route", descZh: "检测上游及三网回程路径。", descEn: "Inspect upstream and 3-network return routes.", enabled: false, needNet: true},
{key: "nt3", nameZh: "NT3路由", nameEn: "NT3 Route", descZh: "按指定地区与协议执行详细路由追踪。", descEn: "Run detailed route trace by selected location/protocol.", enabled: false, needNet: true},
{key: "speed", nameZh: "测速", nameEn: "Speed Test", descZh: "测试下载/上传带宽与延迟。", descEn: "Measure download/upload bandwidth and latency.", enabled: false, needNet: true},
{key: "ping", nameZh: "Ping测试", nameEn: "Ping Test", descZh: "全国/多地区延迟质量测试。", descEn: "Latency quality checks across multiple regions.", enabled: false, needNet: true},
{key: "tgdc", nameZh: "Telegram DC测试", nameEn: "Telegram DC Test", descZh: "检测 Telegram 数据中心延迟表现。", descEn: "Measure latency to Telegram data centers.", enabled: false, needNet: true},
{key: "web", nameZh: "网站延迟", nameEn: "Website Latency", descZh: "检测常见网站访问延迟。", descEn: "Check latency to commonly used websites.", enabled: false, needNet: true},
}
}
func option(value, zh, en, descZh, descEn string) advOption {
return advOption{value: value, labelZh: zh, labelEn: en, descZh: descZh, descEn: descEn}
}
func defaultAdvSettings(config *params.Config) []advSetting {
adv := []advSetting{
{
key: "cpum", nameZh: "CPU测试方法", nameEn: "CPU Method", kind: "option",
descZh: "选择 CPU 压测方法,不同方法偏向不同负载模型。",
descEn: "Choose CPU benchmark method. Different methods model different workloads.",
options: []advOption{
option("sysbench", "Sysbench", "Sysbench", "通用基准,稳定易比较。", "General-purpose benchmark with stable comparability."),
option("geekbench", "Geekbench", "Geekbench", "偏综合应用模型,便于横向对比。", "Application-like synthetic benchmark for broad comparison."),
option("winsat", "WinSAT", "WinSAT", "Windows 场景下常用基准。", "Common benchmark in Windows environments."),
},
},
{
key: "cput", nameZh: "CPU线程模式", nameEn: "CPU Thread Mode", kind: "option",
descZh: "单线程看核心峰值,多线程看整机并发能力。",
descEn: "Single-core shows peak core power; multi-core shows parallel throughput.",
options: []advOption{
option("multi", "多线程", "Multi-thread", "评估整机并发算力。", "Evaluate full-machine parallel compute capability."),
option("single", "单线程", "Single-thread", "评估单核心峰值性能。", "Evaluate peak single-core performance."),
},
},
{
key: "memorym", nameZh: "内存测试方法", nameEn: "Memory Method", kind: "option",
descZh: "选择内存测试方法,结果关注带宽与访问效率。",
descEn: "Choose memory benchmark method to evaluate bandwidth/access efficiency.",
options: []advOption{
option("stream", "STREAM", "STREAM", "侧重带宽测试。", "Focused on memory bandwidth."),
option("sysbench", "Sysbench", "Sysbench", "通用内存压测。", "General-purpose memory stress benchmark."),
option("dd", "dd", "dd", "基于系统工具的简化测试。", "Simple system-tool-based measurement."),
option("winsat", "WinSAT", "WinSAT", "Windows 环境内存基准。", "Windows-oriented memory benchmark."),
option("auto", "自动", "Auto", "自动选择可用且优先方法。", "Automatically select the preferred available method."),
},
},
{
key: "diskm", nameZh: "磁盘测试方法", nameEn: "Disk Method", kind: "option",
descZh: "选择磁盘测试方法,评估顺序/随机读写能力。",
descEn: "Choose disk method to evaluate sequential/random I/O performance.",
options: []advOption{
option("fio", "FIO", "FIO", "更全面的磁盘 I/O 基准。", "Comprehensive disk I/O benchmark."),
option("dd", "dd", "dd", "快速顺序写读基准。", "Quick sequential write/read benchmark."),
option("winsat", "WinSAT", "WinSAT", "Windows 磁盘基准。", "Disk benchmark for Windows environments."),
},
},
{
key: "diskp", nameZh: "磁盘测试路径", nameEn: "Disk Test Path", kind: "text",
descZh: "自定义磁盘测试目录。留空表示默认路径。",
descEn: "Custom disk test directory. Empty means default path.",
textVal: config.DiskTestPath,
},
{
key: "diskmc", nameZh: "多磁盘检测", nameEn: "Multi-Disk Check", kind: "bool",
descZh: "启用后尝试检测并测试多磁盘路径。",
descEn: "When enabled, detect and benchmark multiple disk paths.",
boolVal: config.DiskMultiCheck,
},
{
key: "nt3loc", nameZh: "NT3测试地区", nameEn: "NT3 Location", kind: "option",
descZh: "选择路由追踪地区。显示中文全称,内部仍使用标准参数值。",
descEn: "Choose route trace region. Full names are shown while preserving standard values.",
options: []advOption{
option("GZ", "广州", "Guangzhou", "从广州节点进行追踪。", "Trace from Guangzhou node."),
option("SH", "上海", "Shanghai", "从上海节点进行追踪。", "Trace from Shanghai node."),
option("BJ", "北京", "Beijing", "从北京节点进行追踪。", "Trace from Beijing node."),
option("CD", "成都", "Chengdu", "从成都节点进行追踪。", "Trace from Chengdu node."),
option("ALL", "全部地区", "All Regions", "依次测试全部地区节点。", "Run route traces from all supported regions."),
},
},
{
key: "nt3t", nameZh: "NT3协议类型", nameEn: "NT3 Protocol", kind: "option",
descZh: "指定 NT3 路由检测协议栈。",
descEn: "Select protocol stack used by NT3 route checks.",
options: []advOption{
option("ipv4", "仅 IPv4", "IPv4 Only", "仅测试 IPv4 路由路径。", "Test IPv4 routing only."),
option("ipv6", "仅 IPv6", "IPv6 Only", "仅测试 IPv6 路由路径。", "Test IPv6 routing only."),
option("both", "IPv4 + IPv6", "IPv4 + IPv6", "同时测试 IPv4 与 IPv6。", "Test both IPv4 and IPv6."),
},
},
{
key: "spnum", nameZh: "测速节点数/运营商", nameEn: "Speed Nodes per ISP", kind: "option",
descZh: "每个运营商选择的测速节点数量。",
descEn: "Number of speed test nodes selected per ISP.",
options: []advOption{
option("1", "1 个", "1 node", "最快速,覆盖最少。", "Fastest run with least coverage."),
option("2", "2 个", "2 nodes", "默认平衡。", "Default balanced option."),
option("3", "3 个", "3 nodes", "覆盖更广,耗时增加。", "Broader coverage with more runtime."),
option("4", "4 个", "4 nodes", "更完整网络采样。", "More complete network sampling."),
option("5", "5 个", "5 nodes", "高覆盖,耗时较高。", "High coverage with longer runtime."),
option("6", "6 个", "6 nodes", "深度采样。", "Deep sampling."),
option("7", "7 个", "7 nodes", "深度采样。", "Deep sampling."),
option("8", "8 个", "8 nodes", "深度采样。", "Deep sampling."),
option("9", "9 个", "9 nodes", "深度采样。", "Deep sampling."),
option("10", "10 个", "10 nodes", "最全面,耗时最高。", "Most comprehensive, longest runtime."),
},
},
{
key: "log", nameZh: "调试日志", nameEn: "Debug Logger", kind: "bool",
descZh: "启用后输出更多调试日志,便于排障。",
descEn: "Enable verbose logs for troubleshooting.",
boolVal: config.EnableLogger,
},
{
key: "upload", nameZh: "上传并生成分享链接", nameEn: "Upload & Share Link", kind: "bool",
descZh: "启用后上传测试结果并生成可分享链接。",
descEn: "Upload final result and generate a shareable link.",
boolVal: config.EnableUpload,
},
{
key: "analysis", nameZh: "测试后结果总结分析", nameEn: "Post-Test Summary Analysis", kind: "bool",
descZh: "测试结束后生成简明总结,提炼优势、短板和用途建议。",
descEn: "Generate concise final summary with strengths, limits and usage hints.",
boolVal: config.AnalyzeResult,
},
{
key: "filepath", nameZh: "结果文件名", nameEn: "Result File Name", kind: "text",
descZh: "上传前本地结果文件名。",
descEn: "Local result filename used before upload.",
textVal: config.FilePath,
},
{
key: "width", nameZh: "输出宽度", nameEn: "Output Width", kind: "option",
descZh: "控制终端输出排版宽度。",
descEn: "Controls console output formatting width.",
options: []advOption{
option("72", "72 列", "72 cols", "紧凑显示。", "Compact layout."),
option("82", "82 列", "82 cols", "默认宽度。", "Default width."),
option("100", "100 列", "100 cols", "更宽显示。", "Wider layout."),
option("120", "120 列", "120 cols", "宽屏显示。", "Wide-screen layout."),
},
},
}
for i := range adv {
switch adv[i].key {
case "cpum":
adv[i].current = optionIndexByValue(adv[i].options, config.CpuTestMethod)
case "cput":
adv[i].current = optionIndexByValue(adv[i].options, config.CpuTestThreadMode)
case "memorym":
adv[i].current = optionIndexByValue(adv[i].options, config.MemoryTestMethod)
case "diskm":
adv[i].current = optionIndexByValue(adv[i].options, config.DiskTestMethod)
case "nt3loc":
adv[i].current = optionIndexByValue(adv[i].options, config.Nt3Location)
case "nt3t":
adv[i].current = optionIndexByValue(adv[i].options, config.Nt3CheckType)
case "spnum":
adv[i].current = optionIndexByValue(adv[i].options, strconv.Itoa(config.SpNum))
case "width":
adv[i].current = optionIndexByValue(adv[i].options, strconv.Itoa(config.Width))
}
}
return adv
}
func optionIndexByValue(options []advOption, value string) int {
for i, opt := range options {
if opt.value == value {
return i
}
}
return 0
}
func newTuiModel(preCheck utils.NetCheckResult, config *params.Config, langPreset bool, statsTotal, statsDaily int, hasStats bool, cmpVersion int, newVersion string) tuiModel {
toggles := defaultTestToggles()
advanced := defaultAdvSettings(config)
ti := textinput.New()
ti.Prompt = "> "
ti.Placeholder = ""
ti.CharLimit = 255
ti.Width = 45
m := tuiModel{
config: config,
preCheck: preCheck,
langPreset: langPreset,
mainItems: defaultMainItems(),
toggles: toggles,
advanced: advanced,
customTotal: len(toggles) + len(advanced) + 1,
statsTotal: statsTotal,
statsDaily: statsDaily,
hasStats: hasStats,
cmpVersion: cmpVersion,
newVersion: newVersion,
width: config.Width,
height: 24,
textInput: ti,
}
if langPreset {
m.phase = phaseMain
m.result.language = config.Language
} else {
m.phase = phaseLang
}
return m
}
func (m tuiModel) Init() tea.Cmd {
return nil
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
switch m.phase {
case phaseLang:
return m.updateLang(msg)
case phaseMain:
return m.updateMain(msg)
case phaseCustom:
return m.updateCustom(msg)
}
}
return m, nil
}
func (m tuiModel) View() string {
switch m.phase {
case phaseLang:
return m.viewLang()
case phaseMain:
return m.viewMain()
case phaseCustom:
return m.viewCustom()
}
return ""
}
func (m tuiModel) updateLang(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.langCursor > 0 {
m.langCursor--
}
case "down", "j":
if m.langCursor < 1 {
m.langCursor++
}
case "1":
m.result.language = "zh"
m.phase = phaseMain
case "2":
m.result.language = "en"
m.phase = phaseMain
case "enter":
if m.langCursor == 0 {
m.result.language = "zh"
} else {
m.result.language = "en"
}
m.phase = phaseMain
case "q", "ctrl+c":
m.result.quit = true
return m, tea.Quit
}
return m, nil
}
func (m tuiModel) viewLang() string {
var s strings.Builder
s.WriteString("\n")
s.WriteString(tTitleStyle.Render(" VPS融合怪测试 / VPS Fusion Monster Test"))
s.WriteString("\n\n")
s.WriteString(tInfoStyle.Render(" 请选择语言 / Please select language:"))
s.WriteString("\n\n")
langs := []string{"1. 中文", "2. English"}
for i, l := range langs {
cursor := " "
style := tNormStyle
if m.langCursor == i {
cursor = " > "
style = tSelStyle
}
s.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(l)))
}
s.WriteString("\n")
s.WriteString(tHelpStyle.Render(" ↑/↓ Navigate Enter Confirm 1/2 Quick-Select q Quit"))
s.WriteString("\n")
return s.String()
}
func (m tuiModel) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "up", "k":
if m.mainCursor > 0 {
m.mainCursor--
}
case "down", "j":
if m.mainCursor < len(m.mainItems)-1 {
m.mainCursor++
}
case "home":
m.mainCursor = 0
case "end":
m.mainCursor = len(m.mainItems) - 1
case "enter":
item := m.mainItems[m.mainCursor]
if item.needNet && !m.preCheck.Connected {
return m, nil
}
if item.id == "custom" {
m.phase = phaseCustom
m.customCursor = 0
return m, nil
}
m.result.choice = item.id
return m, tea.Quit
case "q", "ctrl+c":
m.result.quit = true
return m, tea.Quit
default:
for i, item := range m.mainItems {
if key == item.id {
if item.needNet && !m.preCheck.Connected {
return m, nil
}
if item.id == "custom" {
m.phase = phaseCustom
m.customCursor = 0
return m, nil
}
m.mainCursor = i
m.result.choice = item.id
return m, tea.Quit
}
}
}
return m, nil
}
func (m tuiModel) selectedMainDesc(lang string) string {
item := m.mainItems[m.mainCursor]
if lang == "zh" {
return item.descZh
}
return item.descEn
}
func (m tuiModel) viewMain() string {
lang := m.result.language
var s strings.Builder
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tTitleStyle.Render(fmt.Sprintf(" VPS融合怪 %s", m.config.EcsVersion)))
} else {
s.WriteString(tTitleStyle.Render(fmt.Sprintf(" VPS Fusion Monster %s", m.config.EcsVersion)))
}
s.WriteString("\n")
if m.preCheck.Connected && m.cmpVersion == -1 {
if lang == "zh" {
s.WriteString(tWarnStyle.Render(fmt.Sprintf(" ! 检测到新版本 %s 如有必要请更新", m.newVersion)))
} else {
s.WriteString(tWarnStyle.Render(fmt.Sprintf(" ! New version %s detected", m.newVersion)))
}
s.WriteString("\n")
}
if m.preCheck.Connected && m.hasStats {
if lang == "zh" {
s.WriteString(tInfoStyle.Render(fmt.Sprintf(" 总使用量: %s | 今日使用: %s", utils.FormatGoecsNumber(m.statsTotal), utils.FormatGoecsNumber(m.statsDaily))))
} else {
s.WriteString(tInfoStyle.Render(fmt.Sprintf(" Total Usage: %s | Daily Usage: %s", utils.FormatGoecsNumber(m.statsTotal), utils.FormatGoecsNumber(m.statsDaily))))
}
s.WriteString("\n")
}
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tSectStyle.Render(" 请选择测试方案:"))
} else {
s.WriteString(tSectStyle.Render(" Select Test Suite:"))
}
s.WriteString("\n\n")
for i, item := range m.mainItems {
cursor := " "
style := tNormStyle
if m.mainCursor == i {
cursor = " > "
style = tSelStyle
}
label := item.en
if lang == "zh" {
label = item.zh
}
prefix := ""
switch {
case item.id == "custom":
prefix = ""
case item.id == "0":
prefix = " 0. "
default:
prefix = fmt.Sprintf("%2s. ", item.id)
}
suffix := ""
if item.needNet && !m.preCheck.Connected {
style = tDimStyle
if lang == "zh" {
suffix = " [需要网络]"
} else {
suffix = " [No Network]"
}
}
s.WriteString(fmt.Sprintf("%s%s%s\n", cursor, style.Render(prefix+label), tDimStyle.Render(suffix)))
}
s.WriteString("\n")
panelTitle := " 当前选项说明"
panelBody := m.selectedMainDesc(lang)
if lang == "en" {
panelTitle = " Selected Option Description"
}
s.WriteString(tSectStyle.Render(panelTitle) + "\n")
s.WriteString(tPanelStyle.Width(maxInt(60, m.width-6)).Render(panelBody) + "\n")
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tHelpStyle.Render(" ↑/↓/j/k 移动 Enter 确认 数字 快速选择 q 退出"))
} else {
s.WriteString(tHelpStyle.Render(" Up/Down/j/k Navigate Enter Confirm Number Quick-Select q Quit"))
}
s.WriteString("\n")
return s.String()
}
func (m *tuiModel) startEditText(settingIdx int) {
m.editingText = true
m.editingIdx = settingIdx
m.textInput.SetValue(m.advanced[settingIdx].textVal)
m.textInput.Focus()
}
func (m *tuiModel) stopEditText(save bool) {
if save {
m.advanced[m.editingIdx].textVal = strings.TrimSpace(m.textInput.Value())
}
m.textInput.Blur()
m.editingText = false
}
func (m tuiModel) updateCustom(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.editingText {
switch msg.String() {
case "enter":
m.stopEditText(true)
return m, nil
case "esc":
m.stopEditText(false)
return m, nil
case "ctrl+c":
m.result.quit = true
return m, tea.Quit
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
key := msg.String()
switch key {
case "up", "k":
if m.customCursor > 0 {
m.customCursor--
}
case "down", "j":
if m.customCursor < m.customTotal-1 {
m.customCursor++
}
case "home":
m.customCursor = 0
case "end":
m.customCursor = m.customTotal - 1
case " ", "enter", "right", "l", "left", "h":
if m.customCursor < len(m.toggles) {
t := &m.toggles[m.customCursor]
if t.needNet && !m.preCheck.Connected {
break
}
t.enabled = !t.enabled
break
}
if m.customCursor == m.customTotal-1 {
m.result.custom = true
m.result.choice = "custom"
m.result.toggles = m.toggles
m.result.advanced = m.advanced
return m, tea.Quit
}
advIdx := m.customCursor - len(m.toggles)
if advIdx >= 0 && advIdx < len(m.advanced) {
a := &m.advanced[advIdx]
switch a.kind {
case "bool":
a.boolVal = !a.boolVal
case "option":
if key == "left" || key == "h" {
a.current = (a.current - 1 + len(a.options)) % len(a.options)
} else {
a.current = (a.current + 1) % len(a.options)
}
case "text":
if key == "enter" || key == " " {
m.startEditText(advIdx)
}
}
}
case "a":
allEnabled := true
for _, t := range m.toggles {
if !t.enabled && (!t.needNet || m.preCheck.Connected) {
allEnabled = false
break
}
}
for i := range m.toggles {
if m.toggles[i].needNet && !m.preCheck.Connected {
continue
}
m.toggles[i].enabled = !allEnabled
}
case "esc":
m.phase = phaseMain
return m, nil
case "q", "ctrl+c":
m.result.quit = true
return m, tea.Quit
}
return m, nil
}
func (m tuiModel) currentCustomDescription(lang string) string {
if m.customCursor < len(m.toggles) {
t := m.toggles[m.customCursor]
if lang == "zh" {
return t.descZh
}
return t.descEn
}
if m.customCursor == m.customTotal-1 {
if lang == "zh" {
return "确认当前高级自定义配置并开始测试。"
}
return "Confirm current advanced custom configuration and start tests."
}
idx := m.customCursor - len(m.toggles)
a := m.advanced[idx]
if a.kind == "option" {
op := a.options[a.current]
if lang == "zh" {
return a.descZh + " 当前选项: " + op.labelZh + "。" + op.descZh
}
return a.descEn + " Current option: " + op.labelEn + ". " + op.descEn
}
if a.kind == "bool" {
if lang == "zh" {
state := "关闭"
if a.boolVal {
state = "开启"
}
return a.descZh + " 当前状态: " + state + "。"
}
state := "OFF"
if a.boolVal {
state = "ON"
}
return a.descEn + " Current state: " + state + "."
}
if lang == "zh" {
return a.descZh + " 当前值: " + a.textVal
}
return a.descEn + " Current value: " + a.textVal
}
func (m tuiModel) advDisplayValue(a advSetting, lang string) string {
switch a.kind {
case "option":
op := a.options[a.current]
if lang == "zh" {
return op.labelZh
}
return op.labelEn
case "bool":
if a.boolVal {
if lang == "zh" {
return "开启"
}
return "ON"
}
if lang == "zh" {
return "关闭"
}
return "OFF"
case "text":
if strings.TrimSpace(a.textVal) == "" {
if lang == "zh" {
return "(默认)"
}
return "(default)"
}
return a.textVal
}
return ""
}
func (m tuiModel) viewCustom() string {
lang := m.result.language
var s strings.Builder
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tTitleStyle.Render(" 高级自定义参数模式"))
} else {
s.WriteString(tTitleStyle.Render(" Advanced Custom Parameter Mode"))
}
s.WriteString("\n\n")
if lang == "zh" {
s.WriteString(tSectStyle.Render(" 测试开关 (空格切换, a 全选/全不选):"))
} else {
s.WriteString(tSectStyle.Render(" Test Toggles (Space to toggle, a all/none):"))
}
s.WriteString("\n\n")
for i, t := range m.toggles {
cursor := " "
style := tNormStyle
if m.customCursor == i {
cursor = " > "
style = tSelStyle
}
if t.needNet && !m.preCheck.Connected {
style = tDimStyle
}
check := tChkOffStyle.Render("[ ]")
if t.enabled {
check = tChkOnStyle.Render("[x]")
}
name := t.nameEn
if lang == "zh" {
name = t.nameZh
}
s.WriteString(fmt.Sprintf("%s%s %s\n", cursor, check, style.Render(name)))
}
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tSectStyle.Render(" 参数设置 (Enter/空格切换, ←/→改选项):"))
} else {
s.WriteString(tSectStyle.Render(" Parameter Settings (Enter/Space switch, Left/Right cycle):"))
}
s.WriteString("\n\n")
for i, a := range m.advanced {
idx := len(m.toggles) + i
cursor := " "
if m.customCursor == idx {
cursor = " > "
}
style := tNormStyle
if m.customCursor == idx {
style = tSelStyle
}
name := a.nameEn
if lang == "zh" {
name = a.nameZh
}
value := m.advDisplayValue(a, lang)
if a.kind == "option" {
value = "< " + value + " >"
}
s.WriteString(fmt.Sprintf("%s%-26s %s\n", cursor, style.Render(name+":"), tDimStyle.Render(value)))
}
s.WriteString("\n")
confirmIdx := m.customTotal - 1
if m.customCursor == confirmIdx {
if lang == "zh" {
s.WriteString(fmt.Sprintf(" %s\n", tBtnStyle.Render(">> 开始测试 <<")))
} else {
s.WriteString(fmt.Sprintf(" %s\n", tBtnStyle.Render(">> Start Test <<")))
}
} else {
if lang == "zh" {
s.WriteString(fmt.Sprintf(" %s\n", tBtnDimStyle.Render(">> 开始测试 <<")))
} else {
s.WriteString(fmt.Sprintf(" %s\n", tBtnDimStyle.Render(">> Start Test <<")))
}
}
s.WriteString("\n")
panelTitle := " 当前项说明"
if lang == "en" {
panelTitle = " Current Item Description"
}
s.WriteString(tSectStyle.Render(panelTitle) + "\n")
s.WriteString(tPanelStyle.Width(maxInt(60, m.width-6)).Render(m.currentCustomDescription(lang)) + "\n")
if m.editingText {
if lang == "zh" {
s.WriteString("\n" + tWarnStyle.Render(" 文本编辑模式: Enter 保存, Esc 取消") + "\n")
} else {
s.WriteString("\n" + tWarnStyle.Render(" Text edit mode: Enter save, Esc cancel") + "\n")
}
s.WriteString(" " + m.textInput.View() + "\n")
}
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tHelpStyle.Render(" ↑/↓ 移动 Enter/空格 切换 ←/→ 改选项 a 全选 Esc 返回 q 退出"))
} else {
s.WriteString(tHelpStyle.Render(" Up/Down Move Enter/Space Toggle Left/Right Cycle a All Esc Back q Quit"))
}
s.WriteString("\n")
return s.String()
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func RunTuiMenu(preCheck utils.NetCheckResult, config *params.Config) tuiResult {
var statsTotal, statsDaily int
var hasStats bool
var cmpVersion int
var newVersion string
if preCheck.Connected {
var wg sync.WaitGroup
var stats *utils.StatsResponse
var statsErr error
var githubInfo *utils.GitHubRelease
var githubErr error
wg.Add(2)
go func() {
defer wg.Done()
stats, statsErr = utils.GetGoescStats()
}()
go func() {
defer wg.Done()
githubInfo, githubErr = utils.GetLatestEcsRelease()
}()
wg.Wait()
if statsErr == nil {
statsTotal = stats.Total
statsDaily = stats.Daily
hasStats = true
}
if githubErr == nil {
cmpVersion = utils.CompareVersions(config.EcsVersion, githubInfo.TagName)
newVersion = githubInfo.TagName
}
}
langPreset := config.UserSetFlags["lang"] || config.UserSetFlags["l"]
m := newTuiModel(preCheck, config, langPreset, statsTotal, statsDaily, hasStats, cmpVersion, newVersion)
p := tea.NewProgram(m, tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
fmt.Printf("Error running menu: %v\n", err)
os.Exit(1)
}
return finalModel.(tuiModel).result
}
func applyCustomResult(result tuiResult, preCheck utils.NetCheckResult, config *params.Config) {
for _, t := range result.toggles {
enabled := t.enabled
if t.needNet && !preCheck.Connected {
enabled = false
}
switch t.key {
case "basic":
config.BasicStatus = enabled
case "cpu":
config.CpuTestStatus = enabled
case "memory":
config.MemoryTestStatus = enabled
case "disk":
config.DiskTestStatus = enabled
case "ut":
config.UtTestStatus = enabled
case "security":
config.SecurityTestStatus = enabled
case "email":
config.EmailTestStatus = enabled
case "backtrace":
config.BacktraceStatus = enabled
case "nt3":
config.Nt3Status = enabled
case "speed":
config.SpeedTestStatus = enabled
case "ping":
config.PingTestStatus = enabled
case "tgdc":
config.TgdcTestStatus = enabled
case "web":
config.WebTestStatus = enabled
}
}
for _, a := range result.advanced {
switch a.key {
case "cpum":
config.CpuTestMethod = a.options[a.current].value
case "cput":
config.CpuTestThreadMode = a.options[a.current].value
case "memorym":
config.MemoryTestMethod = a.options[a.current].value
case "diskm":
config.DiskTestMethod = a.options[a.current].value
case "diskp":
config.DiskTestPath = strings.TrimSpace(a.textVal)
case "diskmc":
config.DiskMultiCheck = a.boolVal
case "nt3loc":
config.Nt3Location = a.options[a.current].value
case "nt3t":
config.Nt3CheckType = a.options[a.current].value
case "spnum":
if v, err := strconv.Atoi(a.options[a.current].value); err == nil {
config.SpNum = v
}
case "log":
config.EnableLogger = a.boolVal
case "upload":
config.EnableUpload = a.boolVal
case "analysis":
config.AnalyzeResult = a.boolVal
case "filepath":
if strings.TrimSpace(a.textVal) != "" {
config.FilePath = strings.TrimSpace(a.textVal)
}
case "width":
if v, err := strconv.Atoi(a.options[a.current].value); err == nil {
config.Width = v
}
}
}
if !config.BasicStatus && !config.CpuTestStatus && !config.MemoryTestStatus && !config.DiskTestStatus {
config.OnlyIpInfoCheck = true
}
config.AutoChangeDiskMethod = true
}

View File

@@ -3,6 +3,7 @@ package params
import (
"flag"
"fmt"
"strings"
)
// Config holds all configuration parameters
@@ -41,6 +42,7 @@ type Config struct {
AutoChangeDiskMethod bool
FilePath string
EnableUpload bool
AnalyzeResult bool
OnlyIpInfoCheck bool
Help bool
Finish bool
@@ -64,7 +66,7 @@ func NewConfig(version string) *Config {
MemoryTestMethod: "stream",
DiskTestMethod: "fio",
DiskTestPath: "",
DiskMultiCheck: false,
DiskMultiCheck: false,
Nt3CheckType: "ipv4",
SpNum: 2,
Width: 82,
@@ -84,6 +86,7 @@ func NewConfig(version string) *Config {
AutoChangeDiskMethod: true,
FilePath: "goecs.txt",
EnableUpload: true,
AnalyzeResult: false,
OnlyIpInfoCheck: false,
Help: false,
Finish: false,
@@ -92,8 +95,57 @@ func NewConfig(version string) *Config {
}
}
// normalizeBoolArgs preprocesses args so that bool flags written as
// "-flag true" or "-flag false" (space-separated) are converted to
// "-flag=true" / "-flag=false" that the standard flag package understands.
// This also strips any duplicate spaces that may appear between tokens when
// args have been assembled by shell scripts or other callers.
func normalizeBoolArgs(args []string) []string {
// All known boolean flag names (without leading dash).
boolFlags := map[string]bool{
"h": true, "help": true, "v": true, "version": true,
"menu": true, "basic": true, "cpu": true, "memory": true,
"disk": true, "ut": true, "security": true, "email": true,
"backtrace": true, "nt3": true, "speed": true, "ping": true,
"tgdc": true, "web": true, "log": true, "upload": true,
"analysis": true, "analyze": true,
"diskmc": true,
}
out := make([]string, 0, len(args))
i := 0
for i < len(args) {
arg := args[i]
// Skip empty tokens that can appear from split on multiple spaces.
if arg == "" {
i++
continue
}
// Detect flag tokens: -flag or --flag (without embedded =).
if strings.HasPrefix(arg, "-") && !strings.Contains(arg, "=") {
name := strings.TrimLeft(arg, "-")
if boolFlags[name] {
// Peek at next token: if it is "true" or "false", merge.
if i+1 < len(args) {
next := strings.ToLower(strings.TrimSpace(args[i+1]))
if next == "true" || next == "false" {
out = append(out, arg+"="+next)
i += 2
continue
}
}
}
}
out = append(out, arg)
i++
}
return out
}
// ParseFlags parses command line flags
func (c *Config) ParseFlags(args []string) {
args = normalizeBoolArgs(args)
c.GoecsFlag.BoolVar(&c.Help, "h", false, "Show help information")
c.GoecsFlag.BoolVar(&c.Help, "help", false, "Show help information")
c.GoecsFlag.BoolVar(&c.ShowVersion, "v", false, "Display version information")
@@ -131,6 +183,8 @@ func (c *Config) ParseFlags(args []string) {
c.GoecsFlag.IntVar(&c.SpNum, "spnum", 2, "Set the number of servers per operator for speed test")
c.GoecsFlag.BoolVar(&c.EnableLogger, "log", false, "Enable/Disable logging in the current path")
c.GoecsFlag.BoolVar(&c.EnableUpload, "upload", true, "Enable/Disable upload the result")
c.GoecsFlag.BoolVar(&c.AnalyzeResult, "analysis", false, "Enable/Disable post-test concise summary analysis")
c.GoecsFlag.BoolVar(&c.AnalyzeResult, "analyze", false, "Enable/Disable post-test concise summary analysis")
c.GoecsFlag.Parse(args)
c.GoecsFlag.Visit(func(f *flag.Flag) {
@@ -222,6 +276,9 @@ func (c *Config) SaveUserSetParams() map[string]interface{} {
if c.UserSetFlags["spnum"] {
saved["spnum"] = c.SpNum
}
if c.UserSetFlags["analysis"] || c.UserSetFlags["analyze"] {
saved["analysis"] = c.AnalyzeResult
}
return saved
}
@@ -340,6 +397,11 @@ func (c *Config) RestoreUserSetParams(saved map[string]interface{}) {
c.SpNum = intVal
}
}
if val, ok := saved["analysis"]; ok {
if boolVal, ok := val.(bool); ok {
c.AnalyzeResult = boolVal
}
}
c.ValidateParams()
}

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/oneclickvirt/ecs/internal/analysis"
"github.com/oneclickvirt/ecs/internal/params"
"github.com/oneclickvirt/ecs/internal/tests"
"github.com/oneclickvirt/ecs/utils"
@@ -153,7 +154,7 @@ func RunBasicTests(preCheck utils.NetCheckResult, config *params.Config, basicIn
}
if config.BasicStatus {
fmt.Printf("%s", *basicInfo)
} else if (config.Input == "6" || config.Input == "9") && config.SecurityTestStatus {
} else if (config.Choice == "6" || config.Choice == "9") && config.SecurityTestStatus {
scanner := bufio.NewScanner(strings.NewReader(*basicInfo))
for scanner.Scan() {
line := scanner.Text()
@@ -363,6 +364,12 @@ func RunSpeedTests(config *params.Config, output, tempOutput string, outputMutex
}
} else if config.Choice == "6" {
tests.CustomSP("net", "global", 11, config.Language)
} else {
// Custom menu mode and any other fallback choices.
tests.NearbySP()
tests.CustomSP("net", "cu", config.SpNum, config.Language)
tests.CustomSP("net", "ct", config.SpNum, config.Language)
tests.CustomSP("net", "cmcc", config.SpNum, config.Language)
}
// 等待第三方库的输出完全刷新到标准输出
time.Sleep(500 * time.Millisecond)
@@ -427,6 +434,25 @@ func AppendTimeInfo(config *params.Config, output, tempOutput string, startTime
}, tempOutput, output)
}
// AppendAnalysisSummary appends a concise bilingual summary for easier interpretation.
func AppendAnalysisSummary(config *params.Config, output, tempOutput string, outputMutex *sync.Mutex) string {
outputMutex.Lock()
defer outputMutex.Unlock()
finalOutput := output
return utils.PrintAndCapture(func() {
summary := analysis.GenerateSummary(config, finalOutput)
if strings.TrimSpace(summary) == "" {
return
}
if config.Language == "zh" {
utils.PrintCenteredTitle("测试总结分析", config.Width)
} else {
utils.PrintCenteredTitle("Result Summary Analysis", config.Width)
}
fmt.Println(summary)
}, tempOutput, output)
}
// HandleSignalInterrupt handles interrupt signals
func HandleSignalInterrupt(sig chan os.Signal, config *params.Config, startTime *time.Time, output *string, tempOutput string, uploadDone chan bool, outputMutex *sync.Mutex) {
select {