Files
git-hook/dashboard.go
T
2026-09-01 10:13:33 +08:00

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 = &currentExecState{
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)
}