浏览代码

feat: 1) ListGroups返回组内主机详细信息 2) 前端刷新间隔改为5分钟 3) 命令执行和Playbook页面主机组支持展开显示组内主机

Hermes Agent 1 周之前
当前提交
9c1f44e91a

+ 213 - 0
README.md

@@ -0,0 +1,213 @@
+# Ansible批量部署工具
+
+基于 Go + Ansible 的批量运维部署系统,支持批量执行命令、Playbook管理、主机分组等功能。
+
+## 功能特性
+
+- 🌐 **Web界面** - 简洁美观的Web管理界面
+- 🖥️ **主机管理** - 支持添加、删除、编辑主机
+- 📁 **分组管理** - 灵活的主机组管理
+- 💻 **命令执行** - 批量并行/串行执行命令
+- 📜 **Playbook管理** - 预置Playbook模板,支持快速执行
+- 📊 **任务跟踪** - 实时任务进度监控
+- 🔄 **自动刷新** - 数据自动同步
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+# 安装Go
+curl -fsSL https://go.dev/dl/go1.21.linux-amd64.tar.gz | tar -C /usr/local -xzf -
+
+# 安装Ansible
+pip install ansible
+
+# 安装SSH
+apt install openssh-client  # Debian/Ubuntu
+yum install openssh-clients  # CentOS/RHEL
+```
+
+### 2. 构建项目
+
+```bash
+cd /root/ansible-deploy
+go mod tidy
+go build -o ansible-deploy cmd/main.go
+```
+
+### 3. 配置SSH免密登录
+
+```bash
+# 生成SSH密钥
+ssh-keygen -t rsa
+
+# 复制到目标主机
+ssh-copy-id user@hostname
+```
+
+### 4. 启动服务
+
+```bash
+# 默认端口8080
+./ansible-deploy
+
+# 自定义端口
+./ansible-deploy -port 9000
+
+# 自定义配置
+./ansible-deploy -config /path/to/config.yaml
+```
+
+### 5. 访问Web界面
+
+打开浏览器访问: `http://localhost:8080`
+
+## 配置说明
+
+配置文件位于 `config/config.yaml`:
+
+```yaml
+# Ansible路径
+ansible_path: /usr/bin/ansible
+
+# 资产清单目录
+inventory_dir: ~/ansible-deploy/inventory
+
+# Playbook目录
+playbook_dir: ~/ansible-deploy/playbooks
+
+# 日志目录
+log_dir: ~/ansible-deploy/logs
+
+# SSH超时(秒)
+ssh_timeout: 30
+
+# 最大并发数
+max_parallelism: 10
+```
+
+## API接口
+
+### 主机管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/hosts` | 获取主机列表 |
+| POST | `/api/hosts` | 添加主机 |
+| PUT | `/api/hosts/:id` | 更新主机 |
+| DELETE | `/api/hosts/:id` | 删除主机 |
+| POST | `/api/hosts/test/:id` | 测试连接 |
+
+### 主机组
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/groups` | 获取组列表 |
+| POST | `/api/groups` | 创建组 |
+| PUT | `/api/groups/:name` | 更新组 |
+| DELETE | `/api/groups/:name` | 删除组 |
+
+### 命令执行
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/command/execute` | 执行命令 |
+| POST | `/api/command/batch` | 批量执行 |
+
+### Playbook
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/playbooks` | 列出Playbook |
+| POST | `/api/playbooks/execute` | 执行Playbook |
+
+### 任务
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/tasks` | 获取任务列表 |
+| GET | `/api/tasks/:id` | 获取任务详情 |
+| DELETE | `/api/tasks/:id` | 取消任务 |
+
+## 使用示例
+
+### 添加主机
+
+```bash
+curl -X POST http://localhost:8080/api/hosts \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "web-server-01",
+    "ip": "192.168.1.100",
+    "port": 22,
+    "username": "root",
+    "password": "your-password",
+    "groups": ["webservers"]
+  }'
+```
+
+### 批量执行命令
+
+```bash
+curl -X POST http://localhost:8080/api/command/batch \
+  -H "Content-Type: application/json" \
+  -d '{
+    "hosts": ["web1", "web2", "web3"],
+    "command": "df -h",
+    "parallel": true
+  }'
+```
+
+### 执行Playbook
+
+```bash
+curl -X POST http://localhost:8080/api/playbooks/execute \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "update-packages",
+    "hosts": ["all"],
+    "extra_vars": {}
+  }'
+```
+
+## 预置Playbook
+
+| 文件 | 说明 |
+|------|------|
+| `update-packages.yml` | 更新系统包 |
+| `deploy-docker.yml` | 安装Docker |
+| `deploy-nginx.yml` | 部署Nginx |
+| `check-system.yml` | 系统信息检查 |
+
+## 目录结构
+
+```
+ansible-deploy/
+├── cmd/
+│   └── main.go              # 主程序入口
+├── config/
+│   └── config.yaml          # 配置文件
+├── internal/
+│   ├── handlers/           # HTTP处理器
+│   ├── models/              # 数据模型
+│   └── services/            # 业务逻辑
+├── web/
+│   └── dist/
+│       └── index.html       # 前端页面
+├── playbooks/               # Playbook目录
+├── scripts/
+│   └── install.sh          # 安装脚本
+└── README.md
+```
+
+## 注意事项
+
+1. **SSH免密** - 建议配置SSH密钥对实现免密登录
+2. **权限** - 部分操作需要sudo权限,确保用户有sudo权限
+3. **防火墙** - 确保SSH端口开放
+4. **Python** - Ansible需要目标主机安装Python
+
+## License
+
+MIT License

二进制
ansible-deploy


+ 110 - 0
cmd/main.go

@@ -0,0 +1,110 @@
+package main
+
+import (
+	"flag"
+	"log"
+	"os"
+
+	"github.com/ansible-deploy/internal/handlers"
+	"github.com/ansible-deploy/internal/services"
+	"github.com/gin-gonic/gin"
+)
+
+var (
+	configPath string
+	port      string
+)
+
+func init() {
+	flag.StringVar(&configPath, "config", "config/config.yaml", "配置文件路径")
+	flag.StringVar(&port, "port", "8080", "服务端口")
+}
+
+func main() {
+	flag.Parse()
+
+	// 加载配置
+	cfg, err := services.LoadConfig(configPath)
+	if err != nil {
+		log.Printf("配置加载失败: %v,使用默认配置", err)
+		cfg = services.DefaultConfig()
+	}
+
+	// 初始化Ansible服务
+	ansibleService := services.NewAnsibleService(cfg)
+
+	// 初始化处理器
+	h := handlers.NewAnsibleHandler(ansibleService)
+
+	// 初始化Web服务
+	r := gin.Default()
+
+	// CORS配置
+	r.Use(func(c *gin.Context) {
+		c.Header("Access-Control-Allow-Origin", "*")
+		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+		if c.Request.Method == "OPTIONS" {
+			c.AbortWithStatus(204)
+			return
+		}
+		c.Next()
+	})
+
+	// 静态文件
+	r.Static("/static", "./web/dist")
+	r.Static("/assets", "./web/dist/assets")
+
+	// API路由
+	api := r.Group("/api")
+	{
+		// 主机管理
+		api.GET("/hosts", h.ListHosts)
+		api.POST("/hosts", h.AddHost)
+		api.DELETE("/hosts/:id", h.DeleteHost)
+		api.PUT("/hosts/:id", h.UpdateHost)
+		api.POST("/hosts/test/:id", h.TestConnection)
+
+		// 主机组管理
+		api.GET("/groups", h.ListGroups)
+		api.POST("/groups", h.CreateGroup)
+		api.DELETE("/groups/:name", h.DeleteGroup)
+		api.PUT("/groups/:name", h.UpdateGroup)
+
+		// Playbook管理
+		api.GET("/playbooks", h.ListPlaybooks)
+		api.POST("/playbooks", h.CreatePlaybook)
+		api.GET("/playbooks/:name/content", h.GetPlaybookContent)
+		api.PUT("/playbooks/:name", h.UpdatePlaybook)
+		api.DELETE("/playbooks/:name", h.DeletePlaybook)
+		api.POST("/playbooks/execute", h.ExecutePlaybook)
+		api.GET("/playbooks/:name", h.GetPlaybook)
+
+		// 命令执行
+		api.POST("/command/execute", h.ExecuteCommand)
+		api.POST("/command/batch", h.BatchExecute)
+
+		// 任务执行
+		api.GET("/tasks", h.ListTasks)
+		api.GET("/tasks/:id", h.GetTask)
+		api.DELETE("/tasks/:id", h.CancelTask)
+	}
+
+	// 前端路由 - 禁止缓存确保始终返回最新版本
+	r.GET("/", func(c *gin.Context) {
+		c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
+		c.Header("Pragma", "no-cache")
+		c.Header("Expires", "0")
+		c.File("./web/dist/index.html")
+	})
+
+	// 创建必要目录
+	os.MkdirAll(cfg.InventoryDir, 0755)
+	os.MkdirAll(cfg.PlaybookDir, 0755)
+	os.MkdirAll(cfg.LogDir, 0755)
+
+	log.Printf("Ansible部署工具启动,监听端口: %s", port)
+	if err := r.Run(":" + port); err != nil {
+		log.Fatalf("服务启动失败: %v", err)
+	}
+}

+ 30 - 0
config/config.yaml

@@ -0,0 +1,30 @@
+# Ansible Deploy 配置文件
+
+# Ansible安装路径
+ansible_path: /usr/bin/ansible
+
+# 资产清单目录
+inventory_dir: /root/ansible-deploy/inventory
+
+# Playbook目录
+playbook_dir: /root/ansible-deploy/playbooks
+
+# 日志目录
+log_dir: /root/ansible-deploy/logs
+
+# SSH连接超时时间(秒)
+ssh_timeout: 30
+
+# 最大并发数
+max_parallelism: 10
+
+# 输出格式 (json, yaml, plain)
+callback_plugin: json
+
+# SSH连接选项
+ssh_options:
+  strict_host_key_checking: no
+  user_known_hosts_file: /dev/null
+  connect_timeout: 10
+  password_authentication: yes
+  key_authentication: yes

+ 34 - 0
go.mod

@@ -0,0 +1,34 @@
+module github.com/ansible-deploy
+
+go 1.21
+
+require (
+	github.com/gin-gonic/gin v1.9.1
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	github.com/bytedance/sonic v1.9.1 // indirect
+	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.14.0 // indirect
+	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
+	github.com/leodido/go-urn v1.2.4 // indirect
+	github.com/mattn/go-isatty v0.0.19 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.2.11 // indirect
+	golang.org/x/arch v0.3.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
+	golang.org/x/net v0.17.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/text v0.13.0 // indirect
+	google.golang.org/protobuf v1.30.0 // indirect
+)

+ 86 - 0
go.sum

@@ -0,0 +1,86 @@
+github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
+github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
+github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
+github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
+github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
+github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
+github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
+github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
+github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
+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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
+github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
+github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
+golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 456 - 0
internal/handlers/handlers.go

@@ -0,0 +1,456 @@
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/ansible-deploy/internal/models"
+	"github.com/ansible-deploy/internal/services"
+	"github.com/gin-gonic/gin"
+)
+
+// AnsibleHandler API处理器
+type AnsibleHandler struct {
+	service *services.AnsibleService
+}
+
+// NewAnsibleHandler 创建处理器
+func NewAnsibleHandler(svc *services.AnsibleService) *AnsibleHandler {
+	return &AnsibleHandler{service: svc}
+}
+
+// ListHosts 获取主机列表
+func (h *AnsibleHandler) ListHosts(c *gin.Context) {
+	hosts := h.service.ListHosts()
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": hosts,
+	})
+}
+
+// AddHost 添加主机
+func (h *AnsibleHandler) AddHost(c *gin.Context) {
+	var host models.Host
+	if err := c.ShouldBindJSON(&host); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误: " + err.Error(),
+		})
+		return
+	}
+
+	if err := h.service.AddHost(host); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "主机添加成功",
+	})
+}
+
+// DeleteHost 删除主机
+func (h *AnsibleHandler) DeleteHost(c *gin.Context) {
+	id := c.Param("id")
+	if err := h.service.DeleteHost(id); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "主机删除成功",
+	})
+}
+
+// UpdateHost 更新主机
+func (h *AnsibleHandler) UpdateHost(c *gin.Context) {
+	id := c.Param("id")
+	var host models.Host
+	if err := c.ShouldBindJSON(&host); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误",
+		})
+		return
+	}
+
+	if err := h.service.UpdateHost(id, host); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "主机更新成功",
+	})
+}
+
+// TestConnection 测试连接
+func (h *AnsibleHandler) TestConnection(c *gin.Context) {
+	id := c.Param("id")
+	result, err := h.service.TestConnection(id)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": result,
+	})
+}
+
+// ListGroups 获取组列表
+func (h *AnsibleHandler) ListGroups(c *gin.Context) {
+	groups := h.service.ListGroups()
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": groups,
+	})
+}
+
+// CreateGroup 创建组
+func (h *AnsibleHandler) CreateGroup(c *gin.Context) {
+	var group models.HostGroup
+	if err := c.ShouldBindJSON(&group); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误",
+		})
+		return
+	}
+
+	if err := h.service.CreateGroup(group); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "组创建成功",
+	})
+}
+
+// DeleteGroup 删除组
+func (h *AnsibleHandler) DeleteGroup(c *gin.Context) {
+	name := c.Param("name")
+	if err := h.service.DeleteGroup(name); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "组删除成功",
+	})
+}
+
+// UpdateGroup 更新组
+func (h *AnsibleHandler) UpdateGroup(c *gin.Context) {
+	name := c.Param("name")
+	var group models.HostGroup
+	if err := c.ShouldBindJSON(&group); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误",
+		})
+		return
+	}
+
+	if err := h.service.UpdateGroup(name, group); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "组更新成功",
+	})
+}
+
+// ListPlaybooks 列出Playbooks
+func (h *AnsibleHandler) ListPlaybooks(c *gin.Context) {
+	playbooks := h.service.ListPlaybooks()
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": playbooks,
+	})
+}
+
+// GetPlaybook 获取Playbook详情
+func (h *AnsibleHandler) GetPlaybook(c *gin.Context) {
+	name := c.Param("name")
+	playbook, err := h.service.GetPlaybook(name)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"code": 404,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": playbook,
+	})
+}
+
+// ExecutePlaybook 执行Playbook
+func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) {
+	var req models.PlaybookExecutionRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误: " + err.Error(),
+		})
+		return
+	}
+
+	task, err := h.service.ExecutePlaybook(req)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code":   0,
+		"msg":    "任务已启动",
+		"taskId": task.ID,
+	})
+}
+
+// ExecuteCommand 执行命令
+func (h *AnsibleHandler) ExecuteCommand(c *gin.Context) {
+	var req models.CommandRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误: " + err.Error(),
+		})
+		return
+	}
+
+	results, err := h.service.ExecuteCommand(req)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": results,
+	})
+}
+
+// BatchExecute 批量执行
+func (h *AnsibleHandler) BatchExecute(c *gin.Context) {
+	var req models.CommandRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误",
+		})
+		return
+	}
+
+	result := h.service.BatchExecute(req)
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "任务已启动",
+		"data": result,
+	})
+}
+
+// ListTasks 获取任务列表
+func (h *AnsibleHandler) ListTasks(c *gin.Context) {
+	tasks := h.service.ListTasks()
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": tasks,
+	})
+}
+
+// GetTask 获取任务详情
+func (h *AnsibleHandler) GetTask(c *gin.Context) {
+	id := c.Param("id")
+	task := h.service.GetTask(id)
+	if task == nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"code": 404,
+			"msg":  "任务不存在",
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": task,
+	})
+}
+
+// CancelTask 取消任务
+func (h *AnsibleHandler) CancelTask(c *gin.Context) {
+	id := c.Param("id")
+	if err := h.service.CancelTask(id); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "任务已取消",
+	})
+}
+
+// CreatePlaybook 创建Playbook
+func (h *AnsibleHandler) CreatePlaybook(c *gin.Context) {
+	var req struct {
+		Name    string `json:"name" binding:"required"`
+		Content string `json:"content"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误: " + err.Error(),
+		})
+		return
+	}
+
+	if req.Content == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "Playbook内容不能为空",
+		})
+		return
+	}
+
+	if err := h.service.CreatePlaybook(req.Name, req.Content); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "Playbook创建成功",
+	})
+}
+
+// DeletePlaybook 删除Playbook
+func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) {
+	name := c.Param("name")
+	if err := h.service.DeletePlaybook(name); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "Playbook删除成功",
+	})
+}
+
+// GetPlaybookContent 获取Playbook内容
+func (h *AnsibleHandler) GetPlaybookContent(c *gin.Context) {
+	name := c.Param("name")
+	content, err := h.service.GetPlaybookContent(name)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"code": 404,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "success",
+		"data": content,
+	})
+}
+
+// UpdatePlaybook 更新Playbook
+func (h *AnsibleHandler) UpdatePlaybook(c *gin.Context) {
+	name := c.Param("name")
+	var req struct {
+		Content string `json:"content" binding:"required"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"code": 400,
+			"msg":  "参数错误",
+		})
+		return
+	}
+
+	if err := h.service.UpdatePlaybook(name, req.Content); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"code": 500,
+			"msg":  err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "Playbook更新成功",
+	})
+}
+
+// WebSocketLogs WebSocket日志
+func (h *AnsibleHandler) WebSocketLogs(c *gin.Context) {
+	taskID := c.Param("taskId")
+	_ = taskID
+	// WebSocket实现需要单独处理,这里返回提示
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0,
+		"msg":  "WebSocket连接",
+	})
+}

