- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API, SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送, 前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面 - React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、 GitOps 部署、批量发布、发布记录等 14 个模块页面, tRPC 全部替换为 REST(TanStack Query) - 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt) - run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
273 lines
8.4 KiB
Go
273 lines
8.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"flowstack/internal/database"
|
|
"flowstack/internal/model"
|
|
)
|
|
|
|
// CreateRun 创建运行并启动模拟执行引擎
|
|
func CreateRun(hub *Hub) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var p model.Pipeline
|
|
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Branch string `json:"branch"`
|
|
Env string `json:"env"`
|
|
TriggerBy string `json:"triggerBy"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
if body.Branch == "" {
|
|
body.Branch = p.SourceBranch
|
|
}
|
|
if body.Env == "" {
|
|
body.Env = p.Env
|
|
}
|
|
if body.TriggerBy == "" {
|
|
body.TriggerBy = "admin"
|
|
}
|
|
|
|
var nodes []model.PipelineNode
|
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
|
if len(nodes) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
|
|
return
|
|
}
|
|
var edges []model.PipelineEdge
|
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
|
nodesJSON, _ := json.Marshal(nodes)
|
|
edgesJSON, _ := json.Marshal(edges)
|
|
|
|
run := model.PipelineRun{
|
|
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
|
PipelineID: p.ID, PipelineName: p.Name,
|
|
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
|
|
TriggerBy: body.TriggerBy, TriggerType: "manual",
|
|
Status: "running", StartedAt: time.Now(),
|
|
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
|
}
|
|
if err := database.DB.Create(&run).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 按画布 X 坐标从左到右生成执行阶段
|
|
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
|
stages := make([]model.RunStage, len(nodes))
|
|
for i, n := range nodes {
|
|
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
|
database.DB.Create(&stages[i])
|
|
}
|
|
|
|
go simulateRun(hub, run, stages)
|
|
|
|
c.JSON(http.StatusOK, run)
|
|
}
|
|
}
|
|
|
|
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
|
|
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
|
start := time.Now()
|
|
emit := func(level, msg string, stageID uint) {
|
|
entry := model.LogEntry{RunID: run.ID, StageID: stageID, Level: level, Message: msg, Source: "runner", Timestamp: time.Now()}
|
|
database.DB.Create(&entry)
|
|
hub.Publish(run.RunID, "log", entry)
|
|
}
|
|
setStage := func(stage *model.RunStage, status string) {
|
|
now := time.Now()
|
|
updates := map[string]any{"status": status}
|
|
if status == "running" {
|
|
stage.StartedAt = &now
|
|
updates["started_at"] = now
|
|
} else {
|
|
stage.CompletedAt = &now
|
|
updates["completed_at"] = now
|
|
exit := 0
|
|
stage.ExitCode = &exit
|
|
updates["exit_code"] = exit
|
|
}
|
|
database.DB.Model(&model.RunStage{}).Where("id = ?", stage.ID).Updates(updates)
|
|
stage.Status = status
|
|
hub.Publish(run.RunID, "stage", gin.H{"stageId": stage.ID, "nodeId": stage.NodeID, "name": stage.Name, "status": status})
|
|
}
|
|
setRun := func(status string) {
|
|
now := time.Now()
|
|
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
|
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
|
}
|
|
|
|
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
|
for i := range stages {
|
|
st := &stages[i]
|
|
setStage(st, "running")
|
|
for _, line := range stageScript(st) {
|
|
emit("info", line, st.ID)
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
setStage(st, "success")
|
|
}
|
|
setRun("success")
|
|
}
|
|
|
|
// stageScript 按节点类别生成贴近真实构建过程的日志
|
|
func stageScript(st *model.RunStage) []string {
|
|
switch st.Name {
|
|
case "git-clone":
|
|
return []string{
|
|
fmt.Sprintf("$ git clone --depth=50 --branch=master %s", "https://gitee.com/attacker/aiops.git"),
|
|
"Cloning into 'source'...",
|
|
"HEAD is now at 8f3c21a feat: release v2.4.0",
|
|
}
|
|
case "unit-test":
|
|
return []string{
|
|
"$ go test -v -race ./...",
|
|
"ok \tflowstack/internal/handler\t2.318s",
|
|
"ok \tflowstack/internal/model\t0.402s",
|
|
"coverage: 87.4% of statements",
|
|
}
|
|
case "kaniko-build":
|
|
return []string{
|
|
"$ kaniko --dockerfile Dockerfile --destination registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504"),
|
|
"INFO[0002] Retrieving image manifest golang:1.24-alpine",
|
|
"INFO[0018] Executing 12 build steps",
|
|
"INFO[0041] Pushing image to registry.example.com",
|
|
}
|
|
case "trivy-scan":
|
|
return []string{
|
|
"$ trivy image --severity HIGH,CRITICAL registry.example.com/aiops/aiops-backend:latest",
|
|
"alpine (apk): 0 HIGH, 0 CRITICAL",
|
|
"Scan completed: no critical vulnerabilities found",
|
|
}
|
|
case "kubectl-deploy":
|
|
return []string{
|
|
"$ kubectl set image deployment/aiops-backend app=registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504") + " -n aiops",
|
|
"deployment.apps/aiops-backend image updated",
|
|
"Waiting for rollout: 3/3 updated",
|
|
"deployment \"aiops-backend\" successfully rolled out",
|
|
}
|
|
case "npm build":
|
|
return []string{
|
|
"$ npm ci --registry=https://registry.npmmirror.com",
|
|
"$ npm run build",
|
|
"vite v7.x building for production...",
|
|
"✓ built in 18.4s",
|
|
}
|
|
case "npm audit":
|
|
return []string{
|
|
"$ npm audit --audit-level=high",
|
|
"found 0 vulnerabilities",
|
|
}
|
|
case "主机部署":
|
|
return []string{
|
|
"$ tar zcf dist.tar.gz dist/ && scp dist.tar.gz production-group:/home/admin/app/",
|
|
"$ rsync -avz --delete /tmp/dist/ /usr/share/nginx/html/",
|
|
"$ nginx -t && nginx -s reload",
|
|
"2/2 主机部署完成",
|
|
}
|
|
case "刷新 CDN":
|
|
return []string{
|
|
"$ curl -X POST https://cdn.aliyuncs.com/api/refresh",
|
|
`{"urls":["https://aiops.example.com/*"]}`,
|
|
"RefreshTaskId: 202610271200****",
|
|
}
|
|
default:
|
|
return []string{
|
|
fmt.Sprintf("[%s] 开始执行…", st.Name),
|
|
fmt.Sprintf("[%s] 执行完成 (exit 0)", st.Name),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ListRuns GET /api/runs?pipelineId=&status=
|
|
func ListRuns(c *gin.Context) {
|
|
q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100)
|
|
if v := c.Query("pipelineId"); v != "" {
|
|
q = q.Where("pipeline_id = ?", v)
|
|
}
|
|
if v := c.Query("status"); v != "" && v != "all" {
|
|
q = q.Where("status = ?", v)
|
|
}
|
|
var runs []model.PipelineRun
|
|
q.Find(&runs)
|
|
c.JSON(http.StatusOK, runs)
|
|
}
|
|
|
|
// GetRun GET /api/runs/:runId
|
|
func GetRun(c *gin.Context) {
|
|
var run model.PipelineRun
|
|
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
|
return
|
|
}
|
|
var stages []model.RunStage
|
|
database.DB.Where("run_id = ?", run.ID).Order("id").Find(&stages)
|
|
var logs []model.LogEntry
|
|
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&logs)
|
|
c.JSON(http.StatusOK, gin.H{"run": run, "stages": stages, "logs": logs})
|
|
}
|
|
|
|
// StreamLogs GET /api/runs/:runId/logs/stream —— SSE:先回放历史日志再实时推送
|
|
func StreamLogs(hub *Hub) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
runId := c.Param("runId")
|
|
var run model.PipelineRun
|
|
if err := database.DB.Where("run_id = ?", runId).First(&run).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
flush := func() { if f, ok := c.Writer.(http.Flusher); ok { f.Flush() } }
|
|
|
|
fmt.Fprintf(c.Writer, "event: connected\ndata: {\"runId\":\"%s\"}\n\n", runId)
|
|
flush()
|
|
|
|
// 回放历史日志
|
|
var history []model.LogEntry
|
|
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&history)
|
|
for _, entry := range history {
|
|
data, _ := json.Marshal(entry)
|
|
fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", data)
|
|
}
|
|
if len(history) > 0 {
|
|
flush()
|
|
}
|
|
|
|
ch := hub.Subscribe(runId)
|
|
defer hub.Unsubscribe(runId, ch)
|
|
|
|
heartbeat := time.NewTicker(15 * time.Second)
|
|
defer heartbeat.Stop()
|
|
|
|
ctx := c.Request.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-heartbeat.C:
|
|
fmt.Fprint(c.Writer, ": ping\n\n")
|
|
flush()
|
|
case frame := <-ch:
|
|
c.Writer.Write(frame)
|
|
flush()
|
|
}
|
|
}
|
|
}
|
|
}
|