feat: 流水线队列引擎 — max_executors并发控制 + Jenkins风格构建队列+取消
This commit is contained in:
1 parent
be9db65a22
commit
7bc5169e8f
5 files changed
+249
-4
No files matched your search
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
@@ -234,6 +235,8 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
|||||||
WebhookToken: req.WebhookToken,
|
WebhookToken: req.WebhookToken,
|
||||||
// 缓存
|
// 缓存
|
||||||
CachePaths: req.CachePaths,
|
CachePaths: req.CachePaths,
|
||||||
|
// 并发控制
|
||||||
|
MaxExecutors: req.MaxExecutors,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +318,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
|||||||
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
|
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
|
||||||
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
|
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
|
||||||
if req.Enabled != nil { updates["enabled"] = req.Enabled }
|
if req.Enabled != nil { updates["enabled"] = req.Enabled }
|
||||||
|
if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors }
|
||||||
|
|
||||||
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
|
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.Response{
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
@@ -418,8 +422,8 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台异步执行流水线
|
// 通过队列引擎执行(尊重 max_executors 并发限制)
|
||||||
go h.executePipeline(pipeline, run)
|
go h.tryExecuteOrQueue(pipeline, run)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已触发", Data: run})
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已触发", Data: run})
|
||||||
}
|
}
|
||||||
@@ -548,3 +552,29 @@ func (h *PipelineHandler) WebhookTrigger(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPipelineQueue 查询流水线队列状态
|
||||||
|
func (h *PipelineHandler) GetPipelineQueue(c *gin.Context) {
|
||||||
|
id := getPipelineID(c)
|
||||||
|
if id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := h.GetQueueStatus(uint(id))
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: status})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelQueueItem 取消排队中的构建
|
||||||
|
func (h *PipelineHandler) CancelQueueItem(c *gin.Context) {
|
||||||
|
id := getPipelineID(c)
|
||||||
|
runID, _ := strconv.ParseUint(c.Param("runId"), 10, 64)
|
||||||
|
if id == 0 || runID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.CancelQueued(uint(id), uint(runID)); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已取消"})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
@@ -108,6 +109,7 @@ func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.P
|
|||||||
log("success", "完成", "流水线执行成功 (0s)")
|
log("success", "完成", "流水线执行成功 (0s)")
|
||||||
updateStatus("success", 0)
|
updateStatus("success", 0)
|
||||||
updatePipelineStats("success")
|
updatePipelineStats("success")
|
||||||
|
h.dequeueNext(pipeline.ID)
|
||||||
h.flushLogsToDB(ctx, runID, logKey)
|
h.flushLogsToDB(ctx, runID, logKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,6 +146,7 @@ func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.P
|
|||||||
log("warn", stage.Name, "流水线已被停止")
|
log("warn", stage.Name, "流水线已被停止")
|
||||||
updateStatus("cancelled", int(time.Since(totalStart).Seconds()))
|
updateStatus("cancelled", int(time.Since(totalStart).Seconds()))
|
||||||
updatePipelineStats("cancelled")
|
updatePipelineStats("cancelled")
|
||||||
|
h.dequeueNext(pipeline.ID)
|
||||||
h.flushLogsToDB(ctx, runID, logKey)
|
h.flushLogsToDB(ctx, runID, logKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,7 @@ func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.P
|
|||||||
log("error", stage.Name, fmt.Sprintf("✗ %s 失败 (%v): %v", stage.Name, stageDur.Round(time.Second), err))
|
log("error", stage.Name, fmt.Sprintf("✗ %s 失败 (%v): %v", stage.Name, stageDur.Round(time.Second), err))
|
||||||
updateStatus("failed", int(time.Since(totalStart).Seconds()))
|
updateStatus("failed", int(time.Since(totalStart).Seconds()))
|
||||||
updatePipelineStats("failed")
|
updatePipelineStats("failed")
|
||||||
|
h.dequeueNext(pipeline.ID)
|
||||||
h.flushLogsToDB(ctx, runID, logKey)
|
h.flushLogsToDB(ctx, runID, logKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -182,10 +186,10 @@ func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.P
|
|||||||
|
|
||||||
totalDur := int(time.Since(totalStart).Seconds())
|
totalDur := int(time.Since(totalStart).Seconds())
|
||||||
updateStatus("success", totalDur)
|
updateStatus("success", totalDur)
|
||||||
updatePipelineStats("success")
|
|
||||||
log("success", "完成", fmt.Sprintf("流水线执行成功 (%ds)", totalDur))
|
log("success", "完成", fmt.Sprintf("流水线执行成功 (%ds)", totalDur))
|
||||||
|
|
||||||
// 执行结束 → Redis 日志批量写入 MySQL
|
updatePipelineStats("success")
|
||||||
|
h.dequeueNext(pipeline.ID)
|
||||||
h.flushLogsToDB(ctx, runID, logKey)
|
h.flushLogsToDB(ctx, runID, logKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +553,93 @@ func cleanOldWorkspaces(baseDir string, maxAge time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 流水线队列引擎(Jenkins 风格并发控制)====================
|
||||||
|
|
||||||
|
var (
|
||||||
|
pipelineRunningCount = make(map[uint]int) // pipelineID → 当前运行数
|
||||||
|
pipelineQueues = make(map[uint][]uint) // pipelineID → 排队 runID 列表
|
||||||
|
queueMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// tryExecuteOrQueue 触发流水线时检查并发限制
|
||||||
|
// 如果当前运行数 < maxExecutors → 立即执行
|
||||||
|
// 否则 → 加入队列 (status=queued)
|
||||||
|
func (h *PipelineHandler) tryExecuteOrQueue(pipeline models.Pipeline, run models.PipelineRun) {
|
||||||
|
max := pipeline.MaxExecutors
|
||||||
|
if max <= 0 { max = 1 }
|
||||||
|
|
||||||
|
queueMu.Lock()
|
||||||
|
running := pipelineRunningCount[pipeline.ID]
|
||||||
|
if running < max {
|
||||||
|
pipelineRunningCount[pipeline.ID] = running + 1
|
||||||
|
queueMu.Unlock()
|
||||||
|
// 立即执行
|
||||||
|
h.db.Model(&models.PipelineRun{}).Where("id = ?", run.ID).Update("status", "running")
|
||||||
|
go h.executePipeline(pipeline, run)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 加入队列
|
||||||
|
pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID)
|
||||||
|
queueMu.Unlock()
|
||||||
|
h.db.Model(&models.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dequeueNext 流水线运行结束后,从队列中取下一个执行
|
||||||
|
func (h *PipelineHandler) dequeueNext(pipelineID uint) {
|
||||||
|
queueMu.Lock()
|
||||||
|
running := pipelineRunningCount[pipelineID]
|
||||||
|
if running > 0 {
|
||||||
|
pipelineRunningCount[pipelineID] = running - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := pipelineQueues[pipelineID]
|
||||||
|
if len(queue) == 0 {
|
||||||
|
queueMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextRunID := queue[0]
|
||||||
|
pipelineQueues[pipelineID] = queue[1:]
|
||||||
|
pipelineRunningCount[pipelineID] = pipelineRunningCount[pipelineID] + 1
|
||||||
|
queueMu.Unlock()
|
||||||
|
|
||||||
|
// 加载 pipeline + run
|
||||||
|
var pipeline models.Pipeline
|
||||||
|
if err := h.db.First(&pipeline, pipelineID).Error; err != nil { return }
|
||||||
|
var run models.PipelineRun
|
||||||
|
if err := h.db.First(&run, nextRunID).Error; err != nil { return }
|
||||||
|
|
||||||
|
h.db.Model(&run).Update("status", "running")
|
||||||
|
go h.executePipeline(pipeline, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueStatus 查询流水线队列状态(供 API 使用)
|
||||||
|
func (h *PipelineHandler) GetQueueStatus(pipelineID uint) map[string]interface{} {
|
||||||
|
queueMu.Lock()
|
||||||
|
defer queueMu.Unlock()
|
||||||
|
running := pipelineRunningCount[pipelineID]
|
||||||
|
queued := pipelineQueues[pipelineID]
|
||||||
|
return map[string]interface{}{
|
||||||
|
"running": running,
|
||||||
|
"queued": len(queued),
|
||||||
|
"queue_ids": queued,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelQueued 取消排队中的构建
|
||||||
|
func (h *PipelineHandler) CancelQueued(pipelineID uint, runID uint) error {
|
||||||
|
queueMu.Lock()
|
||||||
|
defer queueMu.Unlock()
|
||||||
|
queue := pipelineQueues[pipelineID]
|
||||||
|
for i, id := range queue {
|
||||||
|
if id == runID {
|
||||||
|
pipelineQueues[pipelineID] = append(queue[:i], queue[i+1:]...)
|
||||||
|
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("status", "cancelled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("run %d not in queue", runID)
|
||||||
|
}
|
||||||
|
|
||||||
func findFiles(dir string, exts ...string) []string {
|
func findFiles(dir string, exts ...string) []string {
|
||||||
var result []string
|
var result []string
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
|
|||||||
@@ -474,6 +474,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
pipelineScoped.POST("/runs/:runId/stop", pipelineHandler.StopPipelineRun)
|
pipelineScoped.POST("/runs/:runId/stop", pipelineHandler.StopPipelineRun)
|
||||||
pipelineScoped.POST("/runs/batch-delete", pipelineHandler.BatchDeleteRuns)
|
pipelineScoped.POST("/runs/batch-delete", pipelineHandler.BatchDeleteRuns)
|
||||||
pipelineScoped.GET("/runs/:runId/log", pipelineHandler.GetRunLog)
|
pipelineScoped.GET("/runs/:runId/log", pipelineHandler.GetRunLog)
|
||||||
|
// 队列管理
|
||||||
|
pipelineScoped.GET("/queue", pipelineHandler.GetPipelineQueue)
|
||||||
|
pipelineScoped.DELETE("/queue/:runId", pipelineHandler.CancelQueueItem)
|
||||||
// 成员管理
|
// 成员管理
|
||||||
pipelineScoped.GET("/members", pipelineHandler.ListPipelineMembers)
|
pipelineScoped.GET("/members", pipelineHandler.ListPipelineMembers)
|
||||||
pipelineScoped.POST("/members", pipelineHandler.AddPipelineMember)
|
pipelineScoped.POST("/members", pipelineHandler.AddPipelineMember)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type Pipeline struct {
|
|||||||
DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"`
|
DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"`
|
||||||
DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"`
|
DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"`
|
||||||
|
|
||||||
|
// 并发控制
|
||||||
|
MaxExecutors int `gorm:"default:1;comment:最大并发执行数(1=串行)" json:"max_executors"`
|
||||||
|
|
||||||
// SSH 部署目标服务器 (JSON: [1,2,3])
|
// SSH 部署目标服务器 (JSON: [1,2,3])
|
||||||
ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
|
ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
|
||||||
|
|
||||||
@@ -170,6 +173,9 @@ type CreatePipelineRequest struct {
|
|||||||
|
|
||||||
// 缓存路径
|
// 缓存路径
|
||||||
CachePaths string `json:"cache_paths"`
|
CachePaths string `json:"cache_paths"`
|
||||||
|
|
||||||
|
// 并发控制
|
||||||
|
MaxExecutors int `json:"max_executors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePipelineRequest 更新流水线请求
|
// UpdatePipelineRequest 更新流水线请求
|
||||||
@@ -203,6 +209,9 @@ type UpdatePipelineRequest struct {
|
|||||||
// 缓存
|
// 缓存
|
||||||
CachePaths string `json:"cache_paths"`
|
CachePaths string `json:"cache_paths"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
|
||||||
|
// 并发控制
|
||||||
|
MaxExecutors int `json:"max_executors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerPipelineRequest 触发流水线请求
|
// TriggerPipelineRequest 触发流水线请求
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export function PipelineManager() {
|
|||||||
// Cache paths
|
// Cache paths
|
||||||
const [newCachePaths, setNewCachePaths] = useState('');
|
const [newCachePaths, setNewCachePaths] = useState('');
|
||||||
|
|
||||||
|
// Executor concurrency
|
||||||
|
const [newMaxExecutors, setNewMaxExecutors] = useState(1);
|
||||||
|
|
||||||
// Reference data
|
// Reference data
|
||||||
const [repos, setRepos] = useState<any[]>([]);
|
const [repos, setRepos] = useState<any[]>([]);
|
||||||
const [registries, setRegistries] = useState<any[]>([]);
|
const [registries, setRegistries] = useState<any[]>([]);
|
||||||
@@ -190,6 +193,7 @@ export function PipelineManager() {
|
|||||||
variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined,
|
variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined,
|
||||||
// 缓存路径
|
// 缓存路径
|
||||||
cache_paths: newCachePaths || undefined,
|
cache_paths: newCachePaths || undefined,
|
||||||
|
max_executors: newMaxExecutors,
|
||||||
});
|
});
|
||||||
toast.success('流水线已创建');
|
toast.success('流水线已创建');
|
||||||
setNewOpen(false); resetForm();
|
setNewOpen(false); resetForm();
|
||||||
@@ -461,6 +465,7 @@ export function PipelineManager() {
|
|||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-[var(--dg-border)]">
|
<div className="border-t border-[var(--dg-border)]">
|
||||||
|
<PipelineQueuePanel pipeline={pipeline} clusterId="" />
|
||||||
{isLoadingRuns ? (
|
{isLoadingRuns ? (
|
||||||
<div className="flex items-center justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
|
<div className="flex items-center justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
|
||||||
) : pipelineRuns.length === 0 ? (
|
) : pipelineRuns.length === 0 ? (
|
||||||
@@ -796,6 +801,26 @@ export function PipelineManager() {
|
|||||||
placeholder="node_modules, .m2, target" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-8 text-xs font-mono" />
|
placeholder="node_modules, .m2, target" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-8 text-xs font-mono" />
|
||||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">多个路径用逗号分隔,相同的 Key 会命中已有缓存</p>
|
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">多个路径用逗号分隔,相同的 Key 会命中已有缓存</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ══════════ 并发执行器 ══════════ */}
|
||||||
|
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||||
|
<Label className="mb-2 block">并发执行数 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(Number of executors)</span></Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={String(newMaxExecutors)} onValueChange={v => setNewMaxExecutors(Number(v))}>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 — 串行</SelectItem>
|
||||||
|
<SelectItem value="2">2</SelectItem>
|
||||||
|
<SelectItem value="3">3</SelectItem>
|
||||||
|
<SelectItem value="4">4</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,超过 1 可并发构建</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
|
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||||
<Button variant="outline" onClick={() => setNewOpen(false)}>取消</Button>
|
<Button variant="outline" onClick={() => setNewOpen(false)}>取消</Button>
|
||||||
@@ -834,3 +859,90 @@ export function PipelineManager() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════ 流水线队列面板 (Jenkins 风格) ══════════
|
||||||
|
function PipelineQueuePanel({ pipeline, clusterId: _cid }: { pipeline: any; clusterId: string }) {
|
||||||
|
const [queue, setQueue] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const code = pipeline.code || pipeline.id;
|
||||||
|
|
||||||
|
const loadQueue = async () => {
|
||||||
|
if (!code) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res: any = await http.get(`/pipelines/${code}/queue`);
|
||||||
|
setQueue(res?.data || res);
|
||||||
|
} catch { setQueue(null); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadQueue(); const t = setInterval(loadQueue, 10000); return () => clearInterval(t); }, [code]);
|
||||||
|
|
||||||
|
const handleCancel = async (runId: number) => {
|
||||||
|
try {
|
||||||
|
await http.delete(`/pipelines/${code}/queue/${runId}`);
|
||||||
|
toast.success('已取消');
|
||||||
|
loadQueue();
|
||||||
|
} catch { toast.error('取消失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const running = queue?.running || 0;
|
||||||
|
const queued = queue?.queued || 0;
|
||||||
|
const queueIds: number[] = queue?.queue_ids || [];
|
||||||
|
const max = pipeline.max_executors || 1;
|
||||||
|
const pendingRuns = queue?.pending_runs || [];
|
||||||
|
|
||||||
|
if (loading && !queue) return null;
|
||||||
|
if (running === 0 && queued === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2 space-y-2">
|
||||||
|
{/* 构建执行状态 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-[var(--dg-text-secondary)]">构建执行状态</span>
|
||||||
|
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
|
||||||
|
{queued > 0 && (
|
||||||
|
<span className="text-[var(--dg-text-secondary)] ml-2">
|
||||||
|
构建队列 <span className="font-bold text-amber-400">({queued})</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 排队中的构建 */}
|
||||||
|
{queued > 0 && pendingRuns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{pendingRuns.map((run: any) => (
|
||||||
|
<div key={run.id} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Clock className="h-3 w-3 text-amber-400" />
|
||||||
|
<span className="text-[var(--dg-text-secondary)] font-mono">#{run.build_number || run.id}</span>
|
||||||
|
<span className="text-[var(--dg-text-secondary)]">{run.branch || '-'}</span>
|
||||||
|
<span className="text-[var(--dg-text-secondary)] opacity-50">{run.triggered_by || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(run.id)}>
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 仅有队列无详情时用 ID 列表兜底 */}
|
||||||
|
{queued > 0 && pendingRuns.length === 0 && queueIds.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{queueIds.map((id: number) => (
|
||||||
|
<div key={id} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Clock className="h-3 w-3 text-amber-400" />
|
||||||
|
<span className="text-[var(--dg-text-muted)] font-mono">Run #{id}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(id)}>
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user