+ 132 - 0
internal/models/models.go

@@ -0,0 +1,132 @@
+package models
+
+import "time"
+
+// Host 主机信息
+type Host struct {
+	ID        string   `json:"id"`
+	Name      string   `json:"name"`
+	IP        string   `json:"ip"`
+	Port      int      `json:"port"`
+	Username    string   `json:"username"`
+	Password    string   `json:"password,omitempty"`
+	SSHKey      string   `json:"ssh_key,omitempty"`
+	AuthType    string   `json:"auth_type,omitempty"` // password 或 sshkey
+	Groups    []string `json:"groups"`
+	Vars      map[string]string `json:"vars,omitempty"`
+	Status    string   `json:"status"`
+	LastCheck time.Time `json:"last_check,omitempty"`
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// HostGroup 主机组
+type HostGroup struct {
+	Name        string   `json:"name"`
+	Description string   `json:"description"`
+	Hosts       []string `json:"hosts"`
+	HostList    []Host   `json:"host_list,omitempty"` // 组内主机的详细信息
+	Vars        map[string]string `json:"vars,omitempty"`
+	Children    []string `json:"children,omitempty"`
+}
+
+// Inventory 资产清单
+type Inventory struct {
+	All      *InventoryGroup `yaml:"all"`
+	Ungrouped *InventoryGroup `yaml:"ungrouped,omitempty"`
+}
+
+// InventoryGroup 资产组
+type InventoryGroup struct {
+	Children map[string]*InventoryGroup `yaml:"children,omitempty"`
+	Hosts    map[string]Host `yaml:"hosts,omitempty"`
+	Vars     map[string]interface{} `yaml:"vars,omitempty"`
+}
+
+// Playbook Playbook定义
+type Playbook struct {
+	Name        string `json:"name"`
+	Path        string `json:"path"`
+	Description string `json:"description"`
+	Variables   map[string]interface{} `json:"variables,omitempty"`
+	Hosts       string `json:"hosts"`
+	Tasks       []Task `json:"tasks"`
+}
+
+// Task 任务定义
+type Task struct {
+	Name    string                 `json:"name"`
+	Module  string                 `json:"module"`
+	Args    map[string]interface{} `json:"args,omitempty"`
+	When    string                 `json:"when,omitempty"`
+	Loop    []interface{}          `json:"loop,omitempty"`
+	LoopVar string                 `json:"loop_var,omitempty"`
+}
+
+// TaskExecution 任务执行记录
+type TaskExecution struct {
+	ID          string    `json:"id"`
+	Name        string    `json:"name"`
+	Playbook    string    `json:"playbook"`
+	Hosts       []string  `json:"hosts"`
+	Status      string    `json:"status"` // pending, running, success, failed, cancelled
+	StartTime   time.Time `json:"start_time"`
+	EndTime     time.Time `json:"end_time,omitempty"`
+	Progress    int       `json:"progress"`
+	TotalHosts  int       `json:"total_hosts"`
+	SuccessHosts int      `json:"success_hosts"`
+	FailedHosts  int      `json:"failed_hosts"`
+	Output      string    `json:"output,omitempty"`
+	Error       string    `json:"error,omitempty"`
+}
+
+// CommandRequest 命令执行请求
+type CommandRequest struct {
+	Hosts     []string `json:"hosts" binding:"required"`
+	Command   string   `json:"command" binding:"required"`
+	Parallel  bool     `json:"parallel"`
+	Timeout   int      `json:"timeout"`
+}
+
+// CommandResult 命令执行结果
+type CommandResult struct {
+	Host    string `json:"host"`
+	Success bool   `json:"success"`
+	Output  string `json:"output"`
+	Error   string `json:"error,omitempty"`
+	ExitCode int   `json:"exit_code"`
+	Duration int64 `json:"duration_ms"`
+}
+
+// BatchCommandResult 批量命令结果
+type BatchCommandResult struct {
+	TaskID   string          `json:"task_id"`
+	Total    int             `json:"total"`
+	Success  int             `json:"success"`
+	Failed   int             `json:"failed"`
+	Results  []CommandResult `json:"results"`
+}
+
+// PlaybookExecutionRequest Playbook执行请求
+type PlaybookExecutionRequest struct {
+	Name       string                 `json:"name" binding:"required"`
+	Hosts      []string               `json:"hosts"`
+	ExtraVars  map[string]interface{} `json:"extra_vars"`
+	Tags       []string               `json:"tags,omitempty"`        // 只执行指定tags
+	SkipTags   []string               `json:"skip_tags,omitempty"`   // 跳过指定tags
+	Verbose    string                 `json:"verbose,omitempty"`     // v, vv, vvv, vvvv
+	Diff       bool                   `json:"diff,omitempty"`        // 显示文件差异
+	Check      bool                   `json:"check,omitempty"`       // dry-run模式
+	Become     *bool                  `json:"become,omitempty"`      // 是否提权,nil表示使用playbook默认
+	Forks      int                    `json:"forks,omitempty"`       // 并发数
+	Timeout    int                    `json:"timeout,omitempty"`     // 超时(秒)
+	ExtraArgs  string                 `json:"extra_args,omitempty"`  // 自定义额外参数
+}
+
+// LogEntry 日志条目
+type LogEntry struct {
+	Time    string `json:"time"`
+	Level   string `json:"level"`
+	Host    string `json:"host"`
+	Message string `json:"message"`
+}

+ 893 - 0
internal/services/ansible.go

@@ -0,0 +1,893 @@
+package services
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/ansible-deploy/internal/models"
+	"gopkg.in/yaml.v3"
+)
+
+// AnsibleService Ansible服务
+type AnsibleService struct {
+	config      *Config
+	hosts       map[string]*models.Host
+	groups      map[string]*models.HostGroup
+	inventoryPath string
+	tasks       map[string]*models.TaskExecution
+	taskLock    sync.RWMutex
+}
+
+// NewAnsibleService 创建Ansible服务
+func NewAnsibleService(cfg *Config) *AnsibleService {
+	svc := &AnsibleService{
+		config:      cfg,
+		hosts:       make(map[string]*models.Host),
+		groups:      make(map[string]*models.HostGroup),
+		inventoryPath: filepath.Join(cfg.InventoryDir, "hosts"),
+		tasks:       make(map[string]*models.TaskExecution),
+	}
+
+	// 初始化默认组
+	svc.groups["all"] = &models.HostGroup{Name: "all", Description: "所有主机"}
+	svc.groups["ungrouped"] = &models.HostGroup{Name: "ungrouped", Description: "未分组主机"}
+
+	// 加载现有数据
+	svc.loadHosts()
+
+	return svc
+}
+
+// generateID 生成唯一ID
+func (s *AnsibleService) generateID() string {
+	hash := md5.New()
+	hash.Write([]byte(time.Now().String()))
+	return hex.EncodeToString(hash.Sum(nil))[:8]
+}
+
+// loadInventory 加载资产清单
+func (s *AnsibleService) loadInventory() {
+	invFile := filepath.Join(s.config.InventoryDir, "hosts")
+	data, err := os.ReadFile(invFile)
+	if err != nil {
+		return
+	}
+
+	// 解析INI格式的inventory
+	scanner := bufio.NewScanner(bytes.NewReader(data))
+	var currentGroup string
+	groupVars := make(map[string]map[string]string)
+
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		// 组定义
+		if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+			currentGroup = strings.Trim(line, "[]")
+			continue
+		}
+
+		// 变量定义
+		if strings.Contains(line, "=") {
+			parts := strings.SplitN(line, "=", 2)
+			if len(parts) == 2 {
+				if groupVars[currentGroup] == nil {
+					groupVars[currentGroup] = make(map[string]string)
+				}
+				groupVars[currentGroup][strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
+			}
+		}
+
+		// 主机定义
+		if strings.Contains(line, "ansible_host") {
+			re := regexp.MustCompile(`(\S+)\s+ansible_host=(\S+)`)
+			if matches := re.FindStringSubmatch(line); len(matches) == 3 {
+				host := &models.Host{
+					ID:    s.generateID(),
+					Name:  matches[1],
+					IP:    matches[2],
+					Status: "unknown",
+				}
+				s.hosts[host.ID] = host
+			}
+		}
+	}
+}
+
+// loadHosts 加载主机列表
+func (s *AnsibleService) loadHosts() {
+	// 从hosts.json加载详细配置(唯一数据源)
+	hostsFile := filepath.Join(s.config.InventoryDir, "hosts.json")
+	data, err := os.ReadFile(hostsFile)
+	if err != nil {
+		return
+	}
+
+	var hosts []models.Host
+	if err := json.Unmarshal(data, &hosts); err == nil {
+		for _, h := range hosts {
+			hcopy := h
+			if hcopy.ID == "" {
+				hcopy.ID = s.generateID()
+			}
+			if hcopy.Port == 0 {
+				hcopy.Port = 22
+			}
+			if hcopy.Username == "" {
+				hcopy.Username = "root"
+			}
+			if hcopy.Status == "" {
+				hcopy.Status = "pending"
+			}
+			s.hosts[hcopy.ID] = &hcopy
+		}
+		// 保存以持久化补全的字段
+		s.saveHosts()
+	}
+}
+
+// saveHosts 保存主机列表
+func (s *AnsibleService) saveHosts() error {
+	hostsFile := filepath.Join(s.config.InventoryDir, "hosts.json")
+	var hosts []models.Host
+	for _, h := range s.hosts {
+		hosts = append(hosts, *h)
+	}
+
+	data, _ := json.MarshalIndent(hosts, "", "  ")
+	if err := os.WriteFile(hostsFile, data, 0644); err != nil {
+		return err
+	}
+
+	// 更新inventory文件
+	s.updateInventoryFile()
+	return nil
+}
+
+// updateInventoryFile 更新inventory文件
+func (s *AnsibleService) updateInventoryFile() {
+	var lines []string
+	lines = append(lines, "# Ansible Inventory File")
+	lines = append(lines, "# Generated by ansible-deploy")
+	lines = append(lines, "")
+
+	// 按组分组主机
+	groupedHosts := make(map[string][]models.Host)
+	for _, h := range s.hosts {
+		if len(h.Groups) == 0 {
+			groupedHosts["ungrouped"] = append(groupedHosts["ungrouped"], *h)
+		} else {
+			for _, g := range h.Groups {
+				groupedHosts[g] = append(groupedHosts[g], *h)
+			}
+		}
+	}
+
+	// 输出每个组
+	for group, hosts := range groupedHosts {
+		lines = append(lines, fmt.Sprintf("[%s]", group))
+		for _, h := range hosts {
+			line := fmt.Sprintf("  %s ansible_host=%s", h.Name, h.IP)
+			if h.Port != 0 && h.Port != 22 {
+				line += fmt.Sprintf(" ansible_port=%d", h.Port)
+			}
+			if h.Username != "" {
+				line += fmt.Sprintf(" ansible_user=%s", h.Username)
+			}
+			if h.AuthType == "sshkey" && h.SSHKey != "" {
+				line += fmt.Sprintf(" ansible_ssh_private_key_file=%s", h.SSHKey)
+			}
+			lines = append(lines, line)
+		}
+		lines = append(lines, "")
+	}
+
+	invFile := filepath.Join(s.config.InventoryDir, "hosts")
+	os.WriteFile(invFile, []byte(strings.Join(lines, "\n")), 0644)
+}
+
+// ListHosts 获取主机列表
+func (s *AnsibleService) ListHosts() []models.Host {
+	var hosts []models.Host
+	for _, h := range s.hosts {
+		hosts = append(hosts, *h)
+	}
+	return hosts
+}
+
+// AddHost 添加主机
+func (s *AnsibleService) AddHost(host models.Host) error {
+	host.ID = s.generateID()
+	host.CreatedAt = time.Now()
+	host.UpdatedAt = time.Now()
+	host.Status = "pending"
+
+	s.hosts[host.ID] = &host
+	return s.saveHosts()
+}
+
+// DeleteHost 删除主机
+func (s *AnsibleService) DeleteHost(id string) error {
+	if _, ok := s.hosts[id]; !ok {
+		return fmt.Errorf("主机不存在")
+	}
+	delete(s.hosts, id)
+	return s.saveHosts()
+}
+
+// UpdateHost 更新主机
+func (s *AnsibleService) UpdateHost(id string, host models.Host) error {
+	if _, ok := s.hosts[id]; !ok {
+		return fmt.Errorf("主机不存在")
+	}
+	host.UpdatedAt = time.Now()
+	s.hosts[id] = &host
+	return s.saveHosts()
+}
+
+// ListGroups 获取主机组列表
+func (s *AnsibleService) ListGroups() []models.HostGroup {
+	var groups []models.HostGroup
+	for _, g := range s.groups {
+		gcopy := *g
+		// 展开组内主机的详细信息
+		var hostList []models.Host
+		for _, hName := range g.Hosts {
+			for _, h := range s.hosts {
+				if h.Name == hName {
+					hostList = append(hostList, *h)
+					break
+				}
+			}
+		}
+		gcopy.HostList = hostList
+		groups = append(groups, gcopy)
+	}
+	return groups
+}
+
+// CreateGroup 创建主机组
+func (s *AnsibleService) CreateGroup(group models.HostGroup) error {
+	if _, ok := s.groups[group.Name]; ok {
+		return fmt.Errorf("组已存在")
+	}
+	s.groups[group.Name] = &group
+	return s.saveGroups()
+}
+
+// DeleteGroup 删除主机组
+func (s *AnsibleService) DeleteGroup(name string) error {
+	if name == "all" || name == "ungrouped" {
+		return fmt.Errorf("不能删除系统组")
+	}
+	delete(s.groups, name)
+	return s.saveGroups()
+}
+
+// UpdateGroup 更新主机组
+func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error {
+	if _, ok := s.groups[name]; !ok {
+		return fmt.Errorf("组不存在")
+	}
+	s.groups[name] = &group
+	return s.saveGroups()
+}
+
+// saveGroups 保存组信息
+func (s *AnsibleService) saveGroups() error {
+	groupsFile := filepath.Join(s.config.InventoryDir, "groups.json")
+	data, _ := json.MarshalIndent(s.groups, "", "  ")
+	return os.WriteFile(groupsFile, data, 0644)
+}
+
+// TestConnection 测试主机连接
+func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, error) {
+	host, ok := s.hosts[hostID]
+	if !ok {
+		return nil, fmt.Errorf("主机不存在")
+	}
+
+	start := time.Now()
+	result := &models.CommandResult{
+		Host:    host.Name,
+		Success: false,
+	}
+
+	// 构建ansible命令
+	args := []string{
+		host.Name,
+		"-i", s.inventoryPath,
+		"-m", "ping",
+		"-u", host.Username,
+	}
+
+	// 认证方式:SSH Key 或 密码
+	if host.AuthType == "sshkey" && host.SSHKey != "" {
+		// SSH Key 认证
+		args = append(args, "--private-key", host.SSHKey)
+	} else if host.Password != "" {
+		// 密码认证
+		args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
+	}
+
+	// 如果端口不是22,通过extra-vars传递
+	if host.Port != 0 && host.Port != 22 {
+		args = append(args, "--extra-vars", fmt.Sprintf("ansible_port=%d", host.Port))
+	}
+
+	cmd := exec.Command(s.config.AnsiblePath, args...)
+	// 通过环境变量禁用SSH主机密钥检查
+	cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
+	output, err := cmd.CombinedOutput()
+	result.Duration = time.Since(start).Milliseconds()
+	result.Output = string(output)
+
+	if err != nil {
+		result.Error = err.Error()
+		host.Status = "offline"
+	} else {
+		result.Success = true
+		if strings.Contains(string(output), "SUCCESS") || strings.Contains(string(output), "pong") {
+			host.Status = "online"
+		} else {
+			host.Status = "offline"
+		}
+	}
+	host.LastCheck = time.Now()
+
+	// 持久化状态
+	s.saveHosts()
+
+	return result, nil
+}
+
+// ExecuteCommand 执行单个命令
+func (s *AnsibleService) ExecuteCommand(req models.CommandRequest) ([]models.CommandResult, error) {
+	var results []models.CommandResult
+
+	for _, hostName := range req.Hosts {
+		result := s.runCommand(hostName, req.Command, req.Timeout)
+		results = append(results, result)
+	}
+
+	return results, nil
+}
+
+// runCommand 在主机上执行命令
+func (s *AnsibleService) runCommand(hostName string, command string, timeout int) models.CommandResult {
+	start := time.Now()
+	result := models.CommandResult{
+		Host:    hostName,
+		Success: false,
+	}
+
+	// 查找主机获取认证信息
+	var host *models.Host
+	for _, h := range s.hosts {
+		if h.Name == hostName {
+			host = h
+			break
+		}
+	}
+	if host == nil {
+		result.Error = "主机不存在"
+		return result
+	}
+
+	if timeout == 0 {
+		timeout = s.config.SSHTimeout
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
+	defer cancel()
+
+	args := []string{
+		host.Name,
+		"-i", s.inventoryPath,
+		"-m", "shell",
+		"-a", command,
+		"-u", host.Username,
+	}
+
+	// 认证方式:SSH Key 或 密码
+	if host.AuthType == "sshkey" && host.SSHKey != "" {
+		args = append(args, "--private-key", host.SSHKey)
+	} else if host.Password != "" {
+		args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
+	}
+
+	// 如果端口不是22,通过extra-vars传递
+	if host.Port != 0 && host.Port != 22 {
+		args = append(args, "--extra-vars", fmt.Sprintf("ansible_port=%d", host.Port))
+	}
+
+	cmd := exec.CommandContext(ctx, s.config.AnsiblePath, args...)
+	// 通过环境变量禁用SSH主机密钥检查
+	cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
+	output, err := cmd.CombinedOutput()
+	result.Duration = time.Since(start).Milliseconds()
+	result.Output = string(output)
+
+	if err != nil {
+		result.Error = err.Error()
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			result.ExitCode = exitErr.ExitCode()
+		}
+	} else {
+		result.Success = true
+		result.ExitCode = 0
+	}
+
+	return result
+}
+
+// BatchExecute 批量执行命令
+func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCommandResult {
+	result := &models.BatchCommandResult{
+		TaskID:  s.generateID(),
+		Total:   len(req.Hosts),
+		Results: make([]models.CommandResult, 0),
+	}
+
+	task := &models.TaskExecution{
+		ID:        result.TaskID,
+		Name:      "批量命令执行",
+		Hosts:     req.Hosts,
+		Status:    "running",
+		StartTime: time.Now(),
+		TotalHosts: len(req.Hosts),
+	}
+
+	s.taskLock.Lock()
+	s.tasks[result.TaskID] = task
+	s.taskLock.Unlock()
+
+	// 并行执行
+	if req.Parallel {
+		var wg sync.WaitGroup
+		results := make(chan models.CommandResult, len(req.Hosts))
+
+		parallelism := s.config.MaxParallelism
+		if parallelism <= 0 {
+			parallelism = 10
+		}
+		semaphore := make(chan struct{}, parallelism)
+
+		for _, host := range req.Hosts {
+			wg.Add(1)
+			go func(h string) {
+				defer wg.Done()
+				semaphore <- struct{}{}
+				defer func() { <-semaphore }()
+
+				r := s.runCommand(h, req.Command, req.Timeout)
+				results <- r
+			}(host)
+		}
+
+		go func() {
+			wg.Wait()
+			close(results)
+		}()
+
+		for r := range results {
+			result.Results = append(result.Results, r)
+			if r.Success {
+				result.Success++
+			} else {
+				result.Failed++
+			}
+			s.updateTaskProgress(result.TaskID, 1)
+		}
+	} else {
+		// 串行执行
+		for _, host := range req.Hosts {
+			r := s.runCommand(host, req.Command, req.Timeout)
+			result.Results = append(result.Results, r)
+			if r.Success {
+				result.Success++
+			} else {
+				result.Failed++
+			}
+			s.updateTaskProgress(result.TaskID, 1)
+		}
+	}
+
+	task.Status = "completed"
+	task.EndTime = time.Now()
+
+	return result
+}
+
+// updateTaskProgress 更新任务进度
+func (s *AnsibleService) updateTaskProgress(taskID string, increment int) {
+	s.taskLock.Lock()
+	defer s.taskLock.Unlock()
+
+	if task, ok := s.tasks[taskID]; ok {
+		task.Progress += increment
+		task.SuccessHosts = task.Progress
+		if task.Progress >= task.TotalHosts {
+			task.Status = "completed"
+			task.EndTime = time.Now()
+		}
+	}
+}
+
+// ListTasks 获取任务列表
+func (s *AnsibleService) ListTasks() []*models.TaskExecution {
+	s.taskLock.RLock()
+	defer s.taskLock.RUnlock()
+
+	var tasks []*models.TaskExecution
+	for _, t := range s.tasks {
+		tasks = append(tasks, t)
+	}
+	return tasks
+}
+
+// GetTask 获取单个任务
+func (s *AnsibleService) GetTask(id string) *models.TaskExecution {
+	s.taskLock.RLock()
+	defer s.taskLock.RUnlock()
+	return s.tasks[id]
+}
+
+// CancelTask 取消任务
+func (s *AnsibleService) CancelTask(id string) error {
+	s.taskLock.Lock()
+	defer s.taskLock.Unlock()
+
+	if task, ok := s.tasks[id]; ok {
+		if task.Status == "running" {
+			task.Status = "cancelled"
+			task.EndTime = time.Now()
+			return nil
+		}
+		return fmt.Errorf("任务无法取消")
+	}
+	return fmt.Errorf("任务不存在")
+}
+
+// ExecutePlaybook 执行Playbook
+func (s *AnsibleService) ExecutePlaybook(req models.PlaybookExecutionRequest) (*models.TaskExecution, error) {
+	playbookPath := filepath.Join(s.config.PlaybookDir, req.Name+".yml")
+
+	if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
+		return nil, fmt.Errorf("Playbook不存在: %s", req.Name)
+	}
+
+	task := &models.TaskExecution{
+		ID:          s.generateID(),
+		Name:        req.Name,
+		Playbook:    playbookPath,
+		Hosts:       req.Hosts,
+		Status:      "running",
+		StartTime:   time.Now(),
+		TotalHosts:  len(req.Hosts),
+		SuccessHosts: 0,
+		FailedHosts: 0,
+	}
+
+	s.taskLock.Lock()
+	s.tasks[task.ID] = task
+	s.taskLock.Unlock()
+
+	// 启动异步执行
+	go s.runPlaybook(task, playbookPath, req)
+
+	return task, nil
+}
+
+// runPlaybook 运行Playbook
+func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath string, req models.PlaybookExecutionRequest) {
+	var args []string
+
+	// 添加inventory
+	args = append(args, "-i", s.inventoryPath)
+
+	// 添加hosts限制
+	if len(req.Hosts) > 0 {
+		args = append(args, "-l", strings.Join(req.Hosts, ","))
+	}
+
+	// 添加extra-vars
+	if len(req.ExtraVars) > 0 {
+		varsJSON, _ := json.Marshal(req.ExtraVars)
+		args = append(args, "-e", string(varsJSON))
+	}
+
+	// 添加tags
+	if len(req.Tags) > 0 {
+		args = append(args, "-t", strings.Join(req.Tags, ","))
+	}
+
+	// 添加skip-tags
+	if len(req.SkipTags) > 0 {
+		args = append(args, "--skip-tags", strings.Join(req.SkipTags, ","))
+	}
+
+	// 添加verbose
+	if req.Verbose != "" {
+		args = append(args, "-"+req.Verbose)
+	}
+
+	// 显示文件差异
+	if req.Diff {
+		args = append(args, "-D")
+	}
+
+	// dry-run模式
+	if req.Check {
+		args = append(args, "-C")
+	}
+
+	// 是否提权
+	if req.Become != nil {
+		if *req.Become {
+			args = append(args, "-b")
+		} else {
+			args = append(args, "--no-become")
+		}
+	}
+
+	// 并发数
+	if req.Forks > 0 {
+		args = append(args, "-f", strconv.Itoa(req.Forks))
+	}
+
+	// 自定义额外参数
+	if req.ExtraArgs != "" {
+		extraParts := strings.Fields(req.ExtraArgs)
+		args = append(args, extraParts...)
+	}
+
+	// playbook路径放最后
+	args = append(args, playbookPath)
+
+	// 构建命令
+	cmd := exec.Command("ansible-playbook", args...)
+
+	// 设置超时
+	if req.Timeout > 0 {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout)*time.Second)
+		defer cancel()
+		cmd = exec.CommandContext(ctx, "ansible-playbook", args...)
+	}
+
+	var output bytes.Buffer
+	cmd.Stdout = &output
+	cmd.Stderr = &output
+
+	err := cmd.Run()
+
+	task.EndTime = time.Now()
+	if err != nil {
+		task.Status = "failed"
+		task.Error = err.Error()
+	} else {
+		task.Status = "success"
+	}
+	task.Output = output.String()
+}
+
+// ListPlaybooks 列出可用Playbooks
+func (s *AnsibleService) ListPlaybooks() []models.Playbook {
+	var playbooks []models.Playbook
+
+	files, _ := os.ReadDir(s.config.PlaybookDir)
+	for _, f := range files {
+		if !f.IsDir() && strings.HasSuffix(f.Name(), ".yml") {
+			name := strings.TrimSuffix(f.Name(), ".yml")
+			playbookPath := filepath.Join(s.config.PlaybookDir, f.Name())
+			playbook := models.Playbook{
+				Name: name,
+				Path: playbookPath,
+			}
+
+			// 解析YAML获取描述和变量信息
+			data, err := os.ReadFile(playbookPath)
+			if err == nil {
+				// 尝试解析为playbook列表
+				var playEntries []map[string]interface{}
+				if yaml.Unmarshal(data, &playEntries) == nil && len(playEntries) > 0 {
+					first := playEntries[0]
+					// 提取注释中的描述(name字段)
+					if nameVal, ok := first["name"]; ok {
+						playbook.Description = fmt.Sprintf("%v", nameVal)
+					}
+					// 提取vars
+					if varsVal, ok := first["vars"]; ok {
+						if varsMap, ok := varsVal.(map[string]interface{}); ok {
+							playbook.Variables = varsMap
+						}
+					}
+				}
+			}
+
+			playbooks = append(playbooks, playbook)
+		}
+	}
+
+	return playbooks
+}
+
+// GetPlaybook 获取Playbook详情
+func (s *AnsibleService) GetPlaybook(name string) (*models.Playbook, error) {
+	playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
+
+	data, err := os.ReadFile(playbookPath)
+	if err != nil {
+		return nil, fmt.Errorf("Playbook不存在")
+	}
+
+	var playbook models.Playbook
+	playbook.Name = name
+	playbook.Path = playbookPath
+
+	// 简单解析YAML
+	if err := yaml.Unmarshal(data, &playbook); err != nil {
+		return nil, fmt.Errorf("Playbook解析失败")
+	}
+
+	return &playbook, nil
+}
+
+// WebSocketLogs WebSocket日志流
+func (s *AnsibleService) WebSocketLogs(taskID string) (<-chan models.LogEntry, error) {
+	logChan := make(chan models.LogEntry, 100)
+
+	go func() {
+		defer close(logChan)
+
+		ticker := time.NewTicker(500 * time.Millisecond)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ticker.C:
+				s.taskLock.RLock()
+				task, ok := s.tasks[taskID]
+				s.taskLock.RUnlock()
+
+				if !ok {
+					return
+				}
+
+				entry := models.LogEntry{
+					Time:    time.Now().Format("15:04:05"),
+					Level:   "info",
+					Host:    "system",
+					Message: fmt.Sprintf("Progress: %d/%d", task.Progress, task.TotalHosts),
+				}
+				logChan <- entry
+
+				if task.Status == "completed" || task.Status == "failed" {
+					return
+				}
+			}
+		}
+	}()
+
+	return logChan, nil
+}
+
+// ParseAnsibleOutput 解析Ansible输出
+func (s *AnsibleService) ParseAnsibleOutput(output string) (map[string]interface{}, error) {
+	var result map[string]interface{}
+	if err := json.Unmarshal([]byte(output), &result); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// GetTaskOutput 获取任务输出
+func (s *AnsibleService) GetTaskOutput(taskID string) string {
+	s.taskLock.RLock()
+	defer s.taskLock.RUnlock()
+
+	if task, ok := s.tasks[taskID]; ok {
+		return task.Output
+	}
+	return ""
+}
+
+// CreatePlaybook 创建Playbook(通过内容)
+func (s *AnsibleService) CreatePlaybook(name string, content string) error {
+	if name == "" {
+		return fmt.Errorf("Playbook名称不能为空")
+	}
+	// 检查名称是否含非法字符
+	if strings.Contains(name, "/") || strings.Contains(name, "..") {
+		return fmt.Errorf("Playbook名称包含非法字符")
+	}
+
+	playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
+	if _, err := os.Stat(playbookPath); err == nil {
+		return fmt.Errorf("Playbook已存在: %s", name)
+	}
+
+	// 验证YAML格式
+	var dummy interface{}
+	if err := yaml.Unmarshal([]byte(content), &dummy); err != nil {
+		return fmt.Errorf("YAML格式错误: %v", err)
+	}
+
+	return os.WriteFile(playbookPath, []byte(content), 0644)
+}
+
+// DeletePlaybook 删除Playbook
+func (s *AnsibleService) DeletePlaybook(name string) error {
+	if strings.Contains(name, "/") || strings.Contains(name, "..") {
+		return fmt.Errorf("Playbook名称包含非法字符")
+	}
+
+	playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
+	if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
+		return fmt.Errorf("Playbook不存在: %s", name)
+	}
+
+	return os.Remove(playbookPath)
+}
+
+// UpdatePlaybook 更新Playbook内容
+func (s *AnsibleService) UpdatePlaybook(name string, content string) error {
+	if strings.Contains(name, "/") || strings.Contains(name, "..") {
+		return fmt.Errorf("Playbook名称包含非法字符")
+	}
+
+	playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
+	if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
+		return fmt.Errorf("Playbook不存在: %s", name)
+	}
+
+	// 验证YAML格式
+	var dummy interface{}
+	if err := yaml.Unmarshal([]byte(content), &dummy); err != nil {
+		return fmt.Errorf("YAML格式错误: %v", err)
+	}
+
+	return os.WriteFile(playbookPath, []byte(content), 0644)
+}
+
+// GetPlaybookContent 获取Playbook原始内容
+func (s *AnsibleService) GetPlaybookContent(name string) (string, error) {
+	if strings.Contains(name, "/") || strings.Contains(name, "..") {
+		return "", fmt.Errorf("Playbook名称包含非法字符")
+	}
+
+	playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
+	data, err := os.ReadFile(playbookPath)
+	if err != nil {
+		return "", fmt.Errorf("Playbook不存在")
+	}
+	return string(data), nil
+}
+
+// CheckAnsibleInstalled 检查Ansible是否安装
+func (s *AnsibleService) CheckAnsibleInstalled() bool {
+	cmd := exec.Command("ansible", "--version")
+	err := cmd.Run()
+	return err == nil
+}
+
+// GetInventoryPath 获取inventory路径
+func (s *AnsibleService) GetInventoryPath() string {
+	return s.inventoryPath
+}

