Files
xinfra/server/internal/handler/task_log.go
T

348 lines
11 KiB
Go

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" {
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)...)
}
}
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.TaskFinished:
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.TaskRegisterFailed, model.TaskCanceled:
return "err"
case model.TaskRunning, model.TaskDispatching, model.TaskRegistering, model.TaskCanceling:
return "warn"
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
}
}