Compare commits

...

4 Commits

Author SHA1 Message Date
3a5f5ddd5d 优化数据库,日志,命令模块 2026-02-23 18:54:54 +08:00
47a2dfeda1 optimize tui interface and interaction 2026-02-22 20:18:12 +08:00
ce9af9f7d0 add tui command 2026-02-21 20:22:21 +08:00
fbe6aec707 add config.yaml 2026-02-20 20:24:02 +08:00
22 changed files with 1927 additions and 373 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
sunhpc
testgui

66
cmd/test/main.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
// model 定义应用的状态
type model struct {
items []string // 列表数据
selectedIdx int // 当前选中的索引
}
// Init 初始化模型,返回初始命令(这里不需要,返回 nil
func (m model) Init() tea.Cmd {
return nil
}
// Update 处理用户输入和状态更新
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// 处理键盘输入
case tea.KeyMsg:
switch msg.String() {
// Tab 键:切换选中项(循环切换)
case "tab":
m.selectedIdx = (m.selectedIdx + 1) % len(m.items)
// Ctrl+C 或 q 键:退出程序
case "ctrl+c", "q":
return m, tea.Quit
}
}
return m, nil
}
// View 渲染界面
func (m model) View() string {
s := "网络接口列表(按 Tab 切换选择,按 q 退出)\n\n"
// 遍历列表项,渲染每一项
for i, item := range m.items {
// 标记当前选中的项
if i == m.selectedIdx {
s += fmt.Sprintf("→ %s (选中)\n", item)
} else {
s += fmt.Sprintf(" %s\n", item)
}
}
return s
}
func main() {
// 初始化模型,设置列表数据
initialModel := model{
items: []string{"eth0", "eth1", "eth2", "eth3"},
selectedIdx: 0, // 默认选中第一个项
}
// 启动 Bubble Tea 程序
p := tea.NewProgram(initialModel)
if _, err := p.Run(); err != nil {
fmt.Printf("程序运行出错: %v\n", err)
}
}

View File

