feat: dashboard
This commit is contained in:
+305
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,6 +18,9 @@ func main() {
|
|||||||
log.Fatalf("加载配置文件失败: %v", err)
|
log.Fatalf("加载配置文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化 dashboard 状态(必须在 startBuildWorker 之前,否则 worker 回调为 nil)
|
||||||
|
dashboard = newDashboard()
|
||||||
|
|
||||||
//gin.SetMode(gin.ReleaseMode)
|
//gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
@@ -29,10 +33,20 @@ func main() {
|
|||||||
|
|
||||||
startBuildWorker()
|
startBuildWorker()
|
||||||
|
|
||||||
|
// Webhook 入口
|
||||||
r.POST("/webhook", handleWebhook)
|
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)
|
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 {
|
if err := r.Run(listenAddr); err != nil {
|
||||||
log.Fatalf("服务启动失败: %v", err)
|
log.Fatalf("服务启动失败: %v", err)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scriptTimeout 脚本执行超时上限(15 分钟)
|
// scriptTimeout 脚本执行超时上限(60 分钟)
|
||||||
const scriptTimeout = 60 * time.Minute
|
const scriptTimeout = 60 * time.Minute
|
||||||
|
|
||||||
// buildTask 单次构建任务的快照,handler 入队时构造。
|
// buildTask 单次构建任务的快照,handler 入队时构造。
|
||||||
@@ -44,14 +44,25 @@ var (
|
|||||||
func enqueueBuildTask(t buildTask) {
|
func enqueueBuildTask(t buildTask) {
|
||||||
select {
|
select {
|
||||||
case buildCh <- t:
|
case buildCh <- t:
|
||||||
return // 入队成功
|
// 入队成功后再登记到 dashboard,保证 channel 与列表一致
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.AddPending(t)
|
||||||
|
}
|
||||||
|
return
|
||||||
default:
|
default:
|
||||||
// 队列已满,丢弃旧 task
|
// 队列已满,丢弃旧 task
|
||||||
select {
|
select {
|
||||||
case <-buildCh:
|
case <-buildCh:
|
||||||
|
if dashboard != nil {
|
||||||
|
// 同步移除最旧一条,避免 pending 列表与 channel 错位
|
||||||
|
dashboard.RemovePendingAndPeek(t)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
buildCh <- t // 阻塞不会发生,因为我们是唯一发送方
|
buildCh <- t // 阻塞不会发生,因为我们是唯一发送方
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.AddPending(t)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +74,10 @@ func startBuildWorker() {
|
|||||||
// consumeBuild worker 主循环;取出 task,按冷却规则执行或跳过
|
// consumeBuild worker 主循环;取出 task,按冷却规则执行或跳过
|
||||||
func consumeBuild() {
|
func consumeBuild() {
|
||||||
for task := range buildCh {
|
for task := range buildCh {
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.RemovePendingAndPeek(task)
|
||||||
|
}
|
||||||
|
|
||||||
lockKey := fmt.Sprintf("%s:%s", task.repoName, task.ref)
|
lockKey := fmt.Sprintf("%s:%s", task.repoName, task.ref)
|
||||||
entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{})
|
entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{})
|
||||||
entry := entryVal.(*debounceEntry)
|
entry := entryVal.(*debounceEntry)
|
||||||
@@ -77,6 +92,13 @@ func consumeBuild() {
|
|||||||
entry.mu.Unlock()
|
entry.mu.Unlock()
|
||||||
log.Printf("[SKIP] 3 分钟冷却中,跳过构建 [%s] commit %s 剩余 %d 秒\n",
|
log.Printf("[SKIP] 3 分钟冷却中,跳过构建 [%s] commit %s 剩余 %d 秒\n",
|
||||||
lockKey, shortCommit(task.commitID), remainingSeconds)
|
lockKey, shortCommit(task.commitID), remainingSeconds)
|
||||||
|
// 冷却跳过也写入历史,方便追踪
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.StartExecution(task)
|
||||||
|
dashboard.FinishExecution(task, statusSkipped,
|
||||||
|
fmt.Sprintf("冷却中,剩余 %d 秒", remainingSeconds),
|
||||||
|
time.Duration(0))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entry.lastExec = now
|
entry.lastExec = now
|
||||||
@@ -100,12 +122,16 @@ func shortCommit(commitID string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// streamLog 逐行读取并实时打印脚本输出,直到 r 关闭
|
// 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)
|
br := bufio.NewReader(r)
|
||||||
for {
|
for {
|
||||||
line, err := br.ReadString('\n')
|
line, err := br.ReadString('\n')
|
||||||
if line != "" {
|
if line != "" {
|
||||||
log.Printf("[SCRIPT %s] %s", tag, strings.TrimRight(line, "\r\n"))
|
log.Printf("[SCRIPT %s] %s", tag, strings.TrimRight(line, "\r\n"))
|
||||||
|
if onLine != nil {
|
||||||
|
onLine(line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -130,13 +156,24 @@ func notifyResult(ok bool, repoName, ref, commitID string, cost time.Duration, r
|
|||||||
log.Println("[INFO] 推送通知发送成功")
|
log.Println("[INFO] 推送通知发送成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// runScript 异步执行 Shell 脚本,输出实时流式打印,超时 15 分钟
|
// runScript 异步执行 Shell 脚本,输出实时流式打印,超时上限 scriptTimeout
|
||||||
func runScript(scriptPath, repoName, ref, commitID string) {
|
func runScript(scriptPath, repoName, ref, commitID string) {
|
||||||
|
task := buildTask{
|
||||||
|
scriptPath: scriptPath,
|
||||||
|
repoName: repoName,
|
||||||
|
ref: ref,
|
||||||
|
commitID: commitID,
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
tag := fmt.Sprintf("%s:%s", repoName, ref)
|
tag := fmt.Sprintf("%s:%s", repoName, ref)
|
||||||
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s) 超时上限 %s",
|
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s) 超时上限 %s",
|
||||||
scriptPath, repoName, ref, scriptTimeout)
|
scriptPath, repoName, ref, scriptTimeout)
|
||||||
|
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.StartExecution(task)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -161,12 +198,20 @@ func runScript(scriptPath, repoName, ref, commitID string) {
|
|||||||
pr.Close()
|
pr.Close()
|
||||||
log.Printf("[ERROR] 脚本启动失败 [%s]: %v", scriptPath, err)
|
log.Printf("[ERROR] 脚本启动失败 [%s]: %v", scriptPath, err)
|
||||||
notifyResult(false, repoName, ref, commitID, time.Since(start), fmt.Sprintf("启动失败: %v", 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
streamLog(pr, tag)
|
streamLog(pr, tag, func(line string) {
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.AppendLog(line)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
err := cmd.Wait() // 进程退出 + 内部 copy goroutine 结束
|
err := cmd.Wait() // 进程退出 + 内部 copy goroutine 结束
|
||||||
@@ -180,13 +225,22 @@ func runScript(scriptPath, repoName, ref, commitID string) {
|
|||||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
log.Printf("[TIMEOUT] 脚本执行超时 [%s] 已运行 %s 上限 %s", scriptPath, cost.Round(time.Second), scriptTimeout)
|
log.Printf("[TIMEOUT] 脚本执行超时 [%s] 已运行 %s 上限 %s", scriptPath, cost.Round(time.Second), scriptTimeout)
|
||||||
notifyResult(false, repoName, ref, commitID, cost, "执行超时,已强制终止")
|
notifyResult(false, repoName, ref, commitID, cost, "执行超时,已强制终止")
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.FinishExecution(task, statusTimeout, "执行超时,已强制终止", cost)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Printf("[ERROR] 脚本执行失败 [%s] 耗时 %s: %v", scriptPath, cost.Round(time.Millisecond), err)
|
log.Printf("[ERROR] 脚本执行失败 [%s] 耗时 %s: %v", scriptPath, cost.Round(time.Millisecond), err)
|
||||||
notifyResult(false, repoName, ref, commitID, cost, err.Error())
|
notifyResult(false, repoName, ref, commitID, cost, err.Error())
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.FinishExecution(task, statusFailed, err.Error(), cost)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[SUCCESS] 脚本执行成功 [%s] 耗时 %s", scriptPath, cost.Round(time.Millisecond))
|
log.Printf("[SUCCESS] 脚本执行成功 [%s] 耗时 %s", scriptPath, cost.Round(time.Millisecond))
|
||||||
notifyResult(true, repoName, ref, commitID, cost, "执行成功")
|
notifyResult(true, repoName, ref, commitID, cost, "执行成功")
|
||||||
|
if dashboard != nil {
|
||||||
|
dashboard.FinishExecution(task, statusSuccess, "执行成功", cost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+279
@@ -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); }
|
||||||
+254
@@ -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 = `<p class="empty">暂无等待任务</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = state.pending.map((p) => `
|
||||||
|
<div class="pending-item">
|
||||||
|
<div class="row">
|
||||||
|
<strong>${escapeHtml(p.repo)}</strong>
|
||||||
|
<span class="badge badge-warn">等待中</span>
|
||||||
|
</div>
|
||||||
|
<div class="row meta">
|
||||||
|
<span>分支 <code>${escapeHtml(p.ref)}</code></span>
|
||||||
|
<span>commit <code>${escapeHtml(shortCommit(p.commit))}</code></span>
|
||||||
|
</div>
|
||||||
|
<div class="script">${escapeHtml(p.script)}</div>
|
||||||
|
<div class="meta">入队时间: ${escapeHtml(fmtFullTime(p.queuedAt))}</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRunning() {
|
||||||
|
const body = $("#running-body");
|
||||||
|
const stateBadge = $("#running-state");
|
||||||
|
const sizeEl = $("#log-size");
|
||||||
|
|
||||||
|
if (!state.current) {
|
||||||
|
stateBadge.textContent = "空闲";
|
||||||
|
stateBadge.className = "badge badge-grey";
|
||||||
|
body.innerHTML = `<p class="empty">当前没有任务在执行</p>`;
|
||||||
|
sizeEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = state.current;
|
||||||
|
stateBadge.textContent = "执行中";
|
||||||
|
stateBadge.className = "badge badge-warn";
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<dl class="running-meta">
|
||||||
|
<dt>仓库</dt><dd>${escapeHtml(c.task.repo)}</dd>
|
||||||
|
<dt>分支</dt><dd>${escapeHtml(c.task.ref)}</dd>
|
||||||
|
<dt>Commit</dt><dd>${escapeHtml(shortCommit(c.task.commit))}</dd>
|
||||||
|
<dt>脚本</dt><dd>${escapeHtml(c.task.script)}</dd>
|
||||||
|
<dt>开始</dt><dd>${escapeHtml(fmtFullTime(c.startedAt))}</dd>
|
||||||
|
</dl>
|
||||||
|
<pre class="log-box" id="live-log"></pre>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `<span class="status-pill ${cls}"><span class="dot"></span>${text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
const root = $("#history-list");
|
||||||
|
if (!state.scripts.length) {
|
||||||
|
root.innerHTML = `<p class="empty">配置文件中未声明任何脚本</p>`;
|
||||||
|
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 `
|
||||||
|
<tr class="${liveMarker}">
|
||||||
|
<td>${escapeHtml(fmtFullTime(r.startedAt))}</td>
|
||||||
|
<td>${escapeHtml(shortCommit(r.commit))}</td>
|
||||||
|
<td>${escapeHtml(r.repo)} <span class="muted">${escapeHtml(r.ref)}</span></td>
|
||||||
|
<td>${statusPill(r.status)}</td>
|
||||||
|
<td>${escapeHtml(r.duration || (r.status === "running" ? "进行中" : "-"))}</td>
|
||||||
|
<td>${escapeHtml(r.reason || "")}</td>
|
||||||
|
<td>
|
||||||
|
${r.log
|
||||||
|
? `<button class="toggle-log" data-target="${logId}" type="button">查看日志</button>`
|
||||||
|
: `<span class="muted small">无日志</span>`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${r.log ? `
|
||||||
|
<tr class="log-cell">
|
||||||
|
<td colspan="7" style="padding:0;">
|
||||||
|
<pre class="log-box" id="${logId}" style="display:none; max-height:280px;">${escapeHtml(r.log)}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>` : ""}
|
||||||
|
`;
|
||||||
|
}).join("")
|
||||||
|
: `<tr><td colspan="7" class="empty">尚无执行记录</td></tr>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-group">
|
||||||
|
<h3><code>${escapeHtml(script)}</code><span class="muted small">(${records.length})</span></h3>
|
||||||
|
<table class="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>开始时间</th><th>Commit</th><th>仓库/分支</th>
|
||||||
|
<th>状态</th><th>耗时</th><th>原因</th><th>日志</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
// 绑定展开日志按钮
|
||||||
|
root.querySelectorAll(".toggle-log").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const target = document.getElementById(btn.dataset.target);
|
||||||
|
if (!target) return;
|
||||||
|
const visible = target.style.display !== "none";
|
||||||
|
target.style.display = visible ? "none" : "block";
|
||||||
|
btn.textContent = visible ? "查看日志" : "收起日志";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Wiring ============
|
||||||
|
$("#refresh-btn").addEventListener("click", () => {
|
||||||
|
pollQueue();
|
||||||
|
pollHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#log-autoscroll").addEventListener("click", (e) => {
|
||||||
|
state.autoscroll = !state.autoscroll;
|
||||||
|
e.currentTarget.classList.toggle("active", state.autoscroll);
|
||||||
|
});
|
||||||
|
|
||||||
|
pollQueue();
|
||||||
|
pollHistory();
|
||||||
|
setInterval(pollQueue, POLL_QUEUE_MS);
|
||||||
|
setInterval(pollHistory, POLL_HISTORY_MS);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>GitLab Webhook 控制台</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo">⬢</span>
|
||||||
|
<div>
|
||||||
|
<h1>GitLab Webhook 控制台</h1>
|
||||||
|
<p class="subtitle">队列 / 执行日志 / 执行历史</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
<span id="conn-dot" class="dot dot-grey" title="连接状态"></span>
|
||||||
|
<span id="conn-text">连接中…</span>
|
||||||
|
<button id="refresh-btn" class="btn-ghost" type="button">立即刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="card" id="queue-card">
|
||||||
|
<header class="card-head">
|
||||||
|
<h2>① 等待中的队列</h2>
|
||||||
|
<span id="pending-count" class="badge">0</span>
|
||||||
|
</header>
|
||||||
|
<div id="pending-list" class="card-body pending-list">
|
||||||
|
<p class="empty">暂无等待任务</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="running-card">
|
||||||
|
<header class="card-head">
|
||||||
|
<h2>② 正在执行</h2>
|
||||||
|
<span id="running-state" class="badge badge-grey">空闲</span>
|
||||||
|
</header>
|
||||||
|
<div id="running-body" class="card-body running-body">
|
||||||
|
<p class="empty">当前没有任务在执行</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<button id="log-autoscroll" class="btn-ghost active" type="button">自动滚动</button>
|
||||||
|
<span id="log-size" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="history-card">
|
||||||
|
<header class="card-head">
|
||||||
|
<h2>③ 执行历史</h2>
|
||||||
|
<span class="muted small">按脚本分组,最多保留 20 条</span>
|
||||||
|
</header>
|
||||||
|
<div id="history-list" class="card-body history-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user