+ 62 - 0
internal/services/config.go

@@ -0,0 +1,62 @@
+package services
+
+import (
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+// Config 配置
+type Config struct {
+	AnsiblePath    string `yaml:"ansible_path"`
+	InventoryDir   string `yaml:"inventory_dir"`
+	PlaybookDir    string `yaml:"playbook_dir"`
+	LogDir         string `yaml:"log_dir"`
+	SSHTimeout     int    `yaml:"ssh_timeout"`
+	MaxParallelism int    `yaml:"max_parallelism"`
+	CallbackPlugin string `yaml:"callback_plugin"`
+}
+
+// DefaultConfig 默认配置
+func DefaultConfig() *Config {
+	home, _ := os.UserHomeDir()
+	return &Config{
+		AnsiblePath:    "/usr/bin/ansible",
+		InventoryDir:   filepath.Join(home, "ansible-deploy/inventory"),
+		PlaybookDir:    filepath.Join(home, "ansible-deploy/playbooks"),
+		LogDir:         filepath.Join(home, "ansible-deploy/logs"),
+		SSHTimeout:     30,
+		MaxParallelism: 10,
+		CallbackPlugin: "json",
+	}
+}
+
+// LoadConfig 加载配置文件
+func LoadConfig(path string) (*Config, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return DefaultConfig(), err
+	}
+
+	var cfg Config
+	if err := yaml.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+
+	// 设置默认值
+	if cfg.AnsiblePath == "" {
+		cfg.AnsiblePath = "/usr/bin/ansible"
+	}
+	if cfg.InventoryDir == "" {
+		cfg.InventoryDir = filepath.Join(os.Getenv("HOME"), "ansible-deploy/inventory")
+	}
+	if cfg.PlaybookDir == "" {
+		cfg.PlaybookDir = filepath.Join(os.Getenv("HOME"), "ansible-deploy/playbooks")
+	}
+	if cfg.LogDir == "" {
+		cfg.LogDir = filepath.Join(os.Getenv("HOME"), "ansible-deploy/logs")
+	}
+
+	return &cfg, nil
+}

