feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)

后端(35+ 新路由):
- AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key
- 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/
  JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列,
  PipelineRun 扩展 buildNumber/externalUrl
- 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、
  工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、
  入站流水线触发 /api/hooks/pipeline/:id
- Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/
  buildWithParameters/Location 队列/progressiveText 增量控制台,
  服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、
  jenkins 类型流水线真实执行回传日志
- 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表
- 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进

前端(14 页全部去 mock):
- 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/
  系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API
- 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史)
- TopBar 真实流水线切换 + 仅保存/保存并运行
- Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
This commit is contained in:
Logan.Li committed 2026-10-27 03:15:20 +08:00
1 parent 04d1342b27
commit 26341c3e8a
37 files changed
+5702 -4683

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/jenkins"
"flowstack/internal/model"
)
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
token := s.Token
if crypto.IsEncrypted(token) {
if plain, err := crypto.DecryptField(token); err == nil {
token = plain
}
}
return jenkins.NewClient(s.BaseURL, s.Username, token)
}
// ---- Jenkins 服务器 CRUD ----
func ListJenkinsServers(c *gin.Context) {
var list []model.JenkinsServer
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].TokenSet = list[i].Token != ""
list[i].Token = ""
}
c.JSON(http.StatusOK, list)
}
func CreateJenkinsServer(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
baseURL, _ := raw["baseUrl"].(string)
if name == "" || baseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
return
}
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
s.Username, _ = raw["username"].(string)
if token, ok := raw["token"].(string); ok && token != "" {
enc, err := crypto.EncryptField(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
return
}
s.Token = enc
}
if err := database.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func UpdateJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
if v, ok := raw[k]; ok {
key := k
if k == "baseUrl" {
key = "base_url"
}
updates[key] = v
}
}
if token, ok := raw["token"].(string); ok && token != "" {
if enc, err := crypto.EncryptField(token); err == nil {
updates["token"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&s).Updates(updates)
}
database.DB.First(&s, s.ID)
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func DeleteJenkinsServer(c *gin.Context) {
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
func TestJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
start := time.Now()
err := clientFromServer(s).Test()
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
"message": func() string {
if err != nil {
return "连接失败: " + err.Error()
}
return "连接成功,认证通过"
}(),
})
}
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
func ListJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
jobs, err := clientFromServer(s).ListJobs()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, jobs)
}
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
func ImportJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var body struct {
Jobs []string `json:"jobs"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
return
}
imported, skipped := 0, 0
for _, jobPath := range body.Jobs {
var exist model.Pipeline
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
skipped++
continue
}
name := jobPath
if len(name) > 60 {
name = name[len(name)-60:]
}
p := model.Pipeline{
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
}
// 名称冲突时追加后缀保证唯一
base := name
for i := 0; ; i++ {
var cnt int64
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
if cnt == 0 {
break
}
if i >= 5 {
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
break
}
p.Name = fmt.Sprintf("%s-%d", base, i+1)
}
database.DB.Create(&p)
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
}
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
start := time.Now()
emit := func(msg string) {
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
database.DB.Create(&entry)
hub.Publish(run.RunID, "log", entry)
}
setRun := func(status string) {
now := time.Now()
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
})
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
})
}
}
emit("$ jenkins build \"" + jobName + "\"")
queueURL, err := client.Trigger(jobName, nil)
if err != nil {
emit("触发失败: " + err.Error())
setRun("failed")
return
}
emit("已进入 Jenkins 构建队列…")
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
if err != nil {
emit("队列等待失败: " + err.Error())
setRun("failed")
return
}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
})
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
client.StreamConsole(buildURL, emit)
result, err := client.WaitResult(buildURL, 30*time.Minute)
if err != nil {
emit("获取构建结果超时: " + err.Error())
setRun("failed")
return
}
emit("Jenkins 结果: " + result)
setRun(jenkins.MapResult(result))
}