mirror of
http://bgp.hk.skcks.cn:10088/github.com/oneclickvirt/ecs
synced 2026-04-21 05:10:32 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b74cbe1e7 | ||
|
|
8137a7ac6a | ||
|
|
04725fef54 | ||
|
|
4e868a384a | ||
|
|
7a4885346b |
3
.github/workflows/build_public.yml
vendored
3
.github/workflows/build_public.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/sync.yaml
vendored
3
.github/workflows/sync.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
14
go.mod
@@ -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
31
go.sum
@@ -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=
|
||||
|
||||
5
goecs.go
5
goecs.go
@@ -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)
|
||||
}
|
||||
|
||||
6
goecs.sh
6
goecs.sh
@@ -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
|
||||
|
||||
652
internal/analysis/summary.go
Normal file
652
internal/analysis/summary.go
Normal 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")
|
||||
}
|
||||
@@ -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
999
internal/menu/tui.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user