+ 17 - 0
inventory/groups.json

@@ -0,0 +1,17 @@
+{
+  "all": {
+    "name": "all",
+    "description": "所有主机",
+    "hosts": null
+  },
+  "ungrouped": {
+    "name": "ungrouped",
+    "description": "未分组主机",
+    "hosts": null
+  },
+  "zebu_user01": {
+    "name": "zebu_user01",
+    "description": "",
+    "hosts": null
+  }
+}

+ 11 - 0
inventory/hosts

@@ -0,0 +1,11 @@
+# Ansible Inventory File
+# Generated by ansible-deploy
+
+[ungrouped]
+  scmp47 ansible_host=172.16.11.44 ansible_user=root
+
+[all]
+  nas ansible_host=10.168.1.209 ansible_user=root
+
+[zebu_user01]
+  scmp46 ansible_host=172.16.11.42 ansible_user=root

+ 44 - 0
inventory/hosts.json

@@ -0,0 +1,44 @@
+[
+  {
+    "id": "c3e976cb",
+    "name": "scmp46",
+    "ip": "172.16.11.42",
+    "port": 22,
+    "username": "root",
+    "password": "STC#scmp%0818",
+    "groups": [
+      "zebu_user01"
+    ],
+    "status": "online",
+    "last_check": "2026-05-13T17:43:47.863884597+08:00",
+    "created_at": "2026-05-13T16:19:53.979745105+08:00",
+    "updated_at": "2026-05-13T16:19:53.979745185+08:00"
+  },
+  {
+    "id": "5ef41aa3",
+    "name": "scmp47",
+    "ip": "172.16.11.44",
+    "port": 22,
+    "username": "root",
+    "groups": [],
+    "status": "pending",
+    "last_check": "0001-01-01T00:00:00Z",
+    "created_at": "0001-01-01T00:00:00Z",
+    "updated_at": "2026-05-13T17:40:47.833904109+08:00"
+  },
+  {
+    "id": "706f8ce7",
+    "name": "nas",
+    "ip": "10.168.1.209",
+    "port": 22,
+    "username": "root",
+    "password": "WXJwxj91612!!",
+    "groups": [
+      "all"
+    ],
+    "status": "online",
+    "last_check": "2026-05-13T17:34:10.808052527+08:00",
+    "created_at": "2026-05-13T16:03:45.265250935+08:00",
+    "updated_at": "2026-05-13T16:03:45.265251013+08:00"
+  }
+]

+ 53 - 0
playbooks/check-cpu-mem-disk.yml

@@ -0,0 +1,53 @@
+---
+- name: 检测主机 CPU、内存、磁盘占用
+  hosts: all
+  gather_facts: yes
+  vars:
+    warn_threshold: 80
+    crit_threshold: 90
+
+  tasks:
+    - name: 获取 CPU 使用率
+      shell: |
+        top -bn1 | grep "Cpu(s)" | awk '{print "cpu_usage:" $2}' | cut -d'%' -f1
+      register: cpu_result
+
+    - name: 获取内存使用率
+      shell: |
+        free | grep Mem | awk '{printf "memory_used:%.0f\nmemory_total:%.0f\n", $3, $2}'
+      register: mem_result
+
+    - name: 获取磁盘使用率
+      shell: |
+        df -h | grep -E '/$|/data' | awk '{print $1 ":" $5}'
+      register: disk_result
+
+    - name: 格式化输出
+      set_fact:
+        cpu_usage: "{{ cpu_result.stdout_lines[0].split(':')[1] | trim }}"
+        memory_used_mb: "{{ (mem_result.stdout_lines[0].split(':')[1] | trim | float / 1024) | round | int }}"
+        memory_total_mb: "{{ (mem_result.stdout_lines[1].split(':')[1] | trim | float / 1024) | round | int }}"
+        disk_usage: "{{ disk_result.stdout_lines }}"
+
+    - name: 显示检测结果
+      debug:
+        msg: |
+          ================== 主机检测报告 ==================
+          主机名: {{ inventory_hostname }}
+          CPU 使用率: {{ cpu_usage }}%
+          内存使用: {{ memory_used_mb }} MB / {{ memory_total_mb }} MB
+          磁盘使用:
+          {% for disk in disk_usage %}
+            - {{ disk }}
+          {% endfor %}
+          =================================================
+
+    - name: 告警判断
+      fail:
+        msg: "{{ inventory_hostname }} CPU 使用率超过 {{ crit_threshold }}%: {{ cpu_usage }}%"
+      when: cpu_usage | int >= crit_threshold
+
+    - name: 警告判断
+      warn:
+        msg: "{{ inventory_hostname }} CPU 使用率超过 {{ warn_threshold }}%: {{ cpu_usage }}%"
+      when: cpu_usage | int >= warn_threshold and cpu_usage | int < crit_threshold

+ 38 - 0
playbooks/check-system.yml

@@ -0,0 +1,38 @@
+---
+# 系统信息检查
+- name: System Information
+  hosts: "{{ target_hosts | default('all') }}"
+  gather_facts: yes
+  
+  tasks:
+    - name: Display OS info
+      debug:
+        msg: "OS: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"
+        
+    - name: Display hostname
+      debug:
+        msg: "Hostname: {{ ansible_facts['hostname'] }}"
+        
+    - name: Display IP addresses
+      debug:
+        msg: "IP: {{ ansible_facts['default_ipv4']['address'] }}"
+        
+    - name: Display memory info
+      debug:
+        msg: "Memory: {{ (ansible_facts['memtotal_mb'] / 1024) | round(2) }} GB"
+        
+    - name: Display CPU info
+      debug:
+        msg: "CPU: {{ ansible_facts['processor_vcpus'] }} vCPUs"
+        
+    - name: Display disk space
+      debug:
+        msg: "Disk: {{ ansible_facts['mounts'][0]['size_total'] | default(0) | int / 1024 / 1024 / 1024 | round(2) }} GB"
+        
+    - name: Uptime
+      command: uptime -s
+      register: uptime
+        
+    - name: Show uptime
+      debug:
+        msg: "Uptime since: {{ uptime.stdout }}"

+ 51 - 0
playbooks/deploy-docker.yml

@@ -0,0 +1,51 @@
+---
+# 安装Docker
+- name: Install Docker
+  hosts: "{{ target_hosts | default('all') }}"
+  become: yes
+  vars:
+    docker_version: latest
+    
+  tasks:
+    - name: Install required packages
+      apt:
+        name:
+          - apt-transport-https
+          - ca-certificates
+          - curl
+          - gnupg
+          - lsb-release
+        state: present
+      when: ansible_os_family == "Debian"
+      
+    - name: Add Docker GPG key
+      apt_key:
+        url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
+        state: present
+        
+    - name: Add Docker repository
+      apt_repository:
+        repo: "deb [arch=amd64] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
+        state: present
+      when: ansible_os_family == "Debian"
+      
+    - name: Install Docker
+      apt:
+        name:
+          - docker-ce
+          - docker-ce-cli
+          - containerd.io
+          - docker-compose-plugin
+        state: present
+        
+    - name: Start Docker service
+      service:
+        name: docker
+        state: started
+        enabled: yes
+        
+    - name: Add user to docker group
+      user:
+        name: "{{ ansible_user }}"
+        groups: docker
+        append: yes

+ 53 - 0
playbooks/deploy-nginx.yml

@@ -0,0 +1,53 @@
+---
+# 部署Nginx
+- name: Deploy Nginx
+  hosts: "{{ target_hosts | default('all') }}"
+  become: yes
+  vars:
+    nginx_worker_processes: auto
+    nginx_worker_connections: 1024
+    server_name: localhost
+    
+  tasks:
+    - name: Install Nginx
+      apt:
+        name: nginx
+        state: present
+      when: ansible_os_family == "Debian"
+        
+    - name: Install Nginx
+      yum:
+        name: nginx
+        state: present
+      when: ansible_os_family == "RedHat"
+      
+    - name: Configure Nginx
+      template:
+        src: templates/nginx.conf.j2
+        dest: /etc/nginx/nginx.conf
+        mode: '0644'
+      notify: Restart Nginx
+      
+    - name: Create site configuration
+      template:
+        src: templates/site.conf.j2
+        dest: /etc/nginx/conf.d/{{ server_name }}.conf
+        mode: '0644'
+      notify: Reload Nginx
+      
+    - name: Start Nginx
+      service:
+        name: nginx
+        state: started
+        enabled: yes
+        
+  handlers:
+    - name: Restart Nginx
+      service:
+        name: nginx
+        state: restarted
+        
+    - name: Reload Nginx
+      service:
+        name: nginx
+        state: reloaded

+ 30 - 0
playbooks/update-packages.yml

@@ -0,0 +1,30 @@
+---
+# 更新系统包
+- name: Update System Packages
+  hosts: all
+  become: yes
+  vars:
+    update_cache: yes
+    
+  tasks:
+    - name: Update apt cache
+      apt:
+        update_cache: yes
+      when: ansible_os_family == "Debian"
+      
+    - name: Upgrade all packages
+      apt:
+        upgrade: dist
+        autoremove: yes
+      when: ansible_os_family == "Debian"
+      
+    - name: Update yum cache
+      yum:
+        update_cache: yes
+      when: ansible_os_family == "RedHat"
+      
+    - name: Upgrade all packages
+      yum:
+        name: "*"
+        state: latest
+      when: ansible_os_family == "RedHat"

+ 110 - 0
scripts/install.sh

