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

+313
View File
@@ -0,0 +1,313 @@
// Package jenkins Jenkins REST 客户端(移植自 opsx-jenkins 生产实现):
// crumb → BasicAuth → buildWithParameters → Location 队列 URL → 队列/构建轮询 → progressiveText 增量控制台。
package jenkins
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
BaseURL string
Username string
Token string
HTTP *http.Client
}
func NewClient(baseURL, username, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Username: username,
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// JobInfo Job 概要(folder 已压平为 path/name 形式)
type JobInfo struct {
Name string `json:"name"` // 压平路径 folder/job
URL string `json:"url"`
Type string `json:"type"` // folder | freestyle | pipeline | multibranch
Status string `json:"status"` // success | failed | running | unknown
}
func (c *Client) do(method, path string, form url.Values) (*http.Response, error) {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.Username, c.Token)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// CSRF crumb(未启用时 /crumbIssuer 返回 404,跳过)
if crumb, err := c.crumb(); err == nil {
req.Header.Set(crumb[0], crumb[1])
}
return c.HTTP.Do(req)
}
func (c *Client) crumb() ([2]string, error) {
req, _ := http.NewRequest("GET", c.BaseURL+"/crumbIssuer/api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := c.HTTP.Do(req)
if err != nil {
return [2]string{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return [2]string{}, errors.New("crumb not enabled")
}
var out struct {
CrumbRequestField string `json:"crumbRequestField"`
Crumb string `json:"crumb"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Crumb == "" {
return [2]string{}, errors.New("no crumb")
}
return [2]string{out.CrumbRequestField, out.Crumb}, nil
}
// Test 连通测试:访问 /api/json
func (c *Client) Test() error {
resp, err := c.do("GET", "/api/json", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return errors.New("认证失败:用户名或 Token 错误")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
type rawJob struct {
Name string `json:"name"`
URL string `json:"url"`
Color string `json:"color"`
Jobs []rawJob `json:"jobs"`
}
// colorToStatus Jenkins ball color → 状态
func colorToStatus(color string) string {
switch {
case color == "":
return "unknown"
case strings.HasSuffix(color, "anime"):
return "running"
case strings.HasPrefix(color, "blue"):
return "success"
case strings.HasPrefix(color, "red"):
return "failed"
case strings.HasPrefix(color, "notbuilt"), strings.HasPrefix(color, "disabled"), strings.HasPrefix(color, "aborted"):
return "unknown"
default:
return "unknown"
}
}
// ListJobs 递归 3 层拉取 Job 树并压平 folder
func (c *Client) ListJobs() ([]JobInfo, error) {
resp, err := c.do("GET", "/api/json?tree=jobs[name,url,color,jobs[name,url,color,jobs[name,url,color,jobs[name,url,color]]]]", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var root struct {
Jobs []rawJob `json:"jobs"`
}
if err := json.NewDecoder(resp.Body).Decode(&root); err != nil {
return nil, err
}
var flat []JobInfo
var walk func(jobs []rawJob, prefix string)
walk = func(jobs []rawJob, prefix string) {
for _, j := range jobs {
full := j.Name
if prefix != "" {
full = prefix + "/" + j.Name
}
if len(j.Jobs) > 0 {
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "folder", Status: "unknown"})
walk(j.Jobs, full)
} else {
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "pipeline", Status: colorToStatus(j.Color)})
}
}
}
walk(root.Jobs, "")
return flat, nil
}
// jobPathToURL folder/job → /job/folder/job/job/name
func jobPathToURL(jobPath string) string {
segs := strings.Split(jobPath, "/")
var b strings.Builder
for _, s := range segs {
b.WriteString("/job/")
b.WriteString(url.PathEscape(s))
}
return b.String()
}
// Trigger 触发构建,返回队列 URL(Location 头;拒绝回退 lastBuild 防串号)
func (c *Client) Trigger(jobPath string, params map[string]string) (string, error) {
path := jobPathToURL(jobPath)
var form url.Values
if len(params) > 0 {
form = url.Values{}
for k, v := range params {
form.Set(k, v)
}
path += "/buildWithParameters"
} else {
path += "/build"
}
resp, err := c.do("POST", path, form)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("触发失败 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
queueURL := resp.Header.Get("Location")
if queueURL == "" {
return "", errors.New("未返回队列地址(Location),请检查 Jenkins 版本与权限")
}
return queueURL, nil
}
// WaitQueue 等待队列分配为具体构建,返回构建号与构建 URL
func (c *Client) WaitQueue(queueURL string, timeout time.Duration, emit func(string)) (int, string, error) {
deadline := time.Now().Add(timeout)
pollClient := &http.Client{Timeout: 15 * time.Second}
for time.Now().Before(deadline) {
req, _ := http.NewRequest("GET", queueURL+"api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := pollClient.Do(req)
if err == nil {
var q struct {
Cancelled bool `json:"cancelled"`
Executable *struct {
Number int `json:"number"`
URL string `json:"url"`
} `json:"executable"`
Why string `json:"why"`
}
if json.NewDecoder(resp.Body).Decode(&q) == nil {
resp.Body.Close()
if q.Cancelled {
return 0, "", errors.New("构建已被 Jenkins 取消(排队被拒绝)")
}
if q.Executable != nil {
return q.Executable.Number, strings.TrimRight(q.Executable.URL, "/"), nil
}
if q.Why != "" {
emit("排队中: " + q.Why)
}
} else {
resp.Body.Close()
}
}
time.Sleep(2 * time.Second)
}
return 0, "", errors.New("等待队列超时")
}
// StreamConsole 增量拉取控制台日志(X-Text-Size/X-More-Data 推进),直到构建结束
func (c *Client) StreamConsole(buildURL string, emit func(string)) {
offset := 0
pollClient := &http.Client{Timeout: 15 * time.Second}
for {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/logText/progressiveText?start=%d", buildURL, offset), nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := pollClient.Do(req)
if err != nil {
emit("日志拉取失败: " + err.Error())
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if len(body) > 0 {
for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") {
if line != "" {
emit(line)
}
}
}
if newSize := parseInt(resp.Header.Get("X-Text-Size")); newSize > offset {
offset = newSize
}
if resp.Header.Get("X-More-Data") != "true" {
return
}
time.Sleep(2 * time.Second)
}
}
// WaitResult 轮询构建结果:SUCCESS/FAILURE/ABORTED/null(进行中)
func (c *Client) WaitResult(buildURL string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
req, _ := http.NewRequest("GET", buildURL+"/api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := c.HTTP.Do(req)
if err == nil {
var b struct {
Building bool `json:"building"`
Result *string `json:"result"`
}
if json.NewDecoder(resp.Body).Decode(&b) == nil {
resp.Body.Close()
if !b.Building && b.Result != nil {
return *b.Result, nil
}
} else {
resp.Body.Close()
}
}
time.Sleep(3 * time.Second)
}
return "", errors.New("等待构建结果超时")
}
func parseInt(s string) int {
n := 0
for _, ch := range s {
if ch < '0' || ch > '9' {
return n
}
n = n*10 + int(ch-'0')
}
return n
}
// MapResult Jenkins 结果 → FlowStack 运行状态
func MapResult(r string) string {
switch r {
case "SUCCESS":
return "success"
case "ABORTED":
return "cancelled"
default:
return "failed"
}
}