feat: playbook执行日志实时流式推送(SSE) + 任务详情显示原始日志
This commit is contained in:
Binary file not shown.
@@ -87,6 +87,7 @@ func main() {
|
|||||||
// 任务执行
|
// 任务执行
|
||||||
api.GET("/tasks", h.ListTasks)
|
api.GET("/tasks", h.ListTasks)
|
||||||
api.GET("/tasks/:id", h.GetTask)
|
api.GET("/tasks/:id", h.GetTask)
|
||||||
|
api.GET("/tasks/:id/stream", h.StreamTaskOutput)
|
||||||
api.DELETE("/tasks/:id", h.CancelTask)
|
api.DELETE("/tasks/:id", h.CancelTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ansible-deploy/internal/models"
|
"github.com/ansible-deploy/internal/models"
|
||||||
"github.com/ansible-deploy/internal/services"
|
"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 取消任务
|
// CancelTask 取消任务
|
||||||
func (h *AnsibleHandler) CancelTask(c *gin.Context) {
|
func (h *AnsibleHandler) CancelTask(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|||||||
@@ -703,12 +703,45 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st
|
|||||||
cmd = exec.CommandContext(ctx, "ansible-playbook", args...)
|
cmd = exec.CommandContext(ctx, "ansible-playbook", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
var output bytes.Buffer
|
// 实时写入日志的 Writer
|
||||||
cmd.Stdout = &output
|
sw := &syncWriter{buf: bytes.NewBuffer(nil)}
|
||||||
cmd.Stderr = &output
|
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()
|
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()
|
task.EndTime = time.Now()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
@@ -716,7 +749,25 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st
|
|||||||
} else {
|
} else {
|
||||||
task.Status = "success"
|
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
|
// ListPlaybooks 列出可用Playbooks
|
||||||
|
|||||||
+1
-1
@@ -5,6 +5,6 @@
|
|||||||
nas ansible_host=10.168.1.209 ansible_user=root
|
nas ansible_host=10.168.1.209 ansible_user=root
|
||||||
|
|
||||||
[zebu_user01]
|
[zebu_user01]
|
||||||
scmp47 ansible_host=172.16.11.44 ansible_user=root
|
|
||||||
scmp48 ansible_host=172.16.11.46 ansible_user=root
|
scmp48 ansible_host=172.16.11.46 ansible_user=root
|
||||||
scmp46 ansible_host=172.16.11.42 ansible_user=root
|
scmp46 ansible_host=172.16.11.42 ansible_user=root
|
||||||
|
scmp47 ansible_host=172.16.11.44 ansible_user=root
|
||||||
|
|||||||
+30
-30
@@ -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",
|
"id": "57884720",
|
||||||
"name": "scmp46",
|
"name": "scmp46",
|
||||||
@@ -58,5 +28,35 @@
|
|||||||
"last_check": "2026-05-13T18:30:33.343216547+08:00",
|
"last_check": "2026-05-13T18:30:33.343216547+08:00",
|
||||||
"created_at": "0001-01-01T00:00:00Z",
|
"created_at": "0001-01-01T00:00:00Z",
|
||||||
"updated_at": "2026-05-13T18:28:08.656450224+08:00"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Vendored
+58
-9
@@ -1246,6 +1246,11 @@
|
|||||||
|
|
||||||
function closeModal(id) {
|
function closeModal(id) {
|
||||||
document.getElementById(id).classList.remove('active');
|
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) {
|
async function viewTaskOutput(id) {
|
||||||
const res = await api(`/tasks/${id}`);
|
const res = await api(`/tasks/${id}`);
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
const task = res.data;
|
const task = res.data;
|
||||||
const content = document.getElementById('taskDetailContent');
|
const content = document.getElementById('taskDetailContent');
|
||||||
|
|
||||||
|
// 关闭之前的 SSE 连接
|
||||||
|
if (_taskLogSSE) {
|
||||||
|
_taskLogSSE.close();
|
||||||
|
_taskLogSSE = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_taskLogOutput = task.output || '';
|
||||||
|
|
||||||
|
const isRunning = task.status === 'running';
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div style="margin-bottom: 15px;">
|
<div style="margin-bottom: 15px;">
|
||||||
<strong>任务名称:</strong> ${task.name}<br>
|
<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.start_time ? new Date(task.start_time).toLocaleString() : '-'}<br>
|
||||||
<strong>结束时间:</strong> ${task.end_time ? new Date(task.end_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>
|
</div>
|
||||||
<h3 style="color:#00d4aa;margin-bottom:10px;">执行结果</h3>
|
<h3 style="color:#00d4aa;margin-bottom:10px;">执行日志 ${isRunning ? '<span style="color:#ffd93d;font-size:12px;">(实时更新中...)</span>' : ''}</h3>
|
||||||
<div class="terminal">
|
<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>
|
||||||
${(task.results || []).map(r => `
|
|
||||||
<div class="line ${r.success ? 'success' : 'error'}">[${r.host}] ${r.success ? '✓' : '✗'} (${r.duration || 0}ms)</div>
|
|
||||||
${r.output ? `<div class="line">${escapeHtml(r.output)}</div>` : ''}
|
|
||||||
${r.error ? `<div class="line error">${escapeHtml(r.error)}</div>` : ''}
|
|
||||||
`).join('') || '<div class="line info">暂无结果</div>'}
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
document.getElementById('taskDetailModal').classList.add('active');
|
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 {
|
} else {
|
||||||
showToast('获取任务详情失败', 'error');
|
showToast('获取任务详情失败', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user