Forráskód Böngészése

feat: playbook执行日志实时流式推送(SSE) + 任务详情显示原始日志

Hermes Agent 6 napja
szülő
commit
d58e860059
7 módosított fájl, 193 hozzáadás és 40 törlés
  1. BIN
      ansible-deploy
  2. 1 0
      cmd/main.go
  3. 52 0
      internal/handlers/handlers.go
  4. 55 4
      internal/services/ansible.go
  5. 1 1
      inventory/hosts
  6. 26 26
      inventory/hosts.json
  7. 58 9
      web/dist/index.html

BIN
ansible-deploy


+ 1 - 0
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)
 	}
 

+ 52 - 0
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")

+ 55 - 4
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

+ 1 - 1
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

+ 26 - 26
inventory/hosts.json

@@ -1,23 +1,23 @@
 [
   {
-    "id": "706f8ce7",
-    "name": "nas",
-    "ip": "10.168.1.209",
+    "id": "57884720",
+    "name": "scmp46",
+    "ip": "172.16.11.42",
     "port": 22,
     "username": "root",
-    "password": "WXJwxj91612!!",
+    "password": "STC#scmp%0818",
     "groups": [
-      "all"
+      "zebu_user01"
     ],
     "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"
+    "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": "4d7a1f03",
-    "name": "scmp48",
-    "ip": "172.16.11.46",
+    "id": "5150c740",
+    "name": "scmp47",
+    "ip": "172.16.11.44",
     "port": 22,
     "username": "root",
     "password": "STC#scmp%0818",
@@ -25,29 +25,29 @@
       "zebu_user01"
     ],
     "status": "online",
-    "last_check": "2026-05-13T18:27:19.272543838+08:00",
+    "last_check": "2026-05-13T18:30:33.343216547+08:00",
     "created_at": "0001-01-01T00:00:00Z",
-    "updated_at": "2026-05-13T18:23:27.433461112+08:00"
+    "updated_at": "2026-05-13T18:28:08.656450224+08:00"
   },
   {
-    "id": "57884720",
-    "name": "scmp46",
-    "ip": "172.16.11.42",
+    "id": "706f8ce7",
+    "name": "nas",
+    "ip": "10.168.1.209",
     "port": 22,
     "username": "root",
-    "password": "STC#scmp%0818",
+    "password": "WXJwxj91612!!",
     "groups": [
-      "zebu_user01"
+      "all"
     ],
     "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"
+    "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": "5150c740",
-    "name": "scmp47",
-    "ip": "172.16.11.44",
+    "id": "4d7a1f03",
+    "name": "scmp48",
+    "ip": "172.16.11.46",
     "port": 22,
     "username": "root",
     "password": "STC#scmp%0818",
@@ -55,8 +55,8 @@
       "zebu_user01"
     ],
     "status": "online",
-    "last_check": "2026-05-13T18:30:33.343216547+08:00",
+    "last_check": "2026-05-13T18:27:19.272543838+08:00",
     "created_at": "0001-01-01T00:00:00Z",
-    "updated_at": "2026-05-13T18:28:08.656450224+08:00"
+    "updated_at": "2026-05-13T18:23:27.433461112+08:00"
   }
 ]

+ 58 - 9
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 = `
                     <div style="margin-bottom: 15px;">
                         <strong>任务名称:</strong> ${task.name}<br>
-                        <strong>状态:</strong> <span class="status ${task.status}">${statusText(task.status)}</span><br>
+                        <strong>状态:</strong> <span class="status ${task.status}">${statusText(task.status)}</span>
+                        ${isRunning ? '<span class="spinner" style="margin-left:8px;"></span>' : ''}<br>
                         <strong>开始时间:</strong> ${task.start_time ? new Date(task.start_time).toLocaleString() : '-'}<br>
                         <strong>结束时间:</strong> ${task.end_time ? new Date(task.end_time).toLocaleString() : '-'}<br>
+                        ${task.error ? `<strong style="color:#ff6b6b;">错误:</strong> <span style="color:#ff6b6b;">${escapeHtml(task.error)}</span><br>` : ''}
                     </div>
-                    <h3 style="color:#00d4aa;margin-bottom:10px;">执行结果</h3>
-                    <div class="terminal">
-                        ${(task.results || []).map(r => `
-                            <div class="line ${r.success ? 'success' : 'error'}">[${r.host}] ${r.success ? '✓' : '✗'} (${r.duration || 0}ms)</div>
-                            ${r.output ? `<div class="line">${escapeHtml(r.output)}</div>` : ''}
-                            ${r.error ? `<div class="line error">${escapeHtml(r.error)}</div>` : ''}
-                        `).join('') || '<div class="line info">暂无结果</div>'}
-                    </div>
+                    <h3 style="color:#00d4aa;margin-bottom:10px;">执行日志 ${isRunning ? '<span style="color:#ffd93d;font-size:12px;">(实时更新中...)</span>' : ''}</h3>
+                    <div class="terminal" id="taskLogTerminal" style="max-height:500px;overflow-y:auto;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all;background:#0d1117;padding:15px;border-radius:8px;">${escapeHtml(_taskLogOutput) || '<span style="color:#8899a6;">等待输出...</span>'}</div>
                 `;
                 document.getElementById('taskDetailModal').classList.add('active');
+
+                // 运行中的任务用 SSE 实时推送
+                if (isRunning) {
+                    const sseUrl = `${API_BASE}/tasks/${id}/stream`;
+                    _taskLogSSE = new EventSource(sseUrl);
+                    _taskLogSSE.addEventListener('log', function(e) {
+                        _taskLogOutput += e.data;
+                        const terminal = document.getElementById('taskLogTerminal');
+                        if (terminal) {
+                            terminal.textContent = _taskLogOutput;
+                            terminal.scrollTop = terminal.scrollHeight;
+                        }
+                    });
+                    _taskLogSSE.addEventListener('status', function(e) {
+                        const statusEl = content.querySelector('.status');
+                        if (statusEl) {
+                            statusEl.className = `status ${e.data}`;
+                            statusEl.textContent = statusText(e.data);
+                        }
+                        // 移除 spinner 和更新提示
+                        const spinner = content.querySelector('.spinner');
+                        if (spinner) spinner.remove();
+                        const hint = content.querySelector('h3 span');
+                        if (hint) hint.remove();
+                    });
+                    _taskLogSSE.addEventListener('error', function(e) {
+                        // SSE 连接关闭(正常完成或断开)
+                        if (_taskLogSSE) {
+                            _taskLogSSE.close();
+                            _taskLogSSE = null;
+                        }
+                    });
+                }
             } else {
                 showToast('获取任务详情失败', 'error');
             }