@@ -18,7 +18,7 @@ import (
// - db/*/*.yaml : 匹配data/一级子目录下的所有yaml文件.
// - 如需递归匹配子目录(如data/db/sub/*.yaml),用 data/**/*.yaml(Go.18+)
//
//go:embed db/*.yaml firewall/*.yaml
//go:embed frontend/*.yaml firewall/*.yaml
var ConfigFS embed.FS
// GetConfigFile 获取指定目录下的的单个配置文件内容

View File

@@ -1,123 +0,0 @@
# 基础数据配置文件
version: 1.0
description: "SunHPC 基础数据配置"
# 节点基础数据
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: ""
- node_name: frontend
attr: state
value: Liaoning
shadow: ""
- node_name: frontend
attr: city
value: Shenyang
shadow: ""
# 网络配置
- node_name: frontend
attr: network_type
value: management
shadow: ""
- node_name: frontend
attr: ip_address
value: 192.168.1.100
shadow: ""
- node_name: frontend
attr: subnet_mask
value: 255.255.255.0
shadow: ""
# 硬件信息
- node_name: frontend
attr: manufacturer
value: Dell
shadow: ""
- node_name: frontend
attr: model
value: PowerEdge R740
shadow: ""
# 系统配置
- node_name: frontend
attr: timezone
value: Asia/Shanghai
shadow: ""
- node_name: frontend
attr: language
value: zh_CN.UTF-8
shadow: ""
- node_name: frontend
attr: kernel_version
value: "5.10.0"
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 并行计算库"
# 网络基础数据
networks:
- node_name: frontend
interface: eth0
ip_address: 192.168.1.100
netmask: 255.255.255.0
gateway: 192.168.1.1
type: management
mac: "00:11:22:33:44:55"
# 分区基础数据
partitions:
- node_name: frontend
device: /dev/sda1
mount_point: /boot
size: 1024
fs_type: ext4
- node_name: frontend
device: /dev/sda2
mount_point: /
size: 102400
fs_type: ext4
- node_name: frontend
device: /dev/sda3
mount_point: /home
size: 51200
fs_type: ext4

View File

@@ -0,0 +1,293 @@
# 数据中心/集群基础配置
metadata:
version: "1.0"
last_updated: "2024-01-01"
description: "数据中心基础设施配置"
# 集群配置
cluster:
name: "sunhpc-cluster"
type: "control"
osname: "Rocky Linux"
osversion: "9.7"
location:
country: "China"
city: "Beijing"
timezone:
name: "Asia/Shanghai"
offset: "+08:00"
ntp_servers:
- "ntp1.aliyun.com"
- "ntp2.tencent.com"
- "pool.ntp.org"
environment:
type: "production" # production/staging/development
region: "华北"
availability_zone: "AZ-01"
network:
domain: "sunhpc.local"
dns:
primary: "8.8.8.8"
secondary: "114.114.114.114"
wan:
- interface: "eth0"
address: "202.96.128.86"
netmask: "255.255.255.0"
gateway: "202.96.128.1"
mtu: 1500
type: "public"
description: "public network"
lan:
- interface: "eth1"
address: "192.168.1.100"
netmask: "255.255.255.0"
gateway: ""
mtu: 1500
type: "management"
description: "management network"
disks:
- device: "/dev/sda"
model: "PowerVault ME484"
type: "ssd"
size: "50TB"
vendor: "Dell"
serial: "1234567890"
status: "online"
partition:
- name: "sda1"
usage: "boot partition"
mount: "/boot"
size: "16GB"
fstype: "ext4"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- name: "sda2"
usage: "root partition"
mount: "/"
size: "100GB"
fstype: "ext4"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
options: "defaults,noatime"
- name: "sda3"
usage: "home partition"
mount: "/home"
size: "50TB"
fstype: "xfs"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- name: "sda4"
usage: "var partition"
mount: "/var"
size: "150GB"
fstype: "xfs"
filesystem: "xfs"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- device: "/dev/sdb"
model: "PowerVault ME484"
type: "ssd"
size: "50TB"
vendor: "Dell"
serial: "1234567890"
status: "online"
partition:
- name: "sdb1"
usage: "data partition"
mount: "/data"
size: "50TB"
fstype: "xfs"
filesystem: "xfs"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
firewall:
global_policies:
- name: "默认策略"
input: "drop"
output: "accept"
forward: "drop"
zones:
- name: "public"
interfaces: ["eth0", "eth1"]
services_allowed: ["ssh", "http", "https"]
source_ranges: ["0.0.0.0/0"]
- name: "internal"
interfaces: ["eth2"]
services_allowed: ["ssh", "mysql", "redis", "mongodb", "nfs", "samba"]
source_ranges: ["192.168.0.0/16", "10.0.0.0/8"]
- name: "storage"
interfaces: ["eth3"]
services_allowed: ["iscsi", "nfs", "smb"]
source_ranges: ["172.16.0.0/12"]
rules:
- name: "允许Ping"
protocol: "icmp"
action: "accept"
source: "any"
destination: "any"
- name: "限制SSH访问"
protocol: "tcp"
port: 22
action: "accept"
source: "192.168.1.0/24"
destination: "any"
# 全局服务配置
services:
common_services:
- name: "sshd"
port: 22
protocol: "tcp"
enabled: true
description: "SSH远程登录服务"
- name: "ntpd"
port: 123
protocol: "udp"
enabled: true
description: "时间同步服务"
- name: "rsyslog"
port: 514
protocol: "udp"
enabled: true
description: "日志收集服务"
monitoring_services:
- name: "prometheus"
port: 9090
protocol: "tcp"
enabled: true
description: "监控数据采集"
- name: "grafana"
port: 3000
protocol: "tcp"
enabled: true
description: "监控数据可视化"
- name: "node_exporter"
port: 9100
protocol: "tcp"
enabled: true
description: "节点指标采集"
database_services:
- name: "mysql"
port: 3306
protocol: "tcp"
enabled: true
version: "8.0"
description: "关系型数据库"
- name: "redis"
port: 6379
protocol: "tcp"
enabled: true
version: "6.2"
description: "缓存数据库"
- name: "mongodb"
port: 27017
protocol: "tcp"
enabled: true
version: "5.0"
description: "文档数据库"
# 节点列表
nodes:
# 计算节点
compute_nodes:
- name: "compute-01"
hostname: "compute01.example.local"
role: "compute"
status: "active"
basic_info:
timezone: "Asia/Shanghai"
cpu: "Intel Xeon Gold 6248R 3.0GHz (48核)"
memory: "512GB DDR4"
os: "CentOS 7.9"
kernel: "3.10.0-1160"
virtualization: "KVM"
network:
interfaces:
- name: "eth0"
ip_address: "192.168.1.11"
mac_address: "00:0c:29:xx:xx:01"
network_type: "management"
speed: "1Gbps"
disk:
- device: "/dev/sda"
size: "480GB"
type: "SSD"
mount_point: "/"
filesystem: "xfs"
usage: "系统盘"
- device: "/dev/sdb"
size: "3.6TB"
type: "NVMe"
mount_point: "/data/local"
filesystem: "xfs"
usage: "本地数据盘"
- device: "/dev/sdc"
size: "10TB"
type: "HDD"
mount_point: "/data/shared"
filesystem: "xfs"
usage: "共享存储挂载"
services:
enabled:
- "sshd"
- "ntpd"
- "docker"
- "kubelet"
- "node_exporter"
disabled:
- "firewalld"
- "postfix"
firewall:
enabled: true
rules:
- port: 22
protocol: "tcp"
source: "192.168.1.0/24"
action: "accept"
- port: 10250
protocol: "tcp"
source: "10.10.0.0/16"
action: "accept"
hardware:
manufacturer: "Dell"
model: "PowerEdge R740xd"
serial_number: "ABC123XYZ"
warranty_expiry: "2025-12-31"
location:
rack: "RACK-01"
position: "01U"
power_consumption: "500W"
- name: "compute-02"
hostname: "compute02.example.local"
role: "compute"
status: "active"
# ... 类似配置IP地址递增

View File

@@ -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 获取指定目录下的的单个配置文件内容

41
go.mod
View File

@@ -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
)

100
go.sum
View File

@@ -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=

View File