@@ -0,0 +1,110 @@
+#!/bin/bash
+
+# Ansible Deploy 部署脚本
+
+set -e
+
+echo "=== Ansible Deploy 批量部署工具 ==="
+
+# 检查依赖
+check_dependencies() {
+    echo "检查依赖..."
+    
+    # 检查Go
+    if ! command -v go &> /dev/null; then
+        echo "错误: Go未安装"
+        exit 1
+    fi
+    echo "✓ Go已安装: $(go version)"
+    
+    # 检查Ansible
+    if ! command -v ansible &> /dev/null; then
+        echo "警告: Ansible未安装,是否安装? (y/n)"
+        read -r answer
+        if [ "$answer" = "y" ]; then
+            pip install ansible
+        else
+            echo "警告: Ansible未安装,部分功能可能不可用"
+        fi
+    else
+        echo "✓ Ansible已安装: $(ansible --version | head -1)"
+    fi
+    
+    # 检查SSH
+    if ! command -v ssh &> /dev/null; then
+        echo "错误: SSH未安装"
+        exit 1
+    fi
+    echo "✓ SSH已安装"
+}
+
+# 创建目录结构
+create_dirs() {
+    echo "创建目录结构..."
+    mkdir -p ~/ansible-deploy/{inventory,playbooks,logs}
+    echo "✓ 目录已创建"
+}
+
+# 初始化配置文件
+init_config() {
+    echo "初始化配置文件..."
+    
+    # 复制配置
+    if [ ! -f ~/ansible-deploy/config.yaml ]; then
+        cp config/config.yaml ~/ansible-deploy/config.yaml
+        echo "✓ 配置文件已创建"
+    fi
+    
+    # 创建默认inventory
+    if [ ! -f ~/ansible-deploy/inventory/hosts ]; then
+        cat > ~/ansible-deploy/inventory/hosts << 'EOF'
+# Ansible Inventory File
+# 添加你的主机信息,格式:
+# server1 ansible_host=192.168.1.10 ansible_user=root
+
+[webservers]
+web1 ansible_host=192.168.1.10 ansible_user=root
+web2 ansible_host=192.168.1.11 ansible_user=root
+
+[dbservers]
+db1 ansible_host=192.168.1.20 ansible_user=root
+
+[all:vars]
+ansible_python_interpreter=/usr/bin/python3
+EOF
+        echo "✓ Inventory文件已创建"
+    fi
+}
+
+# 构建项目
+build_project() {
+    echo "构建项目..."
+    cd /root/ansible-deploy
+    go build -o ansible-deploy cmd/main.go
+    echo "✓ 构建成功: ansible-deploy"
+}
+
+# 安装
+install_project() {
+    echo "安装..."
+    cp ansible-deploy /usr/local/bin/
+    chmod +x /usr/local/bin/ansible-deploy
+    echo "✓ 安装成功"
+}
+
+# 主函数
+main() {
+    check_dependencies
+    create_dirs
+    init_config
+    build_project
+    install_project
+    
+    echo ""
+    echo "=== 安装完成 ==="
+    echo "启动服务: ansible-deploy -port 8080"
+    echo "配置文件: ~/ansible-deploy/config.yaml"
+    echo "访问地址: http://localhost:8080"
+}
+
+main "$@"

+ 1639 - 0
web/dist/index.html

