306 lines
7.9 KiB
Go
306 lines
7.9 KiB
Go
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)
|
|
}
|