@@ -2,18 +2,14 @@ package initcmd
import (
"sunhpc/internal/middler/auth"
"sunhpc/pkg/logger"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
// NewConfigCmd 创建 "init config" 命令
func NewInitCfgCmd() *cobra.Command {
var (
force bool
path string
verbose bool
output string
)
cmd := &cobra.Command{
@@ -24,23 +20,19 @@ func NewInitCfgCmd() *cobra.Command {
示例:
sunhpc init config # 生成默认配置文件
sunhpc init config -f # 强制覆盖已有配置文件
sunhpc init config -p /etc/sunhpc/sunhpc.yaml # 指定路径
sunhpc init config -o /etc/sunhpc/sunhpc.yaml # 指定路径
`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := auth.RequireRoot(); err != nil {
return err
}
logger.Info("✅ 配置文件已生成", zap.String("path", path))
return nil
},
}
// 定义局部 flags
cmd.Flags().BoolVarP(&force, "force", "f", false, "强制覆盖已有配置文件")
cmd.Flags().StringVarP(&path, "path", "p", "", "指定配置文件路径")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "显示详细日志")
cmd.Flags().StringVarP(&output, "output", "o", "", "指定配置文件路径")
return cmd
}

View File

@@ -3,7 +3,6 @@ package initcmd
import (
"fmt"
"sunhpc/internal/middler/auth"
"sunhpc/pkg/config"
"sunhpc/pkg/database"
"sunhpc/pkg/logger"
@@ -28,26 +27,29 @@ func NewInitDBCmd() *cobra.Command {
logger.Debug("执行数据库初始化...")
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("加载配置失败: %w", err)
}
// 初始化数据库
db, err := database.GetInstance(&cfg.Database, nil)
db, err := database.GetDB()
if err != nil {
return fmt.Errorf("数据库连接失败: %w", err)
}
defer db.Close()
if err := db.InitTables(force); err != nil {
if err := database.InitTables(db, force); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
// 测试数据库连接
if err := database.TestNodeInsert(db); err != nil {
return fmt.Errorf("数据库测试失败: %w", err)
}
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "强制重新初始化")
cmd.Flags().BoolVarP(
&force, "force", "f", false,
"强制重新初始化")
return cmd
}

View File

@@ -14,6 +14,7 @@ func NewInitCmd() *cobra.Command {
cmd.AddCommand(NewInitDBCmd())
cmd.AddCommand(NewInitCfgCmd())
cmd.AddCommand(NewInitTuiCmd())
return cmd
}

33
internal/cli/init/tui.go Normal file
View File

@@ -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
}

View File

@@ -1,6 +1,9 @@
package cli
import (
"fmt"
"os"
"path/filepath"
initcmd "sunhpc/internal/cli/init"
"sunhpc/pkg/config"
"sunhpc/pkg/logger"
@@ -20,21 +23,49 @@ func NewRootCmd() *cobra.Command {
Use: "sunhpc",
Short: "SunHPC - HPC集群一体化运维工具",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 加载全局配置(只加载一次)
// 设置 CLI 参数
config.CLIParams.Verbose = verbose
config.CLIParams.NoColor = noColor
config.CLIParams.Config = cfgFile
// 初始化配置目录和默认文件
if err := config.InitConfigs(); err != nil {
fmt.Fprintf(os.Stderr, "初始化配置目录失败: %v\n", err)
os.Exit(1)
}
// 加载配置(后续调用直接返回缓存)
cfg, err := config.LoadConfig()
if err != nil {
// 配置加载失败,使用默认日志配置初始化
logger.Warnf("加载配置失败,使用默认日志配置: %v", err)
logger.Init(logger.LogConfig{})
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
// 初始化日志
logger.Init(cfg.Log)
// 记录启动信息
logger.Debugf("SunHPC 启动中...")
logger.Debugf("配置文件: %s", viper.ConfigFileUsed())
logger.Debugf("日志级别: %s", cfg.Log.Level)
logger.Debugf("日志格式: %s", cfg.Log.Format)
logger.Debugf("日志输出: %s", cfg.Log.Output)
logger.Debugf("日志文件: %s", cfg.Log.LogFile)
logger.Debugf("显示颜色: %v", cfg.Log.ShowColor)
// 跳过数据库检查,仅在 init db 时检查
if isInitDbCommand(cmd) {
return
}
// 3. 初始化全局日志(全局只执行一次)
logger.Init(logger.LogConfig{
Verbose: cfg.Log.Verbose,
ShowColor: !cfg.Log.ShowColor,
LogFile: cfg.Log.LogFile,
})
// 检查数据库文件是否存在
dbPath := filepath.Join(cfg.Database.Path, cfg.Database.Name)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
logger.Warnf("数据库文件不存在: %s", dbPath)
logger.Errorf("请先运行 sunhpc init db 初始化数据库")
os.Exit(1)
}
logger.Debugf("数据库文件存在: %s", dbPath)
},
Run: func(cmd *cobra.Command, args []string) {
@@ -42,23 +73,17 @@ func NewRootCmd() *cobra.Command {
},
}
cmd.PersistentFlags().StringVarP(
&config.CLIParams.Config,
"config", "c",
"", "配置文件路径 (默认:/etc/sunhpc/config.yaml)")
cmd.PersistentFlags().BoolVarP(
&config.CLIParams.Verbose,
"verbose", "v", false, "启用详细日志输出")
cmd.PersistentFlags().BoolVar(
&verbose, "verbose", false,
"启用详细日志输出")
cmd.PersistentFlags().BoolVar(
&config.CLIParams.NoColor,
"no-color", false, "禁用彩色输出")
&noColor, "no-color", false,
"禁用彩色输出")
// 如果指定了 --config 参数,优先使用该配置文件
if config.CLIParams.Config != "" {
viper.SetConfigFile(config.CLIParams.Config)
}
cmd.PersistentFlags().StringVar(
&cfgFile, "config", "",
"配置文件路径 (默认:/etc/sunhpc/config.yaml)")
cmd.AddCommand(initcmd.NewInitCmd())
return cmd
@@ -67,3 +92,14 @@ func NewRootCmd() *cobra.Command {
func Execute() error {
return NewRootCmd().Execute()
}
func isInitDbCommand(cmd *cobra.Command) bool {
// 检查当前命令是否是 db 子命令
if cmd.Name() == "db" {
// 检查父命令是否是 init
if parent := cmd.Parent(); parent != nil {
return parent.Name() == "init"
}
}
return false
}

View File

@@ -4,70 +4,145 @@ import (
"fmt"
"os"
"path/filepath"
"sunhpc/pkg/logger"
"sunhpc/pkg/utils"
"sync"
"github.com/spf13/viper"
"go.yaml.in/yaml/v3"
)
// ==============================================================
// 全局变量
// ==============================================================
var (
GlobalConfig *Config
configOnce sync.Once // 确保配置只加载一次
configMutex sync.RWMutex // 读写锁保护 GlobalConfig
)
// ==============================================================
// 目录常量 (可在 main.go 中通过 flag 覆盖默认值)
// ==============================================================
var (
BaseDir string = utils.DefaultBaseDir
TmplDir string = utils.DefaultTmplDir
LogDir string = utils.DefaultLogDir
)
// ==============================================================
// 配置结构体
// ==============================================================
type Config struct {
Database DatabaseConfig `yaml:"database"`
Log LogConfig `yaml:"log"`
Log logger.LogConfig `mapstructure:"log" yaml:"log"`
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
}
type DatabaseConfig struct {
DSN string `yaml:"dsn"` // 数据库连接字符串
Path string `yaml:"path"` // SQLite: 目录路径
Name string `yaml:"name"` // SQLite: 文件名
DSN string `mapstructure:"dsn" yaml:"dsn"` // 数据库连接字符串
Path string `mapstructure:"path" yaml:"path"` // SQLite: 目录路径
Name string `mapstructure:"name" yaml:"name"` // SQLite: 文件名
Args string `mapstructure:"args" yaml:"args"` // 数据库连接参数
}
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
Verbose bool `yaml:"verbose"`
LogFile string `yaml:"log_file"`
ShowColor bool `yaml:"show_color"`
}
// --------------------------------- 全局单例配置(核心) ---------------------------------
var (
// GlobalConfig 全局配置单例实例
GlobalConfig *Config
// 命令行参数配置(全局、由root命令绑定)
CLIParams = struct {
type CLIParamsType struct {
Verbose bool // -v/--verbose
NoColor bool // --no-color
Config string // -c/--config
}{}
BaseDir string = "/etc/sunhpc"
LogDir string = "/var/log/sunhpc"
TmplDir string = BaseDir + "/tmpl.d"
appName string = "sunhpc"
defaultDBPath string = "/var/lib/sunhpc"
defaultDBName string = "sunhpc.db"
)
}
var CLIParams CLIParamsType // 命令行参数
// InitConfigs 初始化所有配置目录等数据
func InitConfigs() error {
dirs := []string{
BaseDir,
TmplDir,
LogDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", d, err)
}
}
// 检查配置文件是否存在,不存在则创建默认配置
configPath := filepath.Join(BaseDir, "sunhpc.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
if err := createDefaultConfig(configPath); err != nil {
return fmt.Errorf("创建默认配置文件失败: %w", err)
}
}
return nil
}
func createDefaultConfig(configPath string) error {
defaultConfig := &Config{
Database: DatabaseConfig{
Path: utils.DefaultDBPath,
Name: utils.DefaultDBName,
Args: utils.DefaultDBArgs,
},
Log: logger.LogConfig{
Level: "info",
Format: "text",
Output: "stdout",
Verbose: false,
LogFile: utils.DefaultLogFile,
ShowColor: true,
},
}
// 确保数据库目录存在
if err := os.MkdirAll(defaultConfig.Database.Path, 0755); err != nil {
return fmt.Errorf("创建数据库目录失败: %w", err)
}
// 序列号并写入
data, err := yaml.Marshal(defaultConfig)
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// ----------------------------------- 配置加载(只加载一次) -----------------------------------
func LoadConfig() (*Config, error) {
// 如果已经加载过,直接返回
configMutex.RLock()
if GlobalConfig != nil {
// 如果已经加载过,直接返回
configMutex.RUnlock()
return GlobalConfig, nil
}
configMutex.RUnlock()
configMutex.Lock()
defer configMutex.Unlock()
// 双重检查
if GlobalConfig != nil {
// 如果已经加载过,直接返回
return GlobalConfig, nil
}
// 配置文件路径
if CLIParams.Config != "" {
viper.SetConfigFile(CLIParams.Config)
} else {
viper.SetConfigName("sunhpc")
viper.SetConfigType("yaml")
viper.AddConfigPath(BaseDir)
viper.AddConfigPath(".")
viper.AddConfigPath(filepath.Join(os.Getenv("HOME"), "."))
}
// Step 1: 设置默认值(最低优先级)
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "text")
viper.SetDefault("log.output", "stdout")
viper.SetDefault("log.verbose", false)
viper.SetDefault("log.log_file", filepath.Join(LogDir, "sunhpc.log"))
viper.SetDefault("database.name", "sunhpc.db")
viper.SetDefault("database.path", "/var/lib/sunhpc")
viper.SetDefault("log.log_file", utils.DefaultLogFile)
viper.SetDefault("database.name", utils.DefaultDBName)
viper.SetDefault("database.path", utils.DefaultDBPath)
if err := viper.ReadInConfig(); err != nil {
// 配置文件不存在时,使用默认值
@@ -87,10 +162,12 @@ func LoadConfig() (*Config, error) {
viper.Set("log.show_color", false)
}
fullPath := filepath.Join(
viper.GetString("database.path"), viper.GetString("database.name"))
dsn := fmt.Sprintf(
"%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000", fullPath)
// 计算派生配置 (如数据库 DSN)
dbPath := viper.GetString("database.path")
dbName := viper.GetString("database.name")
dbArgs := viper.GetString("database.args")
fullPath := filepath.Join(dbPath, dbName)
dsn := fmt.Sprintf("%s?%s", fullPath, dbArgs)
viper.Set("database.dsn", dsn)
// 解码到结构体
@@ -104,57 +181,23 @@ func LoadConfig() (*Config, error) {
return GlobalConfig, nil
}
// InitDirs 创建所有必需目录
func InitDirs() error {
dirs := []string{
BaseDir,
TmplDir,
LogDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", d, err)
}
}
return nil
}
// ==============================================================
// SaveConfig - 保存全局配置到文件、运行时配置
// ==============================================================
func SaveConfig() error {
configMutex.RLock()
defer configMutex.RUnlock()
func (c *Config) WriteDefaultConfig(path string) error {
// 确保目录存在
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
if GlobalConfig == nil {
return fmt.Errorf("全局配置为空")
}
// 生成默认配置
cfg := DefaultConfig(path)
// 序列化为 YAML
data, err := yaml.Marshal(cfg)
configPath := filepath.Join(BaseDir, "config.yaml")
data, err := yaml.Marshal(GlobalConfig)
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
// 写入文件0644 权限)
return os.WriteFile(path, data, 0644)
}
func DefaultConfig(path string) *Config {
return &Config{
Database: DatabaseConfig{
DSN: fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000",
filepath.Join(filepath.Dir(path), defaultDBName)),
Path: filepath.Dir(path),
Name: defaultDBName,
},
Log: LogConfig{
Level: "info",
Format: "text",
Output: "stdout",
LogFile: filepath.Join(filepath.Dir(path), "sunhpc.log"),
Verbose: false,
},
return err
}
return os.WriteFile(configPath, data, 0644)
}
// ResetConfig 重置全局配置为默认值

View File

@@ -5,7 +5,6 @@ import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
@@ -16,65 +15,57 @@ import (
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
db *sql.DB
logger logger.Logger
}
// =========================================================
// 全局变量
// =========================================================
var (
dbInstance *DB
dbInstance *sql.DB
dbOnce sync.Once
dbMutex sync.RWMutex
dbErr error
)
func GetInstance(dbConfig *config.DatabaseConfig, log logger.Logger) (*DB, error) {
// =========================================================
// GetDB - 获取数据库连接(单例模式)
// =========================================================
func GetDB() (*sql.DB, error) {
dbOnce.Do(func() {
// 兜底: 未注入则使用全局默认日志实例
if log == nil {
log = logger.DefaultLogger
}
log.Debugf("开始初始化数据库,路径: %s", dbConfig.Path)
// 确认数据库目录存在
if err := os.MkdirAll(dbConfig.Path, 0755); err != nil {
log.Errorf("创建数据库目录失败: %v", err)
dbErr = err
if dbInstance != nil {
return
}
fullPath := filepath.Join(dbConfig.Path, dbConfig.Name)
log.Debugf("数据库路径: %s", fullPath)
// 确保配置已加载
cfg, err := config.LoadConfig()
if err != nil {
dbErr = fmt.Errorf("加载配置失败: %w", err)
return
}
// 构建DSN
dsn := fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000&cache=shared",
fullPath)
log.Debugf("DSN: %s", dsn)
logger.Debugf("DSN: %s", cfg.Database.DSN)
// 打开SQLite 连接
sqlDB, err := sql.Open("sqlite3", dsn)
sqlDB, err := sql.Open("sqlite3", cfg.Database.DSN)
if err != nil {
log.Errorf("数据库打开失败: %v", err)
dbErr = err
dbErr = fmt.Errorf("数据库打开失败: %w", err)
return
}
// 设置连接池参数
sqlDB.SetMaxOpenConns(1) // SQLite 只支持单连接
sqlDB.SetMaxIdleConns(1) // 保持一个空闲连接
sqlDB.SetMaxOpenConns(10) // 最大打开连接
sqlDB.SetMaxIdleConns(5) // 保持空闲连接
sqlDB.SetConnMaxLifetime(0) // 禁用连接生命周期超时
sqlDB.SetConnMaxIdleTime(0) // 禁用空闲连接超时
// 测试数据库连接
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
log.Errorf("数据库连接失败: %v", err)
dbErr = err
dbErr = fmt.Errorf("数据库连接失败: %w", err)
return
}
// 赋值 *DB 类型的单例(而非直接复制 *sql.DB)
log.Info("数据库连接成功")
dbInstance = &DB{sqlDB, log}
logger.Debug("数据库连接成功")
dbInstance = sqlDB
})
if dbErr != nil {
@@ -84,21 +75,6 @@ func GetInstance(dbConfig *config.DatabaseConfig, log logger.Logger) (*DB, error
return dbInstance, nil
}
// Close 关闭数据库连接
func (d *DB) Close() error {
if d.db != nil {
d.logger.Debug("关闭数据库连接")
err := d.db.Close()
if err != nil {
d.logger.Errorf("数据库连接关闭失败: %v", err)
return err
}
d.logger.Info("数据库连接关闭成功")
return nil
}
return nil
}
func confirmAction(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
@@ -112,38 +88,38 @@ func confirmAction(prompt string) bool {
return response == "y" || response == "yes"
}
func (d *DB) InitTables(force bool) error {
d.logger.Info("开始初始化数据库表...")
func InitTables(db *sql.DB, force bool) error {
if force {
// 确认是否强制删除
if !confirmAction("确认强制删除所有表和触发器?") {
d.logger.Info("操作已取消")
logger.Info("操作已取消")
db.Close()
os.Exit(0)
return nil
}
// 强制删除所有表和触发器
d.logger.Debug("强制删除所有表和触发器...")
if err := dropTables(d.db); err != nil {
logger.Debug("强制删除所有表和触发器...")
if err := dropTables(db); err != nil {
return fmt.Errorf("删除表失败: %w", err)
}
d.logger.Debug("删除所有表和触发器成功")
logger.Debug("删除所有表和触发器成功")
if err := dropTriggers(d.db); err != nil {
if err := dropTriggers(db); err != nil {
return fmt.Errorf("删除触发器失败: %w", err)
}
d.logger.Debug("删除所有触发器成功")
logger.Debug("删除所有触发器成功")
}
// ✅ 调用 schema.go 中的函数
for _, ddl := range CreateTableStatements() {
d.logger.Debugf("执行: %s", ddl)
if _, err := d.db.Exec(ddl); err != nil {
logger.Debugf("执行: %s", ddl)
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("数据表创建失败: %w", err)
}
}
d.logger.Info("数据库表创建成功")
logger.Info("数据库表创建成功")
/*
使用sqlite3命令 测试数据库是否存在表
✅ 查询所有表
@@ -174,3 +150,65 @@ func dropTriggers(db *sql.DB) error {
}
return nil
}
func CloseDB() error {
dbMutex.Lock()
defer dbMutex.Unlock()
if dbInstance == nil {
if err := dbInstance.Close(); err != nil {
return err
}
dbInstance = nil
}
return nil
}
// 使用事务回滚测试
func RunTestWithRollback(db *sql.DB, testFunc func(*sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
// 执行测试
if err := testFunc(tx); err != nil {
tx.Rollback()
return err
}
// 回滚事务,所有更改(包括 ID 递增)都会撤销
return tx.Rollback()
}
// 使用示例
func TestNodeInsert(db *sql.DB) error {
logger.Debug("测试数据插入...")
return RunTestWithRollback(db, func(tx *sql.Tx) error {
// 插入测试数据
logger.Debug("执行插入测试数据...")
_, err := tx.Exec(`
INSERT INTO nodes (name, cpus, rack, rank)
VALUES (?, ?, ?, ?)
`, "test-node", 64, 1, 1)
if err != nil {
return err
}
// 验证插入
var count int
logger.Debug("执行查询测试数据...")
err = tx.QueryRow(`
SELECT COUNT(*) FROM nodes WHERE name = ?
`, "test-node").Scan(&count)
if err != nil {
return err
}
logger.Infof("测试数据插入成功,共 %d 条", count)
// 不需要手动删除,回滚会自动撤销
return nil
})
}

View File

@@ -67,21 +67,25 @@ var (
// DefaultLogger 全局默认日志实例(所有模块可直接用,也可注入自定义实现)
DefaultLogger Logger
// once 保证日志只初始化一次
once sync.Once
initOnce sync.Once
)
// LogConfig 日志配置结构体和项目的config包对齐
type LogConfig struct {
Verbose bool // 是否开启详细模式Debug级别
Level string // 日志级别debug/info/warn/error
ShowColor bool // 是否显示彩色输出
LogFile string // 日志文件路径(可选,空则只输出到控制台)
Level string `mapstructure:"level" yaml:"level"`
Format string `mapstructure:"format" yaml:"format"`
Output string `mapstructure:"output" yaml:"output"`
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
LogFile string `mapstructure:"log_file" yaml:"log_file"`
ShowColor bool `mapstructure:"show_color" yaml:"show_color"`
}
// 默认配置
var defaultConfig = LogConfig{
Verbose: false,
Level: "info",
Format: "text",
Output: "stdout",
Verbose: false,
ShowColor: true,
LogFile: "/var/log/sunhpc/sunhpc.log",
}
@@ -113,6 +117,9 @@ func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) {
colorReset = ""
}
// Debug,打印原始字节,用于调试
// fmt.Printf("%q\n", entry.Message)
// 拼接格式:
// 灰色日期 + 空格 + 带颜色的[级别] + 空格 + 日志内容 + 空格 + 灰色文件行号 + 重置
fmt.Fprintf(&buf, "%s%s%s %s[%s]%s %s %s%s:%d%s\n",
@@ -150,27 +157,6 @@ func getLevelInfo(level logrus.Level) (string, string) {
}
}
// getCallerInfo 获取调用日志的文件和行号跳过logrus内部调用
func _getCallerInfo() (string, int) {
// 跳过的调用栈深度根据实际情况调整这里跳过logrus和logger包的调用
skip := 6
pc, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown.go", 0
}
// 只保留文件名(如 db.go去掉完整路径
fileName := filepath.Base(file)
// 过滤logrus内部调用可选
funcName := runtime.FuncForPC(pc).Name()
if funcName == "" || filepath.Base(funcName) == "logrus" {
return getCallerInfoWithSkip(skip + 1)
}
return fileName, line
}
func getCallerInfo() (string, int) {
// 从当前调用开始,逐层向上查找
for i := 2; i < 15; i++ { // i从2开始跳过getCallerInfo自身
@@ -203,8 +189,7 @@ func shouldSkipPackage(funcName, file string) bool {
}
// 跳过logger包你自己的包装包
if strings.Contains(funcName, "your/package/logger") ||
strings.Contains(file, "logger") {
if strings.Contains(file, "/logger/") {
return true
}
@@ -227,21 +212,14 @@ func getCallerInfoWithSkip(skip int) (string, int) {
// Init 初始化全局默认日志实例(全局只执行一次)
func Init(cfg LogConfig) {
once.Do(func() {
// 合并配置:传入的配置为空则用默认值
if cfg.Level == "" {
cfg.Level = defaultConfig.Level
}
if cfg.LogFile == "" {
cfg.LogFile = defaultConfig.LogFile
}
initOnce.Do(func() {
// 1. 创建logrus实例
logrusInst := logrus.New()
// 2. 配置输出(控制台 + 文件,可选)
var outputs []io.Writer
outputs = append(outputs, os.Stdout) // 控制台输出
// 如果配置了日志文件,添加文件输出
if cfg.LogFile != "" {
// 确保日志目录存在

View File

@@ -31,3 +31,23 @@ func GenerateID() (string, error) {
func GetTimestamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// 定义短语
const (
NoAvailableNetworkInterfaces = "No available network interfaces"
)
// 定义目录
const (
DefaultBaseDir string = "/etc/sunhpc"
DefaultTmplDir string = DefaultBaseDir + "/tmpl.d"
DefaultLogDir string = "/var/log/sunhpc"
DefaultLogFile string = DefaultLogDir + "/sunhpc.log"
)
// 定义数据库
const (
DefaultDBName string = "sunhpc.db"
DefaultDBPath string = "/var/lib/sunhpc"
DefaultDBArgs string = "_foreign_keys=on&_journal_mode=WAL&_timeout=5000"
)

217
pkg/wizard/config.go Normal file
View File

@@ -0,0 +1,217 @@
package wizard
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"sunhpc/pkg/utils"
"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.HomePage = m.textInputs[0].Value()
m.config.Hostname = m.textInputs[1].Value()
m.config.Country = m.textInputs[2].Value()
m.config.Region = m.textInputs[3].Value()
m.config.Timezone = m.textInputs[4].Value()
m.config.DBAddress = m.textInputs[5].Value()
m.config.DataAddress = m.textInputs[6].Value()
}
}
func (m *model) savePublicNetworkPage() {
if len(m.textInputs) >= 4 {
m.config.PublicInterface = m.textInputs[0].Value()
m.config.PublicIPAddress = m.textInputs[1].Value()
m.config.PublicNetmask = m.textInputs[2].Value()
m.config.PublicGateway = m.textInputs[3].Value()
}
}
func (m *model) saveInternalNetworkPage() {
if len(m.textInputs) >= 3 {
m.config.InternalInterface = m.textInputs[0].Value()
m.config.InternalIPAddress = m.textInputs[1].Value()
m.config.InternalNetmask = 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 }{
{"Homepage", m.config.HomePage},
{"Hostname", m.config.Hostname},
{"Country", m.config.Country},
{"Region", m.config.Region},
{"Timezone", m.config.Timezone},
{"DB Path", m.config.DBAddress},
{"Software", m.config.DataAddress},
}
for _, f := range fields {
ti := textinput.New()
ti.Placeholder = ""
ti.Placeholder = f.label
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{"Homepage", "Hostname", "Country", "Region", "Timezone", "DBPath", "Software"}
case PagePublicNetwork:
fields := []struct{ label, value string }{
{"Public Interface", m.config.PublicInterface},
{"Public IP Address", m.config.PublicIPAddress},
{"Public Netmask", m.config.PublicNetmask},
{"Public Gateway", m.config.PublicGateway},
}
for _, f := range fields {
ti := textinput.New()
ti.Placeholder = ""
ti.Placeholder = f.label
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{"Public Interface", "Public IP Address", "Public Netmask", "Public Gateway"}
case PageInternalNetwork:
fields := []struct{ label, value string }{
{"Internal Interface", m.config.InternalInterface},
{"Internal IP Address", m.config.InternalIPAddress},
{"Internal Netmask", m.config.InternalNetmask},
}
for _, f := range fields {
ti := textinput.New()
ti.Placeholder = f.label
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{"Internal Interface", "Internal IP", "Internal Mask"}
case PageDNS:
fields := []struct{ label, value string }{
{"Primary DNS", m.config.DNSPrimary},
{"Secondary DNS", m.config.DNSSecondary},
}
for _, f := range fields {
ti := textinput.New()
ti.Placeholder = f.label
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{"Pri DNS", "Sec DNS"}
}
}
// 获取系统网络接口
func getNetworkInterfaces() []string {
// 实现获取系统网络接口的逻辑
// 例如:使用 net.Interface() 函数获取系统网络接口
// 返回一个字符串切片,包含系统网络接口的名称
interfaces, err := net.Interfaces()
if err != nil {
return []string{utils.NoAvailableNetworkInterfaces}
}
var result []string
for _, iface := range interfaces {
// 跳过 loopback 接口
if iface.Flags&net.FlagLoopback != 0 {
continue
}
result = append(result, iface.Name)
}
if len(result) == 0 {
return []string{utils.NoAvailableNetworkInterfaces}
}
return result
}

354
pkg/wizard/model.go Normal file
View File

@@ -0,0 +1,354 @@
package wizard
import (
"fmt"
"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"`
DataAddress string `json:"data_address"`
// 公网设置
PublicInterface string `json:"public_interface"`
PublicIPAddress string `json:"ip_address"`
PublicNetmask string `json:"netmask"`
PublicGateway string `json:"gateway"`
// 内网配置
InternalInterface string `json:"internal_interface"`
InternalIPAddress string `json:"internal_ip"`
InternalNetmask 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
networkInterfaces []string // 所有系统网络接口
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 {
var (
defaultPublicInterface string
defaultInternalInterface string
)
interfaces := getNetworkInterfaces()
switch len(interfaces) {
case 0:
defaultPublicInterface = ""
defaultInternalInterface = ""
case 1:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = fmt.Sprintf("%s:0", interfaces[0])
case 2:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = interfaces[1]
default:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = interfaces[1]
}
return Config{
Hostname: "cluster.hpc.org",
Country: "China",
Region: "Beijing",
Timezone: "Asia/Shanghai",
HomePage: "www.sunhpc.com",
DBAddress: "/var/lib/sunhpc/sunhpc.db",
DataAddress: "/export/sunhpc",
PublicInterface: defaultPublicInterface,
PublicIPAddress: "",
PublicNetmask: "",
PublicGateway: "",
InternalInterface: defaultInternalInterface,
InternalIPAddress: "172.16.9.254",
InternalNetmask: "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
}

356
pkg/wizard/pages.go Normal file
View File

@@ -0,0 +1,356 @@
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 Software License Agreement")
agreement := agreementBox.Render(`
┌─────────────────────────────────────────────────────────────┐
│ SunHPC License Agreement │
└─────────────────────────────────────────────────────────────┘
1. License Grant
This software grants you a non-exclusive, non-transferable
license to use it.
2. Disclaimer of Warranties
This software is provided "as is" without any warranties,
whether express or implied.
3. Limitation of Liability
This software is provided without warranty of any kind,
either express or implied.
In no event shall the author be liable for any damages
arising out of the use of this software.
4. Termination of Agreement
If you violate any of the terms of this agreement,
the license will automatically terminate.
───────────────────────────────────────────────────────────────
PLEASE READ THE ABOVE TERMS CAREFULLY AND CLICK "ACCEPT"
TO AGREE AND FOLLOW THIS AGREEMENT.
───────────────────────────────────────────────────────────────
`)
var acceptBtn, rejectBtn string
if m.agreementIdx == 0 {
rejectBtn = selectedButton.Render(">> Reject <<")
acceptBtn = " Accept "
} else {
rejectBtn = " Reject "
acceptBtn = selectedButton.Render(">> Accept <<")
}
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("Use Up/Down OR Tab Change,Enter Confirm")
return lipgloss.JoinVertical(lipgloss.Center,
title, "",
agreement, "",
buttonGroup, "",
// debugInfo, "", // ✅ 显示调试信息
hint,
)
}
// dataView 数据接收页面
func (m model) dataView() string {
title := titleStyle.Render("Cluster Information")
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("Use Up/Down OR Tab Change,Enter Confirm")
return lipgloss.JoinVertical(lipgloss.Center,
title, "",
inputs.String(), "",
buttons, "",
hint,
)
}
// publicNetworkView 公网设置页面
func (m model) publicNetworkView() string {
title := titleStyle.Render("Public Network Configuration")
networkInterfaces := getNetworkInterfaces()
autoDetect := infoStyle.Render(
"[*] Auto Detect Network Interfaces: " + strings.Join(networkInterfaces, ", "))
var inputs strings.Builder
for i, ti := range m.textInputs {
info := fmt.Sprintf("%-20s|", m.inputLabels[i])
input := inputBox.Render(info + ti.View())
inputs.WriteString(input + "\n")
}
buttons := m.renderNavButtons()
hint := hintStyle.Render("Use Up/Down OR Tab Change,Enter Confirm")
return lipgloss.JoinVertical(lipgloss.Center,
title, "",
autoDetect, "",
inputs.String(), "",
buttons, "",
hint,
)
}
// internalNetworkView 内网配置页面
func (m model) internalNetworkView() string {
title := titleStyle.Render("Internal Network Configuration")
networkInterfaces := getNetworkInterfaces()
autoDetect := infoStyle.Render(
"[*] Auto Detect Network Interfaces: " + strings.Join(networkInterfaces, ", "))
var inputs strings.Builder
for i, ti := range m.textInputs {
info := fmt.Sprintf("%-20s|", m.inputLabels[i])
input := inputBox.Render(info + ti.View())
inputs.WriteString(input + "\n")
}
buttons := m.renderNavButtons()
hint := hintStyle.Render("Use Up/Down OR Tab Change,Enter Confirm")
return lipgloss.JoinVertical(lipgloss.Center,
title, "",
autoDetect, "",
inputs.String(), "",
buttons, "",
hint,
)
}
// dnsView DNS 配置页面
func (m model) dnsView() string {
title := titleStyle.Render("DNS Configuration")
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("Use Up/Down OR Tab Change,Enter Confirm")
return lipgloss.JoinVertical(lipgloss.Center,
title, "",
inputs.String(), "",
buttons, "",
hint,
)
}
// summaryView 总结页面
func (m model) summaryView() string {
title := titleStyle.Render("Summary")
subtitle := subTitleStyle.Render("Please confirm the following configuration information")
summary := summaryBox.Render(fmt.Sprintf(`
+----------------------------------------------------+
Basic Information
+----------------------------------------------------+
Homepage : %-38s
Hostname : %-35s
Country : %-31s
Region : %-31s
Timezone : %-38s
Homepage : %-38s
+----------------------------------------------------+
Database Configuration
+----------------------------------------------------+
Database Path : %-38s
Software : %-33s
+----------------------------------------------------+
Public Network Configuration
+----------------------------------------------------+
Public Interface : %-38s
Public IP : %-41s
Public Netmask : %-38s
Public Gateway : %-38s
+----------------------------------------------------+
Internal Network Configuration
+----------------------------------------------------+
Internal Interface: %-38s
Internal IP : %-41s
Internal Netmask : %-38s
+----------------------------------------------------+
DNS Configuration
+----------------------------------------------------+
Primary DNS : %-37s
Secondary DNS : %-37s
+----------------------------------------------------+
`,
m.config.HomePage,
m.config.Hostname,
m.config.Country,
m.config.Region,
m.config.Timezone,
m.config.HomePage,
m.config.DBAddress,
m.config.DataAddress,
m.config.PublicInterface,
m.config.PublicIPAddress,
m.config.PublicNetmask,
m.config.PublicGateway,
m.config.InternalInterface,
m.config.InternalIPAddress,
m.config.InternalNetmask,
m.config.DNSPrimary,
m.config.DNSSecondary,
))
var buttons string
if m.focusIndex == 0 {
buttons = selectedButton.Render("[>] Start Initialization") + " " + normalButton.Render("[ ] Cancel")
} else {
buttons = normalButton.Render("[>] Start Initialization") + " " + selectedButton.Render("[ ] Cancel")
}
hint := hintStyle.Render("Use Up/Down OR Tab Change,Enter Confirm")
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{"License", "Data", "Network", "Network", "DNS", "Summary"}
label := labelStyle.Render(labels[current])
return progressStyle.Render(progress) + " " + label
}
// successView 成功视图
func successView() string {
return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center,
successTitle.Render("Initialization Completed!"), "",
successMsg.Render("System configuration has been saved, and the system is initializing..."), "",
hintStyle.Render("Press any key to exit"),
))
}
// quitView 退出视图
func quitView() string {
return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center,
errorTitle.Render("Canceled"), "",
errorMsg.Render("Initialization canceled, no configuration saved"),
))
}
// errorView 错误视图
func errorView(err error) string {
return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Center,
errorTitle.Render("Error"), "",
errorMsg.Render(err.Error()), "",
hintStyle.Render("Press Ctrl+C to exit"),
))
}
// navButtons 导航按钮
func navButtons(m model, prev, next string) string {
var btns string
if m.currentPage == 0 {
btns = normalButton.Render(next) + " " + selectedButton.Render(prev)
} else {
btns = selectedButton.Render(next) + " " + normalButton.Render(prev)
}
return btns
}
func (m model) renderNavButtons() string {
var prevBtn, nextBtn string
switch m.focusType {
case FocusTypePrev:
// 焦点在"上一步"
nextBtn = normalButton.Render(" Next ")
prevBtn = selectedButton.Render(" << Prev >>")
case FocusTypeNext:
// 焦点在"下一步"
nextBtn = selectedButton.Render(" << Next >>")
prevBtn = normalButton.Render(" Prev ")
default:
// 焦点在输入框
nextBtn = normalButton.Render(" Next ")
prevBtn = normalButton.Render(" Prev ")
}
return lipgloss.JoinHorizontal(
lipgloss.Center,
nextBtn,
" ",
prevBtn,
)
}

97
pkg/wizard/styles.go Normal file
View File

@@ -0,0 +1,97 @@
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().
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)

46
pkg/wizard/wizard.go Normal file
View File

@@ -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
}