@@ -0,0 +1,1639 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Ansible批量部署工具</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #0f1419;
+            color: #e7e9ea;
+            min-height: 100vh;
+        }
+        .container {
+            max-width: 1400px;
+            margin: 0 auto;
+            padding: 20px;
+        }
+        header {
+            background: linear-gradient(135deg, #1a1f2e 0%, #2d3748 100%);
+            padding: 20px 0;
+            border-bottom: 1px solid #38444d;
+        }
+        .header-content {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+        h1 {
+            font-size: 24px;
+            color: #00d4aa;
+        }
+        .nav {
+            display: flex;
+            gap: 10px;
+        }
+        .nav button {
+            background: #2d3748;
+            color: #e7e9ea;
+            border: 1px solid #38444d;
+            padding: 8px 16px;
+            border-radius: 6px;
+            cursor: pointer;
+            transition: all 0.3s;
+        }
+        .nav button:hover, .nav button.active {
+            background: #00d4aa;
+            color: #0f1419;
+            border-color: #00d4aa;
+        }
+        .card {
+            background: #1a1f2e;
+            border-radius: 12px;
+            padding: 20px;
+            margin-bottom: 20px;
+            border: 1px solid #38444d;
+        }
+        .card h2 {
+            font-size: 18px;
+            margin-bottom: 15px;
+            color: #00d4aa;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }
+        .grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+            gap: 20px;
+        }
+        .stat-card {
+            background: linear-gradient(135deg, #1a1f2e 0%, #252d3d 100%);
+            border-radius: 12px;
+            padding: 20px;
+            border: 1px solid #38444d;
+        }
+        .stat-card h3 {
+            font-size: 14px;
+            color: #8899a6;
+            margin-bottom: 8px;
+        }
+        .stat-card .value {
+            font-size: 32px;
+            font-weight: bold;
+            color: #00d4aa;
+        }
+        .table {
+            width: 100%;
+            border-collapse: collapse;
+        }
+        .table th, .table td {
+            padding: 12px;
+            text-align: left;
+            border-bottom: 1px solid #38444d;
+        }
+        .table th {
+            color: #8899a6;
+            font-weight: 500;
+        }
+        .table tr:hover {
+            background: rgba(0, 212, 170, 0.1);
+        }
+        .status {
+            display: inline-block;
+            padding: 4px 10px;
+            border-radius: 20px;
+            font-size: 12px;
+        }
+        .status.online { background: #00d4aa; color: #0f1419; }
+        .status.offline { background: #f4212e; color: #fff; }
+        .status.pending { background: #ffd400; color: #0f1419; }
+        .status.running { background: #1d9bf0; color: #fff; }
+        .status.completed { background: #00d4aa; color: #0f1419; }
+        .status.failed { background: #f4212e; color: #fff; }
+        .btn {
+            padding: 6px 14px;
+            border-radius: 6px;
+            border: none;
+            cursor: pointer;
+            font-size: 13px;
+            transition: all 0.3s;
+            margin-right: 4px;
+        }
+        .btn-primary {
+            background: #00d4aa;
+            color: #0f1419;
+        }
+        .btn-primary:hover {
+            background: #00e6b8;
+        }
+        .btn-warning {
+            background: #f59e0b;
+            color: #0f1419;
+        }
+        .btn-warning:hover {
+            background: #fbbf24;
+        }
+        .btn-danger {
+            background: #f4212e;
+            color: #fff;
+        }
+        .btn-danger:hover {
+            background: #ff5c5c;
+        }
+        .btn-info {
+            background: #1d9bf0;
+            color: #fff;
+        }
+        .btn-info:hover {
+            background: #4db8ff;
+        }
+        .btn:disabled {
+            opacity: 0.5;
+            cursor: not-allowed;
+        }
+        .form-group {
+            margin-bottom: 15px;
+        }
+        .form-group label {
+            display: block;
+            margin-bottom: 5px;
+            color: #8899a6;
+        }
+        .form-group input, .form-group textarea, .form-group select {
+            width: 100%;
+            padding: 10px;
+            border-radius: 6px;
+            border: 1px solid #38444d;
+            background: #2d3748;
+            color: #e7e9ea;
+            font-size: 14px;
+        }
+        .form-group input:focus, .form-group textarea:focus {
+            outline: none;
+            border-color: #00d4aa;
+        }
+        .terminal {
+            background: #0a0f14;
+            border-radius: 8px;
+            padding: 15px;
+            font-family: 'Monaco', 'Menlo', monospace;
+            font-size: 13px;
+            max-height: 400px;
+            overflow-y: auto;
+        }
+        .terminal .line {
+            padding: 2px 0;
+            white-space: pre-wrap;
+            word-break: break-all;
+        }
+        .terminal .success { color: #00d4aa; }
+        .terminal .error { color: #f4212e; }
+        .terminal .info { color: #1d9bf0; }
+        .tabs {
+            display: flex;
+            gap: 5px;
+            margin-bottom: 15px;
+        }
+        .tab {
+            padding: 10px 20px;
+            background: #2d3748;
+            border: 1px solid #38444d;
+            border-radius: 6px 6px 0 0;
+            cursor: pointer;
+            color: #8899a6;
+        }
+        .tab.active {
+            background: #1a1f2e;
+            color: #00d4aa;
+            border-bottom-color: #1a1f2e;
+        }
+        .modal {
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0, 0, 0, 0.8);
+            z-index: 1000;
+            align-items: center;
+            justify-content: center;
+        }
+        .modal.active {
+            display: flex;
+        }
+        .modal-content {
+            background: #1a1f2e;
+            border-radius: 12px;
+            padding: 30px;
+            max-width: 600px;
+            width: 90%;
+            max-height: 90vh;
+            overflow-y: auto;
+            border: 1px solid #38444d;
+        }
+        .modal-header {
+            display: flex;
+            justify-content: space-between;
+            margin-bottom: 20px;
+        }
+        .modal-header h2 {
+            color: #00d4aa;
+        }
+        .close {
+            background: none;
+            border: none;
+            color: #8899a6;
+            font-size: 24px;
+            cursor: pointer;
+        }
+        .close:hover {
+            color: #e7e9ea;
+        }
+        .checkbox-group {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 10px;
+        }
+        .checkbox-item {
+            display: flex;
+            align-items: center;
+            gap: 5px;
+            background: #2d3748;
+            padding: 8px 12px;
+            border-radius: 6px;
+            cursor: pointer;
+        }
+        .checkbox-item input {
+            width: auto;
+        }
+        .progress-bar {
+            height: 8px;
+            background: #2d3748;
+            border-radius: 4px;
+            overflow: hidden;
+        }
+        .progress-bar .fill {
+            height: 100%;
+            background: #00d4aa;
+            transition: width 0.3s;
+        }
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+        .spinner {
+            display: inline-block;
+            width: 16px;
+            height: 16px;
+            border: 2px solid #38444d;
+            border-top-color: #00d4aa;
+            border-radius: 50%;
+            animation: spin 1s linear infinite;
+            vertical-align: middle;
+        }
+        .toast {
+            position: fixed;
+            top: 20px;
+            right: 20px;
+            padding: 15px 25px;
+            border-radius: 8px;
+            color: #fff;
+            font-size: 14px;
+            z-index: 9999;
+            transition: all 0.3s;
+            opacity: 0;
+            transform: translateX(100px);
+        }
+        .toast.show {
+            opacity: 1;
+            transform: translateX(0);
+        }
+        .toast.success { background: #00d4aa; color: #0f1419; }
+        .toast.error { background: #f4212e; }
+        .toast.info { background: #1d9bf0; }
+        .action-group {
+            display: flex;
+            gap: 4px;
+        }
+    </style>
+</head>
+<body>
+    <header>
+        <div class="container header-content">
+            <h1>⚡ Ansible批量部署工具</h1>
+            <div class="nav">
+                <button class="active" onclick="showTab('dashboard')">📊 仪表盘</button>
+                <button onclick="showTab('hosts')">🖥️ 主机管理</button>
+                <button onclick="showTab('command')">💻 命令执行</button>
+                <button onclick="showTab('playbook')">📜 Playbook</button>
+                <button onclick="showTab('tasks')">📋 任务列表</button>
+            </div>
+        </div>
+    </header>
+
+    <main class="container">
+        <!-- 仪表盘 -->
+        <div id="dashboard" class="tab-content">
+            <div class="grid">
+                <div class="stat-card">
+                    <h3>主机总数</h3>
+                    <div class="value" id="totalHosts">0</div>
+                </div>
+                <div class="stat-card">
+                    <h3>在线主机</h3>
+                    <div class="value" id="onlineHosts">0</div>
+                </div>
+                <div class="stat-card">
+                    <h3>离线主机</h3>
+                    <div class="value" id="offlineHosts">0</div>
+                </div>
+                <div class="stat-card">
+                    <h3>运行中任务</h3>
+                    <div class="value" id="runningTasks">0</div>
+                </div>
+            </div>
+
+            <div class="card" style="margin-top: 20px;">
+                <h2>📝 最近任务</h2>
+                <table class="table" id="recentTasks">
+                    <thead>
+                        <tr>
+                            <th>任务名称</th>
+                            <th>状态</th>
+                            <th>进度</th>
+                            <th>开始时间</th>
+                        </tr>
+                    </thead>
+                    <tbody id="taskTableBody">
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <!-- 主机管理 -->
+        <div id="hosts" class="tab-content" style="display: none;">
+            <div class="card">
+                <h2>🖥️ 主机列表 <button class="btn btn-primary" onclick="showAddHostModal()" style="float: right;">+ 添加主机</button></h2>
+                <table class="table">
+                    <thead>
+                        <tr>
+                            <th>名称</th>
+                            <th>IP地址</th>
+                            <th>端口</th>
+                            <th>用户名</th>
+                            <th>状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="hostTableBody">
+                    </tbody>
+                </table>
+            </div>
+
+            <div class="card">
+                <h2>📁 主机组</h2>
+                <div class="grid">
+                    <div>
+                        <button class="btn btn-primary" onclick="showAddGroupModal()">+ 创建组</button>
+                    </div>
+                </div>
+                <div id="groupsList" style="margin-top: 15px;"></div>
+            </div>
+        </div>
+
+        <!-- 命令执行 -->
+        <div id="command" class="tab-content" style="display: none;">
+            <div class="card">
+                <h2>💻 命令执行</h2>
+                <div style="display: grid; grid-template-columns: 220px 1fr 1fr; gap: 20px;">
+                    <div>
+                        <div class="form-group">
+                            <label>选择主机组</label>
+                            <div class="checkbox-group" id="cmdGroupCheckboxes"></div>
+                        </div>
+                    </div>
+                    <div>
+                        <div class="form-group">
+                            <label>选择主机</label>
+                            <div class="checkbox-group" id="hostCheckboxes"></div>
+                        </div>
+                        <div class="form-group">
+                            <label>要执行的命令</label>
+                            <textarea id="commandInput" rows="4" placeholder="输入Shell命令,如: df -h、free -m、uptime"></textarea>
+                        </div>
+                        <div class="form-group">
+                            <label>快捷命令</label>
+                            <div style="display: flex; flex-wrap: wrap; gap: 6px;">
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='uptime'">⏱ uptime</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='df -h'">💾 df -h</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='free -m'">🧠 free -m</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='hostname'">🖥 hostname</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='ip addr show'">🌐 ip addr</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='cat /etc/os-release'">📋 系统版本</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='systemctl status --failed'">⚠️ 失败服务</button>
+                                <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='top -bn1 | head -20'">📊 Top20</button>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label><input type="checkbox" id="parallelExecute" checked> 并行执行</label>
+                        </div>
+                        <button class="btn btn-primary" onclick="executeCommand()" style="width:100%;padding:12px;">▶ 执行命令</button>
+                    </div>
+                    <div>
+                        <div class="form-group">
+                            <label>执行结果</label>
+                            <div class="terminal" id="commandOutput" style="min-height: 350px;">
+                                <div class="line info">选择主机,输入命令后点击执行...</div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Playbook -->
+        <div id="playbook" class="tab-content" style="display: none;">
+            <div style="display: grid; grid-template-columns: 340px 1fr; gap: 20px;">
+                <!-- 左列:Playbook列表 -->
+                <div class="card" style="padding:15px;">
+                    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
+                        <h2 style="margin:0;">📜 Playbook列表</h2>
+                        <button class="btn btn-primary" onclick="showCreatePlaybookModal()" style="font-size:13px;padding:5px 12px;">➕ 新建</button>
+                    </div>
+                    <div id="playbooksList"></div>
+                </div>
+
+                <!-- 右列:执行配置 + 输出 -->
+                <div>
+                    <!-- 执行配置 -->
+                    <div class="card" style="margin-bottom:20px;">
+                        <h2>🚀 执行配置 <span id="selectedPlaybookName" style="color:#8899a6;font-size:14px;font-weight:normal;"></span></h2>
+
+                        <!-- 基础设置 -->
+                        <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;">
+                            <div>
+                                <div class="form-group">
+                                    <label>选择主机组</label>
+                                    <div class="checkbox-group" id="playbookGroupCheckboxes"></div>
+                                </div>
+                            </div>
+                            <div>
+                                <div class="form-group">
+                                    <label>选择主机</label>
+                                    <div class="checkbox-group" id="playbookHostCheckboxes"></div>
+                                </div>
+                            </div>
+                            <div>
+                                <div class="form-group">
+                                    <label>变量 (JSON格式)</label>
+                                    <textarea id="extraVarsInput" rows="5" placeholder='{"key": "value"}' style="font-family:Monaco,Menlo,monospace;font-size:13px;"></textarea>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Playbook变量(自动从YAML解析) -->
+                        <div id="playbookVarsSection" style="display:none;margin-bottom:15px;">
+                            <div class="form-group">
+                                <label>📋 Playbook变量 <span style="color:#8899a6;font-size:12px;">(自动解析,点击填充到变量区)</span></label>
+                                <div id="playbookVarsList" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
+                            </div>
+                        </div>
+
+                        <!-- 选项配置 -->
+                        <div style="background:#0f1419;border-radius:8px;padding:15px;margin-bottom:15px;">
+                            <h3 style="font-size:14px;color:#00d4aa;margin-bottom:12px;cursor:pointer;" onclick="toggleOptions()">
+                                ⚙️ 执行选项 <span id="optionsToggleIcon" style="float:right;">▼</span>
+                            </h3>
+                            <div id="optionsPanel" style="display:none;">
+                                <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:15px;">
+                                    <div class="form-group" style="margin-bottom:10px;">
+                                        <label>Verbose级别</label>
+                                        <select id="verboseSelect">
+                                            <option value="">默认</option>
+                                            <option value="v">v (-v)</option>
+                                            <option value="vv">vv (-vv)</option>
+                                            <option value="vvv">vvv (-vvv)</option>
+                                            <option value="vvvv">vvvv (-vvvv)</option>
+                                        </select>
+                                    </div>
+                                    <div class="form-group" style="margin-bottom:10px;">
+                                        <label>并发数 (Forks)</label>
+                                        <input type="number" id="forksInput" min="1" max="100" value="5" placeholder="默认5">
+                                    </div>
+                                    <div class="form-group" style="margin-bottom:10px;">
+                                        <label>超时时间(秒)</label>
+                                        <input type="number" id="timeoutInput" min="0" placeholder="0=不限">
+                                    </div>
+                                </div>
+                                <div style="display:flex;flex-wrap:wrap;gap:20px;margin-top:8px;">
+                                    <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
+                                        <input type="checkbox" id="checkMode"> 🔍 Dry-Run (检查模式)
+                                    </label>
+                                    <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
+                                        <input type="checkbox" id="diffMode"> 📝 Diff (显示差异)
+                                    </label>
+                                    <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
+                                        <input type="checkbox" id="becomeMode" checked> 🔑 Become (提权)
+                                    </label>
+                                </div>
+                                <div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:12px;">
+                                    <div class="form-group" style="margin-bottom:10px;">
+                                        <label>Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,只执行指定标签)</span></label>
+                                        <input type="text" id="tagsInput" placeholder="如: install,config">
+                                    </div>
+                                    <div class="form-group" style="margin-bottom:10px;">
+                                        <label>Skip Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,跳过指定标签)</span></label>
+                                        <input type="text" id="skipTagsInput" placeholder="如: restart">
+                                    </div>
+                                </div>
+                                <div class="form-group" style="margin-bottom:10px;margin-top:5px;">
+                                    <label>自定义参数 <span style="color:#8899a6;font-size:11px;">(直接追加到命令行)</span></label>
+                                    <input type="text" id="extraArgsInput" placeholder="如: --private-key=/path/to/key">
+                                </div>
+                            </div>
+                        </div>
+
+                        <button class="btn btn-primary" onclick="executePlaybook()" style="width:100%;padding:12px;font-size:15px;">▶ 执行Playbook</button>
+                    </div>
+
+                    <!-- 执行输出 -->
+                    <div class="card">
+                        <h2>📝 执行输出</h2>
+                        <div class="terminal" id="playbookOutput" style="min-height:250px;max-height:500px;">
+                            <div class="line info">等待执行...</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 任务列表 -->
+        <div id="tasks" class="tab-content" style="display: none;">
+            <div class="card">
+                <h2>📋 所有任务</h2>
+                <table class="table">
+                    <thead>
+                        <tr>
+                            <th>ID</th>
+                            <th>名称</th>
+                            <th>主机</th>
+                            <th>状态</th>
+                            <th>进度</th>
+                            <th>开始时间</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="allTasksBody">
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </main>
+
+    <!-- 添加主机模态框 -->
+    <div id="addHostModal" class="modal">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2>添加主机</h2>
+                <button class="close" onclick="closeModal('addHostModal')">&times;</button>
+            </div>
+            <form id="addHostForm">
+                <div class="form-group">
+                    <label>名称</label>
+                    <input type="text" name="name" required placeholder="如: web-server-01">
+                </div>
+                <div class="form-group">
+                    <label>IP地址</label>
+                    <input type="text" name="ip" required placeholder="如: 192.168.1.100">
+                </div>
+                <div class="form-group">
+                    <label>端口</label>
+                    <input type="number" name="port" value="22" placeholder="SSH端口">
+                </div>
+                <div class="form-group">
+                    <label>用户名</label>
+                    <input type="text" name="username" required placeholder="如: root">
+                </div>
+                <div class="form-group">
+                    <label>密码</label>
+                    <input type="password" name="password" placeholder="留空则使用SSH密钥">
+                </div>
+                <div class="form-group">
+                    <label>加入组</label>
+                    <div class="checkbox-group" id="hostGroupCheckboxes"></div>
+                </div>
+                <button type="submit" class="btn btn-primary">添加</button>
+            </form>
+        </div>
+    </div>
+
+    <!-- 编辑主机模态框 -->
+    <div id="editHostModal" class="modal">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2>编辑主机</h2>
+                <button class="close" onclick="closeModal('editHostModal')">&times;</button>
+            </div>
+            <form id="editHostForm">
+                <input type="hidden" name="id" id="editHostId">
+                <div class="form-group">
+                    <label>名称</label>
+                    <input type="text" name="name" id="editHostName" required placeholder="如: web-server-01">
+                </div>
+                <div class="form-group">
+                    <label>IP地址</label>
+                    <input type="text" name="ip" id="editHostIp" required placeholder="如: 192.168.1.100">
+                </div>
+                <div class="form-group">
+                    <label>端口</label>
+                    <input type="number" name="port" id="editHostPort" placeholder="SSH端口">
+                </div>
+                <div class="form-group">
+                    <label>用户名</label>
+                    <input type="text" name="username" id="editHostUsername" required placeholder="如: root">
+                </div>
+                <div class="form-group">
+                    <label>密码</label>
+                    <input type="password" name="password" id="editHostPassword" placeholder="留空则不修改密码">
+                </div>
+                <div class="form-group">
+                    <label>加入组</label>
+                    <div class="checkbox-group" id="editHostGroupCheckboxes"></div>
+                </div>
+                <button type="submit" class="btn btn-primary">保存修改</button>
+            </form>
+        </div>
+    </div>
+
+    <!-- 任务详情模态框 -->
+    <div id="taskDetailModal" class="modal">
+        <div class="modal-content" style="max-width: 800px;">
+            <div class="modal-header">
+                <h2>📋 任务详情</h2>
+                <button class="close" onclick="closeModal('taskDetailModal')">&times;</button>
+            </div>
+            <div id="taskDetailContent"></div>
+        </div>
+    </div>
+
+    <!-- 新建/编辑Playbook模态框 -->
+    <div id="playbookEditorModal" class="modal">
+        <div class="modal-content" style="max-width: 900px;">
+            <div class="modal-header">
+                <h2 id="playbookEditorTitle">📝 新建Playbook</h2>
+                <button class="close" onclick="closeModal('playbookEditorModal')">&times;</button>
+            </div>
+            <div class="form-group">
+                <label>Playbook名称 <span style="color:#8899a6;font-size:12px;">(仅英文、数字、中横线,自动加.yml后缀)</span></label>
+                <input type="text" id="playbookEditorName" placeholder="如: deploy-app" style="width:100%;">
+            </div>
+            <div class="form-group">
+                <label>YAML内容</label>
+                <textarea id="playbookEditorContent" rows="20" placeholder="---&#10;- name: Example Playbook&#10;  hosts: all&#10;  become: yes&#10;  vars:&#10;    key: value&#10;  tasks:&#10;    - name: Task name&#10;      module:&#10;        param: value" style="width:100%;font-family:Monaco,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;resize:vertical;min-height:400px;"></textarea>
+            </div>
+            <div style="display:flex;gap:10px;justify-content:flex-end;">
+                <button class="btn" onclick="closeModal('playbookEditorModal')" style="background:#2d3748;color:#e7e9ea;">取消</button>
+                <button class="btn btn-primary" onclick="savePlaybook()">💾 保存</button>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const API_BASE = '/api';
+        let hosts = [];
+        let groups = [];
+        let playbooks = [];
+        let tasks = [];
+        let currentTab = 'dashboard';
+
+        // Toast提示
+        function showToast(msg, type = 'info') {
+            const toast = document.createElement('div');
+            toast.className = `toast ${type}`;
+            toast.textContent = msg;
+            document.body.appendChild(toast);
+            setTimeout(() => toast.classList.add('show'), 10);
+            setTimeout(() => {
+                toast.classList.remove('show');
+                setTimeout(() => toast.remove(), 300);
+            }, 3000);
+        }
+
+        // 初始化
+        async function init() {
+            await loadHosts();
+            await loadGroups();
+            await loadPlaybooks();
+            await loadTasks();
+            updateDashboard();
+            renderHostCheckboxes();
+        }
+
+        // API请求
+        async function api(endpoint, options = {}) {
+            try {
+                const opts = { ...options };
+                // DELETE/GET without body should not send Content-Type: application/json
+                if (!opts.body) {
+                    opts.headers = { ...(opts.headers || {}) };
+                    delete opts.headers['Content-Type'];
+                } else {
+                    opts.headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
+                }
+                const res = await fetch(API_BASE + endpoint, opts);
+                const text = await res.text();
+                try {
+                    return JSON.parse(text);
+                } catch {
+                    return { code: res.status, msg: text };
+                }
+            } catch (err) {
+                console.error('API Error:', err);
+                return { code: 500, msg: err.message };
+            }
+        }
+
+        // 加载数据
+        async function loadHosts() {
+            const res = await api('/hosts');
+            if (res.code === 0) hosts = res.data || [];
+            renderHostsTable();
+        }
+
+        async function loadGroups() {
+            const res = await api('/groups');
+            if (res.code === 0) groups = res.data || [];
+            renderGroups();
+        }
+
+        async function loadPlaybooks() {
+            const res = await api('/playbooks');
+            if (res.code === 0) playbooks = res.data || [];
+            renderPlaybooks();
+        }
+
+        async function loadTasks() {
+            const res = await api('/tasks');
+            if (res.code === 0) tasks = res.data || [];
+            renderTasks();
+        }
+
+        // 渲染主机表格
+        function renderHostsTable() {
+            const tbody = document.getElementById('hostTableBody');
+            if (hosts.length === 0) {
+                tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#8899a6;padding:30px;">暂无主机,点击右上角添加</td></tr>';
+                return;
+            }
+            tbody.innerHTML = hosts.map(h => `
+                <tr>
+                    <td>${h.name}</td>
+                    <td>${h.ip}</td>
+                    <td>${h.port || 22}</td>
+                    <td>${h.username}</td>
+                    <td><span class="status ${h.status || 'pending'}">${statusText(h.status)}</span></td>
+                    <td>
+                        <div class="action-group">
+                            <button class="btn btn-info" id="testBtn_${h.id}" onclick="testConnection('${h.id}')">🔌 测试</button>
+                            <button class="btn btn-warning" onclick="showEditHostModal('${h.id}')">✏️ 编辑</button>
+                            <button class="btn btn-danger" onclick="deleteHost('${h.id}')">🗑 删除</button>
+                        </div>
+                    </td>
+                </tr>
+            `).join('');
+        }
+
+        function statusText(s) {
+            const map = { online: '在线', offline: '离线', pending: '未知', running: '运行中', completed: '已完成', failed: '失败' };
+            return map[s] || s || '未知';
+        }
+
+        function renderGroups() {
+            const groupsList = document.getElementById('groupsList');
+            groupsList.innerHTML = groups.map(g => `
+                <div style="display: inline-block; background: #2d3748; padding: 10px 15px; border-radius: 8px; margin: 5px;">
+                    <strong>${g.name}</strong> (${g.hosts?.length || 0}台)
+                    <button class="btn btn-danger" onclick="deleteGroup('${g.name}')" style="margin-left: 10px;">删除</button>
+                </div>
+            `).join('');
+
+            // 添加主机时的组复选框
+            const checkboxes = document.getElementById('hostGroupCheckboxes');
+            if (checkboxes) {
+                checkboxes.innerHTML = groups.map(g => `
+                    <label class="checkbox-item">
+                        <input type="checkbox" name="groups" value="${g.name}">
+                        ${g.name}
+                    </label>
+                `).join('');
+            }
+
+            // 编辑主机时的组复选框
+            const editCheckboxes = document.getElementById('editHostGroupCheckboxes');
+            if (editCheckboxes) {
+                editCheckboxes.innerHTML = groups.map(g => `
+                    <label class="checkbox-item">
+                        <input type="checkbox" name="groups" value="${g.name}">
+                        ${g.name}
+                    </label>
+                `).join('');
+            }
+        }
+
+        let selectedPlaybook = null;
+
+        function renderPlaybooks() {
+            const list = document.getElementById('playbooksList');
+            list.innerHTML = playbooks.map(p => {
+                const isSelected = selectedPlaybook === p.name;
+                const varsCount = p.variables ? Object.keys(p.variables).length : 0;
+                return `
+                <div style="
+                    background:${isSelected ? '#2d3748' : '#1a1f2e'};
+                    border:1px solid ${isSelected ? '#00d4aa' : '#38444d'};
+                    border-radius:8px;
+                    padding:12px;
+                    margin-bottom:10px;
+                    cursor:pointer;
+                    transition:all 0.2s;
+                    ${isSelected ? 'box-shadow:0 0 8px rgba(0,212,170,0.2);' : ''}
+                " onmouseover="if('${p.name}'!=='${selectedPlaybook}')this.style.borderColor='#555'" onmouseout="if('${p.name}'!=='${selectedPlaybook}')this.style.borderColor='#38444d'">
+                    <div style="display:flex;justify-content:space-between;align-items:center;">
+                        <strong onclick="selectPlaybook('${p.name}')" style="color:${isSelected ? '#00d4aa' : '#e7e9ea'};font-size:14px;flex:1;">📜 ${p.name}</strong>
+                        <div style="display:flex;gap:6px;align-items:center;">
+                            ${varsCount > 0 ? `<span style="background:#2d3748;color:#00d4aa;padding:2px 8px;border-radius:10px;font-size:11px;">${varsCount}变量</span>` : ''}
+                            <button onclick="event.stopPropagation();showEditPlaybookModal('${p.name}')" style="background:none;border:none;color:#1d9bf0;cursor:pointer;font-size:14px;padding:2px 4px;" title="编辑">✏️</button>
+                            <button onclick="event.stopPropagation();deletePlaybook('${p.name}')" style="background:none;border:none;color:#f44336;cursor:pointer;font-size:14px;padding:2px 4px;" title="删除">🗑️</button>
+                        </div>
+                    </div>
+                    <p onclick="selectPlaybook('${p.name}')" style="color:#8899a6;margin-top:5px;font-size:12px;">${p.description || '暂无描述'}</p>
+                    ${varsCount > 0 ? `<div onclick="selectPlaybook('${p.name}')" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px;">
+                        ${Object.keys(p.variables).map(k => `<span style="background:#0f1419;color:#1d9bf0;padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;">${k}</span>`).join('')}
+                    </div>` : ''}
+                </div>`;
+            }).join('');
+        }
+
+        function selectPlaybook(name) {
+            selectedPlaybook = name;
+            const pb = playbooks.find(p => p.name === name);
+            document.getElementById('selectedPlaybookName').textContent = pb ? `— ${pb.description || name}` : '';
+
+            // 显示playbook变量
+            const varsSection = document.getElementById('playbookVarsSection');
+            const varsList = document.getElementById('playbookVarsList');
+            if (pb && pb.variables && Object.keys(pb.variables).length > 0) {
+                varsSection.style.display = 'block';
+                varsList.innerHTML = Object.entries(pb.variables).map(([k, v]) => `
+                    <div onclick="fillVariable('${k}', '${JSON.stringify(v).replace(/'/g, "\\'")}')" style="
+                        background:#2d3748;
+                        border:1px solid #38444d;
+                        border-radius:6px;
+                        padding:8px 12px;
+                        cursor:pointer;
+                        transition:all 0.2s;
+                    " onmouseover="this.style.borderColor='#00d4aa'" onmouseout="this.style.borderColor='#38444d'">
+                        <div style="color:#1d9bf0;font-size:12px;font-family:monospace;">${k}</div>
+                        <div style="color:#8899a6;font-size:11px;margin-top:2px;">默认: ${JSON.stringify(v)}</div>
+                    </div>
+                `).join('');
+            } else {
+                varsSection.style.display = 'none';
+                varsList.innerHTML = '';
+            }
+
+            // 自动填充当前playbook变量到变量区
+            if (pb && pb.variables) {
+                document.getElementById('extraVarsInput').value = JSON.stringify(pb.variables, null, 2);
+            }
+
+            renderPlaybooks(); // 刷新高亮
+        }
+
+        function fillVariable(key, defaultValue) {
+            const input = document.getElementById('extraVarsInput');
+            let vars = {};
+            if (input.value.trim()) {
+                try { vars = JSON.parse(input.value); } catch(e) {
+                    showToast('变量区JSON格式有误,请先修正', 'error');
+                    return;
+                }
+            }
+            try {
+                vars[key] = JSON.parse(defaultValue);
+            } catch(e) {
+                vars[key] = defaultValue;
+            }
+            input.value = JSON.stringify(vars, null, 2);
+            showToast(`变量 ${key} 已填充`, 'success');
+        }
+
+        // ===== Playbook 编辑器 =====
+        let editingPlaybookName = null; // null=新建模式, 非null=编辑模式
+
+        function showCreatePlaybookModal() {
+            editingPlaybookName = null;
+            document.getElementById('playbookEditorTitle').textContent = '📝 新建Playbook';
+            document.getElementById('playbookEditorName').value = '';
+            document.getElementById('playbookEditorName').disabled = false;
+            document.getElementById('playbookEditorContent').value = `---
+- name: Example Playbook
+  hosts: all
+  become: yes
+  vars:
+    key: value
+  tasks:
+    - name: Example task
+      debug:
+        msg: "Hello {{ key }}"
+`;
+            document.getElementById('playbookEditorModal').classList.add('active');
+        }
+
+        async function showEditPlaybookModal(name) {
+            editingPlaybookName = name;
+            document.getElementById('playbookEditorTitle').textContent = '✏️ 编辑Playbook';
+            document.getElementById('playbookEditorName').value = name;
+            document.getElementById('playbookEditorName').disabled = true;
+            document.getElementById('playbookEditorContent').value = '加载中...';
+            document.getElementById('playbookEditorModal').classList.add('active');
+
+            try {
+                const res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(name)}/content`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    document.getElementById('playbookEditorContent').value = data.data;
+                } else {
+                    showToast('加载Playbook内容失败: ' + data.msg, 'error');
+                }
+            } catch(e) {
+                showToast('加载Playbook内容失败: ' + e.message, 'error');
+            }
+        }
+
+        async function savePlaybook() {
+            const name = document.getElementById('playbookEditorName').value.trim();
+            const content = document.getElementById('playbookEditorContent').value;
+
+            if (!name) {
+                showToast('请输入Playbook名称', 'error');
+                return;
+            }
+            if (!name.match(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/)) {
+                showToast('名称只能包含英文、数字、中横线、下划线,且以英文或数字开头', 'error');
+                return;
+            }
+            if (!content.trim()) {
+                showToast('YAML内容不能为空', 'error');
+                return;
+            }
+
+            try {
+                let res;
+                if (editingPlaybookName) {
+                    // 编辑模式 - PUT
+                    res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(editingPlaybookName)}`, {
+                        method: 'PUT',
+                        headers: {'Content-Type': 'application/json'},
+                        body: JSON.stringify({ content })
+                    });
+                } else {
+                    // 新建模式 - POST
+                    res = await fetch(`${API_BASE}/playbooks`, {
+                        method: 'POST',
+                        headers: {'Content-Type': 'application/json'},
+                        body: JSON.stringify({ name, content })
+                    });
+                }
+                const data = await res.json();
+                if (data.code === 0) {
+                    showToast(editingPlaybookName ? 'Playbook更新成功' : 'Playbook创建成功', 'success');
+                    closeModal('playbookEditorModal');
+                    loadPlaybooks(); // 刷新列表
+                } else {
+                    showToast(data.msg || '操作失败', 'error');
+                }
+            } catch(e) {
+                showToast('保存失败: ' + e.message, 'error');
+            }
+        }
+
+        async function deletePlaybook(name) {
+            if (!confirm(`确定删除Playbook "${name}" 吗?此操作不可恢复!`)) return;
+
+            try {
+                const res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(name)}`, {
+                    method: 'DELETE'
+                });
+                const data = await res.json();
+                if (data.code === 0) {
+                    showToast('Playbook已删除', 'success');
+                    if (selectedPlaybook === name) {
+                        selectedPlaybook = null;
+                        document.getElementById('selectedPlaybookName').textContent = '';
+                        document.getElementById('extraVarsInput').value = '';
+                        document.getElementById('playbookVarsSection').style.display = 'none';
+                    }
+                    loadPlaybooks();
+                } else {
+                    showToast(data.msg || '删除失败', 'error');
+                }
+            } catch(e) {
+                showToast('删除失败: ' + e.message, 'error');
+            }
+        }
+
+        async function loadPlaybooks() {
+            try {
+                const res = await fetch(`${API_BASE}/playbooks`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    playbooks = data.data || [];
+                    renderPlaybooks();
+                    // 如果当前选中的playbook还在列表中,重新触发选中
+                    if (selectedPlaybook && playbooks.find(p => p.name === selectedPlaybook)) {
+                        selectPlaybook(selectedPlaybook);
+                    }
+                }
+            } catch(e) {
+                console.error('加载playbooks失败', e);
+            }
+        }
+
+        function toggleOptions() {
+            const panel = document.getElementById('optionsPanel');
+            const icon = document.getElementById('optionsToggleIcon');
+            if (panel.style.display === 'none') {
+                panel.style.display = 'block';
+                icon.textContent = '▲';
+            } else {
+                panel.style.display = 'none';
+                icon.textContent = '▼';
+            }
+        }
+
+        function renderTasks() {
+            const tbody = document.getElementById('allTasksBody');
+            if (tasks.length === 0) {
+                tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8899a6;padding:30px;">暂无任务</td></tr>';
+                return;
+            }
+            tbody.innerHTML = tasks.map(t => `
+                <tr>
+                    <td>${t.id ? t.id.substring(0,8) : '-'}</td>
+                    <td>${t.name}</td>
+                    <td>${(t.hosts || []).join(', ')}</td>
+                    <td><span class="status ${t.status}">${statusText(t.status)}</span></td>
+                    <td>
+                        <div class="progress-bar">
+                            <div class="fill" style="width: ${(t.progress / t.total_hosts * 100) || 0}%"></div>
+                        </div>
+                        ${t.progress || 0}/${t.total_hosts || 0}
+                    </td>
+                    <td>${t.start_time ? new Date(t.start_time).toLocaleString() : '-'}</td>
+                    <td>
+                        <div class="action-group">
+                            ${t.status === 'running' ? `<button class="btn btn-danger" onclick="cancelTask('${t.id}')">取消</button>` : ''}
+                            <button class="btn btn-info" onclick="viewTaskOutput('${t.id}')">详情</button>
+                        </div>
+                    </td>
+                </tr>
+            `).join('');
+        }
+
+        function renderHostCheckboxes() {
+            const container = document.getElementById('hostCheckboxes');
+            if (container) {
+                container.innerHTML = hosts.map(h => `
+                    <label class="checkbox-item">
+                        <input type="checkbox" name="hosts" value="${h.name}">
+                        ${h.name} (${h.ip})
+                    </label>
+                `).join('');
+            }
+
+            const playbookHosts = document.getElementById('playbookHostCheckboxes');
+            if (playbookHosts) {
+                playbookHosts.innerHTML = hosts.map(h => `
+                    <label class="checkbox-item">
+                        <input type="checkbox" name="playbookHosts" value="${h.name}">
+                        ${h.name} (${h.ip})
+                    </label>
+                `).join('');
+            }
+
+            // 渲染Playbook页面主机组复选框(可展开)
+            const playbookGroups = document.getElementById('playbookGroupCheckboxes');
+            if (playbookGroups) {
+                playbookGroups.innerHTML = groups.map(g => `
+                    <div class="group-checkbox-item" data-group="${g.name}" data-type="playbook">
+                        <label class="checkbox-item">
+                            <input type="checkbox" name="playbookGroups" value="${g.name}" onchange="toggleGroupHosts('${g.name}', 'playbook', this.checked)">
+                            <span class="group-toggle" onclick="toggleGroupExpand('${g.name}', 'playbook')">▶ ${g.name} (${g.hosts?.length || 0}台)</span>
+                        </label>
+                        <div class="group-hosts" id="playbook_group_${g.name}" style="display:none;margin-left:20px;border-left:2px solid #38444d;padding-left:10px;">
+                            ${(g.host_list || []).map(h => `
+                                <label class="checkbox-item" style="font-size:12px;">
+                                    <input type="checkbox" name="playbookHosts" value="${h.name}" data-group="${g.name}">
+                                    ${h.name} (${h.ip}) <span class="status ${h.status}" style="font-size:10px;padding:2px 6px;">${statusText(h.status)}</span>
+                                </label>
+                            `).join('')}
+                        </div>
+                    </div>
+                `).join('');
+            }
+
+            // 渲染命令执行页面主机组复选框(可展开)
+            const cmdGroups = document.getElementById('cmdGroupCheckboxes');
+            if (cmdGroups) {
+                cmdGroups.innerHTML = groups.map(g => `
+                    <div class="group-checkbox-item" data-group="${g.name}" data-type="cmd">
+                        <label class="checkbox-item">
+                            <input type="checkbox" name="cmdGroups" value="${g.name}" onchange="toggleGroupHosts('${g.name}', 'cmd', this.checked)">
+                            <span class="group-toggle" onclick="toggleGroupExpand('${g.name}', 'cmd')">▶ ${g.name} (${g.hosts?.length || 0}台)</span>
+                        </label>
+                        <div class="group-hosts" id="cmd_group_${g.name}" style="display:none;margin-left:20px;border-left:2px solid #38444d;padding-left:10px;">
+                            ${(g.host_list || []).map(h => `
+                                <label class="checkbox-item" style="font-size:12px;">
+                                    <input type="checkbox" name="hosts" value="${h.name}" data-group="${g.name}">
+                                    ${h.name} (${h.ip}) <span class="status ${h.status}" style="font-size:10px;padding:2px 6px;">${statusText(h.status)}</span>
+                                </label>
+                            `).join('')}
+                        </div>
+                    </div>
+                `).join('');
+            }
+        }
+
+        // 展开/收起主机组内的主机列表
+        function toggleGroupExpand(groupName, type) {
+            const div = document.getElementById(type + '_group_' + groupName);
+            const toggle = div.previousElementSibling.querySelector('.group-toggle');
+            if (div.style.display === 'none') {
+                div.style.display = 'block';
+                toggle.textContent = '▼ ' + groupName + ' (' + (groups.find(g => g.name === groupName)?.hosts?.length || 0) + '台)';
+            } else {
+                div.style.display = 'none';
+                toggle.textContent = '▶ ' + groupName + ' (' + (groups.find(g => g.name === groupName)?.hosts?.length || 0) + '台)';
+            }
+        }
+
+        // 选中/取消主机组时,选中/取消组内所有主机
+        function toggleGroupHosts(groupName, type, checked) {
+            const div = document.getElementById(type + '_group_' + groupName);
+            const checkboxes = div.querySelectorAll('input[type="checkbox"]');
+            checkboxes.forEach(cb => cb.checked = checked);
+        }
+
+        function updateDashboard() {
+            document.getElementById('totalHosts').textContent = hosts.length;
+            document.getElementById('onlineHosts').textContent = hosts.filter(h => h.status === 'online').length;
+            document.getElementById('offlineHosts').textContent = hosts.filter(h => h.status === 'offline').length;
+            document.getElementById('runningTasks').textContent = tasks.filter(t => t.status === 'running').length;
+
+            const tbody = document.getElementById('taskTableBody');
+            const recent = tasks.slice(-5).reverse();
+            tbody.innerHTML = recent.map(t => `
+                <tr>
+                    <td>${t.name}</td>
+                    <td><span class="status ${t.status}">${statusText(t.status)}</span></td>
+                    <td>${t.progress || 0}/${t.total_hosts || 0}</td>
+                    <td>${t.start_time ? new Date(t.start_time).toLocaleString() : '-'}</td>
+                </tr>
+            `).join('');
+        }
+
+        // Tab切换
+        function showTab(tab) {
+            document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
+            document.querySelectorAll('.nav button').forEach(el => el.classList.remove('active'));
+            document.getElementById(tab).style.display = 'block';
+            document.querySelector(`.nav button[onclick="showTab('${tab}')"]`).classList.add('active');
+            currentTab = tab;
+        }
+
+        // 模态框
+        function showAddHostModal() {
+            document.getElementById('addHostForm').reset();
+            document.getElementById('addHostModal').classList.add('active');
+        }
+
+        function showAddGroupModal() {
+            const name = prompt('输入组名称:');
+            if (name) {
+                api('/groups', {
+                    method: 'POST',
+                    body: JSON.stringify({ name, description: '' })
+                }).then(res => {
+                    if (res.code === 0) {
+                        showToast('组创建成功', 'success');
+                        loadGroups();
+                    } else {
+                        showToast('创建失败: ' + res.msg, 'error');
+                    }
+                });
+            }
+        }
+
+        function closeModal(id) {
+            document.getElementById(id).classList.remove('active');
+        }
+
+        // 编辑主机
+        function showEditHostModal(hostId) {
+            const host = hosts.find(h => h.id === hostId);
+            if (!host) {
+                showToast('主机不存在', 'error');
+                return;
+            }
+
+            document.getElementById('editHostId').value = host.id;
+            document.getElementById('editHostName').value = host.name || '';
+            document.getElementById('editHostIp').value = host.ip || '';
+            document.getElementById('editHostPort').value = host.port || 22;
+            document.getElementById('editHostUsername').value = host.username || '';
+            document.getElementById('editHostPassword').value = '';
+
+            // 渲染组复选框并勾选已有组
+            const editCheckboxes = document.getElementById('editHostGroupCheckboxes');
+            editCheckboxes.innerHTML = groups.map(g => `
+                <label class="checkbox-item">
+                    <input type="checkbox" name="groups" value="${g.name}" ${(host.groups || []).includes(g.name) ? 'checked' : ''}>
+                    ${g.name}
+                </label>
+            `).join('');
+
+            document.getElementById('editHostModal').classList.add('active');
+        }
+
+        // 操作
+        async function addHost(form) {
+            const data = new FormData(form);
+            const host = {
+                name: data.get('name'),
+                ip: data.get('ip'),
+                port: parseInt(data.get('port')) || 22,
+                username: data.get('username'),
+                password: data.get('password'),
+                groups: data.getAll('groups')
+            };
+
+            const res = await api('/hosts', {
+                method: 'POST',
+                body: JSON.stringify(host)
+            });
+
+            if (res.code === 0) {
+                closeModal('addHostModal');
+                showToast('主机添加成功', 'success');
+                await loadHosts();
+                await loadGroups();
+                renderHostCheckboxes();
+            } else {
+                showToast('添加失败: ' + res.msg, 'error');
+            }
+        }
+
+        async function updateHost(form) {
+            const data = new FormData(form);
+            const hostId = data.get('id');
+            const host = {
+                name: data.get('name'),
+                ip: data.get('ip'),
+                port: parseInt(data.get('port')) || 22,
+                username: data.get('username'),
+                groups: data.getAll('groups')
+            };
+
+            // 密码不为空时才提交
+            if (data.get('password')) {
+                host.password = data.get('password');
+            }
+
+            const res = await api(`/hosts/${hostId}`, {
+                method: 'PUT',
+                body: JSON.stringify(host)
+            });
+
+            if (res.code === 0) {
+                closeModal('editHostModal');
+                showToast('主机更新成功', 'success');
+                await loadHosts();
+                await loadGroups();
+                renderHostCheckboxes();
+            } else {
+                showToast('更新失败: ' + res.msg, 'error');
+            }
+        }
+
+        async function deleteHost(id) {
+            if (confirm('确定删除此主机?')) {
+                const res = await api(`/hosts/${id}`, { method: 'DELETE' });
+                if (res.code === 0) {
+                    showToast('主机已删除', 'success');
+                    await loadHosts();
+                    renderHostCheckboxes();
+                } else {
+                    showToast('删除失败: ' + res.msg, 'error');
+                }
+            }
+        }
+
+        async function deleteGroup(name) {
+            if (confirm('确定删除此组?')) {
+                const res = await api(`/groups/${name}`, { method: 'DELETE' });
+                if (res.code === 0) {
+                    showToast('组已删除', 'success');
+                    await loadGroups();
+                } else {
+                    showToast('删除失败: ' + res.msg, 'error');
+                }
+            }
+        }
+
+        async function testConnection(id) {
+            const btn = document.getElementById(`testBtn_${id}`);
+            if (btn) {
+                btn.disabled = true;
+                btn.innerHTML = '<span class="spinner"></span> 测试中';
+            }
+
+            try {
+                const res = await api(`/hosts/test/${id}`, { method: 'POST' });
+                if (res.code === 0 && res.data) {
+                    if (res.data.success) {
+                        showToast(`✓ 连接成功!延迟: ${res.data.duration || 0}ms`, 'success');
+                    } else {
+                        showToast(`✗ 连接失败: ${res.data.error || '未知错误'}`, 'error');
+                    }
+                    await loadHosts();
+                } else {
+                    showToast('测试失败: ' + (res.msg || '未知错误'), 'error');
+                }
+            } catch (err) {
+                showToast('请求异常: ' + err.message, 'error');
+            } finally {
+                if (btn) {
+                    btn.disabled = false;
+                    btn.innerHTML = '🔌 测试';
+                }
+            }
+        }
+
+        async function executeCommand() {
+            const checkedHosts = document.querySelectorAll('input[name="hosts"]:checked');
+            const checkedGroups = document.querySelectorAll('input[name="cmdGroups"]:checked');
+
+            // 主机列表(直接选的主机)
+            let hostList = Array.from(checkedHosts).map(h => h.value);
+
+            // 如果选了主机组,展开为组内成员主机
+            const groupList = Array.from(checkedGroups).map(g => g.value);
+            if (groupList.length > 0) {
+                groupList.forEach(gName => {
+                    const g = groups.find(gr => gr.name === gName);
+                    if (g && g.hosts) {
+                        g.hosts.forEach(hName => {
+                            if (!hostList.includes(hName)) hostList.push(hName);
+                        });
+                    }
+                });
+            }
+
+            const command = document.getElementById('commandInput').value;
+            const parallel = document.getElementById('parallelExecute').checked;
+
+            if (hostList.length === 0) {
+                showToast('请选择主机或主机组', 'error');
+                return;
+            }
+            if (!command) {
+                showToast('请输入命令', 'error');
+                return;
+            }
+
+            const output = document.getElementById('commandOutput');
+            output.innerHTML = '<div class="line info"><span class="spinner"></span> 执行中...</div>';
+
+            const res = await api('/command/batch', {
+                method: 'POST',
+                body: JSON.stringify({ hosts: hostList, command, parallel })
+            });
+
+            if (res.code === 0) {
+                output.innerHTML = '';
+                const results = res.data?.results || [];
+                if (results.length === 0) {
+                    output.innerHTML = '<div class="line info">任务已提交,请在任务列表中查看</div>';
+                } else {
+                    results.forEach(r => {
+                        const cls = r.success ? 'success' : 'error';
+                        output.innerHTML += `<div class="line ${cls}">[${r.host}] ${r.success ? '✓ 成功' : '✗ 失败'} (${r.duration || 0}ms)</div>`;
+                        if (r.output) output.innerHTML += `<div class="line">${escapeHtml(r.output)}</div>`;
+                        if (r.error) output.innerHTML += `<div class="line error">${escapeHtml(r.error)}</div>`;
+                    });
+                }
+            } else {
+                output.innerHTML = `<div class="line error">执行失败: ${res.msg}</div>`;
+            }
+        }
+
+        async function executePlaybook() {
+            if (!selectedPlaybook) {
+                showToast('请先选择一个Playbook', 'error');
+                return;
+            }
+
+            const checkedHosts = document.querySelectorAll('input[name="playbookHosts"]:checked');
+            const checkedGroups = document.querySelectorAll('input[name="playbookGroups"]:checked');
+
+            // 主机列表(直接选的主机)
+            let hostList = Array.from(checkedHosts).map(h => h.value);
+
+            // 如果选了主机组,展开为组内成员主机
+            const groupList = Array.from(checkedGroups).map(g => g.value);
+            if (groupList.length > 0) {
+                groupList.forEach(gName => {
+                    const g = groups.find(gr => gr.name === gName);
+                    if (g && g.hosts) {
+                        g.hosts.forEach(hName => {
+                            if (!hostList.includes(hName)) hostList.push(hName);
+                        });
+                    }
+                });
+            }
+
+            if (hostList.length === 0) {
+                showToast('请选择主机或主机组', 'error');
+                return;
+            }
+
+            const extraVarsText = document.getElementById('extraVarsInput').value;
+
+            let extraVars = {};
+            if (extraVarsText) {
+                try {
+                    extraVars = JSON.parse(extraVarsText);
+                } catch (e) {
+                    showToast('变量格式错误,请使用JSON格式', 'error');
+                    return;
+                }
+            }
+
+            // 收集选项
+            const req = {
+                name: selectedPlaybook,
+                hosts: hostList,
+                extra_vars: extraVars,
+                verbose: document.getElementById('verboseSelect').value || '',
+                diff: document.getElementById('diffMode').checked,
+                check: document.getElementById('checkMode').checked,
+                become: document.getElementById('becomeMode').checked,
+                forks: parseInt(document.getElementById('forksInput').value) || 0,
+                timeout: parseInt(document.getElementById('timeoutInput').value) || 0,
+                extra_args: document.getElementById('extraArgsInput').value.trim(),
+            };
+
+            // tags
+            const tagsVal = document.getElementById('tagsInput').value.trim();
+            if (tagsVal) {
+                req.tags = tagsVal.split(',').map(t => t.trim()).filter(t => t);
+            }
+            const skipTagsVal = document.getElementById('skipTagsInput').value.trim();
+            if (skipTagsVal) {
+                req.skip_tags = skipTagsVal.split(',').map(t => t.trim()).filter(t => t);
+            }
+
+            const output = document.getElementById('playbookOutput');
+            output.innerHTML = '<div class="line info"><span class="spinner"></span> 执行中...</div>';
+
+            const res = await api('/playbooks/execute', {
+                method: 'POST',
+                body: JSON.stringify(req)
+            });
+
+            if (res.code === 0) {
+                output.innerHTML = `<div class="line info">任务已启动,ID: ${res.taskId || '未知'}</div>`;
+                showToast('Playbook执行已启动', 'success');
+                await loadTasks();
+            } else {
+                output.innerHTML = `<div class="line error">执行失败: ${res.msg}</div>`;
+                showToast('执行失败: ' + res.msg, 'error');
+            }
+        }
+
+        async function cancelTask(id) {
+            if (confirm('确定取消此任务?')) {
+                const res = await api(`/tasks/${id}`, { method: 'DELETE' });
+                if (res.code === 0) {
+                    showToast('任务已取消', 'success');
+                    await loadTasks();
+                } else {
+                    showToast('取消失败: ' + res.msg, 'error');
+                }
+            }
+        }
+
+        async function viewTaskOutput(id) {
+            const res = await api(`/tasks/${id}`);
+            if (res.code === 0 && res.data) {
+                const task = res.data;
+                const content = document.getElementById('taskDetailContent');
+                content.innerHTML = `
+                    <div style="margin-bottom: 15px;">
+                        <strong>任务名称:</strong> ${task.name}<br>
+                        <strong>状态:</strong> <span class="status ${task.status}">${statusText(task.status)}</span><br>
+                        <strong>开始时间:</strong> ${task.start_time ? new Date(task.start_time).toLocaleString() : '-'}<br>
+                        <strong>结束时间:</strong> ${task.end_time ? new Date(task.end_time).toLocaleString() : '-'}<br>
+                    </div>
+                    <h3 style="color:#00d4aa;margin-bottom:10px;">执行结果</h3>
+                    <div class="terminal">
+                        ${(task.results || []).map(r => `
+                            <div class="line ${r.success ? 'success' : 'error'}">[${r.host}] ${r.success ? '✓' : '✗'} (${r.duration || 0}ms)</div>
+                            ${r.output ? `<div class="line">${escapeHtml(r.output)}</div>` : ''}
+                            ${r.error ? `<div class="line error">${escapeHtml(r.error)}</div>` : ''}
+                        `).join('') || '<div class="line info">暂无结果</div>'}
+                    </div>
+                `;
+                document.getElementById('taskDetailModal').classList.add('active');
+            } else {
+                showToast('获取任务详情失败', 'error');
+            }
+        }
+
+        function escapeHtml(text) {
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        // 表单提交
+        document.getElementById('addHostForm').onsubmit = function(e) {
+            e.preventDefault();
+            addHost(this);
+        };
+
+        document.getElementById('editHostForm').onsubmit = function(e) {
+            e.preventDefault();
+            updateHost(this);
+        };
+
+        // 点击模态框外部关闭
+        document.querySelectorAll('.modal').forEach(modal => {
+            modal.addEventListener('click', function(e) {
+                if (e.target === this) {
+                    this.classList.remove('active');
+                }
+            });
+        });
+
+        // 定时刷新(保留用户已选择的状态)
+        setInterval(async () => {
+            await loadTasks();
+            await loadHosts();
+            saveSelectedHosts();
+            renderHostCheckboxes();
+            restoreSelectedHosts();
+            if (currentTab === 'dashboard') updateDashboard();
+        }, 300000); // 5分钟
+
+        // 保存当前选中的主机和主机组
+        function saveSelectedHosts() {
+            window._savedHostCheckboxes = Array.from(document.querySelectorAll('input[name="hosts"]:checked')).map(i => i.value);
+            window._savedPlaybookHostCheckboxes = Array.from(document.querySelectorAll('input[name="playbookHosts"]:checked')).map(i => i.value);
+            window._savedPlaybookGroupCheckboxes = Array.from(document.querySelectorAll('input[name="playbookGroups"]:checked')).map(i => i.value);
+            window._savedCmdGroupCheckboxes = Array.from(document.querySelectorAll('input[name="cmdGroups"]:checked')).map(i => i.value);
+        }
+
+        // 恢复选中的主机和主机组
+        function restoreSelectedHosts() {
+            const savedHosts = window._savedHostCheckboxes || [];
+            const savedPbHosts = window._savedPlaybookHostCheckboxes || [];
+            const savedPbGroups = window._savedPlaybookGroupCheckboxes || [];
+            const savedCmdGroups = window._savedCmdGroupCheckboxes || [];
+            document.querySelectorAll('input[name="hosts"]').forEach(i => {
+                if (savedHosts.includes(i.value)) i.checked = true;
+            });
+            document.querySelectorAll('input[name="playbookHosts"]').forEach(i => {
+                if (savedPbHosts.includes(i.value)) i.checked = true;
+            });
+            document.querySelectorAll('input[name="playbookGroups"]').forEach(i => {
+                if (savedPbGroups.includes(i.value)) i.checked = true;
+            });
+            document.querySelectorAll('input[name="cmdGroups"]').forEach(i => {
+                if (savedCmdGroups.includes(i.value)) i.checked = true;
+            });
+        }
+
+        // 启动
+        init();
+    </script>
+</body>
+</html>