① 等待中的队列
+ 0 +暂无等待任务
+② 正在执行
+ 空闲 +当前没有任务在执行
+diff --git a/dashboard.go b/dashboard.go new file mode 100644 index 0000000..461d615 --- /dev/null +++ b/dashboard.go @@ -0,0 +1,305 @@ +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) +} diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..89848d5 --- /dev/null +++ b/embed.go @@ -0,0 +1,15 @@ +package main + +import ( + "embed" + "io/fs" +) + +//go:embed static +var staticFS embed.FS + +// staticSubFS 返回 static 子目录的 fs.FS,便于 StaticFS 挂载 +func staticSubFS() fs.FS { + sub, _ := fs.Sub(staticFS, "static") + return sub +} diff --git a/main.go b/main.go index 30c961d..3a9dca0 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "net/http" "time" "github.com/gin-gonic/gin" @@ -17,6 +18,9 @@ func main() { log.Fatalf("加载配置文件失败: %v", err) } + // 初始化 dashboard 状态(必须在 startBuildWorker 之前,否则 worker 回调为 nil) + dashboard = newDashboard() + //gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) @@ -29,10 +33,20 @@ func main() { startBuildWorker() + // Webhook 入口 r.POST("/webhook", handleWebhook) + // Dashboard 静态资源 + 页面 + r.GET("/", handleIndex) + r.StaticFS("/static", http.FS(staticSubFS())) + + // Dashboard JSON API + r.GET("/api/queue", handleQueue) + r.GET("/api/history", handleHistory) + listenAddr := fmt.Sprintf(":%d", *portFlag) - log.Printf("GitLab Webhook 服务已启动 (队列模式 + 3 分钟冷却去重),监听端口 %d ...\n", *portFlag) + log.Printf("GitLab Webhook 服务已启动 (队列模式 + 3 分钟冷却去重), 监听端口 %d ...\n", *portFlag) + log.Printf("Dashboard: http://localhost:%d/\n", *portFlag) if err := r.Run(listenAddr); err != nil { log.Fatalf("服务启动失败: %v", err) diff --git a/runner.go b/runner.go index 2e99193..411d271 100644 --- a/runner.go +++ b/runner.go @@ -14,7 +14,7 @@ import ( "time" ) -// scriptTimeout 脚本执行超时上限(15 分钟) +// scriptTimeout 脚本执行超时上限(60 分钟) const scriptTimeout = 60 * time.Minute // buildTask 单次构建任务的快照,handler 入队时构造。 @@ -44,14 +44,25 @@ var ( func enqueueBuildTask(t buildTask) { select { case buildCh <- t: - return // 入队成功 + // 入队成功后再登记到 dashboard,保证 channel 与列表一致 + if dashboard != nil { + dashboard.AddPending(t) + } + return default: // 队列已满,丢弃旧 task select { case <-buildCh: + if dashboard != nil { + // 同步移除最旧一条,避免 pending 列表与 channel 错位 + dashboard.RemovePendingAndPeek(t) + } default: } buildCh <- t // 阻塞不会发生,因为我们是唯一发送方 + if dashboard != nil { + dashboard.AddPending(t) + } } } @@ -63,6 +74,10 @@ func startBuildWorker() { // consumeBuild worker 主循环;取出 task,按冷却规则执行或跳过 func consumeBuild() { for task := range buildCh { + if dashboard != nil { + dashboard.RemovePendingAndPeek(task) + } + lockKey := fmt.Sprintf("%s:%s", task.repoName, task.ref) entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{}) entry := entryVal.(*debounceEntry) @@ -77,6 +92,13 @@ func consumeBuild() { entry.mu.Unlock() log.Printf("[SKIP] 3 分钟冷却中,跳过构建 [%s] commit %s 剩余 %d 秒\n", lockKey, shortCommit(task.commitID), remainingSeconds) + // 冷却跳过也写入历史,方便追踪 + if dashboard != nil { + dashboard.StartExecution(task) + dashboard.FinishExecution(task, statusSkipped, + fmt.Sprintf("冷却中,剩余 %d 秒", remainingSeconds), + time.Duration(0)) + } continue } entry.lastExec = now @@ -100,12 +122,16 @@ func shortCommit(commitID string) string { } // streamLog 逐行读取并实时打印脚本输出,直到 r 关闭 -func streamLog(r io.Reader, tag string) { +// onLine 在每行非空时回调一次(携带换行符) +func streamLog(r io.Reader, tag string, onLine func(line string)) { br := bufio.NewReader(r) for { line, err := br.ReadString('\n') if line != "" { log.Printf("[SCRIPT %s] %s", tag, strings.TrimRight(line, "\r\n")) + if onLine != nil { + onLine(line) + } } if err != nil { return @@ -130,13 +156,24 @@ func notifyResult(ok bool, repoName, ref, commitID string, cost time.Duration, r log.Println("[INFO] 推送通知发送成功") } -// runScript 异步执行 Shell 脚本,输出实时流式打印,超时 15 分钟 +// runScript 异步执行 Shell 脚本,输出实时流式打印,超时上限 scriptTimeout func runScript(scriptPath, repoName, ref, commitID string) { + task := buildTask{ + scriptPath: scriptPath, + repoName: repoName, + ref: ref, + commitID: commitID, + } + start := time.Now() tag := fmt.Sprintf("%s:%s", repoName, ref) log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s) 超时上限 %s", scriptPath, repoName, ref, scriptTimeout) + if dashboard != nil { + dashboard.StartExecution(task) + } + ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout) defer cancel() @@ -161,12 +198,20 @@ func runScript(scriptPath, repoName, ref, commitID string) { pr.Close() log.Printf("[ERROR] 脚本启动失败 [%s]: %v", scriptPath, err) notifyResult(false, repoName, ref, commitID, time.Since(start), fmt.Sprintf("启动失败: %v", err)) + if dashboard != nil { + dashboard.FinishExecution(task, statusFailed, + fmt.Sprintf("启动失败: %v", err), time.Since(start)) + } return } var wg sync.WaitGroup wg.Go(func() { - streamLog(pr, tag) + streamLog(pr, tag, func(line string) { + if dashboard != nil { + dashboard.AppendLog(line) + } + }) }) err := cmd.Wait() // 进程退出 + 内部 copy goroutine 结束 @@ -180,13 +225,22 @@ func runScript(scriptPath, repoName, ref, commitID string) { case errors.Is(ctx.Err(), context.DeadlineExceeded): log.Printf("[TIMEOUT] 脚本执行超时 [%s] 已运行 %s 上限 %s", scriptPath, cost.Round(time.Second), scriptTimeout) notifyResult(false, repoName, ref, commitID, cost, "执行超时,已强制终止") + if dashboard != nil { + dashboard.FinishExecution(task, statusTimeout, "执行超时,已强制终止", cost) + } return case err != nil: log.Printf("[ERROR] 脚本执行失败 [%s] 耗时 %s: %v", scriptPath, cost.Round(time.Millisecond), err) notifyResult(false, repoName, ref, commitID, cost, err.Error()) + if dashboard != nil { + dashboard.FinishExecution(task, statusFailed, err.Error(), cost) + } return } log.Printf("[SUCCESS] 脚本执行成功 [%s] 耗时 %s", scriptPath, cost.Round(time.Millisecond)) notifyResult(true, repoName, ref, commitID, cost, "执行成功") + if dashboard != nil { + dashboard.FinishExecution(task, statusSuccess, "执行成功", cost) + } } diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..6e120a8 --- /dev/null +++ b/static/app.css @@ -0,0 +1,279 @@ +:root { + --bg: #0f1115; + --surface: #161a22; + --surface-2: #1d2230; + --border: #262c3a; + --text: #e6e8ee; + --text-dim: #9aa3b2; + --muted: #6f7787; + --accent: #5b8def; + --accent-2: #3a6fd1; + --success: #3ec07a; + --warn: #e0a458; + --danger: #e26a6a; + --skipped: #9aa3b2; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --shadow: 0 1px 0 rgba(255, 255, 255, 0.04), 0 8px 24px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.55; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } + +/* ============ Topbar ============ */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 28px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, #161a22 0%, #11141b 100%); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(8px); +} + +.brand { display: flex; align-items: center; gap: 14px; } +.brand .logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + font-size: 22px; + box-shadow: var(--shadow); +} +.brand h1 { margin: 0; font-size: 18px; font-weight: 600; } +.subtitle { margin: 2px 0 0; color: var(--text-dim); font-size: 12px; } + +.status { display: flex; align-items: center; gap: 12px; color: var(--text-dim); font-size: 13px; } +.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; } +.dot-green { background: var(--success); box-shadow: 0 0 8px var(--success); } +.dot-red { background: var(--danger); box-shadow: 0 0 8px var(--danger); } +.dot-grey { background: var(--muted); } + +/* ============ Layout ============ */ +.layout { + display: grid; + grid-template-columns: 1fr 1.4fr; + grid-template-rows: auto auto; + gap: 18px; + padding: 22px; + max-width: 1400px; + margin: 0 auto; +} +#queue-card { grid-column: 1; grid-row: 1; } +#running-card { grid-column: 2; grid-row: 1; } +#history-card { grid-column: 1 / span 2; grid-row: 2; } + +@media (max-width: 980px) { + .layout { grid-template-columns: 1fr; } + #queue-card, #running-card, #history-card { + grid-column: 1; grid-row: auto; + } +} + +/* ============ Cards ============ */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + min-height: 200px; + overflow: hidden; +} +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} +.card-head h2 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); } +.card-body { padding: 14px 16px; flex: 1; min-height: 0; } +.card-foot { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + border-top: 1px solid var(--border); + background: var(--surface-2); + font-size: 12px; +} + +.empty { color: var(--muted); text-align: center; padding: 20px 0; margin: 0; } + +.badge { + display: inline-flex; + align-items: center; + font-size: 12px; + padding: 2px 10px; + border-radius: 999px; + background: var(--accent); + color: #fff; +} +.badge-grey { background: var(--muted); } +.badge-success { background: var(--success); } +.badge-failed { background: var(--danger); } +.badge-warn { background: var(--warn); color: #2b1d05; } +.badge-skipped { background: var(--skipped); } + +.muted { color: var(--text-dim); } +.small { font-size: 12px; } + +/* ============ Pending ============ */ +.pending-list { display: flex; flex-direction: column; gap: 10px; } +.pending-item { + border: 1px solid var(--border); + border-left: 3px solid var(--warn); + background: var(--surface-2); + border-radius: 8px; + padding: 10px 12px; +} +.pending-item .row { display: flex; justify-content: space-between; gap: 8px; } +.pending-item .script { font-family: var(--mono); font-size: 12px; color: var(--text-dim); } +.pending-item .meta { font-size: 12px; color: var(--text-dim); margin-top: 4px; } +.pending-item .meta code { background: var(--bg); padding: 1px 6px; border-radius: 4px; } + +/* ============ Running ============ */ +.running-body { display: flex; flex-direction: column; gap: 10px; } +.running-meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 14px; + font-size: 13px; +} +.running-meta dt { color: var(--text-dim); } +.running-meta dd { margin: 0; font-family: var(--mono); font-size: 12px; } + +.log-box { + background: #0a0c12; + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + height: 360px; + overflow: auto; + font-family: var(--mono); + font-size: 12.5px; + white-space: pre-wrap; + word-break: break-all; + color: #cdd3df; + scroll-behavior: smooth; +} +.log-box .placeholder { color: var(--muted); } + +/* ============ History ============ */ +.history-list { display: flex; flex-direction: column; gap: 18px; } +.history-group h3 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 8px; +} +.history-group h3 code { + background: var(--bg); + padding: 2px 8px; + border-radius: 4px; + color: var(--text); + font-family: var(--mono); + font-size: 12px; +} + +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.history-table th, +.history-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.history-table th { + background: var(--surface-2); + color: var(--text-dim); + font-weight: 500; + font-size: 12px; +} +.history-table tr:last-child td { border-bottom: 0; } +.history-table tr.row-running td { background: rgba(91, 141, 239, 0.08); } + +.history-table .toggle-log { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; +} +.history-table .toggle-log:hover { color: var(--text); border-color: var(--accent); } +.history-table .log-cell { + padding: 0; +} +.history-table .log-cell .log-box { + border-radius: 0; + border: none; + height: auto; + max-height: 320px; +} + +/* ============ Buttons ============ */ +.btn-ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} +.btn-ghost:hover { color: var(--text); border-color: var(--accent); } +.btn-ghost.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(91, 141, 239, 0.08); +} + +/* ============ Status dot in row ============ */ +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + font-weight: 500; +} +.status-pill .dot { width: 7px; height: 7px; } +.status-success { color: var(--success); background: rgba(62, 192, 122, 0.10); } +.status-failed { color: var(--danger); background: rgba(226, 106, 106, 0.10); } +.status-timeout { color: var(--warn); background: rgba(224, 164, 88, 0.12); } +.status-skipped { color: var(--skipped); background: rgba(154, 163, 178, 0.12); } +.status-running { color: var(--accent); background: rgba(91, 141, 239, 0.12); } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..a77df40 --- /dev/null +++ b/static/app.js @@ -0,0 +1,254 @@ +// GitLab Webhook Dashboard - 前端逻辑 +// 数据源: +// GET /api/queue -> { pending: [], current: { task, startedAt, log } | null } +// GET /api/history -> { scripts: [], history: { scriptPath: [record, ...] } } + +const POLL_QUEUE_MS = 1500; // 队列 + 当前执行 轮询周期 +const POLL_HISTORY_MS = 4000; // 历史 轮询周期 +const LOG_TAIL_CHARS = 12000; // 前端日志渲染尾部最大字符数 + +const state = { + pending: [], + current: null, + history: {}, // { scriptPath: [record, ...] } + scripts: [], // 配置中的全部脚本 + autoscroll: true, +}; + +// ============ Helpers ============ +const $ = (sel) => document.querySelector(sel); +const fmtTime = (iso) => { + if (!iso) return "-"; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +}; +const fmtFullTime = (iso) => { + if (!iso) return "-"; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +}; +const shortCommit = (s) => { + if (!s) return "unknown"; + return s.length > 7 ? s.slice(0, 7) : s; +}; +const escapeHtml = (s) => + String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); + +// ============ Connection status ============ +function setConn(ok, text) { + const dot = $("#conn-dot"); + const txt = $("#conn-text"); + dot.classList.remove("dot-green", "dot-red", "dot-grey"); + dot.classList.add(ok ? "dot-green" : "dot-red"); + txt.textContent = text; +} + +// ============ Fetchers ============ +async function fetchJSON(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +async function pollQueue() { + try { + const data = await fetchJSON("/api/queue"); + state.pending = data.pending || []; + state.current = data.current || null; + setConn(true, "已连接"); + renderQueue(); + renderRunning(); + } catch (e) { + setConn(false, "连接失败"); + console.warn("queue poll failed:", e); + } +} + +async function pollHistory() { + try { + const data = await fetchJSON("/api/history"); + state.scripts = data.scripts || []; + state.history = data.history || {}; + renderHistory(); + } catch (e) { + console.warn("history poll failed:", e); + } +} + +// ============ Renderers ============ +function renderQueue() { + const list = $("#pending-list"); + const badge = $("#pending-count"); + badge.textContent = String(state.pending.length); + + if (state.pending.length === 0) { + list.innerHTML = `
暂无等待任务
`; + return; + } + + list.innerHTML = state.pending.map((p) => ` +当前没有任务在执行
`; + sizeEl.textContent = ""; + return; + } + + const c = state.current; + stateBadge.textContent = "执行中"; + stateBadge.className = "badge badge-warn"; + + body.innerHTML = ` + + + `; + + const logBox = $("#live-log"); + // 仅渲染尾部以避免长日志卡顿 + const fullLog = c.log || ""; + const tail = fullLog.length > LOG_TAIL_CHARS + ? fullLog.slice(fullLog.length - LOG_TAIL_CHARS) + : fullLog; + logBox.textContent = tail || "(等待脚本输出…)"; + sizeEl.textContent = fullLog.length + ? `${(fullLog.length / 1024).toFixed(1)} KB` + : ""; + + if (state.autoscroll) { + logBox.scrollTop = logBox.scrollHeight; + } +} + +function statusPill(status) { + const map = { + success: ["status-success", "成功"], + failed: ["status-failed", "失败"], + timeout: ["status-timeout", "超时"], + skipped: ["status-skipped", "跳过"], + running: ["status-running", "运行中"], + }; + const [cls, text] = map[status] || ["status-skipped", status || "-"]; + return `${text}`; +} + +function renderHistory() { + const root = $("#history-list"); + if (!state.scripts.length) { + root.innerHTML = `配置文件中未声明任何脚本
`; + return; + } + + // 当前正在执行的任务 ID(用于高亮) + const liveKey = state.current + ? `${state.current.task.script}|${state.current.task.repo}|${state.current.task.ref}` + : null; + + root.innerHTML = state.scripts.map((script) => { + const records = state.history[script] || []; + const rows = records.length + ? records.slice().reverse().map((r) => { + const liveMarker = (liveKey && liveKey === `${r.script}|${r.repo}|${r.ref}` && r.status === "running") + ? "row-running" + : ""; + const logId = `log-${r.id}`; + return ` +${escapeHtml(script)}(${records.length})| 开始时间 | Commit | 仓库/分支 | +状态 | 耗时 | 原因 | 日志 | +
|---|
队列 / 执行日志 / 执行历史
+暂无等待任务
+当前没有任务在执行
+