Files
ecs/internal/menu/tui.go
2026-04-17 10:00:19 +08:00

1152 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
tOnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("82")).Bold(true)
tOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
tValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
tCurStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
)
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
mainAnalyze bool
mainUpload bool
}
type tuiModel struct {
phase menuPhase
config *params.Config
preCheck utils.NetCheckResult
langPreset bool
langCursor int
mainCursor int
mainItems []mainMenuItem
mainAnalyze bool
mainUpload bool
mainExtraTotal int
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: "System info + CPU + memory + disk + 5 speed nodes. No unlock/network/routing tests.", needNet: false},
{id: "3", zh: "精简版", en: "Standard Suite", descZh: "系统信息+CPU+内存+磁盘+跨国平台解锁+三网回程路由+测速节点×5。", descEn: "System info + CPU + memory + disk + streaming unlock + 3-network routing + 5 speed nodes.", needNet: false},
{id: "4", zh: "精简网络版", en: "Network Suite", descZh: "系统信息+CPU+内存+磁盘+上游及三网回程路由+测速节点×5。", descEn: "System info + CPU + memory + disk + upstream/3-network backtrace routing + 5 speed nodes.", needNet: false},
{id: "5", zh: "精简解锁版", en: "Unlock Suite", descZh: "系统信息+CPU+内存+磁盘IO+跨国平台解锁+测速节点×5。", descEn: "System info + CPU + memory + disk IO + streaming unlock + 5 speed nodes.", 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 check across 15 databases + email port connectivity test.", 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 each Telegram data center node.", 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 基准测试工具sysbench/geekbench/winsat。",
descEn: "Choose CPU benchmark tool (sysbench/geekbench/winsat).",
options: []advOption{
option("sysbench", "Sysbench", "Sysbench", "通用 CPU 基准测试工具。", "General-purpose CPU benchmark tool."),
option("geekbench", "Geekbench", "Geekbench", "综合场景 CPU 基准测试工具。", "Synthetic benchmark simulating real-world application workloads."),
option("winsat", "WinSAT", "WinSAT", "Windows 环境下的 CPU 基准测试工具。", "CPU benchmark tool for Windows environments."),
},
},
{
key: "cput", nameZh: "CPU线程模式", nameEn: "CPU Thread Mode", kind: "option",
descZh: "单线程: 测试单核最高运算速度; 多线程: 测试全核并发吞吐。",
descEn: "Single-thread: peak single-core speed; Multi-thread: full-core parallel throughput.",
options: []advOption{
option("multi", "多线程", "Multi-thread", "测试所有核心并发运算吞吐。", "Measure parallel compute throughput across all cores."),
option("single", "单线程", "Single-thread", "测试单核最高运算速度。", "Measure peak single-core compute speed."),
},
},
{
key: "memorym", nameZh: "内存测试方法", nameEn: "Memory Method", kind: "option",
descZh: "选择内存基准测试工具。",
descEn: "Choose memory benchmark tool.",
options: []advOption{
option("stream", "STREAM", "STREAM", "专项内存带宽基准测试工具STREAM。", "Memory bandwidth benchmark tool (STREAM)."),
option("sysbench", "Sysbench", "Sysbench", "通用内存基准测试工具。", "General-purpose memory benchmark tool."),
option("dd", "dd", "dd", "使用 dd 命令测量内存顺序读写。", "Measure memory sequential R/W using dd command."),
option("winsat", "WinSAT", "WinSAT", "Windows 环境内存基准测试工具。", "Memory benchmark tool for Windows environments."),
option("auto", "自动", "Auto", "按优先级自动选择可用测试工具。", "Automatically select the preferred available tool."),
},
},
{
key: "diskm", nameZh: "磁盘测试方法", nameEn: "Disk Method", kind: "option",
descZh: "选择磁盘基准测试工具。",
descEn: "Choose disk benchmark tool.",
options: []advOption{
option("fio", "FIO", "FIO", "多队列深度顺序/随机 I/O 全面基准测试。", "Comprehensive sequential/random I/O benchmark with multiple queue depths."),
option("dd", "dd", "dd", "使用 dd 命令进行顺序读写基准测试。", "Sequential read/write benchmark using dd command."),
option("winsat", "WinSAT", "WinSAT", "Windows 环境磁盘基准测试工具。", "Disk benchmark tool 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 all mounted disk paths.",
boolVal: config.DiskMultiCheck,
},
{
key: "autodiskm", nameZh: "磁盘方法失败自动切换", nameEn: "Auto Switch Disk Method", kind: "bool",
descZh: "所选磁盘测试方法失败时自动切换为其他可用方法。",
descEn: "Automatically try another available disk method if the selected method fails.",
boolVal: config.AutoChangeDiskMethod,
},
{
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 per ISP.",
options: []advOption{
option("1", "1 个", "1 node", "每运营商1节点耗时最短覆盖面最小。", "1 node per ISP, shortest runtime, least coverage."),
option("2", "2 个", "2 nodes", "每运营商2节点默认值。", "2 nodes per ISP (default)."),
option("3", "3 个", "3 nodes", "每运营商3节点覆盖面扩大耗时增加。", "3 nodes per ISP, wider coverage, longer runtime."),
option("4", "4 个", "4 nodes", "每运营商4节点。", "4 nodes per ISP."),
option("5", "5 个", "5 nodes", "每运营商5节点覆盖面宽耗时较高。", "5 nodes per ISP, wide coverage, higher runtime."),
option("6", "6 个", "6 nodes", "每运营商6节点。", "6 nodes per ISP."),
option("7", "7 个", "7 nodes", "每运营商7节点。", "7 nodes per ISP."),
option("8", "8 个", "8 nodes", "每运营商8节点。", "8 nodes per ISP."),
option("9", "9 个", "9 nodes", "每运营商9节点。", "9 nodes per ISP."),
option("10", "10 个", "10 nodes", "每运营商10节点覆盖面最宽耗时最高。", "10 nodes per ISP, widest coverage, 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: "测试结束后输出简明总结含CPU排名、带宽和延迟数据。",
descEn: "Output a concise summary after tests (CPU rank, bandwidth, latency scores).",
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(),
mainAnalyze: config.AnalyzeResult,
mainUpload: config.EnableUpload,
mainExtraTotal: 2,
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 = tCurStyle.Render(" > ")
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()
maxCursor := len(m.mainItems) + m.mainExtraTotal - 1
switch key {
case "up", "k":
if m.mainCursor > 0 {
m.mainCursor--
}
case "down", "j":
if m.mainCursor < maxCursor {
m.mainCursor++
}
case "home":
m.mainCursor = 0
case "end":
m.mainCursor = maxCursor
case " ":
if m.mainCursor >= len(m.mainItems) {
switch m.mainCursor - len(m.mainItems) {
case 0:
m.mainAnalyze = !m.mainAnalyze
case 1:
m.mainUpload = !m.mainUpload
}
}
case "enter":
if m.mainCursor >= len(m.mainItems) {
switch m.mainCursor - len(m.mainItems) {
case 0:
m.mainAnalyze = !m.mainAnalyze
case 1:
m.mainUpload = !m.mainUpload
}
return m, nil
}
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.mainAnalyze = m.mainAnalyze
m.result.mainUpload = m.mainUpload
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.mainAnalyze = m.mainAnalyze
m.result.mainUpload = m.mainUpload
m.result.choice = item.id
return m, tea.Quit
}
}
}
return m, nil
}
func (m tuiModel) selectedMainDesc(lang string) string {
if m.mainCursor >= len(m.mainItems) {
switch m.mainCursor - len(m.mainItems) {
case 0:
if lang == "zh" {
return "测试结束后输出简明总结含CPU排名、带宽和延迟数据。默认关闭。"
}
return "Output a concise summary after tests (CPU rank, bandwidth, latency scores). Disabled by default."
case 1:
if lang == "zh" {
return "上传测试结果到服务端并生成可分享链接。默认启用。"
}
return "Upload test results to the server and generate a shareable link. Enabled by default."
}
return ""
}
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 = tCurStyle.Render(" > ")
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")
// Quick options: analyze + upload
if lang == "zh" {
s.WriteString(tSectStyle.Render(" 快速选项:") + " " + tDimStyle.Render("Space/Enter 切换"))
} else {
s.WriteString(tSectStyle.Render(" Quick Options:") + " " + tDimStyle.Render("Space/Enter to toggle"))
}
s.WriteString("\n")
for qi, qState := range []bool{m.mainAnalyze, m.mainUpload} {
qIdx := len(m.mainItems) + qi
cur := " "
nameStyle := tNormStyle
if m.mainCursor == qIdx {
cur = tCurStyle.Render(" > ")
nameStyle = tSelStyle
}
chk := tChkOffStyle.Render("[ ]")
if qState {
chk = tChkOnStyle.Render("[x]")
}
var qName, qVal string
if qi == 0 {
if lang == "zh" {
qName = "测试后自动总结分析"
} else {
qName = "Post-test Summary Analysis"
}
} else {
if lang == "zh" {
qName = "上传结果并生成分享链接"
} else {
qName = "Upload Result & Share Link"
}
}
if qState {
if lang == "zh" {
qVal = tOnStyle.Render("开启")
} else {
qVal = tOnStyle.Render("ON")
}
} else {
if lang == "zh" {
qVal = tOffStyle.Render("关闭")
} else {
qVal = tOffStyle.Render("OFF")
}
}
s.WriteString(fmt.Sprintf("%s%s %s %s\n", cur, chk, nameStyle.Render(qName), qVal))
}
s.WriteString("\n")
if lang == "zh" {
s.WriteString(tHelpStyle.Render(" ↑/↓/j/k 移动 Enter 确认 Space 切换 数字 快速选择 q 退出"))
} else {
s.WriteString(tHelpStyle.Render(" Up/Down/j/k Move Enter Confirm Space Toggle 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(fmt.Sprintf(" VPS融合怪 %s — 高级自定义", m.config.EcsVersion)))
} else {
s.WriteString(tTitleStyle.Render(fmt.Sprintf(" VPS Fusion Monster %s — Advanced Custom", 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(" 测试开关 (空格切换, 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 = tCurStyle.Render(" > ")
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 := " "
style := tNormStyle
if m.customCursor == idx {
cursor = tCurStyle.Render(" > ")
style = tSelStyle
}
name := a.nameEn
if lang == "zh" {
name = a.nameZh
}
var valueRendered string
switch a.kind {
case "bool":
if a.boolVal {
if lang == "zh" {
valueRendered = tOnStyle.Render("开启")
} else {
valueRendered = tOnStyle.Render("ON")
}
} else {
if lang == "zh" {
valueRendered = tOffStyle.Render("关闭")
} else {
valueRendered = tOffStyle.Render("OFF")
}
}
case "option":
op := a.options[a.current]
lbl := op.labelEn
if lang == "zh" {
lbl = op.labelZh
}
valueRendered = tDimStyle.Render("< ") + tValStyle.Render(lbl) + tDimStyle.Render(" >")
case "text":
v := strings.TrimSpace(a.textVal)
if v == "" {
if lang == "zh" {
valueRendered = tDimStyle.Render("(默认)")
} else {
valueRendered = tDimStyle.Render("(default)")
}
} else {
valueRendered = tValStyle.Render(v)
}
}
s.WriteString(fmt.Sprintf("%s%-26s %s\n", cursor, style.Render(name+":"), valueRendered))
}
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 "autodiskm":
config.AutoChangeDiskMethod = 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
}
}