diff --git a/ansible-deploy b/ansible-deploy index 5015d36..499b57f 100755 Binary files a/ansible-deploy and b/ansible-deploy differ diff --git a/cmd/main.go b/cmd/main.go index d9d46be..4352fec 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -87,6 +87,7 @@ func main() { // 任务执行 api.GET("/tasks", h.ListTasks) api.GET("/tasks/:id", h.GetTask) + api.GET("/tasks/:id/stream", h.StreamTaskOutput) api.DELETE("/tasks/:id", h.CancelTask) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 51625f5..09a2090 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/ansible-deploy/internal/models" "github.com/ansible-deploy/internal/services" @@ -325,6 +326,57 @@ func (h *AnsibleHandler) GetTask(c *gin.Context) { }) } +// StreamTaskOutput SSE 流式推送任务日志 +func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) { + id := c.Param("id") + task := h.service.GetTask(id) + if task == nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "任务不存在"}) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + 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(): + return + } + } +} + // CancelTask 取消任务 func (h *AnsibleHandler) CancelTask(c *gin.Context) { id := c.Param("id") diff --git a/internal/services/ansible.go b/internal/services/ansible.go index 2fa937b..84961b2 100644 --- a/internal/services/ansible.go +++ b/internal/services/ansible.go @@ -703,12 +703,45 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st cmd = exec.CommandContext(ctx, "ansible-playbook", args...) } - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &output + // 实时写入日志的 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) + defer ticker.Stop() + var lastLen int + for { + select { + case <-ticker.C: + s.taskLock.Lock() + sw.mu.Lock() + currentLen := sw.buf.Len() + if currentLen > lastLen { + task.Output = sw.buf.String() + lastLen = currentLen + } + sw.mu.Unlock() + s.taskLock.Unlock() + case <-done: + return + } + } + }() err := cmd.Run() + close(done) // 通知 goroutine 退出 + // 最终同步一次完整日志 + sw.mu.Lock() + finalOutput := sw.buf.String() + sw.mu.Unlock() + + s.taskLock.Lock() + task.Output = finalOutput task.EndTime = time.Now() if err != nil { task.Status = "failed" @@ -716,7 +749,25 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st } else { task.Status = "success" } - task.Output = output.String() + s.taskLock.Unlock() +} + +// syncWriter 线程安全的 Writer +type syncWriter struct { + buf *bytes.Buffer + mu sync.Mutex +} + +func (w *syncWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.Write(p) +} + +func (w *syncWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.String() } // ListPlaybooks 列出可用Playbooks diff --git a/inventory/hosts b/inventory/hosts index 0a0b8cf..6e5bf4a 100644 --- a/inventory/hosts +++ b/inventory/hosts @@ -5,6 +5,6 @@ nas ansible_host=10.168.1.209 ansible_user=root [zebu_user01] - scmp47 ansible_host=172.16.11.44 ansible_user=root 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 index 79c6ec2..7a5f4fd 100644 --- a/inventory/hosts.json +++ b/inventory/hosts.json @@ -1,34 +1,4 @@ [ - { - "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", @@ -58,5 +28,35 @@ "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" + }, + { + "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" } ] \ No newline at end of file diff --git a/web/dist/index.html b/web/dist/index.html index d0dc92e..9ea8b62 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -1246,6 +1246,11 @@ function closeModal(id) { document.getElementById(id).classList.remove('active'); + // 关闭任务日志 SSE 连接 + if (id === 'taskDetailModal' && _taskLogSSE) { + _taskLogSSE.close(); + _taskLogSSE = null; + } } // 编辑主机 @@ -1543,28 +1548,72 @@ } } + // SSE 流式日志连接管理 + let _taskLogSSE = null; + let _taskLogOutput = ''; + async function viewTaskOutput(id) { const res = await api(`/tasks/${id}`); if (res.code === 0 && res.data) { const task = res.data; const content = document.getElementById('taskDetailContent'); + + // 关闭之前的 SSE 连接 + if (_taskLogSSE) { + _taskLogSSE.close(); + _taskLogSSE = null; + } + + _taskLogOutput = task.output || ''; + + const isRunning = task.status === 'running'; + content.innerHTML = `