package main import ( "fmt" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" ) // execStatus 执行结果状态 const ( statusRunning = "running" statusSuccess = "success" statusFailed = "failed" statusTimeout = "timeout" statusSkipped = "skipped" ) // 历史记录每脚本最多保留条数 const historyCapPerScript = 20 // 单个 execution 内存中保留的日志行上限(超出截断) const maxLogLinesPerExec = 2000 // liveLogCap 当前执行实时日志环形缓冲行数 const liveLogCap = 500 // ExecutionRecord 一次完整执行的记录(成功后写入历史) type ExecutionRecord struct { ID string `json:"id"` ScriptPath string `json:"script"` RepoName string `json:"repo"` Ref string `json:"ref"` CommitID string `json:"commit"` Status string `json:"status"` StartTime time.Time `json:"startedAt"` EndTime time.Time `json:"endedAt,omitempty"` Duration string `json:"duration,omitempty"` Reason string `json:"reason,omitempty"` Log string `json:"log,omitempty"` } // PendingTask 队列中待执行任务的对外视图 type PendingTask struct { ScriptPath string `json:"script"` RepoName string `json:"repo"` Ref string `json:"ref"` CommitID string `json:"commit"` QueuedAt time.Time `json:"queuedAt"` } // CurrentExecution 正在执行的任务对外视图(实时日志) type CurrentExecution struct { Task PendingTask `json:"task"` StartedAt time.Time `json:"startedAt"` Log string `json:"log"` } // currentExecState 内部维护的当前执行状态 type currentExecState struct { task PendingTask startedAt time.Time logLines []string } // Dashboard 维护队列/当前执行/历史的全局状态 type Dashboard struct { pendingMu sync.RWMutex pendingTasks []PendingTask currentMu sync.RWMutex currentExec *currentExecState historyMu sync.RWMutex history map[string][]ExecutionRecord idCounter atomic.Uint64 } // newDashboard 构造一个 Dashboard func newDashboard() *Dashboard { return &Dashboard{ history: make(map[string][]ExecutionRecord), } } // nextID 生成自增 ID func (d *Dashboard) nextID() string { n := d.idCounter.Add(1) return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), n) } // AddPending 入队时追加到 pending 列表(必须在写入 channel 后调用) func (d *Dashboard) AddPending(t buildTask) { d.pendingMu.Lock() defer d.pendingMu.Unlock() d.pendingTasks = append(d.pendingTasks, PendingTask{ ScriptPath: t.scriptPath, RepoName: t.repoName, Ref: t.ref, CommitID: t.commitID, QueuedAt: time.Now(), }) } // RemovePendingAndPeek 取出 pending 列表中与 task 匹配的首条并删除; // 返回值告知是否成功移除(用于在 channel 已消费但 pending 不同步时回退) func (d *Dashboard) RemovePendingAndPeek(t buildTask) { d.pendingMu.Lock() defer d.pendingMu.Unlock() for i, p := range d.pendingTasks { if p.ScriptPath == t.scriptPath && p.RepoName == t.repoName && p.Ref == t.ref && p.CommitID == t.commitID { d.pendingTasks = append(d.pendingTasks[:i], d.pendingTasks[i+1:]...) return } } // 未匹配上:丢弃最早的一条作为回退 if len(d.pendingTasks) > 0 { d.pendingTasks = d.pendingTasks[1:] } } // ListPending 返回当前 pending 列表副本 func (d *Dashboard) ListPending() []PendingTask { d.pendingMu.RLock() defer d.pendingMu.RUnlock() out := make([]PendingTask, len(d.pendingTasks)) copy(out, d.pendingTasks) return out } // StartExecution 标记一个任务开始执行 func (d *Dashboard) StartExecution(t buildTask) { d.currentMu.Lock() defer d.currentMu.Unlock() d.currentExec = ¤tExecState{ task: PendingTask{ ScriptPath: t.scriptPath, RepoName: t.repoName, Ref: t.ref, CommitID: t.commitID, QueuedAt: time.Now(), }, startedAt: time.Now(), logLines: make([]string, 0, 64), } } // AppendLog 写入一行实时日志(线程安全) func (d *Dashboard) AppendLog(line string) { d.currentMu.Lock() defer d.currentMu.Unlock() if d.currentExec == nil { return } d.currentExec.logLines = append(d.currentExec.logLines, line) if len(d.currentExec.logLines) > liveLogCap { // 保留尾部 drop := len(d.currentExec.logLines) - liveLogCap d.currentExec.logLines = d.currentExec.logLines[drop:] } } // FinishExecution 写入执行结果并清空 current func (d *Dashboard) FinishExecution(t buildTask, status, reason string, cost time.Duration) ExecutionRecord { d.currentMu.Lock() var rec ExecutionRecord if d.currentExec != nil { logStr := strings.Join(d.currentExec.logLines, "") if count := strings.Count(logStr, "\n"); count > maxLogLinesPerExec { // 截断保留尾部 idx := len(logStr) - 1 kept := 0 for ; idx >= 0 && kept < maxLogLinesPerExec; idx-- { if logStr[idx] == '\n' { kept++ } } if idx < 0 { idx = 0 } else { idx++ // 跳过最后一个换行 } logStr = logStr[idx:] } rec = ExecutionRecord{ ID: d.nextID(), ScriptPath: d.currentExec.task.ScriptPath, RepoName: d.currentExec.task.RepoName, Ref: d.currentExec.task.Ref, CommitID: d.currentExec.task.CommitID, Status: status, StartTime: d.currentExec.startedAt, EndTime: time.Now(), Duration: cost.Round(time.Second).String(), Reason: reason, Log: logStr, } } else { rec = ExecutionRecord{ ID: d.nextID(), ScriptPath: t.scriptPath, RepoName: t.repoName, Ref: t.ref, CommitID: t.commitID, Status: status, StartTime: time.Now().Add(-cost), EndTime: time.Now(), Duration: cost.Round(time.Second).String(), Reason: reason, } } d.currentExec = nil d.currentMu.Unlock() d.historyMu.Lock() list := d.history[rec.ScriptPath] list = append(list, rec) if len(list) > historyCapPerScript { list = list[len(list)-historyCapPerScript:] } d.history[rec.ScriptPath] = list d.historyMu.Unlock() return rec } // GetCurrent 返回当前执行视图(无任务时返回 nil) func (d *Dashboard) GetCurrent() *CurrentExecution { d.currentMu.RLock() defer d.currentMu.RUnlock() if d.currentExec == nil { return nil } return &CurrentExecution{ Task: d.currentExec.task, StartedAt: d.currentExec.startedAt, Log: strings.Join(d.currentExec.logLines, ""), } } // GetHistory 返回指定脚本的历史副本 func (d *Dashboard) GetHistory(script string) []ExecutionRecord { d.historyMu.RLock() defer d.historyMu.RUnlock() src := d.history[script] out := make([]ExecutionRecord, len(src)) copy(out, src) return out } // GetAllScripts 返回配置中声明的全部脚本路径(去重、保序) func (d *Dashboard) GetAllScripts() []string { seen := make(map[string]struct{}) out := make([]string, 0) for _, repoMap := range globalConfig.Projects { for _, script := range repoMap { if _, ok := seen[script]; ok { continue } seen[script] = struct{}{} out = append(out, script) } } return out } // 全局 dashboard 实例(main.go 启动时初始化) var dashboard *Dashboard // handleQueue 返回队列 + 当前执行状态 func handleQueue(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "pending": dashboard.ListPending(), "current": dashboard.GetCurrent(), }) } // handleHistory 返回所有已配置脚本的执行历史 func handleHistory(c *gin.Context) { scripts := dashboard.GetAllScripts() out := make(map[string][]ExecutionRecord, len(scripts)) for _, s := range scripts { out[s] = dashboard.GetHistory(s) } c.JSON(http.StatusOK, gin.H{ "scripts": scripts, "history": out, }) } // handleIndex 返回嵌入的 index.html func handleIndex(c *gin.Context) { data, err := staticFS.ReadFile("static/index.html") if err != nil { c.String(http.StatusInternalServerError, "index.html not found") return } c.Data(http.StatusOK, "text/html; charset=utf-8", data) }