diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a0c641
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# 二进制文件
+ansible-deploy
+
+# 敏感数据 - 主机配置含密码
+inventory/hosts.json
+inventory/groups.json
+inventory/hosts
+
+# SSH私钥
+inventory/ssh_keys/
+
+# 日志
+logs/
+
+# 系统文件
+.DS_Store
+*.log
diff --git a/README.md b/README.md
index baa4b38..3ca7991 100644
--- a/README.md
+++ b/README.md
@@ -1,96 +1,86 @@
-# Ansible批量部署工具
+# ⚡ Ansible Deploy Pro
-基于 Go + Ansible 的批量运维部署系统,支持批量执行命令、Playbook管理、主机分组等功能。
+基于 Go + Ansible 的企业级批量运维部署平台,支持 SSH 密钥管理、Playbook 模板中心、文件分发、实时日志流等功能。
-## 功能特性
+## ✨ 功能特性
-- 🌐 **Web界面** - 简洁美观的Web管理界面
-- 🖥️ **主机管理** - 支持添加、删除、编辑主机
-- 📁 **分组管理** - 灵活的主机组管理
-- 💻 **命令执行** - 批量并行/串行执行命令
-- 📜 **Playbook管理** - 预置Playbook模板,支持快速执行
-- 📊 **任务跟踪** - 实时任务进度监控
-- 🔄 **自动刷新** - 数据自动同步
+- 🎨 **现代化 Web 界面** - 暗色玻璃拟态设计,侧边栏导航,流畅动画
+- 🖥️ **主机管理** - 支持密码/SSH密钥双认证,连接测试,分组管理
+- 🔑 **SSH 密钥库** - 集中管理 SSH 私钥,自动指纹识别
+- 📜 **Playbook 管理** - 在线编辑、变量解析、执行选项配置
+- 🏪 **模板中心** - 20+ 预置模板(Docker/Nginx/MySQL/Redis/安全加固/监控等),一键创建
+- 📦 **文件分发** - 批量分发文件/内容到远程主机
+- 💻 **命令执行** - 批量并行/串行执行命令,快捷命令
+- 📊 **任务中心** - SSE 实时日志流,进度追踪,任务取消
+- 📈 **仪表盘** - 主机状态概览、任务统计、系统信息
-## 快速开始
+## 🚀 快速开始
### 1. 安装依赖
```bash
-# 安装Go
+# 安装 Go
curl -fsSL https://go.dev/dl/go1.21.linux-amd64.tar.gz | tar -C /usr/local -xzf -
-# 安装Ansible
+# 安装 Ansible
pip install ansible
-# 安装SSH
+# 安装 SSH
apt install openssh-client # Debian/Ubuntu
-yum install openssh-clients # CentOS/RHEL
```
-### 2. 构建项目
+### 2. 构建
```bash
-cd /root/ansible-deploy
+cd ansible-deploy
go mod tidy
go build -o ansible-deploy cmd/main.go
```
-### 3. 配置SSH免密登录
+### 3. 启动
```bash
-# 生成SSH密钥
-ssh-keygen -t rsa
-
-# 复制到目标主机
-ssh-copy-id user@hostname
-```
-
-### 4. 启动服务
-
-```bash
-# 默认端口8080
+# 默认端口 8080
./ansible-deploy
-# 自定义端口
-./ansible-deploy -port 9000
-
-# 自定义配置
-./ansible-deploy -config /path/to/config.yaml
+# 自定义端口和配置
+./ansible-deploy -port 9000 -config /path/to/config.yaml
```
-### 5. 访问Web界面
+### 4. 访问
-打开浏览器访问: `http://localhost:8080`
+打开浏览器: `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
+```
+ansible-deploy/
+├── cmd/
+│ └── main.go # 主程序入口
+├── config/
+│ └── config.yaml # 配置文件
+├── internal/
+│ ├── handlers/
+│ │ └── handlers.go # HTTP 处理器
+│ ├── models/
+│ │ └── models.go # 数据模型
+│ └── services/
+│ ├── ansible.go # Ansible 核心服务
+│ ├── config.go # 配置加载
+│ └── templates.go # Playbook 模板库
+├── web/dist/
+│ └── index.html # 前端界面
+├── playbooks/ # Playbook 目录
+├── inventory/ # 资产清单
+│ ├── hosts.json # 主机数据
+│ ├── groups.json # 分组数据
+│ └── ssh_keys/ # SSH 密钥存储
+└── README.md
```
-## API接口
+## 🔌 API 接口
### 主机管理
-
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/hosts` | 获取主机列表 |
@@ -100,113 +90,78 @@ max_parallelism: 10
| 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/playbooks` | 列出 Playbook |
+| POST | `/api/playbooks` | 创建 Playbook |
+| GET | `/api/playbooks/:name/content` | 获取内容 |
+| PUT | `/api/playbooks/:name` | 更新 Playbook |
+| DELETE | `/api/playbooks/:name` | 删除 Playbook |
+| POST | `/api/playbooks/execute` | 执行 Playbook |
+
+### 模板中心
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/templates` | 列出模板 |
+| GET | `/api/templates/categories` | 模板分类 |
+| GET | `/api/templates/:id` | 模板详情 |
+| POST | `/api/templates/deploy` | 从模板创建 |
+
+### SSH 密钥
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/sshkeys` | 列出密钥 |
+| POST | `/api/sshkeys` | 添加密钥 |
+| DELETE | `/api/sshkeys/:name` | 删除密钥 |
+
+### 文件分发
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/files/distribute` | 分发文件 |
### 任务
-
| 方法 | 路径 | 说明 |
|------|------|------|
-| GET | `/api/tasks` | 获取任务列表 |
-| GET | `/api/tasks/:id` | 获取任务详情 |
+| GET | `/api/tasks` | 任务列表 |
+| GET | `/api/tasks/:id` | 任务详情 |
+| GET | `/api/tasks/:id/stream` | SSE 实时日志 |
| DELETE | `/api/tasks/:id` | 取消任务 |
-## 使用示例
+### 系统
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/system/info` | 系统信息 |
-### 添加主机
+## 📋 预置模板
-```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
+| 分类 | 模板 | 说明 |
+|------|------|------|
+| 🖥️ 系统管理 | 系统包更新 | 自动更新软件包 |
+| | 系统信息采集 | 采集 OS/硬件/网络信息 |
+| | 资源监控检查 | CPU/内存/磁盘告警 |
+| | 日志清理 | 清理日志和临时文件 |
+| | 时间同步(NTP) | 配置 chrony 时间同步 |
+| 🌐 Web服务 | 部署 Nginx | 安装配置 Nginx + 反向代理 |
+| | 部署 Apache | 安装配置 Apache |
+| 🗄️ 数据库 | 部署 MySQL | 安装 MySQL + 基础优化 |
+| | 部署 Redis | 安装 Redis + 内存配置 |
+| | 部署 PostgreSQL | 安装 PostgreSQL |
+| 🐳 容器化 | 部署 Docker | Docker CE + 镜像加速 |
+| | 部署 Portainer | 容器管理面板 |
+| 🔧 DevOps | 部署 Node.js | Node.js + PM2 |
+| | 部署 Python 应用 | Gunicorn + Systemd |
+| | 数据备份 | 定时备份 + 自动清理 |
+| 🔒 安全加固 | 安全加固 | SSH加固 + 防火墙 + fail2ban |
+| | SSL 证书部署 | Let's Encrypt 自动申请 |
+| 📊 监控告警 | 部署 Node Exporter | Prometheus 指标采集 |
+| | 部署 Grafana | 可视化监控面板 |
## License
diff --git a/cmd/main.go b/cmd/main.go
index 4352fec..9af6e7b 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -12,7 +12,7 @@ import (
var (
configPath string
- port string
+ port string
)
func init() {
@@ -23,23 +23,17 @@ func init() {
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")
@@ -51,11 +45,9 @@ func main() {
c.Next()
})
- // 静态文件
r.Static("/static", "./web/dist")
r.Static("/assets", "./web/dist/assets")
- // API路由
api := r.Group("/api")
{
// 主机管理
@@ -64,6 +56,7 @@ func main() {
api.DELETE("/hosts/:id", h.DeleteHost)
api.PUT("/hosts/:id", h.UpdateHost)
api.POST("/hosts/test/:id", h.TestConnection)
+ api.GET("/hosts/test/:id", h.TestConnection)
// 主机组管理
api.GET("/groups", h.ListGroups)
@@ -80,18 +73,34 @@ func main() {
api.POST("/playbooks/execute", h.ExecutePlaybook)
api.GET("/playbooks/:name", h.GetPlaybook)
+ // Playbook模板
+ api.GET("/templates", h.ListTemplates)
+ api.GET("/templates/categories", h.ListTemplateCategories)
+ api.GET("/templates/:id", h.GetTemplate)
+ api.POST("/templates/deploy", h.CreateFromTemplate)
+
// 命令执行
api.POST("/command/execute", h.ExecuteCommand)
api.POST("/command/batch", h.BatchExecute)
+ // 文件分发
+ api.POST("/files/distribute", h.DistributeFile)
+
+ // SSH密钥管理
+ api.GET("/sshkeys", h.ListSSHKeys)
+ api.POST("/sshkeys", h.AddSSHKey)
+ api.DELETE("/sshkeys/:name", h.DeleteSSHKey)
+
// 任务执行
api.GET("/tasks", h.ListTasks)
api.GET("/tasks/:id", h.GetTask)
api.GET("/tasks/:id/stream", h.StreamTaskOutput)
api.DELETE("/tasks/:id", h.CancelTask)
+
+ // 系统信息
+ api.GET("/system/info", h.GetSystemInfo)
}
- // 前端路由 - 禁止缓存确保始终返回最新版本
r.GET("/", func(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
@@ -99,12 +108,11 @@ func main() {
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)
+ log.Printf("🚀 Ansible Deploy v2.0 启动,监听端口: %s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
diff --git a/config/config.yaml b/config/config.yaml
index ba247ab..f3781da 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -1,16 +1,16 @@
-# Ansible Deploy 配置文件
+# Ansible Deploy Pro 配置文件
# Ansible安装路径
ansible_path: /usr/bin/ansible
# 资产清单目录
-inventory_dir: /root/ansible-deploy/inventory
+inventory_dir: ./inventory
# Playbook目录
-playbook_dir: /root/ansible-deploy/playbooks
+playbook_dir: ./playbooks
# 日志目录
-log_dir: /root/ansible-deploy/logs
+log_dir: ./logs
# SSH连接超时时间(秒)
ssh_timeout: 30
@@ -20,11 +20,3 @@ 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
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 09a2090..3b0991a 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -19,314 +19,335 @@ 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,
- })
+ 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(),
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "主机添加成功",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "主机删除成功",
- })
+ 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": "参数错误",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "主机更新成功",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": result,
- })
+ 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,
- })
+ 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": "参数错误",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "组创建成功",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "组删除成功",
- })
+ 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": "参数错误",
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "组更新成功",
- })
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "组更新成功"})
}
-// ListPlaybooks 列出Playbooks
+// ===== Playbook管理 =====
+
func (h *AnsibleHandler) ListPlaybooks(c *gin.Context) {
playbooks := h.service.ListPlaybooks()
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": playbooks,
- })
+ 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(),
- })
+ c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": playbook,
- })
+ 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(),
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "任务已启动",
- "taskId": task.ID,
- })
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "任务已启动", "taskId": task.ID})
}
-// ExecuteCommand 执行命令
+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创建成功"})
+}
+
+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删除成功"})
+}
+
+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})
+}
+
+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更新成功"})
+}
+
+// ===== Playbook模板 =====
+
+func (h *AnsibleHandler) ListTemplates(c *gin.Context) {
+ templates := services.GetPlaybookTemplates()
+ category := c.Query("category")
+ if category != "" {
+ var filtered []services.PlaybookTemplate
+ for _, t := range templates {
+ if t.Category == category {
+ filtered = append(filtered, t)
+ }
+ }
+ templates = filtered
+ }
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": templates})
+}
+
+func (h *AnsibleHandler) ListTemplateCategories(c *gin.Context) {
+ cats := services.GetTemplateCategories()
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": cats})
+}
+
+func (h *AnsibleHandler) GetTemplate(c *gin.Context) {
+ id := c.Param("id")
+ for _, t := range services.GetPlaybookTemplates() {
+ if t.ID == id {
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": t})
+ return
+ }
+ }
+ c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "模板不存在"})
+}
+
+func (h *AnsibleHandler) CreateFromTemplate(c *gin.Context) {
+ var req struct {
+ TemplateID string `json:"template_id" binding:"required"`
+ PlaybookName string `json:"playbook_name" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()})
+ return
+ }
+ for _, t := range services.GetPlaybookTemplates() {
+ if t.ID == req.TemplateID {
+ if err := h.service.CreatePlaybook(req.PlaybookName, t.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创建成功"})
+ return
+ }
+ }
+ c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "模板不存在"})
+}
+
+// ===== 命令执行 =====
+
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(),
- })
+ 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": results,
- })
+ 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": "参数错误",
- })
+ 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,
- })
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "任务已启动", "data": result})
}
-// ListTasks 获取任务列表
+// ===== 文件分发 =====
+
+func (h *AnsibleHandler) DistributeFile(c *gin.Context) {
+ var req models.FileDistributeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()})
+ return
+ }
+ if req.Content == "" && req.SourceFile == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "请提供文件内容或源文件路径"})
+ return
+ }
+ task, err := h.service.DistributeFile(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})
+}
+
+// ===== SSH密钥管理 =====
+
+func (h *AnsibleHandler) ListSSHKeys(c *gin.Context) {
+ keys := h.service.ListSSHKeys()
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": keys})
+}
+
+func (h *AnsibleHandler) AddSSHKey(c *gin.Context) {
+ var req models.SSHKeyCreateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()})
+ return
+ }
+ if err := h.service.AddSSHKey(req.Name, req.PrivateKey); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "SSH密钥添加成功"})
+}
+
+func (h *AnsibleHandler) DeleteSSHKey(c *gin.Context) {
+ name := c.Param("name")
+ if err := h.service.DeleteSSHKey(name); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "SSH密钥已删除"})
+}
+
+// ===== 任务管理 =====
+
func (h *AnsibleHandler) ListTasks(c *gin.Context) {
tasks := h.service.ListTasks()
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": tasks,
- })
+ 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": "任务不存在",
- })
+ c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "任务不存在"})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "success",
- "data": task,
- })
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": task})
}
-// StreamTaskOutput SSE 流式推送任务日志
func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) {
id := c.Param("id")
task := h.service.GetTask(id)
@@ -341,34 +362,26 @@ func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) {
lastLen := 0
for {
- // 检查客户端是否断开
if c.Request.Context().Err() != nil {
return
}
-
task := h.service.GetTask(id)
if task == nil {
return
}
-
output := task.Output
if len(output) > lastLen {
- // 只发送增量
increment := output[lastLen:]
lastLen = len(output)
c.SSEvent("log", increment)
c.Writer.Flush()
}
-
if task.Status != "running" {
- // 任务完成,发送最终状态
c.SSEvent("status", task.Status)
c.SSEvent("error", task.Error)
c.Writer.Flush()
return
}
-
- // 等 500ms 再推送
select {
case <-time.After(500 * time.Millisecond):
case <-c.Request.Context().Done():
@@ -377,132 +390,18 @@ func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) {
}
}
-// 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(),
- })
+ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()})
return
}
-
- c.JSON(http.StatusOK, gin.H{
- "code": 0,
- "msg": "任务已取消",
- })
+ 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连接",
- })
+func (h *AnsibleHandler) GetSystemInfo(c *gin.Context) {
+ info := h.service.GetSystemInfo()
+ c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": info})
}
diff --git a/internal/models/models.go b/internal/models/models.go
index 48aeda8..1e4db10 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -4,53 +4,41 @@ 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"`
+ 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"` // SSH密钥名称或路径
+ 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"`
+ Status string `json:"status"`
+ OS string `json:"os,omitempty"`
+ 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"` // 组内主机的详细信息
+ 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"`
+ Children []string `json:"children,omitempty"`
}
// Playbook Playbook定义
type Playbook struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Description string `json:"description"`
+ 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"`
+ Hosts string `json:"hosts"`
+ Tasks []Task `json:"tasks"`
}
// Task 任务定义
@@ -65,62 +53,62 @@ type Task struct {
// 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"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Playbook string `json:"playbook"`
+ Hosts []string `json:"hosts"`
+ Status string `json:"status"`
+ 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"`
+ 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"`
+ 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"`
+ 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"` // 自定义额外参数
+ Name string `json:"name" binding:"required"`
+ Hosts []string `json:"hosts"`
+ ExtraVars map[string]interface{} `json:"extra_vars"`
+ Tags []string `json:"tags,omitempty"`
+ SkipTags []string `json:"skip_tags,omitempty"`
+ Verbose string `json:"verbose,omitempty"`
+ Diff bool `json:"diff,omitempty"`
+ Check bool `json:"check,omitempty"`
+ Become *bool `json:"become,omitempty"`
+ Forks int `json:"forks,omitempty"`
+ Timeout int `json:"timeout,omitempty"`
+ ExtraArgs string `json:"extra_args,omitempty"`
}
// LogEntry 日志条目
@@ -130,3 +118,43 @@ type LogEntry struct {
Host string `json:"host"`
Message string `json:"message"`
}
+
+// SSHKey SSH密钥
+type SSHKey struct {
+ Name string `json:"name"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ Path string `json:"path"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// SSHKeyCreateRequest 创建SSH密钥请求
+type SSHKeyCreateRequest struct {
+ Name string `json:"name" binding:"required"`
+ PrivateKey string `json:"private_key" binding:"required"`
+}
+
+// FileDistributeRequest 文件分发请求
+type FileDistributeRequest struct {
+ Hosts []string `json:"hosts" binding:"required"`
+ Content string `json:"content"` // 文件内容(与SourceFile二选一)
+ SourceFile string `json:"source_file"` // 服务器上的源文件路径
+ DestPath string `json:"dest_path" binding:"required"` // 目标路径
+ Owner string `json:"owner"`
+ Group string `json:"group"`
+ Mode string `json:"mode"` // 如 0644
+}
+
+// SystemInfo 系统信息
+type SystemInfo struct {
+ Version string `json:"version"`
+ AnsiblePath string `json:"ansible_path"`
+ AnsibleVer string `json:"ansible_version"`
+ Hostname string `json:"hostname"`
+ OS string `json:"os"`
+ Arch string `json:"arch"`
+ HostCount int `json:"host_count"`
+ GroupCount int `json:"group_count"`
+ PlaybookCount int `json:"playbook_count"`
+ TaskCount int `json:"task_count"`
+ Uptime string `json:"uptime"`
+}
diff --git a/internal/services/ansible.go b/internal/services/ansible.go
index 6f2a5b4..0e2b1ac 100644
--- a/internal/services/ansible.go
+++ b/internal/services/ansible.go
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
+ "runtime"
"strconv"
"strings"
"sync"
@@ -23,43 +24,303 @@ import (
// AnsibleService Ansible服务
type AnsibleService struct {
- config *Config
- hosts map[string]*models.Host
- groups map[string]*models.HostGroup
+ config *Config
+ hosts map[string]*models.Host
+ groups map[string]*models.HostGroup
inventoryPath string
- tasks map[string]*models.TaskExecution
- taskLock sync.RWMutex
+ tasks map[string]*models.TaskExecution
+ taskLock sync.RWMutex
+ sshKeys map[string]*models.SSHKey
+ sshKeyDir string
+ startTime time.Time
}
// NewAnsibleService 创建Ansible服务
func NewAnsibleService(cfg *Config) *AnsibleService {
+ sshKeyDir := filepath.Join(cfg.InventoryDir, "ssh_keys")
+ os.MkdirAll(sshKeyDir, 0700)
+
svc := &AnsibleService{
- config: cfg,
- hosts: make(map[string]*models.Host),
- groups: make(map[string]*models.HostGroup),
+ 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),
+ tasks: make(map[string]*models.TaskExecution),
+ sshKeys: make(map[string]*models.SSHKey),
+ sshKeyDir: sshKeyDir,
+ startTime: time.Now(),
}
- // 初始化默认组
svc.groups["all"] = &models.HostGroup{Name: "all", Description: "所有主机"}
svc.groups["ungrouped"] = &models.HostGroup{Name: "ungrouped", Description: "未分组主机"}
- // 加载现有数据
svc.loadHosts()
svc.loadGroups()
+ svc.loadSSHKeys()
return svc
}
-// loadGroups 加载主机组列表
+// ===== SSH密钥管理 =====
+
+func (s *AnsibleService) loadSSHKeys() {
+ files, _ := os.ReadDir(s.sshKeyDir)
+ for _, f := range files {
+ if f.IsDir() || !strings.HasSuffix(f.Name(), ".pem") {
+ continue
+ }
+ name := strings.TrimSuffix(f.Name(), ".pem")
+ path := filepath.Join(s.sshKeyDir, f.Name())
+ fingerprint := s.getKeyFingerprint(path)
+ info, _ := f.Info()
+ s.sshKeys[name] = &models.SSHKey{
+ Name: name,
+ Path: path,
+ Fingerprint: fingerprint,
+ CreatedAt: info.ModTime(),
+ }
+ }
+}
+
+func (s *AnsibleService) getKeyFingerprint(path string) string {
+ cmd := exec.Command("ssh-keygen", "-lf", path)
+ out, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ fields := strings.Fields(string(out))
+ if len(fields) >= 2 {
+ return fields[1]
+ }
+ return ""
+}
+
+func (s *AnsibleService) ListSSHKeys() []models.SSHKey {
+ var keys []models.SSHKey
+ for _, k := range s.sshKeys {
+ keys = append(keys, *k)
+ }
+ return keys
+}
+
+func (s *AnsibleService) AddSSHKey(name string, privateKey string) error {
+ if name == "" {
+ return fmt.Errorf("密钥名称不能为空")
+ }
+ if strings.Contains(name, "/") || strings.Contains(name, "..") {
+ return fmt.Errorf("密钥名称包含非法字符")
+ }
+ keyPath := filepath.Join(s.sshKeyDir, name+".pem")
+ if _, err := os.Stat(keyPath); err == nil {
+ return fmt.Errorf("密钥已存在: %s", name)
+ }
+ if err := os.WriteFile(keyPath, []byte(privateKey), 0600); err != nil {
+ return fmt.Errorf("写入密钥失败: %v", err)
+ }
+ fingerprint := s.getKeyFingerprint(keyPath)
+ s.sshKeys[name] = &models.SSHKey{
+ Name: name,
+ Path: keyPath,
+ Fingerprint: fingerprint,
+ CreatedAt: time.Now(),
+ }
+ return nil
+}
+
+func (s *AnsibleService) DeleteSSHKey(name string) error {
+ keyPath := filepath.Join(s.sshKeyDir, name+".pem")
+ if _, err := os.Stat(keyPath); os.IsNotExist(err) {
+ return fmt.Errorf("密钥不存在: %s", name)
+ }
+ os.Remove(keyPath)
+ delete(s.sshKeys, name)
+ return nil
+}
+
+// resolveSSHKeyPath 解析SSH密钥路径(名称或完整路径)
+func (s *AnsibleService) resolveSSHKeyPath(keyRef string) string {
+ if keyRef == "" {
+ return ""
+ }
+ // 如果是完整路径,直接返回
+ if strings.HasPrefix(keyRef, "/") {
+ return keyRef
+ }
+ // 尝试从密钥库查找
+ if k, ok := s.sshKeys[keyRef]; ok {
+ return k.Path
+ }
+ // 尝试加.pem后缀
+ keyPath := filepath.Join(s.sshKeyDir, keyRef+".pem")
+ if _, err := os.Stat(keyPath); err == nil {
+ return keyPath
+ }
+ return keyRef
+}
+
+// ===== 文件分发 =====
+
+func (s *AnsibleService) DistributeFile(req models.FileDistributeRequest) (*models.TaskExecution, error) {
+ task := &models.TaskExecution{
+ ID: s.generateID(),
+ Name: "文件分发 → " + req.DestPath,
+ Hosts: req.Hosts,
+ Status: "running",
+ StartTime: time.Now(),
+ TotalHosts: len(req.Hosts),
+ }
+
+ s.taskLock.Lock()
+ s.tasks[task.ID] = task
+ s.taskLock.Unlock()
+
+ go s.runFileDistribute(task, req)
+ return task, nil
+}
+
+func (s *AnsibleService) runFileDistribute(task *models.TaskExecution, req models.FileDistributeRequest) {
+ var sw syncWriter
+ sw.buf = bytes.NewBuffer(nil)
+
+ for i, hostName := range req.Hosts {
+ host := s.findHostByName(hostName)
+ if host == nil {
+ sw.WriteString(fmt.Sprintf("[%s] ✗ 主机不存在\n", hostName))
+ s.taskLock.Lock()
+ task.FailedHosts++
+ task.Progress = i + 1
+ task.Output = sw.String()
+ s.taskLock.Unlock()
+ continue
+ }
+
+ start := time.Now()
+ var args []string
+
+ if req.Content != "" {
+ // 使用copy模块分发内容
+ args = []string{
+ host.Name, "-i", s.inventoryPath,
+ "-m", "copy",
+ "-a", fmt.Sprintf("content='%s' dest=%s", strings.ReplaceAll(req.Content, "'", "\\'"), req.DestPath),
+ "-u", host.Username,
+ }
+ } else {
+ // 使用copy模块分发文件
+ copyArgs := fmt.Sprintf("src=%s dest=%s", req.SourceFile, req.DestPath)
+ if req.Owner != "" {
+ copyArgs += " owner=" + req.Owner
+ }
+ if req.Group != "" {
+ copyArgs += " group=" + req.Group
+ }
+ if req.Mode != "" {
+ copyArgs += " mode=" + req.Mode
+ }
+ args = []string{
+ host.Name, "-i", s.inventoryPath,
+ "-m", "copy",
+ "-a", copyArgs,
+ "-u", host.Username,
+ }
+ }
+
+ // 认证
+ if host.AuthType == "sshkey" && host.SSHKey != "" {
+ keyPath := s.resolveSSHKeyPath(host.SSHKey)
+ args = append(args, "--private-key", keyPath)
+ } else if host.Password != "" {
+ args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
+ }
+ 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...)
+ cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
+ output, err := cmd.CombinedOutput()
+ duration := time.Since(start).Milliseconds()
+
+ s.taskLock.Lock()
+ if err != nil {
+ sw.WriteString(fmt.Sprintf("[%s] ✗ 失败 (%dms): %s\n", hostName, duration, string(output)))
+ task.FailedHosts++
+ } else {
+ sw.WriteString(fmt.Sprintf("[%s] ✓ 成功 (%dms)\n", hostName, duration))
+ task.SuccessHosts++
+ }
+ task.Progress = i + 1
+ task.Output = sw.String()
+ s.taskLock.Unlock()
+ }
+
+ s.taskLock.Lock()
+ task.EndTime = time.Now()
+ if task.FailedHosts > 0 {
+ task.Status = "failed"
+ } else {
+ task.Status = "success"
+ }
+ s.taskLock.Unlock()
+}
+
+// ===== 系统信息 =====
+
+func (s *AnsibleService) GetSystemInfo() models.SystemInfo {
+ info := models.SystemInfo{
+ Version: "2.0.0",
+ AnsiblePath: s.config.AnsiblePath,
+ Hostname: getHostname(),
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ HostCount: len(s.hosts),
+ TaskCount: len(s.tasks),
+ Uptime: time.Since(s.startTime).Round(time.Second).String(),
+ }
+
+ // 获取组数量(排除系统组)
+ groupCount := 0
+ for name := range s.groups {
+ if name != "all" && name != "ungrouped" {
+ groupCount++
+ }
+ }
+ info.GroupCount = groupCount
+
+ // Playbook数量
+ files, _ := os.ReadDir(s.config.PlaybookDir)
+ for _, f := range files {
+ if !f.IsDir() && strings.HasSuffix(f.Name(), ".yml") {
+ info.PlaybookCount++
+ }
+ }
+
+ // Ansible版本
+ cmd := exec.Command("ansible", "--version")
+ out, err := cmd.Output()
+ if err == nil {
+ lines := strings.Split(string(out), "\n")
+ if len(lines) > 0 {
+ info.AnsibleVer = strings.TrimSpace(lines[0])
+ }
+ }
+
+ return info
+}
+
+func getHostname() string {
+ h, _ := os.Hostname()
+ return h
+}
+
+// ===== 原有功能 =====
+
func (s *AnsibleService) loadGroups() {
groupsFile := filepath.Join(s.config.InventoryDir, "groups.json")
data, err := os.ReadFile(groupsFile)
if err != nil {
return
}
-
var groups map[string]models.HostGroup
if err := json.Unmarshal(data, &groups); err == nil {
for name, g := range groups {
@@ -71,22 +332,18 @@ func (s *AnsibleService) loadGroups() {
}
}
-// generateID 生成唯一ID
func (s *AnsibleService) generateID() string {
hash := md5.New()
- hash.Write([]byte(time.Now().String()))
+ hash.Write([]byte(time.Now().String() + strconv.Itoa(os.Getpid())))
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)
@@ -96,14 +353,10 @@ func (s *AnsibleService) loadInventory() {
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 {
@@ -113,15 +366,13 @@ func (s *AnsibleService) loadInventory() {
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],
+ ID: s.generateID(),
+ Name: matches[1],
+ IP: matches[2],
Status: "unknown",
}
s.hosts[host.ID] = host
@@ -130,70 +381,59 @@ func (s *AnsibleService) loadInventory() {
}
}
-// 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 {
- host := h // 避免循环变量指针问题
- if host.ID == "" {
- host.ID = s.generateID()
+ for _, h := range hosts {
+ host := h
+ if host.ID == "" {
+ host.ID = s.generateID()
+ }
+ if host.Port == 0 {
+ host.Port = 22
+ }
+ if host.Username == "" {
+ host.Username = "root"
+ }
+ if host.Status == "" {
+ host.Status = "pending"
+ }
+ s.hosts[host.ID] = &host
}
- if host.Port == 0 {
- host.Port = 22
- }
- if host.Username == "" {
- host.Username = "root"
- }
- if host.Status == "" {
- host.Status = "pending"
- }
- s.hosts[host.ID] = &host
- }
- // 保存以持久化补全的字段
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 {
hcopy := *h
- // 确保每个主机都有ID,并更新map中的指针
if hcopy.ID == "" {
hcopy.ID = s.generateID()
- h.ID = hcopy.ID // 更新map中的指针
+ h.ID = hcopy.ID
}
hosts = append(hosts, hcopy)
}
-
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 {
@@ -205,7 +445,6 @@ func (s *AnsibleService) updateInventoryFile() {
}
}
- // 输出每个组
for group, hosts := range groupedHosts {
lines = append(lines, fmt.Sprintf("[%s]", group))
for _, h := range hosts {
@@ -217,7 +456,8 @@ func (s *AnsibleService) updateInventoryFile() {
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)
+ keyPath := s.resolveSSHKeyPath(h.SSHKey)
+ line += fmt.Sprintf(" ansible_ssh_private_key_file=%s", keyPath)
}
lines = append(lines, line)
}
@@ -228,7 +468,6 @@ func (s *AnsibleService) updateInventoryFile() {
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 {
@@ -237,18 +476,15 @@ func (s *AnsibleService) ListHosts() []models.Host {
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("主机不存在")
@@ -257,26 +493,32 @@ func (s *AnsibleService) DeleteHost(id string) error {
return s.saveHosts()
}
-// UpdateHost 更新主机
func (s *AnsibleService) UpdateHost(id string, host models.Host) error {
if _, ok := s.hosts[id]; !ok {
return fmt.Errorf("主机不存在")
}
+ host.ID = id
host.UpdatedAt = time.Now()
s.hosts[id] = &host
return s.saveHosts()
}
-// ListGroups 获取主机组列表
+func (s *AnsibleService) findHostByName(name string) *models.Host {
+ for _, h := range s.hosts {
+ if h.Name == name {
+ return h
+ }
+ }
+ return nil
+}
+
func (s *AnsibleService) ListGroups() []models.HostGroup {
var groups []models.HostGroup
for _, g := range s.groups {
gcopy := *g
- // 动态展开组内主机(通过 host.Groups 字段关联,而非 group.Hosts)
var hostList []models.Host
for _, h := range s.hosts {
if gcopy.Name == "all" {
- // all 组包含所有主机
hcopy := *h
hostList = append(hostList, hcopy)
continue
@@ -288,7 +530,6 @@ func (s *AnsibleService) ListGroups() []models.HostGroup {
break
}
}
- // 也检查主机的默认组(ungrouped)
if len(h.Groups) == 0 && gcopy.Name == "ungrouped" {
hcopy := *h
hostList = append(hostList, hcopy)
@@ -300,7 +541,6 @@ func (s *AnsibleService) ListGroups() []models.HostGroup {
return groups
}
-// CreateGroup 创建主机组
func (s *AnsibleService) CreateGroup(group models.HostGroup) error {
if _, ok := s.groups[group.Name]; ok {
return fmt.Errorf("组已存在")
@@ -309,7 +549,6 @@ func (s *AnsibleService) CreateGroup(group models.HostGroup) error {
return s.saveGroups()
}
-// DeleteGroup 删除主机组
func (s *AnsibleService) DeleteGroup(name string) error {
if name == "all" || name == "ungrouped" {
return fmt.Errorf("不能删除系统组")
@@ -318,7 +557,6 @@ func (s *AnsibleService) DeleteGroup(name string) error {
return s.saveGroups()
}
-// UpdateGroup 更新主机组
func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error {
if _, ok := s.groups[name]; !ok {
return fmt.Errorf("组不存在")
@@ -327,14 +565,12 @@ func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error
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 {
@@ -342,12 +578,8 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e
}
start := time.Now()
- result := &models.CommandResult{
- Host: host.Name,
- Success: false,
- }
+ result := &models.CommandResult{Host: host.Name, Success: false}
- // 构建ansible命令
args := []string{
host.Name,
"-i", s.inventoryPath,
@@ -355,22 +587,17 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e
"-u", host.Username,
}
- // 认证方式:SSH Key 或 密码
if host.AuthType == "sshkey" && host.SSHKey != "" {
- // SSH Key 认证
- args = append(args, "--private-key", host.SSHKey)
+ keyPath := s.resolveSSHKeyPath(host.SSHKey)
+ args = append(args, "--private-key", keyPath)
} 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()
@@ -388,41 +615,25 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e
}
}
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,
- }
+ result := models.CommandResult{Host: hostName, Success: false}
- // 查找主机获取认证信息
- var host *models.Host
- for _, h := range s.hosts {
- if h.Name == hostName {
- host = h
- break
- }
- }
+ host := s.findHostByName(hostName)
if host == nil {
result.Error = "主机不存在"
return result
@@ -443,20 +654,17 @@ func (s *AnsibleService) runCommand(hostName string, command string, timeout int
"-u", host.Username,
}
- // 认证方式:SSH Key 或 密码
if host.AuthType == "sshkey" && host.SSHKey != "" {
- args = append(args, "--private-key", host.SSHKey)
+ keyPath := s.resolveSSHKeyPath(host.SSHKey)
+ args = append(args, "--private-key", keyPath)
} 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()
@@ -475,7 +683,6 @@ func (s *AnsibleService) runCommand(hostName string, command string, timeout int
return result
}
-// BatchExecute 批量执行命令
func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCommandResult {
result := &models.BatchCommandResult{
TaskID: s.generateID(),
@@ -484,11 +691,11 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo
}
task := &models.TaskExecution{
- ID: result.TaskID,
- Name: "批量命令执行",
- Hosts: req.Hosts,
- Status: "running",
- StartTime: time.Now(),
+ ID: result.TaskID,
+ Name: "批量命令执行",
+ Hosts: req.Hosts,
+ Status: "running",
+ StartTime: time.Now(),
TotalHosts: len(req.Hosts),
}
@@ -496,11 +703,9 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo
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
@@ -513,7 +718,6 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
-
r := s.runCommand(h, req.Command, req.Timeout)
results <- r
}(host)
@@ -534,7 +738,6 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo
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)
@@ -553,11 +756,9 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo
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
@@ -568,11 +769,9 @@ func (s *AnsibleService) updateTaskProgress(taskID string, increment int) {
}
}
-// 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)
@@ -580,18 +779,15 @@ func (s *AnsibleService) ListTasks() []*models.TaskExecution {
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"
@@ -603,80 +799,60 @@ func (s *AnsibleService) CancelTask(id string) error {
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),
+ ID: s.generateID(),
+ Name: req.Name,
+ Playbook: playbookPath,
+ Hosts: req.Hosts,
+ Status: "running",
+ StartTime: time.Now(),
+ TotalHosts: len(req.Hosts),
SuccessHosts: 0,
- FailedHosts: 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")
@@ -684,37 +860,28 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st
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...)
}
- // 实时写入日志的 Writer
sw := &syncWriter{buf: bytes.NewBuffer(nil)}
cmd.Stdout = sw
cmd.Stderr = sw
- // 启动 goroutine 实时搬运日志到 task.Output
done := make(chan struct{})
go func() {
ticker := time.NewTicker(200 * time.Millisecond)
@@ -739,9 +906,8 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st
}()
err := cmd.Run()
- close(done) // 通知 goroutine 退出
+ close(done)
- // 最终同步一次完整日志
sw.mu.Lock()
finalOutput := sw.buf.String()
sw.mu.Unlock()
@@ -758,7 +924,6 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st
s.taskLock.Unlock()
}
-// syncWriter 线程安全的 Writer
type syncWriter struct {
buf *bytes.Buffer
mu sync.Mutex
@@ -776,32 +941,29 @@ func (w *syncWriter) String() string {
return w.buf.String()
}
-// ListPlaybooks 列出可用Playbooks
+func (w *syncWriter) WriteString(s string) (n int, err error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.WriteString(s)
+}
+
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,
- }
+ 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
@@ -809,158 +971,75 @@ func (s *AnsibleService) ListPlaybooks() []models.Playbook {
}
}
}
-
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 {
@@ -969,14 +1048,12 @@ func (s *AnsibleService) GetPlaybookContent(name string) (string, error) {
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
}
diff --git a/internal/services/templates.go b/internal/services/templates.go
new file mode 100644
index 0000000..99acb38
--- /dev/null
+++ b/internal/services/templates.go
@@ -0,0 +1,1658 @@
+package services
+
+// PlaybookTemplate Playbook模板定义
+type PlaybookTemplate struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Category string `json:"category"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ Content string `json:"content"`
+ Tags []string `json:"tags"`
+}
+
+// TemplateCategory 模板分类
+type TemplateCategory struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ Count int `json:"count"`
+}
+
+// GetTemplateCategories 获取所有模板分类
+func GetTemplateCategories() []TemplateCategory {
+ cats := map[string]TemplateCategory{}
+ for _, t := range GetPlaybookTemplates() {
+ c, ok := cats[t.Category]
+ if !ok {
+ c = TemplateCategory{ID: t.Category, Name: categoryNames[t.Category], Icon: categoryIcons[t.Category]}
+ }
+ c.Count++
+ cats[t.Category] = c
+ }
+ var result []TemplateCategory
+ // 按固定顺序输出
+ order := []string{"system", "web", "database", "container", "devops", "security", "monitoring"}
+ for _, id := range order {
+ if c, ok := cats[id]; ok {
+ result = append(result, c)
+ }
+ }
+ return result
+}
+
+var categoryNames = map[string]string{
+ "system": "系统管理",
+ "web": "Web服务",
+ "database": "数据库",
+ "container": "容器化",
+ "devops": "DevOps",
+ "security": "安全加固",
+ "monitoring": "监控告警",
+}
+
+var categoryIcons = map[string]string{
+ "system": "🖥️",
+ "web": "🌐",
+ "database": "🗄️",
+ "container": "🐳",
+ "devops": "🔧",
+ "security": "🔒",
+ "monitoring": "📊",
+}
+
+// GetPlaybookTemplates 获取所有Playbook模板
+func GetPlaybookTemplates() []PlaybookTemplate {
+ return []PlaybookTemplate{
+ // ===== 系统管理 =====
+ {
+ ID: "update-packages", Name: "系统包更新", Category: "system",
+ Description: "自动更新系统软件包,支持Debian/RedHat系列",
+ Icon: "📦", Tags: []string{"update", "apt", "yum"},
+ Content: `---
+- name: 系统包更新
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ update_cache: yes
+ upgrade_type: dist # dist(完整升级) 或 safe(安全升级)
+
+ tasks:
+ - name: 更新apt缓存
+ apt:
+ update_cache: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 升级所有包(Debian)
+ apt:
+ upgrade: "{{ upgrade_type }}"
+ autoremove: yes
+ autoclean: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 更新yum缓存
+ yum:
+ update_cache: yes
+ when: ansible_os_family == "RedHat"
+
+ - name: 升级所有包(RedHat)
+ yum:
+ name: "*"
+ state: latest
+ when: ansible_os_family == "RedHat"
+
+ - name: 检查是否需要重启
+ stat:
+ path: /var/run/reboot-required
+ register: reboot_required
+ when: ansible_os_family == "Debian"
+
+ - name: 提示重启
+ debug:
+ msg: "⚠️ 系统需要重启以完成更新"
+ when: ansible_os_family == "Debian" and reboot_required.stat.exists
+`,
+ },
+ {
+ ID: "check-system", Name: "系统信息采集", Category: "system",
+ Description: "采集操作系统、硬件、网络等详细信息",
+ Icon: "🔍", Tags: []string{"info", "facts", "inventory"},
+ Content: `---
+- name: 系统信息采集
+ hosts: "{{ target_hosts | default('all') }}"
+ gather_facts: yes
+
+ tasks:
+ - name: 操作系统信息
+ debug:
+ msg: |
+ ══════════ 系统信息 ══════════
+ 主机名: {{ ansible_facts['hostname'] }}
+ 系统: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}
+ 内核: {{ ansible_facts['kernel'] }}
+ 架构: {{ ansible_facts['architecture'] }}
+ CPU: {{ ansible_facts['processor_vcpus'] }} vCPUs
+ 内存: {{ (ansible_facts['memtotal_mb'] / 1024) | round(2) }} GB
+ IP: {{ ansible_facts['default_ipv4']['address'] | default('N/A') }}
+ ═══════════════════════════════
+
+ - name: 磁盘使用
+ shell: df -h | grep -E '^/dev/'
+ register: disk_info
+
+ - name: 显示磁盘
+ debug:
+ msg: "{{ disk_info.stdout_lines }}"
+
+ - name: 运行时间
+ shell: uptime -p
+ register: uptime_info
+
+ - name: 显示运行时间
+ debug:
+ msg: "运行时间: {{ uptime_info.stdout }}"
+`,
+ },
+ {
+ ID: "check-resources", Name: "资源监控检查", Category: "system",
+ Description: "检查CPU/内存/磁盘使用率,超阈值告警",
+ Icon: "📈", Tags: []string{"monitor", "cpu", "memory", "disk"},
+ Content: `---
+- name: 资源监控检查
+ hosts: "{{ target_hosts | default('all') }}"
+ gather_facts: yes
+ vars:
+ warn_threshold: 80
+ crit_threshold: 90
+
+ tasks:
+ - name: 获取CPU使用率
+ shell: top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1
+ register: cpu_result
+ changed_when: false
+
+ - name: 获取内存使用率
+ shell: free | grep Mem | awk '{printf "%.0f", $3/$2*100}'
+ register: mem_result
+ changed_when: false
+
+ - name: 获取磁盘使用率
+ shell: df -h / | tail -1 | awk '{print $5}' | tr -d '%'
+ register: disk_result
+ changed_when: false
+
+ - name: 显示资源报告
+ debug:
+ msg: |
+ ═══════ {{ inventory_hostname }} 资源报告 ═══════
+ CPU 使用率: {{ cpu_result.stdout }}%
+ 内存使用率: {{ mem_result.stdout }}%
+ 磁盘使用率: {{ disk_result.stdout }}%
+ 告警阈值: {{ warn_threshold }}% / 严重: {{ crit_threshold }}%
+ ═══════════════════════════════════════
+
+ - name: 严重告警
+ fail:
+ msg: "🚨 严重: {{ inventory_hostname }} 资源使用率超过 {{ crit_threshold }}%"
+ when: >
+ (cpu_result.stdout | int >= crit_threshold) or
+ (mem_result.stdout | int >= crit_threshold) or
+ (disk_result.stdout | int >= crit_threshold)
+`,
+ },
+ {
+ ID: "log-cleanup", Name: "日志清理", Category: "system",
+ Description: "清理系统日志、临时文件,释放磁盘空间",
+ Icon: "🧹", Tags: []string{"cleanup", "log", "disk"},
+ Content: `---
+- name: 日志清理
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ log_retention_days: 30
+ tmp_retention_days: 7
+
+ tasks:
+ - name: 清理journal日志
+ shell: journalctl --vacuum-time={{ log_retention_days }}d
+ register: journal_result
+ changed_when: "'Vacuuming' in journal_result.stdout"
+
+ - name: 清理旧日志文件
+ find:
+ paths: /var/log
+ patterns: "*.gz,*.old,*.[0-9]"
+ age: "{{ log_retention_days }}d"
+ register: old_logs
+
+ - name: 删除旧日志
+ file:
+ path: "{{ item.path }}"
+ state: absent
+ loop: "{{ old_logs.files }}"
+
+ - name: 清理临时文件
+ find:
+ paths: /tmp
+ age: "{{ tmp_retention_days }}d"
+ register: tmp_files
+
+ - name: 删除临时文件
+ file:
+ path: "{{ item.path }}"
+ state: absent
+ loop: "{{ tmp_files.files }}"
+ ignore_errors: yes
+
+ - name: 清理apt缓存
+ apt:
+ autoclean: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 显示磁盘空间
+ shell: df -h /
+ register: disk_after
+
+ - name: 清理完成
+ debug:
+ msg: "{{ disk_after.stdout_lines }}"
+`,
+ },
+ {
+ ID: "time-sync", Name: "时间同步(NTP)", Category: "system",
+ Description: "配置NTP时间同步,确保集群时间一致",
+ Icon: "⏰", Tags: []string{"ntp", "time", "chrony"},
+ Content: `---
+- name: 时间同步配置
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ ntp_servers:
+ - ntp.aliyun.com
+ - ntp1.aliyun.com
+ - pool.ntp.org
+
+ tasks:
+ - name: 安装chrony
+ package:
+ name: chrony
+ state: present
+
+ - name: 配置NTP服务器
+ lineinfile:
+ path: /etc/chrony.conf
+ regexp: "^server "
+ line: "server {{ item }} iburst"
+ create: yes
+ loop: "{{ ntp_servers }}"
+
+ - name: 启动chrony
+ service:
+ name: chronyd
+ state: started
+ enabled: yes
+
+ - name: 验证时间同步
+ shell: chronyc tracking
+ register: chrony_status
+ changed_when: false
+
+ - name: 显示同步状态
+ debug:
+ msg: "{{ chrony_status.stdout_lines }}"
+`,
+ },
+
+ // ===== Web服务 =====
+ {
+ ID: "deploy-nginx", Name: "部署Nginx", Category: "web",
+ Description: "安装配置Nginx,支持自定义站点和反向代理",
+ Icon: "🌐", Tags: []string{"nginx", "web", "proxy"},
+ Content: `---
+- name: 部署Nginx
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ nginx_worker_processes: auto
+ nginx_worker_connections: 1024
+ server_name: localhost
+ listen_port: 80
+ proxy_pass: ""
+ root_dir: /var/www/html
+
+ tasks:
+ - name: 安装Nginx
+ package:
+ name: nginx
+ state: present
+
+ - name: 创建网站目录
+ file:
+ path: "{{ root_dir }}"
+ state: directory
+ owner: www-data
+ group: www-data
+ mode: '0755'
+ when: ansible_os_family == "Debian"
+
+ - name: 创建网站目录(RedHat)
+ file:
+ path: "{{ root_dir }}"
+ state: directory
+ owner: nginx
+ group: nginx
+ mode: '0755'
+ when: ansible_os_family == "RedHat"
+
+ - name: 部署默认页面
+ copy:
+ content: |
+
+
{{ server_name }}
+ Deployed by Ansible Deploy
+ Server: {{ inventory_hostname }}
+ dest: "{{ root_dir }}/index.html"
+ when: proxy_pass == ""
+
+ - name: 配置Nginx站点
+ copy:
+ content: |
+ server {
+ listen {{ listen_port }};
+ server_name {{ server_name }};
+ {% if proxy_pass != "" %}
+ location / {
+ proxy_pass {{ proxy_pass }};
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+ {% else %}
+ root {{ root_dir }};
+ index index.html;
+ location / {
+ try_files $uri $uri/ =404;
+ }
+ {% endif %}
+ }
+ dest: /etc/nginx/conf.d/{{ server_name }}.conf
+ notify: Reload Nginx
+
+ - name: 启动Nginx
+ service:
+ name: nginx
+ state: started
+ enabled: yes
+
+ handlers:
+ - name: Reload Nginx
+ service:
+ name: nginx
+ state: reloaded
+`,
+ },
+ {
+ ID: "deploy-apache", Name: "部署Apache", Category: "web",
+ Description: "安装配置Apache HTTP Server",
+ Icon: "🪶", Tags: []string{"apache", "httpd", "web"},
+ Content: `---
+- name: 部署Apache
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ server_name: localhost
+ listen_port: 80
+ document_root: /var/www/html
+
+ tasks:
+ - name: 安装Apache(Debian)
+ apt:
+ name: apache2
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Apache(RedHat)
+ yum:
+ name: httpd
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 创建网站目录
+ file:
+ path: "{{ document_root }}"
+ state: directory
+ mode: '0755'
+
+ - name: 部署测试页面
+ copy:
+ content: "Apache on {{ inventory_hostname }}
"
+ dest: "{{ document_root }}/index.html"
+
+ - name: 启动Apache(Debian)
+ service:
+ name: apache2
+ state: started
+ enabled: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 启动Apache(RedHat)
+ service:
+ name: httpd
+ state: started
+ enabled: yes
+ when: ansible_os_family == "RedHat"
+`,
+ },
+
+ // ===== 数据库 =====
+ {
+ ID: "deploy-mysql", Name: "部署MySQL", Category: "database",
+ Description: "安装MySQL/MariaDB,配置root密码和基础优化",
+ Icon: "🐬", Tags: []string{"mysql", "mariadb", "database"},
+ Content: `---
+- name: 部署MySQL
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ mysql_root_password: "ChangeMe@2024"
+ mysql_port: 3306
+ mysql_max_connections: 500
+ mysql_datadir: /var/lib/mysql
+
+ tasks:
+ - name: 安装MySQL(Debian)
+ apt:
+ name:
+ - mysql-server
+ - mysql-client
+ - python3-pymysql
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 安装MariaDB(RedHat)
+ yum:
+ name:
+ - mariadb-server
+ - mariadb
+ - python3-PyMySQL
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 启动MySQL
+ service:
+ name: "{{ 'mysql' if ansible_os_family == 'Debian' else 'mariadb' }}"
+ state: started
+ enabled: yes
+
+ - name: 设置root密码
+ shell: |
+ mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mysql_root_password }}'; FLUSH PRIVILEGES;"
+ ignore_errors: yes
+ no_log: true
+
+ - name: 优化配置
+ copy:
+ content: |
+ [mysqld]
+ port = {{ mysql_port }}
+ max_connections = {{ mysql_max_connections }}
+ datadir = {{ mysql_datadir }}
+ innodb_buffer_pool_size = 256M
+ character-set-server = utf8mb4
+ collation-server = utf8mb4_unicode_ci
+ slow_query_log = 1
+ slow_query_log_file = /var/log/mysql/slow.log
+ long_query_time = 2
+ dest: /etc/mysql/conf.d/optimize.cnf
+ when: ansible_os_family == "Debian"
+ notify: Restart MySQL
+
+ - name: 验证MySQL
+ shell: mysql -u root -p{{ mysql_root_password }} -e "SELECT VERSION();"
+ register: mysql_version
+ changed_when: false
+ no_log: true
+
+ - name: 显示版本
+ debug:
+ msg: "MySQL版本: {{ mysql_version.stdout_lines[-1] | default('unknown') }}"
+
+ handlers:
+ - name: Restart MySQL
+ service:
+ name: "{{ 'mysql' if ansible_os_family == 'Debian' else 'mariadb' }}"
+ state: restarted
+`,
+ },
+ {
+ ID: "deploy-redis", Name: "部署Redis", Category: "database",
+ Description: "安装Redis,配置密码、内存限制和持久化",
+ Icon: "🔴", Tags: []string{"redis", "cache", "database"},
+ Content: `---
+- name: 部署Redis
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ redis_port: 6379
+ redis_password: "ChangeMe@2024"
+ redis_maxmemory: "512mb"
+ redis_maxmemory_policy: allkeys-lru
+ redis_bind: "0.0.0.0"
+
+ tasks:
+ - name: 安装Redis
+ package:
+ name: redis-server
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Redis(RedHat)
+ yum:
+ name: redis
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 配置Redis
+ lineinfile:
+ path: /etc/redis/redis.conf
+ regexp: "{{ item.regexp }}"
+ line: "{{ item.line }}"
+ loop:
+ - { regexp: '^bind ', line: 'bind {{ redis_bind }}' }
+ - { regexp: '^port ', line: 'port {{ redis_port }}' }
+ - { regexp: '^requirepass ', line: 'requirepass {{ redis_password }}' }
+ - { regexp: '^maxmemory ', line: 'maxmemory {{ redis_maxmemory }}' }
+ - { regexp: '^maxmemory-policy ', line: 'maxmemory-policy {{ redis_maxmemory_policy }}' }
+ notify: Restart Redis
+
+ - name: 启动Redis
+ service:
+ name: "{{ 'redis-server' if ansible_os_family == 'Debian' else 'redis' }}"
+ state: started
+ enabled: yes
+
+ - name: 验证Redis
+ shell: redis-cli -a {{ redis_password }} ping
+ register: redis_ping
+ changed_when: false
+ no_log: true
+
+ - name: 显示状态
+ debug:
+ msg: "Redis状态: {{ redis_ping.stdout }}"
+
+ handlers:
+ - name: Restart Redis
+ service:
+ name: "{{ 'redis-server' if ansible_os_family == 'Debian' else 'redis' }}"
+ state: restarted
+`,
+ },
+ {
+ ID: "deploy-postgresql", Name: "部署PostgreSQL", Category: "database",
+ Description: "安装PostgreSQL,配置用户和基础优化",
+ Icon: "🐘", Tags: []string{"postgresql", "postgres", "database"},
+ Content: `---
+- name: 部署PostgreSQL
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ postgres_version: "15"
+ postgres_password: "ChangeMe@2024"
+ postgres_port: 5432
+ postgres_max_connections: 200
+
+ tasks:
+ - name: 安装PostgreSQL(Debian)
+ apt:
+ name:
+ - postgresql
+ - postgresql-contrib
+ - python3-psycopg2
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 安装PostgreSQL(RedHat)
+ yum:
+ name:
+ - postgresql-server
+ - postgresql-contrib
+ - python3-psycopg2
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 初始化数据库(RedHat)
+ shell: postgresql-setup --initdb
+ when: ansible_os_family == "RedHat"
+ ignore_errors: yes
+
+ - name: 启动PostgreSQL
+ service:
+ name: postgresql
+ state: started
+ enabled: yes
+
+ - name: 设置postgres密码
+ become_user: postgres
+ shell: psql -c "ALTER USER postgres PASSWORD '{{ postgres_password }}';"
+ no_log: true
+
+ - name: 验证安装
+ become_user: postgres
+ shell: psql -c "SELECT version();"
+ register: pg_version
+ changed_when: false
+
+ - name: 显示版本
+ debug:
+ msg: "{{ pg_version.stdout_lines[0] | default('PostgreSQL installed') }}"
+`,
+ },
+
+ // ===== 容器化 =====
+ {
+ ID: "deploy-docker", Name: "部署Docker", Category: "container",
+ Description: "安装Docker CE + Docker Compose,配置镜像加速",
+ Icon: "🐳", Tags: []string{"docker", "container", "compose"},
+ Content: `---
+- name: 部署Docker
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ docker_mirror: "https://mirror.ccs.tencentyun.com"
+ docker_data_root: /var/lib/docker
+ docker_log_max_size: "100m"
+ docker_log_max_file: "3"
+
+ tasks:
+ - name: 安装依赖(Debian)
+ apt:
+ name:
+ - apt-transport-https
+ - ca-certificates
+ - curl
+ - gnupg
+ - lsb-release
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 添加Docker GPG密钥
+ shell: |
+ curl -fsSL https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
+ when: ansible_os_family == "Debian"
+ ignore_errors: yes
+
+ - name: 添加Docker仓库
+ shell: |
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable" > /etc/apt/sources.list.d/docker.list
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Docker(Debian)
+ apt:
+ name:
+ - docker-ce
+ - docker-ce-cli
+ - containerd.io
+ - docker-compose-plugin
+ state: present
+ update_cache: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Docker(RedHat)
+ yum:
+ name:
+ - docker
+ - docker-compose-plugin
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 配置Docker
+ copy:
+ content: |
+ {
+ "data-root": "{{ docker_data_root }}",
+ "registry-mirrors": ["{{ docker_mirror }}"],
+ "log-driver": "json-file",
+ "log-opts": {
+ "max-size": "{{ docker_log_max_size }}",
+ "max-file": "{{ docker_log_max_file }}"
+ },
+ "storage-driver": "overlay2",
+ "live-restore": true
+ }
+ dest: /etc/docker/daemon.json
+ notify: Restart Docker
+
+ - name: 启动Docker
+ service:
+ name: docker
+ state: started
+ enabled: yes
+
+ - name: 添加用户到docker组
+ user:
+ name: "{{ ansible_user }}"
+ groups: docker
+ append: yes
+ ignore_errors: yes
+
+ - name: 验证Docker
+ shell: docker version --format '{{.Server.Version}}'
+ register: docker_ver
+ changed_when: false
+
+ - name: 显示版本
+ debug:
+ msg: "Docker版本: {{ docker_ver.stdout }}"
+
+ handlers:
+ - name: Restart Docker
+ service:
+ name: docker
+ state: restarted
+`,
+ },
+ {
+ ID: "deploy-portainer", Name: "部署Portainer", Category: "container",
+ Description: "部署Portainer CE容器管理面板",
+ Icon: "🎛️", Tags: []string{"portainer", "docker", "ui"},
+ Content: `---
+- name: 部署Portainer
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ portainer_port: 9443
+ portainer_data: /opt/portainer
+
+ tasks:
+ - name: 创建数据目录
+ file:
+ path: "{{ portainer_data }}"
+ state: directory
+
+ - name: 拉取并启动Portainer
+ shell: |
+ docker volume create portainer_data
+ docker run -d \
+ -p 8000:8000 \
+ -p {{ portainer_port }}:9443 \
+ --name portainer \
+ --restart=always \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v portainer_data:/data \
+ portainer/portainer-ce:latest
+ register: portainer_result
+ ignore_errors: yes
+
+ - name: 显示访问地址
+ debug:
+ msg: "Portainer已部署: https://{{ ansible_host | default(inventory_hostname) }}:{{ portainer_port }}"
+`,
+ },
+
+ // ===== DevOps =====
+ {
+ ID: "deploy-nodejs", Name: "部署Node.js", Category: "devops",
+ Description: "安装Node.js + PM2进程管理器",
+ Icon: "💚", Tags: []string{"nodejs", "npm", "pm2"},
+ Content: `---
+- name: 部署Node.js
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ node_version: "20"
+ install_pm2: true
+
+ tasks:
+ - name: 安装NodeSource仓库
+ shell: |
+ curl -fsSL https://deb.nodesource.com/setup_{{ node_version }}.x | bash -
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Node.js(Debian)
+ apt:
+ name: nodejs
+ state: present
+ update_cache: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Node.js(RedHat)
+ yum:
+ name: nodejs
+ state: present
+ when: ansible_os_family == "RedHat"
+
+ - name: 安装PM2
+ npm:
+ name: pm2
+ global: yes
+ when: install_pm2
+
+ - name: 设置PM2开机启动
+ shell: pm2 startup systemd -u {{ ansible_user }} --hp /home/{{ ansible_user }}
+ when: install_pm2
+ ignore_errors: yes
+
+ - name: 验证安装
+ shell: node --version && npm --version
+ register: node_ver
+ changed_when: false
+
+ - name: 显示版本
+ debug:
+ msg: "{{ node_ver.stdout_lines }}"
+`,
+ },
+ {
+ ID: "deploy-python-app", Name: "部署Python应用", Category: "devops",
+ Description: "配置Python环境 + Gunicorn + Systemd服务",
+ Icon: "🐍", Tags: []string{"python", "gunicorn", "flask", "django"},
+ Content: `---
+- name: 部署Python应用
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ app_name: myapp
+ app_dir: /opt/myapp
+ python_version: python3
+ venv_dir: /opt/myapp/venv
+ gunicorn_port: 8000
+ gunicorn_workers: 4
+ app_user: www-data
+
+ tasks:
+ - name: 安装Python和依赖
+ package:
+ name:
+ - "{{ python_version }}"
+ - "{{ python_version }}-venv"
+ - python3-pip
+ state: present
+
+ - name: 创建应用目录
+ file:
+ path: "{{ app_dir }}"
+ state: directory
+ owner: "{{ app_user }}"
+ mode: '0755'
+
+ - name: 创建虚拟环境
+ shell: "{{ python_version }} -m venv {{ venv_dir }}"
+ args:
+ creates: "{{ venv_dir }}/bin/activate"
+
+ - name: 安装Gunicorn
+ pip:
+ name: gunicorn
+ virtualenv: "{{ venv_dir }}"
+
+ - name: 创建Systemd服务
+ copy:
+ content: |
+ [Unit]
+ Description={{ app_name }} Gunicorn Service
+ After=network.target
+
+ [Service]
+ User={{ app_user }}
+ WorkingDirectory={{ app_dir }}
+ ExecStart={{ venv_dir }}/bin/gunicorn --workers {{ gunicorn_workers }} --bind 0.0.0.0:{{ gunicorn_port }} app:app
+ Restart=always
+
+ [Install]
+ WantedBy=multi-user.target
+ dest: /etc/systemd/system/{{ app_name }}.service
+ notify: Restart App
+
+ - name: 启动服务
+ systemd:
+ name: "{{ app_name }}"
+ state: started
+ enabled: yes
+ daemon_reload: yes
+
+ handlers:
+ - name: Restart App
+ systemd:
+ name: "{{ app_name }}"
+ state: restarted
+ daemon_reload: yes
+`,
+ },
+ {
+ ID: "backup-data", Name: "数据备份", Category: "devops",
+ Description: "定时备份指定目录到本地或远程",
+ Icon: "💾", Tags: []string{"backup", "cron", "archive"},
+ Content: `---
+- name: 数据备份
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ backup_source: /data
+ backup_dest: /backup
+ backup_retention_days: 7
+ backup_hour: "2"
+ backup_minute: "0"
+
+ tasks:
+ - name: 创建备份目录
+ file:
+ path: "{{ backup_dest }}"
+ state: directory
+ mode: '0700'
+
+ - name: 创建备份脚本
+ copy:
+ content: |
+ #!/bin/bash
+ DATE=$(date +%Y%m%d_%H%M%S)
+ BACKUP_FILE="{{ backup_dest }}/backup_${DATE}.tar.gz"
+ tar -czf "$BACKUP_FILE" {{ backup_source }} 2>/dev/null
+ find {{ backup_dest }} -name "backup_*.tar.gz" -mtime +{{ backup_retention_days }} -delete
+ echo "Backup completed: $BACKUP_FILE"
+ dest: /usr/local/bin/backup.sh
+ mode: '0755'
+
+ - name: 配置定时任务
+ cron:
+ name: "daily-backup"
+ hour: "{{ backup_hour }}"
+ minute: "{{ backup_minute }}"
+ job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1"
+
+ - name: 立即执行一次备份
+ shell: /usr/local/bin/backup.sh
+ register: backup_result
+
+ - name: 备份结果
+ debug:
+ msg: "{{ backup_result.stdout }}"
+`,
+ },
+
+ // ===== 安全加固 =====
+ {
+ ID: "security-hardening", Name: "安全加固", Category: "security",
+ Description: "SSH加固、防火墙配置、禁用root远程登录",
+ Icon: "🔒", Tags: []string{"security", "ssh", "firewall", "hardening"},
+ Content: `---
+- name: 安全加固
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ ssh_port: 22
+ disable_root_login: true
+ enable_firewall: true
+ allowed_ssh_users: []
+
+ tasks:
+ - name: 修改SSH端口
+ lineinfile:
+ path: /etc/ssh/sshd_config
+ regexp: "^#?Port "
+ line: "Port {{ ssh_port }}"
+ notify: Restart SSH
+
+ - name: 禁用root登录
+ lineinfile:
+ path: /etc/ssh/sshd_config
+ regexp: "^#?PermitRootLogin "
+ line: "PermitRootLogin no"
+ when: disable_root_login
+ notify: Restart SSH
+
+ - name: 禁用密码认证(仅密钥)
+ lineinfile:
+ path: /etc/ssh/sshd_config
+ regexp: "^#?PasswordAuthentication "
+ line: "PasswordAuthentication no"
+ notify: Restart SSH
+
+ - name: 设置SSH超时
+ lineinfile:
+ path: /etc/ssh/sshd_config
+ regexp: "^#?ClientAliveInterval "
+ line: "ClientAliveInterval 300"
+ notify: Restart SSH
+
+ - name: 安装UFW(Debian)
+ apt:
+ name: ufw
+ state: present
+ when: ansible_os_family == "Debian" and enable_firewall
+
+ - name: 配置UFW规则
+ shell: |
+ ufw allow {{ ssh_port }}/tcp
+ ufw allow 80/tcp
+ ufw allow 443/tcp
+ ufw --force enable
+ when: ansible_os_family == "Debian" and enable_firewall
+
+ - name: 安装fail2ban
+ package:
+ name: fail2ban
+ state: present
+ ignore_errors: yes
+
+ - name: 启动fail2ban
+ service:
+ name: fail2ban
+ state: started
+ enabled: yes
+ ignore_errors: yes
+
+ - name: 加固完成
+ debug:
+ msg: "✅ 安全加固完成 - SSH端口: {{ ssh_port }}, Root登录: {{ '禁用' if disable_root_login else '允许' }}"
+
+ handlers:
+ - name: Restart SSH
+ service:
+ name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}"
+ state: restarted
+`,
+ },
+ {
+ ID: "ssl-cert", Name: "SSL证书部署", Category: "security",
+ Description: "使用Certbot自动申请Let's Encrypt证书",
+ Icon: "📜", Tags: []string{"ssl", "https", "certbot", "letsencrypt"},
+ Content: `---
+- name: SSL证书部署
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ domain: example.com
+ email: admin@example.com
+ webroot: /var/www/html
+
+ tasks:
+ - name: 安装Certbot
+ package:
+ name:
+ - certbot
+ - python3-certbot-nginx
+ state: present
+
+ - name: 申请证书
+ shell: |
+ certbot certonly --webroot -w {{ webroot }} \
+ -d {{ domain }} \
+ --email {{ email }} \
+ --agree-tos --non-interactive
+ args:
+ creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem
+
+ - name: 配置自动续期
+ cron:
+ name: "certbot-renew"
+ hour: "3"
+ minute: "30"
+ job: "certbot renew --quiet --post-hook 'systemctl reload nginx'"
+
+ - name: 证书信息
+ shell: openssl x509 -in /etc/letsencrypt/live/{{ domain }}/fullchain.pem -noout -dates
+ register: cert_info
+ changed_when: false
+ ignore_errors: yes
+
+ - name: 显示证书
+ debug:
+ msg: "{{ cert_info.stdout_lines | default(['证书已申请']) }}"
+`,
+ },
+
+ // ===== 监控告警 =====
+ {
+ ID: "deploy-node-exporter", Name: "部署Node Exporter", Category: "monitoring",
+ Description: "部署Prometheus Node Exporter采集主机指标",
+ Icon: "📡", Tags: []string{"prometheus", "monitoring", "exporter"},
+ Content: `---
+- name: 部署Node Exporter
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ exporter_version: "1.7.0"
+ exporter_port: 9100
+
+ tasks:
+ - name: 创建用户
+ user:
+ name: node_exporter
+ system: yes
+ shell: /usr/sbin/nologin
+ create_home: no
+
+ - name: 下载Node Exporter
+ get_url:
+ url: "https://github.com/prometheus/node_exporter/releases/download/v{{ exporter_version }}/node_exporter-{{ exporter_version }}.linux-amd64.tar.gz"
+ dest: /tmp/node_exporter.tar.gz
+
+ - name: 解压
+ unarchive:
+ src: /tmp/node_exporter.tar.gz
+ dest: /tmp
+ remote_src: yes
+
+ - name: 安装二进制
+ copy:
+ src: "/tmp/node_exporter-{{ exporter_version }}.linux-amd64/node_exporter"
+ dest: /usr/local/bin/node_exporter
+ mode: '0755'
+ remote_src: yes
+
+ - name: 创建Systemd服务
+ copy:
+ content: |
+ [Unit]
+ Description=Node Exporter
+ After=network.target
+
+ [Service]
+ User=node_exporter
+ ExecStart=/usr/local/bin/node_exporter --web.listen-address=:{{ exporter_port }}
+ Restart=always
+
+ [Install]
+ WantedBy=multi-user.target
+ dest: /etc/systemd/system/node_exporter.service
+
+ - name: 启动服务
+ systemd:
+ name: node_exporter
+ state: started
+ enabled: yes
+ daemon_reload: yes
+
+ - name: 验证
+ shell: curl -s http://localhost:{{ exporter_port }}/metrics | head -5
+ register: metrics
+ changed_when: false
+
+ - name: 显示状态
+ debug:
+ msg: "Node Exporter运行在端口 {{ exporter_port }}"
+`,
+ },
+ {
+ ID: "deploy-grafana", Name: "部署Grafana", Category: "monitoring",
+ Description: "部署Grafana可视化监控面板",
+ Icon: "📊", Tags: []string{"grafana", "dashboard", "visualization"},
+ Content: `---
+- name: 部署Grafana
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ grafana_port: 3000
+ grafana_admin_password: "admin123"
+
+ tasks:
+ - name: 安装依赖
+ apt:
+ name:
+ - apt-transport-https
+ - software-properties-common
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: 添加Grafana仓库
+ shell: |
+ curl -fsSL https://apt.grafana.com/gpg.key | gpg --dearmor -o /usr/share/keyrings/grafana.gpg
+ echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" > /etc/apt/sources.list.d/grafana.list
+ when: ansible_os_family == "Debian"
+
+ - name: 安装Grafana
+ apt:
+ name: grafana
+ state: present
+ update_cache: yes
+ when: ansible_os_family == "Debian"
+
+ - name: 配置端口
+ lineinfile:
+ path: /etc/grafana/grafana.ini
+ regexp: "^;?http_port"
+ line: "http_port = {{ grafana_port }}"
+
+ - name: 启动Grafana
+ service:
+ name: grafana-server
+ state: started
+ enabled: yes
+
+ - name: 显示访问信息
+ debug:
+ msg: "Grafana: http://{{ ansible_host | default(inventory_hostname) }}:{{ grafana_port }} (admin/{{ grafana_admin_password }})"
+`,
+ },
+
+ // ===== 系统管理(新增) =====
+ {
+ ID: "host-connectivity", Name: "主机连通性测试", Category: "system",
+ Description: "批量检测主机SSH连通性、DNS解析、端口可达性",
+ Icon: "🔗", Tags: []string{"ping", "connectivity", "network", "check"},
+ Content: `---
+- name: 主机连通性测试
+ hosts: "{{ target_hosts | default('all') }}"
+ gather_facts: no
+
+ tasks:
+ - name: Ansible Ping 测试
+ ping:
+ register: ping_result
+ ignore_errors: yes
+
+ - name: 检测SSH端口
+ wait_for:
+ host: "{{ ansible_host | default(inventory_hostname) }}"
+ port: "{{ ansible_port | default(22) }}"
+ timeout: 5
+ delegate_to: localhost
+ register: ssh_port
+ ignore_errors: yes
+
+ - name: 检测DNS解析
+ shell: nslookup {{ inventory_hostname }} 2>/dev/null || host {{ inventory_hostname }} 2>/dev/null || echo "DNS解析失败"
+ delegate_to: localhost
+ register: dns_result
+ changed_when: false
+ ignore_errors: yes
+
+ - name: 检测常用端口
+ wait_for:
+ host: "{{ ansible_host | default(inventory_hostname) }}"
+ port: "{{ item }}"
+ timeout: 3
+ delegate_to: localhost
+ loop: [80, 443]
+ register: port_scan
+ ignore_errors: yes
+
+ - name: 输出连通性报告
+ debug:
+ msg: |
+ ═══════ {{ inventory_hostname }} 连通性报告 ═══════
+ SSH Ping: {{ '✅ 成功' if ping_result is succeeded else '❌ 失败' }}
+ SSH 端口: {{ '✅ 开放' if ssh_port is succeeded else '❌ 不可达' }}
+ DNS 解析: {{ dns_result.stdout | default('N/A') }}
+ HTTP(80): {{ '✅ 开放' if port_scan.results[0] is succeeded else '❌ 关闭' }}
+ HTTPS(443): {{ '✅ 开放' if port_scan.results[1] is succeeded else '❌ 关闭' }}
+ ═══════════════════════════════════════
+`,
+ },
+ {
+ ID: "ubuntu-multi-ip", Name: "Ubuntu 22.04 多IP配置", Category: "system",
+ Description: "通过Netplan为Ubuntu 22.04配置多IP地址(主IP+辅助IP)",
+ Icon: "🌐", Tags: []string{"ubuntu", "netplan", "network", "multi-ip"},
+ Content: `---
+- name: Ubuntu 22.04 多IP地址配置
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ # 主网卡名称(根据实际修改)
+ primary_nic: ens192
+ # 主IP配置
+ primary_ip: "192.168.1.100/24"
+ primary_gateway: "192.168.1.1"
+ primary_dns:
+ - 223.5.5.5
+ - 119.29.29.29
+ # 辅助IP列表(可添加多个)
+ secondary_ips:
+ - "192.168.1.101/24"
+ - "10.0.0.100/16"
+
+ tasks:
+ - name: 确认系统版本
+ assert:
+ that:
+ - ansible_distribution == "Ubuntu"
+ - ansible_distribution_version is version('22.04', '>=')
+ fail_msg: "此模板仅适用于 Ubuntu 22.04+"
+ success_msg: "系统版本: {{ ansible_distribution }} {{ ansible_distribution_version }}"
+
+ - name: 备份现有Netplan配置
+ shell: |
+ mkdir -p /etc/netplan/backup
+ cp /etc/netplan/*.yaml /etc/netplan/backup/ 2>/dev/null || true
+ args:
+ creates: /etc/netplan/backup
+
+ - name: 生成Netplan配置
+ copy:
+ content: |
+ network:
+ version: 2
+ ethernets:
+ {{ primary_nic }}:
+ addresses:
+ - {{ primary_ip }}
+ {% for ip in secondary_ips %}
+ - {{ ip }}
+ {% endfor %}
+ routes:
+ - to: default
+ via: {{ primary_gateway }}
+ nameservers:
+ addresses:
+ {% for dns in primary_dns %}
+ - {{ dns }}
+ {% endfor %}
+ dest: /etc/netplan/01-multi-ip.yaml
+ mode: '0600'
+ register: netplan_config
+
+ - name: 验证Netplan配置语法
+ shell: netplan try --timeout=10
+ environment:
+ NETPLAN_TRY_TIMEOUT: "10"
+ ignore_errors: yes
+ register: netplan_try
+
+ - name: 应用Netplan配置
+ shell: netplan apply
+ when: netplan_config is changed
+
+ - name: 等待网络恢复
+ wait_for:
+ host: "{{ primary_ip | ansible.utils.ipaddr('address') }}"
+ port: 22
+ timeout: 30
+ delegate_to: localhost
+ when: netplan_config is changed
+ ignore_errors: yes
+
+ - name: 验证IP配置
+ shell: ip addr show {{ primary_nic }} | grep "inet "
+ register: ip_verify
+ changed_when: false
+
+ - name: 显示配置结果
+ debug:
+ msg: |
+ ═══════ 多IP配置完成 ═══════
+ 网卡: {{ primary_nic }}
+ 主IP: {{ primary_ip }}
+ 网关: {{ primary_gateway }}
+ 辅助IP:
+ {% for ip in secondary_ips %}
+ - {{ ip }}
+ {% endfor %}
+ 当前IP列表:
+ {{ ip_verify.stdout }}
+ ═══════════════════════════
+`,
+ },
+ {
+ ID: "ipmitool-bmc", Name: "IPMI配置BMC地址", Category: "system",
+ Description: "使用ipmitool配置服务器BMC/IPMI管理口IP地址",
+ Icon: "🔧", Tags: []string{"ipmi", "bmc", "ipmitool", "hardware"},
+ Content: `---
+- name: IPMI配置BMC地址
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ # BMC网络配置
+ bmc_ip: "10.10.10.100"
+ bmc_netmask: "255.255.255.0"
+ bmc_gateway: "10.10.10.1"
+ # BMC用户配置
+ bmc_user: "admin"
+ bmc_password: "Admin@123"
+ bmc_channel: 1
+ # 是否启用DHCP(设为true则忽略上面的静态IP配置)
+ bmc_dhcp: false
+
+ tasks:
+ - name: 安装ipmitool
+ package:
+ name: ipmitool
+ state: present
+
+ - name: 加载IPMI内核模块
+ modprobe:
+ name: "{{ item }}"
+ state: present
+ loop:
+ - ipmi_devintf
+ - ipmi_si
+ ignore_errors: yes
+
+ - name: 查看当前BMC配置
+ shell: ipmitool lan print {{ bmc_channel }}
+ register: bmc_current
+ changed_when: false
+ ignore_errors: yes
+
+ - name: 显示当前BMC信息
+ debug:
+ msg: "{{ bmc_current.stdout_lines | default(['无法读取BMC信息']) }}"
+
+ - name: 设置BMC为静态IP
+ shell: ipmitool lan set {{ bmc_channel }} ipsrc static
+ when: not bmc_dhcp
+
+ - name: 配置BMC IP地址
+ shell: ipmitool lan set {{ bmc_channel }} ipaddr {{ bmc_ip }}
+ when: not bmc_dhcp
+
+ - name: 配置BMC子网掩码
+ shell: ipmitool lan set {{ bmc_channel }} netmask {{ bmc_netmask }}
+ when: not bmc_dhcp
+
+ - name: 配置BMC默认网关
+ shell: ipmitool lan set {{ bmc_channel }} defgw ipaddr {{ bmc_gateway }}
+ when: not bmc_dhcp
+
+ - name: 设置BMC为DHCP模式
+ shell: ipmitool lan set {{ bmc_channel }} ipsrc dhcp
+ when: bmc_dhcp
+
+ - name: 配置BMC用户密码
+ shell: |
+ ipmitool user set name {{ bmc_channel }} 2 {{ bmc_user }} 2>/dev/null || true
+ ipmitool user set password 2 {{ bmc_password }}
+ ipmitool user enable 2
+ ipmitool channel setaccess {{ bmc_channel }} 2 privilege=4
+ no_log: true
+ ignore_errors: yes
+
+ - name: 验证BMC配置
+ shell: ipmitool lan print {{ bmc_channel }}
+ register: bmc_verify
+ changed_when: false
+
+ - name: 显示最终配置
+ debug:
+ msg: |
+ ═══════ BMC配置完成 ═══════
+ 模式: {{ 'DHCP' if bmc_dhcp else '静态IP' }}
+ IP: {{ bmc_ip }}
+ 掩码: {{ bmc_netmask }}
+ 网关: {{ bmc_gateway }}
+ 用户: {{ bmc_user }}
+ ═══════════════════════════
+ {{ bmc_verify.stdout }}
+`,
+ },
+ {
+ ID: "apt-install", Name: "APT软件安装", Category: "system",
+ Description: "通过apt批量安装/卸载软件包,支持指定版本和仓库",
+ Icon: "📦", Tags: []string{"apt", "install", "package", "debian"},
+ Content: `---
+- name: APT软件安装
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ # 要安装的软件包列表
+ packages_install:
+ - vim
+ - curl
+ - wget
+ - htop
+ - net-tools
+ - tree
+ - unzip
+ - git
+ # 要卸载的软件包列表
+ packages_remove: []
+ # 是否更新缓存
+ update_cache: yes
+ # 是否自动清理
+ autoremove: yes
+ # 安装推荐包
+ install_recommends: no
+ # 指定版本(可选,格式: "package=version")
+ # 例: packages_install: ["nginx=1.18.0-0ubuntu1"]
+
+ tasks:
+ - name: 确认Debian系统
+ assert:
+ that: ansible_os_family == "Debian"
+ fail_msg: "此模板仅适用于 Debian/Ubuntu 系统"
+
+ - name: 更新APT缓存
+ apt:
+ update_cache: yes
+ cache_valid_time: 3600
+ when: update_cache
+
+ - name: 安装软件包
+ apt:
+ name: "{{ packages_install }}"
+ state: present
+ install_recommends: "{{ install_recommends }}"
+ register: install_result
+ when: packages_install | length > 0
+
+ - name: 卸载软件包
+ apt:
+ name: "{{ packages_remove }}"
+ state: absent
+ purge: yes
+ when: packages_remove | length > 0
+
+ - name: 自动清理
+ apt:
+ autoremove: yes
+ autoclean: yes
+ when: autoremove
+
+ - name: 验证安装结果
+ shell: dpkg -l {{ packages_install | join(' ') }} 2>/dev/null | grep "^ii" | awk '{print $2, $3}'
+ register: verify_result
+ changed_when: false
+ when: packages_install | length > 0
+
+ - name: 显示安装报告
+ debug:
+ msg: |
+ ═══════ APT安装报告 ═══════
+ 已安装: {{ packages_install | length }} 个包
+ 已卸载: {{ packages_remove | length }} 个包
+ 变更: {{ install_result.changed | default(false) }}
+ {% if verify_result is defined and verify_result.stdout_lines is defined %}
+ 已确认:
+ {% for line in verify_result.stdout_lines %}
+ ✓ {{ line }}
+ {% endfor %}
+ {% endif %}
+ ═══════════════════════════
+`,
+ },
+ {
+ ID: "dpkg-install", Name: "DPKG软件安装", Category: "system",
+ Description: "通过dpkg安装本地.deb包,支持URL下载和依赖修复",
+ Icon: "📥", Tags: []string{"dpkg", "deb", "install", "offline"},
+ Content: `---
+- name: DPKG软件安装
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ # 本地deb包路径列表(服务器上的路径)
+ local_deb_files: []
+ # 远程URL列表(会自动下载后安装)
+ remote_deb_urls: []
+ # 下载临时目录
+ download_dir: /tmp/dpkg_install
+ # 安装后是否修复依赖
+ fix_dependencies: yes
+ # 是否保留下载的deb文件
+ keep_deb_files: no
+
+ tasks:
+ - name: 确认Debian系统
+ assert:
+ that: ansible_os_family == "Debian"
+ fail_msg: "此模板仅适用于 Debian/Ubuntu 系统"
+
+ - name: 创建下载目录
+ file:
+ path: "{{ download_dir }}"
+ state: directory
+ mode: '0755'
+ when: remote_deb_urls | length > 0
+
+ - name: 下载远程deb包
+ get_url:
+ url: "{{ item }}"
+ dest: "{{ download_dir }}/{{ item | basename }}"
+ mode: '0644'
+ loop: "{{ remote_deb_urls }}"
+ register: download_result
+ when: remote_deb_urls | length > 0
+
+ - name: 安装本地deb包
+ apt:
+ deb: "{{ item }}"
+ state: present
+ loop: "{{ local_deb_files }}"
+ register: local_install
+ when: local_deb_files | length > 0
+
+ - name: 安装下载的deb包
+ apt:
+ deb: "{{ download_dir }}/{{ item | basename }}"
+ state: present
+ loop: "{{ remote_deb_urls }}"
+ register: remote_install
+ when: remote_deb_urls | length > 0
+
+ - name: 修复依赖关系
+ apt:
+ update_cache: yes
+ force_apt_get: yes
+ shell: apt-get install -f -y
+ when: fix_dependencies
+ ignore_errors: yes
+
+ - name: 清理下载文件
+ file:
+ path: "{{ download_dir }}"
+ state: absent
+ when: not keep_deb_files and remote_deb_urls | length > 0
+
+ - name: 显示安装报告
+ debug:
+ msg: |
+ ═══════ DPKG安装报告 ═══════
+ 本地包: {{ local_deb_files | length }} 个
+ 远程包: {{ remote_deb_urls | length }} 个
+ {% if local_deb_files | length > 0 %}
+ 本地安装:
+ {% for f in local_deb_files %}
+ ✓ {{ f }}
+ {% endfor %}
+ {% endif %}
+ {% if remote_deb_urls | length > 0 %}
+ 远程安装:
+ {% for u in remote_deb_urls %}
+ ✓ {{ u | basename }}
+ {% endfor %}
+ {% endif %}
+ 依赖修复: {{ '是' if fix_dependencies else '否' }}
+ ═══════════════════════════
+`,
+ },
+ }
+}
diff --git a/inventory/groups.json b/inventory/groups.json
deleted file mode 100644
index 67b2bc0..0000000
--- a/inventory/groups.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "all": {
- "name": "all",
- "description": "所有主机",
- "hosts": null
- },
- "ungrouped": {
- "name": "ungrouped",
- "description": "未分组主机",
- "hosts": null
- },
- "zebu_user01": {
- "name": "zebu_user01",
- "description": "",
- "hosts": null
- }
-}
\ No newline at end of file
diff --git a/inventory/hosts b/inventory/hosts
deleted file mode 100644
index 6e5bf4a..0000000
--- a/inventory/hosts
+++ /dev/null
@@ -1,10 +0,0 @@
-# Ansible Inventory File
-# Generated by ansible-deploy
-
-[all]
- nas ansible_host=10.168.1.209 ansible_user=root
-
-[zebu_user01]
- scmp48 ansible_host=172.16.11.46 ansible_user=root
- scmp46 ansible_host=172.16.11.42 ansible_user=root
- scmp47 ansible_host=172.16.11.44 ansible_user=root
diff --git a/inventory/hosts.json b/inventory/hosts.json
deleted file mode 100644
index 79c6ec2..0000000
--- a/inventory/hosts.json
+++ /dev/null
@@ -1,62 +0,0 @@
-[
- {
- "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"
- },
- {
- "id": "4d7a1f03",
- "name": "scmp48",
- "ip": "172.16.11.46",
- "port": 22,
- "username": "root",
- "password": "STC#scmp%0818",
- "groups": [
- "zebu_user01"
- ],
- "status": "online",
- "last_check": "2026-05-13T18:27:19.272543838+08:00",
- "created_at": "0001-01-01T00:00:00Z",
- "updated_at": "2026-05-13T18:23:27.433461112+08:00"
- },
- {
- "id": "57884720",
- "name": "scmp46",
- "ip": "172.16.11.42",
- "port": 22,
- "username": "root",
- "password": "STC#scmp%0818",
- "groups": [
- "zebu_user01"
- ],
- "status": "online",
- "last_check": "2026-05-13T18:30:38.103119534+08:00",
- "created_at": "0001-01-01T00:00:00Z",
- "updated_at": "2026-05-13T18:28:16.71131472+08:00"
- },
- {
- "id": "5150c740",
- "name": "scmp47",
- "ip": "172.16.11.44",
- "port": 22,
- "username": "root",
- "password": "STC#scmp%0818",
- "groups": [
- "zebu_user01"
- ],
- "status": "online",
- "last_check": "2026-05-13T18:30:33.343216547+08:00",
- "created_at": "0001-01-01T00:00:00Z",
- "updated_at": "2026-05-13T18:28:08.656450224+08:00"
- }
-]
\ No newline at end of file
diff --git a/playbooks/deploy-node-exporter.yml b/playbooks/deploy-node-exporter.yml
new file mode 100644
index 0000000..478f3c4
--- /dev/null
+++ b/playbooks/deploy-node-exporter.yml
@@ -0,0 +1,65 @@
+---
+- name: 部署Node Exporter
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ exporter_version: "1.7.0"
+ exporter_port: 9100
+
+ tasks:
+ - name: 创建用户
+ user:
+ name: node_exporter
+ system: yes
+ shell: /usr/sbin/nologin
+ create_home: no
+
+ - name: 下载Node Exporter
+ get_url:
+ url: "https://github.com/prometheus/node_exporter/releases/download/v{{ exporter_version }}/node_exporter-{{ exporter_version }}.linux-amd64.tar.gz"
+ dest: /tmp/node_exporter.tar.gz
+
+ - name: 解压
+ unarchive:
+ src: /tmp/node_exporter.tar.gz
+ dest: /tmp
+ remote_src: yes
+
+ - name: 安装二进制
+ copy:
+ src: "/tmp/node_exporter-{{ exporter_version }}.linux-amd64/node_exporter"
+ dest: /usr/local/bin/node_exporter
+ mode: '0755'
+ remote_src: yes
+
+ - name: 创建Systemd服务
+ copy:
+ content: |
+ [Unit]
+ Description=Node Exporter
+ After=network.target
+
+ [Service]
+ User=node_exporter
+ ExecStart=/usr/local/bin/node_exporter --web.listen-address=:{{ exporter_port }}
+ Restart=always
+
+ [Install]
+ WantedBy=multi-user.target
+ dest: /etc/systemd/system/node_exporter.service
+
+ - name: 启动服务
+ systemd:
+ name: node_exporter
+ state: started
+ enabled: yes
+ daemon_reload: yes
+
+ - name: 验证
+ shell: curl -s http://localhost:{{ exporter_port }}/metrics | head -5
+ register: metrics
+ changed_when: false
+
+ - name: 显示状态
+ debug:
+ msg: "Node Exporter运行在端口 {{ exporter_port }}"
diff --git a/playbooks/deploy-python-app.yml b/playbooks/deploy-python-app.yml
new file mode 100644
index 0000000..856850c
--- /dev/null
+++ b/playbooks/deploy-python-app.yml
@@ -0,0 +1,70 @@
+---
+- name: 部署Python应用
+ hosts: "{{ target_hosts | default('all') }}"
+ become: yes
+ vars:
+ app_name: myapp
+ app_dir: /opt/myapp
+ python_version: python3
+ venv_dir: /opt/myapp/venv
+ gunicorn_port: 8000
+ gunicorn_workers: 4
+ app_user: www-data
+
+ tasks:
+ - name: 安装Python和依赖
+ package:
+ name:
+ - "{{ python_version }}"
+ - "{{ python_version }}-venv"
+ - python3-pip
+ state: present
+
+ - name: 创建应用目录
+ file:
+ path: "{{ app_dir }}"
+ state: directory
+ owner: "{{ app_user }}"
+ mode: '0755'
+
+ - name: 创建虚拟环境
+ shell: "{{ python_version }} -m venv {{ venv_dir }}"
+ args:
+ creates: "{{ venv_dir }}/bin/activate"
+
+ - name: 安装Gunicorn
+ pip:
+ name: gunicorn
+ virtualenv: "{{ venv_dir }}"
+
+ - name: 创建Systemd服务
+ copy:
+ content: |
+ [Unit]
+ Description={{ app_name }} Gunicorn Service
+ After=network.target
+
+ [Service]
+ User={{ app_user }}
+ WorkingDirectory={{ app_dir }}
+ ExecStart={{ venv_dir }}/bin/gunicorn --workers {{ gunicorn_workers }} --bind 0.0.0.0:{{ gunicorn_port }} app:app
+ Restart=always
+
+ [Install]
+ WantedBy=multi-user.target
+ dest: /etc/systemd/system/{{ app_name }}.service
+ notify: Restart App
+
+ - name: 启动服务
+ systemd:
+ name: "{{ app_name }}"
+ state: started
+ enabled: yes
+ daemon_reload: yes
+
+ handlers:
+ - name: Restart App
+ systemd:
+ name: "{{ app_name }}"
+ state: restarted
+ daemon_reload: yes
diff --git a/web/dist/index.html b/web/dist/index.html
index 9ea8b62..f590c86 100644
--- a/web/dist/index.html
+++ b/web/dist/index.html
@@ -1,1690 +1,1452 @@
-
-
- Ansible批量部署工具
-
+
+
+Ansible Deploy Pro · 自动化部署平台
+
+
+
+
+
-
-
-
-
-
+
-
-
📝 最近任务
-
-
-
- | 任务名称 |
- 状态 |
- 进度 |
- 开始时间 |
-
-
-
-
-
-
-
+
+
-
-
-
-
🖥️ 主机列表
-
-
-
- | 名称 |
- IP地址 |
- 端口 |
- 用户名 |
- 状态 |
- 主机组 |
- 操作 |
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
💻 命令执行
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
📜 Playbook列表
-
-
-
-
+
-
-
-
-
-
🚀 执行配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${keys.length?'':'
⚠️ 尚未添加密钥,请先到「SSH 密钥」页面创建
'}
+
+
+
${S.groups.map(g=>`${esc(g.name)}`).join('')||'暂无主机组,可在主机页右侧面板创建'}
+
`,
+ '',`
`);
+ const seg=$('#hf-auth');
+ const sync=()=>{const v=seg.querySelector('.on').dataset.v;$('#hf-pwd-w').style.display=v==='password'?'':'none';$('#hf-key-w').style.display=v==='sshkey'?'':'none';};
+ seg.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{seg.querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');sync();}));
+ sync();
+ $('#hf-groups').querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>c.classList.toggle('on')));
+ $('#hf-save').addEventListener('click',async()=>{
+ const name=$('#hf-name').value.trim(),ip=$('#hf-ip').value.trim();
+ if(!name||!ip)return toast('请填写主机名称与 IP 地址','warn');
+ const auth=seg.querySelector('.on').dataset.v;
+ const body={name,ip,port:+$('#hf-port').value||22,username:$('#hf-user').value.trim()||'root',auth_type:auth,
+ password:auth==='password'?$('#hf-pwd').value:'',ssh_key:auth==='sshkey'?$('#hf-key').value:'',
+ groups:$$('#hf-groups .chip.on').map(c=>c.dataset.g)};
+ await withBtn($('#hf-save'),async()=>{
+ if(h)await api(`/api/hosts/${h.id}`,{method:'PUT',body});
+ else await api('/api/hosts',{method:'POST',body});
+ toast(h?'主机已更新':'主机添加成功,可点击 ⚡ 测试连接');closeModal();
+ if($('#view-hosts').classList.contains('on'))await refreshHosts();
+ });
+ });
+}
+/* ---- 主机组 ---- */
+function drawGroups(){
+ const el=$('#group-list');if(!el)return;
+ if(!S.groups.length){el.innerHTML=emptyMini('📁','还没有主机组','创建主机组后可按组批量执行任务');return;}
+ el.innerHTML=S.groups.map(g=>`
+
${ic.layers}
+
${esc(g.name)}
${esc(g.description||'无描述')}
+
+
${(g.host_list||[]).map(x=>`${sdot(x.status)} ${esc(x.name)}`).join('')||'组内暂无主机'}
+
`).join('');
+}
+function openGroupModal(){
+ openModal(`${ic.plus} 新建主机组`,`
+
+
`,
+ '',`
`);
+ $('#gf-save').addEventListener('click',async()=>{
+ const name=$('#gf-name').value.trim();
+ if(!name)return toast('请填写组名称','warn');
+ await withBtn($('#gf-save'),async()=>{
+ await api('/api/groups',{method:'POST',body:{name,description:$('#gf-desc').value.trim(),hosts:[]}});
+ toast('主机组创建成功');closeModal();await refreshHosts();
+ });
+ });
+}
+async function delGroup(name){
+ if(!await confirmBox('删除主机组',`确定要删除主机组
${esc(name)} 吗?组内主机不会被删除。`))return;
+ try{await api(`/api/groups/${encodeURIComponent(name)}`,{method:'DELETE'});toast('主机组已删除');await refreshHosts();}catch(e){toast(esc(e.message),'err');}
+}
+
+/* ================= 命令执行 ================= */
+function getHist(){try{return JSON.parse(localStorage.getItem('adp-cmd-hist')||'[]');}catch(e){return[];}}
+function pushHist(c){const h=getHist().filter(x=>x!==c);h.unshift(c);try{localStorage.setItem('adp-cmd-hist',JSON.stringify(h.slice(0,8)));}catch(e){}}
+function drawCmdHist(){
+ const el=$('#cmd-hist');if(!el)return;
+ const h=getHist();
+ el.innerHTML=h.length?'
历史:'+h.map(x=>`
${esc(x)}`).join(''):'';
+ el.querySelectorAll('.hist-chip').forEach(c=>c.addEventListener('click',()=>{$('#cmd-text').value=c.dataset.c;$('#cmd-text').focus();}));
+}
+async function renderCommand(){
+ $('#view-command').innerHTML=`
+
+
${ic.terminal}命令执行
通过 Ansible ad-hoc 在目标主机上执行 Shell 命令
+
+
+
+
+
+
+
+
+
+
+
+
+
${emptyMini('📡','暂无执行记录','左侧执行命令后,结果将实时展示在这里')}
+
+
`;
+ cmdPicker=new HostPicker($('#cmd-picker'),{groups:false,placeholder:'点击选择目标主机(可多选)'});
+ cmdPicker.load();
+ drawCmdHist();
+ $('#cmd-run').addEventListener('click',runCommand);
+ $('#cmd-batch').addEventListener('click',runBatch);
+ $('#cmd-clear').addEventListener('click',()=>{$('#cmd-out').innerHTML=emptyMini('🧹','已清空','');$('#cmd-summary').textContent='等待执行…';});
+}
+const renderCmdRes=r=>`
+
${sdot(r.success?'online':'offline')}
${esc(r.host)}
+
exit ${r.exit_code??'-'} · ${r.duration_ms??0}ms
+ ${r.success?'
✓ 成功':'
✗ 失败'}
+
${esc(r.output||r.error||'(无输出)')}`;
+async function runCommand(){
+ const hosts=cmdPicker.value(),command=$('#cmd-text').value.trim();
+ if(!hosts.length)return toast('请先选择目标主机','warn');
+ if(!command)return toast('请输入要执行的命令','warn');
+ pushHist(command);drawCmdHist();
+ await withBtn($('#cmd-run'),async()=>{
+ $('#cmd-summary').innerHTML='
正在目标主机上执行…';
+ $('#cmd-out').innerHTML=`
`;
+ const t0=performance.now();
+ const r=await api('/api/command/execute',{method:'POST',body:{hosts,command,parallel:$('#cmd-parallel').checked,timeout:+$('#cmd-timeout').value||30}});
+ const res=r.data||[];
+ const ok=res.filter(x=>x.success).length;
+ $('#cmd-summary').innerHTML=`执行完成 · 成功
${ok} / 失败
${res.length-ok} · 总耗时 ${Math.round(performance.now()-t0)}ms`;
+ $('#cmd-out').innerHTML=res.length?res.map(renderCmdRes).join(''):emptyMini('🤔','没有返回结果','请检查目标主机配置');
+ toast(ok===res.length?`全部 ${res.length} 台主机执行成功`:`执行完成:${ok} 成功 / ${res.length-ok} 失败`,ok===res.length?'ok':'warn');
+ });
+}
+async function runBatch(){
+ const hosts=cmdPicker.value(),command=$('#cmd-text').value.trim();
+ if(!hosts.length)return toast('请先选择目标主机','warn');
+ if(!command)return toast('请输入要执行的命令','warn');
+ pushHist(command);drawCmdHist();
+ await withBtn($('#cmd-batch'),async()=>{
+ const r=await api('/api/command/batch',{method:'POST',body:{hosts,command,parallel:true,timeout:+$('#cmd-timeout').value||30}});
+ const d=r.data||{};
+ toast(`批量任务已启动 · 任务 ID
${esc(String(d.task_id||'').slice(0,8))}`,'ok',4200);
+ if(d.results&&d.results.length){
+ $('#cmd-summary').innerHTML=`批量执行完成 · 成功
${d.success??0} / 失败
${d.failed??0}`;
+ $('#cmd-out').innerHTML=d.results.map(renderCmdRes).join('');
+ }
+ });
+}
+
+/* ================= Playbook 管理 ================= */
+async function renderPlaybooks(){
+ $('#view-playbooks').innerHTML=`
+
+
${ic.file}
+
+
${sklRows(4)}
+
+
${emptyMini('📜','选择一个 Playbook','从左侧列表选择剧本进行编辑与执行')}
+
`;
+ await loadPlaybooks();
+}
+async function loadPlaybooks(){
+ const r=await api('/api/playbooks');S.playbooks=r.data||[];updateNavCounts();
+ const cnt=$('#pb-count');if(cnt)cnt.textContent=`共 ${S.playbooks.length} 个剧本`;
+ const el=$('#pb-list');if(!el)return;
+ if(!S.playbooks.length){
+ el.innerHTML=emptyMini('📜','暂无 Playbook','点击「新建」或到模板中心快速创建');
+ $('#pb-detail').innerHTML=`
${empty('📜','还没有 Playbook','点击左侧「新建」手写剧本,或前往「模板中心」一键生成最佳实践剧本',``)}
`;
+ S.curPb=null;return;
+ }
+ if(!S.curPb||!S.playbooks.find(p=>p.name===S.curPb))S.curPb=S.playbooks[0].name;
+ el.innerHTML=S.playbooks.map(p=>`
+
${ic.file}
+
${esc(p.name)}
${esc(p.description||'无描述')}
`).join('');
+ await selectPb(S.curPb);
+}
+async function selectPb(name){
+ S.curPb=name;
+ $$('#pb-list .pb-item').forEach(x=>x.classList.toggle('on',x.querySelector('.nm').textContent===name));
+ const meta=S.playbooks.find(p=>p.name===name)||{};
+ const detail=$('#pb-detail');
+ detail.innerHTML=`
`;
+ let content='';
+ try{const r=await api(`/api/playbooks/${encodeURIComponent(name)}/content`);content=(r.data&&typeof r.data==='string')?r.data:(r.data?.content||'');}
+ catch(e){detail.innerHTML=`
${empty('⚠️','内容加载失败',esc(e.message))}
`;return;}
+ detail.innerHTML=`
+
${ic.code}
+
${esc(name)}.yml
${esc(meta.description||'YAML Playbook')}
+
+
+
+
+
+
+
+
${ic.chev} 高级执行选项(Tags / 并发 / 演练模式…)
+
+
+
+
+
+
+
+
`;
+ pbPicker=new HostPicker($('#pb-picker'),{groups:true,placeholder:'点击选择执行目标(支持主机组)'});
+ pbPicker.load();
+ $('#pb-adv .coll-h').addEventListener('click',()=>$('#pb-adv').classList.toggle('open'));
+ const ed=$('#pb-editor'),g=$('#pb-gutter');
+ const syncGutter=()=>{const n=ed.value.split('\n').length;g.innerHTML=Array.from({length:n},(_,i)=>`
${i+1}
`).join('');g.scrollTop=ed.scrollTop;};
+ ed.addEventListener('input',syncGutter);ed.addEventListener('scroll',()=>g.scrollTop=ed.scrollTop);
+ ed.addEventListener('keydown',e=>{if(e.key==='Tab'){e.preventDefault();const s=ed.selectionStart;ed.value=ed.value.slice(0,s)+' '+ed.value.slice(ed.selectionEnd);ed.selectionStart=ed.selectionEnd=s+2;syncGutter();}});
+ syncGutter();
+ $('#pb-save').addEventListener('click',()=>withBtn($('#pb-save'),async()=>{
+ await api(`/api/playbooks/${encodeURIComponent(name)}`,{method:'PUT',body:{content:ed.value}});
+ toast('Playbook 已保存');loadPlaybooks();
+ }));
+ $('#pb-del').addEventListener('click',async()=>{
+ if(!await confirmBox('删除 Playbook',`确定要删除剧本
${esc(name)} 吗?文件将被移除,不可恢复。`))return;
+ try{await api(`/api/playbooks/${encodeURIComponent(name)}`,{method:'DELETE'});toast('Playbook 已删除');S.curPb=null;await loadPlaybooks();}catch(e){toast(esc(e.message),'err');}
+ });
+ $('#pb-exec').addEventListener('click',()=>withBtn($('#pb-exec'),async()=>{
+ const hosts=pbPicker.value();
+ if(!hosts.length)return toast('请选择目标主机或主机组','warn');
+ const vars={};$('#ex-vars').value.trim().split(/\s+/).filter(Boolean).forEach(kv=>{const i=kv.indexOf('=');if(i>0)vars[kv.slice(0,i)]=kv.slice(i+1);});
+ const r=await api('/api/playbooks/execute',{method:'POST',body:{
+ name,hosts,extra_vars:vars,tags:csv($('#ex-tags').value),skip_tags:csv($('#ex-skip').value),
+ verbose:$('#ex-v').value,diff:$('#ex-diff').checked,check:$('#ex-check').checked,become:$('#ex-become').checked,
+ forks:+$('#ex-forks').value||0,timeout:+$('#ex-timeout').value||0,extra_args:$('#ex-args').value.trim()}});
+ toast('任务已启动,正在执行…');
+ openTaskModal(r.taskId);
+ }));
+}
+function openPbModal(){
+ const sample=`---\n- name: 示例部署任务\n hosts: all\n become: true\n tasks:\n - name: 确保软件包已安装\n ansible.builtin.package:\n name: vim\n state: present\n`;
+ openModal(`${ic.plus} 新建 Playbook`,`
+
+
`,
+ '',`
`);
+ $('#npf-save').addEventListener('click',async()=>{
+ const name=$('#npf-name').value.trim(),content=$('#npf-content').value;
+ if(!name)return toast('请填写剧本名称','warn');
+ if(!/^[a-zA-Z0-9_\-.]+$/.test(name))return toast('名称仅支持字母、数字、下划线、中划线和点','warn');
+ if(!content.trim())return toast('请填写 YAML 内容','warn');
+ await withBtn($('#npf-save'),async()=>{
+ await api('/api/playbooks',{method:'POST',body:{name,content}});
+ toast('Playbook 创建成功');closeModal();S.curPb=name;
+ if($('#view-playbooks').classList.contains('on'))await loadPlaybooks();else navigate('playbooks');
+ });
+ });
+}
+
+/* ================= 模板中心 ================= */
+async function renderTemplates(){
+ $('#view-templates').innerHTML=`
${sklCards(6,'180px')}
`;
+ const[c,t]=await Promise.all([api('/api/templates/categories'),api('/api/templates')]);
+ S.tplCats=c.data||[];S.tpls=t.data||[];S.tplCat='all';
+ drawCats();drawTpls();
+}
+function drawCats(){
+ const el=$('#tpl-cats');if(!el)return;
+ el.innerHTML=`
✨ 全部 ${S.tpls.length}
`+
+ S.tplCats.map(c=>`
${esc(c.icon||'📦')} ${esc(c.name)} ${c.count}
`).join('');
+}
+function setCat(c){S.tplCat=c;drawCats();drawTpls();}
+function catName(id){const c=S.tplCats.find(x=>x.id===id);return c?c.name:id;}
+function drawTpls(){
+ const el=$('#tpl-grid');if(!el)return;
+ const list=S.tplCat==='all'?S.tpls:S.tpls.filter(t=>t.category===S.tplCat);
+ if(!list.length){el.innerHTML=`
${empty('🗂️','该分类下暂无模板','试试其他分类,或稍后再来')}
`;return;}
+ el.innerHTML=list.map(t=>`
+
${esc(t.icon||'📄')}${esc(catName(t.category))}
+
${esc(t.name)}
+
${esc(t.description||'')}
+
${(t.tags||[]).map(x=>`${esc(x)}`).join('')}
+
+
+
+
+
`).join('');
+}
+async function useTpl(id){
+ const t=S.tpls.find(x=>x.id===id);if(!t)return;
+ openModal(`${ic.zap} 使用模板创建 Playbook`,`
+
+
${esc(t.icon||'📄')}
+
${esc(t.name)}
${esc(t.description||'')}
+
+
`,
+ '',`
`);
+ $('#tpl-go').addEventListener('click',async()=>{
+ const name=$('#tpl-name').value.trim();
+ if(!name)return toast('请填写 Playbook 名称','warn');
+ await withBtn($('#tpl-go'),async()=>{
+ await api('/api/templates/deploy',{method:'POST',body:{template_id:id,playbook_name:name}});
+ toast(`已基于模板创建
${esc(name)} 🎉`);closeModal();
+ S.curPb=name;navigate('playbooks');
+ });
+ });
+}
+async function viewTpl(id){
+ openModal(`${ic.eye} 模板预览`,`
`,'modal-lg');
+ try{
+ const r=await api(`/api/templates/${encodeURIComponent(id)}`);const t=r.data||{};
+ openModal(`${ic.eye} ${esc(t.name||'模板预览')}`,`
+
${(t.tags||[]).map(x=>`${esc(x)}`).join('')}${esc(catName(t.category))}
+
${esc(t.name||'')}.yml
+
${esc(t.content||'(无内容)')}
`,'modal-lg',
+ `
`);
+ }catch(e){toast(esc(e.message),'err');closeModal();}
+}
+
+/* ================= 文件分发 ================= */
+let fileMode='content';
+async function renderFiles(){
+ $('#view-files').innerHTML=`
+
+
${ic.send}文件分发
通过 Ansible copy 模块批量推送文件到目标主机
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${emptyMini('📦','暂无分发记录','左侧提交分发任务后,Ansible 执行输出将实时显示在这里')}
+
+
`;
+ filePicker=new HostPicker($('#file-picker'),{groups:true,placeholder:'点击选择目标主机 / 主机组'});
+ filePicker.load();
+ fileMode='content';
+ $('#file-mode').querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{
+ $('#file-mode').querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');
+ fileMode=b.dataset.v;
+ $('#f-content-w').style.display=fileMode==='content'?'':'none';
+ $('#f-src-w').style.display=fileMode==='source'?'':'none';
+ }));
+ $('#file-go').addEventListener('click',distribute);
+}
+async function distribute(){
+ const hosts=filePicker.value(),dest=$('#f-dest').value.trim();
+ if(!hosts.length)return toast('请选择目标主机','warn');
+ if(!dest)return toast('请填写目标路径','warn');
+ const content=fileMode==='content'?$('#f-content').value:'';
+ const src=fileMode==='source'?$('#f-src').value.trim():'';
+ if(fileMode==='source'&&!src)return toast('请填写服务器源文件路径','warn');
+ await withBtn($('#file-go'),async()=>{
+ $('#file-summary').innerHTML='
正在创建分发任务…';
+ const r=await api('/api/files/distribute',{method:'POST',body:{hosts,content,source_file:src,dest_path:dest,
+ owner:$('#f-owner').value.trim(),group:$('#f-group').value.trim(),mode:$('#f-mode').value.trim()}});
+ toast('分发任务已启动');
+ showTaskInPanel(r.taskId,$('#file-out'),$('#file-summary'),`→ ${dest} · ${hosts.length} 个目标`);
+ });
+}
+function showTaskInPanel(taskId,el,summaryEl,label){
+ let buf='';
+ el.innerHTML=`
任务 #${esc(String(taskId).slice(0,8))}${esc(label)}LIVE
+
ansible · 实时输出
+
正在连接日志流…
`;
+ const paint=()=>{const box=$('#panel-log');if(!box)return;const atEnd=box.scrollHeight-box.scrollTop-box.clientHeight<60;
+ box.innerHTML=ansi(buf)||'
(暂无输出)';if(atEnd)box.scrollTop=box.scrollHeight;};
+ api(`/api/tasks/${taskId}`).then(r=>{buf=r.data?.output||'';paint();
+ if(r.data&&r.data.status!=='running'){finishPanel(r.data.status);}else{openStream();}
+ }).catch(e=>{$('#panel-log').innerHTML=`
${esc(e.message)}`;});
+ function finishPanel(status){
+ el.querySelector('.live')?.classList.add('off');
+ if(summaryEl)summaryEl.innerHTML=status==='success'||status==='completed'
+ ?`分发完成 ·
成功`:`分发结束 · ${statusBadge(status)}`;
+ if(status==='success')toast('文件分发完成 🎉');else if(status==='failed')toast('文件分发失败','err');
+ }
+ function openStream(){
+ const es=new EventSource(`/api/tasks/${taskId}/stream`);
+ es.addEventListener('log',e=>{buf+=e.data;requestAnimationFrame(paint);});
+ es.addEventListener('status',e=>{es.close();finishPanel(e.data);
+ api(`/api/tasks/${taskId}`).then(r=>{if(r.data?.output){buf=r.data.output;paint();}}).catch(()=>{});});
+ es.addEventListener('error',e=>{if(e.data){const box=$('#panel-log');if(box)box.innerHTML+=`
\n${esc(e.data)}`;}});
+ es.onerror=()=>{};
+ }
+}
+
+/* ================= SSH 密钥 ================= */
+async function renderKeys(){
+ $('#view-keys').innerHTML=`
+
${ic.key}
+
+
${sklRows(3)}
+
`;
+ await loadKeys();
+}
+async function loadKeys(){
+ const r=await api('/api/sshkeys');S.keys=r.data||[];
+ const el=$('#key-list');if(!el)return;
+ if(!S.keys.length){el.innerHTML=empty('🔑','还没有 SSH 密钥','添加私钥后,新增主机时即可选择「密钥认证」方式,实现免密登录与部署');return;}
+ el.innerHTML=`
`+S.keys.map(k=>`
+
${ic.key}
+
${esc(k.name)}
+
${esc(k.fingerprint||'— 无指纹信息 —')}
+
${esc(k.path||'')} · 创建于 ${fmtD(k.created_at)}
+
+
`).join('')+`
`;
+}
+function openKeyModal(){
+ openModal(`${ic.plus} 添加 SSH 密钥`,`
+
+
+
+
私钥将保存在服务器受控目录中,仅用于 Ansible 连接目标主机
`,
+ '',`
`);
+ $('#kf-save').addEventListener('click',async()=>{
+ const name=$('#kf-name').value.trim(),key=$('#kf-key').value;
+ if(!name)return toast('请填写密钥名称','warn');
+ if(!key.trim())return toast('请粘贴私钥内容','warn');
+ await withBtn($('#kf-save'),async()=>{
+ await api('/api/sshkeys',{method:'POST',body:{name,private_key:key}});
+ toast('SSH 密钥添加成功');closeModal();
+ if($('#view-keys').classList.contains('on'))await loadKeys();
+ });
+ });
+}
+async function delKey(name){
+ if(!await confirmBox('删除 SSH 密钥',`确定要删除密钥
${esc(name)} 吗?
依赖该密钥的主机将无法继续连接。`))return;
+ try{await api(`/api/sshkeys/${encodeURIComponent(name)}`,{method:'DELETE'});toast('SSH 密钥已删除');await loadKeys();}catch(e){toast(esc(e.message),'err');}
+}
+
+/* ================= 任务中心 ================= */
+async function renderTasks(){
+ $('#view-tasks').innerHTML=`
+
${ic.zap}
+
+
+
${sklRows(4)}
+
`;
+ await loadTasks();
+}
+async function loadTasks(){
+ const el=$('#task-table');if(!el)return;
+ const r=await api('/api/tasks');
+ S.tasks=(r.data||[]).slice().sort((a,b)=>new Date(b.start_time)-new Date(a.start_time));
+ updateNavCounts();
+ const running=S.tasks.filter(t=>t.status==='running').length;
+ const sum=$('#task-summary');
+ if(sum)sum.innerHTML=`共 ${S.tasks.length} 个任务${running?` ·
${running} 个运行中`:' · 无运行中任务'}`;
+ if(!S.tasks.length){el.innerHTML=empty('🚀','还没有执行过任务','运行 Playbook、批量命令或文件分发后,任务会出现在这里,支持实时日志流');return;}
+ el.innerHTML=`
| 状态 | 任务 | 主机 | 进度 | 开始时间 | 耗时 | 操作 |
`+
+ S.tasks.map(t=>{const p=prog(t);return `
+ | ${statusBadge(t.status)} |
+ ${esc(t.name)} ${esc(shortPath(t.playbook)||('ID '+String(t.id).slice(0,8)))} |
+ ${(t.hosts||[]).length} 台 ${esc((t.hosts||[]).slice(0,2).join(', '))}${(t.hosts||[]).length>2?' …':''} |
+
${t.success_hosts||0}✓ ${t.failed_hosts||0}✗${t.total_hosts?` / ${t.total_hosts}`:''} |
+ ${fmtDT(t.start_time)} |
+ ${dur(t.start_time,t.end_time)} |
+ ${t.status==='running'
+ ?``
+ :``} |
+
`;}).join('')+`
`;
+}
+async function cancelTask(id){
+ if(!await confirmBox('取消任务','确定要取消该任务吗?
正在运行的 Ansible 进程将被终止。','确认取消'))return;
+ try{await api(`/api/tasks/${id}`,{method:'DELETE'});toast('已发送取消请求','warn');
+ if($('#modal').classList.contains('show'))openTaskModal(id);
+ else if($('#view-tasks').classList.contains('on'))setTimeout(()=>loadTasks(),600);
+ }catch(e){toast(esc(e.message),'err');}
+}
+
+/* ---- 任务日志弹窗(SSE 实时流) ---- */
+let taskES=null,taskBuf='',taskPaintQueued=false,curTaskId=null;
+function openTaskModal(id){
+ curTaskId=id;taskBuf='';
+ openModal(`${ic.terminal} 任务日志
#${esc(String(id).slice(0,8))}`,`
+
+
+
+
ansible-playbook · 实时输出LIVE
+
正在连接日志流…
+
+
`,
+ 'modal-xl',
+ `
`);
+ $('#task-cancel').addEventListener('click',()=>cancelTask(id));
+ api(`/api/tasks/${id}`).then(r=>{
+ const t=r.data;if(!t)return;
+ paintTaskMeta(t);
+ taskBuf=t.output||'';paintTaskLog();
+ if(t.status!=='running')finishTaskUI(t.status);
+ else startTaskStream(id);
+ }).catch(e=>{const el=$('#task-log');if(el)el.innerHTML=`
${esc(e.message)}`;});
+}
+function paintTaskMeta(t){
+ const el=$('#task-meta');if(!el)return;
+ const p=prog(t);
+ el.innerHTML=`
${esc(t.name)}${statusBadge(t.status)}
+
${esc((t.hosts||[]).join(', '))}
+
开始 ${fmtDT(t.start_time)} · 耗时 ${dur(t.start_time,t.end_time)}
+
`;
+}
+function paintTaskLog(){
+ const el=$('#task-log');if(!el)return;
+ const atEnd=el.scrollHeight-el.scrollTop-el.clientHeight<70;
+ el.innerHTML=ansi(taskBuf)||'
(暂无输出)';
+ if(atEnd)el.scrollTop=el.scrollHeight;
+}
+function queueTaskPaint(){if(taskPaintQueued)return;taskPaintQueued=true;requestAnimationFrame(()=>{taskPaintQueued=false;paintTaskLog();});}
+function startTaskStream(id){
+ stopTaskStream();
+ const es=new EventSource(`/api/tasks/${id}/stream`);taskES=es;
+ es.addEventListener('log',e=>{taskBuf+=e.data;queueTaskPaint();});
+ es.addEventListener('status',e=>{finishTaskUI(e.data);});
+ es.addEventListener('error',e=>{if(e.data){const er=$('#task-err');if(er){er.textContent='错误:'+e.data;er.style.display='block';}}});
+ es.onerror=()=>{};/* 服务端完成后主动关闭流,忽略 */
+}
+function stopTaskStream(){if(taskES){try{taskES.close();}catch(e){}taskES=null;}}
+function finishTaskUI(status){
+ stopTaskStream();
+ $('#task-live')?.classList.add('off');
+ const cb=$('#task-cancel');if(cb)cb.style.display='none';
+ api(`/api/tasks/${curTaskId}`).then(r=>{
+ const t=r.data;if(!t)return;
+ paintTaskMeta(t);
+ if(t.output){taskBuf=t.output;paintTaskLog();}
+ if(t.error){const er=$('#task-err');if(er){er.textContent='错误:'+t.error;er.style.display='block';}}
+ }).catch(()=>{});
+ if(status==='success'||status==='completed')toast('任务执行成功 🎉');
+ else if(status==='failed')toast('任务执行失败','err');
+ else if(status==='cancelled')toast('任务已取消','warn');
+ if($('#view-tasks').classList.contains('on'))loadTasks().catch(()=>{});
+}
+
+/* ================= 顶栏 / 系统信息 ================= */
+async function loadSystemInfo(){
+ try{const r=await api('/api/system/info');S.sys=r.data||null;}catch(e){S.sys=null;}
+ drawTopbar();
+}
+function drawTopbar(){
+ const s=S.sys;
+ $('#tb-host').innerHTML=s?`${ic.server}
${esc(s.hostname||'—')}`:`${ic.server}
离线`;
+ $('#tb-ver').innerHTML=s?`${ic.box}
Ansible ${esc(String(s.ansible_version||'—').split('\n')[0].replace(/^ansible\s+/i,''))}`:'';
+ $('#tb-up').innerHTML=`${ic.clock}
${esc(s?.uptime||'—')}`;
+ const foot=$('#side-status'),dot=$('#side-dot'),ver=$('#side-ver');
+ if(s){foot.textContent='系统运行正常';dot.className='sdot s-online';ver.textContent='v'+(s.version||'');}
+ else{foot.textContent='后端连接失败';dot.className='sdot s-offline';ver.textContent='';}
+}
+async function refreshAll(){
+ const b=$('#tb-refresh');b.classList.add('spinning');
+ await loadSystemInfo();
+ try{await PAGES[cur]();}catch(e){}
+ setTimeout(()=>b.classList.remove('spinning'),650);
+ toast('已刷新','info',1600);
+}
+function updateNavCounts(){
+ const set=(id,n)=>{const el=$(id);if(el)el.textContent=n>0?n:'';};
+ set('#cnt-hosts',S.hosts.length);
+ set('#cnt-pb',S.playbooks.length);
+ set('#cnt-tasks',S.tasks.filter(t=>t.status==='running').length);
+}
+
+/* ================= 路由 ================= */
+const PAGES={dashboard:renderDashboard,hosts:renderHosts,command:renderCommand,playbooks:renderPlaybooks,
+ templates:renderTemplates,files:renderFiles,keys:renderKeys,tasks:renderTasks};
+async function navigate(p){
+ cur=p;
+ $$('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.p===p));
+ $$('.page').forEach(x=>x.classList.remove('on'));
+ $('#view-'+p).classList.add('on');
+ $('#crumb').textContent=PAGE_META[p][0];
+ $('#crumb-sub').textContent=PAGE_META[p][1];
+ $('#sidebar').classList.remove('open');
+ try{await PAGES[p]();}
+ catch(e){$('#view-'+p).innerHTML=`
${empty('⚠️','页面加载失败',esc(e.message||String(e)),``)}
`;}
+}
+
+/* ================= 初始化 ================= */
+window.addEventListener('DOMContentLoaded',async()=>{
+ $('#menuBtn').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
+ $('#modal').addEventListener('click',e=>{if(e.target.id==='modal')closeModal();});
+ $('#modal-x').addEventListener('click',closeModal);
+ $('#tb-refresh').addEventListener('click',refreshAll);
+ $$('.nav-item').forEach(n=>n.addEventListener('click',()=>navigate(n.dataset.p)));
+ document.addEventListener('keydown',e=>{
+ if(e.key!=='Escape')return;
+ if($('#confirm').classList.contains('show'))$('#cf-no').click();
+ else if($('#modal').classList.contains('show'))closeModal();
+ });
+ await loadSystemInfo();
+ setInterval(loadSystemInfo,60000);
+ setInterval(()=>{/* 任务页自动刷新 */
+ if(cur==='tasks'&&$('#task-auto')?.checked&&!$('#modal').classList.contains('show'))loadTasks().catch(()=>{});
+ },4000);
+ navigate('dashboard');
+});
+