feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化

- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理
  - 正则匹配流水线 code,实时命中预览
  - 内置用户分配面板,无需跳转系统管理
  - 支持创建/编辑/删除/启用禁用 Item Role
- 修复 PipelineEditor 成员管理 http 双解包 bug
- 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns)
- 新增批量删除流水线运行记录 API
- Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组
- 恢复系统管理菜单(不受发布权限改动影响)
- 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
attacker committed 2026-06-21 05:26:01 +00:00
1 parent 0fea0ccc20
commit 24f955832e
40 files changed
+7530 -1032

No files matched your search

+14 -3
View File
@@ -61,11 +61,18 @@ FROM alpine:latest
# 使用国内 Alpine 镜像源加速 // Logan.Li # 使用国内 Alpine 镜像源加速 // Logan.Li
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维) # 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维,nodejs+npm=前端构建,maven=Java构建)
RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass && \ RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass git docker-cli docker-cli-buildx nodejs npm maven && \
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
chmod +x kubectl && mv kubectl /usr/local/bin/ chmod +x kubectl && mv kubectl /usr/local/bin/
# Maven 阿里云镜像加速(避免 DNS 间歇性 SERVFAIL)
RUN mkdir -p /root/.m2 && \
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
# npm 淘宝镜像加速
RUN npm config set registry https://registry.npmmirror.com
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
WORKDIR /app WORKDIR /app
@@ -85,9 +92,13 @@ COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/
COPY agent/deploy/install.sh /app/agent/deploy/ COPY agent/deploy/install.sh /app/agent/deploy/
COPY agent/deploy/aiops-agent.service /app/agent/deploy/ COPY agent/deploy/aiops-agent.service /app/agent/deploy/
# 启动脚本(注入 hosts DNS 缓存)
COPY backend/entrypoint.sh /app/
RUN chmod +x /app/entrypoint.sh
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1 CMD curl -f http://localhost:8080/health || exit 1
CMD ["./aiops"] CMD ["./entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# AIOps 启动脚本 - 注入 DNS 缓存直连避免 Docker DNS 间歇性 SERVFAIL
# 署名 Logan.Li
# 注入常用镜像站的 hosts 直连
grep -q "maven.aliyun.com" /etc/hosts || echo "122.228.195.17 maven.aliyun.com" >> /etc/hosts
grep -q "registry.npmmirror.com" /etc/hosts || echo "122.228.223.11 registry.npmmirror.com" >> /etc/hosts
exec ./aiops "$@"
+324 -84
View File
@@ -1,8 +1,13 @@
package handlers package handlers
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"ezops/internal/models" "ezops/internal/models"
"ezops/pkg/crypto" "ezops/pkg/crypto"
@@ -11,150 +16,385 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// CredentialHandler 凭据管理处理器 // Logan.Li // CredentialHandler 凭据管理处理器 v2(多类型动态字段)
type CredentialHandler struct { type CredentialHandler struct {
db *gorm.DB db *gorm.DB
crypt *crypto.SecretBox crypt *crypto.SecretBox
} }
func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler { func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler {
return &CredentialHandler{ return &CredentialHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
db: db,
crypt: crypto.NewSecretBox(masterKey),
}
} }
// preview 内容脱敏预览:前8字符 + ... + 后4字符 // ────── Helpers ──────
func preview(s string) string {
if len(s) <= 12 { func genEnvPrefix() string {
return "****" b := make([]byte, 4)
} rand.Read(b)
return s[:8] + "..." + s[len(s)-4:] return hex.EncodeToString(b)[:8]
} }
// ListCredentials 凭据列表 func buildContent(req models.CreateSecretRequest) (string, string, error) {
switch req.SecretType {
case "ssh":
data := map[string]interface{}{
"private_key": req.SSHPrivateKey,
"public_key": req.SSHPublicKey,
"ssh_user": req.SSHUser,
"ssh_port": req.SSHPort,
"password": req.SSHPassword,
}
b, _ := json.Marshal(data)
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
case "docker":
data := map[string]string{
"server": req.DockerServer,
"username": req.DockerUsername,
"password": req.DockerPassword,
}
b, _ := json.Marshal(data)
return string(b), req.DockerServer, nil
case "tls":
data := map[string]string{"cert": req.TLSCert, "key": req.TLSKey, "ca": req.TLSCA}
b, _ := json.Marshal(data)
return string(b), "TLS 证书已配置", nil
case "aksk":
data := map[string]string{
"provider": req.AKSKProvider,
"access_key": req.AKSKAccessKey,
"secret_key": req.AKSKSecretKey,
"region": req.AKSKRegion,
}
b, _ := json.Marshal(data)
return string(b), req.AKSKProvider, nil
case "database":
data := map[string]interface{}{
"db_type": req.DBType,
"host": req.DBHost,
"port": req.DBPort,
"db_name": req.DBName,
"username": req.DBUsername,
"password": req.DBPassword,
}
b, _ := json.Marshal(data)
return string(b), fmt.Sprintf("%s://%s:%d/%s", req.DBType, req.DBHost, req.DBPort, req.DBName), nil
case "api":
data := map[string]string{"api_key": req.APIKey, "endpoint": req.APIEndpoint}
b, _ := json.Marshal(data)
return string(b), req.APIEndpoint, nil
case "generic":
data := map[string]string{"generic_key": req.GenericKey, "generic_value": req.GenericValue}
b, _ := json.Marshal(data)
return string(b), req.GenericKey, nil
}
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
}
// ────── List ──────
func (h *CredentialHandler) ListCredentials(c *gin.Context) { func (h *CredentialHandler) ListCredentials(c *gin.Context) {
var creds []models.Credential var query models.SecretListQuery
if err := h.db.Order("updated_at DESC").Find(&creds).Error; err != nil { c.ShouldBindQuery(&query)
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
return db := h.db.Model(&models.Credential{})
if query.SecretType != "" {
db = db.Where("secret_type = ?", query.SecretType)
} }
items := make([]models.CredentialListResponse, 0, len(creds)) if query.Search != "" {
s := "%" + query.Search + "%"
db = db.Where("name LIKE ? OR description LIKE ? OR variable_name LIKE ?", s, s, s)
}
if query.Tags != "" {
for _, tag := range strings.Split(query.Tags, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
db = db.Where("tags LIKE ?", "%\""+tag+"\"%")
}
}
}
var total int64
db.Count(&total)
page := query.Page
if page < 1 { page = 1 }
pageSize := query.PageSize
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
var creds []models.Credential
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
items := make([]models.SecretListResponse, 0, len(creds))
for _, cred := range creds { for _, cred := range creds {
items = append(items, models.CredentialListResponse{ tags := []string{}
ID: cred.ID, if cred.Tags != "" {
Name: cred.Name, json.Unmarshal([]byte(cred.Tags), &tags)
Type: cred.Type, }
Preview: preview(cred.Content), items = append(items, models.SecretListResponse{
Description: cred.Description, ID: cred.ID,
CreatedAt: cred.CreatedAt, Name: cred.Name,
UpdatedAt: cred.UpdatedAt, VariableName: cred.VariableName,
SecretType: cred.SecretType,
Summary: cred.Summary,
Tags: tags,
Description: cred.Description,
ExpiresAt: cred.ExpiresAt,
IsActive: cred.IsActive,
EnvPrefix: cred.EnvPrefix,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
}) })
} }
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{ c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{
List: items, Total: int64(len(items)), List: items, Total: total,
}}) }})
} }
// CreateCredential 创建凭据 // ────── Create ──────
func (h *CredentialHandler) CreateCredential(c *gin.Context) { func (h *CredentialHandler) CreateCredential(c *gin.Context) {
var req models.CredentialCreateRequest var req models.CreateSecretRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return return
} }
encrypted, err := h.crypt.Encrypt(req.Content) plainContent, summary, err := buildContent(req)
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
encrypted, err := h.crypt.Encrypt(plainContent)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
return return
} }
tagsJSON, _ := json.Marshal(req.Tags)
isActive := true
if req.IsActive != nil { isActive = *req.IsActive }
cred := models.Credential{ cred := models.Credential{
Name: req.Name, Name: req.Name,
Type: req.Type, VariableName: req.VariableName,
Content: encrypted, SecretType: req.SecretType,
Description: req.Description, Summary: summary,
Content: encrypted,
Tags: string(tagsJSON),
Description: req.Description,
ExpiresAt: req.ExpiresAt,
IsActive: isActive,
EnvPrefix: genEnvPrefix(),
} }
if err := h.db.Create(&cred).Error; err != nil { if err := h.db.Create(&cred).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
return return
} }
c.JSON(http.StatusOK, models.Response{ c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: cred.ID})
Code: 200,
Message: "创建成功",
Data: models.CredentialListResponse{
ID: cred.ID,
Name: cred.Name,
Type: cred.Type,
Preview: preview(cred.Content),
Description: cred.Description,
CreatedAt: cred.CreatedAt,
UpdatedAt: cred.UpdatedAt,
},
})
} }
// UpdateCredential 更新凭据 // ────── Get Detail ──────
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
return
}
var req models.CredentialUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
func (h *CredentialHandler) GetCredential(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
var cred models.Credential var cred models.Credential
if h.db.First(&cred, id).Error != nil { if h.db.First(&cred, id).Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"}) c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return return
} }
cred.Name = req.Name // Decrypt content
cred.Type = req.Type plain, err := h.crypt.Decrypt(cred.Content)
cred.Description = req.Description if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "解密失败"})
return
}
if req.Content != "" { var contentData map[string]interface{}
encrypted, err := h.crypt.Encrypt(req.Content) json.Unmarshal([]byte(plain), &contentData)
tags := []string{}
if cred.Tags != "" { json.Unmarshal([]byte(cred.Tags), &tags) }
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"id": cred.ID,
"name": cred.Name,
"variable_name": cred.VariableName,
"secret_type": cred.SecretType,
"content": contentData,
"tags": tags,
"description": cred.Description,
"expires_at": cred.ExpiresAt,
"is_active": cred.IsActive,
"env_prefix": cred.EnvPrefix,
"created_at": cred.CreatedAt,
"updated_at": cred.UpdatedAt,
}})
}
// ────── Update ──────
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
var cred models.Credential
if h.db.First(&cred, id).Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return
}
var req models.UpdateSecretRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
return
}
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.VariableName != nil { updates["variable_name"] = *req.VariableName }
if req.Description != nil { updates["description"] = *req.Description }
if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt }
if req.IsActive != nil { updates["is_active"] = *req.IsActive }
if len(req.Tags) > 0 {
b, _ := json.Marshal(req.Tags)
updates["tags"] = string(b)
}
// Rebuild content if any type-specific field changed
if hasTypeFieldUpdate(req) {
// Decrypt old content
oldPlain, _ := h.crypt.Decrypt(cred.Content)
var old map[string]interface{}
json.Unmarshal([]byte(oldPlain), &old)
if old == nil { old = map[string]interface{}{} }
mergeUpdate(req, old)
newPlain, _ := json.Marshal(old)
enc, err := h.crypt.Encrypt(string(newPlain))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
return return
} }
cred.Content = encrypted updates["content"] = enc
// Update summary
updates["summary"] = buildSummaryFromRaw(old, cred.SecretType)
} }
if err := h.db.Save(&cred).Error; err != nil { if len(updates) > 0 {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) h.db.Model(&cred).Updates(updates)
return
} }
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功"}) c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功"})
} }
// DeleteCredential 删除凭据 func hasTypeFieldUpdate(req models.UpdateSecretRequest) bool {
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
req.DBType != nil || req.DBHost != nil || req.DBPort != nil || req.DBName != nil || req.DBUsername != nil || req.DBPassword != nil ||
req.APIKey != nil || req.APIEndpoint != nil ||
req.GenericKey != nil || req.GenericValue != nil
}
func mergeUpdate(req models.UpdateSecretRequest, old map[string]interface{}) {
if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey }
if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey }
if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser }
if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort }
if req.SSHPassword != nil { old["password"] = *req.SSHPassword }
if req.DockerServer != nil { old["server"] = *req.DockerServer }
if req.DockerUsername != nil { old["username"] = *req.DockerUsername }
if req.DockerPassword != nil { old["password"] = *req.DockerPassword }
if req.TLSCert != nil { old["cert"] = *req.TLSCert }
if req.TLSKey != nil { old["key"] = *req.TLSKey }
if req.TLSCA != nil { old["ca"] = *req.TLSCA }
if req.AKSKProvider != nil { old["provider"] = *req.AKSKProvider }
if req.AKSKAccessKey != nil { old["access_key"] = *req.AKSKAccessKey }
if req.AKSKSecretKey != nil { old["secret_key"] = *req.AKSKSecretKey }
if req.AKSKRegion != nil { old["region"] = *req.AKSKRegion }
if req.DBType != nil { old["db_type"] = *req.DBType }
if req.DBHost != nil { old["host"] = *req.DBHost }
if req.DBPort != nil { old["port"] = *req.DBPort }
if req.DBName != nil { old["db_name"] = *req.DBName }
if req.DBUsername != nil { old["username"] = *req.DBUsername }
if req.DBPassword != nil { old["password"] = *req.DBPassword }
if req.APIKey != nil { old["api_key"] = *req.APIKey }
if req.APIEndpoint != nil { old["endpoint"] = *req.APIEndpoint }
if req.GenericKey != nil { old["generic_key"] = *req.GenericKey }
if req.GenericValue != nil { old["generic_value"] = *req.GenericValue }
}
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
switch stype {
case "ssh":
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
case "docker":
return fmt.Sprintf("%v", data["server"])
case "aksk":
return fmt.Sprintf("%v", data["provider"])
case "database":
return fmt.Sprintf("%v://%v:%v/%v", data["db_type"], data["host"], data["port"], data["db_name"])
case "api":
return fmt.Sprintf("%v", data["endpoint"])
case "generic":
return fmt.Sprintf("%v", data["generic_key"])
case "tls":
return "TLS 证书已配置"
}
return ""
}
// ────── Patch (toggle is_active) ──────
func (h *CredentialHandler) PatchCredential(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
var body map[string]interface{}
c.ShouldBindJSON(&body)
updates := map[string]interface{}{}
if v, ok := body["is_active"]; ok {
updates["is_active"] = v
}
if len(updates) > 0 {
h.db.Model(&models.Credential{}).Where("id = ?", id).Updates(updates)
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "ok"})
}
// ────── Delete ──────
func (h *CredentialHandler) DeleteCredential(c *gin.Context) { func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if h.db.Delete(&models.Credential{}, id).Error != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
return
}
var cred models.Credential
if h.db.First(&cred, id).Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
return
}
if err := h.db.Delete(&cred).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
return return
} }
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
} }
// ────── Tags ──────
func (h *CredentialHandler) GetTags(c *gin.Context) {
var rows []string
h.db.Model(&models.Credential{}).Where("tags != ''").Pluck("tags", &rows)
tagSet := map[string]bool{}
for _, r := range rows {
var tags []string
if json.Unmarshal([]byte(r), &tags) == nil {
for _, t := range tags { tagSet[t] = true }
}
}
tags := make([]string, 0, len(tagSet))
for t := range tagSet { tags = append(tags, t) }
c.JSON(http.StatusOK, models.Response{Code: 200, Data: tags})
}
@@ -43,7 +43,7 @@ func (h *CredentialHandler) AssignCredential(c *gin.Context) {
return return
} }
if cred.Type == "ssh_key" { if cred.SecretType == "ssh" {
server.SSHKey = plain server.SSHKey = plain
server.AuthType = "key" server.AuthType = "key"
server.Password = "" server.Password = ""
+164
View File
@@ -0,0 +1,164 @@
package handlers
import (
"net/http"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DeployHandler 发布系统处理器
type DeployHandler struct {
db *gorm.DB
}
func NewDeployHandler(db *gorm.DB) *DeployHandler {
return &DeployHandler{db: db}
}
// ==================== Git 仓库认证 ====================
func (h *DeployHandler) ListRepos(c *gin.Context) {
var items []models.PipelineRepo
h.db.Order("updated_at DESC").Find(&items)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
}
func (h *DeployHandler) GetRepo(c *gin.Context) {
var item models.PipelineRepo
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) CreateRepo(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
URL string `json:"url" binding:"required"`
AuthType models.RepoAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
if req.AuthType == "" { req.AuthType = models.RepoAuthToken }
item := models.PipelineRepo{
Name: req.Name, Description: req.Description, URL: req.URL,
AuthType: req.AuthType, CredentialID: req.CredentialID,
CreatedBy: c.GetString("username"),
}
h.db.Create(&item)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) UpdateRepo(c *gin.Context) {
var item models.PipelineRepo
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
URL *string `json:"url"`
AuthType *models.RepoAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
Enabled *bool `json:"enabled"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
if req.URL != nil { updates["url"] = *req.URL }
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
h.db.Model(&item).Updates(updates)
h.db.First(&item, item.ID)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) DeleteRepo(c *gin.Context) {
h.db.Delete(&models.PipelineRepo{}, c.Param("id"))
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
}
// ==================== 镜像仓库认证 ====================
func (h *DeployHandler) ListRegistries(c *gin.Context) {
var items []models.PipelineRegistry
h.db.Order("updated_at DESC").Find(&items)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
}
func (h *DeployHandler) GetRegistry(c *gin.Context) {
var item models.PipelineRegistry
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) CreateRegistry(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
RegistryURL string `json:"registry_url" binding:"required"`
Namespace string `json:"namespace"`
AuthType models.RegistryAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
if req.AuthType == "" { req.AuthType = models.RegistryAuthPassword }
item := models.PipelineRegistry{
Name: req.Name, Description: req.Description, RegistryURL: req.RegistryURL,
Namespace: req.Namespace, AuthType: req.AuthType, CredentialID: req.CredentialID,
CreatedBy: c.GetString("username"),
}
h.db.Create(&item)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) UpdateRegistry(c *gin.Context) {
var item models.PipelineRegistry
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
RegistryURL *string `json:"registry_url"`
Namespace *string `json:"namespace"`
AuthType *models.RegistryAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
Enabled *bool `json:"enabled"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
if req.RegistryURL != nil { updates["registry_url"] = *req.RegistryURL }
if req.Namespace != nil { updates["namespace"] = *req.Namespace }
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
h.db.Model(&item).Updates(updates)
h.db.First(&item, item.ID)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
func (h *DeployHandler) DeleteRegistry(c *gin.Context) {
h.db.Delete(&models.PipelineRegistry{}, c.Param("id"))
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
}
@@ -0,0 +1,234 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
)
// ==================== 环境管理 ====================
// ListEnvironments 环境列表
func (h *PipelineHandler) ListEnvironments(c *gin.Context) {
var items []models.DeployEnvironment
h.db.Order("sort_order ASC, id ASC").Find(&items)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
}
// CreateEnvironment 创建环境
func (h *PipelineHandler) CreateEnvironment(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
DisplayName string `json:"display_name" binding:"required"`
Type string `json:"type" binding:"required,oneof=production staging testing development"`
Color string `json:"color"`
SortOrder int `json:"sort_order"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
if req.Color == "" { req.Color = typeDefaultColor(req.Type) }
item := models.DeployEnvironment{
Name: req.Name, DisplayName: req.DisplayName,
Type: models.EnvironmentType(req.Type), Color: req.Color,
SortOrder: req.SortOrder, Description: req.Description,
}
if err := h.db.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
// UpdateEnvironment 更新环境
func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var item models.DeployEnvironment
if err := h.db.First(&item, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
DisplayName *string `json:"display_name"`
Type *string `json:"type"`
Color *string `json:"color"`
SortOrder *int `json:"sort_order"`
Description *string `json:"description"`
Enabled *bool `json:"enabled"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.DisplayName != nil { updates["display_name"] = *req.DisplayName }
if req.Type != nil { updates["type"] = *req.Type }
if req.Color != nil { updates["color"] = *req.Color }
if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder }
if req.Description != nil { updates["description"] = *req.Description }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
h.db.Model(&item).Updates(updates)
h.db.First(&item, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
}
// DeleteEnvironment 删除环境
func (h *PipelineHandler) DeleteEnvironment(c *gin.Context) {
h.db.Delete(&models.DeployEnvironment{}, c.Param("id"))
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
}
func typeDefaultColor(t string) string {
switch t {
case "production": return "#EF4444"
case "staging": return "#F59E0B"
case "testing": return "#10B981"
default: return "#3B82F6" // development
}
}
// ==================== 流水线成员管理 ====================
// ListPipelineMembers 成员列表
func (h *PipelineHandler) ListPipelineMembers(c *gin.Context) {
pipelineID := getPipelineID(c)
var members []models.DeployPipelineMember
h.db.Where("pipeline_id = ?", pipelineID).Order("created_at ASC").Find(&members)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: members})
}
// AddPipelineMember 添加成员
func (h *PipelineHandler) AddPipelineMember(c *gin.Context) {
pipelineID := getPipelineID(c)
var req struct {
Username string `json:"username" binding:"required"`
Role string `json:"role" binding:"required,oneof=owner editor viewer"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
var pipeline models.Pipeline
if err := h.db.First(&pipeline, pipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
return
}
member := models.DeployPipelineMember{
PipelineID: uint(pipelineID),
Username: &req.Username,
Role: models.MemberRole(req.Role),
}
if err := h.db.Create(&member).Error; err != nil {
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已是成员"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member})
}
// RemovePipelineMember 移除成员
func (h *PipelineHandler) RemovePipelineMember(c *gin.Context) {
pipelineID := getPipelineID(c)
memberID, _ := strconv.Atoi(c.Param("memberId"))
result := h.db.Where("id = ? AND pipeline_id = ?", memberID, pipelineID).Delete(&models.DeployPipelineMember{})
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"})
}
// ==================== 流水线运行控制 ====================
// StopPipelineRun 停止运行中的流水线
func (h *PipelineHandler) StopPipelineRun(c *gin.Context) {
pipelineID := getPipelineID(c)
runID, _ := strconv.Atoi(c.Param("runId"))
var run models.PipelineRun
if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"})
return
}
if run.Status != "running" && run.Status != "pending" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "仅运行中或等待中的任务可停止, 当前状态: " + run.Status})
return
}
now := time.Now()
updates := map[string]interface{}{
"status": "cancelled",
"finished_at": &now,
}
if run.StartedAt != nil {
dur := int(now.Sub(*run.StartedAt).Seconds())
updates["duration"] = dur
}
h.db.Model(&run).Updates(updates)
// 追加日志
logEntry := map[string]string{
"time": now.Format("15:04:05"),
"level": "warn",
"message": ">> 任务已被手动停止",
"stage": "--",
}
existingLogs := run.Logs
if existingLogs == "" {
existingLogs = "[]"
}
var logEntries []map[string]string
json.Unmarshal([]byte(existingLogs), &logEntries)
logEntries = append(logEntries, logEntry)
newLogs, _ := json.Marshal(logEntries)
h.db.Model(&run).Update("logs", string(newLogs))
h.db.First(&run, runID)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: run, Message: "已停止"})
}
// GetRunLog 获取运行日志
func (h *PipelineHandler) GetRunLog(c *gin.Context) {
pipelineID := getPipelineID(c)
runID, _ := strconv.Atoi(c.Param("runId"))
var run models.PipelineRun
if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"})
return
}
var logs []map[string]string
if run.Logs != "" {
json.Unmarshal([]byte(run.Logs), &logs)
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"run": run,
"logs": logs,
}})
}
// BatchDeleteRuns 批量删除运行记录
func (h *PipelineHandler) BatchDeleteRuns(c *gin.Context) {
pipelineID := getPipelineID(c)
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请选择要删除的记录"})
return
}
result := h.db.Where("pipeline_id = ? AND id IN ?", pipelineID, req.IDs).Delete(&models.PipelineRun{})
c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已删除 %d 条记录", result.RowsAffected)})
}
@@ -0,0 +1,151 @@
package handlers
import (
"net/http"
"strconv"
"ezops/internal/models"
"github.com/gin-gonic/gin"
)
// ==================== 用户组管理 ====================
// ListGroups 用户组列表
func (h *PipelineHandler) ListGroups(c *gin.Context) {
var groups []models.DeployUserGroup
h.db.Preload("Members").Order("created_at DESC").Find(&groups)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: groups, Total: int64(len(groups))}})
}
// CreateGroup 创建用户组
func (h *PipelineHandler) CreateGroup(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
group := models.DeployUserGroup{
Name: req.Name, Description: req.Description,
CreatedBy: c.GetString("username"),
}
if err := h.db.Create(&group).Error; err != nil {
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "组名已存在: " + err.Error()})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: group})
}
// UpdateGroup 更新用户组
func (h *PipelineHandler) UpdateGroup(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var group models.DeployUserGroup
if err := h.db.First(&group, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
h.db.Model(&group).Updates(updates)
h.db.First(&group, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: group})
}
// DeleteGroup 删除用户组
func (h *PipelineHandler) DeleteGroup(c *gin.Context) {
id := c.Param("id")
// 先删组成员和流水线授权
h.db.Where("group_id = ?", id).Delete(&models.DeployUserGroupMember{})
h.db.Where("group_id = ?", id).Delete(&models.DeployPipelineMember{})
h.db.Delete(&models.DeployUserGroup{}, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
}
// ==================== 用户组成员管理 ====================
// ListGroupMembers 组成员列表
func (h *PipelineHandler) ListGroupMembers(c *gin.Context) {
groupID, _ := strconv.Atoi(c.Param("id"))
var members []models.DeployUserGroupMember
h.db.Where("group_id = ?", groupID).Find(&members)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: members})
}
// AddGroupMember 添加组成员
func (h *PipelineHandler) AddGroupMember(c *gin.Context) {
groupID, _ := strconv.Atoi(c.Param("id"))
var req struct {
Username string `json:"username" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
member := models.DeployUserGroupMember{GroupID: uint(groupID), Username: req.Username}
if err := h.db.Create(&member).Error; err != nil {
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已在组内"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member})
}
// RemoveGroupMember 移除组成员
func (h *PipelineHandler) RemoveGroupMember(c *gin.Context) {
groupID, _ := strconv.Atoi(c.Param("id"))
memberID, _ := strconv.Atoi(c.Param("memberId"))
result := h.db.Where("id = ? AND group_id = ?", memberID, groupID).Delete(&models.DeployUserGroupMember{})
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"})
}
// ==================== 组授权给流水线 ====================
// AssignGroupToPipeline 将用户组授权到流水线
func (h *PipelineHandler) AssignGroupToPipeline(c *gin.Context) {
pipelineID := getPipelineID(c)
var req struct {
GroupID uint `json:"group_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=owner editor viewer"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
var pipeline models.Pipeline
if err := h.db.First(&pipeline, pipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
return
}
var group models.DeployUserGroup
if err := h.db.First(&group, req.GroupID).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "用户组不存在"})
return
}
member := models.DeployPipelineMember{
PipelineID: uint(pipelineID),
GroupID: &req.GroupID,
Role: models.MemberRole(req.Role),
}
if err := h.db.Create(&member).Error; err != nil {
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该组已被授权到此流水线"})
return
}
// 预加载关联
member.Group = &group
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member, Message: "已授权"})
}
+201
View File
@@ -0,0 +1,201 @@
package handlers
import (
"net/http"
"strconv"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ItemRoleHandler Item Role handler (Jenkins-style regex pipeline matching)
type ItemRoleHandler struct {
db *gorm.DB
}
// NewItemRoleHandler creates a new ItemRoleHandler
func NewItemRoleHandler(db *gorm.DB) *ItemRoleHandler {
return &ItemRoleHandler{db: db}
}
// ListItemRoles GET /admin/item-roles
func (h *ItemRoleHandler) ListItemRoles(c *gin.Context) {
var roles []models.ItemRole
h.db.Order("created_at DESC").Find(&roles)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Data: models.ListResponse{List: roles, Total: int64(len(roles))},
})
}
// CreateItemRole POST /admin/item-roles
func (h *ItemRoleHandler) CreateItemRole(c *gin.Context) {
var req models.CreateItemRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
// 唯一性校验
var existing models.ItemRole
if err := h.db.Where("name = ? OR code = ?", req.Name, req.Code).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "Item Role 名称或编码已存在"})
return
}
role := models.ItemRole{
Name: req.Name,
Code: req.Code,
Pattern: req.Pattern,
Role: req.Role,
Description: req.Description,
Status: 1,
}
if err := h.db.Create(&role).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: role})
}
// UpdateItemRole PUT /admin/item-roles/:id
func (h *ItemRoleHandler) UpdateItemRole(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"})
return
}
var req models.UpdateItemRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
var role models.ItemRole
if err := h.db.First(&role, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Item Role 不存在"})
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Code != "" {
updates["code"] = req.Code
}
if req.Pattern != "" {
updates["pattern"] = req.Pattern
}
if req.Role != "" {
updates["role"] = req.Role
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Status != nil {
updates["status"] = *req.Status
}
if len(updates) > 0 {
if err := h.db.Model(&role).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"})
return
}
}
h.db.First(&role, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: role})
}
// DeleteItemRole DELETE /admin/item-roles/:id
func (h *ItemRoleHandler) DeleteItemRole(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"})
return
}
// Clean up user associations first
h.db.Where("item_role_id = ?", id).Delete(&models.UserItemRole{})
if err := h.db.Delete(&models.ItemRole{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
}
// ListUserItemRoles GET /admin/users/:id/item-roles
func (h *ItemRoleHandler) ListUserItemRoles(c *gin.Context) {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
return
}
var assignments []models.UserItemRole
h.db.Preload("ItemRole").Where("user_id = ?", userID).Find(&assignments)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Data: models.ListResponse{List: assignments, Total: int64(len(assignments))},
})
}
// AssignItemRole POST /admin/users/:id/item-roles
func (h *ItemRoleHandler) AssignItemRole(c *gin.Context) {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
return
}
var req models.AssignItemRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
// Check if already assigned
var existing models.UserItemRole
if err := h.db.Where("user_id = ? AND item_role_id = ?", userID, req.ItemRoleID).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "该用户已拥有此 Item Role"})
return
}
assignment := models.UserItemRole{
UserID: uint(userID),
ItemRoleID: req.ItemRoleID,
}
if err := h.db.Create(&assignment).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "分配失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "分配成功", Data: assignment})
}
// RemoveItemRole DELETE /admin/users/:id/item-roles/:roleId
func (h *ItemRoleHandler) RemoveItemRole(c *gin.Context) {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
return
}
roleID, err := strconv.Atoi(c.Param("roleId"))
if err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 Role ID"})
return
}
if err := h.db.Where("user_id = ? AND item_role_id = ?", uint(userID), roleID).Delete(&models.UserItemRole{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "移除失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "移除成功"})
}
+266 -64
View File
@@ -2,22 +2,63 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "regexp"
"time"
"ezops/internal/models" "ezops/internal/models"
"ezops/internal/api/middleware"
ezredis "ezops/pkg/redis"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
) )
// PipelineHandler 流水线处理器(流水线 + 应用发布两个子域,应用发布方法拆分至 pipeline_release.go) // Logan.Li // getPipelineID 从 context 获取中间件注入的流水线 ID
func getPipelineID(c *gin.Context) uint {
if v, exists := c.Get("pipelineID"); exists {
if id, ok := v.(uint); ok {
return id
}
}
return 0
}
// codePattern 流水线编码规则:字母+数字+连字符,不能以连字符开头/结尾
var codePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
// validateCode 校验流水线编码格式并查重
func validateCode(db *gorm.DB, code string, excludeID uint) string {
if code == "" {
return "流水线编码不能为空"
}
if len(code) > 64 {
return "编码长度不能超过 64 字符"
}
if !codePattern.MatchString(code) {
return "编码格式不正确:只能使用字母、数字、连字符(-),不能以连字符开头或结尾\n示例: My-Pipeline, demo-jar-backend"
}
// 查重
var count int64
q := db.Model(&models.Pipeline{}).Where("code = ?", code)
if excludeID > 0 {
q = q.Where("id != ?", excludeID)
}
q.Count(&count)
if count > 0 {
return "编码已存在,请使用其他编码"
}
return ""
}
// PipelineHandler 流水线管理器
type PipelineHandler struct { type PipelineHandler struct {
db *gorm.DB db *gorm.DB
cache ezredis.Cache
} }
// NewPipelineHandler 创建流水线处理器 // NewPipelineHandler 创建流水线处理器
func NewPipelineHandler(db *gorm.DB) *PipelineHandler { func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache) *PipelineHandler {
return &PipelineHandler{db: db} return &PipelineHandler{db: db, cache: cache}
} }
// ListPipelines 获取流水线列表 // ListPipelines 获取流水线列表
@@ -29,24 +70,38 @@ func NewPipelineHandler(db *gorm.DB) *PipelineHandler {
// @Param page query int false "页码" // @Param page query int false "页码"
// @Param page_size query int false "每页数量" // @Param page_size query int false "每页数量"
// @Success 200 {object} models.Response{data=models.ListResponse} // @Success 200 {object} models.Response{data=models.ListResponse}
// @Router /pipelines [get] // ListPipelines 获取流水线列表
func (h *PipelineHandler) ListPipelines(c *gin.Context) { func (h *PipelineHandler) ListPipelines(c *gin.Context) {
var query models.PageQuery var query models.PageQuery
c.ShouldBindQuery(&query) c.ShouldBindQuery(&query)
// 管理员看全部,非管理员只看自己有权限的
isAdmin, _ := c.Get("isAdmin")
username, _ := c.Get("username")
usernameStr, _ := username.(string)
userID, _ := c.Get("userID")
uid, _ := userID.(uint)
db := h.db.Model(&models.Pipeline{})
if isAdmin != true && usernameStr != "" {
ids, hasAccess := middleware.GetAccessiblePipelineIDs(h.db, usernameStr, uid)
if !hasAccess {
// 没有任何流水线权限 → 返回空列表
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: []models.Pipeline{}, Total: 0}})
return
}
db = db.Where("id IN ?", ids)
}
var total int64 var total int64
h.db.Model(&models.Pipeline{}).Count(&total) db.Count(&total)
var pipelines []models.Pipeline var pipelines []models.Pipeline
h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&pipelines) db.Preload("Environment").Offset(query.GetOffset()).Limit(query.GetLimit()).Order("updated_at DESC").Find(&pipelines)
c.JSON(http.StatusOK, models.Response{ c.JSON(http.StatusOK, models.Response{
Code: 200, Code: 200,
Message: "success", Data: models.ListResponse{List: pipelines, Total: total},
Data: models.ListResponse{
List: pipelines,
Total: total,
},
}) })
} }
@@ -60,7 +115,11 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
// @Success 200 {object} models.Response{data=models.Pipeline} // @Success 200 {object} models.Response{data=models.Pipeline}
// @Router /pipelines/{id} [get] // @Router /pipelines/{id} [get]
func (h *PipelineHandler) GetPipeline(c *gin.Context) { func (h *PipelineHandler) GetPipeline(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id := getPipelineID(c)
if id == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
return
}
var pipeline models.Pipeline var pipeline models.Pipeline
if err := h.db.First(&pipeline, id).Error; err != nil { if err := h.db.First(&pipeline, id).Error; err != nil {
@@ -98,17 +157,42 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
return return
} }
// 校验编码格式
if msg := validateCode(h.db, req.Code, 0); msg != "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: msg})
return
}
pipeline := models.Pipeline{ pipeline := models.Pipeline{
Name: req.Name, Name: req.Name,
Code: req.Code, Code: req.Code,
Description: req.Description, JobName: req.JobName,
Type: req.Type, Description: req.Description,
Source: req.Source, Type: req.Type,
Branch: req.Branch, Source: req.Source,
Dockerfile: req.Dockerfile, Branch: req.Branch,
Stages: req.Stages, Dockerfile: req.Dockerfile,
Variables: req.Variables, Stages: req.Stages,
Enabled: true, Variables: req.Variables,
DeployMode: req.DeployMode,
DeployTarget: req.DeployTarget,
ServerIDs: req.ServerIDs,
K8sClusterID: req.K8sClusterID,
K8sNamespace: req.K8sNamespace,
RepoID: req.RepoID,
RegistryID: req.RegistryID,
GitCredentialID: req.GitCredentialID,
RegistryCredentialID: req.RegistryCredentialID,
EnvironmentID: req.EnvironmentID,
// 触发配置
TriggerMode: req.TriggerMode,
WatchBranches: req.WatchBranches,
ExcludeBranch: req.ExcludeBranch,
CronExpr: req.CronExpr,
WebhookToken: req.WebhookToken,
// 缓存
CachePaths: req.CachePaths,
Enabled: true,
} }
if err := h.db.Create(&pipeline).Error; err != nil { if err := h.db.Create(&pipeline).Error; err != nil {
@@ -138,9 +222,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
// @Success 200 {object} models.Response{data=models.Pipeline} // @Success 200 {object} models.Response{data=models.Pipeline}
// @Router /pipelines/{id} [put] // @Router /pipelines/{id} [put]
func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id := getPipelineID(c)
if id == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
return
}
var req models.CreatePipelineRequest var req models.UpdatePipelineRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{ c.JSON(http.StatusBadRequest, models.Response{
Code: 400, Code: 400,
@@ -158,16 +246,33 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
return return
} }
updates := map[string]interface{}{ updates := map[string]interface{}{}
"name": req.Name, if req.Name != "" { updates["name"] = req.Name }
"description": req.Description, if req.JobName != "" { updates["job_name"] = req.JobName }
"type": req.Type, if req.Description != "" { updates["description"] = req.Description }
"source": req.Source, if req.Type != "" { updates["type"] = req.Type }
"branch": req.Branch, if req.Source != "" { updates["source"] = req.Source }
"dockerfile": req.Dockerfile, if req.Branch != "" { updates["branch"] = req.Branch }
"stages": req.Stages, if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile }
"variables": req.Variables, if req.Stages != "" { updates["stages"] = req.Stages }
} if req.Variables != "" { updates["variables"] = req.Variables }
if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode }
if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget }
if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs }
if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace }
if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID }
if req.RepoID != nil { updates["repo_id"] = req.RepoID }
if req.RegistryID != nil { updates["registry_id"] = req.RegistryID }
if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID }
if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID }
if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID }
if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode }
if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches }
if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch }
if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr }
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
if req.Enabled != nil { updates["enabled"] = req.Enabled }
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{
@@ -196,7 +301,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
// @Success 200 {object} models.Response // @Success 200 {object} models.Response
// @Router /pipelines/{id} [delete] // @Router /pipelines/{id} [delete]
func (h *PipelineHandler) DeletePipeline(c *gin.Context) { func (h *PipelineHandler) DeletePipeline(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id := getPipelineID(c)
if err := h.db.Delete(&models.Pipeline{}, id).Error; err != nil { if err := h.db.Delete(&models.Pipeline{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{ c.JSON(http.StatusInternalServerError, models.Response{
@@ -222,50 +327,59 @@ func (h *PipelineHandler) DeletePipeline(c *gin.Context) {
// @Param id path int true "流水线ID" // @Param id path int true "流水线ID"
// @Param request body models.TriggerPipelineRequest true "触发请求" // @Param request body models.TriggerPipelineRequest true "触发请求"
// @Success 200 {object} models.Response{data=models.PipelineRun} // @Success 200 {object} models.Response{data=models.PipelineRun}
// @Router /pipelines/{id}/trigger [post] // TriggerPipeline 触发流水线
func (h *PipelineHandler) TriggerPipeline(c *gin.Context) { func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id := getPipelineID(c)
if id == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
return
}
var pipeline models.Pipeline var pipeline models.Pipeline
if err := h.db.First(&pipeline, id).Error; err != nil { if err := h.db.First(&pipeline, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{ c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
Code: 404,
Message: "流水线不存在",
})
return return
} }
var req models.TriggerPipelineRequest var req models.TriggerPipelineRequest
c.ShouldBindJSON(&req) c.ShouldBindJSON(&req)
// 获取最新构建号
var maxBuildNumber int
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id).Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
var maxBuildNumber int
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id).
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
branch := req.Branch
tag := req.Tag
ref := branch
if req.Tag != "" {
ref = tag
} else if branch == "" {
ref = pipeline.Branch
}
username, _ := c.Get("username")
userStr, _ := username.(string)
now := time.Now()
run := models.PipelineRun{ run := models.PipelineRun{
PipelineID: uint(id), PipelineID: uint(id),
BuildNumber: maxBuildNumber + 1, BuildNumber: maxBuildNumber + 1,
Status: "pending", Status: "running",
Branch: req.Branch, Branch: ref,
TriggeredBy: "manual", ImageTag: tag, // 发布 Tag
} TriggeredBy: userStr,
StartedAt: &now,
if req.Branch == "" {
run.Branch = pipeline.Branch
} }
if err := h.db.Create(&run).Error; err != nil { if err := h.db.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{ c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"})
Code: 500,
Message: "触发失败",
})
return return
} }
c.JSON(http.StatusOK, models.Response{ // 后台异步执行流水线
Code: 200, go h.executePipeline(pipeline, run)
Message: "触发成功",
Data: run, c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已触发", Data: run})
})
} }
// ListRuns 获取运行记录列表 // ListRuns 获取运行记录列表
@@ -280,7 +394,7 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
// @Success 200 {object} models.Response{data=models.ListResponse} // @Success 200 {object} models.Response{data=models.ListResponse}
// @Router /pipelines/{id}/runs [get] // @Router /pipelines/{id}/runs [get]
func (h *PipelineHandler) ListRuns(c *gin.Context) { func (h *PipelineHandler) ListRuns(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id := getPipelineID(c)
var query models.PageQuery var query models.PageQuery
c.ShouldBindQuery(&query) c.ShouldBindQuery(&query)
@@ -290,7 +404,7 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) {
db.Count(&total) db.Count(&total)
var runs []models.PipelineRun var runs []models.PipelineRun
db.Order("created_at DESC"). db.Order("started_at DESC").
Offset(query.GetOffset()). Offset(query.GetOffset()).
Limit(query.GetLimit()). Limit(query.GetLimit()).
Find(&runs) Find(&runs)
@@ -304,3 +418,91 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) {
}, },
}) })
} }
// WebhookTrigger 外部 Webhook 触发流水线 (支持 URL: /webhook/job/:jobName 和 /webhook/pipeline/:code)
func (h *PipelineHandler) WebhookTrigger(c *gin.Context) {
jobName := c.Param("jobName")
code := c.Param("code")
var pipeline models.Pipeline
if jobName != "" {
if err := h.db.Where("job_name = ? AND enabled = ?", jobName, true).First(&pipeline).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Job 不存在或已禁用"})
return
}
} else if code != "" {
if err := h.db.Where("code = ? AND enabled = ?", code, true).First(&pipeline).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在或已禁用"})
return
}
} else {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "缺少 jobName 或 code"})
return
}
// 验证 Webhook Token
if pipeline.WebhookToken != "" {
token := c.GetHeader("X-Webhook-Token")
if token == "" {
token = c.Query("token")
}
if token != pipeline.WebhookToken {
c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "Webhook Token 无效"})
return
}
}
// 解析请求体获取 branch/tag
var req models.TriggerPipelineRequest
c.ShouldBindJSON(&req)
var maxBuildNumber int
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID).
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
ref := req.Branch
if req.Tag != "" {
ref = req.Tag
} else if ref == "" {
ref = pipeline.Branch
}
triggeredBy := "webhook"
if reqPayload, exists := c.Get("raw_body"); exists {
// 尝试从 webhook payload 中提取提交者信息
if body, ok := reqPayload.(map[string]interface{}); ok {
if pusher, ok := body["pusher"].(map[string]interface{}); ok {
if name, ok := pusher["name"].(string); ok {
triggeredBy = name
}
}
}
}
now := time.Now()
run := models.PipelineRun{
PipelineID: pipeline.ID,
BuildNumber: maxBuildNumber + 1,
Status: "running",
Branch: ref,
ImageTag: req.Tag,
TriggeredBy: triggeredBy,
StartedAt: &now,
}
if err := h.db.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"})
return
}
go h.executePipeline(pipeline, run)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "已触发",
Data: map[string]interface{}{
"build_number": run.BuildNumber,
"run_id": run.ID,
},
})
}
@@ -0,0 +1,574 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"ezops/internal/models"
"go.uber.org/zap"
)
// ==================== 流水线执行引擎 V2(Redis 实时日志推送)====================
// LogEntry 日志条目
type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Stage string `json:"stage"`
Message string `json:"message"`
}
// StageStep 单个阶段定义
type StageStep struct {
Name string `json:"name"`
Type string `json:"type"` // checkout/build/test/image/deploy/verify
Status string `json:"status"`
}
var logger *zap.Logger
func init() {
logger, _ = zap.NewProduction()
}
// executePipeline 后台执行流水线(goroutine)— V2 Redis 版
func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.PipelineRun) {
runID := run.ID
ctx := context.Background()
logKey := fmt.Sprintf("pipeline:run:%d:logs", runID)
pubCh := fmt.Sprintf("pipeline:run:%d", runID)
// 日志函数:同时写 Redis buffer + 发布 Pub/Sub
log := func(level, stage, msg string) {
entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg}
data, _ := json.Marshal(entry)
// 追加到 Redis 列表
if h.cache != nil {
existing, _ := h.cache.Get(ctx, logKey)
var entries []LogEntry
if existing != "" {
json.Unmarshal([]byte(existing), &entries)
}
entries = append(entries, entry)
buf, _ := json.Marshal(entries)
h.cache.Set(ctx, logKey, string(buf), 30*time.Minute)
// Pub/Sub 实时推送
h.cache.Publish(ctx, pubCh, string(data))
}
}
updateStatus := func(status string, duration int) {
now := time.Now()
updates := map[string]interface{}{"status": status}
if duration > 0 {
updates["duration"] = duration
}
if status == "success" || status == "failed" || status == "cancelled" {
updates["finished_at"] = &now
}
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Updates(updates)
// 推送状态变更
if h.cache != nil {
statusData, _ := json.Marshal(map[string]interface{}{"type": "status", "status": status, "duration": duration})
h.cache.Publish(ctx, pubCh, string(statusData))
}
}
updatePipelineStats := func(status string) {
ups := map[string]interface{}{"last_status": status, "last_run_at": time.Now(), "run_count": pipeline.RunCount + 1}
if status == "success" {
ups["success_count"] = pipeline.SuccessCount + 1
}
if status == "failed" {
ups["fail_count"] = pipeline.FailCount + 1
}
h.db.Model(&models.Pipeline{}).Where("id = ?", pipeline.ID).Updates(ups)
}
isCancelled := func() bool {
var current models.PipelineRun
h.db.First(&current, runID)
return current.Status == "cancelled"
}
// 解析阶段
var stages []StageStep
if pipeline.Stages != "" {
json.Unmarshal([]byte(pipeline.Stages), &stages)
}
if len(stages) == 0 {
log("warn", "init", "没有配置阶段,流水线结束")
log("success", "完成", "流水线执行成功 (0s)")
updateStatus("success", 0)
updatePipelineStats("success")
h.flushLogsToDB(ctx, runID, logKey)
return
}
// 工作目录 — 按仓库名+分支+时间戳命名,方便区分不同构建
branch := run.Branch
if branch == "" {
branch = pipeline.Branch
}
gitURL := pipeline.Source
// 从 Git URL 提取仓库名 (e.g. https://gitee.com/attacker/demo-jar.git → demo-jar)
repoName := extractRepoName(gitURL)
if repoName == "" {
repoName = pipeline.Code
}
ts := time.Now()
tsDir := fmt.Sprintf("%d%02d%02d%02d%02d", ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute())
workspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code)
os.RemoveAll(workspace)
os.MkdirAll(workspace, 0755)
// 保存 workspace 路径到运行记录,方便终端查看
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("workspace_path", workspace)
imageTag := fmt.Sprintf("%s-%s", repoName, tsDir)
log("info", "init", fmt.Sprintf("触发方式: 手动 (admin)"))
log("info", "init", fmt.Sprintf("分支: %s | 镜像标签: %s", branch, imageTag))
totalStart := time.Now()
for _, stage := range stages {
if isCancelled() {
log("warn", stage.Name, "流水线已被停止")
updateStatus("cancelled", int(time.Since(totalStart).Seconds()))
updatePipelineStats("cancelled")
h.flushLogsToDB(ctx, runID, logKey)
return
}
log("info", stage.Name, fmt.Sprintf("===== 阶段: %s =====", stage.Name))
stageStart := time.Now()
var err error
switch stage.Type {
case "checkout":
err = h.runCheckout(gitURL, branch, workspace, log)
case "build":
err = h.runBuild(workspace, pipeline.Dockerfile, log)
case "test":
err = h.runTest(workspace, log)
case "image":
err = h.runDockerBuild(workspace, pipeline, imageTag, log)
case "deploy":
err = h.runDeploy(workspace, pipeline, imageTag, log)
case "verify":
err = h.runVerify(workspace, log)
default:
log("info", stage.Name, "跳过未知阶段类型: "+stage.Type)
}
stageDur := time.Since(stageStart)
if err != nil {
log("error", stage.Name, fmt.Sprintf("✗ %s 失败 (%v): %v", stage.Name, stageDur.Round(time.Second), err))
updateStatus("failed", int(time.Since(totalStart).Seconds()))
updatePipelineStats("failed")
h.flushLogsToDB(ctx, runID, logKey)
return
}
log("success", stage.Name, fmt.Sprintf("✓ %s 完成 (%v)", stage.Name, stageDur.Round(time.Second)))
}
totalDur := int(time.Since(totalStart).Seconds())
updateStatus("success", totalDur)
updatePipelineStats("success")
log("success", "完成", fmt.Sprintf("流水线执行成功 (%ds)", totalDur))
// 执行结束 → Redis 日志批量写入 MySQL
h.flushLogsToDB(ctx, runID, logKey)
}
// flushLogsToDB Redis 日志批量写入 MySQL
func (h *PipelineHandler) flushLogsToDB(ctx context.Context, runID uint, logKey string) {
if h.cache == nil {
return
}
raw, err := h.cache.Get(ctx, logKey)
if err != nil || raw == "" {
return
}
var entries []LogEntry
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
return
}
logsJSON, _ := json.Marshal(entries)
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("logs", string(logsJSON))
// 清理 Redis 缓存
h.cache.Del(ctx, logKey)
}
// runCheckout git clone
func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(string, string, string)) error {
if url == "" {
logFn("warn", "代码检出", "没有配置 Git URL,跳过代码检出")
return nil
}
logFn("info", "代码检出", "> Git 拉取 "+url+" (分支: "+branch+")")
cmd := exec.Command("git", "clone", "--depth", "1", "-b", branch, url, workspace)
output, err := cmd.CombinedOutput()
// 流式输出到日志
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line != "" {
logFn("info", "代码检出", line)
}
}
if err != nil {
return fmt.Errorf("git clone 失败: %v", err)
}
hashCmd := exec.Command("git", "-C", workspace, "rev-parse", "--short", "HEAD")
hashOut, _ := hashCmd.Output()
logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(string(hashOut))))
return nil
}
// runBuild 构建(自动检测 Maven / npm / Go / Makefile)
func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(string, string, string)) error {
buildDir := workspace
if dockerfile != "" {
if dir := dotdot(dockerfile); dir != "." && dir != "" {
testDir := workspace + "/" + dir
if _, err := os.Stat(testDir); err == nil {
buildDir = testDir
logFn("info", "构建", ">>> 切换到构建目录: "+dir)
}
}
}
// 如果根目录没找到构建文件,扫描一级子目录
if !hasBuildFile(buildDir) {
type subMatch struct {
dir string
kind string // "maven", "npm", "go", "make"
}
var matches []subMatch
entries, err := os.ReadDir(buildDir)
if err == nil {
for _, e := range entries {
if !e.IsDir() || e.Name() == ".git" {
continue
}
sub := buildDir + "/" + e.Name()
if _, err := os.Stat(sub + "/pom.xml"); err == nil {
matches = append(matches, subMatch{sub, "maven"})
}
if _, err := os.Stat(sub + "/package.json"); err == nil {
matches = append(matches, subMatch{sub, "npm"})
}
if _, err := os.Stat(sub + "/go.mod"); err == nil {
matches = append(matches, subMatch{sub, "go"})
}
if _, err := os.Stat(sub + "/Makefile"); err == nil {
matches = append(matches, subMatch{sub, "make"})
}
}
}
if len(matches) > 0 {
// 有多个匹配时列出,优先选择第一个
if len(matches) > 1 {
names := make([]string, len(matches))
for i, m := range matches {
names[i] = fmt.Sprintf("%s(%s)", m.dir[len(buildDir)+1:], m.kind)
}
logFn("info", "构建", ">>> 发现多个构建目录: "+strings.Join(names, ", "))
}
buildDir = matches[0].dir
logFn("info", "构建", ">>> 自动检测到构建目录: "+buildDir[len(workspace)+1:])
} else {
logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建")
return nil
}
}
if _, err := os.Stat(buildDir + "/pom.xml"); err == nil {
logFn("info", "构建", ">>> Maven 构建 (mvn clean package -DskipTests)")
logFn("info", "构建", "🔨 正在下载依赖并编译...")
cmd := exec.Command("mvn", "clean", "package", "-DskipTests")
cmd.Dir = buildDir
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "构建", line)
}
}
if err != nil {
return fmt.Errorf("Maven 构建失败: %v", err)
}
logFn("success", "构建", "Maven 构建成功 ✅")
return nil
}
if _, err := os.Stat(buildDir + "/package.json"); err == nil {
logFn("info", "构建", ">>> npm 构建 (npm install && npm run build)")
logFn("info", "构建", "📦 正在安装依赖...")
installCmd := exec.Command("sh", "-c", "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1")
installCmd.Dir = buildDir
out, err := installCmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "构建", line)
}
}
if err != nil {
return fmt.Errorf("npm install 失败: %v", err)
}
logFn("info", "构建", "🏗️ 正在打包构建...")
buildCmd := exec.Command("sh", "-c", "npm run build 2>&1")
buildCmd.Dir = buildDir
out, err = buildCmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "构建", line)
}
}
if err != nil {
return fmt.Errorf("npm build 失败: %v", err)
}
logFn("success", "构建", "npm 构建成功 ✅")
return nil
}
if _, err := os.Stat(buildDir + "/go.mod"); err == nil {
logFn("info", "构建", ">>> Go 构建 (go build ./...)")
cmd := exec.Command("go", "build", "./...")
cmd.Dir = buildDir
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "构建", line)
}
}
if err != nil {
return fmt.Errorf("Go 构建失败: %v", err)
}
logFn("success", "构建", "Go 构建成功")
return nil
}
logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建")
return nil
}
// hasBuildFile 检查目录是否包含可识别的构建文件
func hasBuildFile(dir string) bool {
for _, f := range []string{"pom.xml", "package.json", "go.mod", "Makefile"} {
if _, err := os.Stat(dir + "/" + f); err == nil {
return true
}
}
return false
}
// runTest 执行测试
func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, string)) error {
if _, err := os.Stat(workspace + "/pom.xml"); err == nil {
logFn("info", "测试", ">>> Maven 测试 (mvn test)")
cmd := exec.Command("mvn", "test")
cmd.Dir = workspace
out, _ := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "测试", line)
}
}
}
return nil
}
// runDockerBuild Docker 构建 & 推送
func (h *PipelineHandler) runDockerBuild(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error {
dockerfile := pipeline.Dockerfile
if dockerfile == "" {
dockerfile = "Dockerfile"
}
var registryURL, imageName string
if pipeline.RegistryID != nil {
var reg models.PipelineRegistry
if err := h.db.First(&reg, *pipeline.RegistryID).Error; err == nil {
registryURL = reg.RegistryURL
if reg.Namespace != "" {
imageName = reg.Namespace + "/" + pipeline.Code
}
}
}
if imageName == "" {
imageName = pipeline.Code
}
if registryURL == "" {
registryURL = "registry.example.com"
}
fullImage := fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag)
logFn("info", "镜像打包", fmt.Sprintf("镜像地址: %s", fullImage))
buildDir := workspace
if dir := dotdot(dockerfile); dir != "." && dir != "" {
buildDir = workspace + "/" + dir
}
dfPath := dockerfile
if strings.Contains(dockerfile, "/") {
dfPath = dockerfile[strings.LastIndex(dockerfile, "/")+1:]
}
logFn("info", "镜像打包", fmt.Sprintf(">>> Docker 构建 (dockerfile: %s)", dfPath))
cmd := exec.Command("docker", "build", "-t", fullImage, "-f", dfPath, ".")
cmd.Dir = buildDir
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "镜像打包", line)
}
}
if err != nil {
return fmt.Errorf("Docker 构建失败: %v", err)
}
logFn("success", "镜像打包", "Docker 构建成功")
logFn("info", "镜像打包", ">>> 推送镜像: "+fullImage)
pushCmd := exec.Command("docker", "push", fullImage)
pushOut, err := pushCmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(pushOut)), "\n") {
if line != "" {
logFn("info", "镜像打包", line)
}
}
if err != nil {
return fmt.Errorf("Docker 推送失败: %v", err)
}
logFn("success", "镜像打包", "镜像推送完成")
return nil
}
// runDeploy 部署到 K8s / 服务器
func (h *PipelineHandler) runDeploy(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error {
deployYamls := findFiles(workspace, ".yaml", ".yml")
var k8sFiles []string
for _, f := range deployYamls {
content, _ := os.ReadFile(f)
if strings.Contains(string(content), "kind:") &&
(strings.Contains(string(content), "Deployment") ||
strings.Contains(string(content), "Service") ||
strings.Contains(string(content), "StatefulSet")) {
k8sFiles = append(k8sFiles, f)
}
}
if len(k8sFiles) == 0 {
logFn("info", "部署", "未找到 K8s 部署文件,跳过部署")
return nil
}
for _, yamlFile := range k8sFiles {
logFn("info", "部署", ">>> kubectl apply -f "+yamlFile)
cmd := exec.Command("kubectl", "apply", "-f", yamlFile)
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "部署", line)
}
}
if err != nil {
logFn("error", "部署", fmt.Sprintf("kubectl apply 失败: %v", err))
return fmt.Errorf("kubectl apply 失败")
}
}
logFn("success", "部署", "部署完成")
return nil
}
// runVerify 部署后验证
func (h *PipelineHandler) runVerify(workspace string, logFn func(string, string, string)) error {
logFn("info", "验证", ">>> 部署后健康检查")
cmd := exec.Command("kubectl", "get", "pods", "-o", "wide")
cmd.Dir = workspace
out, err := cmd.CombinedOutput()
if err != nil {
logFn("warn", "验证", fmt.Sprintf("kubectl get pods 失败: %v", err))
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
logFn("info", "验证", line)
}
}
return nil
}
// ======== helpers ========
func dotdot(path string) string {
i := strings.LastIndex(path, "/")
if i < 0 {
return "."
}
return path[:i]
}
// extractRepoName 从 Git URL 提取仓库名
// e.g. https://gitee.com/attacker/demo-jar.git → demo-jar
func extractRepoName(url string) string {
// Remove trailing .git
clean := strings.TrimSuffix(url, ".git")
// Get last path segment
idx := strings.LastIndex(clean, "/")
if idx < 0 {
return ""
}
return clean[idx+1:]
}
// cleanOldWorkspaces 清理超过 maxAge 的 workspace 目录
func cleanOldWorkspaces(baseDir string, maxAge time.Duration) {
entries, err := os.ReadDir(baseDir)
if err != nil {
return
}
now := time.Now()
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > maxAge {
os.RemoveAll(baseDir + "/" + e.Name())
}
}
}
func findFiles(dir string, exts ...string) []string {
var result []string
entries, err := os.ReadDir(dir)
if err != nil {
return result
}
for _, e := range entries {
full := dir + "/" + e.Name()
if e.IsDir() {
if e.Name() != ".git" {
result = append(result, findFiles(full, exts...)...)
}
} else {
for _, ext := range exts {
if strings.HasSuffix(e.Name(), ext) {
result = append(result, full)
break
}
}
}
}
return result
}
@@ -43,7 +43,7 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"})
return return
} }
if cred.Type == "ssh_key" { if cred.SecretType == "ssh" {
decryptedKey = plain decryptedKey = plain
authType = "key" authType = "key"
} else { } else {
@@ -0,0 +1,184 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ezops/internal/models"
)
// WorkspaceFilesHandler 工作区文件浏览器处理器
type WorkspaceFilesHandler struct {
db *gorm.DB
}
func NewWorkspaceFilesHandler(db *gorm.DB) *WorkspaceFilesHandler {
return &WorkspaceFilesHandler{db: db}
}
// FileInfo 文件信息
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
ModTime string `json:"mod_time"`
}
// resolveWorkspace 解析 run ID 获取 workspace 路径
func (h *WorkspaceFilesHandler) resolveWorkspace(c *gin.Context) (string, error) {
runID, err := strconv.Atoi(c.Param("runId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run id"})
return "", err
}
var run models.PipelineRun
if err := h.db.First(&run, runID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return "", err
}
if run.WorkspacePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace path not available, pipeline may still be running"})
return "", fmt.Errorf("no workspace")
}
return run.WorkspacePath, nil
}
// safePath 安全拼接路径,防止目录穿越
func safePath(base, sub string) (string, error) {
if sub == "" || sub == "." {
sub = "/"
}
// 清理路径
clean := filepath.Clean(sub)
if filepath.IsAbs(clean) {
// 去掉根前缀,转为相对路径
clean = clean[1:]
}
fullPath := filepath.Join(base, clean)
// 确保不逃逸出 base
absBase, _ := filepath.Abs(base)
absFull, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path")
}
if !strings.HasPrefix(absFull, absBase) {
return "", fmt.Errorf("path traversal detected")
}
return fullPath, nil
}
// ListFiles 列出目录内容
// GET /api/v1/pipelines/runs/:runId/files?path=subdir
func (h *WorkspaceFilesHandler) ListFiles(c *gin.Context) {
workspace, err := h.resolveWorkspace(c)
if err != nil {
return
}
subPath := c.Query("path")
targetPath, err := safePath(workspace, subPath)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
entries, err := os.ReadDir(targetPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "directory not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read directory"})
}
return
}
files := make([]FileInfo, 0)
dirs := make([]FileInfo, 0)
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
rel, _ := filepath.Rel(workspace, filepath.Join(targetPath, entry.Name()))
fi := FileInfo{
Name: entry.Name(),
Path: rel,
IsDir: entry.IsDir(),
Size: info.Size(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
}
if entry.IsDir() {
dirs = append(dirs, fi)
} else {
files = append(files, fi)
}
}
// 当前路径(相对于 workspace)
currentRel, _ := filepath.Rel(workspace, targetPath)
if currentRel == "." {
currentRel = ""
}
c.JSON(http.StatusOK, gin.H{
"current": currentRel,
"dirs": dirs,
"files": files,
})
}
// DownloadFile 下载文件
// GET /api/v1/pipelines/runs/:runId/files/download?path=file
func (h *WorkspaceFilesHandler) DownloadFile(c *gin.Context) {
workspace, err := h.resolveWorkspace(c)
if err != nil {
return
}
subPath := c.Query("path")
if subPath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
return
}
targetPath, err := safePath(workspace, subPath)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
info, err := os.Stat(targetPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if info.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download a directory"})
return
}
file, err := os.Open(targetPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
return
}
defer file.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(targetPath)))
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", fmt.Sprintf("%d", info.Size()))
io.Copy(c.Writer, file)
}
@@ -0,0 +1,142 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
ezredis "ezops/pkg/redis"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// WSPipelineLogHandler WebSocket 流水线日志推流
type WSPipelineLogHandler struct {
db *gorm.DB
cache ezredis.Cache
}
// NewWSPipelineLogHandler 创建 WebSocket 日志处理器
func NewWSPipelineLogHandler(db *gorm.DB, cache ezredis.Cache) *WSPipelineLogHandler {
return &WSPipelineLogHandler{db: db, cache: cache}
}
// StreamLog WebSocket 端点:/ws/pipeline/:runId/log
func (h *WSPipelineLogHandler) StreamLog(c *gin.Context) {
runIDStr := c.Param("runId")
runID, err := strconv.ParseUint(runIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"})
return
}
ws, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer ws.Close()
logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID))
ctx := c.Request.Context()
// 首次发送:已有日志
raw, _ := h.cache.Get(ctx, logKey)
if raw != "" {
ws.WriteJSON(map[string]interface{}{"type": "init", "logs": raw})
}
// 轮询新日志(500ms 间隔)
lastLen := 0
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
done := make(chan struct{})
go func() {
defer close(done)
for {
_, _, err := ws.ReadMessage()
if err != nil {
return
}
}
}()
for {
select {
case <-done:
return
case <-ticker.C:
raw, err := h.cache.Get(ctx, logKey)
if err != nil || raw == "" {
continue
}
var entries []LogEntry
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
continue
}
if len(entries) > lastLen {
// 增量推送
newEntries := entries[lastLen:]
lastLen = len(entries)
for _, e := range newEntries {
buf, _ := json.Marshal(e)
if err := ws.WriteMessage(websocket.TextMessage, buf); err != nil {
return
}
}
}
// 检查流水线是否已结束(日志 key 被删除 = 已写入 DB)
if lastLen > 0 {
exists, _ := h.cache.Exists(ctx, logKey)
if exists == 0 {
// 发送完成信号
ws.WriteJSON(map[string]interface{}{"type": "done"})
return
}
}
case <-ctx.Done():
return
}
}
}
// GetLogHistory HTTP 日志历史(已完成的任务从 DB 读取)
func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) {
runIDStr := c.Param("runId")
runID, err := strconv.ParseUint(runIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"})
return
}
// 先查 Redis(执行中)
logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID))
ctx := c.Request.Context()
raw, _ := h.cache.Get(ctx, logKey)
if raw != "" && !strings.HasPrefix(raw, "[") {
raw = "[]"
}
if raw != "" {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "redis", "logs": raw}})
return
}
// 查 DB(已完成)
var logs string
h.db.Table("ops_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs)
if logs == "" {
logs = "[]"
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "db", "logs": logs}})
}
@@ -0,0 +1,139 @@
package middleware
import (
"net/http"
"regexp"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PipelineAccess 流水线权限中间件
// 路由参数 :code(流水线编码)→ 查找 pipeline → 注入 pipelineID 到 context
// 权限检查顺序:管理员 → 直接成员 → 用户组 → Item Role (regex)
func PipelineAccess(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// 1) 解析 code → 查找流水线(所有用户都需要)
code := c.Param("code")
if code == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少流水线编码"})
return
}
var pipeline models.Pipeline
if err := db.Where("code = ?", code).First(&pipeline).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": 404, "message": "流水线不存在"})
return
}
// 注入 context,后续 handlers 统一从这里读
c.Set("pipelineID", pipeline.ID)
c.Set("pipelineCode", code)
// 2) 管理员直接放行(跳过权限检查)
if isAdmin, _ := c.Get("isAdmin"); isAdmin == true {
c.Next()
return
}
username, _ := c.Get("username")
usernameStr, _ := username.(string)
if usernameStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未认证"})
return
}
pipelineID := pipeline.ID
// 3) 检查是否直接是流水线成员
var directCount int64
db.Model(&models.DeployPipelineMember{}).
Where("pipeline_id = ? AND username = ?", pipelineID, usernameStr).
Count(&directCount)
if directCount > 0 {
c.Next()
return
}
// 4) 检查是否属于授权给该流水线的用户组
var groupCount int64
db.Table("deploy_pipeline_members").
Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id").
Where("deploy_pipeline_members.pipeline_id = ? AND deploy_user_group_members.username = ?", pipelineID, usernameStr).
Count(&groupCount)
if groupCount > 0 {
c.Next()
return
}
// 5) Jenkins-style Item Role — regex pattern matching on pipeline code
var itemRoles []models.ItemRole
db.Table("iam_item_roles").
Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id").
Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?",
func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()).
Find(&itemRoles)
for _, ir := range itemRoles {
if matched, _ := regexp.MatchString(ir.Pattern, code); matched {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": 403,
"message": "无权访问此流水线,请联系流水线 Owner 添加你为成员",
})
}
}
// GetAccessiblePipelineIDs 获取当前用户有权访问的流水线 ID 列表
// 包括:直接成员 + 用户组 + Item Role (regex)
func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint, bool) {
var directIDs []uint
db.Model(&models.DeployPipelineMember{}).
Where("username = ?", username).
Pluck("pipeline_id", &directIDs)
var groupIDs []uint
db.Model(&models.DeployPipelineMember{}).
Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id").
Where("deploy_user_group_members.username = ?", username).
Pluck("deploy_pipeline_members.pipeline_id", &groupIDs)
idSet := make(map[uint]bool)
for _, id := range directIDs {
idSet[id] = true
}
for _, id := range groupIDs {
idSet[id] = true
}
// Item Role regex matching: get all item roles for user, match against pipeline codes
var itemRoles []models.ItemRole
db.Table("iam_item_roles").
Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id").
Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", userID).
Find(&itemRoles)
if len(itemRoles) > 0 {
var allPipelines []models.Pipeline
db.Find(&allPipelines)
for _, p := range allPipelines {
for _, ir := range itemRoles {
if matched, _ := regexp.MatchString(ir.Pattern, p.Code); matched {
idSet[p.ID] = true
break
}
}
}
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
return ids, len(ids) > 0
}
+93 -7
View File
@@ -16,6 +16,9 @@ import (
// Register 注册所有路由 // Logan.Li // Register 注册所有路由 // Logan.Li
// cache 参数为可选 Redis 缓存实例(支持 redka/redis 双后端) // cache 参数为可选 Redis 缓存实例(支持 redka/redis 双后端)
func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, cache interface{}) { func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, cache interface{}) {
// 提取 Redis 缓存实例(供流水线 WebSocket 等使用)
redisCache, _ := cache.(ezredis.Cache)
// API版本前缀 // API版本前缀
api := r.Group("/api/v1") api := r.Group("/api/v1")
@@ -64,6 +67,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
metricsWSHandler := handlers.NewMetricsWSHandler(metricsHub) metricsWSHandler := handlers.NewMetricsWSHandler(metricsHub)
r.GET("/ws/metrics", middleware.JWTAuth(&cfg.JWT), metricsWSHandler.ServeWS) r.GET("/ws/metrics", middleware.JWTAuth(&cfg.JWT), metricsWSHandler.ServeWS)
// WebSocket 流水线日志推流
wsLogHandler := handlers.NewWSPipelineLogHandler(db, redisCache)
r.GET("/ws/pipeline/:runId/log", wsLogHandler.StreamLog)
r.GET("/api/v1/pipelines/runs/:runId/log-stream", wsLogHandler.GetLogHistory)
// 工作区文件浏览器(浏览/下载流水线构建目录文件)
workspaceFilesHandler := handlers.NewWorkspaceFilesHandler(db)
r.GET("/api/v1/pipelines/runs/:runId/files", workspaceFilesHandler.ListFiles)
r.GET("/api/v1/pipelines/runs/:runId/files/download", workspaceFilesHandler.DownloadFile)
// WebSocket K8s exec 交互终端 // Logan.Li // WebSocket K8s exec 交互终端 // Logan.Li
k8sHandler := handlers.NewKubernetesHandler(db) k8sHandler := handlers.NewKubernetesHandler(db)
r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal) r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal)
@@ -77,6 +90,14 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
api.POST("/auth/login", authHandler.Login) api.POST("/auth/login", authHandler.Login)
api.POST("/auth/register", authHandler.Register) api.POST("/auth/register", authHandler.Register)
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
pipelineHandler := handlers.NewPipelineHandler(db, redisCache)
webhookGroup := api.Group("/webhook")
{
webhookGroup.POST("/job/:jobName", pipelineHandler.WebhookTrigger)
webhookGroup.POST("/pipeline/:code", pipelineHandler.WebhookTrigger)
}
// Agent 安装处理器(在 servers 和 agent 路由中共享) // Agent 安装处理器(在 servers 和 agent 路由中共享)
agentInstallHandler := handlers.NewAgentInstallHandler(db) agentInstallHandler := handlers.NewAgentInstallHandler(db)
@@ -113,6 +134,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// 权限列表(供角色权限选择) // 权限列表(供角色权限选择)
admin.GET("/permissions", authHandler.ListPermissions) admin.GET("/permissions", authHandler.ListPermissions)
// Item Role 管理(Jenkins-style regex pipeline matching) // Logan.Li
itemRoleHandler := handlers.NewItemRoleHandler(db)
admin.GET("/item-roles", itemRoleHandler.ListItemRoles)
admin.POST("/item-roles", itemRoleHandler.CreateItemRole)
admin.PUT("/item-roles/:id", itemRoleHandler.UpdateItemRole)
admin.DELETE("/item-roles/:id", itemRoleHandler.DeleteItemRole)
admin.GET("/users/:id/item-roles", itemRoleHandler.ListUserItemRoles)
admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole)
admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole)
} }
// LDAP 配置路由 // LDAP 配置路由
@@ -371,16 +402,51 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
} }
// 流水线路由 // 流水线路由
pipelineHandler := handlers.NewPipelineHandler(db)
pipelines := authorized.Group("/pipelines") pipelines := authorized.Group("/pipelines")
{ {
pipelines.GET("", pipelineHandler.ListPipelines) pipelines.GET("", pipelineHandler.ListPipelines)
pipelines.POST("", pipelineHandler.CreatePipeline) pipelines.POST("", pipelineHandler.CreatePipeline)
pipelines.GET("/:id", pipelineHandler.GetPipeline)
pipelines.PUT("/:id", pipelineHandler.UpdatePipeline) // 需要流水线权限的路由
pipelines.DELETE("/:id", pipelineHandler.DeletePipeline) pipelineScoped := pipelines.Group("/:code")
pipelines.POST("/:id/trigger", pipelineHandler.TriggerPipeline) pipelineScoped.Use(middleware.PipelineAccess(db))
pipelines.GET("/:id/runs", pipelineHandler.ListRuns) {
pipelineScoped.GET("", pipelineHandler.GetPipeline)
pipelineScoped.PUT("", pipelineHandler.UpdatePipeline)
pipelineScoped.DELETE("", pipelineHandler.DeletePipeline)
pipelineScoped.POST("/trigger", pipelineHandler.TriggerPipeline)
pipelineScoped.GET("/runs", pipelineHandler.ListRuns)
pipelineScoped.POST("/runs/:runId/stop", pipelineHandler.StopPipelineRun)
pipelineScoped.POST("/runs/batch-delete", pipelineHandler.BatchDeleteRuns)
pipelineScoped.GET("/runs/:runId/log", pipelineHandler.GetRunLog)
// 成员管理
pipelineScoped.GET("/members", pipelineHandler.ListPipelineMembers)
pipelineScoped.POST("/members", pipelineHandler.AddPipelineMember)
pipelineScoped.DELETE("/members/:memberId", pipelineHandler.RemovePipelineMember)
// 组授权
pipelineScoped.POST("/groups", pipelineHandler.AssignGroupToPipeline)
}
}
// 用户组管理路由
groups := authorized.Group("/deploy/groups")
{
groups.GET("", pipelineHandler.ListGroups)
groups.POST("", pipelineHandler.CreateGroup)
groups.PUT("/:id", pipelineHandler.UpdateGroup)
groups.DELETE("/:id", pipelineHandler.DeleteGroup)
groups.GET("/:id/members", pipelineHandler.ListGroupMembers)
groups.POST("/:id/members", pipelineHandler.AddGroupMember)
groups.DELETE("/:id/members/:memberId", pipelineHandler.RemoveGroupMember)
}
// 环境管理路由
environments := authorized.Group("/deploy/environments")
{
environments.GET("", pipelineHandler.ListEnvironments)
environments.POST("", pipelineHandler.CreateEnvironment)
environments.PUT("/:id", pipelineHandler.UpdateEnvironment)
environments.DELETE("/:id", pipelineHandler.DeleteEnvironment)
} }
// 应用发布路由 // 应用发布路由
@@ -394,6 +460,24 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease) releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
} }
// 仓库认证 & 镜像仓库认证路由
deployHandler := handlers.NewDeployHandler(db)
{
repos := authorized.Group("/deploy/repos")
repos.GET("", deployHandler.ListRepos)
repos.POST("", deployHandler.CreateRepo)
repos.GET("/:id", deployHandler.GetRepo)
repos.PUT("/:id", deployHandler.UpdateRepo)
repos.DELETE("/:id", deployHandler.DeleteRepo)
registries := authorized.Group("/deploy/registries")
registries.GET("", deployHandler.ListRegistries)
registries.POST("", deployHandler.CreateRegistry)
registries.GET("/:id", deployHandler.GetRegistry)
registries.PUT("/:id", deployHandler.UpdateRegistry)
registries.DELETE("/:id", deployHandler.DeleteRegistry)
}
// 自动化运维路由 // 自动化运维路由
automationHandler := handlers.NewAutomationHandler(db) automationHandler := handlers.NewAutomationHandler(db)
cronJobs := authorized.Group("/cron-jobs") cronJobs := authorized.Group("/cron-jobs")
@@ -515,7 +599,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
} }
// Agent 上报路由(无需 JWT) // Agent 上报路由(无需 JWT)
redisCache, _ := cache.(ezredis.Cache)
agentHandler := handlers.NewAgentHandler(db, redisCache, metricsHub) agentHandler := handlers.NewAgentHandler(db, redisCache, metricsHub)
taskHandler := handlers.NewAgentTaskHandler(db) taskHandler := handlers.NewAgentTaskHandler(db)
agentGroup := api.Group("/agent") agentGroup := api.Group("/agent")
@@ -645,8 +728,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
credGroup := authorized.Group("/credentials") credGroup := authorized.Group("/credentials")
{ {
credGroup.GET("", credentialHandler.ListCredentials) credGroup.GET("", credentialHandler.ListCredentials)
credGroup.GET("/tags", credentialHandler.GetTags)
credGroup.POST("", credentialHandler.CreateCredential) credGroup.POST("", credentialHandler.CreateCredential)
credGroup.GET("/:id", credentialHandler.GetCredential)
credGroup.PUT("/:id", credentialHandler.UpdateCredential) credGroup.PUT("/:id", credentialHandler.UpdateCredential)
credGroup.PATCH("/:id", credentialHandler.PatchCredential)
credGroup.DELETE("/:id", credentialHandler.DeleteCredential) credGroup.DELETE("/:id", credentialHandler.DeleteCredential)
} }
// 服务器凭据关联 // Logan.Li // 服务器凭据关联 // Logan.Li
+297 -35
View File
@@ -3,59 +3,321 @@ package models
import ( import (
"time" "time"
"gorm.io/gorm"
) )
// AlertRule 告警规则模型 // ============================================
// AlertRule 告警规则(增强版 — 合并原V2特性)
// ============================================
// AlertRuleStatus 告警规则状态
type AlertRuleStatus string
const (
AlertRuleEnabled AlertRuleStatus = "enabled"
AlertRuleDisabled AlertRuleStatus = "disabled"
)
// AlertSeverity 告警级别
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical"
AlertSeverityWarning AlertSeverity = "warning"
AlertSeverityNotice AlertSeverity = "notice"
AlertSeverityInfo AlertSeverity = "info"
)
// AggFunction 聚合函数
type AggFunction string
const (
AggFunctionAvg AggFunction = "avg"
AggFunctionMin AggFunction = "min"
AggFunctionMax AggFunction = "max"
AggFunctionSum AggFunction = "sum"
AggFunctionCount AggFunction = "count"
AggFunctionLast AggFunction = "last"
AggFunctionP95 AggFunction = "p95"
AggFunctionP99 AggFunction = "p99"
)
// Operator 比较运算符
type Operator string
const (
OperatorGT Operator = ">"
OperatorGTE Operator = ">="
OperatorLT Operator = "<"
OperatorLTE Operator = "<="
OperatorEQ Operator = "=="
OperatorNE Operator = "!="
)
// AlertRule 告警规则(增强版)
type AlertRule struct { type AlertRule struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"`
Description string `gorm:"size:500" json:"description"` Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"`
Type string `gorm:"size:32;not null" json:"type"` // metric, log, event // ====== 原 V1 兼容字段 ======
Source string `gorm:"size:64;not null" json:"source"` // prometheus, zabbix, custom Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"`
Expression string `gorm:"type:text;not null" json:"expression"` Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom" json:"source,omitempty"`
Severity string `gorm:"size:32;default:warning" json:"severity"` // info, warning, critical Expression string `gorm:"type:text;comment:PromQL/Zabbix表达式" json:"expression,omitempty"`
Duration int `json:"duration"` // 持续时间(秒) Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"`
Enabled bool `gorm:"default:true" json:"enabled"` Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
Labels string `gorm:"type:text" json:"labels"` // JSON Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
Annotations string `gorm:"type:text" json:"annotations"` // JSON // ====== V2 增强字段 ======
NotifyChannels string `gorm:"type:text" json:"notify_channels"` // JSON Status AlertRuleStatus `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status,omitempty"`
CreatedAt time.Time `json:"created_at"` TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"`
UpdatedAt time.Time `json:"updated_at"` TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"`
TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"`
Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"`
AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window,omitempty"`
AggFunction string `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function,omitempty"`
RecoverCount int `gorm:"default:1;comment:恢复次数" json:"recover_count,omitempty"`
RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window,omitempty"`
Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval,omitempty"`
CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times,omitempty"`
NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode,omitempty"`
ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"`
NotifyChannels string `gorm:"type:text;comment:通知渠道JSON(兼容V1)" json:"notify_channels,omitempty"`
TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"`
UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"`
SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"`
Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
} }
// TableName 表名
func (AlertRule) TableName() string { func (AlertRule) TableName() string {
return "monitor_alert_rules" return "monitor_alert_rules"
} }
// AlertEvent 告警事件模型 // ============================================
// AlertEvent 告警事件(增强版 — 合并原V2特性)
// ============================================
// AlertEventStatus 告警事件状态
type AlertEventStatus string
const (
AlertEventFiring AlertEventStatus = "firing"
AlertEventResolved AlertEventStatus = "resolved"
AlertEventSilenced AlertEventStatus = "silenced"
)
// AlertEvent 告警事件(增强版)
type AlertEvent struct { type AlertEvent struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
RuleID uint `json:"rule_id"` RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"`
RuleName string `gorm:"size:128" json:"rule_name"` RuleName string `gorm:"type:varchar(128);comment:规则名称" json:"rule_name,omitempty"`
Severity string `gorm:"size:32" json:"severity"` Severity string `gorm:"type:varchar(32);comment:告警级别" json:"severity,omitempty"`
Status string `gorm:"size:32;default:firing" json:"status"` // firing, resolved Status string `gorm:"type:varchar(32);default:'firing';comment:事件状态" json:"status"`
Target string `gorm:"size:255" json:"target"` // ====== 原V1兼容字段 ======
Message string `gorm:"type:text" json:"message"` Target string `gorm:"type:varchar(255);comment:目标标签" json:"target,omitempty"`
Value float64 `json:"value"` Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"`
Labels string `gorm:"type:text" json:"labels"` Value float64 `gorm:"comment:触发值" json:"value,omitempty"`
StartedAt time.Time `json:"started_at"` // ====== V2增强字段 ======
ResolvedAt *time.Time `json:"resolved_at"` TargetID uint `gorm:"index;comment:目标ID" json:"target_id,omitempty"`
Acknowledged bool `json:"acknowledged"` TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"`
AcknowledgedBy *uint `json:"acknowledged_by"` TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at"` Title string `gorm:"type:varchar(256);comment:告警标题" json:"title,omitempty"`
CreatedAt time.Time `json:"created_at"` MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"`
UpdatedAt time.Time `json:"updated_at"` Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"`
Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"`
Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"`
Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"`
ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"`
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"`
NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count,omitempty"`
LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"`
UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level,omitempty"`
Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"`
AcknowledgedBy *uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"`
AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
// 关联 // 关联
Rule *AlertRule `json:"rule,omitempty"` Rule *AlertRule `json:"rule,omitempty" gorm:"foreignKey:RuleID"`
} }
// TableName 表名
func (AlertEvent) TableName() string { func (AlertEvent) TableName() string {
return "monitor_alert_events" return "monitor_alert_events"
} }
// ============================================
// AlertCondition 告警条件定义
// ============================================
type AlertCondition struct {
Metric string `json:"metric"`
Operator Operator `json:"operator"`
Threshold float64 `json:"threshold"`
Unit string `json:"unit,omitempty"`
Index string `json:"index,omitempty"`
LogicOp string `json:"logic_op,omitempty"`
AggFunc AggFunction `json:"agg_func,omitempty"`
Window int `json:"window,omitempty"`
}
func (AlertCondition) TableName() string {
return "monitor_alert_conditions"
}
type AlertConditionGroup struct {
Conditions []AlertCondition `json:"conditions"`
LogicOp string `json:"logic_op"`
}
// ============================================
// AlertNotifyTemplate 告警通知模板
// ============================================
type AlertNotifyTemplate struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"`
TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"`
BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"`
Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"`
RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"`
RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"`
IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertNotifyTemplate) TableName() string {
return "monitor_alert_notify_templates"
}
type TemplateVariable struct {
Name string `json:"name"`
Description string `json:"description"`
Example string `json:"example"`
}
var DefaultTemplateVariables = []TemplateVariable{
{Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"},
{Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"},
{Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"},
{Name: "{{.Severity}}", Description: "告警级别", Example: "critical"},
{Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"},
{Name: "{{.Value}}", Description: "当前值", Example: "95.2"},
{Name: "{{.Threshold}}", Description: "阈值", Example: "90"},
{Name: "{{.Operator}}", Description: "运算符", Example: ">"},
{Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"},
{Name: "{{.Duration}}", Description: "持续时间", Example: "5m"},
{Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"},
{Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"},
}
// ============================================
// AlertUpgrade + AlertUpgradeRecord 告警升级
// ============================================
type AlertUpgrade struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"`
MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"`
IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertUpgrade) TableName() string {
return "monitor_alert_upgrades"
}
type AlertUpgradeRule struct {
Level int `json:"level"`
Duration int `json:"duration"`
Severity string `json:"severity"`
ChannelIDs []string `json:"channel_ids"`
NotifyUsers []uint `json:"notify_users"`
RepeatInterval int `json:"repeat_interval"`
}
type AlertUpgradeRecord struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"`
RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"`
FromLevel int `gorm:"comment:原级别" json:"from_level"`
ToLevel int `gorm:"comment:升级后级别" json:"to_level"`
UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
}
func (AlertUpgradeRecord) TableName() string {
return "monitor_alert_upgrade_records"
}
// ============================================
// AlertSilence 告警静默规则
// ============================================
type AlertSilenceType string
const (
AlertSilenceOneTime AlertSilenceType = "onetime"
AlertSilenceDaily AlertSilenceType = "daily"
AlertSilenceWeekly AlertSilenceType = "weekly"
)
type AlertSilence struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"`
TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"`
Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"`
SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"`
StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"`
Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"`
Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertSilence) TableName() string {
return "monitor_alert_silences"
}
type AlertSilenceMatcher struct {
Name string `json:"name"`
Value string `json:"value"`
IsRegex bool `json:"is_regex"`
}
type AlertSilenceCheckResult struct {
IsSilenced bool `json:"is_silenced"`
SilenceID uint `json:"silence_id,omitempty"`
SilenceName string `json:"silence_name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// NotifyChannel 通知渠道模型 // NotifyChannel 通知渠道模型
type NotifyChannel struct { type NotifyChannel struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
-405
View File
@@ -1,405 +0,0 @@
package models
import (
"time"
)
// ============================================
// AlertRuleV2 增强告警规则
// ============================================
// AlertRuleV2Status 告警规则状态
type AlertRuleV2Status string
const (
AlertRuleV2Enabled AlertRuleV2Status = "enabled" // 启用
AlertRuleV2Disabled AlertRuleV2Status = "disabled" // 禁用
)
// AlertSeverity 告警级别
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical" // 严重
AlertSeverityWarning AlertSeverity = "warning" // 警告
AlertSeverityNotice AlertSeverity = "notice" // 通知
AlertSeverityInfo AlertSeverity = "info" // 信息
)
// AggFunction 聚合函数
type AggFunction string
const (
AggFunctionAvg AggFunction = "avg" // 平均值
AggFunctionMin AggFunction = "min" // 最小值
AggFunctionMax AggFunction = "max" // 最大值
AggFunctionSum AggFunction = "sum" // 求和
AggFunctionCount AggFunction = "count" // 计数
AggFunctionLast AggFunction = "last" // 最新值
AggFunctionP95 AggFunction = "p95" // 95分位
AggFunctionP99 AggFunction = "p99" // 99分位
)
// Operator 比较运算符
type Operator string
const (
OperatorGT Operator = ">" // 大于
OperatorGTE Operator = ">=" // 大于等于
OperatorLT Operator = "<" // 小于
OperatorLTE Operator = "<=" // 小于等于
OperatorEQ Operator = "==" // 等于
OperatorNE Operator = "!=" // 不等于
)
// AlertRuleV2 增强告警规则表
// 支持多条件组合、聚合函数、静默期等高级特性
type AlertRuleV2 struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:规则描述" json:"description,omitempty"`
Severity AlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"`
Status AlertRuleV2Status `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status"`
// 关联目标
TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"`
TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"`
TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"`
// 触发条件(JSON存储支持多条件)
Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"`
// 聚合配置
AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window"`
AggFunction AggFunction `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function"`
// 恢复配置
RecoverCount int `gorm:"default:1;comment:恢复次数(连续N次低于阈值触发恢复)" json:"recover_count"`
RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window"`
// 执行配置
Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval"`
CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times"`
NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode"`
// 通知渠道
ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"`
TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"`
// 升级策略
UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"`
// 静默策略
SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"`
// 标签和注释
Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON(用于通知内容模板)" json:"annotations,omitempty"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertRuleV2) TableName() string {
return "monitor_alert_rules_v2"
}
// AlertCondition 告警条件定义
type AlertCondition struct {
Metric string `json:"metric"` // 指标名称
Operator Operator `json:"operator"` // 比较运算符
Threshold float64 `json:"threshold"` // 阈值
Unit string `json:"unit"` // 单位
Index string `json:"index"` // 实例索引
LogicOp string `json:"logic_op"` // 逻辑关系: AND/OR
AggFunc AggFunction `json:"agg_func"` // 条件级聚合函数
Window int `json:"window"` // 条件级聚合窗口
}
// TableName 指定表名
func (AlertCondition) TableName() string {
return "monitor_alert_conditions"
}
// AlertConditionGroup 告警条件组
type AlertConditionGroup struct {
Conditions []AlertCondition `json:"conditions"` // 条件列表
LogicOp string `json:"logic_op"` // 组间逻辑: AND/OR
}
// RecoverConfig 告警恢复配置
type RecoverConfig struct {
Count int `json:"count"` // 连续恢复次数
Window int `json:"window"` // 恢复检查窗口(秒)
}
// NotifyMode 通知模式
type NotifyMode string
const (
NotifyModeEvery NotifyMode = "every" // 每次都通知
NotifyModeOnce NotifyMode = "once" // 仅通知一次
NotifyModeUpgrade NotifyMode = "upgrade" // 升级通知
)
// ============================================
// AlertNotifyTemplate 告警通知模板
// ============================================
// AlertNotifyTemplate 告警通知模板表
// 定义告警通知的内容模板,支持变量替换
type AlertNotifyTemplate struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"`
// 模板内容
TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"`
BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"`
// 变量定义
Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"`
// 恢复通知模板
RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"`
RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"`
IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertNotifyTemplate) TableName() string {
return "monitor_alert_notify_templates"
}
// TemplateVariable 模板变量定义
type TemplateVariable struct {
Name string `json:"name"` // 变量名
Description string `json:"description"` // 描述
Example string `json:"example"` // 示例
}
// DefaultTemplateVariables 默认模板变量
var DefaultTemplateVariables = []TemplateVariable{
{Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"},
{Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"},
{Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"},
{Name: "{{.Severity}}", Description: "告警级别", Example: "critical"},
{Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"},
{Name: "{{.Value}}", Description: "当前值", Example: "95.2"},
{Name: "{{.Threshold}}", Description: "阈值", Example: "90"},
{Name: "{{.Operator}}", Description: "运算符", Example: ">"},
{Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"},
{Name: "{{.Duration}}", Description: "持续时间", Example: "5m"},
{Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"},
{Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"},
}
// ============================================
// AlertUpgrade 告警升级策略
// ============================================
// AlertUpgrade 告警升级策略表
// 定义告警在不同阶段的升级策略
type AlertUpgrade struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
// 升级规则(JSON存储支持多级升级)
Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"`
// 执行配置
MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"`
IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertUpgrade) TableName() string {
return "monitor_alert_upgrades"
}
// AlertUpgradeRule 告警升级规则
type AlertUpgradeRule struct {
Level int `json:"level"` // 升级级别
Duration int `json:"duration"` // 触发升级的持续时长(秒)
Severity string `json:"severity"` // 升级后的级别
ChannelIDs []string `json:"channel_ids"` // 升级后通知渠道
NotifyUsers []uint `json:"notify_users"` // 升级后通知用户
RepeatInterval int `json:"repeat_interval"` // 重复通知间隔(秒)
}
// AlertUpgradeRecord 告警升级记录
type AlertUpgradeRecord struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"`
RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"`
FromLevel int `gorm:"comment:原级别" json:"from_level"`
ToLevel int `gorm:"comment:升级后级别" json:"to_level"`
UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
}
// TableName 指定表名
func (AlertUpgradeRecord) TableName() string {
return "monitor_alert_upgrade_records"
}
// ============================================
// AlertSilence 告警静默规则
// ============================================
// AlertSilenceType 静默类型
type AlertSilenceType string
const (
AlertSilenceOneTime AlertSilenceType = "onetime" // 一次性静默
AlertSilenceDaily AlertSilenceType = "daily" // 每日重复
AlertSilenceWeekly AlertSilenceType = "weekly" // 每周重复
)
// AlertSilence 告警静默规则表
// 定义告警静默策略,支持一次性、周期性静默
type AlertSilence struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
// 匹配条件
RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"`
TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"`
Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"`
// 时间配置
SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"`
StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"`
Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"`
// 周期性配置
Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"`
// 状态
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertSilence) TableName() string {
return "monitor_alert_silences"
}
// AlertSilenceMatcher 静默匹配条件
type AlertSilenceMatcher struct {
Name string `json:"name"` // 标签名
Value string `json:"value"` // 标签值
IsRegex bool `json:"is_regex"` // 是否正则匹配
}
// AlertSilenceCheckResult 静默检查结果
type AlertSilenceCheckResult struct {
IsSilenced bool `json:"is_silenced"`
SilenceID uint `json:"silence_id,omitempty"`
SilenceName string `json:"silence_name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ============================================
// AlertEventV2 增强告警事件
// ============================================
// AlertEventV2Status 告警事件状态
type AlertEventV2Status string
const (
AlertEventV2Firing AlertEventV2Status = "firing" // 触发中
AlertEventV2Resolved AlertEventV2Status = "resolved" // 已恢复
AlertEventV2Silenced AlertEventV2Status = "silenced" // 已静默
)
// AlertEventV2 增强告警事件表
// 记录告警触发和恢复的全过程
type AlertEventV2 struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"`
TargetID uint `gorm:"not null;index;comment:目标ID" json:"target_id"`
TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"`
TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"`
Severity AlertSeverity `gorm:"type:varchar(16);not null;index;comment:告警级别" json:"severity"`
Status AlertEventV2Status `gorm:"type:varchar(16);default:'firing';index;comment:事件状态" json:"status"`
Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"`
Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"`
// 触发值信息
MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"`
Value float64 `gorm:"comment:触发值" json:"value,omitempty"`
Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"`
Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"`
Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"`
// 标签和注释
Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
// 时间信息
StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"`
ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"`
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"`
NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"`
LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"`
UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level"`
// 确认信息
Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"`
AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"`
AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 指定表名
func (AlertEventV2) TableName() string {
return "monitor_alert_events_v2"
}
// AlertRuleV2CreateRequest 告警规则创建请求
type AlertRuleV2CreateRequest struct {
Name string `json:"name" binding:"required,max=128"`
Description string `json:"description"`
Severity AlertSeverity `json:"severity" binding:"required"`
TargetType string `json:"target_type"`
TargetID uint `json:"target_id"`
TargetTags string `json:"target_tags"`
Conditions []AlertCondition `json:"conditions" binding:"required,min=1"`
AggWindow int `json:"agg_window"`
AggFunction AggFunction `json:"agg_function"`
RecoverCount int `json:"recover_count"`
Interval int `json:"interval"`
CheckTimes int `json:"check_times"`
NotifyMode string `json:"notify_mode"`
ChannelIDs []string `json:"channel_ids"`
TemplateID uint `json:"template_id"`
UpgradeID uint `json:"upgrade_id"`
SilenceID uint `json:"silence_id"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
// AlertRuleV2UpdateRequest 告警规则更新请求
type AlertRuleV2UpdateRequest struct {
Name string `json:"name" binding:"max=128"`
Description string `json:"description"`
Severity AlertSeverity `json:"severity"`
Status AlertRuleV2Status `json:"status"`
TargetType string `json:"target_type"`
TargetID uint `json:"target_id"`
TargetTags string `json:"target_tags"`
Conditions []AlertCondition `json:"conditions"`
AggWindow int `json:"agg_window"`
AggFunction AggFunction `json:"agg_function"`
RecoverCount int `json:"recover_count"`
Interval int `json:"interval"`
CheckTimes int `json:"check_times"`
NotifyMode string `json:"notify_mode"`
ChannelIDs []string `json:"channel_ids"`
TemplateID uint `json:"template_id"`
UpgradeID uint `json:"upgrade_id"`
SilenceID uint `json:"silence_id"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
+107 -8
View File
@@ -10,6 +10,7 @@ type Pipeline struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` Name string `gorm:"size:128;not null" json:"name"`
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
JobName string `gorm:"size:128;uniqueIndex;comment:Job名称(webhook URL标识, 如 cex-admin-server)" json:"job_name,omitempty"`
Description string `gorm:"size:500" json:"description"` Description string `gorm:"size:500" json:"description"`
Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test
Source string `gorm:"size:255" json:"source"` // Git仓库地址 Source string `gorm:"size:255" json:"source"` // Git仓库地址
@@ -18,13 +19,51 @@ type Pipeline struct {
Stages string `gorm:"type:text" json:"stages"` // JSON Stages string `gorm:"type:text" json:"stages"` // JSON
Variables string `gorm:"type:text" json:"variables"` // JSON Variables string `gorm:"type:text" json:"variables"` // JSON
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus string `gorm:"size:32" json:"last_status"` // 部署模式
RunCount int `gorm:"default:0" json:"run_count"` DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"`
SuccessCount int `gorm:"default:0" json:"success_count"` DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"`
FailCount int `gorm:"default:0" json:"fail_count"`
CreatedAt time.Time `json:"created_at"` // SSH 部署目标服务器 (JSON: [1,2,3])
UpdatedAt time.Time `json:"updated_at"` ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
// K8s 部署目标
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters
K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"`
// 仓库和镜像仓库关联(旧字段,待废弃)
RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos
RegistryID *uint `json:"registry_id"` // FK → deploy_pipeline_registries
// 凭证关联(直接关联凭证 ID)
GitCredentialID *uint `json:"git_credential_id"` // FK → iam_credentials (Git Token)
RegistryCredentialID *uint `json:"registry_credential_id"` // FK → iam_credentials (Docker Password)
// 环境分组
EnvironmentID *uint `json:"environment_id"` // FK → deploy_environments
// 触发配置
TriggerMode string `gorm:"type:varchar(32);default:'manual';comment:触发方式: git_push/cron/webhook/manual" json:"trigger_mode"`
WatchBranches string `gorm:"type:varchar(256);comment:监听分支(逗号分隔, 支持通配)" json:"watch_branches,omitempty"`
ExcludeBranch string `gorm:"type:varchar(256);comment:排除分支" json:"exclude_branch,omitempty"`
CronExpr string `gorm:"type:varchar(64);comment:Cron 表达式" json:"cron_expr,omitempty"`
WebhookToken string `gorm:"type:varchar(128);comment:Webhook 签名 Token" json:"webhook_token,omitempty"`
// 缓存配置
CachePaths string `gorm:"type:text;comment:缓存路径(逗号分隔)" json:"cache_paths,omitempty"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus string `gorm:"size:32" json:"last_status"`
RunCount int `gorm:"default:0" json:"run_count"`
SuccessCount int `gorm:"default:0" json:"success_count"`
FailCount int `gorm:"default:0" json:"fail_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 关联
Repo *PipelineRepo `json:"repo,omitempty" gorm:"foreignKey:RepoID"`
Registry *PipelineRegistry `json:"registry,omitempty" gorm:"foreignKey:RegistryID"`
Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"`
} }
// TableName 表名 // TableName 表名
@@ -44,6 +83,7 @@ type PipelineRun struct {
Author string `gorm:"size:64" json:"author"` Author string `gorm:"size:64" json:"author"`
ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签
ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址
WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径
Duration int `json:"duration"` // 秒 Duration int `json:"duration"` // 秒
Stages string `gorm:"type:text" json:"stages"` // JSON Stages string `gorm:"type:text" json:"stages"` // JSON
Logs string `gorm:"type:longtext" json:"logs"` Logs string `gorm:"type:longtext" json:"logs"`
@@ -98,6 +138,7 @@ func (AppRelease) TableName() string {
type CreatePipelineRequest struct { type CreatePipelineRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
JobName string `json:"job_name"` // Webhook Job名称
Description string `json:"description"` Description string `json:"description"`
Type string `json:"type" binding:"omitempty,oneof=build deploy test"` Type string `json:"type" binding:"omitempty,oneof=build deploy test"`
Source string `json:"source"` Source string `json:"source"`
@@ -105,11 +146,69 @@ type CreatePipelineRequest struct {
Dockerfile string `json:"dockerfile"` Dockerfile string `json:"dockerfile"`
Stages string `json:"stages"` Stages string `json:"stages"`
Variables string `json:"variables"` Variables string `json:"variables"`
// 部署模式
DeployMode string `json:"deploy_mode"` // branch / tag
DeployTarget string `json:"deploy_target"` // ssh / k8s
ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3]
K8sClusterID *uint `json:"k8s_cluster_id"`
K8sNamespace string `json:"k8s_namespace"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
// 触发配置
TriggerMode string `json:"trigger_mode"`
WatchBranches string `json:"watch_branches"`
ExcludeBranch string `json:"exclude_branch"`
CronExpr string `json:"cron_expr"`
WebhookToken string `json:"webhook_token"`
// 缓存路径
CachePaths string `json:"cache_paths"`
}
// UpdatePipelineRequest 更新流水线请求
type UpdatePipelineRequest struct {
Name string `json:"name"`
JobName string `json:"job_name"`
Description string `json:"description"`
Type string `json:"type"`
Source string `json:"source"`
Branch string `json:"branch"`
Dockerfile string `json:"dockerfile"`
Stages string `json:"stages"`
Variables string `json:"variables"`
DeployMode string `json:"deploy_mode"`
DeployTarget string `json:"deploy_target"`
ServerIDs string `json:"server_ids"`
K8sClusterID *uint `json:"k8s_cluster_id"`
K8sNamespace string `json:"k8s_namespace"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
// 触发配置
TriggerMode string `json:"trigger_mode"`
WatchBranches string `json:"watch_branches"`
ExcludeBranch string `json:"exclude_branch"`
CronExpr string `json:"cron_expr"`
WebhookToken string `json:"webhook_token"`
// 缓存
CachePaths string `json:"cache_paths"`
Enabled *bool `json:"enabled"`
} }
// TriggerPipelineRequest 触发流水线请求 // TriggerPipelineRequest 触发流水线请求
type TriggerPipelineRequest struct { type TriggerPipelineRequest struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Tag string `json:"tag"` // 发布 Tag
Variables map[string]string `json:"variables"` Variables map[string]string `json:"variables"`
} }
+183 -29
View File
@@ -4,44 +4,198 @@ import (
"time" "time"
) )
// Credential 凭据管理模型(SSH密钥/API Token/密码等统一管理) // Credential 凭据管理模型 — 统一管理 SSH/Docker/Token/AKSK/数据库/API 等敏感信息
// 设计参考:opsx-web Secret 模型,按类型动态切换字段
// 核心加密字段 content 存储类型特有数据(JSON),API 不返回
//
// 类型与内容 JSON 映射:
// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
// docker → {"server":"...","username":"...","password":"..."}
// tls → {"cert":"...","key":"...","ca":"..."}
// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
// api → {"key":"...","endpoint":"..."}
// generic → {"key":"...","value":"..."}
type Credential struct { type Credential struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称 Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
Type string `gorm:"size:32;not null" json:"type"` // ssh_key, api_token, password, access_key VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
Content string `gorm:"type:text;not null" json:"-"` // AES加密后的内容,API不返回 SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/docker/tls/aksk/database/api/generic
Description string `gorm:"size:500" json:"description"` Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
CreatedAt time.Time `json:"created_at"` Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
UpdatedAt time.Time `json:"updated_at"` Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
Description string `gorm:"size:500" json:"description,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (Credential) TableName() string { func (Credential) TableName() string {
return "iam_credentials" return "iam_credentials"
} }
// CredentialCreateRequest 创建凭据请求 // ────── 类型定义 ──────
type CredentialCreateRequest struct {
Name string `json:"name" binding:"required"` // SecretType SSH 凭据 content
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"` type SSHSecretContent struct {
Content string `json:"content" binding:"required"` // 明文,后端加密存储 PrivateKey string `json:"private_key"`
Description string `json:"description"` PublicKey string `json:"public_key"`
SSHUser string `json:"ssh_user"`
SSHPort int `json:"ssh_port"`
Password string `json:"password,omitempty"`
} }
// CredentialUpdateRequest 更新凭据请求 // DockerSecretContent Docker 凭据 content
type CredentialUpdateRequest struct { type DockerSecretContent struct {
Name string `json:"name" binding:"required"` Server string `json:"server"`
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"` Username string `json:"username"`
Content string `json:"content"` // 空则不更新内容 Password string `json:"password"`
Description string `json:"description"`
} }
// CredentialListResponse 列表返回(内容脱敏) // TLSSecretContent TLS 证书凭据 content
type CredentialListResponse struct { type TLSSecretContent struct {
ID uint `json:"id"` Cert string `json:"cert"`
Name string `json:"name"` Key string `json:"key"`
Type string `json:"type"` CA string `json:"ca"`
Preview string `json:"preview"` // 内容预览(前8+后4字符) }
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"` // AKSKSecretContent AKSK 凭据 content
UpdatedAt time.Time `json:"updated_at"` type AKSKSecretContent struct {
Provider string `json:"provider"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region,omitempty"`
}
// DatabaseSecretContent 数据库凭据 content
type DatabaseSecretContent struct {
DBType string `json:"db_type"`
Host string `json:"host"`
Port int `json:"port"`
DBName string `json:"db_name"`
Username string `json:"username"`
Password string `json:"password"`
}
// APISecretContent API 密钥凭据 content
type APISecretContent struct {
Key string `json:"api_key"`
Endpoint string `json:"endpoint,omitempty"`
}
// GenericSecretContent 通用凭据 content
type GenericSecretContent struct {
Key string `json:"generic_key"`
Value string `json:"generic_value"`
}
// ────── 请求/响应 ──────
// CreateSecretRequest 创建凭据请求
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
VariableName string `json:"variable_name"`
SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"`
Tags []string `json:"tags"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive *bool `json:"is_active"`
// 类型特有字段(明文,后端组装成 JSON 加密存储)
// SSH
SSHPrivateKey string `json:"ssh_private_key,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
SSHUser string `json:"ssh_user,omitempty"`
SSHPort int `json:"ssh_port,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
// Docker
DockerServer string `json:"docker_server,omitempty"`
DockerUsername string `json:"docker_username,omitempty"`
DockerPassword string `json:"docker_password,omitempty"`
// TLS
TLSCert string `json:"tls_cert,omitempty"`
TLSKey string `json:"tls_key,omitempty"`
TLSCA string `json:"tls_ca,omitempty"`
// AKSK
AKSKProvider string `json:"aksk_provider,omitempty"`
AKSKAccessKey string `json:"aksk_access_key,omitempty"`
AKSKSecretKey string `json:"aksk_secret_key,omitempty"`
AKSKRegion string `json:"aksk_region,omitempty"`
// Database
DBType string `json:"db_type,omitempty"`
DBHost string `json:"db_host,omitempty"`
DBPort int `json:"db_port,omitempty"`
DBName string `json:"db_name,omitempty"`
DBUsername string `json:"db_username,omitempty"`
DBPassword string `json:"db_password,omitempty"`
// API
APIKey string `json:"api_key,omitempty"`
APIEndpoint string `json:"api_endpoint,omitempty"`
// Generic
GenericKey string `json:"generic_key,omitempty"`
GenericValue string `json:"generic_value,omitempty"`
}
// UpdateSecretRequest 更新凭据请求
type UpdateSecretRequest struct {
Name *string `json:"name"`
VariableName *string `json:"variable_name"`
Tags []string `json:"tags"`
Description *string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive *bool `json:"is_active"`
// 类型特有字段 — 传了才更新
SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
SSHPublicKey *string `json:"ssh_public_key,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SSHPort *int `json:"ssh_port,omitempty"`
SSHPassword *string `json:"ssh_password,omitempty"`
DockerServer *string `json:"docker_server,omitempty"`
DockerUsername *string `json:"docker_username,omitempty"`
DockerPassword *string `json:"docker_password,omitempty"`
TLSCert *string `json:"tls_cert,omitempty"`
TLSKey *string `json:"tls_key,omitempty"`
TLSCA *string `json:"tls_ca,omitempty"`
AKSKProvider *string `json:"aksk_provider,omitempty"`
AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
AKSKRegion *string `json:"aksk_region,omitempty"`
DBType *string `json:"db_type,omitempty"`
DBHost *string `json:"db_host,omitempty"`
DBPort *int `json:"db_port,omitempty"`
DBName *string `json:"db_name,omitempty"`
DBUsername *string `json:"db_username,omitempty"`
DBPassword *string `json:"db_password,omitempty"`
APIKey *string `json:"api_key,omitempty"`
APIEndpoint *string `json:"api_endpoint,omitempty"`
GenericKey *string `json:"generic_key,omitempty"`
GenericValue *string `json:"generic_value,omitempty"`
}
// SecretListResponse 列表返回(内容脱敏)
type SecretListResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
VariableName string `json:"variable_name,omitempty"`
SecretType string `json:"secret_type"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
EnvPrefix string `json:"env_prefix,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SecretListQuery 列表查询参数
type SecretListQuery struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
SecretType string `form:"secret_type"`
Tags string `form:"tags"` // 逗号分隔
Search string `form:"search"`
} }
+193
View File
@@ -0,0 +1,193 @@
package models
import "time"
// ============================================
// PipelineRepo Git 仓库认证配置
// ============================================
// RepoAuthType 仓库认证方式
type RepoAuthType string
const (
RepoAuthToken RepoAuthType = "token" // Personal Access Token
RepoAuthSSH RepoAuthType = "ssh" // SSH Key
RepoAuthPassword RepoAuthType = "password" // 用户名+密码
)
// PipelineRepo Git 仓库认证配置表
type PipelineRepo struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
URL string `gorm:"type:varchar(512);not null;comment:仓库 URL" json:"url"`
AuthType RepoAuthType `gorm:"type:varchar(16);default:'token';comment:认证方式: token/ssh/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRepo) TableName() string {
return "deploy_pipeline_repos"
}
// ============================================
// PipelineRegistry 镜像仓库认证配置
// ============================================
// RegistryAuthType 镜像仓库认证方式
type RegistryAuthType string
const (
RegistryAuthToken RegistryAuthType = "token" // Access Token
RegistryAuthPassword RegistryAuthType = "password" // 用户名+密码
)
// PipelineRegistry 镜像仓库认证配置表
type PipelineRegistry struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
RegistryURL string `gorm:"type:varchar(512);not null;comment:镜像仓库地址" json:"registry_url"`
Namespace string `gorm:"type:varchar(128);comment:默认命名空间" json:"namespace,omitempty"`
AuthType RegistryAuthType `gorm:"type:varchar(16);default:'password';comment:认证方式: token/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRegistry) TableName() string {
return "deploy_pipeline_registries"
}
// ============================================
// PipelineStage 流水线阶段定义(JSON)
// ============================================
// PipelineStage 一个阶段包含多个任务
type PipelineStage struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // source/build/deploy/test/custom
Tasks []PipelineTask `json:"tasks"`
}
// PipelineTask 阶段内的任务
type PipelineTask struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // build/deploy/test/custom
Icon string `json:"icon,omitempty"`
Config map[string]string `json:"config,omitempty"`
Script string `json:"script,omitempty"`
ScriptType string `json:"script_type,omitempty"` // shell/python
}
// ============================================
// DeployEnvironment 发布环境分组
// ============================================
type EnvironmentType string
const (
EnvProduction EnvironmentType = "production"
EnvStaging EnvironmentType = "staging"
EnvTesting EnvironmentType = "testing"
EnvDev EnvironmentType = "development"
)
// DeployEnvironment 环境分组表(生产/测试/预发布/开发)
type DeployEnvironment struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(64);not null;uniqueIndex;comment:环境名称" json:"name"`
DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"`
Type EnvironmentType `gorm:"type:varchar(16);not null;index;comment:环境类型: production/staging/testing/development" json:"type"`
Color string `gorm:"type:varchar(16);default:'#3B82F6';comment:标识颜色" json:"color"`
SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
func (DeployEnvironment) TableName() string {
return "deploy_environments"
}
// ============================================
// DeployPipelineMember 流水线成员
// ============================================
type MemberRole string
const (
MemberOwner MemberRole = "owner" // 拥有者
MemberEditor MemberRole = "editor" // 编辑者
MemberViewer MemberRole = "viewer" // 查看者
)
// DeployPipelineMember 流水线成员表
// 支持两种授权方式:直接用户授权(username 非空)和用户组授权(group_id 非空)
type DeployPipelineMember struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
PipelineID uint `gorm:"index:idx_pipeline_user,unique,priority:1;index:idx_pipeline_group,unique,priority:1;not null;comment:流水线ID" json:"pipeline_id"`
Username *string `gorm:"type:varchar(64);index:idx_pipeline_user,unique,priority:2;comment:用户名(直接授权)" json:"username,omitempty"`
GroupID *uint `gorm:"index:idx_pipeline_group,unique,priority:2;comment:用户组ID(组授权)" json:"group_id,omitempty"`
Role MemberRole `gorm:"type:varchar(16);default:'editor';comment:角色: owner/editor/viewer" json:"role"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
// 关联
Pipeline *Pipeline `json:"pipeline,omitempty" gorm:"foreignKey:PipelineID"`
Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployPipelineMember) TableName() string {
return "deploy_pipeline_members"
}
// ============================================
// DeployUserGroup 发布系统用户组
// ============================================
// DeployUserGroup 用户组(用于流水线权限分组授权)
type DeployUserGroup struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;comment:组名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
// 关联
Members []DeployUserGroupMember `json:"members,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployUserGroup) TableName() string {
return "deploy_user_groups"
}
// ============================================
// DeployUserGroupMember 用户组成员
// ============================================
// DeployUserGroupMember 用户组成员关联表
type DeployUserGroupMember struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
GroupID uint `gorm:"uniqueIndex:idx_group_user;not null;comment:组ID" json:"group_id"`
Username string `gorm:"type:varchar(64);uniqueIndex:idx_group_user;not null;comment:用户名" json:"username"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
// 关联
Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployUserGroupMember) TableName() string {
return "deploy_user_group_members"
}
+63 -4
View File
@@ -69,6 +69,40 @@ func (Permission) TableName() string {
return "iam_permissions" return "iam_permissions"
} }
// ItemRole Jenkins-style Item Role — regex pipeline code matching
type ItemRole struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
Pattern string `gorm:"size:255;not null;comment:正则表达式匹配流水线code" json:"pattern"`
Role string `gorm:"size:16;default:'editor';comment:角色owner/editor/viewer" json:"role"`
Description string `gorm:"size:255" json:"description"`
Status int `gorm:"default:1;comment:0禁用1启用" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (ItemRole) TableName() string {
return "iam_item_roles"
}
// UserItemRole user ↔ item_role association
type UserItemRole struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"user_id"`
ItemRoleID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"item_role_id"`
CreatedAt time.Time `json:"created_at"`
User *User `json:"user,omitempty" gorm:"foreignKey:UserID"`
ItemRole *ItemRole `json:"item_role,omitempty" gorm:"foreignKey:ItemRoleID"`
}
// TableName 表名
func (UserItemRole) TableName() string {
return "iam_user_item_roles"
}
// LoginRequest 登录请求 // LoginRequest 登录请求
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
@@ -111,11 +145,36 @@ type CreateRoleRequest struct {
// UpdateRoleRequest 更新角色请求 // Logan.Li // UpdateRoleRequest 更新角色请求 // Logan.Li
type UpdateRoleRequest struct { type UpdateRoleRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=64"` Name string `json:"name" binding:"omitempty,min=1,max=64"`
Code string `json:"code" binding:"omitempty,min=1,max=64"` Code string `json:"code" binding:"omitempty,min=1,max=64"`
Description *string `json:"description"` Description *string `json:"description"`
Status *int `json:"status"` Status *int `json:"status"`
PermissionIDs []uint `json:"permission_ids"` PermissionIDs []uint `json:"permission_ids"`
}
// CreateItemRoleRequest 创建Item Role请求
type CreateItemRoleRequest struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Code string `json:"code" binding:"required,min=1,max=64"`
Pattern string `json:"pattern" binding:"required,max=255"`
Role string `json:"role" binding:"oneof=owner editor viewer"`
Description string `json:"description"`
}
// UpdateItemRoleRequest 更新Item Role请求
type UpdateItemRoleRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=64"`
Code string `json:"code" binding:"omitempty,min=1,max=64"`
Pattern string `json:"pattern" binding:"omitempty,max=255"`
Role string `json:"role" binding:"omitempty,oneof=owner editor viewer"`
Description string `json:"description"`
Status *int `json:"status"`
}
// AssignItemRoleRequest 分配Item Role给用户
type AssignItemRoleRequest struct {
UserID uint `json:"user_id" binding:"required"`
ItemRoleID uint `json:"item_role_id" binding:"required"`
} }
// ChangePasswordRequest 修改密码请求 // ChangePasswordRequest 修改密码请求
+13
View File
@@ -119,6 +119,19 @@ func AutoMigrate(db *gorm.DB) error {
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li &models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
&models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li &models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
&models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li &models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
&models.PipelineRepo{}, // Git 仓库认证 // Logan.Li
&models.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
&models.DeployEnvironment{}, // 发布环境分组 // Logan.Li
&models.DeployPipelineMember{}, // 流水线成员 // Logan.Li
&models.DeployUserGroup{}, // 用户组 // Logan.Li
&models.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
&models.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
&models.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
&models.AlertCondition{}, // 告警条件 // Logan.Li
&models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
&models.AlertUpgrade{}, // 告警升级策略 // Logan.Li
&models.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
&models.AlertSilence{}, // 告警静默 // Logan.Li
} }
var lastErr error var lastErr error
+4
View File
@@ -93,3 +93,7 @@ func (r *goRedisCache) ZRangeByScore(ctx context.Context, key string, min, max s
func (r *goRedisCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error { func (r *goRedisCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error {
return r.client.ZRemRangeByScore(ctx, key, min, max).Err() return r.client.ZRemRangeByScore(ctx, key, min, max).Err()
} }
func (r *goRedisCache) Publish(ctx context.Context, channel string, message string) error {
return r.client.Publish(ctx, channel, message).Err()
}
+3
View File
@@ -50,6 +50,9 @@ type Cache interface {
Ping(ctx context.Context) error Ping(ctx context.Context) error
Close() error Close() error
// Pub/Sub(实时日志推送)
Publish(ctx context.Context, channel string, message string) error
// 有序集合操作(热数据指标存储,仅 go-redis 后端完整支持) // 有序集合操作(热数据指标存储,仅 go-redis 后端完整支持)
ZAdd(ctx context.Context, key string, member SortedSetMember) error ZAdd(ctx context.Context, key string, member SortedSetMember) error
ZRangeByScore(ctx context.Context, key string, min, max string) ([]SortedSetMember, error) ZRangeByScore(ctx context.Context, key string, min, max string) ([]SortedSetMember, error)
+6
View File
@@ -111,3 +111,9 @@ func (r *redkaCache) ZRangeByScore(ctx context.Context, key string, min, max str
func (r *redkaCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error { func (r *redkaCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error {
return errSortedSetNotSupported return errSortedSetNotSupported
} }
var errPubSubNotSupported = fmt.Errorf("Pub/Sub is not supported by the redka backend; use go-redis (driver=redis)")
func (r *redkaCache) Publish(ctx context.Context, channel string, message string) error {
return errPubSubNotSupported
}
Executable
BIN
View File
Binary file not shown.
+1
View File
@@ -62,6 +62,7 @@ services:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./backend/config.yaml:/app/config.yaml - ./backend/config.yaml:/app/config.yaml
- /var/run/docker.sock:/var/run/docker.sock
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
+12 -5
View File
@@ -24,13 +24,16 @@ import PodMetricsPage from '@/components/kubernetes/PodMetricsPage';
import EventsPage from '@/components/kubernetes/EventsPage'; import EventsPage from '@/components/kubernetes/EventsPage';
import CapacityPage from '@/components/kubernetes/CapacityPage'; import CapacityPage from '@/components/kubernetes/CapacityPage';
import { AlertManager } from '@/components/monitor/AlertManager'; import { AlertManager } from '@/components/monitor/AlertManager';
import { AppReleaseManager } from '@/components/deploy/AppRelease';
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
import { ScriptManager } from '@/components/automation/ScriptManager'; import { ScriptManager } from '@/components/automation/ScriptManager';
import { SystemManager } from '@/components/system/SystemManager'; import { SystemManager } from '@/components/system/SystemManager';
import { CredentialManager } from '@/components/system/CredentialManager'; import CredentialManager from '@/components/deploy/CredentialManager';
import { PipelineManager } from '@/components/deploy/PipelineManager'; import { PipelineManager } from '@/components/deploy/PipelineManager';
import { ReleaseManager } from '@/components/deploy/ReleaseManager'; import DeployCredentials from '@/components/deploy/DeployCredentials';
import PipelineEditor from '@/components/deploy/PipelineEditor';
import PipelineRunDetail from '@/components/deploy/PipelineRunDetail';
import DeployRBAC from '@/components/deploy/DeployRBAC';
import UserGroupsManager from '@/components/deploy/UserGroupsManager';
import { CronJobManager } from '@/components/automation/CronJobManager'; import { CronJobManager } from '@/components/automation/CronJobManager';
import AnsibleManager from '@/components/automation/AnsibleManager'; import AnsibleManager from '@/components/automation/AnsibleManager';
import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow'; import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow';
@@ -136,8 +139,12 @@ function App() {
<Route path="topology" element={<TopologyView />} /> <Route path="topology" element={<TopologyView />} />
{/* 应用发布 */} {/* 应用发布 */}
<Route path="pipelines" element={<PipelineManager />} /> <Route path="pipelines" element={<PipelineManager />} />
<Route path="releases" element={<ReleaseManager />} /> <Route path="pipelines/:code" element={<PipelineEditor />} />
<Route path="deploy" element={<AppReleaseManager />} /> <Route path="pipelines/:code/runs" element={<PipelineRunDetail />} />
<Route path="pipelines/:code/edit" element={<PipelineEditor />} />
<Route path="deploy/credentials" element={<DeployCredentials />} />
<Route path="deploy/groups" element={<UserGroupsManager />} />
<Route path="deploy/rbac" element={<DeployRBAC />} />
{/* 监控告警 */} {/* 监控告警 */}
<Route path="metrics" element={<MetricsExplorer />} /> <Route path="metrics" element={<MetricsExplorer />} />
<Route path="datasources" element={<DatasourcePage />} /> <Route path="datasources" element={<DatasourcePage />} />
@@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Key, Globe, Container } from 'lucide-react';
import { toast } from 'sonner';
import { deployApi } from '@/services/api';
import http from '@/services/http';
interface Repo {
id: number; name: string; url: string; credential_id: number | null;
}
interface Registry {
id: number; name: string; registry_url: string; namespace: string; credential_id: number | null;
}
interface Credential {
id: number; name: string; secret_type: string; summary: string;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
type: 'repo' | 'registry';
editId: number | null;
repos: Repo[];
registries: Registry[];
onSave: () => void;
}
export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) {
const isRepo = type === 'repo';
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [url, setUrl] = useState('');
const [namespace, setNamespace] = useState('');
const [credentialId, setCredentialId] = useState<number | string>('');
const [credentials, setCredentials] = useState<Credential[]>([]);
const [saving, setSaving] = useState(false);
// Docker 仓库 → docker 类型;Git 仓库 → api 或 docker 类型
const filteredCredentials = credentials.filter(c =>
isRepo ? true : c.secret_type === 'docker'
);
const loadCredentials = async () => {
try {
const res: any = await http.get('/credentials?page_size=100');
setCredentials(res?.list || []);
} catch { /* silent */ }
};
useEffect(() => {
loadCredentials();
if (editId) {
if (isRepo) {
const r = repos.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.url); setCredentialId(r.credential_id || ''); }
} else {
const r = registries.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.registry_url); setNamespace(r.namespace || ''); setCredentialId(r.credential_id || ''); }
}
} else {
setName(''); setDesc(''); setUrl(''); setNamespace(''); setCredentialId('');
}
}, [editId, open, isRepo]);
const handleSave = async () => {
if (!name.trim() || !url.trim()) { toast.error('请填写名称和地址'); return; }
setSaving(true);
try {
const cid = credentialId ? Number(credentialId) : null;
const payload = isRepo
? { name: name.trim(), description: desc.trim(), url: url.trim(), credential_id: cid }
: { name: name.trim(), description: desc.trim(), registry_url: url.trim(), namespace: namespace.trim(), credential_id: cid };
if (editId) {
if (isRepo) await deployApi.updateRepo(editId, payload);
else await deployApi.updateRegistry(editId, payload);
toast.success('已更新');
} else {
if (isRepo) await deployApi.createRepo(payload);
else await deployApi.createRegistry(payload);
toast.success('已创建');
}
onOpenChange(false);
onSave();
} catch { toast.error('保存失败'); }
finally { setSaving(false); }
};
const CredIcon = isRepo ? Globe : Container;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<CredIcon className="h-5 w-5 text-[var(--dg-blue)]" />
{editId ? '编辑' : '新增'}{isRepo ? ' Git 仓库' : ' 镜像仓库'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1"><Label>名称 *</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? '如:GitLab CE' : '如:Harbor'} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
<div className="space-y-1"><Label>{isRepo ? '仓库 URL *' : 'Registry URL *'}</Label>
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
{!isRepo && <div className="space-y-1"><Label>默认命名空间</Label>
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder="如:aiops" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>}
{/* 关联凭证 */}
<div className="space-y-1">
<Label>关联凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">(认证由凭据管理统一配置)</span></Label>
<Select value={String(credentialId)} onValueChange={v => setCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={isRepo ? '选择凭据(API/密码等)' : '选择 Docker 密码凭据'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">不关联凭证</SelectItem>
{filteredCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5">
<Key className="h-3 w-3 opacity-60" />
{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{filteredCredentials.length === 0 && (
<p className="text-xs text-amber-400 flex items-center gap-1 mt-1">
<Plus className="h-3 w-3" />
暂无{isRepo ? '' : 'Docker 密码'}类型凭证,请在凭证配置页面新增
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '保存中...' : '保存'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,822 @@
import { useState, useEffect, useCallback } from 'react';
import {
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Globe, Search,
Lock, Eye, EyeOff, Copy, Server, Container, Database, FileText,
Cloud, Tag, Clock, AlertTriangle, CheckCircle2, XCircle, Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import http from '@/services/http';
// ────── Types ──────
interface Credential {
id: number;
name: string;
variable_name: string;
secret_type: SecretType;
summary: string;
tags: string[];
description: string;
expires_at: string | null;
is_active: boolean;
env_prefix: string;
created_at: string;
updated_at: string;
}
type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' },
};
const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
tls: 'bg-green-500/20 text-green-400 border-green-500/30',
aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
database: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
api: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
// ────── Detail Dialog ──────
function CredDetailDialog({ open, onClose, credId }: { open: boolean; onClose: () => void; credId: number | null }) {
const [detail, setDetail] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!credId || !open) return;
setLoading(true);
http.get(`/credentials/${credId}`).then((res: any) => {
setDetail(res);
}).catch(() => toast.error('加载详情失败')).finally(() => setLoading(false));
}, [credId, open]);
if (!open || !credId) return null;
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); };
const st = detail?.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const content = detail?.content || {};
const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: any; sensitive?: boolean; fieldKey?: string }) => {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : value}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleShow(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
{cfg ? <cfg.icon className={`h-5 w-5 ${cfg.color}`} /> : <Key className="h-5 w-5 text-[var(--dg-text-muted)]" />}
凭据详情
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
) : detail ? (
<div className="space-y-4">
{/* Basic info */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-[var(--dg-text-muted)]">名称:</span><span className="text-[var(--dg-text-primary)] font-medium">{detail.name}</span></div>
<div><span className="text-[var(--dg-text-muted)]">类型:</span>
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
</div>
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
{detail.is_active
? <Badge className="bg-green-500/20 text-green-400 text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 text-red-400 text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
{detail.expires_at && new Date(detail.expires_at) < new Date() && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue(detail.env_prefix || '')}>
<span className="text-[var(--dg-text-muted)]">引用ID:</span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detail.env_prefix || '无'}</code>
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
</div>
{detail.tags?.length > 0 && (
<div className="col-span-2">
<span className="text-[var(--dg-text-muted)]">标签:</span>
{detail.tags.map((t: string) => (
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
))}
</div>
)}
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">描述:</span><span className="text-[var(--dg-text-secondary)]">{detail.description || '无'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">创建:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.created_at).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]">更新:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.updated_at).toLocaleString()}</span></div>
</div>
{/* Secret data */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)] font-medium mb-3">
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs text-yellow-400/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
</div>
{st === 'ssh' && <>
<Field label="SSH 用户" value={content.ssh_user} />
<Field label="SSH 端口" value={content.ssh_port} />
<Field label="SSH 密码" value={content.password} sensitive fieldKey="ssh_password" />
<Field label="私钥" value={content.private_key} sensitive fieldKey="ssh_private_key" />
<Field label="公钥" value={content.public_key} fieldKey="ssh_public_key" />
</>}
{st === 'docker' && <>
<Field label="Registry 地址" value={content.server} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
</>}
{st === 'tls' && <>
<Field label="证书" value={content.cert} sensitive fieldKey="tls_cert" />
<Field label="私钥" value={content.key} sensitive fieldKey="tls_key" />
<Field label="CA 证书" value={content.ca} sensitive fieldKey="tls_ca" />
</>}
{st === 'aksk' && <>
<Field label="Access Key ID" value={content.access_key} />
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
</>}
{st === 'database' && <>
<Field label="数据库类型" value={(content.db_type || '').toUpperCase()} />
<Field label="主机" value={content.host} />
<Field label="端口" value={content.port} />
<Field label="数据库名" value={content.db_name} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="db_password" />
</>}
{st === 'api' && <>
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label="API 端点" value={content.endpoint} />
</>}
{st === 'generic' && <>
<Field label="Key" value={content.generic_key} />
<Field label="Value" value={content.generic_value} sensitive fieldKey="generic_value" />
</>}
</div>
{/* Usage hint */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">在流水线/脚本中引用此凭据:</div>
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
{'${'}{detail.variable_name || detail.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}{'}'}
</code>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">未找到凭据数据</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Form Dialog ──────
function CredFormDialog({ open, onClose, onSaved, editData }: {
open: boolean; onClose: () => void; onSaved: () => void; editData: Credential | null;
}) {
const isEdit = !!editData;
const [name, setName] = useState('');
const [varName, setVarName] = useState('');
const [secretType, setSecretType] = useState<SecretType>('ssh');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [isActive, setIsActive] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
// Type-specific fields
// SSH
const [sshUser, setSshUser] = useState('root');
const [sshPort, setSshPort] = useState('22');
const [sshPassword, setSshPassword] = useState('');
const [sshPrivateKey, setSshPrivateKey] = useState('');
const [sshPublicKey, setSshPublicKey] = useState('');
// Docker
const [dockerServer, setDockerServer] = useState('');
const [dockerUsername, setDockerUsername] = useState('');
const [dockerPassword, setDockerPassword] = useState('');
// TLS
const [tlsCert, setTlsCert] = useState('');
const [tlsKey, setTlsKey] = useState('');
const [tlsCa, setTlsCa] = useState('');
// AKSK
const [akskProvider, setAkskProvider] = useState('');
const [akskAccessKey, setAkskAccessKey] = useState('');
const [akskSecretKey, setAkskSecretKey] = useState('');
const [akskRegion, setAkskRegion] = useState('');
// Database
const [dbType, setDbType] = useState('mysql');
const [dbHost, setDbHost] = useState('');
const [dbPort, setDbPort] = useState('3306');
const [dbName, setDbName] = useState('');
const [dbUsername, setDbUsername] = useState('');
const [dbPassword, setDbPassword] = useState('');
// API
const [apiKey, setApiKey] = useState('');
const [apiEndpoint, setApiEndpoint] = useState('');
// Generic
const [genericKey, setGenericKey] = useState('');
const [genericValue, setGenericValue] = useState('');
useEffect(() => {
if (!open) return;
if (editData) {
setName(editData.name);
setVarName(editData.variable_name || '');
setSecretType(editData.secret_type);
setDescription(editData.description || '');
setTags((editData.tags || []).join(', '));
setIsActive(editData.is_active);
// Don't prefill type-specific fields — user can edit selectively
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
} else {
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
}
setShowMap({});
}, [editData, open]);
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const handleSubmit = async () => {
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
setSubmitting(true);
try {
const payload: any = {
name: name.trim(),
secret_type: secretType,
description: description.trim(),
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
is_active: isActive,
};
if (varName.trim()) payload.variable_name = varName.trim();
// Type-specific
if (secretType === 'ssh') {
if (sshUser.trim()) payload.ssh_user = sshUser.trim();
if (sshPort) payload.ssh_port = parseInt(sshPort) || 22;
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'docker') {
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
if (dockerPassword.trim()) payload.docker_password = dockerPassword.trim();
} else if (secretType === 'tls') {
if (tlsCert.trim()) payload.tls_cert = tlsCert.trim();
if (tlsKey.trim()) payload.tls_key = tlsKey.trim();
if (tlsCa.trim()) payload.tls_ca = tlsCa.trim();
} else if (secretType === 'aksk') {
if (akskAccessKey.trim()) payload.aksk_access_key = akskAccessKey.trim();
if (akskSecretKey.trim()) payload.aksk_secret_key = akskSecretKey.trim();
} else if (secretType === 'database') {
if (dbType.trim()) payload.db_type = dbType.trim();
if (dbHost.trim()) payload.db_host = dbHost.trim();
if (dbPort) payload.db_port = parseInt(dbPort) || 3306;
if (dbName.trim()) payload.db_name = dbName.trim();
if (dbUsername.trim()) payload.db_username = dbUsername.trim();
if (dbPassword.trim()) payload.db_password = dbPassword.trim();
} else if (secretType === 'api') {
if (apiKey.trim()) payload.api_key = apiKey.trim();
if (apiEndpoint.trim()) payload.api_endpoint = apiEndpoint.trim();
} else if (secretType === 'generic') {
if (genericKey.trim()) payload.generic_key = genericKey.trim();
if (genericValue.trim()) payload.generic_value = genericValue.trim();
}
if (isEdit) {
await http.put(`/credentials/${editData!.id}`, payload);
toast.success('已更新');
} else {
await http.post('/credentials', payload);
toast.success('已创建');
}
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message || '操作失败');
} finally { setSubmitting(false); }
};
const FieldWithToggle = ({ label, value, onChange, placeholder, fieldKey, required, isLong }: {
label: string; value: string; onChange: (v: string) => void; placeholder: string;
fieldKey: string; required?: boolean; isLong?: boolean;
}) => {
const show = showMap[fieldKey];
return (
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="text-red-400"> *</span>}</Label>
<div className="relative">
{isLong ? (
<textarea value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} rows={5}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-sm font-mono placeholder:text-[var(--dg-text-muted)]" />
) : (
<Input value={value} onChange={e => onChange(e.target.value)}
type={show ? 'text' : 'password'} placeholder={placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] pr-10 font-mono text-sm" />
)}
<button type="button" onClick={() => toggleShow(fieldKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] text-xs">
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
const TI = TYPE_CONFIG[secretType];
const TIicon = TI?.icon || Key;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[620px] max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<TIicon className={`h-5 w-5 ${TI?.color || ''}`} />
{isEdit ? '编辑凭据' : '新增凭据'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
{TI?.desc || '管理敏感凭据信息'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Basic */}
<div className="border-b border-[var(--dg-border)] pb-3">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="text-red-400">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">变量别名</Label>
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="text-red-400"> *</span>}</Label>
<Select value={secretType} onValueChange={(v) => setSecretType(v as SecretType)} disabled={isEdit}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{(Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][]).map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-4 w-4" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">标签</Label>
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
<div className="mt-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">描述</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
{/* Type-specific fields */}
<div>
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2 flex items-center gap-2">
<TIicon className="h-3.5 w-3.5" />{TI?.label || secretType} 配置
{isEdit && <span className="text-[var(--dg-text-muted)]">(仅填写需要修改的字段)</span>}
</div>
{secretType === 'ssh' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 用户</Label>
<Input value={sshUser} onChange={e => setSshUser(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 端口</Label>
<Input value={sshPort} onChange={e => setSshPort(e.target.value)} placeholder="22" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="col-span-2">
<FieldWithToggle label="SSH 密码" value={sshPassword} onChange={setSshPassword}
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
</div>
<div className="col-span-2">
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
</div>
<div className="col-span-2">
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
</div>
</div>
)}
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="text-red-400">*</span></Label>
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dockerUsername} onChange={e => setDockerUsername(e.target.value)} placeholder="admin"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dockerPassword} onChange={setDockerPassword}
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
</div>
)}
{secretType === 'tls' && (
<div className="space-y-3">
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
</div>
)}
{secretType === 'aksk' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="text-red-400">*</span></Label>
<Input value={akskAccessKey} onChange={e => setAkskAccessKey(e.target.value)} placeholder="AKIDxxxx..."
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="col-span-2">
<FieldWithToggle label="Secret Access Key" value={akskSecretKey} onChange={setAkskSecretKey}
placeholder="输入 Secret Key" fieldKey="aksk_sk" required />
</div>
</div>
)}
{secretType === 'database' && (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库类型</Label>
<Select value={dbType} onValueChange={setDbType}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mongodb">MongoDB</SelectItem>
<SelectItem value="redis">Redis</SelectItem>
<SelectItem value="mssql">SQL Server</SelectItem>
<SelectItem value="oracle">Oracle</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="text-red-400">*</span></Label>
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">端口</Label>
<Input value={dbPort} onChange={e => setDbPort(e.target.value)} placeholder="3306" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库名</Label>
<Input value={dbName} onChange={e => setDbName(e.target.value)} placeholder="aiops"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dbUsername} onChange={e => setDbUsername(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dbPassword} onChange={setDbPassword}
placeholder="数据库密码" fieldKey="db_pwd" /></div>
</div>
)}
{secretType === 'api' && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">API 端点</Label>
<Input value={apiEndpoint} onChange={e => setApiEndpoint(e.target.value)} placeholder="https://api.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="API Key / Token" value={apiKey} onChange={setApiKey}
placeholder="sk-xxxx..." fieldKey="api_key" required />
</div>
)}
{secretType === 'generic' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="text-red-400">*</span></Label>
<Input value={genericKey} onChange={e => setGenericKey(e.target.value)} placeholder="MY_SECRET"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="Value" value={genericValue} onChange={setGenericValue}
placeholder="密钥值" fieldKey="generic_val" required />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Main Manager ──────
export default function CredentialManager() {
const confirm = useConfirm();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// Filters
const [filterType, setFilterType] = useState<string>('');
const [filterSearch, setFilterSearch] = useState('');
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterType) params.set('secret_type', filterType);
if (filterSearch) params.set('search', filterSearch);
params.set('page_size', '100');
const res: any = await http.get(`/credentials?${params.toString()}`);
setCredentials(res?.list || []);
} catch { toast.error('获取凭证列表失败'); }
finally { setLoading(false); }
}, [filterType, filterSearch]);
useEffect(() => { fetchAll(); }, [fetchAll]);
const handleDelete = async (cred: Credential) => {
const ok = await confirm({
title: '删除凭据',
description: `确定删除凭据「${cred.name}」?此操作不可撤销。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await http.delete(`/credentials/${cred.id}`);
toast.success('已删除');
fetchAll();
} catch { toast.error('删除失败'); }
};
const handleToggleActive = async (cred: Credential) => {
try {
await http.put(`/credentials/${cred.id}`, { is_active: !cred.is_active });
toast.success(cred.is_active ? '已禁用' : '已启用');
fetchAll();
} catch { toast.error('操作失败'); }
};
const handleCopyPrefix = (prefix: string) => {
navigator.clipboard.writeText(prefix);
toast.success('引用 ID 已复制');
};
const typeOptions = Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">凭据管理</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">
统一管理 SSH 密钥、Docker 密码、AKSK、数据库等敏感凭据(AES 加密存储)
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增凭据
</Button>
</div>
</div>
{/* Search/Filter */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{typeOptions.map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input value={filterSearch} onChange={e => setFilterSearch(e.target.value)}
placeholder="搜索名称、描述..."
className="pl-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm" />
</div>
<span className="text-xs text-[var(--dg-text-muted)]">{credentials.length} 个凭据</span>
</div>
{loading ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Zap className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-blue)]" />加载中...
</div>
) : credentials.length === 0 ? (
<div className="text-center py-16 border border-dashed border-[var(--dg-border)] rounded-lg">
<Shield className="w-12 h-12 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2">暂无凭据</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">创建 SSH、Docker、AKSK 等类型的凭据,供流水线和服务器使用</p>
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />创建第一个凭据
</Button>
</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">摘要</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden lg:table-cell">标签</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden xl:table-cell">引用ID</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{credentials.map(cred => {
const st = cred.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const isExpired = cred.expires_at && new Date(cred.expires_at) < new Date();
return (
<tr key={cred.id}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50 cursor-pointer"
onClick={() => { setDetailId(cred.id); setDetailOpen(true); }}>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
{cfg ? <cfg.icon className={`h-4 w-4 ${cfg.color} shrink-0`} /> : <Key className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />}
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{cred.name}</div>
{cred.variable_name && (
<div className="text-xs text-[var(--dg-text-muted)] font-mono">{cred.variable_name}</div>
)}
</div>
</div>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className={`text-xs ${TYPE_BADGE_CLASS[st] || ''}`}>
{cfg?.label || st}
</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] text-xs hidden md:table-cell max-w-[180px] truncate font-mono">
{cred.summary || '-'}
</td>
<td className="px-3 py-2.5 hidden lg:table-cell">
<div className="flex gap-1 flex-wrap">
{(cred.tags || []).slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-muted)]">{t}</Badge>
))}
{(cred.tags || []).length > 3 && (
<span className="text-xs text-[var(--dg-text-muted)]">+{cred.tags.length - 3}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 hidden xl:table-cell">
<code className="text-xs text-[var(--dg-blue)] cursor-pointer font-mono"
onClick={(e) => { e.stopPropagation(); handleCopyPrefix(cred.env_prefix); }}>
{cred.env_prefix}
</code>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1">
{cred.is_active ? (
<Badge className="bg-green-500/20 text-green-400 text-xs border-0">启用</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 text-xs border-0">禁用</Badge>
)}
{isExpired && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />过期</Badge>
)}
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditing(cred); setDialogOpen(true); }} title="编辑">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred)}
title={cred.is_active ? '禁用' : '启用'}>
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => handleDelete(cred)} title="删除">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={fetchAll} editData={editing} />
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
</div>
);
}
@@ -0,0 +1,297 @@
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/services/api';
import CredentialFormDialog from './CredentialFormDialog';
interface Repo {
id: number; name: string; description: string; url: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Registry {
id: number; name: string; description: string; registry_url: string; namespace: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Environment {
id: number; name: string; display_name: string; type: string; color: string;
sort_order: number; description: string; enabled: boolean;
}
function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; onClose: () => void; onSaved: () => void; editData: Environment | null }) {
const [name, setName] = useState('');
const [displayName, setDisplayName] = useState('');
const [envType, setEnvType] = useState('development');
const [color, setColor] = useState('#3B82F6');
const [description, setDescription] = useState('');
const [sortOrder, setSortOrder] = useState(50);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (editData) {
setName(editData.name);
setDisplayName(editData.display_name);
setEnvType(editData.type);
setColor(editData.color);
setDescription(editData.description || '');
setSortOrder(editData.sort_order);
} else {
setName(''); setDisplayName(''); setEnvType('development'); setColor('#3B82F6'); setDescription(''); setSortOrder(50);
}
}, [editData, open]);
const handleSubmit = async () => {
if (!name.trim() || !displayName.trim()) { toast.error('请填写名称'); return; }
setSubmitting(true);
try {
if (editData) await deployApi.updateEnvironment(editData.id, { name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
else await deployApi.createEnvironment({ name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
toast.success(editData ? '已更新' : '已创建');
onSaved();
onClose();
} catch { toast.error('操作失败'); }
finally { setSubmitting(false); }
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader><DialogTitle>{editData ? '编辑环境' : '新增环境'}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>环境标识</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
<div className="space-y-1"><Label>显示名称</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="生产环境" /></div>
<div className="space-y-1"><Label>环境类型</Label>
<Select value={envType} onValueChange={setEnvType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="production">生产环境</SelectItem>
<SelectItem value="staging">预发布</SelectItem>
<SelectItem value="testing">测试环境</SelectItem>
<SelectItem value="development">开发环境</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>标志颜色</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
<div className="space-y-1"><Label>排序序号</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
<div className="space-y-1"><Label>描述</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选" /></div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? '保存' : '创建'}</Button>
</div>
</DialogContent>
</Dialog>
);
}
export default function DeployCredentials() {
const _confirm = useConfirm();
const [repos, setRepos] = useState<Repo[]>([]);
const [registries, setRegistries] = useState<Registry[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
const [editingId, setEditingId] = useState<number | null>(null);
const [envDialogOpen, setEnvDialogOpen] = useState(false);
const [editEnv, setEditEnv] = useState<Environment | null>(null);
const fetchAll = async () => {
setLoading(true);
try {
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
setRepos((rR as any)?.list || []);
setRegistries((rReg as any)?.list || []);
setEnvironments((rEnv as any)?.list || []);
} catch { toast.error('获取数据失败'); }
finally { setLoading(false); }
};
useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: any) => {
const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' });
if (!ok) return;
try {
if (type === 'repo') await deployApi.deleteRepo(item.id);
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
else await deployApi.deleteEnvironment(item.id);
toast.success('已删除');
fetchAll();
} catch { toast.error('删除失败'); }
};
const envTypeLabel: Record<string, string> = { production: '生产', staging: '预发布', testing: '测试', development: '开发' };
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">发布配置管理</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
<Button size="sm" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增 Git 仓库
</Button>
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增镜像仓库
</Button>
</div>
</div>
<Tabs defaultValue="repos">
<TabsList>
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
</TabsList>
<TabsContent value="repos" className="mt-3">
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: repos.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无 Git 仓库认证配置</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">仓库 URL</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证方式</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{repos.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
<TabsContent value="registries" className="mt-3">
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: registries.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无镜像仓库认证配置</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">命名空间</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{registries.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* Environments tab */}
<TabsContent value="environments" className="mt-3">
<div className="flex items-center justify-end mb-3">
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
</div>
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: environments.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无环境配置,点击"新增环境"创建</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">环境标识</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">显示名称</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{environments.map(env => (
<tr key={env.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-mono text-xs font-medium text-[var(--dg-text-primary)]">{env.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-primary)]">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full inline-block" style={{ backgroundColor: env.color }} />
{env.display_name}
</span>
</td>
<td className="px-3 py-2"><Badge variant="secondary" className="text-xs">{envTypeLabel[env.type] || env.type}</Badge></td>
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
<td className="px-3 py-2">
<Badge className={env.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{env.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
</Tabs>
<CredentialFormDialog
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
repos={repos} registries={registries} onSave={fetchAll}
/>
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
</div>
);
}
@@ -0,0 +1,411 @@
import { useState, useEffect, useMemo } from 'react';
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus, Search } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { deployApi, userApi, pipelineApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
interface ItemRole {
id: number; name: string; code: string; pattern: string; role: string;
description: string; status: number; created_at: string;
}
interface AssignedUser {
id: number; user_id: number; item_role_id: number;
user?: { id: number; username: string; real_name: string };
item_role?: ItemRole;
}
export default function DeployRBAC() {
const [roles, setRoles] = useState<ItemRole[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Pipeline list for live preview
const [pipelines, setPipelines] = useState<any[]>([]);
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
// User assignment
const [users, setUsers] = useState<any[]>([]);
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [assignRoleId, setAssignRoleId] = useState<string>('');
const [assignLoading, setAssignLoading] = useState(false);
// Create
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newCode, setNewCode] = useState('');
const [newPattern, setNewPattern] = useState('');
const [newRole, setNewRole] = useState('editor');
const [newDesc, setNewDesc] = useState('');
// Edit
const [editing, setEditing] = useState<number | null>(null);
const [editPattern, setEditPattern] = useState('');
const [editRole, setEditRole] = useState('');
const _confirm = useConfirm();
const loadData = async () => {
setLoading(true);
try {
const [rolesRes, pipesRes, usersRes] = await Promise.all([
deployApi.getItemRoles(),
pipelineApi.getPipelines({ page_size: 200 }),
userApi.getUsers({ page_size: 500 }),
]);
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as any)?.list || (rolesRes as any)?.data || []);
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as any)?.list || (pipesRes as any)?.data || []);
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as any)?.list || (usersRes as any)?.data || [];
setUsers(uList);
} catch { /* */ }
finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const loadAssignments = async () => {
if (!selectedUserId) { setAssignments([]); return; }
try {
const res: any = await deployApi.getUserItemRoles(Number(selectedUserId));
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
} catch { setAssignments([]); }
};
useEffect(() => { loadAssignments(); }, [selectedUserId]);
// Compute matched pipeline codes for each role
const matchCount = useMemo(() => {
const map: Record<number, string[]> = {};
roles.forEach(r => {
try {
const re = new RegExp(r.pattern);
map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code);
} catch { map[r.id] = []; }
});
return map;
}, [roles, pipelines]);
const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null;
const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : [];
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error('名称、代码、正则表达式不能为空'); return;
}
setSaving(true);
try {
await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc });
toast.success('已创建');
setShowCreate(false);
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
loadData();
} catch (e: any) { toast.error(e?.message || '创建失败'); }
finally { setSaving(false); }
};
const handleUpdate = async (id: number) => {
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
setSaving(true);
try {
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
toast.success('已更新');
setEditing(null);
loadData();
} catch (e: any) { toast.error(e?.message || '更新失败'); }
finally { setSaving(false); }
};
const handleDelete = async (role: ItemRole) => {
const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
catch { toast.error('删除失败'); }
};
const handleToggleStatus = async (role: ItemRole) => {
try {
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
toast.success(role.status ? '已禁用' : '已启用');
loadData();
} catch { toast.error('操作失败'); }
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; }
setAssignLoading(true);
try {
await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) });
toast.success('已分配');
setAssignRoleId('');
loadAssignments();
} catch (e: any) { toast.error(e?.message || '分配失败'); }
finally { setAssignLoading(false); }
};
const handleRevoke = async (id: number) => {
try {
await deployApi.removeItemRole(Number(selectedUserId), id);
toast.success('已撤销');
loadAssignments();
} catch { toast.error('撤销失败'); }
};
const roleBadgeClass = (role: string) =>
role === 'owner' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' :
role === 'editor' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
'bg-slate-500/20 text-slate-400 border-slate-500/30';
const selectedUser = users.find(u => String(u.id) === selectedUserId);
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
<Shield className="w-5 h-5 text-purple-500" />发布权限
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色
</p>
</div>
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
<Plus className="w-4 h-4 mr-1" />新建角色
</Button>
</div>
{/* ====== Pipeline Preview ====== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
</div>
<div className="flex gap-2 mb-3 flex-wrap">
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>全部 ({pipelines.length})</Button>
{roles.map(r => (
<Button key={r.id} variant={previewRoleId === r.id ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(r.id)}
className={r.status === 0 ? 'opacity-40' : ''}>
{r.name} ({matchCount[r.id]?.length || 0})
</Button>
))}
</div>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{previewRoleId === null
? pipelines.map(p => <Badge key={p.id} variant="secondary" className="text-xs"><GitBranch className="w-3 h-3 mr-0.5" />{p.code}</Badge>)
: pipelines.map(p => {
const matched = previewMatches.includes(p.code);
return (
<Badge key={p.id} variant={matched ? 'default' : 'secondary'}
className={`text-xs ${matched ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' : 'opacity-30'}`}>
<GitBranch className="w-3 h-3 mr-0.5" />{p.code}
</Badge>
);
})
}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-red-400">无匹配 — 请调整正则表达式</span>
)}
</div>
</CardContent>
</Card>
{/* Create form */}
{showCreate && (
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-2"><Plus className="w-4 h-4" />新建 Item Role</span>
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">名称</Label><Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs">代码 (唯一标识)</Label><Input value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs flex items-center gap-1"><Regex className="w-3 h-3" />正则匹配 Pattern</Label>
<Input value={newPattern} onChange={e => { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" />
{newPattern && (
<p className="text-xs text-[var(--dg-text-muted)]">
实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()}
</p>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">角色</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者 (owner)</SelectItem>
<SelectItem value="editor">编辑者 (editor)</SelectItem>
<SelectItem value="viewer">查看者 (viewer)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1"><Label className="text-xs">描述 (可选)</Label><Input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" /></div>
<Button onClick={handleCreate} disabled={saving} className="w-full">
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? '创建中...' : '创建'}
</Button>
</CardContent>
</Card>
)}
{/* Role list */}
{roles.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-20" />
<p className="text-sm">暂无 Item Role</p>
</div>
) : (
<div className="space-y-2">
{roles.map(role => (
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${role.status === 0 ? 'opacity-50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Regex className="w-4 h-4 text-purple-400" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
{role.status === 0 && <Badge variant="secondary" className="text-xs text-red-400">已禁用</Badge>}
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
</Badge>
</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
<code className="font-mono text-purple-400 bg-purple-500/10 px-1 rounded">{role.code}</code>
<span className="mx-1">→</span>
<code className="font-mono text-blue-400 bg-blue-500/10 px-1 rounded">{role.pattern}</code>
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{editing === role.id ? (
<>
<Input value={editPattern} onChange={e => setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" />
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-400" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
</>
) : (
<>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}><Save className="w-3 h-3 mr-1" />编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* ====== User Assignment ====== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<UserPlus className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
</div>
<div className="flex gap-3 items-end mb-4">
<div className="space-y-1 flex-1">
<Label className="text-xs">选择用户</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择用户..." /></SelectTrigger>
<SelectContent className="max-h-60">
<div className="px-2 py-1.5 sticky top-0 bg-popover z-10"><Input placeholder="搜索用户..." className="h-7 text-xs" /></div>
{users.map(u => (
<SelectItem key={u.id} value={String(u.id)}>
{u.real_name || u.username} ({u.username})
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs text-purple-400">已分配</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs">Item Role</Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择角色" /></SelectTrigger>
<SelectContent>
{roles.filter(r => r.status === 1).map(r => (
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({r.role})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
分配
</Button>
</div>
{/* Assigned roles for selected user */}
{selectedUser && (
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary" className="text-xs">
<Users className="w-3 h-3 mr-1" />{selectedUser.real_name || selectedUser.username}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">已分配角色:</span>
</div>
{assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">该用户尚未分配任何 Item Role</p>
) : (
<div className="space-y-1">
{assignments.map(a => {
const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id);
return (
<div key={a.id} className="flex items-center justify-between py-1.5 px-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center gap-2">
<Badge className={`text-xs border ${roleBadgeClass(roleInfo?.role || 'viewer')}`}>
{roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'}
</Badge>
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name || `#${a.item_role_id}`}</span>
{roleInfo && (
<code className="font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] px-1 rounded text-xs">
{roleInfo.pattern}
</code>
)}
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400" onClick={() => handleRevoke(a.id)}>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,270 @@
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import {
FolderOpen,
FileText,
Download,
ChevronRight,
Home,
ArrowLeft,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import http from '@/services/http';
import { useUserStore } from '@/store';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mod_time: string;
}
interface ListingData {
current: string;
dirs: FileItem[];
files: FileItem[];
}
interface FileBrowserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
runId: number | null;
buildNumber: number;
}
function formatSize(bytes: number): string {
if (bytes === 0) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
/**
* 工作区文件浏览器 — 浏览/下载流水线构建目录文件
*/
export function FileBrowserDialog({
open,
onOpenChange,
runId,
buildNumber,
}: FileBrowserDialogProps) {
const [listing, setListing] = useState<ListingData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [fullscreen, setFullscreen] = useState(false);
// 加载目录
const loadDir = async (path: string) => {
if (!runId) return;
setLoading(true);
setError('');
try {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
const data = await http.get(`/pipelines/runs/${runId}/files${params}`);
setListing(data);
setCurrentPath(data?.current || '');
} catch (err: any) {
setError(err?.message || '加载失败');
setListing(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && runId) {
setCurrentPath('');
loadDir('');
}
}, [open, runId]);
// 下载文件
const downloadFile = async (file: FileItem) => {
const token = useUserStore.getState().token || '';
const url = `${location.protocol}//${location.host}/api/v1/pipelines/runs/${runId}/files/download?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`;
window.open(url, '_blank');
};
// 面包屑导航
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
const breadcrumbs = [''].concat(
parts.map((_, i) => '/' + parts.slice(0, i + 1).join('/'))
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
>
<DialogTitle className="sr-only">文件浏览器 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录文件浏览</DialogDescription>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium text-sm">
#{buildNumber} 工作区文件
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] overflow-x-auto shrink-0">
{breadcrumbs.map((path, i) => (
<span key={path} className="flex items-center gap-0.5">
{i === 0 ? (
<button
onClick={() => loadDir('')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
>
<Home className="h-3.5 w-3.5" />
</button>
) : (
<>
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
<button
onClick={() => loadDir(path)}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] truncate max-w-[120px]"
>
{parts[i - 1]}
</button>
</>
)}
</span>
))}
</div>
{/* Content */}
<div className="overflow-y-auto" style={{ height: fullscreen ? 'calc(100vh - 110px)' : '420px' }}>
{loading && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)] text-sm">
加载中...
</div>
)}
{error && (
<div className="flex items-center justify-center h-full text-red-400 text-sm">
{error}
</div>
)}
{listing && !loading && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)]">
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
<th className="py-2 pl-4 pr-2 font-medium">名称</th>
<th className="py-2 px-2 font-medium w-24 text-right">大小</th>
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">修改时间</th>
<th className="py-2 pr-4 pl-2 font-medium w-16" />
</tr>
</thead>
<tbody>
{/* Parent dir */}
{currentPath && (
<tr
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => {
const parent = currentPath.split('/').slice(0, -1).join('/');
loadDir(parent);
}}
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<ArrowLeft className="h-4 w-4 text-[var(--dg-text-secondary)]" />
<span className="text-[var(--dg-text-secondary)]">..</span>
</td>
<td className="py-2 px-2 text-right" />
<td className="py-2 pr-4 pl-2 hidden sm:table-cell" />
<td className="py-2 pr-4 pl-2" />
</tr>
)}
{/* Directories */}
{listing.dirs?.map((dir) => (
<tr
key={dir.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => loadDir(dir.path)}
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-amber-400 shrink-0" />
<span className="text-[var(--dg-text-primary)]">{dir.name}</span>
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">-</td>
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{dir.mod_time}</td>
<td className="py-2 pr-4 pl-2" />
</tr>
))}
{/* Files */}
{listing.files?.map((file) => (
<tr
key={file.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<FileText className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-[var(--dg-text-primary)]">{file.name}</span>
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">{formatSize(file.size)}</td>
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{file.mod_time}</td>
<td className="py-2 pr-4 pl-2">
<button
onClick={(e) => { e.stopPropagation(); downloadFile(file); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
title="下载"
>
<Download className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
{/* Empty */}
{listing.dirs?.length === 0 && listing.files?.length === 0 && !currentPath && (
<tr>
<td colSpan={4} className="py-8 text-center text-[var(--dg-text-muted)]">
{loading ? '加载中...' : '目录为空'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,602 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Plus, Trash2, ChevronUp, ChevronDown, ChevronRight, Save, Play, Loader2,
GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container,
Clock, Webhook, Hand, Info, Copy, Check, X, StopCircle, ScrollText,
Users, UserPlus, Shield, Eye, FileText,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { pipelineApi, deployApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
// ---- types ----
interface Task {
id: string; name: string; type: string; icon: string;
config: Record<string, string>; script: string; script_type: string;
}
interface Stage {
id: string; name: string; type: string; tasks: Task[];
}
interface LogEntry {
time: string; level: string; message: string; stage: string;
}
const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' };
const taskIcons: Record<string, any> = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
function fmtDuration(s: number | undefined): string {
if (!s) return '-';
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60); const sec = s % 60;
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
}
// ---- Task Card ----
function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected: boolean; onSelect: () => void; onDelete: () => void }) {
const Icon = taskIcons[task.icon] || Wrench;
return (
<div
onClick={onSelect}
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
selected ? 'border-blue-500 bg-blue-500/10' : 'border-[var(--dg-border)] hover:border-slate-400 bg-[var(--dg-bg-secondary)]'
}`}
>
<div className={`p-1.5 rounded ${selected ? 'bg-blue-100' : 'bg-slate-100 dark:bg-slate-800'}`}>
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="w-3.5 h-3.5 text-red-500" />
</Button>
</div>
);
}
// ---- Task Editor Panel ----
function TaskEditor({ task, onUpdate, onClose }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">选择一个任务开始编辑</div>;
return (
<div className="space-y-4">
<div className="space-y-1"><Label className="text-xs">任务名称</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">类型</Label>
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="build">构建</SelectItem><SelectItem value="deploy">部署</SelectItem><SelectItem value="test">测试</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-xs">图标</Label>
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
</div>
</div>
<div className="space-y-1"><Label className="text-xs">脚本类型</Label>
<div className="flex gap-2">{['shell', 'python'].map(t => (<Badge key={t} variant={task.script_type === t ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: t })}>{t}</Badge>))}</div>
</div>
<div className="space-y-1"><Label className="text-xs">脚本内容</Label>
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-slate-950 text-green-400" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
</div>
</div>
);
}
// ====== Log Dialog ======
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run: any; logs: LogEntry[] }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />执行日志 #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>触发方式: {run?.triggered_by || 'manual'}</span>
<span>分支: {run?.branch || '-'}</span>
<span>耗时: {fmtDuration(run?.duration)}</span>
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
</div>
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<div className="text-slate-500 py-4 text-center">暂无日志</div>
) : (
logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-slate-500 shrink-0">[{l.time}]</span>
<span className={
l.level === 'error' ? 'text-red-400' :
l.level === 'warn' ? 'text-yellow-400' :
l.level === 'success' ? 'text-green-400' :
'text-slate-300'
}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ====== Main Component ======
export default function PipelineEditor() {
const { code } = useParams<{ code: string }>();
const pipelineCode = code || '';
const [pipeline, setPipeline] = useState<any>(null);
const [stages, setStages] = useState<Stage[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState('flow');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<any[]>([]);
const [environments, setEnvironments] = useState<any[]>([]);
const [members, setMembers] = useState<any[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config
const [triggerMode, setTriggerMode] = useState('manual');
const [watchBranches, setWatchBranches] = useState('');
const [cronExpr, setCronExpr] = useState('');
const [webhookToken, setWebhookToken] = useState('');
const [variables, setVariables] = useState<[string, string][]>([]);
const [environmentId, setEnvironmentId] = useState<number | string>('');
// member add dialog
const [newMemberUser, setNewMemberUser] = useState('');
const [newMemberRole, setNewMemberRole] = useState('editor');
// log dialog
const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const _confirm = useConfirm();
const loadEnvironments = useCallback(async () => {
try {
const res = await deployApi.getEnvironments();
setEnvironments(Array.isArray(res) ? res : (res as any)?.list || []);
} catch { /* silent */ }
}, []);
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
try {
const res = await pipelineApi.getMembers(pipelineCode);
setMembers(Array.isArray(res) ? res : (res as any)?.data || []);
} catch { setMembers([]); }
finally { setLoadingMembers(false); }
}, [pipelineCode]);
const loadRuns = useCallback(async () => {
try {
const res = await pipelineApi.getRuns(pipelineCode);
setRuns(Array.isArray(res) ? res : (res as any)?.list || []);
} catch { setRuns([]); }
}, [pipelineCode]);
useEffect(() => {
(async () => {
setLoading(true);
try {
const p = await pipelineApi.getPipelineDetail(pipelineCode) as any;
// http.get 已自动解包 json.data,p 即为 pipeline 对象
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
setPipeline(p);
setTriggerMode(p.trigger_mode || 'manual');
setWatchBranches(p.watch_branches || '');
setCronExpr(p.cron_expr || '');
setWebhookToken(p.webhook_token || '');
setEnvironmentId(p.environment_id || '');
try {
const parsed = p.variables ? JSON.parse(p.variables) : {};
setVariables(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
} catch { setVariables([]); }
try {
const s = p.stages ? JSON.parse(p.stages) : [];
const stageList = Array.isArray(s) ? s : [];
setStages(stageList);
// Auto-expand all stages initially
const expanded: Record<string, boolean> = {};
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
setExpandedStages(expanded);
} catch { setStages([]); }
} catch { toast.error('加载失败'); }
finally { setLoading(false); }
})();
loadEnvironments();
loadMembers();
loadRuns();
}, [pipelineCode, loadEnvironments, loadMembers, loadRuns]);
const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null;
const handleUpdateTask = (t: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === t.id ? t : x) }))); };
const handleAddTask = (stageIdx: number) => {
const newTask: Task = { id: uid(), name: '新任务', type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' };
setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s));
};
const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); };
const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: '新阶段', type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); };
const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); };
const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); };
const handleSave = async () => {
setSaving(true);
try {
await pipelineApi.updatePipeline(pipelineCode, {
stages: JSON.stringify(stages),
trigger_mode: triggerMode,
watch_branches: watchBranches,
cron_expr: cronExpr,
webhook_token: webhookToken,
environment_id: environmentId ? Number(environmentId) : null,
variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))),
});
toast.success('已保存');
} catch { toast.error('保存失败'); }
finally { setSaving(false); }
};
const handleTrigger = async () => {
try {
await pipelineApi.triggerPipeline(pipelineCode);
toast.success('已触发');
loadRuns();
} catch { toast.error('触发失败'); }
};
const handleStop = async (runId: number | string) => {
const ok = await _confirm({ title: '停止运行', description: '确定要停止此运行吗?', variant: 'destructive' });
if (!ok) return;
try {
await pipelineApi.stopRun(pipelineCode, runId);
toast.success('已停止');
loadRuns();
} catch { toast.error('停止失败'); }
};
const handleViewLog = async (run: any) => {
setLogRun(run);
setLogOpen(true);
try {
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as any;
setLogs(data?.logs || []);
} catch { setLogs([]); }
};
const handleAddMember = async () => {
if (!newMemberUser.trim()) { toast.error('请输入用户名'); return; }
try {
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
toast.success('已添加成员');
setNewMemberUser('');
loadMembers();
} catch (e: any) { toast.error(e?.message || '添加失败'); }
};
const handleRemoveMember = async (memberId: number) => {
const ok = await _confirm({ title: '移除成员', description: '确定要移除此成员吗?', variant: 'destructive' });
if (!ok) return;
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success('已移除'); loadMembers(); }
catch { toast.error('移除失败'); }
};
const envBadge = environments.find(e => String(e.id) === String(environmentId));
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
if (!pipeline) return <div className="text-center py-20 text-[var(--dg-text-secondary)]">流水线不存在</div>;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
<GitBranch className="w-5 h-5 text-blue-500" />
{pipeline.name}
{envBadge && <Badge style={{ backgroundColor: envBadge.color + '20', color: envBadge.color, borderColor: envBadge.color + '40' }} className="text-xs ml-2">{envBadge.display_name}</Badge>}
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">code: {pipeline.code} · {pipeline.description}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleSave} disabled={saving}><Save className="w-4 h-4 mr-1" />{saving ? '保存中...' : '保存'}</Button>
<Button variant="default" onClick={handleTrigger}><Play className="w-4 h-4 mr-1" />运行</Button>
</div>
</div>
{/* Environment Selector - quick bar */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">部署环境:</span>
<Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}>
<SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder="选择环境" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">未指定</SelectItem>
{environments.map(env => (
<SelectItem key={env.id} value={String(env.id)}>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color }} />
{env.display_name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-[var(--dg-text-secondary)] opacity-70">选择后流水线执行记录会关联到对应环境</span>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="flow">流程配置</TabsTrigger>
<TabsTrigger value="triggers">触发设置</TabsTrigger>
<TabsTrigger value="variables">变量</TabsTrigger>
<TabsTrigger value="runs">发布记录 ({runs.length})</TabsTrigger>
<TabsTrigger value="members">成员管理 ({members.length})</TabsTrigger>
</TabsList>
{/* ====== Flow Tab ====== */}
<TabsContent value="flow" className="mt-3">
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线阶段 ({stages.length})</span>
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />添加阶段</Button>
</div>
{stages.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">点击「添加阶段」开始</div>
) : (
stages.map((stage, idx) => {
const isExpanded = expandedStages[stage.id] !== false; // default expanded
return (
<div key={stage.id} className="space-y-1">
<div
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-t-md border border-b-0 border-[var(--dg-border)] cursor-pointer"
onClick={() => setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded }))}
>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0" onClick={(e) => { e.stopPropagation(); setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded })); }}>
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</Button>
<Input
value={stage.name}
onChange={e => setStages(prev => prev.map((s, i) => i === idx ? { ...s, name: e.target.value } : s))}
className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} 任务</span>
<Badge variant="secondary" className="text-xs">{stageTypeLabels[stage.type] || stage.type}</Badge>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
{isExpanded && (
<div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2">
{stage.tasks.map(t => (<TaskCard key={t.id} task={t} selected={selectedTaskId === t.id} onSelect={() => setSelectedTaskId(selectedTaskId === t.id ? null : t.id)} onDelete={() => handleDeleteTask(t.id)} />))}
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />添加任务</Button>
</div>
)}
{idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>}
</div>
);
})
)}
</div>
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">任务编辑</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>关闭</Button>}</div>
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
</div>
</div>
</TabsContent>
{/* ====== Triggers Tab ====== */}
<TabsContent value="triggers" className="mt-3">
<div className="space-y-4 max-w-lg">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 text-blue-500" /><span className="font-medium">Git Push 触发</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div>
{triggerMode === 'git_push' && <div className="space-y-1"><Label>监听分支</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 text-blue-500" /><span className="font-medium">定时触发</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div>
{triggerMode === 'cron' && <div className="space-y-1"><Label>Cron 表达式</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 text-blue-500" /><span className="font-medium">Webhook 触发</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div>
{triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder="输入签名密钥" className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 text-blue-500" /><span className="font-medium">手动触发</span><Badge variant="secondary" className="text-xs">默认启用</Badge></div></CardContent></Card>
</div>
</TabsContent>
{/* ====== Variables Tab ====== */}
<TabsContent value="variables" className="mt-3">
<div className="max-w-lg space-y-3">
{variables.map(([k, v], i) => (
<div key={i} className="flex gap-2 items-center">
<Input value={k} onChange={e => { const next = [...variables]; next[i][0] = e.target.value; setVariables(next); }} placeholder="KEY" className="h-9 w-40 font-mono text-sm" />
<span className="text-[var(--dg-text-secondary)]">=</span>
<Input value={v} onChange={e => { const next = [...variables]; next[i][1] = e.target.value; setVariables(next); }} placeholder="VALUE" className="h-9 flex-1" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />添加变量</Button>
</div>
</TabsContent>
{/* ====== Runs Tab ====== */}
<TabsContent value="runs" className="mt-3">
{runs.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">暂无执行记录</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发方式</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发人</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">分支</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">时间</th>
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{runs.map((r: any) => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-3 py-2">
{r.status === 'success' ? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />成功</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><X className="w-3 h-3 mr-0.5" />失败</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />执行中</Badge> :
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />已停止</Badge> :
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />手动</span>}
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />定时</span>}
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
{!r.trigger_mode && (r.triggered_by || '手动')}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">{r.branch || '-'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{fmtDuration(r.duration)}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />日志</Button>
{(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />停止</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ====== Members Tab ====== */}
<TabsContent value="members" className="mt-3">
<div className="max-w-xl space-y-4">
{/* Add member form */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserPlus className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">添加用户</span>
</div>
<div className="flex items-end gap-3 mt-3">
<div className="flex-1 space-y-1">
<Label className="text-xs">用户名</Label>
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder="输入用户名" className="h-9" />
</div>
<div className="space-y-1">
<Label className="text-xs">角色</Label>
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />添加</Button>
</div>
</CardContent>
</Card>
{/* Members list — split direct users vs group assignments */}
{(() => {
const directUsers = members.filter((m: any) => m.username);
const groupAssignments = members.filter((m: any) => m.group_id);
return (
<>
{/* Direct users */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👤 直接用户授权 ({directUsers.length})
</div>
{loadingMembers ? (
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : directUsers.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{directUsers.map((m: any) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />移除</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Group assignments */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👥 用户组授权 ({groupAssignments.length})
</div>
{groupAssignments.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
暂无组授权 — 在「<a href="/deploy/groups" className="text-blue-400 underline">用户组管理</a>」页面创建组后,可在此授权
</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{groupAssignments.map((m: any) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `组 #${m.group_id}`}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>} · 组内全体成员</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />撤销授权</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
);
})()}
</div>
</TabsContent>
</Tabs>
{/* Log Dialog */}
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,473 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import React from 'react';
import { useParams } from 'react-router-dom';
import { Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, Loader2, XCircle, History, FolderOpen, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import http from '@/services/http';
import { FileBrowserDialog } from './FileBrowserDialog';
interface PipelineRun {
id: number;
pipeline_id: number;
build_number: number;
status: string;
branch: string;
commit_id: string;
commit_msg: string;
author: string;
triggered_by: string;
image_tag: string;
duration: number;
started_at: string;
finished_at: string;
created_at: string;
}
interface LogEntry {
time: string;
level: string;
stage: string;
message: string;
}
const STATUS_BADGES: Record<string, string> = {
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
running: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
pending: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
};
const STATUS_ICONS: Record<string, any> = {
success: CheckCircle2,
failed: XCircle,
running: Loader2,
pending: Clock,
cancelled: AlertCircle,
};
const LEVEL_COLORS: Record<string, string> = {
info: 'text-[var(--dg-text-secondary)]',
success: 'text-emerald-400',
error: 'text-red-400',
warn: 'text-amber-400',
};
function formatDuration(seconds: number): string {
if (!seconds) return '-';
if (seconds < 60) return `${seconds}秒`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}分${s}秒`;
}
function formatTime(ts: string): string {
if (!ts) return '-';
const d = new Date(ts);
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export default function PipelineRunDetail() {
const { code: pipelineCode } = useParams<{ code: string }>();
const [runs, setRuns] = useState<PipelineRun[]>([]);
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('records');
const [pageSize, setPageSize] = useState(10);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [deleting, setDeleting] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
// 文件浏览器 dialog 状态
const [browserOpen, setBrowserOpen] = useState(false);
const [browserRun, setBrowserRun] = useState<{ id: number; buildNumber: number } | null>(null);
// 获取执行记录 (http.get 已自动解包 json.data → {list, total})
const fetchRuns = useCallback(async () => {
setLoading(true);
try {
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
const list = data?.list || [];
setRuns(list);
// 自动选中最新运行
if (!selectedRun && list.length > 0) {
setSelectedRun(list[0]);
}
} catch { toast.error('获取执行记录失败'); }
finally { setLoading(false); }
}, [pipelineCode, pageSize]);
useEffect(() => { fetchRuns(); }, []);
useEffect(() => { fetchRuns(); }, [pageSize]);
// 自动刷新(有运行中的记录时每5秒刷新)
useEffect(() => {
const hasRunning = runs.some(r => r.status === 'running');
if (!hasRunning) return;
const t = setInterval(fetchRuns, 5000);
return () => clearInterval(t);
}, [runs, fetchRuns]);
// 加载日志 (http.get 已自动解包 json.data)
const fetchLogs = useCallback(async (run: PipelineRun) => {
try {
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
if (data?.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
} else {
setLogs([]);
}
// 如果运行中,连接 WebSocket
if (run.status === 'running') {
connectWS(run.id);
}
} catch {
setLogs([]);
}
}, []);
// WebSocket 连接
const connectWS = (runId: number) => {
if (wsRef.current) {
wsRef.current.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('token');
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' && data.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
} else if (data.type === 'done') {
ws.close();
fetchRuns();
} else if (data.time && data.level) {
setLogs(prev => [...prev, data]);
}
} catch { /* binary frame, ignore */ }
};
ws.onclose = () => { wsRef.current = null; };
ws.onerror = () => { wsRef.current = null; };
wsRef.current = ws;
};
useEffect(() => {
if (selectedRun) {
fetchLogs(selectedRun);
}
return () => {
if (wsRef.current) wsRef.current.close();
};
}, [selectedRun, fetchLogs]);
// 自动滚动到底部
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// 触发流水线
const triggerRun = async () => {
try {
await http.post(`/pipelines/${pipelineCode}/trigger`, {});
toast.success('已触发');
fetchRuns();
} catch { toast.error('触发失败'); }
};
// 停止流水线
const stopRun = async (runId: number) => {
try {
await http.post(`/pipelines/${pipelineCode}/runs/${runId}/stop`, {});
toast.success('已停止');
fetchRuns();
} catch { toast.error('停止失败'); }
};
// 批量删除
const batchDelete = async () => {
if (selectedIds.size === 0) return;
setDeleting(true);
try {
await http.post(`/pipelines/${pipelineCode}/runs/batch-delete`, { ids: Array.from(selectedIds) });
toast.success(`已删除 ${selectedIds.size} 条记录`);
setSelectedIds(new Set());
fetchRuns();
} catch { toast.error('批量删除失败'); }
finally { setDeleting(false); }
};
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === runs.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(runs.map(r => r.id)));
}
};
const triggerLabel = (run: PipelineRun) => {
if (run.triggered_by === 'system') return 'Cron';
if (run.triggered_by === 'git') return 'Git Push';
return run.triggered_by || 'Manual';
};
return (
<div className="space-y-4 h-full flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
<History className="w-5 h-5 inline mr-2" />
执行记录
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">流水线 #{pipelineCode}</p>
</div>
<div className="flex gap-2 items-center">
{selectedIds.size > 0 && (
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
<Trash2 className="w-4 h-4 mr-1" />删除 ({selectedIds.size})
</Button>
)}
<select
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); }}
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
>
<option value={10}>10条/页</option>
<option value={20}>20条/页</option>
<option value={30}>30条/页</option>
</select>
<Button variant="outline" size="sm" onClick={fetchRuns}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" onClick={triggerRun}>
<Play className="w-4 h-4 mr-1" />立即执行
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
<TabsList>
<TabsTrigger value="records">执行记录 ({runs.length})</TabsTrigger>
<TabsTrigger value="log" disabled={!selectedRun}>执行日志</TabsTrigger>
</TabsList>
{/* 执行记录列表 */}
<TabsContent value="records" className="flex-1 mt-3">
{loading && runs.length === 0 ? (
<div className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />加载中...</div>
) : runs.length === 0 ? (
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg">
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-2 py-2.5 w-10">
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0} onChange={toggleAll} className="rounded" />
</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
</tr>
</thead>
<tbody>
{runs.map(run => {
const StatusIcon = STATUS_ICONS[run.status] || Clock;
return (
<tr key={run.id}
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(run.id)} onChange={() => toggleSelect(run.id)} className="rounded" />
</td>
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
#{run.build_number}
</td>
<td className="px-3 py-2.5">
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
{run.status === 'success' ? '成功' : run.status === 'failed' ? '失败' : run.status === 'running' ? '执行中' : run.status === 'cancelled' ? '已取消' : '待执行'}
</Badge>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
<User className="w-3 h-3" />
{run.triggered_by || 'admin'}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
{run.status === 'running' ? (
<span className="flex items-center gap-1 text-blue-400">
<Loader2 className="w-3 h-3 animate-spin" />
执行中...
</span>
) : formatDuration(run.duration)}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
{formatTime(run.created_at)}
</td>
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
执行日志
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
<FolderOpen className="w-3.5 h-3.5 mr-1" />
浏览
</Button>
{run.status === 'running' && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => stopRun(run.id)}>
<Square className="w-3.5 h-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* 执行日志 - Console 输出 */}
<TabsContent value="log" className="flex-1 mt-3">
{!selectedRun ? (
<div className="text-center py-12 text-[var(--dg-text-muted)]">请先选择一条执行记录</div>
) : (
<Card className="flex-1 flex flex-col border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<CardTitle className="text-sm font-mono">
aiops-backend #{selectedRun.build_number}
</CardTitle>
{selectedRun.status === 'running' && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<span>触发方式: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 400 }}>
{logs.length === 0 && selectedRun.status !== 'running' ? (
<div className="text-center py-12 text-slate-500">暂无日志输出</div>
) : (
<>
{(() => {
let lastStage = '';
const stageStyles: Record<string, string> = {
checkout: 'border-indigo-500 bg-indigo-500/10',
build: 'border-amber-500 bg-amber-500/10',
test: 'border-emerald-500 bg-emerald-500/10',
image: 'border-cyan-500 bg-cyan-500/10',
deploy: 'border-red-500 bg-red-500/10',
verify: 'border-blue-500 bg-blue-500/10',
};
const stageIcons: Record<string, string> = {
checkout: '⬇', build: '⚙', test: '✓',
image: '📦', deploy: '🚀', verify: '🛡',
};
return logs.map((entry, i) => {
const showStage = entry.stage && entry.stage !== lastStage;
if (showStage) lastStage = entry.stage || '';
const isError = entry.level === 'error';
const isWarn = entry.level === 'warn';
const isSuccess = entry.level === 'success';
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10' :
isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10' :
isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
const msgColor = isError ? 'text-red-200' :
isWarn ? 'text-amber-200' :
isSuccess ? 'text-emerald-200' : 'text-slate-300';
const levelBadge = isError
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
: isWarn
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
: isSuccess
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
return (
<React.Fragment key={i}>
{showStage && (
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
<span className="flex-1 border-t border-slate-700/50"></span>
</div>
)}
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
{levelBadge}
<span className={`break-all ${msgColor}`}>{entry.message}</span>
</div>
</React.Fragment>
);
});
})()}
{selectedRun.status === 'running' && (
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
<span className="flex gap-1 ml-1">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'0ms'}}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'150ms'}}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'300ms'}}></span>
</span>
</div>
)}
<div ref={logEndRef} />
</>
)}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* 工作区文件浏览器 */}
<FileBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
runId={browserRun?.id ?? null}
buildNumber={browserRun?.buildNumber ?? 0}
/>
</div>
);
}
@@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { Plus, Trash2, Pencil, RefreshCw, Loader2, Users, UserPlus, UserX, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/services/api';
interface Group {
id: number; name: string; description: string;
members?: GroupMember[]; created_by: string; created_at: string;
}
interface GroupMember {
id: number; group_id: number; username: string; created_at: string;
}
export default function UserGroupsManager() {
const _confirm = useConfirm();
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
// Group form
const [formOpen, setFormOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [saving, setSaving] = useState(false);
// Add member — per-group input state
const [memberUsers, setMemberUsers] = useState<Record<number, string>>({});
const loadGroups = async () => {
setLoading(true);
try { const res = await deployApi.getGroups(); setGroups((res as any)?.list || []); }
catch { toast.error('加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { loadGroups(); }, []);
const handleSave = async () => {
if (!formName.trim()) { toast.error('请输入组名'); return; }
setSaving(true);
try {
if (editId) await deployApi.updateGroup(editId, { name: formName, description: formDesc });
else await deployApi.createGroup({ name: formName, description: formDesc });
toast.success(editId ? '已更新' : '已创建');
setFormOpen(false); loadGroups();
} catch { toast.error('操作失败'); }
finally { setSaving(false); }
};
const handleDelete = async (g: Group) => {
const ok = await _confirm({ title: '删除用户组', description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteGroup(g.id); toast.success('已删除'); loadGroups(); }
catch { toast.error('删除失败'); }
};
const handleAddMember = async (groupId: number) => {
const username = (memberUsers[groupId] || '').trim();
if (!username) { toast.error('输入用户名'); return; }
try { await deployApi.addGroupMember(groupId, { username }); toast.success('已添加'); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
catch { toast.error('添加失败'); }
};
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
const ok = await _confirm({ title: '移除成员', description: `确定从组中移除「${username}」?` });
if (!ok) return;
try { await deployApi.removeGroupMember(groupId, memberId); toast.success('已移除'); loadGroups(); }
catch { toast.error('移除失败'); }
};
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">用户组管理</h2>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">创建用户组,将组授权给流水线,组内成员自动获得流水线权限</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadGroups}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : groups.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p>暂无用户组,点击「新建用户组」创建</p>
</div>
) : (
<div className="space-y-3">
{groups.map(g => (
<div key={g.id} className="border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(g.id)}>
<Shield className="w-5 h-5 text-blue-400" />
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || '暂无描述'} · {(g.members || []).length} 位成员</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditId(g.id); setFormName(g.name); setFormDesc(g.description || ''); setFormOpen(true); }}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(g)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{expanded[g.id] && (
<div className="border-t border-[var(--dg-border)] px-4 py-3 space-y-3">
{/* Add member */}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1"><Label className="text-xs">添加用户</Label>
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder="输入用户名" className="h-8 text-sm" onKeyDown={e => e.key === 'Enter' && handleAddMember(g.id)} />
</div>
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />添加</Button>
</div>
{/* Members list */}
{(g.members || []).length === 0 ? (
<div className="text-xs text-[var(--dg-text-secondary)] py-2">暂无成员</div>
) : (
<div className="flex flex-wrap gap-2">
{(g.members || []).map(m => (
<Badge key={m.id} variant="secondary" className="flex items-center gap-1 pl-2 pr-1 py-1.5">
<Users className="w-3 h-3" />{m.username}
<button className="ml-1 hover:text-red-400" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
<UserX className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Form Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editId ? '编辑用户组' : '新建用户组'}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>组名</Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="例如: 后端开发组" /></div>
<div className="space-y-1"><Label>描述</Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" /></div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
<Button variant="outline" onClick={() => setFormOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : '保存'}</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,196 @@
import { useRef, useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUserStore } from '@/store';
import http from '@/services/http';
interface WorkspaceTermDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
runId: number | null;
buildNumber: number;
}
/**
* Workspace 终端 Dialog — 进入流水线构建目录查看文件
*/
export function WorkspaceTermDialog({
open,
onOpenChange,
runId,
buildNumber,
}: WorkspaceTermDialogProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [workspace, setWorkspace] = useState('');
// 获取 workspace 路径
useEffect(() => {
if (!open || !runId) return;
http.get(`/pipelines/runs/${runId}/workspace`)
.then((data: any) => setWorkspace(data?.workspace_path || ''))
.catch(() => {});
}, [open, runId]);
// 连接终端
useEffect(() => {
if (!open || !runId) return;
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
theme: {
background: '#0a0e14',
foreground: '#d6d6d6',
cursor: '#d6d6d6',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
fitRef.current = fit;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = useUserStore.getState().token || '';
const wsUrl = `${proto}://${location.host}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
}
});
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
term.write(msg.data);
}
} catch {
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
setConnected(true);
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try { fit.fit(); term.focus(); } catch { /* noop */ }
});
}
};
ws.onclose = () => {
setConnected(false);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
try { ws.close(); term.dispose(); } catch { /* noop */ }
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, runId]);
// ResizeObserver for fit
useEffect(() => {
if (!open) return;
const container = containerRef.current;
if (!container) return;
let timer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
if (fitRef.current && termRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
if (wsRef.current?.readyState === WebSocket.OPEN) {
const { cols, rows } = termRef.current;
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => { clearTimeout(timer); ro.disconnect(); };
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">
#{buildNumber} 构建目录
</span>
{workspace && (
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
{workspace}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}
+5 -4
View File
@@ -3,12 +3,12 @@ import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store'; import { useThemeStore, useMenuStore } from '@/store';
import { import {
LayoutDashboard, Server, Cloud, Rocket, Bell, Monitor, LayoutDashboard, Server, Cloud, Bell, Monitor,
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight, CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
Terminal, Cpu, Database, FileCode, Shield, Users, Terminal, Cpu, Database, FileCode, Shield, Users,
Menu, Network, CloudCog, Globe, MapPin, Key, Menu, Network, CloudCog, Globe, MapPin, Key,
Brain, FileTerminal, Activity, BarChart3, GitBranch, Brain, FileTerminal, Activity, BarChart3, GitBranch,
Bot, AlertTriangle, Container, Boxes, Clock, Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -73,8 +73,9 @@ const menuItems: MenuItem[] = [
{ {
id: 'deploy', name: '应用发布', icon: Rocket, id: 'deploy', name: '应用发布', icon: Rocket,
children: [ children: [
{ id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' }, { id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines' },
{ id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' }, { id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/credentials' },
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac' },
], ],
}, },
{ {
+58
View File
@@ -409,6 +409,27 @@ export const pipelineApi = {
getRuns: async (id: number | string) => { getRuns: async (id: number | string) => {
return http.get(`/pipelines/${id}/runs`); return http.get(`/pipelines/${id}/runs`);
}, },
// 运行控制
stopRun: async (pipelineId: number | string, runId: number | string) => {
return http.post(`/pipelines/${pipelineId}/runs/${runId}/stop`);
},
getRunLog: async (pipelineId: number | string, runId: number | string) => {
return http.get(`/pipelines/${pipelineId}/runs/${runId}/log`);
},
// 成员管理
getMembers: async (pipelineId: number | string) => {
return http.get(`/pipelines/${pipelineId}/members`);
},
addMember: async (pipelineId: number | string, data: { username: string; role: string }) => {
return http.post(`/pipelines/${pipelineId}/members`, data);
},
removeMember: async (pipelineId: number | string, memberId: number | string) => {
return http.delete(`/pipelines/${pipelineId}/members/${memberId}`);
},
// 组授权
assignGroup: async (pipelineId: number | string, data: { group_id: number; role: string }) => {
return http.post(`/pipelines/${pipelineId}/groups`, data);
},
}; };
// ==================== 应用发布 API ==================== // ==================== 应用发布 API ====================
@@ -722,6 +743,43 @@ export const terminalSessionApi = {
}, },
}; };
// ==================== 发布系统 API(仓库/镜像仓库认证) ====================
export const deployApi = {
// Git 仓库认证
getRepos: () => http.get<ListResponse>('/deploy/repos'),
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
createRepo: (data: any) => http.post('/deploy/repos', data),
updateRepo: (id: number | string, data: any) => http.put(`/deploy/repos/${id}`, data),
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
// 镜像仓库认证
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
createRegistry: (data: any) => http.post('/deploy/registries', data),
updateRegistry: (id: number | string, data: any) => http.put(`/deploy/registries/${id}`, data),
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
// 环境管理
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
createEnvironment: (data: any) => http.post('/deploy/environments', data),
updateEnvironment: (id: number | string, data: any) => http.put(`/deploy/environments/${id}`, data),
deleteEnvironment: (id: number | string) => http.delete(`/deploy/environments/${id}`),
// Item Role 权限管理 (Jenkins Role Strategy 风格)
getItemRoles: () => http.get<ListResponse>('/admin/item-roles'),
createItemRole: (data: any) => http.post('/admin/item-roles', data),
updateItemRole: (id: number | string, data: any) => http.put(`/admin/item-roles/${id}`, data),
deleteItemRole: (id: number | string) => http.delete(`/admin/item-roles/${id}`),
getUserItemRoles: (userId: number | string) => http.get(`/admin/users/${userId}/item-roles`),
assignItemRole: (userId: number | string, data: { user_id: number; item_role_id: number }) => http.post(`/admin/users/${userId}/item-roles`, data),
removeItemRole: (userId: number | string, roleId: number | string) => http.delete(`/admin/users/${userId}/item-roles/${roleId}`),
// 用户组管理
getGroups: () => http.get<ListResponse>('/deploy/groups'),
createGroup: (data: any) => http.post('/deploy/groups', data),
updateGroup: (id: number | string, data: any) => http.put(`/deploy/groups/${id}`, data),
deleteGroup: (id: number | string) => http.delete(`/deploy/groups/${id}`),
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
};
// ==================== Ansible 自动化 API ==================== // ==================== Ansible 自动化 API ====================
export const ansibleApi = { export const ansibleApi = {
// Playbook 管理 // Playbook 管理