From ce9af9f7d0ee7b5bdc358c032274a95c272dea54 Mon Sep 17 00:00:00 2001 From: kelvin Date: Sat, 21 Feb 2026 20:22:21 +0800 Subject: [PATCH] add tui command --- data/confs/frontend/config.yaml | 220 +------------------ data/tmpls/tmpl.go | 2 +- go.mod | 41 +++- go.sum | 100 ++++++++- internal/cli/init/init.go | 1 + internal/cli/init/tui.go | 33 +++ pkg/wizard/config.go | 192 +++++++++++++++++ pkg/wizard/model.go | 333 +++++++++++++++++++++++++++++ pkg/wizard/pages.go | 366 ++++++++++++++++++++++++++++++++ pkg/wizard/styles.go | 99 +++++++++ pkg/wizard/wizard.go | 46 ++++ 11 files changed, 1194 insertions(+), 239 deletions(-) create mode 100644 internal/cli/init/tui.go create mode 100644 pkg/wizard/config.go create mode 100644 pkg/wizard/model.go create mode 100644 pkg/wizard/pages.go create mode 100644 pkg/wizard/styles.go create mode 100644 pkg/wizard/wizard.go diff --git a/data/confs/frontend/config.yaml b/data/confs/frontend/config.yaml index d0d10e9..31c4e94 100644 --- a/data/confs/frontend/config.yaml +++ b/data/confs/frontend/config.yaml @@ -290,222 +290,4 @@ nodes: hostname: "compute02.example.local" role: "compute" status: "active" - # ... 类似配置,IP地址递增 - - # 存储节点 - storage_nodes: - - name: "storage-01" - hostname: "storage01.example.local" - role: "storage" - status: "active" - - basic_info: - timezone: "Asia/Shanghai" - cpu: "Intel Xeon Silver 4210 2.2GHz (20核)" - memory: "128GB DDR4" - os: "CentOS 7.9" - storage_software: "Ceph" - - network: - interfaces: - - name: "eth0" - ip_address: "192.168.1.21" - network_type: "management" - speed: "1Gbps" - - - name: "eth1" - ip_address: "172.16.1.21" - network_type: "storage_frontend" - speed: "10Gbps" - - - name: "eth2" - ip_address: "172.16.2.21" - network_type: "storage_backend" - speed: "25Gbps" - - - name: "eth3" - ip_address: "172.16.3.21" - network_type: "cluster" - speed: "10Gbps" - - disk: - - device: "/dev/sda" - size: "240GB" - type: "SSD" - mount_point: "/" - filesystem: "xfs" - usage: "系统盘" - - - device: "/dev/sdb" - size: "480GB" - type: "SSD" - mount_point: "/var/lib/ceph/osd/ceph-0" - filesystem: "xfs" - usage: "OSD (日志/WAL)" - - - device: "/dev/sdc" - size: "8TB" - type: "HDD" - mount_point: "/var/lib/ceph/osd/ceph-1" - filesystem: "xfs" - usage: "OSD (数据)" - - - device: "/dev/sdd" - size: "8TB" - type: "HDD" - mount_point: "/var/lib/ceph/osd/ceph-2" - filesystem: "xfs" - usage: "OSD (数据)" - - services: - enabled: - - "sshd" - - "ntpd" - - "ceph-mon" - - "ceph-mgr" - - "ceph-osd" - - ceph_config: - cluster_name: "ceph-prod" - fsid: "12345678-1234-1234-1234-123456789012" - mon_hosts: - - "192.168.1.21" - - "192.168.1.22" - - "192.168.1.23" - - - name: "storage-02" - # ... 类似配置 - - # 其他节点 - other_nodes: - # 管理节点 - - name: "management-01" - hostname: "mgmt01.example.local" - role: "management" - status: "active" - - basic_info: - timezone: "Asia/Shanghai" - cpu: "Intel Xeon Bronze 3204 1.9GHz (6核)" - memory: "64GB DDR4" - os: "CentOS 7.9" - - network: - interfaces: - - name: "eth0" - ip_address: "192.168.1.31" - network_type: "management" - speed: "1Gbps" - - services: - enabled: - - "sshd" - - "ntpd" - - "ansible" - - "salt-master" - - "jumpserver" - - # 网关节点 - - name: "gateway-01" - hostname: "gw01.example.local" - role: "gateway" - status: "active" - - basic_info: - timezone: "Asia/Shanghai" - cpu: "Intel Xeon E-2234 3.6GHz (4核)" - memory: "32GB DDR4" - os: "pfSense 2.5.2" - - network: - interfaces: - - name: "wan" - ip_address: "202.96.128.86" - network_type: "external" - speed: "1Gbps" - - - name: "lan" - ip_address: "192.168.1.254" - network_type: "internal" - speed: "1Gbps" - - - name: "dmz" - ip_address: "192.168.100.254" - network_type: "dmz" - speed: "1Gbps" - - services: - enabled: - - "ssh" - - "dnsmasq" - - "nginx" - - "haproxy" - - "keepalived" - - # 监控节点 - - name: "monitoring-01" - hostname: "mon01.example.local" - role: "monitoring" - status: "active" - - basic_info: - timezone: "Asia/Shanghai" - cpu: "Intel Xeon Silver 4208 2.1GHz (8核)" - memory: "64GB DDR4" - os: "Ubuntu 20.04 LTS" - - services: - enabled: - - "prometheus" - - "grafana" - - "alertmanager" - - "elasticsearch" - - "kibana" - - "filebeat" - - - -# 节点基础数据 -nodes: - - name: frontend - cpus: 4 - memory: 8192 - disk: 100 - rack: null - rank: null - arch: x86_64 - os: linux - runaction: os - installaction: os - status: active - description: "管理节点" - -# 属性基础数据 -attributes: - # 国家地区 - - node_name: frontend # 通过节点名称关联 - attr: country - value: CN - shadow: "" -# 软件基础数据 -software: - - name: openssl - version: "1.1.1k" - vendor: OpenSSL - install_method: source - is_installed: 0 - description: "加密库" - - - name: slurm - version: "23.02" - vendor: SchedMD - install_method: source - is_installed: 0 - description: "作业调度系统" - - - name: openmpi - version: "4.1.5" - vendor: OpenMPI - install_method: source - is_installed: 0 - description: "MPI 并行计算库" + # ... 类似配置,IP地址递增 \ No newline at end of file diff --git a/data/tmpls/tmpl.go b/data/tmpls/tmpl.go index 56d82fd..5699a3d 100644 --- a/data/tmpls/tmpl.go +++ b/data/tmpls/tmpl.go @@ -18,7 +18,7 @@ import ( // - db/*/*.yaml : 匹配data/一级子目录下的所有yaml文件. // - 如需递归匹配子目录(如data/db/sub/*.yaml),用 data/**/*.yaml(Go.18+) // -//go:embed db/*.yaml services/*.yaml firewall/*.yaml +//go:embed services/*.yaml var ConfigFS embed.FS // GetConfigFile 获取指定目录下的的单个配置文件内容 diff --git a/go.mod b/go.mod index 2aa9afa..a0338dc 100644 --- a/go.mod +++ b/go.mod @@ -3,32 +3,55 @@ module sunhpc go 1.25.5 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gdamore/tcell/v2 v2.13.8 + github.com/go-sql-driver/mysql v1.9.3 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/rivo/tview v0.42.0 + github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + go.uber.org/zap v1.27.1 + go.yaml.in/yaml/v3 v3.0.4 gopkg.in/yaml.v3 v3.0.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gdamore/encoding v1.0.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // 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/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index ea82af4..a394511 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,42 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= +github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -21,17 +49,30 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -56,18 +97,57 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/init/init.go b/internal/cli/init/init.go index 6793ae4..b4edaad 100644 --- a/internal/cli/init/init.go +++ b/internal/cli/init/init.go @@ -14,6 +14,7 @@ func NewInitCmd() *cobra.Command { cmd.AddCommand(NewInitDBCmd()) cmd.AddCommand(NewInitCfgCmd()) + cmd.AddCommand(NewInitTuiCmd()) return cmd } diff --git a/internal/cli/init/tui.go b/internal/cli/init/tui.go new file mode 100644 index 0000000..fe0be7c --- /dev/null +++ b/internal/cli/init/tui.go @@ -0,0 +1,33 @@ +package initcmd + +import ( + "sunhpc/internal/middler/auth" + + "sunhpc/pkg/wizard" + + "github.com/spf13/cobra" +) + +func NewInitTuiCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "tui", + Short: "初始化TUI", + Long: `初始化SunHPC TUI,创建所有表结构和默认数据。 + +示例: + sunhpc init tui # 初始化TUI + sunhpc init tui --force # 强制重新初始化`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := auth.RequireRoot(); err != nil { + return err + } + + return wizard.Run(force) + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "强制重新初始化") + return cmd +} diff --git a/pkg/wizard/config.go b/pkg/wizard/config.go new file mode 100644 index 0000000..a2e97c9 --- /dev/null +++ b/pkg/wizard/config.go @@ -0,0 +1,192 @@ +package wizard + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/bubbles/textinput" +) + +// saveConfig 保存配置到文件 +func (m *model) saveConfig() error { + configPath := GetConfigPath() + + // 确保目录存在 + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建配置目录失败:%w", err) + } + + // 序列化配置 + data, err := json.MarshalIndent(m.config, "", " ") + if err != nil { + return fmt.Errorf("序列化配置失败:%w", err) + } + + // 写入文件 + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("保存配置文件失败:%w", err) + } + + return nil +} + +// loadConfig 从文件加载配置 +func loadConfig() (*Config, error) { + configPath := GetConfigPath() + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("读取配置文件失败:%w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败:%w", err) + } + + return &cfg, nil +} + +// 以下是 model.go 中调用的保存方法 +func (m *model) saveCurrentPage() { + switch m.currentPage { + case PageData: + m.saveDataPage() + case PagePublicNetwork: + m.savePublicNetworkPage() + case PageInternalNetwork: + m.saveInternalNetworkPage() + case PageDNS: + m.saveDNSPage() + } +} + +func (m *model) saveDataPage() { + if len(m.textInputs) >= 8 { + m.config.Hostname = m.textInputs[0].Value() + m.config.Country = m.textInputs[1].Value() + m.config.Region = m.textInputs[2].Value() + m.config.Timezone = m.textInputs[3].Value() + m.config.HomePage = m.textInputs[4].Value() + m.config.DBAddress = m.textInputs[5].Value() + m.config.DBName = m.textInputs[6].Value() + m.config.DataAddress = m.textInputs[7].Value() + } +} + +func (m *model) savePublicNetworkPage() { + if len(m.textInputs) >= 4 { + m.config.PublicInterface = m.textInputs[0].Value() + m.config.IPAddress = m.textInputs[1].Value() + m.config.Netmask = m.textInputs[2].Value() + m.config.Gateway = m.textInputs[3].Value() + } +} + +func (m *model) saveInternalNetworkPage() { + if len(m.textInputs) >= 3 { + m.config.InternalInterface = m.textInputs[0].Value() + m.config.InternalIP = m.textInputs[1].Value() + m.config.InternalMask = m.textInputs[2].Value() + } +} + +func (m *model) saveDNSPage() { + if len(m.textInputs) >= 2 { + m.config.DNSPrimary = m.textInputs[0].Value() + m.config.DNSSecondary = m.textInputs[1].Value() + } +} + +// initPageInputs 初始化当前页面的输入框 +func (m *model) initPageInputs() { + m.textInputs = make([]textinput.Model, 0) + + switch m.currentPage { + case PageData: + fields := []struct{ label, value string }{ + {"主机名:", m.config.Hostname}, + {"国家:", m.config.Country}, + {"地区:", m.config.Region}, + {"时区:", m.config.Timezone}, + {"主页:", m.config.HomePage}, + {"数据库地址:", m.config.DBAddress}, + {"数据库名称:", m.config.DBName}, + {"Data 地址:", m.config.DataAddress}, + } + for _, f := range fields { + ti := textinput.New() + ti.Placeholder = "" + ti.Placeholder = f.label + //ti.Placeholder = "请输入" + f.label[:len(f.label)-1] + ti.SetValue(f.value) + ti.Width = 50 + m.textInputs = append(m.textInputs, ti) + } + m.focusIndex = 0 + if len(m.textInputs) > 0 { + m.textInputs[0].Focus() + } + m.inputLabels = []string{"Hostname", "Country", "Region", "Timezone", "Homepage", "DBPath", "DBName", "Software"} + + case PagePublicNetwork: + fields := []struct{ label, value string }{ + {"公网接口:", m.config.PublicInterface}, + {"IP 地址:", m.config.IPAddress}, + {"子网掩码:", m.config.Netmask}, + {"网关:", m.config.Gateway}, + } + for _, f := range fields { + ti := textinput.New() + ti.Placeholder = "" + ti.SetValue(f.value) + ti.Width = 50 + m.textInputs = append(m.textInputs, ti) + } + m.focusIndex = 0 + if len(m.textInputs) > 0 { + m.textInputs[0].Focus() + } + m.inputLabels = []string{"Wan iface", "IPAddress", "Netmask", "Gateway"} + + case PageInternalNetwork: + fields := []struct{ label, value string }{ + {"内网接口:", m.config.InternalInterface}, + {"内网 IP:", m.config.InternalIP}, + {"内网掩码:", m.config.InternalMask}, + } + for _, f := range fields { + ti := textinput.New() + ti.Placeholder = "" + ti.SetValue(f.value) + ti.Width = 50 + m.textInputs = append(m.textInputs, ti) + } + m.focusIndex = 0 + if len(m.textInputs) > 0 { + m.textInputs[0].Focus() + } + m.inputLabels = []string{"Lan iface", "IPAddress", "Netmask"} + + case PageDNS: + fields := []struct{ label, value string }{ + {"主 DNS:", m.config.DNSPrimary}, + {"备 DNS:", m.config.DNSSecondary}, + } + for _, f := range fields { + ti := textinput.New() + ti.Placeholder = "" + ti.SetValue(f.value) + ti.Width = 50 + m.textInputs = append(m.textInputs, ti) + } + m.focusIndex = 0 + if len(m.textInputs) > 0 { + m.textInputs[0].Focus() + } + m.inputLabels = []string{"DNSPrimary", "DNSSecondary"} + } +} diff --git a/pkg/wizard/model.go b/pkg/wizard/model.go new file mode 100644 index 0000000..d1305e0 --- /dev/null +++ b/pkg/wizard/model.go @@ -0,0 +1,333 @@ +package wizard + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// Config 系统配置结构 +type Config struct { + // 协议 + AgreementAccepted bool `json:"agreement_accepted"` + + // 数据接收 + Hostname string `json:"hostname"` + Country string `json:"country"` + Region string `json:"region"` + Timezone string `json:"timezone"` + HomePage string `json:"homepage"` + DBAddress string `json:"db_address"` + DBName string `json:"db_name"` + DataAddress string `json:"data_address"` + + // 公网设置 + PublicInterface string `json:"public_interface"` + IPAddress string `json:"ip_address"` + Netmask string `json:"netmask"` + Gateway string `json:"gateway"` + + // 内网配置 + InternalInterface string `json:"internal_interface"` + InternalIP string `json:"internal_ip"` + InternalMask string `json:"internal_mask"` + + // DNS 配置 + DNSPrimary string `json:"dns_primary"` + DNSSecondary string `json:"dns_secondary"` +} + +// PageType 页面类型 +type PageType int + +const ( + PageAgreement PageType = iota + PageData + PagePublicNetwork + PageInternalNetwork + PageDNS + PageSummary +) + +const ( + FocusTypeInput int = 0 + FocusTypePrev int = 1 + FocusTypeNext int = 2 +) + +// model TUI 主模型 +type model struct { + config Config + currentPage PageType + totalPages int + textInputs []textinput.Model + inputLabels []string // 存储标签 + focusIndex int + focusType int // 0=输入框, 1=上一步按钮, 2=下一步按钮 + agreementIdx int // 0=拒绝,1=接受 + width int + height int + err error + quitting bool + done bool + force bool +} + +// defaultConfig 返回默认配置 +func defaultConfig() Config { + return Config{ + Hostname: "sunhpc01", + Country: "China", + Region: "Beijing", + Timezone: "Asia/Shanghai", + HomePage: "https://sunhpc.example.com", + DBAddress: "127.0.0.1", + DBName: "sunhpc_db", + DataAddress: "/data/sunhpc", + PublicInterface: "eth0", + InternalInterface: "eth1", + IPAddress: "192.168.1.100", + Netmask: "255.255.255.0", + Gateway: "192.168.1.1", + InternalIP: "10.0.0.100", + InternalMask: "255.255.255.0", + DNSPrimary: "8.8.8.8", + DNSSecondary: "8.8.4.4", + } +} + +// initialModel 初始化模型 +func initialModel() model { + cfg := defaultConfig() + m := model{ + config: cfg, + totalPages: 6, + textInputs: make([]textinput.Model, 0), + inputLabels: make([]string, 0), + agreementIdx: 1, + focusIndex: 0, + focusType: 0, // 0=输入框, 1=上一步按钮, 2=下一步按钮 + width: 80, + height: 24, + } + m.initPageInputs() + return m +} + +// Init 初始化命令 +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +// Update 处理消息更新 +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + + case "esc": + if m.currentPage > 0 { + return m.prevPage() + } + + case "enter": + return m.handleEnter() + + case "tab", "shift+tab", "up", "down", "left", "right": + return m.handleNavigation(msg) + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // 动态调整容器宽度 + /* + if msg.Width > 100 { + containerStyle = containerStyle.Width(90) + } else if msg.Width > 80 { + containerStyle = containerStyle.Width(70) + } else { + containerStyle = containerStyle.Width(msg.Width - 10) + } + */ + + // ✅ 动态计算容器宽度(终端宽度的 80%) + containerWidth := msg.Width * 80 / 100 + + // ✅ 重新设置容器样式宽度 + containerStyle = containerStyle.Width(containerWidth) + + // 动态设置协议框宽度(容器宽度的 90%) + agreementWidth := containerWidth * 80 / 100 + agreementBox = agreementBox.Width(agreementWidth) + + // 动态设置输入框宽度 + inputWidth := containerWidth * 60 / 100 + if inputWidth < 40 { + inputWidth = 40 + } + inputBox = inputBox.Width(inputWidth) + + // 动态设置总结框宽度 + summaryWidth := containerWidth * 90 / 100 + summaryBox = summaryBox.Width(summaryWidth) + + return m, nil + } + + // 更新当前焦点的输入框 + if len(m.textInputs) > 0 && m.focusIndex < len(m.textInputs) { + var cmd tea.Cmd + m.textInputs[m.focusIndex], cmd = m.textInputs[m.focusIndex].Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// handleEnter 处理回车事件 +func (m *model) handleEnter() (tea.Model, tea.Cmd) { + switch m.currentPage { + case PageAgreement: + if m.agreementIdx == 1 { + m.config.AgreementAccepted = true + return m.nextPage() + } else { + m.quitting = true + return m, tea.Quit + } + + case PageData, PagePublicNetwork, PageInternalNetwork, PageDNS: + // 根据焦点类型执行不同操作 + switch m.focusType { + case FocusTypeInput: + // 在输入框上,保存并下一页 + m.saveCurrentPage() + return m.nextPage() + case FocusTypePrev: + // 上一步按钮,返回上一页 + return m.prevPage() + case FocusTypeNext: + // 下一步按钮,切换到下一页 + m.saveCurrentPage() + return m.nextPage() + } + + case PageSummary: + switch m.focusIndex { + case 0: // 执行 + m.done = true + if err := m.saveConfig(); err != nil { + m.err = err + return m, nil + } + return m, tea.Quit + case 1: // 取消 + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +// handleNavigation 处理导航 +func (m *model) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // debug + //fmt.Fprintf(os.Stderr, "DEBUG: key=%s page=%d\n", msg.String(), m.currentPage) + + switch m.currentPage { + case PageAgreement: + switch msg.String() { + case "left", "right", "tab", "shift+tab", "up", "down": + m.agreementIdx = 1 - m.agreementIdx + } + + case PageSummary: + switch msg.String() { + case "left", "right", "tab", "shift+tab": + m.focusIndex = 1 - m.focusIndex + } + + default: + // 输入框页面: 支持输入框和按钮之间切换 + // totalFocusable := len(m.textInputs) + 2 + + switch msg.String() { + case "down", "tab": + // 当前在输入框 + switch m.focusType { + case FocusTypeInput: + if m.focusIndex < len(m.textInputs)-1 { + // 切换到下一个输入框 + m.textInputs[m.focusIndex].Blur() + m.focusIndex++ + m.textInputs[m.focusIndex].Focus() + } else { + // 最后一个输入框,切换到“下一步”按钮 + m.textInputs[m.focusIndex].Blur() + m.focusIndex = 0 + m.focusType = FocusTypeNext // 下一步按钮 + } + case FocusTypePrev: + // 当前在“上一步”按钮,切换到第一个输入框 + m.focusType = FocusTypeInput + m.focusIndex = 0 + m.textInputs[0].Focus() + case FocusTypeNext: + // 当前在“下一步”按钮,切换到“上一步”按钮 + m.focusType = FocusTypePrev + } + case "up", "shift+tab": + // 当前在输入框 + switch m.focusType { + case FocusTypeInput: + if m.focusIndex > 0 { + // 切换到上一个输入框 + m.textInputs[m.focusIndex].Blur() + m.focusIndex-- + m.textInputs[m.focusIndex].Focus() + } else { + // 第一个输入框,切换到“上一步”按钮 + m.textInputs[m.focusIndex].Blur() + m.focusIndex = 0 + m.focusType = FocusTypePrev // 上一步按钮 + } + case FocusTypeNext: + // 当前在“下一步”按钮,切换到最后一个输入框 + m.focusType = FocusTypeInput + m.focusIndex = len(m.textInputs) - 1 + m.textInputs[m.focusIndex].Focus() + case FocusTypePrev: + // 当前在“上一步”按钮,切换到“下一步”按钮 + m.focusType = FocusTypeNext + } + } + } + return m, nil +} + +// nextPage 下一页 +func (m *model) nextPage() (tea.Model, tea.Cmd) { + if m.currentPage < PageSummary { + m.currentPage++ + m.focusIndex = 0 + m.initPageInputs() + } + return m, textinput.Blink +} + +// prevPage 上一页 +func (m *model) prevPage() (tea.Model, tea.Cmd) { + if m.currentPage > 0 { + m.saveCurrentPage() + m.currentPage-- + m.focusIndex = 0 + m.initPageInputs() + } + return m, textinput.Blink +} diff --git a/pkg/wizard/pages.go b/pkg/wizard/pages.go new file mode 100644 index 0000000..f9ca2d0 --- /dev/null +++ b/pkg/wizard/pages.go @@ -0,0 +1,366 @@ +package wizard + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// View 渲染视图 +func (m model) View() string { + if m.done { + return successView() + } + if m.quitting { + return quitView() + } + if m.err != nil { + return errorView(m.err) + } + + var page string + switch m.currentPage { + case PageAgreement: + page = m.agreementView() + case PageData: + page = m.dataView() + case PagePublicNetwork: + page = m.publicNetworkView() + case PageInternalNetwork: + page = m.internalNetworkView() + case PageDNS: + page = m.dnsView() + case PageSummary: + page = m.summaryView() + } + + content := strings.Builder{} + content.WriteString(page) + content.WriteString("\n\n") + content.WriteString(progressView(m.currentPage, m.totalPages)) + + return containerStyle.Render(content.String()) +} + +// agreementView 协议页面 +func (m model) agreementView() string { + title := titleStyle.Render("SunHPC 系统初始化向导") + subtitle := subTitleStyle.Render("请先阅读并同意以下协议") + + agreement := agreementBox.Render(` + ┌─────────────────────────────────────────────────────────────┐ + │ SunHPC 软件许可协议 │ + └─────────────────────────────────────────────────────────────┘ + + 1. 许可授予 + 本软件授予您非独占、不可转让的使用许可。 + + 2. 使用限制 + - 不得用于非法目的 + - 不得反向工程或反编译 + - 不得移除版权标识 + + 3. 免责声明 + 本软件按"原样"提供,不提供任何明示或暗示的保证。 + + 4. 责任限制 + 在任何情况下,作者不对因使用本软件造成的任何损失负责。 + + 5. 协议终止 + 如违反本协议条款,许可将自动终止。 + + ─────────────────────────────────────────────────────────────── + 请仔细阅读以上条款,点击"接受"表示您同意并遵守本协议。 + ─────────────────────────────────────────────────────────────── +`) + + var acceptBtn, rejectBtn string + if m.agreementIdx == 0 { + rejectBtn = selectedButton.Render(">> 拒绝 <<") + acceptBtn = selectedButton.Render(" 同意 ") + } else { + rejectBtn = selectedButton.Render(" 拒绝 ") + acceptBtn = selectedButton.Render(">> 同意 <<") + } + + buttonGroup := lipgloss.JoinHorizontal( + lipgloss.Center, + acceptBtn, " ", rejectBtn) + + // ✅ 添加调试信息(确认 agreementIdx 的值) + // debugInfo := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")). + // Render(fmt.Sprintf("[DEBUG: idx=%d]", m.agreementIdx),) + + hint := hintStyle.Render("使用 <- -> 或 Tab 选择,Enter 确认") + + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + agreement, "", + buttonGroup, "", + // debugInfo, "", // ✅ 显示调试信息 + hint, + ) +} + +// dataView 数据接收页面 +func (m model) dataView() string { + title := titleStyle.Render("集群基础配置") + subtitle := subTitleStyle.Render("请填写系统基本信息") + + var inputs strings.Builder + for i, ti := range m.textInputs { + info := fmt.Sprintf("%-10s|", m.inputLabels[i]) + input := inputBox.Render(info + ti.View()) + inputs.WriteString(input + "\n") + } + + buttons := m.renderNavButtons() + + hint := hintStyle.Render("使用 Up/Down 或 Tab 切换、Enter 确认") + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + inputs.String(), "", + buttons, "", + hint, + ) +} + +// publicNetworkView 公网设置页面 +func (m model) publicNetworkView() string { + title := titleStyle.Render("公网配置") + subtitle := subTitleStyle.Render("请配置网络接口信息") + + autoDetect := infoStyle.Render("[*] 自动检测网络接口: eth0, eth1, ens33") + + var inputs strings.Builder + for i, ti := range m.textInputs { + info := fmt.Sprintf("%-10s|", m.inputLabels[i]) + input := inputBox.Render(info + ti.View()) + inputs.WriteString(input + "\n") + } + + buttons := m.renderNavButtons() + + hint := hintStyle.Render("使用 Up/Down 或 Tab 切换、Enter 确认") + + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + autoDetect, "", + inputs.String(), "", + buttons, "", + hint, + ) +} + +// internalNetworkView 内网配置页面 +func (m model) internalNetworkView() string { + title := titleStyle.Render("内网配置") + subtitle := subTitleStyle.Render("请配置内网信息") + + var inputs strings.Builder + for i, ti := range m.textInputs { + info := fmt.Sprintf("%-10s|", m.inputLabels[i]) + input := inputBox.Render(info + ti.View()) + inputs.WriteString(input + "\n") + } + + buttons := m.renderNavButtons() + hint := hintStyle.Render("使用 Up/Down 或 Tab 切换、Enter 确认") + + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + inputs.String(), "", + buttons, "", + hint, + ) +} + +// dnsView DNS 配置页面 +func (m model) dnsView() string { + title := titleStyle.Render("DNS 配置") + subtitle := subTitleStyle.Render("请配置 DNS 服务器") + + var inputs strings.Builder + for i, ti := range m.textInputs { + info := fmt.Sprintf("%-10s|", m.inputLabels[i]) + input := inputBox.Render(info + ti.View()) + inputs.WriteString(input + "\n") + } + + buttons := m.renderNavButtons() + + hint := hintStyle.Render("使用 Up/Down 或 Tab 切换、Enter 确认") + + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + inputs.String(), "", + buttons, "", + hint, + ) +} + +// summaryView 总结页面 +func (m model) summaryView() string { + title := titleStyle.Render("配置总结") + subtitle := subTitleStyle.Render("请确认以下配置信息") + + summary := summaryBox.Render(fmt.Sprintf(` ++---------------------------------------------+ + 基本信息 ++---------------------------------------------+ + 主机名:%-35s + 国 家: %-31s + 地 区:%-31s + 时 区:%-38s + 主 页:%-38s ++---------------------------------------------+ + 数据库 ++---------------------------------------------+ + 地 址:%-38s + 名 称:%-38s + 软 件:%-33s ++---------------------------------------------+ + 公网配置 ++---------------------------------------------+ + 接 口:%-38s + 地 址: %-41s + 掩 码:%-38s + 网 关:%-38s ++---------------------------------------------+ + 内网配置 ++---------------------------------------------+ + 接 口:%-38s + 地 址: %-41s + 掩 码:%-38s ++---------------------------------------------+ + DNS ++---------------------------------------------+ + 主 DNS: %-37s + 备 DNS: %-37s ++---------------------------------------------+ +`, + m.config.Hostname, + m.config.Country, + m.config.Region, + m.config.Timezone, + m.config.HomePage, + m.config.DBAddress, + m.config.DBName, + m.config.DataAddress, + m.config.PublicInterface, + m.config.IPAddress, + m.config.Netmask, + m.config.Gateway, + m.config.InternalInterface, + m.config.InternalIP, + m.config.InternalMask, + m.config.DNSPrimary, + m.config.DNSSecondary, + )) + + var buttons string + if m.focusIndex == 0 { + buttons = selectedButton.Render("[>] 执行初始化") + " " + normalButton.Render("[ ] 取消") + } else { + buttons = normalButton.Render("[>] 执行初始化") + " " + selectedButton.Render("[ ] 取消") + } + + hint := hintStyle.Render("使用 <- -> 或 Tab 选择,Enter 确认") + + return lipgloss.JoinVertical(lipgloss.Center, + title, "", + subtitle, "", + summary, "", + buttons, "", + hint, + ) +} + +// progressView 进度条 +func progressView(current PageType, total int) string { + progress := "" + for i := 0; i < total; i++ { + if i < int(current) { + progress += "[+]" + } else if i == int(current) { + progress += "[-]" + } else { + progress += "[ ]" + } + if i < total-1 { + progress += " " + } + } + labels := []string{"协议", "数据", "公网", "内网", "DNS", "总结"} + label := labelStyle.Render(labels[current]) + return progressStyle.Render(progress) + " " + label +} + +// successView 成功视图 +func successView() string { + return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center, + successTitle.Render("初始化完成!"), "", + successMsg.Render("系统配置已保存,正在初始化..."), "", + hintStyle.Render("按任意键退出"), + )) +} + +// quitView 退出视图 +func quitView() string { + return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center, + errorTitle.Render("已取消"), "", + errorMsg.Render("初始化已取消,未保存任何配置"), + )) +} + +// errorView 错误视图 +func errorView(err error) string { + return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center, + errorTitle.Render("错误"), "", + errorMsg.Render(err.Error()), "", + hintStyle.Render("按 Ctrl+C 退出"), + )) +} + +// navButtons 导航按钮 +func navButtons(m model, prev, next string) string { + var btns string + if m.currentPage == 0 { + btns = normalButton.Render(prev) + " " + selectedButton.Render(next) + } else { + btns = selectedButton.Render(prev) + " " + normalButton.Render(next) + } + return btns +} + +func (m model) renderNavButtons() string { + var prevBtn, nextBtn string + + switch m.focusType { + case FocusTypePrev: + // 焦点在"上一步" + prevBtn = selectedButton.Render("<< 上一步 >>") + nextBtn = normalButton.Render("下一步 >>") + case FocusTypeNext: + // 焦点在"下一步" + prevBtn = normalButton.Render("<< 上一步") + nextBtn = selectedButton.Render("<< 下一步 >>") + default: + // 焦点在输入框 + prevBtn = normalButton.Render("<< 上一步") + nextBtn = normalButton.Render("下一步 >>") + } + + return lipgloss.JoinHorizontal( + lipgloss.Center, + prevBtn, + " ", + nextBtn, + ) +} diff --git a/pkg/wizard/styles.go b/pkg/wizard/styles.go new file mode 100644 index 0000000..3350e2d --- /dev/null +++ b/pkg/wizard/styles.go @@ -0,0 +1,99 @@ +package wizard + +import "github.com/charmbracelet/lipgloss" + +// 颜色定义 +var ( + primaryColor = lipgloss.Color("#7C3AED") + secondaryColor = lipgloss.Color("#10B981") + errorColor = lipgloss.Color("#EF4444") + warnColor = lipgloss.Color("#F59E0B") + + // 背景色设为无,让终端自己的背景色生效,避免黑块 + bgColor = lipgloss.Color("#1F2937") + textColor = lipgloss.Color("#FFFFFF") + mutedColor = lipgloss.Color("#B0B0B0") +) + +// 容器样式 +var containerStyle = lipgloss.NewStyle(). + Padding(2, 4). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + //Background(bgColor). // 注释掉背景色,防止在某些终端出现黑块 + Foreground(textColor). + //Width(80). + Align(lipgloss.Center) + +// 标题样式 +var titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(1) + +var subTitleStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + MarginBottom(2) + +// 按钮样式 +var normalButton = lipgloss.NewStyle(). + Padding(0, 2). + Foreground(lipgloss.Color("#666666")) // 深灰色,更暗 + +var selectedButton = lipgloss.NewStyle(). + Padding(0, 2). + Foreground(lipgloss.Color("#3d4747ff")). // 亮绿色 + Bold(true) + +// 输入框样式 +var inputBox = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + +var labelStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Width(12). + Align(lipgloss.Right) + +// 协议框样式 +var agreementBox = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(warnColor). + Padding(1, 2). + //Width(70). + Align(lipgloss.Left) + +// 总结框样式 +var summaryBox = lipgloss.NewStyle(). + BorderStyle(lipgloss.DoubleBorder()). + BorderForeground(primaryColor). + Padding(0, 0). + Foreground(textColor) + +// 进度条样式 +var progressStyle = lipgloss.NewStyle().Foreground(primaryColor) + +// 提示信息样式 +var hintStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true) + +// 成功/错误样式 +var successTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(secondaryColor) + +var successMsg = lipgloss.NewStyle(). + Foreground(textColor) + +var errorTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(errorColor) + +var errorMsg = lipgloss.NewStyle(). + Foreground(textColor) + +var infoStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) diff --git a/pkg/wizard/wizard.go b/pkg/wizard/wizard.go new file mode 100644 index 0000000..5f15fec --- /dev/null +++ b/pkg/wizard/wizard.go @@ -0,0 +1,46 @@ +package wizard + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +// Run 启动初始化向导 +func Run(force bool) error { + // 检查是否已有配置 + if !force && ConfigExists() { + fmt.Println("⚠️ 检测到已有配置文件") + fmt.Println(" 使用 --force 参数强制重新初始化") + fmt.Println(" 或运行 sunhpc init tui --force") + return nil + } + + // 创建程序实例 + p := tea.NewProgram(initialModel()) + + // 运行程序 + if _, err := p.Run(); err != nil { + return fmt.Errorf("初始化向导运行失败:%w", err) + } + + return nil +} + +// getConfigPath 获取配置文件路径 +func GetConfigPath() string { + // 优先使用环境变量 + if path := os.Getenv("SUNHPC_CONFIG"); path != "" { + return path + } + // 默认路径 + return "/etc/sunhpc/config.json" +} + +// configExists 检查配置文件是否存在 +func ConfigExists() bool { + configPath := GetConfigPath() + _, err := os.Stat(configPath) + return err == nil +}