package handler import ( "errors" "net/http" "strconv" "strings" "time" "github.com/1024XEngineer/xinfra/server/internal/model" "github.com/1024XEngineer/xinfra/server/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type TaskLogHandler struct { db *gorm.DB delivery *service.DeliveryService wayne *service.WayneRoleBindingService } func NewTaskLogHandler(db *gorm.DB, delivery *service.DeliveryService, wayne *service.WayneRoleBindingService) *TaskLogHandler { return &TaskLogHandler{db: db, delivery: delivery, wayne: wayne} } type taskLogSummary struct { ID string `json:"id"` Source string `json:"source"` Service string `json:"service"` Name string `json:"name"` Runner string `json:"runner"` Status string `json:"status"` StatusText string `json:"status_text"` StatusClass string `json:"status_class"` BusinessLineID uint64 `json:"business_line_id"` ReferenceID string `json:"reference_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type taskLogLine struct { Time string `json:"time"` Message string `json:"message"` Class string `json:"class"` } // List 聚合 AWX 交付任务和 Wayne 部署服务任务。 func (h *TaskLogHandler) List(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } source := strings.ToLower(strings.TrimSpace(c.DefaultQuery("source", "all"))) businessLineID, _ := strconv.ParseUint(c.Query("business_line_id"), 10, 64) items := make([]taskLogSummary, 0) if source == "" || source == "all" || source == "awx" { awxItems, err := h.listAWXTasks(c, claims.UserID, claims.IsAdmin, businessLineID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } items = append(items, awxItems...) } if source == "" || source == "all" || source == "wayne" { wayneItems, err := h.listWayneTasks(c, claims.UserID, claims.IsAdmin, businessLineID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } items = append(items, wayneItems...) } sortTaskLogSummaries(items) if len(items) > 100 { items = items[:100] } c.JSON(http.StatusOK, gin.H{"items": items}) } func (h *TaskLogHandler) Get(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } id := c.Param("id") switch { case strings.HasPrefix(id, "awx:"): h.getAWXTask(c, strings.TrimPrefix(id, "awx:"), claims.UserID, claims.IsAdmin) case strings.HasPrefix(id, "wayne:publish:"): h.getWayneTask(c, id, claims.UserID, claims.IsAdmin) default: c.JSON(http.StatusBadRequest, gin.H{"error": "unknown task log source"}) } } func (h *TaskLogHandler) listAWXTasks(c *gin.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]taskLogSummary, error) { tasks, err := h.delivery.ListTasks(c.Request.Context(), userID, isAdmin, service.DeliveryTaskListFilter{BusinessLineID: businessLineID}) if err != nil { return nil, err } items := make([]taskLogSummary, 0, len(tasks)) for _, task := range tasks { items = append(items, awxTaskSummary(task)) } return items, nil } func (h *TaskLogHandler) listWayneTasks(c *gin.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]taskLogSummary, error) { namespaces, err := h.visibleWayneNamespaces(c, userID, isAdmin, businessLineID) if err != nil { return nil, err } histories, err := h.wayne.ListDeploymentHistories(c.Request.Context(), namespaces, 100) if err != nil { return nil, err } items := make([]taskLogSummary, 0, len(histories)) for _, history := range histories { items = append(items, wayneTaskSummary(history)) } return items, nil } func (h *TaskLogHandler) getAWXTask(c *gin.Context, taskID string, userID uint64, isAdmin bool) { task, events, err := h.delivery.GetTask(c.Request.Context(), taskID, userID, isAdmin) if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "task log not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } lines := make([]taskLogLine, 0, len(events)+16) for _, event := range events { lines = append(lines, taskLogLine{Time: formatTaskLogTime(event.CreatedAt), Message: "[" + event.ToState + "] " + event.Message, Class: classForTaskStatus(event.ToState)}) } var execution model.ExecutionJob if err := h.db.WithContext(c.Request.Context()).Where("task_id = ?", task.ID).First(&execution).Error; err == nil && execution.ExecutorJobID != "" && execution.ExecutorJobID != "pending" && !strings.HasPrefix(execution.ExecutorJobID, "pending:") && !strings.HasPrefix(execution.ExecutorJobID, "pending-") { stdout, stdoutErr := h.delivery.AWXJobStdout(c.Request.Context(), execution.ExecutorJobID) if stdoutErr != nil { lines = append(lines, taskLogLine{Time: formatTaskLogTime(time.Now()), Message: "[awx] stdout fetch failed: " + stdoutErr.Error(), Class: "err"}) } else { lines = append(lines, splitStdoutLines(stdout)...) } } var rollback model.RollbackJob if err := h.db.WithContext(c.Request.Context()).Where("task_id = ?", task.ID).First(&rollback).Error; err == nil && rollback.ExecutorJobID != "" && rollback.ExecutorJobID != "pending" && !strings.HasPrefix(rollback.ExecutorJobID, "pending-") { stdout, stdoutErr := h.delivery.AWXJobStdout(c.Request.Context(), rollback.ExecutorJobID) if stdoutErr != nil { lines = append(lines, taskLogLine{Time: formatTaskLogTime(time.Now()), Message: "[rollback awx] stdout fetch failed: " + stdoutErr.Error(), Class: "err"}) } else { lines = append(lines, splitStdoutLines(stdout)...) } } c.JSON(http.StatusOK, gin.H{"task": awxTaskSummary(*task), "lines": lines}) } func (h *TaskLogHandler) getWayneTask(c *gin.Context, id string, userID uint64, isAdmin bool) { resourceID, historyID, ok := parseWaynePublishTaskID(id) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Wayne task log id"}) return } namespaces, err := h.visibleWayneNamespaces(c, userID, isAdmin, 0) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } history, err := h.wayne.GetDeploymentHistory(c.Request.Context(), namespaces, resourceID, historyID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } lines := []taskLogLine{ {Time: formatTaskLogTime(history.CreatedAt), Message: "[wayne] publish history #" + strconv.FormatInt(history.ID, 10), Class: classForWaynePublishStatus(history.Status)}, {Time: formatTaskLogTime(history.CreatedAt), Message: "[deployment] " + history.ResourceName + " resource_id=" + strconv.FormatInt(history.ResourceID, 10), Class: ""}, {Time: formatTaskLogTime(history.CreatedAt), Message: "[cluster] " + history.Cluster + " template_id=" + strconv.FormatInt(history.TemplateID, 10), Class: ""}, {Time: formatTaskLogTime(history.CreatedAt), Message: "[user] " + history.User, Class: ""}, } if strings.TrimSpace(history.Message) != "" { lines = append(lines, taskLogLine{Time: formatTaskLogTime(history.CreatedAt), Message: "[message] " + history.Message, Class: classForWaynePublishStatus(history.Status)}) } c.JSON(http.StatusOK, gin.H{"task": wayneTaskSummary(*history), "lines": lines}) } func awxTaskSummary(task model.DeliveryTask) taskLogSummary { return taskLogSummary{ ID: "awx:" + task.ID, Source: "awx", Service: "mysql", Name: "MySQL 标准化交付 · " + task.InstanceName, Runner: "AWX Job Template #" + strconv.FormatUint(task.TargetID, 10), Status: task.Status, StatusText: textForTaskStatus(task.Status), StatusClass: classForTaskStatus(task.Status), BusinessLineID: task.BusinessLineID, ReferenceID: task.ID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } } func wayneTaskSummary(history service.WayneDeploymentHistory) taskLogSummary { return taskLogSummary{ ID: waynePublishTaskID(history), Source: "wayne", Service: "wayne-deployment", Name: wayneDeploymentTaskName(history), Runner: "Wayne Native API", Status: strconv.Itoa(history.Status), StatusText: textForWaynePublishStatus(history.Status), StatusClass: classForWaynePublishStatus(history.Status), BusinessLineID: history.BusinessLineID, ReferenceID: strconv.FormatInt(history.ID, 10), CreatedAt: history.CreatedAt, UpdatedAt: history.CreatedAt, } } func (h *TaskLogHandler) visibleWayneNamespaces(c *gin.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]model.BusinessLineWayneNamespace, error) { query := h.db.WithContext(c.Request.Context()).Order("business_line_id ASC, wayne_namespace_id ASC") if businessLineID != 0 { query = query.Where("business_line_id = ?", businessLineID) } if !isAdmin { query = query.Where("business_line_id IN (?)", h.db.Model(&model.BusinessLineUser{}).Select("business_line_id").Where("user_id = ?", userID)) } var namespaces []model.BusinessLineWayneNamespace if err := query.Find(&namespaces).Error; err != nil { return nil, err } return namespaces, nil } func waynePublishTaskID(history service.WayneDeploymentHistory) string { return "wayne:publish:" + strconv.FormatInt(history.ResourceID, 10) + ":" + strconv.FormatInt(history.ID, 10) } func parseWaynePublishTaskID(id string) (int64, int64, bool) { parts := strings.Split(id, ":") if len(parts) != 4 || parts[0] != "wayne" || parts[1] != "publish" { return 0, 0, false } resourceID, resourceErr := strconv.ParseInt(parts[2], 10, 64) historyID, historyErr := strconv.ParseInt(parts[3], 10, 64) if resourceErr != nil || historyErr != nil { return 0, 0, false } return resourceID, historyID, true } func wayneDeploymentTaskName(history service.WayneDeploymentHistory) string { name := strings.TrimSpace(history.ResourceName) if name == "" { name = strconv.FormatInt(history.ResourceID, 10) } return "Wayne 服务部署 · " + name } func textForTaskStatus(status string) string { switch status { case model.TaskPending, model.TaskValidating, model.TaskDispatching: return "等待" case model.TaskRunning, model.TaskRegistering, model.TaskCanceling: return "执行中" case model.TaskRollbackPending, model.TaskRollingBack: return "回退中" case model.TaskFinished: return "成功" case model.TaskRolledBack: return "已回退" case model.TaskRollbackFailed: return "回退失败" case model.TaskRollbackAck: return "已确认释放" case model.TaskRegisterFailed: return "注册失败(实例保留)" case model.TaskCanceled: return "已取消" default: return "失败" } } func classForTaskStatus(status string) string { switch status { case model.TaskFinished: return "ok" case model.TaskExecutionFailed, model.TaskValidationFailed, model.TaskCanceled, model.TaskRollbackFailed: return "err" case model.TaskRollbackAck, model.TaskRegisterFailed: return "warn" case model.TaskRunning, model.TaskDispatching, model.TaskRegistering, model.TaskCanceling, model.TaskRollbackPending, model.TaskRollingBack: return "warn" case model.TaskRolledBack: return "ok" default: return "" } } func textForWaynePublishStatus(status int) string { switch status { case 1: return "成功" case 0: return "失败" default: return "未知" } } func classForWaynePublishStatus(status int) string { switch status { case 1: return "ok" case 0: return "err" default: return "" } } func splitStdoutLines(stdout string) []taskLogLine { lines := make([]taskLogLine, 0) for _, line := range strings.Split(stdout, "\n") { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } lines = append(lines, taskLogLine{Time: "", Message: line, Class: classForOutputLine(line)}) } return lines } func classForOutputLine(line string) string { lower := strings.ToLower(line) switch { case strings.Contains(lower, "failed") || strings.Contains(lower, "fatal") || strings.Contains(lower, "error"): return "err" case strings.Contains(lower, "ok:") || strings.Contains(lower, "successful") || strings.Contains(lower, "success"): return "ok" case strings.Contains(lower, "changed:"): return "tag-ok" default: return "" } } func formatTaskLogTime(t time.Time) string { if t.IsZero() { return "" } return t.Format("15:04:05") } func sortTaskLogSummaries(items []taskLogSummary) { for i := 1; i < len(items); i++ { item := items[i] j := i - 1 for j >= 0 && items[j].UpdatedAt.Before(item.UpdatedAt) { items[j+1] = items[j] j-- } items[j+1] = item } }