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:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7471
-973
No files matched your search
+14
-3
@@ -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"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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{}
|
||||||
|
if cred.Tags != "" {
|
||||||
|
json.Unmarshal([]byte(cred.Tags), &tags)
|
||||||
|
}
|
||||||
|
items = append(items, models.SecretListResponse{
|
||||||
ID: cred.ID,
|
ID: cred.ID,
|
||||||
Name: cred.Name,
|
Name: cred.Name,
|
||||||
Type: cred.Type,
|
VariableName: cred.VariableName,
|
||||||
Preview: preview(cred.Content),
|
SecretType: cred.SecretType,
|
||||||
|
Summary: cred.Summary,
|
||||||
|
Tags: tags,
|
||||||
Description: cred.Description,
|
Description: cred.Description,
|
||||||
|
ExpiresAt: cred.ExpiresAt,
|
||||||
|
IsActive: cred.IsActive,
|
||||||
|
EnvPrefix: cred.EnvPrefix,
|
||||||
CreatedAt: cred.CreatedAt,
|
CreatedAt: cred.CreatedAt,
|
||||||
UpdatedAt: cred.UpdatedAt,
|
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,
|
||||||
|
SecretType: req.SecretType,
|
||||||
|
Summary: summary,
|
||||||
Content: encrypted,
|
Content: encrypted,
|
||||||
|
Tags: string(tagsJSON),
|
||||||
Description: req.Description,
|
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 = ""
|
||||||
|
|||||||
@@ -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: "已授权"})
|
||||||
|
}
|
||||||
@@ -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: "移除成功"})
|
||||||
|
}
|
||||||
@@ -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,9 +157,16 @@ 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,
|
||||||
|
JobName: req.JobName,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Source: req.Source,
|
Source: req.Source,
|
||||||
@@ -108,6 +174,24 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
|||||||
Dockerfile: req.Dockerfile,
|
Dockerfile: req.Dockerfile,
|
||||||
Stages: req.Stages,
|
Stages: req.Stages,
|
||||||
Variables: req.Variables,
|
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,
|
Enabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(¤t, 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(®, *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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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,6 +19,39 @@ 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"`
|
||||||
|
|
||||||
|
// 部署模式
|
||||||
|
DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"`
|
||||||
|
DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"`
|
||||||
|
|
||||||
|
// SSH 部署目标服务器 (JSON: [1,2,3])
|
||||||
|
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"`
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
LastStatus string `gorm:"size:32" json:"last_status"`
|
LastStatus string `gorm:"size:32" json:"last_status"`
|
||||||
RunCount int `gorm:"default:0" json:"run_count"`
|
RunCount int `gorm:"default:0" json:"run_count"`
|
||||||
@@ -25,6 +59,11 @@ type Pipeline struct {
|
|||||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,30 @@ 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"` // 关键信息摘要(脱敏展示)
|
||||||
|
Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
|
||||||
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -19,29 +36,166 @@ 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 {
|
||||||
|
Cert string `json:"cert"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
CA string `json:"ca"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AKSKSecretContent AKSK 凭据 content
|
||||||
|
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"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
VariableName string `json:"variable_name,omitempty"`
|
||||||
Preview string `json:"preview"` // 内容预览(前8+后4字符)
|
SecretType string `json:"secret_type"`
|
||||||
Description string `json:"description"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
@@ -118,6 +152,31 @@ type UpdateRoleRequest struct {
|
|||||||
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 修改密码请求
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
OldPassword string `json:"old_password" binding:"required"`
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
Binary file not shown.
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 管理
|
||||||
|
|||||||
Reference in new issue
Block a user