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

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

No files matched your search

+118 -1
View File
@@ -1,15 +1,132 @@
// Package crypto 提供口令哈希原语(argon2id,与 opsx-loki 同参数)。 // Package crypto 提供口令哈希(argon2id)与字段加密(AES-256-GCM,enc:v1: 前缀)原语。
package crypto package crypto
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand" "crypto/rand"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"errors"
"io"
"math/big" "math/big"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
const encPrefix = "enc:v1:"
var (
encKey []byte
encKeyOnce sync.Once
encKeyErr error
)
// InitEncryptionKey 初始化字段加密密钥:
// 优先环境变量 FLOWSTACK_ENCRYPTION_KEY,否则首次启动生成 32 字节随机密钥
// 写入 dataDir/encryption.key(0600),重启复用。
func InitEncryptionKey(dataDir string) error {
encKeyOnce.Do(func() {
if env := os.Getenv("FLOWSTACK_ENCRYPTION_KEY"); env != "" {
encKey = []byte(env)
return
}
keyFile := filepath.Join(dataDir, "encryption.key")
if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 {
encKey = data
return
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
encKeyErr = err
return
}
if err := os.WriteFile(keyFile, key, 0o600); err != nil {
encKeyErr = err
return
}
encKey = key
})
return encKeyErr
}
func gcm() (cipher.AEAD, error) {
if encKey == nil {
return nil, errors.New("加密密钥未初始化")
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// EncryptField AES-256-GCM 加密,返回 enc:v1:base64;空串原样返回
func EncryptField(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
aead, err := gcm()
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := aead.Seal(nonce, nonce, []byte(plaintext), nil)
return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
}
// DecryptField 解密 enc:v1: 前缀密文;非该格式原样返回(向后兼容)
func DecryptField(encrypted string) (string, error) {
if !strings.HasPrefix(encrypted, encPrefix) {
return encrypted, nil
}
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encrypted, encPrefix))
if err != nil {
return "", err
}
aead, err := gcm()
if err != nil {
return "", err
}
if len(data) < aead.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
plain, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// IsEncrypted 判断字段是否为加密格式
func IsEncrypted(v string) bool {
return strings.HasPrefix(v, encPrefix)
}
// Mask 敏感值脱敏展示(保留前后各 1/4,中间星号,最长 24 位)
func Mask(s string) string {
if s == "" {
return ""
}
r := []rune(s)
if len(r) <= 4 {
return strings.Repeat("*", len(r))
}
keep := len(r) / 4
if keep > 4 {
keep = 4
}
return string(r[:keep]) + strings.Repeat("*", 6) + string(r[len(r)-keep:])
}
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码) // HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
salt := make([]byte, 16) salt := make([]byte, 16)
+77 -2
View File
@@ -4,6 +4,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,6 +24,9 @@ func Init() error {
if err := os.MkdirAll(DataDir, 0o755); err != nil { if err := os.MkdirAll(DataDir, 0o755); err != nil {
return err return err
} }
if err := crypto.InitEncryptionKey(DataDir); err != nil {
return err
}
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
if err != nil { if err != nil {
@@ -35,12 +39,16 @@ func Init() error {
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{}, &model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{}, &model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
&model.Application{}, &model.Plugin{}, &model.Application{}, &model.Plugin{},
&model.Host{}, &model.K8sCluster{}, &model.Credential{}, &model.BuildNode{},
&model.DeployTool{}, &model.WebhookEndpoint{}, &model.JenkinsServer{}, &model.SystemSetting{},
); err != nil { ); err != nil {
return err return err
} }
seedAdmin() seedAdmin()
seedDemoData() seedDemoData()
seedResources()
seedTools()
return nil return nil
} }
@@ -153,9 +161,10 @@ func seedDemoData() {
}) })
// ---- GitOps 应用 ---- // ---- GitOps 应用 ----
now := time.Now()
DB.Create([]model.Application{ DB.Create([]model.Application{
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true}, {Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true, SyncStatus: "Synced", HealthStatus: "Healthy", LastSyncAt: &now},
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual"}, {Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual", SyncStatus: "OutOfSync", HealthStatus: "Progressing"},
}) })
// ---- 已安装插件 ---- // ---- 已安装插件 ----
@@ -165,3 +174,69 @@ func seedDemoData() {
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false}, {PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
}) })
} }
// seedResources 资源示例数据(主机/集群/凭证/构建节点/Webhook,各表独立判空)
func seedResources() {
var n int64
DB.Model(&model.Host{}).Count(&n)
if n == 0 {
DB.Create([]model.Host{
{Name: "build-host-01", IP: "10.0.8.11", SSHPort: 22, Username: "root", Tags: `["build","docker"]`, Status: "online", OS: "Ubuntu 22.04", Remark: "通用构建机"},
{Name: "prod-web-01", IP: "10.0.1.21", SSHPort: 22, Username: "admin", Tags: `["production","web"]`, Status: "online", OS: "CentOS 7.9", Remark: "Nginx 前端"},
})
}
DB.Model(&model.K8sCluster{}).Count(&n)
if n == 0 {
DB.Create([]model.K8sCluster{
{Name: "ack-production", Context: "ack-production", APIServer: "https://k8s.prod:6443", Version: "v1.30.1", NodeCount: 3, Namespaces: `["aiops","default","kube-system"]`, Status: "disconnected"},
})
}
DB.Model(&model.Credential{}).Count(&n)
if n == 0 {
enc1, _ := crypto.EncryptField(`{"private_key":"CHANGE_ME","ssh_user":"root","ssh_port":22}`)
DB.Create([]model.Credential{
{Name: "prod-ssh-key", Type: "ssh", Scope: "production-group", Content: enc1, Summary: "ssh_key root@22"},
{Name: "registry-example", Type: "docker", Scope: "全局", Summary: "docker registry.example.com aiops-pull"},
})
}
DB.Model(&model.BuildNode{}).Count(&n)
if n == 0 {
hb := time.Now().Add(-10 * time.Second)
DB.Create([]model.BuildNode{
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, LastHeartbeat: &hb},
{Name: "build-host-02", IP: "10.0.8.12", Labels: `["docker","nodejs"]`, MaxConcurrency: 2, Status: "busy", CurrentJobs: 2, LastHeartbeat: &hb},
{Name: "build-host-03", IP: "10.0.8.13", Labels: `["python"]`, MaxConcurrency: 2, Status: "offline", LastHeartbeat: &hb},
})
}
DB.Model(&model.WebhookEndpoint{}).Count(&n)
if n == 0 {
DB.Create([]model.WebhookEndpoint{
{Name: "发布成功通知", URL: "https://open.feishu.cn/open-apis/bot/v2/hook/CHANGE_ME", Events: `["pipeline.success","pipeline.failed"]`, Active: true, LastStatus: "none"},
})
}
DB.Model(&model.SystemSetting{}).Count(&n)
if n == 0 {
DB.Create(&model.SystemSetting{
ID: 1, ToolMode: "local",
Theme: `{"mode":"light","primaryColor":"#0969da","fontSize":"medium","density":"comfortable","sidebarStyle":"full","borderRadius":"soft"}`,
Notification: `{"email":false,"browser":true,"pipelineSuccess":true,"pipelineFailed":true,"securityAlert":false}`,
})
}
}
// seedTools 工具清单(CheckCommand 为真实版本探测命令,安装走保守检测策略)
func seedTools() {
var n int64
DB.Model(&model.DeployTool{}).Count(&n)
if n > 0 {
return
}
DB.Create([]model.DeployTool{
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17", CheckCommand: "java -version", SortOrder: 1},
{ToolKey: "maven", DisplayName: "Maven", Category: "build", Version: "3.9", InstallMode: "lazy", Path: "/opt/maven", CheckCommand: "mvn -version", SortOrder: 2},
{ToolKey: "golang", DisplayName: "Golang", Category: "build", Version: "1.24", InstallMode: "preinstalled", Path: "/usr/local/go", CheckCommand: "go version", SortOrder: 3},
{ToolKey: "nodejs", DisplayName: "Node.js", Category: "build", Version: "20", InstallMode: "lazy", Path: "/usr/local/nvm", CheckCommand: "node -v", SortOrder: 4},
{ToolKey: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", SortOrder: 5},
{ToolKey: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", SortOrder: 6},
})
}
+45 -15
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -9,16 +10,6 @@ import (
"flowstack/internal/model" "flowstack/internal/model"
) )
// argoApps 模拟 Argo CD 应用状态(演示用)
var argoApps = []gin.H{
{"metadata": gin.H{"name": "aiops-backend"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
{"metadata": gin.H{"name": "aiops-web"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
}
// ListGitOpsApps GET /api/gitops/apps // ListGitOpsApps GET /api/gitops/apps
func ListGitOpsApps(c *gin.Context) { func ListGitOpsApps(c *gin.Context) {
var apps []model.Application var apps []model.Application
@@ -26,12 +17,33 @@ func ListGitOpsApps(c *gin.Context) {
c.JSON(http.StatusOK, apps) c.JSON(http.StatusOK, apps)
} }
// ArgoList GET /api/gitops/argo/apps // ArgoList GET /api/gitops/argo/apps —— 从 Application 表派生 Argo 风格状态
func ArgoList(c *gin.Context) { func ArgoList(c *gin.Context) {
c.JSON(http.StatusOK, argoApps) var apps []model.Application
database.DB.Order("updated_at DESC").Find(&apps)
result := make([]gin.H, 0, len(apps))
for _, a := range apps {
phase := "Succeeded"
if a.SyncStatus == "OutOfSync" {
phase = "Running"
}
result = append(result, gin.H{
"metadata": gin.H{"name": a.Name},
"spec": gin.H{
"source": gin.H{"repoURL": a.Repo, "path": a.Path, "targetRevision": a.Branch},
"destination": gin.H{"server": "https://" + a.Cluster, "namespace": a.Namespace},
},
"status": gin.H{
"sync": gin.H{"status": a.SyncStatus},
"health": gin.H{"status": a.HealthStatus},
"operationState": gin.H{"phase": phase},
},
})
}
c.JSON(http.StatusOK, result)
} }
// ArgoSync POST /api/gitops/argo/sync {appName} // ArgoSync POST /api/gitops/argo/sync {appName} —— 真实推进 Application 同步状态
func ArgoSync(c *gin.Context) { func ArgoSync(c *gin.Context) {
var body struct { var body struct {
AppName string `json:"appName" binding:"required"` AppName string `json:"appName" binding:"required"`
@@ -40,6 +52,22 @@ func ArgoSync(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
return return
} }
var app model.Application
if err := database.DB.Where("name = ?", body.AppName).First(&app).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "application not found"})
return
}
database.DB.Model(&app).Updates(map[string]any{
"sync_status": "Syncing", "health_status": "Progressing",
})
// 异步完成同步(3 秒后 Synced/Healthy),模拟 Argo 同步推进
go func(id uint, name string) {
time.Sleep(3 * time.Second)
done := time.Now()
database.DB.Model(&model.Application{}).Where("id = ?", id).Updates(map[string]any{
"sync_status": "Synced", "health_status": "Healthy", "last_sync_at": &done,
})
}(app.ID, app.Name)
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"}) c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
} }
@@ -56,12 +84,14 @@ func ArgoResourceTree(c *gin.Context) {
}) })
} }
// ArgoDiff GET /api/gitops/argo/:appName/diff // ArgoDiff GET /api/gitops/argo/:appName/diff —— 基于 Application 与目标镜像 tag 生成 diff
func ArgoDiff(c *gin.Context) { func ArgoDiff(c *gin.Context) {
name := c.Param("appName") name := c.Param("appName")
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"diffs": []gin.H{ "diffs": []gin.H{
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"}, {"kind": "Deployment", "name": name, "namespace": "aiops",
"diff": "- image: registry.example.com/aiops/" + name + ":" + time.Now().Add(-24*time.Hour).Format("200601021500") + "\n+ image: registry.example.com/aiops/" + name + ":" + time.Now().Format("200601021500"),
"status": "modified"},
}, },
}) })
} }
+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/jenkins"
"flowstack/internal/model"
)
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
token := s.Token
if crypto.IsEncrypted(token) {
if plain, err := crypto.DecryptField(token); err == nil {
token = plain
}
}
return jenkins.NewClient(s.BaseURL, s.Username, token)
}
// ---- Jenkins 服务器 CRUD ----
func ListJenkinsServers(c *gin.Context) {
var list []model.JenkinsServer
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].TokenSet = list[i].Token != ""
list[i].Token = ""
}
c.JSON(http.StatusOK, list)
}
func CreateJenkinsServer(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
baseURL, _ := raw["baseUrl"].(string)
if name == "" || baseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
return
}
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
s.Username, _ = raw["username"].(string)
if token, ok := raw["token"].(string); ok && token != "" {
enc, err := crypto.EncryptField(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
return
}
s.Token = enc
}
if err := database.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func UpdateJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
if v, ok := raw[k]; ok {
key := k
if k == "baseUrl" {
key = "base_url"
}
updates[key] = v
}
}
if token, ok := raw["token"].(string); ok && token != "" {
if enc, err := crypto.EncryptField(token); err == nil {
updates["token"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&s).Updates(updates)
}
database.DB.First(&s, s.ID)
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func DeleteJenkinsServer(c *gin.Context) {
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
func TestJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
start := time.Now()
err := clientFromServer(s).Test()
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
"message": func() string {
if err != nil {
return "连接失败: " + err.Error()
}
return "连接成功,认证通过"
}(),
})
}
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
func ListJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
jobs, err := clientFromServer(s).ListJobs()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, jobs)
}
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
func ImportJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var body struct {
Jobs []string `json:"jobs"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
return
}
imported, skipped := 0, 0
for _, jobPath := range body.Jobs {
var exist model.Pipeline
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
skipped++
continue
}
name := jobPath
if len(name) > 60 {
name = name[len(name)-60:]
}
p := model.Pipeline{
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
}
// 名称冲突时追加后缀保证唯一
base := name
for i := 0; ; i++ {
var cnt int64
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
if cnt == 0 {
break
}
if i >= 5 {
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
break
}
p.Name = fmt.Sprintf("%s-%d", base, i+1)
}
database.DB.Create(&p)
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
}
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
start := time.Now()
emit := func(msg string) {
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
database.DB.Create(&entry)
hub.Publish(run.RunID, "log", entry)
}
setRun := func(status string) {
now := time.Now()
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
})
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
})
}
}
emit("$ jenkins build \"" + jobName + "\"")
queueURL, err := client.Trigger(jobName, nil)
if err != nil {
emit("触发失败: " + err.Error())
setRun("failed")
return
}
emit("已进入 Jenkins 构建队列…")
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
if err != nil {
emit("队列等待失败: " + err.Error())
setRun("failed")
return
}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
})
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
client.StreamConsole(buildURL, emit)
result, err := client.WaitResult(buildURL, 30*time.Minute)
if err != nil {
emit("获取构建结果超时: " + err.Error())
setRun("failed")
return
}
emit("Jenkins 结果: " + result)
setRun(jenkins.MapResult(result))
}
+95 -10
View File
@@ -10,15 +10,38 @@ import (
"flowstack/internal/model" "flowstack/internal/model"
) )
// ListPipelines GET /api/pipelines // ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
func ListPipelines(c *gin.Context) { func ListPipelines(c *gin.Context) {
var list []model.Pipeline var list []model.Pipeline
database.DB.Order("updated_at DESC").Find(&list) database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
var runs []model.PipelineRun
database.DB.Order("started_at DESC").Limit(500).Find(&runs)
lastByPipeline := make(map[uint]model.PipelineRun)
for _, r := range runs {
if _, ok := lastByPipeline[r.PipelineID]; !ok {
lastByPipeline[r.PipelineID] = r
}
} }
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线 type pipelineWithLastRun struct {
func GetPipeline(c *gin.Context) { model.Pipeline
LastRun *model.PipelineRun `json:"lastRun"`
}
result := make([]pipelineWithLastRun, 0, len(list))
for _, p := range list {
item := pipelineWithLastRun{Pipeline: p}
if r, ok := lastByPipeline[p.ID]; ok {
item.LastRun = &r
}
result = append(result, item)
}
c.JSON(http.StatusOK, result)
}
// GetPipelineDetail GET /api/pipelines/:id —— 完整详情(编辑器各 Tab 数据源)
func GetPipelineDetail(c *gin.Context) {
var p model.Pipeline var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil { if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
@@ -28,16 +51,73 @@ func GetPipeline(c *gin.Context) {
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes) database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
var edges []model.PipelineEdge var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges) database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
var lastRun model.PipelineRun
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"pipeline": p, "nodes": nodes, "edges": edges, "pipeline": p, "nodes": nodes, "edges": edges,
"name": p.Name, "description": p.Description, "type": p.Type, "lastRun": func() *model.PipelineRun {
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch, if hasRun {
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath, return &lastRun
"deployType": p.DeployType, "env": p.Env, }
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt, return nil
}(),
}) })
} }
// GetSummary GET /api/summary —— 侧边栏徽标与首页统计
func GetSummary(c *gin.Context) {
var buildNodes, plugins, running, pipelines, hosts, clusters int64
database.DB.Model(&model.BuildNode{}).Count(&buildNodes)
database.DB.Model(&model.Plugin{}).Count(&plugins)
database.DB.Model(&model.PipelineRun{}).Where("status IN ('pending','queued','running')").Count(&running)
database.DB.Model(&model.Pipeline{}).Count(&pipelines)
database.DB.Model(&model.Host{}).Count(&hosts)
database.DB.Model(&model.K8sCluster{}).Count(&clusters)
c.JSON(http.StatusOK, gin.H{
"buildNodes": buildNodes, "plugins": plugins, "running": running,
"pipelines": pipelines, "hosts": hosts, "clusters": clusters,
})
}
// GetSettings GET /api/settings —— 单行设置
func GetSettings(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1}
database.DB.Create(&s)
}
c.JSON(http.StatusOK, s)
}
// UpdateSettings PUT /api/settings
func UpdateSettings(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1}
database.DB.Create(&s)
}
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{"updated_at": time.Now()}
if v, ok := body["theme"].(string); ok {
updates["theme"] = v
}
if v, ok := body["notification"].(string); ok {
updates["notification"] = v
}
if v, ok := body["toolMode"].(string); ok {
updates["tool_mode"] = v
}
if v, ok := body["extra"].(string); ok {
updates["extra"] = v
}
database.DB.Model(&s).Updates(updates)
c.JSON(http.StatusOK, s)
}
// CreatePipeline POST /api/pipelines // CreatePipeline POST /api/pipelines
func CreatePipeline(c *gin.Context) { func CreatePipeline(c *gin.Context) {
var input struct { var input struct {
@@ -82,7 +162,12 @@ func UpdatePipeline(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true} allowed := map[string]bool{
"name": true, "description": true, "sourceRepo": true, "sourceBranch": true,
"gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true,
"triggerMode": true, "cronExpr": true, "webhookToken": true, "watchBranches": true,
"variables": true, "cachePaths": true, "notifyWebhookURL": true, "jenkinsJobName": true,
}
updates := map[string]any{} updates := map[string]any{}
for k, v := range input { for k, v := range input {
if allowed[k] { if allowed[k] {
+755
View File
@@ -0,0 +1,755 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/model"
)
// dialTCP 真实连通性测试:TCP 拨号 5 秒超时
func dialTCP(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return err
}
return conn.Close()
}
// ============================================================
// 主机 /api/hosts
// ============================================================
func ListHosts(c *gin.Context) {
var list []model.Host
q := database.DB.Order("updated_at DESC")
if tag := c.Query("tag"); tag != "" {
q = q.Where("tags LIKE ?", "%"+tag+"%")
}
q.Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateHost(c *gin.Context) {
var h model.Host
if err := c.ShouldBindJSON(&h); err != nil || h.Name == "" || h.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
h.ID = 0
if err := database.DB.Create(&h).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, h)
}
func UpdateHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
var body map[string]any
_ = c.ShouldBindJSON(&body)
for _, k := range []string{"name", "ip", "port", "username", "tags", "os", "remark"} {
if v, ok := body[k]; ok {
database.DB.Model(&h).Update(k, v)
}
}
database.DB.First(&h, h.ID)
c.JSON(http.StatusOK, h)
}
func DeleteHost(c *gin.Context) {
database.DB.Delete(&model.Host{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestHost POST /api/hosts/:id/test —— 真实 TCP 拨号 SSH 端口
func TestHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
start := time.Now()
err := dialTCP(fmt.Sprintf("%s:%d", h.IP, h.SSHPort))
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
database.DB.Model(&h).Update("status", status)
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("SSH 端口可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 集群 /api/clusters
// ============================================================
func ListClusters(c *gin.Context) {
var list []model.K8sCluster
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].KubeconfigSet = list[i].Kubeconfig != ""
list[i].Kubeconfig = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCluster(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
return
}
cl := model.K8sCluster{Name: name}
cl.Context, _ = raw["context"].(string)
cl.APIServer, _ = raw["apiServer"].(string)
cl.Version, _ = raw["version"].(string)
if v, ok := raw["nodes"].(float64); ok {
cl.NodeCount = int(v)
}
if v, ok := raw["namespaces"].(string); ok {
cl.Namespaces = v
} else if ns, ok := raw["namespaces"].([]any); ok {
if b, err := json.Marshal(ns); err == nil {
cl.Namespaces = string(b)
}
}
if kubeconfig, _ := raw["kubeconfig"].(string); kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
cl.Kubeconfig = enc
}
if err := database.DB.Create(&cl).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func UpdateCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
for _, k := range []string{"name", "context", "apiServer", "version", "nodes", "namespaces"} {
if v, ok := raw[k]; ok {
updates[k] = v
}
}
// kubeconfig 只写不读:非空才更新
if kubeconfig, ok := raw["kubeconfig"].(string); ok && kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
updates["kubeconfig"] = enc
}
if len(updates) > 0 {
database.DB.Model(&cl).Updates(updates)
}
database.DB.First(&cl, cl.ID)
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func DeleteCluster(c *gin.Context) {
database.DB.Delete(&model.K8sCluster{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestCluster POST /api/clusters/:id/test —— 拨号 APIServer(或 kubeconfig 中的 server)
func TestCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
target := cl.APIServer
if cl.Kubeconfig != "" && crypto.IsEncrypted(cl.Kubeconfig) {
if plain, err := crypto.DecryptField(cl.Kubeconfig); err == nil {
for _, line := range strings.Split(plain, "\n") {
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "server:") {
target = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
break
}
}
}
}
if target == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未配置 APIServer 或 kubeconfig"})
return
}
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
err := dialTCP(host)
status := "connected"
if err != nil {
status = "disconnected"
}
now := time.Now()
database.DB.Model(&cl).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status,
"message": map[bool]string{true: "APIServer 可达", false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 凭证 /api/credentials(内容只写不读)
// ============================================================
func ListCredentials(c *gin.Context) {
var list []model.Credential
q := database.DB.Order("updated_at DESC")
if t := c.Query("type"); t != "" && t != "all" {
q = q.Where("type = ?", t)
}
q.Find(&list)
for i := range list {
list[i].ContentSet = list[i].Content != ""
list[i].Content = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCredential(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
typ, _ := raw["type"].(string)
content, _ := raw["content"].(string)
if name == "" || typ == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和类型不能为空"})
return
}
cred := model.Credential{Name: name, Type: typ}
if scope, ok := raw["scope"].(string); ok {
cred.Scope = scope
}
if content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
cred.Content = enc
cred.Summary = summarizeCredential(typ, content)
}
if err := database.DB.Create(&cred).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
// summarizeCredential 从明文 JSON 中提取脱敏摘要(不落明文)
func summarizeCredential(typ, contentJSON string) string {
var m map[string]any
if err := json.Unmarshal([]byte(contentJSON), &m); err != nil {
return typ + " 凭证"
}
pick := func(keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return k + "=" + crypto.Mask(v)
}
}
return ""
}
switch typ {
case "ssh":
return typ + " " + pick("ssh_user", "username", "private_key")
case "docker":
return typ + " " + pick("server") + " " + pick("username")
case "git":
return typ + " " + pick("url", "username")
case "kubeconfig":
return typ + " (kubeconfig)"
case "aksk":
return typ + " " + pick("access_key")
default:
if len(m) > 0 {
return typ + " (" + fmt.Sprint(len(m)) + " 项)"
}
return typ + " 凭证"
}
}
func UpdateCredential(c *gin.Context) {
var cred model.Credential
if err := database.DB.First(&cred, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
if v, ok := raw["name"].(string); ok && v != "" {
updates["name"] = v
}
if v, ok := raw["scope"].(string); ok {
updates["scope"] = v
}
if content, ok := raw["content"].(string); ok && content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
updates["content"] = enc
typ, _ := raw["type"].(string)
if typ == "" {
typ = cred.Type
}
updates["summary"] = summarizeCredential(typ, content)
}
if len(updates) > 0 {
database.DB.Model(&cred).Updates(updates)
}
database.DB.First(&cred, cred.ID)
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
func DeleteCredential(c *gin.Context) {
database.DB.Delete(&model.Credential{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ============================================================
// 构建节点 /api/build-nodes
// ============================================================
func ListBuildNodes(c *gin.Context) {
var list []model.BuildNode
database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" || n.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
n.ID = 0
if err := database.DB.Create(&n).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, n)
}
func UpdateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&n).Update(k, v)
}
}
database.DB.First(&n, n.ID)
c.JSON(http.StatusOK, n)
}
func DeleteBuildNode(c *gin.Context) {
database.DB.Delete(&model.BuildNode{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestBuildNode POST /api/build-nodes/:id/test —— 真实 TCP 拨号
func TestBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
start := time.Now()
err := dialTCP(n.IP + ":22")
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
now := time.Now()
database.DB.Model(&n).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("节点可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 工具 /api/tools(真实检测 + 保守安装)
// ============================================================
func ListTools(c *gin.Context) {
var list []model.DeployTool
database.DB.Order("sort_order").Find(&list)
c.JSON(http.StatusOK, list)
}
func UpdateTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"installMode", "version", "path", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&t).Update(k, v)
}
}
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, t)
}
// runToolCheck 真实执行 CheckCommand,返回输出中的版本号片段
func runToolCheck(checkCommand string) (version string, out string, err error) {
cmd := exec.Command("sh", "-c", checkCommand)
cmd.Stderr = nil
bytes, err := cmd.Output()
out = strings.TrimSpace(string(bytes))
if err != nil {
// java -version 等把版本打到 stderr
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
out = strings.TrimSpace(string(exitErr.Stderr))
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "version") {
return extractVersion(line), out, nil
}
}
}
return "", out, err
}
return extractVersion(out), out, nil
}
// extractVersion 从命令输出中提取首个 x.y.z 形态的版本号
func extractVersion(s string) string {
fields := strings.Fields(s)
inVersion := false
for i, f := range fields {
if strings.Contains(f, "version") {
if i+1 < len(fields) {
return strings.Trim(fields[i+1], `"`)
}
inVersion = true
continue
}
if inVersion {
return strings.Trim(f, `"`)
}
}
// 兜底:找 x.y.z
for _, f := range fields {
if strings.Count(f, ".") >= 1 && f[0] >= '0' && f[0] <= '9' {
return strings.Trim(f, `"`)
}
}
return ""
}
// CheckTool POST /api/tools/:id/check —— 真实执行检测命令并回填版本
func CheckTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
} else {
updates["status"] = "not-installed"
updates["error_msg"] = "未检测到:" + firstLine(out, err)
}
database.DB.Model(&t).Updates(updates)
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, gin.H{"tool": t, "output": out, "detected": version})
}
// InstallTool POST /api/tools/:id/install —— 保守安装:真实检测,检出即装;未检出标记 failed 提示手动安装
func InstallTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
database.DB.Model(&t).Update("status", "installing")
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
c.JSON(http.StatusOK, gin.H{"success": true, "message": "检测到已安装 " + version, "version": version, "output": out})
} else {
updates["status"] = "failed"
updates["error_msg"] = "本机未检测到 " + t.DisplayName + ",保守安装策略不会自动下载,请手动安装(期望版本 " + t.Version + ")"
c.JSON(http.StatusOK, gin.H{"success": false, "message": updates["error_msg"], "output": firstLine(out, err)})
}
database.DB.Model(&t).Updates(updates)
}
func firstLine(out string, err error) string {
if err != nil {
return "命令执行失败: " + err.Error()
}
for _, l := range strings.Split(out, "\n") {
if l != "" {
return l
}
}
return out
}
// ============================================================
// Webhook /api/webhooks(HMAC-SHA256 签名)
// ============================================================
func ListWebhooks(c *gin.Context) {
var list []model.WebhookEndpoint
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].SecretSet = list[i].Secret != ""
list[i].Secret = ""
}
c.JSON(http.StatusOK, list)
}
func CreateWebhook(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
url, _ := raw["url"].(string)
if name == "" || url == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 URL 不能为空"})
return
}
wh := model.WebhookEndpoint{Name: name, URL: url, Active: true}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
wh.Events = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
enc, err := crypto.EncryptField(secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
wh.Secret = enc
}
if err := database.DB.Create(&wh).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func UpdateWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
if v, ok := raw["name"].(string); ok {
updates["name"] = v
}
if v, ok := raw["url"].(string); ok {
updates["url"] = v
}
if v, ok := raw["active"].(bool); ok {
updates["active"] = v
}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
updates["events"] = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
if enc, err := crypto.EncryptField(secret); err == nil {
updates["secret"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&wh).Updates(updates)
}
database.DB.First(&wh, wh.ID)
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func DeleteWebhook(c *gin.Context) {
database.DB.Delete(&model.WebhookEndpoint{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestWebhook POST /api/webhooks/:id/test —— 真实 HTTP POST + HMAC-SHA256 签名
func TestWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
payload, _ := json.Marshal(map[string]any{
"event": "webhook.test", "timestamp": time.Now().Format(time.RFC3339),
"message": "FlowStack Webhook 测试消息",
})
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", "webhook.test")
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
now := time.Now()
if err != nil {
database.DB.Model(&wh).Updates(map[string]any{"last_status": "failed", "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
return
}
defer resp.Body.Close()
status := "success"
if resp.StatusCode >= 300 {
status = "failed"
}
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": resp.StatusCode < 300, "statusCode": resp.StatusCode, "message": fmt.Sprintf("响应 %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))})
}
// NotifyWebhookEvent 流水线事件发生后通知订阅了该事件的 Webhook(异步调用)
func NotifyWebhookEvent(event string, data map[string]any) {
go func() {
var endpoints []model.WebhookEndpoint
database.DB.Where("active = ?", true).Find(&endpoints)
payload, _ := json.Marshal(map[string]any{"event": event, "timestamp": time.Now().Format(time.RFC3339), "data": data})
client := &http.Client{Timeout: 10 * time.Second}
for _, wh := range endpoints {
if !strings.Contains(wh.Events, event) {
continue
}
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", event)
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
status := "success"
if resp, err := client.Do(req); err != nil || resp.StatusCode >= 300 {
status = "failed"
if resp != nil {
resp.Body.Close()
}
}
now := time.Now()
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
}
}()
}
// InboundPipelineHook POST /api/hooks/pipeline/:id?token= —— 入站触发(TriggerPanel 的 Webhook URL)
func InboundPipelineHook(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
token := c.Query("token")
if p.WebhookToken != "" && token != p.WebhookToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token 校验失败"})
return
}
if p.TriggerMode != "webhook" && p.TriggerMode != "git_push" {
c.JSON(http.StatusConflict, gin.H{"error": "流水线未启用 Webhook 触发"})
return
}
go triggerPipelineRun(hub, p, "", "", "webhook", "webhook")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已触发流水线 " + p.Name})
}
}
+67 -17
View File
@@ -13,7 +13,7 @@ import (
"flowstack/internal/model" "flowstack/internal/model"
) )
// CreateRun 创建运行并启动模拟执行引擎 // CreateRun POST /api/pipelines/:id/runs —— 创建运行并启动执行引擎
func CreateRun(hub *Hub) gin.HandlerFunc { func CreateRun(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var p model.Pipeline var p model.Pipeline
@@ -21,28 +21,42 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return return
} }
var body struct { var body struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Env string `json:"env"` Env string `json:"env"`
TriggerBy string `json:"triggerBy"` TriggerBy string `json:"triggerBy"`
TriggerType string `json:"triggerType"`
} }
_ = c.ShouldBindJSON(&body) _ = c.ShouldBindJSON(&body)
if body.Branch == "" {
body.Branch = p.SourceBranch run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
} }
if body.Env == "" { c.JSON(http.StatusOK, run)
body.Env = p.Env
} }
if body.TriggerBy == "" { }
body.TriggerBy = "admin"
// triggerPipelineRun 统一的运行创建入口:校验节点 → 建 Run/Stages → 启动执行引擎
func triggerPipelineRun(hub *Hub, p model.Pipeline, branch, env, triggerBy, triggerType string) (*model.PipelineRun, error) {
if branch == "" {
branch = p.SourceBranch
}
if env == "" {
env = p.Env
}
if triggerBy == "" {
triggerBy = "admin"
}
if triggerType == "" {
triggerType = "manual"
} }
var nodes []model.PipelineNode var nodes []model.PipelineNode
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes) database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
if len(nodes) == 0 { if len(nodes) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"}) return nil, fmt.Errorf("流水线没有节点,请先在画布中编排并保存")
return
} }
var edges []model.PipelineEdge var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges) database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
@@ -52,14 +66,23 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
run := model.PipelineRun{ run := model.PipelineRun{
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()), RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
PipelineID: p.ID, PipelineName: p.Name, PipelineID: p.ID, PipelineName: p.Name,
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type, Branch: branch, Env: env, DeployType: p.DeployType, Type: p.Type,
TriggerBy: body.TriggerBy, TriggerType: "manual", TriggerBy: triggerBy, TriggerType: triggerType,
Status: "running", StartedAt: time.Now(), Status: "running", StartedAt: time.Now(),
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON), NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
} }
if err := database.DB.Create(&run).Error; err != nil { if err := database.DB.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return nil, err
return }
// jenkins 类型流水线走真实 Jenkins 执行引擎
if p.Type == "jenkins" && p.JenkinsJobName != "" {
var server model.JenkinsServer
if err := database.DB.First(&server, p.JenkinsServerID).Error; err == nil && server.Enabled {
client := clientFromServer(server)
go jenkinsRunEngine(hub, run, client, p.JenkinsJobName)
return &run, nil
}
} }
// 按画布 X 坐标从左到右生成执行阶段 // 按画布 X 坐标从左到右生成执行阶段
@@ -71,9 +94,7 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
} }
go simulateRun(hub, run, stages) go simulateRun(hub, run, stages)
return &run, nil
c.JSON(http.StatusOK, run)
}
} }
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送 // simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
@@ -106,6 +127,13 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now} updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates) database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()}) hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName,
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
})
}
} }
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0) emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
@@ -270,3 +298,25 @@ func StreamLogs(hub *Hub) gin.HandlerFunc {
} }
} }
} }
// RetryRun POST /api/runs/:runId/retry —— 以同一流水线与分支重新触发
func RetryRun(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var prev model.PipelineRun
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&prev).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
var p model.Pipeline
if err := database.DB.First(&p, prev.PipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, run)
}
}
+313
View File
@@ -0,0 +1,313 @@
// Package jenkins Jenkins REST 客户端(移植自 opsx-jenkins 生产实现):
// crumb → BasicAuth → buildWithParameters → Location 队列 URL → 队列/构建轮询 → progressiveText 增量控制台。
package jenkins
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
BaseURL string
Username string
Token string
HTTP *http.Client
}
func NewClient(baseURL, username, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Username: username,
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// JobInfo Job 概要(folder 已压平为 path/name 形式)
type JobInfo struct {
Name string `json:"name"` // 压平路径 folder/job
URL string `json:"url"`
Type string `json:"type"` // folder | freestyle | pipeline | multibranch
Status string `json:"status"` // success | failed | running | unknown
}
func (c *Client) do(method, path string, form url.Values) (*http.Response, error) {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.Username, c.Token)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// CSRF crumb(未启用时 /crumbIssuer 返回 404,跳过)
if crumb, err := c.crumb(); err == nil {
req.Header.Set(crumb[0], crumb[1])
}
return c.HTTP.Do(req)
}
func (c *Client) crumb() ([2]string, error) {
req, _ := http.NewRequest("GET", c.BaseURL+"/crumbIssuer/api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := c.HTTP.Do(req)
if err != nil {
return [2]string{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return [2]string{}, errors.New("crumb not enabled")
}
var out struct {
CrumbRequestField string `json:"crumbRequestField"`
Crumb string `json:"crumb"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Crumb == "" {
return [2]string{}, errors.New("no crumb")
}
return [2]string{out.CrumbRequestField, out.Crumb}, nil
}
// Test 连通测试:访问 /api/json
func (c *Client) Test() error {
resp, err := c.do("GET", "/api/json", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return errors.New("认证失败:用户名或 Token 错误")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
type rawJob struct {
Name string `json:"name"`
URL string `json:"url"`
Color string `json:"color"`
Jobs []rawJob `json:"jobs"`
}
// colorToStatus Jenkins ball color → 状态
func colorToStatus(color string) string {
switch {
case color == "":
return "unknown"
case strings.HasSuffix(color, "anime"):
return "running"
case strings.HasPrefix(color, "blue"):
return "success"
case strings.HasPrefix(color, "red"):
return "failed"
case strings.HasPrefix(color, "notbuilt"), strings.HasPrefix(color, "disabled"), strings.HasPrefix(color, "aborted"):
return "unknown"
default:
return "unknown"
}
}
// ListJobs 递归 3 层拉取 Job 树并压平 folder
func (c *Client) ListJobs() ([]JobInfo, error) {
resp, err := c.do("GET", "/api/json?tree=jobs[name,url,color,jobs[name,url,color,jobs[name,url,color,jobs[name,url,color]]]]", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var root struct {
Jobs []rawJob `json:"jobs"`
}
if err := json.NewDecoder(resp.Body).Decode(&root); err != nil {
return nil, err
}
var flat []JobInfo
var walk func(jobs []rawJob, prefix string)
walk = func(jobs []rawJob, prefix string) {
for _, j := range jobs {
full := j.Name
if prefix != "" {
full = prefix + "/" + j.Name
}
if len(j.Jobs) > 0 {
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "folder", Status: "unknown"})
walk(j.Jobs, full)
} else {
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "pipeline", Status: colorToStatus(j.Color)})
}
}
}
walk(root.Jobs, "")
return flat, nil
}
// jobPathToURL folder/job → /job/folder/job/job/name
func jobPathToURL(jobPath string) string {
segs := strings.Split(jobPath, "/")
var b strings.Builder
for _, s := range segs {
b.WriteString("/job/")
b.WriteString(url.PathEscape(s))
}
return b.String()
}
// Trigger 触发构建,返回队列 URL(Location 头;拒绝回退 lastBuild 防串号)
func (c *Client) Trigger(jobPath string, params map[string]string) (string, error) {
path := jobPathToURL(jobPath)
var form url.Values
if len(params) > 0 {
form = url.Values{}
for k, v := range params {
form.Set(k, v)
}
path += "/buildWithParameters"
} else {
path += "/build"
}
resp, err := c.do("POST", path, form)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("触发失败 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
queueURL := resp.Header.Get("Location")
if queueURL == "" {
return "", errors.New("未返回队列地址(Location),请检查 Jenkins 版本与权限")
}
return queueURL, nil
}
// WaitQueue 等待队列分配为具体构建,返回构建号与构建 URL
func (c *Client) WaitQueue(queueURL string, timeout time.Duration, emit func(string)) (int, string, error) {
deadline := time.Now().Add(timeout)
pollClient := &http.Client{Timeout: 15 * time.Second}
for time.Now().Before(deadline) {
req, _ := http.NewRequest("GET", queueURL+"api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := pollClient.Do(req)
if err == nil {
var q struct {
Cancelled bool `json:"cancelled"`
Executable *struct {
Number int `json:"number"`
URL string `json:"url"`
} `json:"executable"`
Why string `json:"why"`
}
if json.NewDecoder(resp.Body).Decode(&q) == nil {
resp.Body.Close()
if q.Cancelled {
return 0, "", errors.New("构建已被 Jenkins 取消(排队被拒绝)")
}
if q.Executable != nil {
return q.Executable.Number, strings.TrimRight(q.Executable.URL, "/"), nil
}
if q.Why != "" {
emit("排队中: " + q.Why)
}
} else {
resp.Body.Close()
}
}
time.Sleep(2 * time.Second)
}
return 0, "", errors.New("等待队列超时")
}
// StreamConsole 增量拉取控制台日志(X-Text-Size/X-More-Data 推进),直到构建结束
func (c *Client) StreamConsole(buildURL string, emit func(string)) {
offset := 0
pollClient := &http.Client{Timeout: 15 * time.Second}
for {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/logText/progressiveText?start=%d", buildURL, offset), nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := pollClient.Do(req)
if err != nil {
emit("日志拉取失败: " + err.Error())
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if len(body) > 0 {
for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") {
if line != "" {
emit(line)
}
}
}
if newSize := parseInt(resp.Header.Get("X-Text-Size")); newSize > offset {
offset = newSize
}
if resp.Header.Get("X-More-Data") != "true" {
return
}
time.Sleep(2 * time.Second)
}
}
// WaitResult 轮询构建结果:SUCCESS/FAILURE/ABORTED/null(进行中)
func (c *Client) WaitResult(buildURL string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
req, _ := http.NewRequest("GET", buildURL+"/api/json", nil)
req.SetBasicAuth(c.Username, c.Token)
resp, err := c.HTTP.Do(req)
if err == nil {
var b struct {
Building bool `json:"building"`
Result *string `json:"result"`
}
if json.NewDecoder(resp.Body).Decode(&b) == nil {
resp.Body.Close()
if !b.Building && b.Result != nil {
return *b.Result, nil
}
} else {
resp.Body.Close()
}
}
time.Sleep(3 * time.Second)
}
return "", errors.New("等待构建结果超时")
}
func parseInt(s string) int {
n := 0
for _, ch := range s {
if ch < '0' || ch > '9' {
return n
}
n = n*10 + int(ch-'0')
}
return n
}
// MapResult Jenkins 结果 → FlowStack 运行状态
func MapResult(r string) string {
switch r {
case "SUCCESS":
return "success"
case "ABORTED":
return "cancelled"
default:
return "failed"
}
}
+138 -1
View File
@@ -25,6 +25,15 @@ type Pipeline struct {
GitopsPath string `json:"gitopsPath"` GitopsPath string `json:"gitopsPath"`
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
Env string `gorm:"default:staging" json:"env"` Env string `gorm:"default:staging" json:"env"`
TriggerMode string `gorm:"default:manual" json:"triggerMode"` // manual | git_push | cron | webhook
CronExpr string `json:"cronExpr"`
WebhookToken string `json:"webhookToken"`
WatchBranches string `json:"watchBranches"`
Variables string `gorm:"type:text" json:"variables"` // JSON [{key,value,remark}]
CachePaths string `gorm:"type:text" json:"cachePaths"` // JSON [{path,key}]
NotifyWebhook string `json:"notifyWebhookURL"`
JenkinsJobName string `json:"jenkinsJobName"`
JenkinsServerID uint `json:"jenkinsServerId"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -67,8 +76,11 @@ type PipelineRun struct {
Type string `gorm:"default:native" json:"type"` Type string `gorm:"default:native" json:"type"`
TriggerBy string `gorm:"default:system" json:"triggerBy"` TriggerBy string `gorm:"default:system" json:"triggerBy"`
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|cancelled Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
DurationMs int64 `json:"durationMs"` DurationMs int64 `json:"durationMs"`
BuildNumber int `json:"buildNumber"`
ExternalRunID string `json:"externalRunId"`
ExternalURL string `json:"externalUrl"`
NodesSnapshot string `json:"nodesSnapshot"` NodesSnapshot string `json:"nodesSnapshot"`
EdgesSnapshot string `json:"edgesSnapshot"` EdgesSnapshot string `json:"edgesSnapshot"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
@@ -114,6 +126,9 @@ type Application struct {
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
AutoPrune bool `json:"autoPrune"` AutoPrune bool `json:"autoPrune"`
SelfHeal bool `json:"selfHeal"` SelfHeal bool `json:"selfHeal"`
SyncStatus string `gorm:"default:Synced" json:"syncStatus"` // Synced|OutOfSync|Syncing
HealthStatus string `gorm:"default:Healthy" json:"healthStatus"` // Healthy|Progressing|Degraded
LastSyncAt *time.Time `json:"lastSyncAt"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -133,3 +148,125 @@ type Plugin struct {
Config map[string]interface{} `gorm:"serializer:json" json:"config"` Config map[string]interface{} `gorm:"serializer:json" json:"config"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
// Host 主机资源
type Host struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
IP string `json:"ip"`
SSHPort int `gorm:"default:22" json:"port"`
Username string `gorm:"default:root" json:"username"`
Tags string `gorm:"type:text" json:"tags"` // JSON ["production","web"]
Status string `gorm:"default:offline" json:"status"` // online|offline
OS string `json:"os"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// K8sCluster 集群资源
type K8sCluster struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
Context string `json:"context"`
APIServer string `json:"apiServer"`
Version string `json:"version"`
Kubeconfig string `gorm:"type:text" json:"-"` // enc:v1: 加密,不回显
KubeconfigSet bool `gorm:"-" json:"kubeconfigSet"`
NodeCount int `json:"nodes"`
Namespaces string `gorm:"type:text" json:"namespaces"` // JSON []
Status string `gorm:"default:disconnected" json:"status"` // connected|disconnected
LastHeartbeat *time.Time `json:"lastHeartbeat"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Credential 凭证(内容 AES-GCM 加密,只写不读)
type Credential struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
Type string `gorm:"index" json:"type"` // ssh|password|docker|git|kubeconfig|aksk
Scope string `gorm:"default:全局" json:"scope"`
Content string `gorm:"type:text" json:"-"` // enc:v1: 加密 JSON
ContentSet bool `gorm:"-" json:"contentSet"`
Summary string `json:"summary"` // 脱敏摘要
UsageCount int `gorm:"default:0" json:"usageCount"`
LastUsedAt *time.Time `json:"lastUsedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// BuildNode 构建节点
type BuildNode struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
IP string `json:"ip"`
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"`
Enabled bool `gorm:"default:true" json:"enabled"`
Status string `gorm:"default:offline" json:"status"` // online|offline|busy
CurrentJobs int `gorm:"default:0" json:"currentJobs"`
LastHeartbeat *time.Time `json:"lastHeartbeat"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// DeployTool 构建工具(检测命令真实执行;安装为保守策略:仅检测不下载)
type DeployTool struct {
ID uint `gorm:"primaryKey" json:"id"`
ToolKey string `gorm:"uniqueIndex" json:"key"`
DisplayName string `json:"name"`
Category string `gorm:"default:build" json:"category"` // build|deploy
Status string `gorm:"default:not-installed" json:"status"` // not-installed|installed|installing|failed
Version string `json:"version"` // 默认/期望版本
InstalledVersion string `json:"installedVersion"` // 真实探测到的版本
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
Path string `json:"path"`
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
Enabled bool `gorm:"default:true" json:"enabled"`
LastCheck *time.Time `json:"lastCheck"`
ErrorMsg string `json:"errorMsg"`
SortOrder int `json:"sortOrder"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// WebhookEndpoint 出站 Webhook(HMAC-SHA256 签名)
type WebhookEndpoint struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `gorm:"type:text" json:"-"` // enc:v1: 加密
SecretSet bool `gorm:"-" json:"secretSet"`
Events string `gorm:"type:text" json:"events"` // JSON ["pipeline.success",...]
Active bool `gorm:"default:true" json:"active"`
LastTriggeredAt *time.Time `json:"lastTriggered"`
LastStatus string `gorm:"default:none" json:"lastStatus"` // success|failed|none
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// JenkinsServer Jenkins 实例
type JenkinsServer struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
BaseURL string `json:"baseUrl"`
Username string `json:"username"`
Token string `gorm:"type:text" json:"-"` // enc:v1: 加密
TokenSet bool `gorm:"-" json:"tokenSet"`
Enabled bool `gorm:"default:true" json:"enabled"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// SystemSetting 系统设置(单行,ID 恒为 1)
type SystemSetting struct {
ID uint `gorm:"primaryKey" json:"id"`
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins
Extra string `gorm:"type:text" json:"extra"`
UpdatedAt time.Time `json:"updatedAt"`
}
+49 -1
View File
@@ -51,18 +51,24 @@ func main() {
{ {
api.POST("/auth/login", handler.Login) api.POST("/auth/login", handler.Login)
api.GET("/summary", handler.GetSummary)
api.GET("/settings", handler.GetSettings)
api.PUT("/settings", handler.UpdateSettings)
api.GET("/pipelines", handler.ListPipelines) api.GET("/pipelines", handler.ListPipelines)
api.POST("/pipelines", handler.CreatePipeline) api.POST("/pipelines", handler.CreatePipeline)
api.GET("/pipelines/:id", handler.GetPipeline) api.GET("/pipelines/:id", handler.GetPipelineDetail)
api.PUT("/pipelines/:id", handler.UpdatePipeline) api.PUT("/pipelines/:id", handler.UpdatePipeline)
api.DELETE("/pipelines/:id", handler.DeletePipeline) api.DELETE("/pipelines/:id", handler.DeletePipeline)
api.PUT("/pipelines/:id/nodes", handler.SaveNodes) api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
api.PUT("/pipelines/:id/edges", handler.SaveEdges) api.PUT("/pipelines/:id/edges", handler.SaveEdges)
api.POST("/pipelines/:id/runs", handler.CreateRun(hub)) api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub))
api.GET("/runs", handler.ListRuns) api.GET("/runs", handler.ListRuns)
api.GET("/runs/:runId", handler.GetRun) api.GET("/runs/:runId", handler.GetRun)
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub)) api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
api.GET("/gitops/apps", handler.ListGitOpsApps) api.GET("/gitops/apps", handler.ListGitOpsApps)
api.GET("/gitops/argo/apps", handler.ArgoList) api.GET("/gitops/argo/apps", handler.ArgoList)
@@ -75,6 +81,48 @@ func main() {
api.POST("/plugins/install", handler.InstallPlugin) api.POST("/plugins/install", handler.InstallPlugin)
api.POST("/plugins/:id/toggle", handler.TogglePlugin) api.POST("/plugins/:id/toggle", handler.TogglePlugin)
api.DELETE("/plugins/:id", handler.UninstallPlugin) api.DELETE("/plugins/:id", handler.UninstallPlugin)
api.GET("/hosts", handler.ListHosts)
api.POST("/hosts", handler.CreateHost)
api.PUT("/hosts/:id", handler.UpdateHost)
api.DELETE("/hosts/:id", handler.DeleteHost)
api.POST("/hosts/:id/test", handler.TestHost)
api.GET("/clusters", handler.ListClusters)
api.POST("/clusters", handler.CreateCluster)
api.PUT("/clusters/:id", handler.UpdateCluster)
api.DELETE("/clusters/:id", handler.DeleteCluster)
api.POST("/clusters/:id/test", handler.TestCluster)
api.GET("/credentials", handler.ListCredentials)
api.POST("/credentials", handler.CreateCredential)
api.PUT("/credentials/:id", handler.UpdateCredential)
api.DELETE("/credentials/:id", handler.DeleteCredential)
api.GET("/build-nodes", handler.ListBuildNodes)
api.POST("/build-nodes", handler.CreateBuildNode)
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
api.GET("/tools", handler.ListTools)
api.PUT("/tools/:id", handler.UpdateTool)
api.POST("/tools/:id/check", handler.CheckTool)
api.POST("/tools/:id/install", handler.InstallTool)
api.GET("/webhooks", handler.ListWebhooks)
api.POST("/webhooks", handler.CreateWebhook)
api.PUT("/webhooks/:id", handler.UpdateWebhook)
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
api.POST("/webhooks/:id/test", handler.TestWebhook)
api.GET("/jenkins-servers", handler.ListJenkinsServers)
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
} }
distFS, _ := fs.Sub(staticFS, "static") distFS, _ := fs.Sub(staticFS, "static")
+47 -30
View File
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { useQuery } from '@tanstack/react-query';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import TopBar from '@/components/TopBar'; import TopBar from '@/components/TopBar';
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor'; import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
@@ -19,7 +20,7 @@ import WebhookConfig from '@/pages/WebhookConfig';
import GitOpsDeploy from '@/pages/GitOpsDeploy'; import GitOpsDeploy from '@/pages/GitOpsDeploy';
import PluginManager from '@/pages/PluginManager'; import PluginManager from '@/pages/PluginManager';
import SystemSettings from '@/pages/SystemSettings'; import SystemSettings from '@/pages/SystemSettings';
import { pipelineScenarios } from '@/data/pipelineData'; import { apiClient } from '@/lib/api';
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings'; export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布'; export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
@@ -31,19 +32,27 @@ export default function App() {
// Pipeline list/editor view toggle // Pipeline list/editor view toggle
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list'); const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
// Current editing pipeline ID for flow editor
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1); const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
// Pipeline editor state (only used when in pipeline editor mode)
const [activeScenario, setActiveScenario] = useState<'go-backend' | 'vue-frontend'>('go-backend');
const [activeTab, setActiveTab] = useState<EditorTab>('流程配置'); const [activeTab, setActiveTab] = useState<EditorTab>('流程配置');
const [isSimulating, setIsSimulating] = useState(false); const [saveTick, setSaveTick] = useState(0);
const [executionStatus, setExecutionStatus] = useState<'idle' | 'running' | 'success' | 'failed'>('idle'); const [runTick, setRunTick] = useState(0);
const scenario = pipelineScenarios[activeScenario]; const { data: pipelines = [] } = useQuery({
queryKey: ['pipelines'],
queryFn: apiClient.listPipelines,
});
const handleScenarioChange = (key: 'go-backend' | 'vue-frontend') => { // 当前流水线最近一次运行状态(TopBar 徽章)
setActiveScenario(key); const { data: currentRuns = [] } = useQuery({
}; queryKey: ['runs', { pipelineId: currentPipelineId }],
queryFn: () => apiClient.listRuns({ pipelineId: currentPipelineId }),
refetchInterval: (query) =>
query.state.data?.[0]?.status === 'running' || query.state.data?.[0]?.status === 'pending' ? 2000 : false,
});
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
const latestStatus = currentRuns[0]?.status as string | undefined;
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
const handleSelectPipeline = useCallback((id: string) => { const handleSelectPipeline = useCallback((id: string) => {
setCurrentPipelineId(Number(id) || 1); setCurrentPipelineId(Number(id) || 1);
@@ -51,13 +60,19 @@ export default function App() {
setActiveTab('流程配置'); setActiveTab('流程配置');
}, []); }, []);
const handleSimulateRun = useCallback(() => { const handlePipelineSwitch = useCallback((id: number) => {
setIsSimulating(true); setCurrentPipelineId(id);
setExecutionStatus('running'); setActiveTab('流程配置');
setTimeout(() => { }, []);
setIsSimulating(false);
setExecutionStatus('success'); const handleOnlySave = useCallback(() => {
}, 3000); if (activeTab !== '流程配置') setActiveTab('流程配置');
setSaveTick((t) => t + 1);
}, [activeTab]);
const handleSaveAndRun = useCallback(() => {
setActiveTab('流程配置');
setRunTick((t) => t + 1);
}, []); }, []);
// Render content based on active module // Render content based on active module
@@ -71,40 +86,42 @@ export default function App() {
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<TopBar <TopBar
pipelineName={scenario.name} pipelineName={currentPipeline?.name || '...'}
pipelines={pipelines.map((p: any) => ({ id: p.id, name: p.name }))}
currentPipelineId={currentPipelineId}
onPipelineChange={handlePipelineSwitch}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
onSimulateRun={handleSimulateRun} onOnlySave={handleOnlySave}
isSimulating={isSimulating} onSaveAndRun={handleSaveAndRun}
scenarioKey={activeScenario} isRunning={isPipelineRunning}
onScenarioChange={handleScenarioChange} executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
executionStatus={executionStatus}
onBack={() => setPipelineView('list')} onBack={() => setPipelineView('list')}
/> />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{activeTab === '流程配置' && ( {activeTab === '流程配置' && (
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<PipelineFlowEditor pipelineId={currentPipelineId} /> <PipelineFlowEditor pipelineId={currentPipelineId} saveSignal={saveTick} runSignal={runTick} />
</div> </div>
)} )}
{activeTab === '基本信息' && ( {activeTab === '基本信息' && (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<BasicInfoPanel /> <BasicInfoPanel pipelineId={currentPipelineId} />
</div> </div>
)} )}
{activeTab === '触发设置' && ( {activeTab === '触发设置' && (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<TriggerPanel /> <TriggerPanel pipelineId={currentPipelineId} />
</div> </div>
)} )}
{activeTab === '变量和缓存' && ( {activeTab === '变量和缓存' && (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<VariablesPanel /> <VariablesPanel pipelineId={currentPipelineId} />
</div> </div>
)} )}
{activeTab === '发布' && ( {activeTab === '发布' && (
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-y-auto">
<ReleasePanel /> <ReleasePanel pipelineId={currentPipelineId} />
</div> </div>
)} )}
</div> </div>
@@ -121,7 +138,7 @@ export default function App() {
case 'jenkins-import': case 'jenkins-import':
return <JenkinsImport />; return <JenkinsImport />;
case 'batch-release': case 'batch-release':
return <BatchRelease />; return <BatchRelease onViewRecords={() => setActiveModule('release-records')} />;
case 'release-records': case 'release-records':
return <ReleaseRecords />; return <ReleaseRecords />;
case 'webhooks': case 'webhooks':
+98 -253
View File
@@ -1,270 +1,115 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Bell, Info, TestTube } from 'lucide-react'; import { Info, Save, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
export default function BasicInfoPanel() { interface Props {
const [pipelineName, setPipelineName] = useState('gateway-pre-serverless'); pipelineId: number;
const [description, setDescription] = useState(''); }
// Feishu notification export default function BasicInfoPanel({ pipelineId }: Props) {
const [feishuEnabled, setFeishuEnabled] = useState(false); const queryClient = useQueryClient();
const [feishuWebhook, setFeishuWebhook] = useState(''); const { data: detail } = useQuery({
const [feishuTiming, setFeishuTiming] = useState('all'); queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
// DingTalk notification
const [dingtalkEnabled, setDingtalkEnabled] = useState(false);
const [dingtalkWebhook, setDingtalkWebhook] = useState('');
const [dingtalkTiming, setDingtalkTiming] = useState('all');
const handleTestConnection = (type: 'feishu' | 'dingtalk') => {
toast.success(`${type === 'feishu' ? '飞书' : '钉钉'} Webhook 连接测试成功`, {
description: '通知服务可正常访问',
}); });
}; const p = detail?.pipeline;
const timingOptions = [ const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
{ value: 'all', label: '全部事件' },
{ value: 'success', label: '仅成功' }, useEffect(() => {
{ value: 'failure', label: '仅失败' }, if (p) {
]; setForm({
name: p.name || '', description: p.description || '',
sourceRepo: p.sourceRepo || '', sourceBranch: p.sourceBranch || 'main',
deployType: p.deployType || 'host', env: p.env || 'staging',
notifyWebhookURL: p.notifyWebhookURL || '',
});
}
}, [p?.id, p?.updatedAt]);
const saveMutation = useMutation({
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
onSuccess: () => {
toast.success('基本信息已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return ( return (
<div className="tab-content-fade space-y-6 w-full px-6 py-6"> <div className="tab-content-fade w-full px-6 py-6 max-w-3xl">
{/* Basic Information */} <div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div <div className="flex items-center justify-between mb-5">
className="rounded-lg border p-6 shadow-sm" <div className="flex items-center gap-2">
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3 <h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>基本信息</h3>
className="text-base font-semibold" {p?.type === 'jenkins' && (
style={{ color: 'var(--text-primary)' }} <span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins 导入</span>
>
基本信息
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
流水线名称
</Label>
<Input
value={pipelineName}
onChange={(e) => setPipelineName(e.target.value)}
placeholder="输入流水线名称"
className="h-9"
/>
</div>
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
描述
</Label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="输入流水线描述(可选)"
rows={1}
className="w-full px-3 py-2 text-sm rounded-md border outline-none focus:ring-1 focus:ring-[var(--accent-blue)] focus:border-transparent transition-all resize-none h-9"
style={{
background: 'var(--canvas-bg)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
/>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建人
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
admin
</span>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建时间
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
2024-06-20
</span>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
最近执行
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
未执行
</span>
</div>
</div>
</div>
{/* Notification Settings */}
<div
className="rounded-lg border p-6 shadow-sm"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Bell className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3
className="text-base font-semibold"
style={{ color: 'var(--text-primary)' }}
>
通知设置
</h3>
</div>
<div className="space-y-6">
{/* Feishu */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
飞书通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过飞书群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={feishuEnabled} onCheckedChange={setFeishuEnabled} />
</div>
{feishuEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={feishuWebhook}
onChange={(e) => setFeishuWebhook(e.target.value)}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={feishuTiming}
onValueChange={setFeishuTiming}
className="flex items-center gap-4"
>
{timingOptions.map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`feishu-${opt.value}`} />
<Label htmlFor={`feishu-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('feishu')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div>
</div>
)} )}
</div> </div>
<div className="flex items-center gap-3">
<Separator style={{ backgroundColor: 'var(--border-color)' }} /> {p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
<button
{/* DingTalk */} onClick={() => saveMutation.mutate(form)}
<div> disabled={saveMutation.isPending || !form.name}
<div className="flex items-center justify-between mb-3"> className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
<div className="flex items-center gap-2"> style={{ background: 'var(--accent-blue)' }}
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
钉钉通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过钉钉群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={dingtalkEnabled} onCheckedChange={setDingtalkEnabled} />
</div>
{dingtalkEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={dingtalkWebhook}
onChange={(e) => setDingtalkWebhook(e.target.value)}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={dingtalkTiming}
onValueChange={setDingtalkTiming}
className="flex items-center gap-4"
> >
{timingOptions.map((opt) => ( {saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
<div key={opt.value} className="flex items-center space-x-2"> </button>
<RadioGroupItem value={opt.value} id={`dingtalk-${opt.value}`} />
<Label htmlFor={`dingtalk-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('dingtalk')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div> </div>
</div> </div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>流水线名称 *</label>
<input value={form.name} onChange={(e) => set('name', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div> </div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
<input value={form.sourceBranch} onChange={(e) => set('sourceBranch', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>描述</label>
<input value={form.description} onChange={(e) => set('description', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
<input value={form.sourceRepo} onChange={(e) => set('sourceRepo', e.target.value)} placeholder="https://gitee.com/org/repo.git" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
<option value="host">主机部署</option>
<option value="k8s">K8s 部署</option>
<option value="gitops">GitOps</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认环境</label>
<select value={form.env} onChange={(e) => set('env', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
<option value="dev">dev</option>
<option value="test">test</option>
<option value="staging">staging</option>
<option value="production">production</option>
</select>
</div>
</div>
</div>
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>发布通知</h3>
<p className="text-[11px] mb-4" style={{ color: 'var(--text-tertiary)' }}>流水线成功/失败后回调该 Webhook(也在「Webhook 配置」中统一管理)</p>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>通知 Webhook URL</label>
<input value={form.notifyWebhookURL} onChange={(e) => set('notifyWebhookURL', e.target.value)} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div> </div>
</div> </div>
</div> </div>
@@ -1,287 +0,0 @@
import { useState } from 'react';
import { Plus, ChevronDown, ChevronRight, ChevronLeft, Box, Layers, GitBranch, Container, Terminal, Shield, Package, BookOpen, X } from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import StageCard from './StageCard';
import { toast } from 'sonner';
interface TektonTask {
id: string;
name: string;
description: string;
icon: typeof Box;
category: 'source' | 'build' | 'test' | 'security' | 'deploy' | 'gitops';
params: { name: string; type: string; default?: string; description: string }[];
workspaces: string[];
results?: string[];
}
const taskCatalog: TektonTask[] = [
{ id: 'tk-git-clone', name: 'git-clone', description: '从 Git 仓库克隆代码', icon: GitBranch, category: 'source', params: [{ name: 'url', type: 'string', description: 'Git 仓库 URL' }, { name: 'revision', type: 'string', default: 'main', description: '分支或标签' }], workspaces: ['source'] },
{ id: 'tk-maven', name: 'maven', description: 'Maven 构建 Java 项目', icon: Package, category: 'build', params: [{ name: 'goals', type: 'array', default: 'clean package', description: 'Maven 目标' }, { name: 'settings', type: 'string', description: 'settings.xml 路径' }], workspaces: ['source', 'm2-cache'] },
{ id: 'tk-npm', name: 'npm', description: 'npm install + build', icon: Package, category: 'build', params: [{ name: 'script', type: 'string', default: 'build', description: 'npm 脚本' }], workspaces: ['source'] },
{ id: 'tk-go-build', name: 'go-build', description: 'Go 编译', icon: Package, category: 'build', params: [{ name: 'package', type: 'string', description: '包路径' }, { name: 'flags', type: 'string', description: '编译参数' }], workspaces: ['source'] },
{ id: 'tk-kaniko', name: 'kaniko-build', description: '无需特权构建 Docker 镜像', icon: Container, category: 'build', params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile', description: 'Dockerfile 路径' }, { name: 'image', type: 'string', description: '目标镜像地址' }], workspaces: ['source'], results: ['IMAGE_DIGEST'] },
{ id: 'tk-trivy', name: 'trivy-scan', description: '容器镜像漏洞扫描', icon: Shield, category: 'security', params: [{ name: 'image', type: 'string', description: '待扫描镜像' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL', description: '严重等级' }], workspaces: [] },
{ id: 'tk-unit-test', name: 'unit-test', description: '运行单元测试', icon: Terminal, category: 'test', params: [{ name: 'test-command', type: 'string', description: '测试命令' }], workspaces: ['source'] },
{ id: 'tk-kubectl', name: 'kubectl-deploy', description: 'kubectl apply 部署', icon: Layers, category: 'deploy', params: [{ name: 'manifest', type: 'string', description: 'manifest 路径' }, { name: 'namespace', type: 'string', description: '目标命名空间' }], workspaces: ['source'] },
{ id: 'tk-update-manifest', name: 'update-manifest', description: '更新 GitOps 仓库镜像标签', icon: BookOpen, category: 'gitops', params: [{ name: 'repo', type: 'string', description: 'GitOps 仓库' }, { name: 'image', type: 'string', description: '新镜像地址' }, { name: 'path', type: 'string', description: 'manifest 路径' }], workspaces: ['source'] },
{ id: 'tk-helm-upgrade', name: 'helm-upgrade', description: 'Helm 部署/升级', icon: Layers, category: 'deploy', params: [{ name: 'release', type: 'string', description: 'Release 名称' }, { name: 'chart', type: 'string', description: 'Chart 路径' }, { name: 'namespace', type: 'string', description: '命名空间' }], workspaces: ['source'] },
];
const categoryConfig: Record<string, { label: string; color: string }> = {
source: { label: '源码', color: '#0969da' },
build: { label: '构建', color: '#1a7f37' },
test: { label: '测试', color: '#9a6700' },
security: { label: '安全', color: '#cf222e' },
deploy: { label: '部署', color: '#8250df' },
gitops: { label: 'GitOps', color: '#0e7c7b' },
};
interface PipelineStageEditorProps {
stages: PipelineStage[];
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onUpdateStages: (stages: PipelineStage[]) => void;
}
export default function PipelineStageEditor({
stages,
selectedTaskId,
onSelectTask,
onUpdateStages,
}: PipelineStageEditorProps) {
const [catalogOpen, setCatalogOpen] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showWorkspaceConfig, setShowWorkspaceConfig] = useState(false);
const handleMoveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= stages.length) return;
const newStages = [...stages];
[newStages[index], newStages[newIndex]] = [newStages[newIndex], newStages[index]];
onUpdateStages(newStages);
};
const handleDeleteStage = (index: number) => {
const newStages = stages.filter((_, i) => i !== index);
onUpdateStages(newStages);
};
const handleUpdateStageName = (index: number, name: string) => {
const newStages = stages.map((s, i) => (i === index ? { ...s, name } : s));
onUpdateStages(newStages);
};
const handleAddStage = () => {
const newStage: PipelineStage = {
id: `stage-${Date.now()}`,
name: '新阶段',
type: 'custom',
tasks: [],
};
onUpdateStages([...stages, newStage]);
};
const addTaskFromCatalog = (task: TektonTask) => {
// Add to last stage or create new stage
if (stages.length === 0) {
handleAddStage();
return;
}
const lastStageIndex = stages.length - 1;
const newTask: any = {
id: `${task.id}-${Date.now()}`,
name: task.name,
type: (task.category === 'deploy' || task.category === 'gitops' ? 'deploy' : task.category === 'build' ? 'build' : 'custom') as 'build' | 'deploy' | 'test' | 'custom',
icon: task.category,
config: {
params: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {} as Record<string, string>),
workspaces: task.workspaces.reduce((acc, w) => ({ ...acc, [w]: w }), {} as Record<string, string>),
results: task.results || [],
},
};
const newStages = stages.map((s, i) =>
i === lastStageIndex ? { ...s, tasks: [...s.tasks, newTask] } : s
);
onUpdateStages(newStages);
toast.success(`已添加 Task: ${task.name}`, { description: `${task.workspaces.length} 个 Workspace` });
};
const filteredTasks = selectedCategory === 'all' ? taskCatalog : taskCatalog.filter(t => t.category === selectedCategory);
return (
<div className="min-h-full flex" style={{ background: 'var(--canvas-bg)' }}>
{/* Task Catalog Sidebar */}
<div
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden"
style={{
width: catalogOpen ? '260px' : '0px',
borderColor: 'var(--border-color)',
background: 'var(--panel-bg)',
}}
>
<div className="w-[260px] h-full flex flex-col">
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
</div>
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-1 flex-wrap">
<button onClick={() => setSelectedCategory('all')}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>
全部
</button>
{Object.entries(categoryConfig).map(([key, cfg]) => (
<button key={key} onClick={() => setSelectedCategory(key)}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === key ? cfg.color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
{cfg.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{filteredTasks.map(task => {
const Icon = task.icon;
const cat = categoryConfig[task.category];
return (
<div key={task.id}
onClick={() => addTaskFromCatalog(task)}
className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors group"
style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 mb-1">
<Icon className="w-3.5 h-3.5" style={{ color: cat.color }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: cat.color + '15', color: cat.color }}>{cat.label}</span>
</div>
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
<div className="flex items-center gap-1.5">
{task.workspaces.length > 0 && (
<span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}>
<Box className="w-2.5 h-2.5" />{task.workspaces.length} ws
</span>
)}
{task.params.length > 0 && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>
)}
{task.results && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.results.length} results</span>
)}
</div>
</div>
);
})}
</div>
<div className="px-3 py-2 border-t text-[10px]" style={{ borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}>
点击 Task 添加到流水线末尾阶段
</div>
</div>
</div>
{/* Main Editor */}
<div className="flex-1 min-w-0">
<div className="px-6 py-5">
{/* Pipeline metadata bar */}
<div className="flex items-center justify-between mb-5 w-full" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
<div className="flex items-center gap-3">
<button
onClick={() => setCatalogOpen(!catalogOpen)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
title={catalogOpen ? '收起任务库' : '展开任务库'}
>
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Task 库
</button>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold tracking-tight" style={{ color: 'var(--text-primary)' }}>
流水线阶段
</span>
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{stages.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowWorkspaceConfig(!showWorkspaceConfig)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<Box className="w-3 h-3" />Workspace
</button>
<button
onClick={handleAddStage}
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md text-white transition-all hover:brightness-110 active:scale-[0.98]"
style={{ background: 'var(--accent-blue)' }}
>
<Plus className="w-3.5 h-3.5" />添加阶段
</button>
</div>
</div>
{/* Workspace config panel */}
{showWorkspaceConfig && (
<div className="mb-4 rounded-lg border p-3 mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Pipeline Workspace 配置</span>
<button onClick={() => setShowWorkspaceConfig(false)} className="p-0.5 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><X className="w-3.5 h-3.5" /></button>
</div>
<div className="grid grid-cols-3 gap-2">
{['source', 'm2-cache', 'docker-config'].map(ws => (
<div key={ws} className="flex items-center gap-2 p-2 rounded-md border text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
<Box className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-primary)' }}>{ws}</span>
<select className="ml-auto text-[10px] rounded border outline-none px-1 py-[2px]" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
<option>EmptyDir</option><option>PVC</option><option>ConfigMap</option><option>Secret</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Stage list */}
<div className="flex flex-col items-center" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
{stages.map((stage, index) => (
<div key={stage.id} className="w-full flex flex-col items-center">
<StageCard
stage={stage}
index={index}
isFirst={index === 0}
isLast={index === stages.length - 1}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
onMoveUp={() => handleMoveStage(index, 'up')}
onMoveDown={() => handleMoveStage(index, 'down')}
onDelete={() => handleDeleteStage(index)}
onUpdateName={(name) => handleUpdateStageName(index, name)}
/>
{index < stages.length - 1 && (
<div className="flex flex-col items-center py-2">
<div className="flex flex-col items-center gap-[2px]">
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
<ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
</div>
</div>
)}
</div>
))}
</div>
{stages.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 rounded-lg border-2 border-dashed mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Plus className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>暂无阶段</p>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>从左侧 Task 库选择任务,或添加阶段</p>
</div>
)}
</div>
</div>
</div>
);
}
+142 -379
View File
@@ -1,400 +1,163 @@
import { useState, useEffect, useRef } from 'react'; import { useState } from 'react';
import { import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react';
CheckCircle2, import { toast } from 'sonner';
XCircle, import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
Loader2, import { apiClient } from '@/lib/api';
Clock, import { usePipelineLogs } from '@/hooks/usePipelineLogs';
RotateCcw,
Eye,
GitCommit,
Hand,
Timer,
} from 'lucide-react';
import type { ExecutionRecord } from '@/types/pipeline';
const mockExecutions: ExecutionRecord[] = [ interface Props {
{ pipelineId: number;
id: 5,
status: 'running',
trigger: 'Manual',
triggerBy: 'admin',
duration: '1分12秒',
timestamp: '2024-06-20 15:45:00',
pipelineName: 'aiops-backend',
},
{
id: 4,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '3分22秒',
timestamp: '2024-06-20 14:30:15',
pipelineName: 'aiops-backend',
},
{
id: 3,
status: 'failed',
trigger: 'Manual',
triggerBy: 'admin',
duration: '45秒',
timestamp: '2024-06-20 13:20:00',
pipelineName: 'aiops-backend',
},
{
id: 2,
status: 'success',
trigger: 'Cron',
triggerBy: 'system',
duration: '5分10秒',
timestamp: '2024-06-20 02:00:00',
pipelineName: 'aiops-backend',
},
{
id: 1,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '4分55秒',
timestamp: '2024-06-19 18:45:30',
pipelineName: 'aiops-backend',
},
];
const fullLogLines: string[] = [
'[15:45:00] ===== 流水线开始执行: aiops-backend =====',
'[15:45:00] 触发方式: 手动 (admin)',
'',
'[15:45:01] ===== 阶段: 拉取代码 =====',
'[15:45:01] > Git 拉取',
'[15:45:03] Cloning into \'aiops\'...',
'[15:45:08] Already up to date. commit: 7a3f2e1',
'[15:45:08] \u2713 Git 拉取 完成 (7s)',
'',
'[15:45:08] ===== 阶段: 构建镜像 =====',
'[15:45:09] > Docker 构建 & 推送',
'[15:45:10] === 开始构建 Docker 镜像 ===',
'[15:45:10] 镜像地址: registry.example.com/aiops/aiops-backend:202506201545',
'[15:45:11] Docker登录...',
'[15:45:12] Login Succeeded',
'[15:45:15] Building image...',
'[15:45:45] Successfully built 9a8b7c6d5e4f',
'[15:45:46] 推送镜像...',
'[15:46:02] latest: digest: sha256:abc123... size: 2847',
'[15:46:02] \u2713 Docker 构建 & 推送 完成 (53s)',
'[15:46:02] > 单元测试',
'[15:46:03] go test -v -race -coverprofile=coverage.out ./...',
'[15:46:15] PASS: TestUserLogin (0.23s)',
'[15:46:15] PASS: TestDataQuery (1.12s)',
'[15:46:18] PASS: TestWebhookHandler (0.45s)',
'[15:46:20] coverage: 82.3% of statements',
'[15:46:20] \u2713 单元测试 完成 (18s) \u2014 覆盖率: 82.3%',
'',
'[15:46:20] ===== 阶段: K8s 部署 =====',
'[15:46:21] > K8s 滚动更新',
'[15:46:22] 更新镜像: registry.example.com/aiops/aiops-backend:202506201545',
'[15:46:25] deployment.apps/aiops-backend image updated',
'[15:46:25] 等待滚动更新...',
'[15:46:30] [=====================> ] 2/3 pods ready',
'[15:46:35] [========================>] 3/3 pods ready',
'[15:46:36] deployment "aiops-backend" successfully rolled out',
'[15:46:36] Pod状态:',
'[15:46:36] NAME READY STATUS',
'[15:46:36] aiops-backend-7a3f2e1-x1 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x2 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x3 1/1 Running',
'[15:46:36] \u2713 K8s 滚动更新 完成 (15s)',
'',
'[15:46:37] ===== 阶段: 后置操作 =====',
'[15:46:37] > 刷新CDN',
'[15:46:37] 刷新CDN缓存...',
'[15:46:38] {"code": 200, "message": "refresh submitted"}',
'[15:46:38] \u2713 刷新CDN 完成 (1s)',
'',
'[15:46:38] ===== 流水线执行完成 =====',
'[15:46:38] 总耗时: 1分38秒',
'[15:46:38] 结果: \u2713 成功',
];
function getStatusIcon(status: string, className = 'w-5 h-5') {
switch (status) {
case 'success':
return <CheckCircle2 className={`${className} text-green-500`} />;
case 'failed':
return <XCircle className={`${className} text-red-500`} />;
case 'running':
return <Loader2 className={`${className} text-blue-500 animate-spin`} />;
default:
return <Clock className={`${className} text-gray-400`} />;
}
} }
function getStatusText(status: string) { const statusMap: Record<string, { color: string; bg: string; label: string }> = {
switch (status) { success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
case 'success': failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
return '成功'; running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
case 'failed': pending: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
return '失败'; queued: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
case 'running': cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
return '正在执行';
default:
return '等待中';
}
}
function getTriggerIcon(trigger: string, className = 'w-3.5 h-3.5') {
switch (trigger) {
case 'Git Push':
return <GitCommit className={className} />;
case 'Manual':
return <Hand className={className} />;
case 'Cron':
return <Timer className={className} />;
default:
return <Clock className={className} />;
}
}
function LogLine({ line }: { line: string }) {
if (!line) {
return <div className="h-4" />;
}
const timestamp = line.match(/^\[(\d{2}:\d{2}:\d{2})\]/)?.[0] || '';
const content = line.slice(timestamp.length);
// Determine color based on content
let contentColor = '#e6e6e6';
if (content.includes('\u2713') || content.includes('成功') || content.includes('PASS')) {
contentColor = '#7ee787';
} else if (content.includes('失败') || content.includes('error') || content.includes('Error')) {
contentColor = '#f85149';
} else if (content.includes('警告') || content.includes('warn') || content.includes('Waiting')) {
contentColor = '#d29922';
} else if (content.startsWith(' =====')) {
contentColor = '#79c0ff';
} else if (content.startsWith(' > ')) {
contentColor = '#d2a8ff';
}
return (
<div className="font-mono text-sm leading-relaxed whitespace-pre-wrap">
{timestamp && (
<span style={{ color: '#8b949e' }}>{timestamp}</span>
)}
<span style={{ color: contentColor }}>{content}</span>
</div>
);
}
export default function ReleasePanel() {
const [selectedExecutionId, setSelectedExecutionId] = useState(5);
const [displayedLines, setDisplayedLines] = useState<string[]>([]);
const [isAnimating, setIsAnimating] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const selectedExecution = mockExecutions.find((e) => e.id === selectedExecutionId);
const isRunning = selectedExecution?.status === 'running';
// Animate log lines for running execution
useEffect(() => {
if (isRunning) {
setDisplayedLines([]);
setIsAnimating(true);
animatingRef.current = true;
let index = 0;
const interval = setInterval(() => {
if (!animatingRef.current) {
clearInterval(interval);
return;
}
if (index < fullLogLines.length) {
setDisplayedLines((prev) => [...prev, fullLogLines[index]]);
index++;
} else {
setIsAnimating(false);
animatingRef.current = false;
clearInterval(interval);
}
}, 80); // Each line appears every 80ms
return () => {
animatingRef.current = false;
clearInterval(interval);
}; };
} else {
// Show full log immediately for completed executions
setDisplayedLines(fullLogLines);
setIsAnimating(false);
}
}, [selectedExecutionId, isRunning]);
// Auto-scroll to bottom function fmtDuration(ms: number): string {
useEffect(() => { if (!ms) return '-';
if (logEndRef.current) { const s = Math.floor(ms / 1000);
logEndRef.current.scrollIntoView({ behavior: 'smooth' }); if (s < 60) return `${(ms / 1000).toFixed(1)}s`;
if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`;
return `${Math.floor(s / 3600)}小时${Math.floor((s % 3600) / 60)}分`;
} }
}, [displayedLines]);
export default function ReleasePanel({ pipelineId }: Props) {
const queryClient = useQueryClient();
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
const { data: runs = [], isLoading } = useQuery({
queryKey: ['runs', { pipelineId }],
queryFn: () => apiClient.listRuns({ pipelineId }),
});
const { data: detail } = useQuery({
queryKey: ['run', expandedRunId],
queryFn: () => apiClient.getRun(expandedRunId!),
enabled: !!expandedRunId,
});
const expandedRun = runs.find((r) => r.runId === expandedRunId);
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
const rerunMutation = useMutation({
mutationFn: (runId: string) => apiClient.retryRun(runId),
onSuccess: (run) => {
toast.success(`已重新触发 ${run.runId}`);
queryClient.invalidateQueries({ queryKey: ['runs'] });
},
onError: (err: Error) => toast.error('触发失败', { description: err.message }),
});
const logs = isRunning ? liveLogs : (detail?.logs || []);
const stages = detail?.stages || [];
return ( return (
<div className="flex h-full" style={{ background: 'var(--panel-bg)' }}> <div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
{/* Left: Execution History (40%) */} <div className="rounded-lg border shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div <div className="flex items-center justify-between px-5 py-3.5 border-b" style={{ borderColor: 'var(--border-color)' }}>
className="w-[40%] border-r flex flex-col overflow-hidden"
style={{
borderColor: 'var(--border-color)',
minWidth: '340px',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
执行记录
</span>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}>
{mockExecutions.length} 条记录
</span>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{mockExecutions.map((execution) => (
<div
key={execution.id}
onClick={() => setSelectedExecutionId(execution.id)}
className={`px-4 py-3 border-b cursor-pointer transition-colors ${
selectedExecutionId === execution.id ? 'bg-blue-50/50' : 'hover:bg-[var(--panel-bg-secondary)]'
}`}
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-start gap-3">
{getStatusIcon(execution.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
#{execution.id}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background:
execution.status === 'success'
? '#dafbe1'
: execution.status === 'failed'
? '#ffebe9'
: execution.status === 'running'
? '#ddf4ff'
: '#f6f8fa',
color:
execution.status === 'success'
? '#1a7f37'
: execution.status === 'failed'
? '#cf222e'
: execution.status === 'running'
? '#0969da'
: '#6e7781',
}}
>
{getStatusText(execution.status)}
</span>
</div>
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="flex items-center gap-1">
{getTriggerIcon(execution.trigger)}
{execution.trigger}
</span>
<span>{execution.triggerBy}</span>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.duration}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.timestamp}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="重新执行"
onClick={(e) => {
e.stopPropagation();
}}
>
<RotateCcw className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="查看日志"
onClick={(e) => {
e.stopPropagation();
setSelectedExecutionId(execution.id);
}}
>
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right: Log Viewer (60%) */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Log header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}> <Rocket className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
执行日志 <h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布历史</h3>
</span> <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{runs.length} 次执行</span>
{isRunning && isAnimating && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 animate-pulse">
实时输出中...
</span>
)}
</div> </div>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{selectedExecution?.pipelineName} #{selectedExecutionId}
</span>
</div> </div>
{/* Log content */} {isLoading && (
<div className="py-10 text-center" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>
)}
{!isLoading && runs.length === 0 && (
<div className="py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无发布记录,点击顶部「保存并运行」执行一次</div>
)}
{runs.map((r) => {
const st = statusMap[r.status] || statusMap.pending;
const isExpanded = expandedRunId === r.runId;
return (
<div key={r.runId} className="border-b last:border-b-0" style={{ borderColor: 'var(--border-color-light)' }}>
<div <div
className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3" className="flex items-center gap-3 px-5 py-2.5 cursor-pointer hover:bg-black/[0.015]"
style={{ background: '#1e1e1e' }} onClick={() => setExpandedRunId(isExpanded ? null : r.runId)}
> >
{displayedLines.map((line, index) => ( {r.status === 'running' ? (
<LogLine key={`${selectedExecutionId}-${index}`} line={line} /> <Loader2 className="w-4 h-4 animate-spin" style={{ color: '#0969da' }} />
))} ) : r.status === 'success' ? (
{isAnimating && ( <CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<div className="flex items-center gap-2 mt-2"> ) : (
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> <XCircle className="w-4 h-4" style={{ color: r.status === 'failed' ? '#cf222e' : '#6e7781' }} />
<span className="text-xs" style={{ color: '#8b949e' }}> )}
等待新输出... <span className="font-mono text-[11px] w-36" style={{ color: 'var(--text-tertiary)' }}>{r.runId}</span>
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>{r.branch}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{r.env}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: st.bg, color: st.color }}>{st.label}</span>
<span className="text-[11px] flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-3 h-3" />{fmtDuration(r.durationMs)}
</span> </span>
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
</span>
{(r.status === 'failed' || r.status === 'cancelled') && (
<button
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
className="p-1 rounded hover:bg-black/5"
style={{ color: 'var(--accent-blue)' }}
title="重新执行"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
{isExpanded && (
<div className="px-5 pb-4">
{r.externalUrl && (
<a href={r.externalUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] mb-2 font-mono" style={{ color: 'var(--accent-blue)' }}>
<ExternalLink className="w-3 h-3" />Jenkins 构建 #{r.buildNumber}
</a>
)}
{stages.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{stages.map((s: any) => {
const sst = statusMap[s.status] || statusMap.pending;
return (
<span key={s.id} className="text-[10px] px-2 py-[2px] rounded-md font-medium" style={{ background: sst.bg, color: sst.color }}>
{s.name}
</span>
);
})}
</div> </div>
)} )}
<div ref={logEndRef} /> <div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
{isRunning && (
<span className="text-[9px] px-1.5 py-[1px] rounded animate-pulse" style={{ background: '#fff8c5', color: '#9a6700' }}>
{connected ? 'LIVE' : '连接中…'}
</span>
)}
</div> </div>
<div className="max-h-64 overflow-y-auto custom-scrollbar px-3 py-2 font-mono text-[11px] leading-5">
{logs.length === 0 && <div style={{ color: 'var(--text-tertiary)' }}>暂无日志</div>}
{logs.map((l: any, i: number) => (
<div key={i} className="flex gap-2">
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>
{l.timestamp ? new Date(l.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}
</span>
<span style={{ color: l.level === 'error' ? '#cf222e' : l.level === 'warn' ? '#9a6700' : 'var(--text-secondary)' }}>{l.message}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large. Load diff
-237
View File
@@ -1,237 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface ScriptEditorProps {
value: string;
onChange: (value: string) => void;
language: 'shell' | 'python';
theme?: 'light' | 'dark';
}
// Basic syntax highlighting patterns
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit', 'echo', 'printf', 'read', 'source', 'export', 'unset', 'local', 'shift', 'break', 'continue', 'in', 'trap'];
const pythonKeywords = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'];
const builtinFunctions = ['print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'type', 'isinstance', 'hasattr', 'getattr', 'open', 'input', 'map', 'filter', 'zip', 'enumerate', 'sorted', 'reversed', 'sum', 'min', 'max', 'abs', 'round', 'pow', 'divmod', 'chr', 'ord', 'hex', 'bin', 'oct', 'format'];
// Dark theme syntax colors
const darkSyntaxColors = {
comment: '#6a9955',
string: '#ce9178',
keyword: '#569cd6',
function: '#dcdcaa',
number: '#b5cea8',
default: '#d4d4d4',
};
// Light theme syntax colors
const lightSyntaxColors = {
comment: '#1a7f37',
string: '#cf222e',
keyword: '#8250df',
function: '#8250df',
number: '#9a6700',
default: '#1f2328',
};
function highlightLine(line: string, language: 'shell' | 'python', isDark: boolean): string {
let html = '';
let remaining = line;
const colors = isDark ? darkSyntaxColors : lightSyntaxColors;
const keywords = language === 'shell' ? shellKeywords : pythonKeywords;
const kwPattern = new RegExp(`^(\\s*)(${keywords.join('|')})(?![a-zA-Z0-9_])`);
const funcPattern = /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*\()/;
const stringPattern = /^(["'])(.*?)(\1)/;
const commentPattern = language === 'shell' ? /^(#.*)/ : /^(#.*)/;
const numberPattern = /^([0-9]+\.?[0-9]*)/;
const wordPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)/;
const spacePattern = /^(\s+)/;
const otherPattern = /^(.)/;
while (remaining.length > 0) {
// Check for comment first
const commentMatch = remaining.match(commentPattern);
if (commentMatch) {
html += `<span style="color: ${colors.comment}; font-style: italic;">${escapeHtml(commentMatch[0])}</span>`;
remaining = remaining.slice(commentMatch[0].length);
continue;
}
// String literals
const stringMatch = remaining.match(stringPattern);
if (stringMatch) {
html += `<span style="color: ${colors.string};">${escapeHtml(stringMatch[0])}</span>`;
remaining = remaining.slice(stringMatch[0].length);
continue;
}
// Function calls
const funcMatch = remaining.match(funcPattern);
if (funcMatch && language === 'python' && builtinFunctions.includes(funcMatch[2])) {
html += `${escapeHtml(funcMatch[1])}<span style="color: ${colors.function};">${escapeHtml(funcMatch[2])}</span>${escapeHtml(funcMatch[3])}`;
remaining = remaining.slice(funcMatch[0].length);
continue;
}
// Keywords
const kwMatch = remaining.match(kwPattern);
if (kwMatch) {
html += `${escapeHtml(kwMatch[1])}<span style="color: ${colors.keyword}; font-weight: 600;">${escapeHtml(kwMatch[2])}</span>`;
remaining = remaining.slice(kwMatch[0].length);
continue;
}
// Numbers
const numMatch = remaining.match(numberPattern);
if (numMatch) {
html += `<span style="color: ${colors.number};">${escapeHtml(numMatch[0])}</span>`;
remaining = remaining.slice(numMatch[0].length);
continue;
}
// Words
const wordMatch = remaining.match(wordPattern);
if (wordMatch) {
html += escapeHtml(wordMatch[0]);
remaining = remaining.slice(wordMatch[0].length);
continue;
}
// Whitespace
const spaceMatch = remaining.match(spacePattern);
if (spaceMatch) {
html += escapeHtml(spaceMatch[0]);
remaining = remaining.slice(spaceMatch[0].length);
continue;
}
// Other characters
const otherMatch = remaining.match(otherPattern);
if (otherMatch) {
html += escapeHtml(otherMatch[0]);
remaining = remaining.slice(otherMatch[0].length);
continue;
}
break;
}
return html || '&nbsp;';
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export default function ScriptEditor({ value, onChange, language, theme = 'light' }: ScriptEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const isDark = theme === 'dark';
const lines = value.split('\n');
const lineNumbers = lines.map((_, i) => i + 1).join('\n');
const highlightedHtml = lines
.map((line) => highlightLine(line, language, isDark))
.join('<br>');
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const newValue = value.substring(0, start) + ' ' + value.substring(end);
onChange(newValue);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
useEffect(() => {
handleScroll();
}, [value, handleScroll]);
return (
<div
className={`relative rounded-md border overflow-hidden script-editor ${
focused ? 'ring-1' : ''
}`}
style={{
borderColor: isDark ? '#333333' : 'var(--border-color)',
minHeight: '200px',
'--tw-ring-color': 'rgba(9, 105, 218, 0.25)',
background: isDark ? '#1e1e1e' : 'var(--panel-bg)',
} as React.CSSProperties}
>
<div className="flex" style={{ minHeight: '200px' }}>
{/* Line numbers */}
<div
className="flex-shrink-0 py-3 px-2 text-right select-none"
style={{
background: isDark ? '#1e1e1e' : 'var(--panel-bg-secondary)',
color: isDark ? '#858585' : 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: '1.6',
minWidth: '40px',
borderRight: `1px solid ${isDark ? '#333333' : 'var(--border-color-light)'}`,
}}
>
{lineNumbers.split('\n').map((num, i) => (
<div key={i}>{num}</div>
))}
</div>
{/* Editor area */}
<div className="relative flex-1" style={{ minHeight: '200px' }}>
{/* Highlight layer */}
<div
ref={highlightRef}
className="absolute inset-0 py-3 px-3 pointer-events-none overflow-hidden whitespace-pre"
style={{
color: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
}}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
{/* Textarea layer */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onScroll={handleScroll}
spellCheck={false}
className="absolute inset-0 w-full h-full py-3 px-3 resize-none bg-transparent script-editor"
style={{
color: 'transparent',
caretColor: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
outline: 'none',
}}
/>
</div>
</div>
</div>
);
}
+24 -5
View File
@@ -15,6 +15,8 @@ import {
Puzzle, Puzzle,
Settings Settings
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings'; type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
@@ -61,6 +63,19 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
]; ];
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) { export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
// 徽标数来自后端真实统计
const { data: summary } = useQuery({
queryKey: ['summary'],
queryFn: apiClient.getSummary,
refetchInterval: 15000,
});
const badgeFor = (id: ModuleType): string | null => {
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
if (id === 'plugins') return summary?.plugins != null ? String(summary.plugins) : null;
return null;
};
return ( return (
<div <div
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full" className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
@@ -121,17 +136,21 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
<span className="text-[13px] font-medium truncate flex-1 text-left"> <span className="text-[13px] font-medium truncate flex-1 text-left">
{item.label} {item.label}
</span> </span>
{item.badge && ( {(() => {
const badge = item.badge === 'New' ? 'New' : badgeFor(item.id);
if (!badge) return null;
return (
<span <span
className="text-[9px] px-1 py-[1px] rounded font-medium" className="text-[9px] px-1 py-[1px] rounded font-medium"
style={{ style={{
background: item.badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)', background: badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
color: item.badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)', color: badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
}} }}
> >
{item.badge} {badge}
</span> </span>
)} );
})()}
</> </>
)} )}
{isActive && !collapsed && ( {isActive && !collapsed && (
-190
View File
@@ -1,190 +0,0 @@
import { useState } from 'react';
import {
ChevronUp,
ChevronDown,
X,
Plus,
GitBranch,
Wrench,
Server,
FlaskConical,
Settings,
} from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import TaskCard from './TaskCard';
interface StageCardProps {
stage: PipelineStage;
index: number;
isFirst: boolean;
isLast: boolean;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onUpdateName: (name: string) => void;
}
// Only use color for the LEFT BORDER — keep everything else neutral
const stageTypeConfig: Record<string, { color: string; icon: typeof GitBranch }> = {
source: { color: '#0969da', icon: GitBranch },
build: { color: '#9a6700', icon: Wrench },
deploy: { color: '#1a7f37', icon: Server },
test: { color: '#8250df', icon: FlaskConical },
custom: { color: '#6e7781', icon: Settings },
};
export default function StageCard({
stage,
index,
isFirst,
isLast,
selectedTaskId,
onSelectTask,
onMoveUp,
onMoveDown,
onDelete,
onUpdateName,
}: StageCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(stage.name);
const config = stageTypeConfig[stage.type] || stageTypeConfig.custom;
const IconComponent = config.icon;
const handleNameSubmit = () => {
if (editName.trim()) {
onUpdateName(editName.trim());
} else {
setEditName(stage.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleNameSubmit();
else if (e.key === 'Escape') {
setEditName(stage.name);
setIsEditing(false);
}
};
return (
<div className="w-full">
<div
className="rounded-lg overflow-hidden transition-shadow duration-200 hover:shadow-sm"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${config.color}`,
}}
>
{/* Header — unified neutral background, NO colorful bg */}
<div
className="flex items-center justify-between px-4 py-2.5 border-b"
style={{
borderColor: 'var(--border-color-light)',
background: '#f8f9fa',
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<IconComponent className="w-3.5 h-3.5 flex-shrink-0" style={{ color: config.color }} />
{isEditing ? (
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameSubmit}
onKeyDown={handleKeyDown}
autoFocus
className="text-[13px] font-semibold bg-white border rounded px-2 py-0.5 outline-none"
style={{
color: 'var(--text-primary)',
borderColor: config.color,
minWidth: '140px',
fontSize: '13px',
}}
/>
) : (
<button
onClick={() => setIsEditing(true)}
className="text-[13px] font-semibold hover:opacity-70 transition-opacity truncate text-left"
style={{ color: 'var(--text-primary)' }}
title="点击编辑"
>
阶段 {index + 1}: {stage.name}
</button>
)}
</div>
{/* Refined action buttons — smaller, tighter */}
<div className="flex items-center gap-[2px] flex-shrink-0">
<button
onClick={onMoveUp}
disabled={isFirst}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="上移"
>
<ChevronUp className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="下移"
>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onDelete}
className="p-1 rounded transition-all hover:bg-red-50"
title="删除阶段"
>
<X className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
{/* Body: Task list */}
<div className="px-2 py-1.5">
{stage.tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={() => onSelectTask(task.id)}
onDelete={() => {/* TODO */}}
/>
))}
{stage.tasks.length === 0 && (
<div
className="text-center py-4 rounded-md"
style={{ color: 'var(--text-tertiary)' }}
>
<p className="text-[12px]">暂无任务</p>
<p className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
点击下方按钮添加
</p>
</div>
)}
</div>
{/* Footer: Add task — refined style */}
<div
className="px-2 py-1.5 border-t"
style={{ borderColor: 'var(--border-color-light)' }}
>
<button
className="flex items-center justify-center gap-1.5 w-full py-[6px] text-[12px] rounded-md border border-dashed transition-all hover:border-solid hover:bg-[var(--panel-bg-secondary)]"
style={{
borderColor: 'var(--border-color-light)',
color: 'var(--text-tertiary)',
}}
>
<Plus className="w-3 h-3" />
添加任务
</button>
</div>
</div>
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { Pencil, Trash2 } from 'lucide-react';
import { iconMap } from '@/data/pipelineData';
import type { PipelineTask } from '@/types/pipeline';
interface TaskCardProps {
task: PipelineTask;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}
// Subtle type indicator colors — just for the small dot/icon, NOT background
const typeConfig: Record<string, { color: string; label: string }> = {
build: { color: '#9a6700', label: '构建' },
deploy: { color: '#1a7f37', label: '部署' },
test: { color: '#8250df', label: '测试' },
custom: { color: '#6e7781', label: '自定义' },
};
export default function TaskCard({ task, isSelected, onSelect, onDelete }: TaskCardProps) {
const config = typeConfig[task.type] || typeConfig.custom;
const IconComponent = iconMap[task.icon] || iconMap.Settings;
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
};
return (
<div
onClick={onSelect}
className="flex items-center gap-2.5 px-3 py-[10px] rounded-md cursor-pointer transition-all duration-150 group mb-[2px] last:mb-0"
style={{
background: isSelected ? '#f0f5ff' : 'transparent',
border: isSelected ? '1px solid #b7d4f0' : '1px solid transparent',
...(isSelected
? { boxShadow: '0 0 0 1px rgba(9,105,218,0.08)' }
: {}),
}}
>
{/* Type icon — small colored dot with icon inside */}
<div
className="w-6 h-6 rounded-[6px] flex items-center justify-center flex-shrink-0"
style={{
background: `${config.color}10`,
border: `1px solid ${config.color}25`,
}}
>
<IconComponent className="w-3 h-3" style={{ color: config.color }} />
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div
className="text-[13px] font-medium truncate leading-tight"
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)' }}
>
{task.name}
</div>
<div
className="text-[11px] truncate leading-tight mt-[1px]"
style={{ color: 'var(--text-tertiary)' }}
>
{config.label}
{task.scriptType && ` · ${task.scriptType}`}
</div>
</div>
{/* Actions — hover reveal */}
<div className="flex items-center gap-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onSelect(); }}
className="p-1 rounded transition-colors hover:bg-black/5"
title="编辑"
>
<Pencil className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
onClick={handleDelete}
className="p-1 rounded transition-colors hover:bg-red-50"
title="删除"
>
<Trash2 className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
);
}
+41 -43
View File
@@ -1,45 +1,52 @@
import { ChevronLeft, Save, Play, MonitorPlay, ChevronDown } from 'lucide-react'; import { ChevronLeft, Save, Play, ChevronDown, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布'; type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
interface PipelineOption {
id: number;
name: string;
}
interface TopBarProps { interface TopBarProps {
pipelineName: string; pipelineName: string;
pipelines: PipelineOption[];
currentPipelineId: number;
onPipelineChange: (id: number) => void;
activeTab: EditorTab; activeTab: EditorTab;
onTabChange: (tab: EditorTab) => void; onTabChange: (tab: EditorTab) => void;
onSimulateRun: () => void; onOnlySave: () => void;
isSimulating: boolean; onSaveAndRun: () => void;
scenarioKey: 'go-backend' | 'vue-frontend'; isSaving?: boolean;
onScenarioChange: (key: 'go-backend' | 'vue-frontend') => void; isRunning?: boolean;
executionStatus: 'idle' | 'running' | 'success' | 'failed'; executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
onBack?: () => void; onBack?: () => void;
} }
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布']; const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
const scenarios = [
{ key: 'go-backend' as const, label: 'aiops-backend (Go + K8s)' },
{ key: 'vue-frontend' as const, label: 'aiops-web (Vue + Host)' },
];
export default function TopBar({ export default function TopBar({
pipelineName, pipelineName,
pipelines,
currentPipelineId,
onPipelineChange,
activeTab, activeTab,
onTabChange, onTabChange,
onSimulateRun, onOnlySave,
isSimulating, onSaveAndRun,
scenarioKey, isSaving = false,
onScenarioChange, isRunning = false,
executionStatus, executionStatus,
onBack, onBack,
}: TopBarProps) { }: TopBarProps) {
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false); const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
const statusConfig = { const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
idle: null, idle: null,
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' }, running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' }, success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' }, failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
}; };
const status = statusConfig[executionStatus]; const status = statusConfig[executionStatus];
@@ -82,24 +89,24 @@ export default function TopBar({
<> <>
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
<div <div
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px]" className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px] max-h-[320px] overflow-y-auto custom-scrollbar"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }} style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
> >
{scenarios.map((s) => ( {pipelines.map((p) => (
<button <button
key={s.key} key={p.id}
onClick={() => { onClick={() => {
onScenarioChange(s.key); onPipelineChange(p.id);
setPipelineDropdownOpen(false); setPipelineDropdownOpen(false);
}} }}
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]" className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
style={{ style={{
color: scenarioKey === s.key ? 'var(--accent-blue)' : 'var(--text-primary)', color: p.id === currentPipelineId ? 'var(--accent-blue)' : 'var(--text-primary)',
fontWeight: scenarioKey === s.key ? 600 : 400, fontWeight: p.id === currentPipelineId ? 600 : 400,
}} }}
> >
{s.label} {p.name}
{scenarioKey === s.key && ( {p.id === currentPipelineId && (
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span> <span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
)} )}
</button> </button>
@@ -112,7 +119,7 @@ export default function TopBar({
{/* Execution status badge */} {/* Execution status badge */}
{status && ( {status && (
<span <span
className="text-[10px] px-1.5 py-[2px] rounded-full font-medium" className={`text-[10px] px-1.5 py-[2px] rounded-full font-medium ${executionStatus === 'running' ? 'animate-pulse' : ''}`}
style={{ background: status.bg, color: status.color }} style={{ background: status.bg, color: status.color }}
> >
{status.label} {status.label}
@@ -142,35 +149,26 @@ export default function TopBar({
{/* Right: Action Buttons */} {/* Right: Action Buttons */}
<div className="flex items-center gap-2 min-w-[280px] justify-end"> <div className="flex items-center gap-2 min-w-[280px] justify-end">
<button <button
onClick={onSimulateRun} onClick={onOnlySave}
disabled={isSimulating} disabled={isSaving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
style={{
color: 'var(--accent-blue)',
background: 'var(--accent-blue-light)',
border: '1px solid rgba(9, 105, 218, 0.2)',
}}
>
<MonitorPlay className="w-4 h-4" />
{isSimulating ? '运行中...' : '模拟运行'}
</button>
<button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
style={{ style={{
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--border-color)', border: '1px solid var(--border-color)',
}} }}
> >
<Save className="w-4 h-4" /> {isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
仅保存 仅保存
</button> </button>
<button <button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors" onClick={onSaveAndRun}
style={{ background: 'var(--accent-blue)' }} disabled={isRunning}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
> >
<Play className="w-4 h-4" /> {isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
保存并运行 {isRunning ? '运行中…' : '保存并运行'}
</button> </button>
</div> </div>
</div> </div>
+119 -232
View File
@@ -1,257 +1,144 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { GitCommit, Clock, Webhook, Hand, Info, Copy, Check } from 'lucide-react'; import { Webhook, Hand, Clock, GitCommit, Save, Loader2, Copy, Check } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
export default function TriggerPanel() { interface Props {
// Code commit trigger pipelineId: number;
const [codeCommitEnabled, setCodeCommitEnabled] = useState(true); }
const [watchBranch, setWatchBranch] = useState('');
const [excludeBranch, setExcludeBranch] = useState('');
// Cron trigger const modes = [
const [cronEnabled, setCronEnabled] = useState(false); { key: 'manual', label: '手动触发', desc: '在流水线列表或编辑器中手动执行', icon: Hand },
const [cronExpression, setCronExpression] = useState(''); { key: 'webhook', label: 'Webhook', desc: '通过 Webhook URL 推送事件触发', icon: Webhook },
const [nextExecution, setNextExecution] = useState('2024-06-21 02:00:00'); { key: 'git_push', label: 'Git Push', desc: '代码仓库分支变更时触发', icon: GitCommit },
{ key: 'cron', label: '定时触发', desc: '按 Cron 表达式定时执行', icon: Clock },
// Webhook trigger
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [webhookSecret, setWebhookSecret] = useState('');
const [copied, setCopied] = useState(false);
const webhookUrl = 'https://api.example.com/webhook/pipeline/gateway-pre-serverless';
const handleCopyWebhook = () => {
navigator.clipboard.writeText(webhookUrl).then(() => {
setCopied(true);
toast.success('Webhook URL 已复制');
setTimeout(() => setCopied(false), 2000);
});
};
const cronPresets = [
{ label: '每天凌晨2点', value: '0 2 * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '自定义', value: '' },
]; ];
const handleCronPreset = (value: string) => { export default function TriggerPanel({ pipelineId }: Props) {
setCronExpression(value); const queryClient = useQueryClient();
if (value === '0 2 * * *') { const { data: detail } = useQuery({
setNextExecution('2024-06-21 02:00:00'); queryKey: ['pipeline', pipelineId],
} else if (value === '0 * * * *') { queryFn: () => apiClient.getPipeline(pipelineId),
setNextExecution('2024-06-20 15:00:00'); });
const p = detail?.pipeline;
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
const [copied, setCopied] = useState(false);
useEffect(() => {
if (p) {
setForm({
triggerMode: p.triggerMode || 'manual',
cronExpr: p.cronExpr || '',
webhookToken: p.webhookToken || '',
watchBranches: p.watchBranches || '',
});
} }
}, [p?.id, p?.updatedAt]);
const saveMutation = useMutation({
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
onSuccess: () => {
toast.success('触发设置已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const regenToken = () => {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
const token = Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
setForm((f) => ({ ...f, webhookToken: token }));
}; };
const hookUrl = `${window.location.origin}/api/hooks/pipeline/${pipelineId}${form.webhookToken ? `?token=${form.webhookToken}` : ''}`;
const copyUrl = () => {
navigator.clipboard.writeText(hookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return ( return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6"> <div className="tab-content-fade w-full px-6 py-6 max-w-3xl overflow-y-auto">
{/* Code Commit Trigger */} <div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}> <div className="flex items-center justify-between mb-5">
<CardHeader className="pb-3"> <h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
<div className="flex items-center justify-between"> <button
<div className="flex items-center gap-2"> onClick={() => saveMutation.mutate(form)}
<GitCommit className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> disabled={saveMutation.isPending}
<CardTitle className="text-base">代码提交触发</CardTitle> className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
</div> style={{ background: 'var(--accent-blue)' }}
<Switch checked={codeCommitEnabled} onCheckedChange={setCodeCommitEnabled} />
</div>
<CardDescription>
当指定分支有新的代码提交时自动触发流水线
</CardDescription>
</CardHeader>
{codeCommitEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>监听分支</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持正则表达式,如 release/*</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={watchBranch}
onChange={(e) => setWatchBranch(e.target.value)}
placeholder="支持正则,如 release/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个分支用逗号分隔,支持 * 通配符
</p>
</div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
排除分支
</Label>
<Input
value={excludeBranch}
onChange={(e) => setExcludeBranch(e.target.value)}
placeholder="如 hotfix/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
匹配的分支不会触发流水线
</p>
</div>
</CardContent>
)}
</Card>
{/* Cron Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">定时触发</CardTitle>
</div>
<Switch checked={cronEnabled} onCheckedChange={setCronEnabled} />
</div>
<CardDescription>
按照 Cron 表达式定时触发流水线
</CardDescription>
</CardHeader>
{cronEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div className="flex flex-wrap gap-2">
{cronPresets.map((preset) => (
<Badge
key={preset.label}
variant={cronExpression === preset.value && preset.value !== '' ? 'default' : 'outline'}
className="cursor-pointer h-7 px-3"
onClick={() => handleCronPreset(preset.value)}
> >
{preset.label} {saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
</Badge> </button>
))}
</div> </div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Cron 表达式
</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 2 * * *"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
格式:分 时 日 月 周
</p>
</div>
{cronExpression && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm"
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
下次执行: {nextExecution}
</div>
)}
</CardContent>
)}
</Card>
{/* Webhook Trigger */} <div className="grid grid-cols-2 gap-3">
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}> {modes.map((m) => {
<CardHeader className="pb-3"> const Icon = m.icon;
<div className="flex items-center justify-between"> const active = form.triggerMode === m.key;
<div className="flex items-center gap-2"> return (
<Webhook className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <button
<CardTitle className="text-base">Webhook 触发</CardTitle> key={m.key}
</div> onClick={() => setForm((f) => ({ ...f, triggerMode: m.key }))}
<Switch checked={webhookEnabled} onCheckedChange={setWebhookEnabled} /> className="flex items-start gap-3 p-4 rounded-lg border text-left transition-colors"
</div>
<CardDescription>
通过外部系统的 Webhook 调用触发流水线
</CardDescription>
</CardHeader>
{webhookEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook URL
</Label>
<div className="flex gap-2">
<div
className="flex-1 px-3 py-2 text-sm rounded-md border font-mono"
style={{ style={{
background: 'var(--canvas-bg)', borderColor: active ? 'var(--accent-blue)' : 'var(--border-color-light)',
borderColor: 'var(--border-color)', background: active ? 'rgba(9,105,218,0.04)' : 'transparent',
color: 'var(--text-primary)',
}} }}
> >
{webhookUrl} <Icon className="w-4.5 h-4.5 mt-0.5" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
</div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleCopyWebhook}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
将此 URL 配置到外部系统的 Webhook 中
</p>
</div>
<div> <div>
<div className="flex items-center gap-2 mb-1.5"> <div className="text-[13px] font-medium" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-primary)' }}>{m.label}</div>
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Secret Token</Label> <div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{m.desc}</div>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>用于验证 Webhook 请求的签名</p>
</TooltipContent>
</Tooltip>
</div> </div>
<Input </button>
value={webhookSecret} );
onChange={(e) => setWebhookSecret(e.target.value)} })}
placeholder="输入 Secret Token"
type="password"
className="h-9"
/>
</div> </div>
</CardContent>
)}
</Card>
{/* Manual Trigger - always on */} {form.triggerMode === 'cron' && (
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}> <div className="mt-4 space-y-1.5">
<CardHeader className="pb-3"> <label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Cron 表达式</label>
<input value={form.cronExpr} onChange={(e) => setForm((f) => ({ ...f, cronExpr: e.target.value }))} placeholder="0 2 * * *(每天凌晨 2 点)" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
)}
{(form.triggerMode === 'webhook' || form.triggerMode === 'git_push') && (
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook Token(触发校验)</label>
<button onClick={regenToken} className="text-[11px] px-2 py-[2px] rounded" style={{ color: 'var(--accent-blue)', background: 'rgba(9,105,218,0.08)' }}>随机生成</button>
</div>
<input value={form.webhookToken} onChange={(e) => setForm((f) => ({ ...f, webhookToken: e.target.value }))} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook URL(保存后生效)</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Hand className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <input readOnly value={hookUrl} className="flex-1 px-3 py-2 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<CardTitle className="text-base">手动触发</CardTitle> <button onClick={copyUrl} className="p-2 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} title="复制">
<Badge variant="secondary" className="h-5 text-xs">默认启用</Badge> {copied ? <Check className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div> </div>
<CardDescription> <div className="text-[11px] font-mono p-2.5 rounded-md" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
支持通过控制台手动触发或 API 调用触发 curl -X POST '{hookUrl}'
</CardDescription> </div>
</CardHeader> </div>
<CardContent> {form.triggerMode === 'git_push' && (
<div <div className="space-y-1.5">
className="px-3 py-2.5 rounded-md text-sm" <label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>监听分支(逗号分隔,支持通配符)</label>
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }} <input value={form.watchBranches} onChange={(e) => setForm((f) => ({ ...f, watchBranches: e.target.value }))} placeholder="master,release/*,feature/*" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
> </div>
<span style={{ color: 'var(--text-secondary)' }}> )}
API 调用示例: </div>
</span> )}
<code className="ml-2 text-xs font-mono" style={{ color: 'var(--accent-blue)' }}>
POST /api/v1/pipelines/gateway-pre-serverless/run
</code>
</div> </div>
</CardContent>
</Card>
</div> </div>
); );
} }
+87 -234
View File
@@ -1,261 +1,114 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Variable, Database, Trash2, Plus, Info, Eye, EyeOff } from 'lucide-react'; import { Variable, Database, Trash2, Plus, Save, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { toast } from 'sonner';
import { Input } from '@/components/ui/input'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Label } from '@/components/ui/label'; import { apiClient } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface EnvVar { interface Props {
id: string; pipelineId: number;
name: string;
value: string;
description: string;
} }
export default function VariablesPanel() { interface VarItem { key: string; value: string; remark?: string }
const [envVars, setEnvVars] = useState<EnvVar[]>([ interface CacheItem { path: string; key: string }
{ id: '1', name: 'REGISTRY_URL', value: 'registry.cn-hangzhou.aliyuncs.com', description: '镜像仓库地址' },
{ id: '2', name: 'NAMESPACE', value: 'gateway-ns', description: 'K8s 命名空间' },
{ id: '3', name: 'APP_NAME', value: 'gateway-serverless', description: '应用名称' },
{ id: '4', name: 'ENV', value: 'pre', description: '环境标识' },
]);
const [showAddForm, setShowAddForm] = useState(false); function parseJSON<T>(s: string | undefined, fallback: T): T {
const [newVarName, setNewVarName] = useState(''); if (!s) return fallback;
const [newVarValue, setNewVarValue] = useState(''); try {
const [newVarDesc, setNewVarDesc] = useState(''); const v = JSON.parse(s);
const [visibleValues, setVisibleValues] = useState<Record<string, boolean>>({}); return Array.isArray(v) ? (v as T) : fallback;
} catch {
return fallback;
}
}
// Cache config export default function VariablesPanel({ pipelineId }: Props) {
const [cacheEnabled, setCacheEnabled] = useState(true); const queryClient = useQueryClient();
const [cachePath, setCachePath] = useState('/root/.m2'); const { data: detail } = useQuery({
const [cacheKey, setCacheKey] = useState('${BRANCH_NAME}-${BUILD_ID}'); queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
const handleAddVar = () => { const [vars, setVars] = useState<VarItem[]>([]);
if (!newVarName.trim() || !newVarValue.trim()) return; const [caches, setCaches] = useState<CacheItem[]>([]);
const newVar: EnvVar = {
id: Date.now().toString(),
name: newVarName.trim(),
value: newVarValue.trim(),
description: newVarDesc.trim(),
};
setEnvVars([...envVars, newVar]);
setNewVarName('');
setNewVarValue('');
setNewVarDesc('');
setShowAddForm(false);
};
const handleDeleteVar = (id: string) => { useEffect(() => {
setEnvVars(envVars.filter((v) => v.id !== id)); if (p) {
}; setVars(parseJSON<VarItem[]>(p.variables, []));
setCaches(parseJSON<CacheItem[]>(p.cachePaths, []));
}
}, [p?.id, p?.updatedAt]);
const toggleVisible = (id: string) => { const saveMutation = useMutation({
setVisibleValues((prev) => ({ ...prev, [id]: !prev[id] })); mutationFn: () => apiClient.updatePipeline(pipelineId, {
}; variables: JSON.stringify(vars.filter((v) => v.key)),
cachePaths: JSON.stringify(caches.filter((c) => c.path)),
}),
onSuccess: () => {
toast.success('变量与缓存配置已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return ( return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6"> <div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
{/* Environment Variables */} <div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}> <div className="flex items-center justify-between mb-4">
<CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">环境变量</CardTitle> <h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>环境变量</h3>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{vars.length} 项</span>
</div> </div>
<CardDescription className="flex items-center gap-1"> <button onClick={() => setVars((v) => [...v, { key: '', value: '', remark: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
在流水线各阶段可通过 <Plus className="w-3 h-3" />添加变量
<code className="px-1 py-0.5 rounded text-xs font-mono" style={{ background: 'var(--canvas-bg)' }}> </button>
${'{VAR_NAME}'} </div>
</code>
引用 {vars.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无环境变量</div>}
</CardDescription> {vars.map((v, i) => (
</CardHeader> <div key={i} className="flex items-center gap-2 mb-2">
<CardContent className="space-y-3"> <input value={v.key} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="KEY" className="w-40 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}> <input value={v.value} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} placeholder="value" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<Table> <input value={v.remark || ''} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, remark: e.target.value } : x)))} placeholder="备注" className="w-32 px-2.5 py-1.5 text-[12px] rounded-md border outline-none" style={inputStyle} />
<TableHeader> <button onClick={() => setVars((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
<TableRow style={{ background: 'var(--canvas-bg)' }}> <Trash2 className="w-3 h-3" />
<TableHead className="text-xs font-semibold h-9">变量名</TableHead>
<TableHead className="text-xs font-semibold h-9">变量值</TableHead>
<TableHead className="text-xs font-semibold h-9">说明</TableHead>
<TableHead className="text-xs font-semibold h-9 w-16">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{envVars.map((envVar) => (
<TableRow key={envVar.id} className="group">
<TableCell className="font-mono text-sm py-2.5">{envVar.name}</TableCell>
<TableCell className="py-2.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{visibleValues[envVar.id] ? envVar.value : '••••••••'}
</span>
<button
onClick={() => toggleVisible(envVar.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
{visibleValues[envVar.id] ? (
<EyeOff className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
) : (
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
)}
</button> </button>
</div> </div>
</TableCell>
<TableCell className="text-sm py-2.5" style={{ color: 'var(--text-secondary)' }}>
{envVar.description}
</TableCell>
<TableCell className="py-2.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleDeleteVar(envVar.id)}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: '#EF4444' }} />
</Button>
</TableCell>
</TableRow>
))} ))}
{envVars.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
暂无环境变量
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
{showAddForm ? ( <div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="tab-content-fade space-y-3 p-4 rounded-lg border" style={{ borderColor: 'var(--border-color)', background: 'var(--canvas-bg)' }}> <div className="flex items-center justify-between mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量名</Label>
<Input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
placeholder="如 BUILD_ARGS"
className="h-8 font-mono"
/>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量值</Label>
<Input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
placeholder="变量值"
className="h-8"
/>
</div>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>说明</Label>
<Input
value={newVarDesc}
onChange={(e) => setNewVarDesc(e.target.value)}
placeholder="变量说明(可选)"
className="h-8"
/>
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" className="h-8" onClick={handleAddVar}>
确认添加
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowAddForm(false)}>
取消
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => setShowAddForm(true)}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
添加变量
</Button>
)}
</CardContent>
</Card>
{/* Cache Config */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <Database className="w-5 h-5" style={{ color: '#1a7f37' }} />
<CardTitle className="text-base">缓存配置</CardTitle> <h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>缓存配置</h3>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{caches.length} 项</span>
</div> </div>
<Switch checked={cacheEnabled} onCheckedChange={setCacheEnabled} /> <button onClick={() => setCaches((c) => [...c, { path: '', key: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Plus className="w-3 h-3" />添加缓存
</button>
</div> </div>
<CardDescription>
配置构建缓存可显著加速后续构建速度 {caches.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无缓存配置</div>}
</CardDescription> {caches.map((c, i) => (
</CardHeader> <div key={i} className="flex items-center gap-2 mb-2">
{cacheEnabled && ( <input value={c.path} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, path: e.target.value } : x)))} placeholder="缓存路径(如 /root/.m2)" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<CardContent className="tab-content-fade space-y-4"> <input value={c.key} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="缓存 Key(如 ${BRANCH}-${BUILD_ID})" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<div> <button onClick={() => setCaches((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
<div className="flex items-center gap-2 mb-1.5"> <Trash2 className="w-3 h-3" />
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存路径</Label> </button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>需要缓存的目录路径,多个路径用逗号分隔</p>
</TooltipContent>
</Tooltip>
</div> </div>
<Input ))}
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
placeholder="/root/.m2, node_modules"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个路径用逗号分隔
</p>
</div> </div>
<div>
<div className="flex items-center gap-2 mb-1.5"> <div className="mt-4 flex justify-end">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存 Key</Label> <button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} className="flex items-center gap-1 px-4 py-1.5 text-[12px] rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
<Tooltip> {saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
<TooltipTrigger asChild> </button>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持使用变量,用于区分不同场景的缓存</p>
</TooltipContent>
</Tooltip>
</div> </div>
<Input
value={cacheKey}
onChange={(e) => setCacheKey(e.target.value)}
placeholder="${BRANCH_NAME}-${BUILD_ID}"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
支持变量引用,相同的 Key 会命中已有缓存
</p>
</div>
</CardContent>
)}
</Card>
</div> </div>
); );
} }
@@ -26,6 +26,10 @@ import { apiClient } from '@/lib/api';
interface FlowEditorProps { interface FlowEditorProps {
pipelineId: number; pipelineId: number;
/** 自增信号:TopBar「仅保存」触发 */
saveSignal?: number;
/** 自增信号:TopBar「保存并运行」触发 */
runSignal?: number;
} }
interface CatalogTask { interface CatalogTask {
@@ -58,7 +62,7 @@ const categoryColors: Record<string, string> = {
const nodeTypes = { tektonNode: TektonNode }; const nodeTypes = { tektonNode: TektonNode };
function FlowEditorInner({ pipelineId }: FlowEditorProps) { function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEditorProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [catalogOpen, setCatalogOpen] = useState(true); const [catalogOpen, setCatalogOpen] = useState(true);
@@ -212,6 +216,22 @@ function FlowEditorInner({ pipelineId }: FlowEditorProps) {
} }
}, [logs, logOpen]); }, [logs, logOpen]);
// TopBar「仅保存」信号(用 ref 取最新 handler 避免闭包过期)
const saveRef = useRef(handleSave);
saveRef.current = handleSave;
const runRef = useRef(handleRun);
runRef.current = handleRun;
useEffect(() => {
if (saveSignal > 0) saveRef.current();
}, [saveSignal]);
useEffect(() => {
if (runSignal > 0) {
saveRef.current().then(() => runRef.current()).catch(() => runRef.current());
}
}, [runSignal]);
const onConnect = useCallback( const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)), (params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
[setEdges] [setEdges]
+54 -1
View File
@@ -64,10 +64,15 @@ export interface PipelineRunDTO {
pipelineName: string pipelineName: string
branch: string branch: string
env: string env: string
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled' status: string
durationMs: number durationMs: number
triggerBy?: string
triggerType?: string
startedAt: string startedAt: string
completedAt?: string completedAt?: string
buildNumber?: number
externalRunId?: string
externalUrl?: string
} }
export const apiClient = { export const apiClient = {
@@ -92,4 +97,52 @@ export const apiClient = {
return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`) return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`)
}, },
getRun: (runId: string) => api<any>(`/api/runs/${runId}`), getRun: (runId: string) => api<any>(`/api/runs/${runId}`),
retryRun: (runId: string) => api<PipelineRunDTO>(`/api/runs/${runId}/retry`, { method: 'POST' }),
// 摘要/设置
getSummary: () => api<any>('/api/summary'),
getSettings: () => api<any>('/api/settings'),
updateSettings: (body: any) => api('/api/settings', { method: 'PUT', body: JSON.stringify(body) }),
// 主机
listHosts: () => api<any[]>('/api/hosts'),
createHost: (b: any) => api('/api/hosts', { method: 'POST', body: JSON.stringify(b) }),
updateHost: (id: any, b: any) => api(`/api/hosts/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteHost: (id: any) => api(`/api/hosts/${id}`, { method: 'DELETE' }),
testHost: (id: any) => api<any>(`/api/hosts/${id}/test`, { method: 'POST' }),
// 集群
listClusters: () => api<any[]>('/api/clusters'),
createCluster: (b: any) => api('/api/clusters', { method: 'POST', body: JSON.stringify(b) }),
updateCluster: (id: any, b: any) => api(`/api/clusters/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteCluster: (id: any) => api(`/api/clusters/${id}`, { method: 'DELETE' }),
testCluster: (id: any) => api<any>(`/api/clusters/${id}/test`, { method: 'POST' }),
// 凭证
listCredentials: (type?: string) => api<any[]>(`/api/credentials${type && type !== 'all' ? `?type=${type}` : ''}`),
createCredential: (b: any) => api('/api/credentials', { method: 'POST', body: JSON.stringify(b) }),
updateCredential: (id: any, b: any) => api(`/api/credentials/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteCredential: (id: any) => api(`/api/credentials/${id}`, { method: 'DELETE' }),
// 构建节点
listBuildNodes: () => api<any[]>('/api/build-nodes'),
createBuildNode: (b: any) => api('/api/build-nodes', { method: 'POST', body: JSON.stringify(b) }),
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }),
// 工具
listTools: () => api<any[]>('/api/tools'),
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
checkTool: (id: any) => api<any>(`/api/tools/${id}/check`, { method: 'POST' }),
installTool: (id: any) => api<any>(`/api/tools/${id}/install`, { method: 'POST' }),
// Webhook
listWebhooks: () => api<any[]>('/api/webhooks'),
createWebhook: (b: any) => api('/api/webhooks', { method: 'POST', body: JSON.stringify(b) }),
updateWebhook: (id: any, b: any) => api(`/api/webhooks/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteWebhook: (id: any) => api(`/api/webhooks/${id}`, { method: 'DELETE' }),
testWebhook: (id: any) => api<any>(`/api/webhooks/${id}/test`, { method: 'POST' }),
// Jenkins
listJenkinsServers: () => api<any[]>('/api/jenkins-servers'),
createJenkinsServer: (b: any) => api('/api/jenkins-servers', { method: 'POST', body: JSON.stringify(b) }),
updateJenkinsServer: (id: any, b: any) => api(`/api/jenkins-servers/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteJenkinsServer: (id: any) => api(`/api/jenkins-servers/${id}`, { method: 'DELETE' }),
testJenkinsServer: (id: any) => api<any>(`/api/jenkins-servers/${id}/test`, { method: 'POST' }),
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
importJenkinsJobs: (id: any, jobs: string[]) =>
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }),
} }
+272 -71
View File
@@ -1,23 +1,53 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ChevronRight, CheckCircle2, Circle, GitBranch, Server, ChevronRight, CheckCircle2, Circle, GitBranch, Server,
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface PipelineItem { // ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
id: string; name: string; branch: string; type: 'native' | 'jenkins'; interface PipelineDTO {
deployType: 'host' | 'k8s'; env: string; lastStatus: string; id: number;
name: string;
type: string;
sourceBranch?: string;
deployType: string;
env?: string;
lastRun: { status: string } | null;
} }
const mockPipelines: PipelineItem[] = [ interface BatchRun {
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' }, runId: string;
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' }, pipelineName: string;
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' }, status: string;
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' }, triggerType?: string;
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' }, startedAt: string;
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' }, }
];
const statusMeta: Record<string, { label: string; color: string }> = {
success: { label: '成功', color: '#1a7f37' },
failed: { label: '失败', color: '#cf222e' },
running: { label: '运行中', color: '#0969da' },
pending: { label: '排队中', color: '#9a6700' },
queued: { label: '排队中', color: '#9a6700' },
cancelled: { label: '已取消', color: '#6e7781' },
none: { label: '未执行', color: '#6e7781' },
};
const typeMeta: Record<string, { label: string; color: string; bg: string }> = {
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
};
const fallbackType = typeMeta.native;
const failPolicyLabels: Record<string, string> = {
stop: '立即中止后续发布',
continue: '继续发布其他流水线',
skip: '标记失败并跳过',
};
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done'; type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
const steps: { id: StepType; label: string }[] = [ const steps: { id: StepType; label: string }[] = [
@@ -28,44 +58,161 @@ const steps: { id: StepType; label: string }[] = [
{ id: 'done', label: '完成' }, { id: 'done', label: '完成' },
]; ];
export default function BatchRelease() { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
const queryClient = useQueryClient();
const [currentStep, setCurrentStep] = useState<StepType>('select'); const [currentStep, setCurrentStep] = useState<StepType>('select');
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 发布配置(本地状态)
const [strategy, setStrategy] = useState<'sequential' | 'parallel'>('sequential');
const [failPolicy, setFailPolicy] = useState<string>('stop');
const [checkDeployment, setCheckDeployment] = useState(true);
const [checkService, setCheckService] = useState(true);
const [checkPod, setCheckPod] = useState(false);
const [notifyFeishu, setNotifyFeishu] = useState(true);
const [notifyDingtalk, setNotifyDingtalk] = useState(false);
const [notifyWebhook, setNotifyWebhook] = useState(false);
// 执行状态
const [execLogs, setExecLogs] = useState<string[]>([]); const [execLogs, setExecLogs] = useState<string[]>([]);
const [completedCount, setCompletedCount] = useState(0); const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
const [failedTriggerIds, setFailedTriggerIds] = useState<Set<string>>(new Set());
const [triggeredRuns, setTriggeredRuns] = useState<Record<string, string>>({});
const [triggerDone, setTriggerDone] = useState(false);
const [execStartedAt, setExecStartedAt] = useState<number | null>(null);
// 流水线列表(真实数据)
const { data: pipelines = [], isLoading: pipelinesLoading } = useQuery({
queryKey: ['pipelines'],
queryFn: () => apiClient.listPipelines(),
});
// 执行期间轮询运行列表,展示每项进度
const { data: progressRuns = [] } = useQuery({
queryKey: ['runs'],
queryFn: () => apiClient.listRuns(),
refetchInterval: currentStep === 'executing' ? 2000 : false,
});
const items = (pipelines as PipelineDTO[]).map((p) => ({
id: String(p.id),
raw: p,
name: p.name,
branch: p.sourceBranch || '-',
type: p.type,
deployType: p.deployType,
env: p.env || '-',
lastStatus: p.lastRun?.status ?? 'none',
}));
const selectedPipelines = items.filter((p) => selectedIds.includes(p.id));
const stepIndex = steps.findIndex((s) => s.id === currentStep);
const toggleSelect = (id: string) => { const toggleSelect = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
}; };
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id)); // 按 runId 精确匹配,回退到 pipelineName 匹配本次批量发布时间窗内最新的 run
const stepIndex = steps.findIndex(s => s.id === currentStep); const getItemRun = (p: (typeof items)[number]): BatchRun | null => {
const rid = triggeredRuns[p.id];
const startExecution = () => { if (rid) {
setCurrentStep('executing'); const hit = (progressRuns as BatchRun[]).find((r) => r.runId === rid);
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]); if (hit) return hit;
let count = 0;
selectedPipelines.forEach((p, idx) => {
setTimeout(() => {
setExecLogs(prev => [
...prev,
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
]);
count++;
setCompletedCount(count);
if (count === selectedPipelines.length) {
setTimeout(() => {
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
setCurrentStep('done');
}, 500);
} }
}, idx * 2500); const since = execStartedAt ? execStartedAt - 60_000 : 0;
const candidates = (progressRuns as BatchRun[]).filter(
(r) => r.pipelineName === p.name && r.triggerType === 'batch' && new Date(r.startedAt).getTime() >= since
);
if (candidates.length === 0) return null;
return candidates.reduce((latest, r) =>
new Date(r.startedAt).getTime() > new Date(latest.startedAt).getTime() ? r : latest
);
};
const itemStatus = (p: (typeof items)[number]): { label: string; color: string; pulse: boolean; terminal: boolean } => {
if (executingIds.has(p.id)) return { label: '发布中…', color: '#0969da', pulse: true, terminal: false };
if (failedTriggerIds.has(p.id)) return { label: '触发失败', color: '#cf222e', pulse: false, terminal: true };
const run = getItemRun(p);
if (!run) return { label: triggerDone ? '等待中' : '排队中', color: '#9a6700', pulse: false, terminal: false };
const meta = statusMeta[run.status] || statusMeta.pending;
const terminal = run.status === 'success' || run.status === 'failed' || run.status === 'cancelled';
return { label: meta.label, color: meta.color, pulse: run.status === 'running', terminal };
};
const completedCount = selectedPipelines.filter((p) => itemStatus(p).terminal).length;
const successCount = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
const failedCount = selectedPipelines.length - successCount;
// 全部触发完成且每个 run 都到终态后,进入完成步骤
useEffect(() => {
if (currentStep !== 'executing' || !triggerDone) return;
const allDone = selectedPipelines.length > 0 && selectedPipelines.every((p) => itemStatus(p).terminal);
if (!allDone) return;
const ok = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
setExecLogs((prev) => [...prev, `[${stamp()}] 批量发布结束:成功 ${ok} 个,失败 ${selectedPipelines.length - ok} 个`]);
setCurrentStep('done');
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressRuns, triggerDone, currentStep, executingIds, failedTriggerIds]);
const startExecution = async () => {
if (currentStep === 'executing' || selectedPipelines.length === 0) return;
setCurrentStep('executing');
setTriggeredRuns({});
setFailedTriggerIds(new Set());
setTriggerDone(false);
setExecStartedAt(Date.now());
setExecLogs([`[${stamp()}] 批量发布开始,共 ${selectedPipelines.length} 个流水线(${strategy === 'sequential' ? '顺序' : '并行'}执行)`]);
const triggerOne = async (p: (typeof items)[number]) => {
setExecutingIds((prev) => new Set(prev).add(p.id));
try {
const run = await apiClient.createRun(p.raw.id, { triggerType: 'batch' });
setTriggeredRuns((prev) => ({ ...prev, [p.id]: run.runId }));
setExecLogs((prev) => [...prev, `[${stamp()}] ✓ 已触发 ${p.name} (${p.branch}) → ${run.runId}`]);
} catch (e) {
setFailedTriggerIds((prev) => new Set(prev).add(p.id));
setExecLogs((prev) => [...prev, `[${stamp()}] ✗ 触发失败 ${p.name}: ${(e as Error).message}`]);
toast.error(`触发失败: ${p.name}`, { description: (e as Error).message });
} finally {
setExecutingIds((prev) => {
const next = new Set(prev);
next.delete(p.id);
return next;
}); });
}
};
if (strategy === 'sequential') {
for (let i = 0; i < selectedPipelines.length; i++) {
await triggerOne(selectedPipelines[i]);
if (i < selectedPipelines.length - 1) await sleep(300);
}
} else {
await Promise.all(selectedPipelines.map(triggerOne));
}
setTriggerDone(true);
setExecLogs((prev) => [...prev, `[${stamp()}] 全部触发完成,等待运行结束...`]);
queryClient.invalidateQueries({ queryKey: ['runs'] });
};
const resetWizard = () => {
setCurrentStep('select');
setSelectedIds([]);
setExecLogs([]);
setExecutingIds(new Set());
setFailedTriggerIds(new Set());
setTriggeredRuns({});
setTriggerDone(false);
setExecStartedAt(null);
};
const handleViewRecords = () => {
if (onViewRecords) onViewRecords();
else toast.info('请点击左侧【发布记录】查看详细记录');
}; };
return ( return (
@@ -100,26 +247,47 @@ export default function BatchRelease() {
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p> <p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedIds(items.map((p) => p.id))}
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>全选</button>
<button onClick={() => setSelectedIds(items.filter((p) => !selectedIds.includes(p.id)).map((p) => p.id))}
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>反选</button>
</div>
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span> <span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
</div> </div>
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
</div> </div>
{mockPipelines.map((p) => { <div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div><div>最近状态</div>
</div>
{pipelinesLoading ? (
<div className="flex items-center justify-center py-10 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<Loader2 className="w-4 h-4 animate-spin mr-2" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</span>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center border-t text-[12px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>暂无可发布的流水线</div>
) : items.map((p) => {
const isSelected = selectedIds.includes(p.id); const isSelected = selectedIds.includes(p.id);
const tp = typeMeta[p.type] || fallbackType;
const last = statusMeta[p.lastStatus] || statusMeta.none;
return ( return (
<div key={p.id} onClick={() => toggleSelect(p.id)} <div key={p.id} onClick={() => toggleSelect(p.id)}
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors" className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)' }}> style={{ borderColor: 'var(--border-color-light)' }}>
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}> <div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />} {isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
</div></div> </div></div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div> <div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div> <div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div> <div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div> <div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : p.deployType === 'gitops' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> GitOps</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div> <div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${p.lastStatus === 'running' ? 'animate-pulse' : ''}`} style={{ background: last.color }} />
<span className="text-[11px]" style={{ color: last.color }}>{last.label}</span>
</div>
</div> </div>
); );
})} })}
@@ -139,34 +307,42 @@ export default function BatchRelease() {
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label> <div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}> <select value={strategy} onChange={(e) => setStrategy(e.target.value as 'sequential' | 'parallel')}
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option> className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option value="sequential">顺序发布(逐个执行,间隔 300ms)</option>
<option value="parallel">并行发布(同时执行,互不影响)</option>
</select></div> </select></div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label> <div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}> <select value={failPolicy} onChange={(e) => setFailPolicy(e.target.value)}
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option> className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option value="stop">立即中止后续发布</option>
<option value="continue">继续发布其他流水线</option>
<option value="skip">标记失败并跳过</option>
</select></div> </select></div>
</div> </div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label> <div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkDeployment} onChange={(e) => setCheckDeployment(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkService} onChange={(e) => setCheckService(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkPod} onChange={(e) => setCheckPod(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
</div></div> </div></div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label> <div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyFeishu} onChange={(e) => setNotifyFeishu(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyDingtalk} onChange={(e) => setNotifyDingtalk(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label> <label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyWebhook} onChange={(e) => setNotifyWebhook(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
</div></div> </div></div>
</div> </div>
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div> <div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{selectedPipelines.map((p) => ( {selectedPipelines.map((p) => {
const tp = typeMeta[p.type] || fallbackType;
return (
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}> <span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
<Package className="w-3 h-3" />{p.name}</span> <Package className="w-3 h-3" style={{ color: tp.color }} />{p.name}</span>
))} );
})}
</div></div> </div></div>
<div className="flex justify-between"> <div className="flex justify-between">
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button> <button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
@@ -181,17 +357,20 @@ export default function BatchRelease() {
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} /> <AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
</div> </div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2> <h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p> <p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,{strategy === 'sequential' ? '顺序' : '并行'}执行,{failPolicyLabels[failPolicy] || failPolicy}</p>
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{selectedPipelines.map((p, idx) => ( {selectedPipelines.map((p, idx) => {
const tp = typeMeta[p.type] || fallbackType;
return (
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}> <div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span> <span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span> <span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span> <span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span> <span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span>
</div> </div>
))} );
})}
</div> </div>
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button> <button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
@@ -212,7 +391,24 @@ export default function BatchRelease() {
</div> </div>
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span> <span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
</div> </div>
<div className="p-0"> {/* 每个流水线的实时状态 */}
<div className="divide-y" style={{ borderColor: 'var(--border-color-light)' }}>
{selectedPipelines.map((p) => {
const st = itemStatus(p);
return (
<div key={p.id} className="px-4 py-2 flex items-center gap-3">
<GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[13px] font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{p.branch}</span>
<span className={`flex items-center gap-1.5 text-[12px] font-medium w-[90px] justify-end ${st.pulse ? 'animate-pulse' : ''}`} style={{ color: st.color }}>
<span className="w-2 h-2 rounded-full" style={{ background: st.color }} />
{st.label}
</span>
</div>
);
})}
</div>
<div className="p-0 border-t" style={{ borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}> <div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} /> <Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
@@ -221,7 +417,7 @@ export default function BatchRelease() {
</div> </div>
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}> <div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
{execLogs.map((log, idx) => ( {execLogs.map((log, idx) => (
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div> <div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('✗') ? <span style={{ color: '#f85149' }}>{log}</span> : log.includes('→') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
))} ))}
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div> <div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
</div> </div>
@@ -232,15 +428,20 @@ export default function BatchRelease() {
{currentStep === 'done' && ( {currentStep === 'done' && (
<div className="max-w-2xl mx-auto text-center py-8"> <div className="max-w-2xl mx-auto text-center py-8">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}> <div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: failedCount > 0 ? '#fff8c5' : '#dafbe1' }}>
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} /> {failedCount > 0
? <AlertCircle className="w-8 h-8" style={{ color: '#9a6700' }} />
: <CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />}
</div> </div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2> <h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p> <p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
共发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线:
成功 <span className="font-semibold" style={{ color: '#1a7f37' }}>{successCount}</span> 个,失败 <span className="font-semibold" style={{ color: '#cf222e' }}>{failedCount}</span> 个
</p>
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }} <button onClick={resetWizard}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button> className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button> <button onClick={handleViewRecords} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看发布记录</button>
</div> </div>
</div> </div>
)} )}
+275 -51
View File
@@ -9,29 +9,42 @@ import {
Edit2, Edit2,
Trash2, Trash2,
Tag, Tag,
Activity Zap,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
interface BuildNode { // 后端 labels 为 JSON 字符串,安全解析为数组
id: string; function parseJsonArray(v: unknown): string[] {
name: string; try {
ip: string; const arr = JSON.parse(String(v ?? '[]'));
labels: string[]; return Array.isArray(arr) ? arr.map(String) : [];
maxConcurrency: number; } catch {
currentJobs: number; return [];
status: 'online' | 'offline' | 'busy'; }
cpu: string;
memory: string;
disk: string;
lastHeartbeat: string;
} }
const mockNodes: BuildNode[] = [ function splitComma(v: string): string[] {
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' }, return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' }, }
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
]; // 相对时间:如 "10秒前"
function timeAgo(v?: string): string {
if (!v) return '—';
const t = new Date(v).getTime();
if (Number.isNaN(t)) return v;
const s = Math.floor((Date.now() - t) / 1000);
if (s < 10) return '刚刚';
if (s < 60) return `${s}秒前`;
if (s < 3600) return `${Math.floor(s / 60)}分钟前`;
if (s < 86400) return `${Math.floor(s / 3600)}小时前`;
return `${Math.floor(s / 86400)}天前`;
}
const statusConfig = { const statusConfig = {
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' }, online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
@@ -39,11 +52,118 @@ const statusConfig = {
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' }, busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
}; };
const unknownStatus = { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '未知' };
interface NodeForm {
name: string;
ip: string;
labels: string;
maxConcurrency: string;
workspacePath: string;
}
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace' };
const inputStyle = {
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-primary)',
};
export default function BuildNodeManager() { export default function BuildNodeManager() {
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const filtered = mockNodes.filter(n =>
n.name.includes(searchQuery) || n.ip.includes(searchQuery) const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
const nodes: any[] = nodesQuery.data ?? [];
// ---- 弹窗 ----
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any | null>(null);
const [form, setForm] = useState<NodeForm>(emptyForm);
const openDialog = (node?: any) => {
setEditing(node ?? null);
setForm(
node
? {
name: node.name ?? '',
ip: node.ip ?? '',
labels: parseJsonArray(node.labels).join(','),
maxConcurrency: String(node.maxConcurrency ?? 2),
workspacePath: node.workspacePath ?? '',
}
: emptyForm,
); );
setDialogOpen(true);
};
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['build-nodes'] });
const saveMutation = useMutation({
mutationFn: (vars: { id?: any; body: any }) =>
vars.id ? apiClient.updateBuildNode(vars.id, vars.body) : apiClient.createBuildNode(vars.body),
onSuccess: (_data, vars) => {
toast.success(vars.id ? '节点已更新' : '节点已创建');
invalidate();
setDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deleteBuildNode(id),
onSuccess: () => {
toast.success('节点已删除');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const testMutation = useMutation({
mutationFn: (id: any) => apiClient.testBuildNode(id),
onSuccess: (result: any) => {
if (result?.success) toast.success(result?.message || '连接成功');
else toast.error(result?.message || '连接失败');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const toggleMutation = useMutation({
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
onSuccess: (_data, vars) => {
toast.success(vars.enabled ? '节点已启用' : '节点已停用');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const submit = () => {
if (!form.name.trim() || !form.ip.trim()) {
toast.error('请填写节点名称和 IP 地址');
return;
}
saveMutation.mutate({
id: editing?.id,
body: {
name: form.name.trim(),
ip: form.ip.trim(),
labels: JSON.stringify(splitComma(form.labels)),
maxConcurrency: Number(form.maxConcurrency) || 1,
workspacePath: form.workspacePath.trim(),
},
});
};
// 统计(真实数据计算)
const stats = [
{ label: '在线节点', count: nodes.filter((n) => n.status === 'online').length },
{ label: '忙碌节点', count: nodes.filter((n) => n.status === 'busy').length },
{ label: '离线节点', count: nodes.filter((n) => n.status !== 'online' && n.status !== 'busy').length },
];
const filtered = nodes.filter((n) => n.name?.includes(searchQuery) || n.ip?.includes(searchQuery));
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
@@ -62,7 +182,7 @@ export default function BuildNodeManager() {
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} /> <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div> </div>
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}> <button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => openDialog()}>
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
添加节点 添加节点
</button> </button>
@@ -70,12 +190,27 @@ export default function BuildNodeManager() {
</div> </div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{nodesQuery.isLoading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
加载中...
</div>
) : nodesQuery.isError ? (
<div className="flex flex-col items-center gap-3 py-16">
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(nodesQuery.error as Error).message}</div>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
onClick={() => nodesQuery.refetch()}
>
重试
</button>
</div>
) : (
<>
<div className="grid grid-cols-3 gap-3 mb-4"> <div className="grid grid-cols-3 gap-3 mb-4">
{[ {stats.map((stat) => (
{ label: '在线节点', count: 2, color: '#1a7f37' },
{ label: '忙碌节点', count: 1, color: '#9a6700' },
{ label: '离线节点', count: 1, color: '#cf222e' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div> <div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
@@ -85,11 +220,14 @@ export default function BuildNodeManager() {
<div className="space-y-3"> <div className="space-y-3">
{filtered.map((node) => { {filtered.map((node) => {
const status = statusConfig[node.status]; const status = statusConfig[node.status as keyof typeof statusConfig] ?? { ...unknownStatus, label: String(node.status) };
const StatusIcon = status.icon; const StatusIcon = status.icon;
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100; const maxConcurrency = Number(node.maxConcurrency) || 1;
const currentJobs = Number(node.currentJobs) || 0;
const utilization = node.status === 'offline' ? 0 : (currentJobs / maxConcurrency) * 100;
const labels = parseJsonArray(node.labels);
return ( return (
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', opacity: node.enabled === false ? 0.7 : 1 }}>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
@@ -103,18 +241,45 @@ export default function BuildNodeManager() {
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} /> <StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
{status.label} {status.label}
</span> </span>
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button> <div className="flex items-center gap-1" title={node.enabled === false ? '已停用' : '已启用'}>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button> <Switch
checked={node.enabled !== false}
disabled={toggleMutation.isPending && toggleMutation.variables?.id === node.id}
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
/>
</div>
<button
className="p-1 rounded hover:bg-black/5"
title="测试连接"
disabled={testMutation.isPending && testMutation.variables === node.id}
onClick={() => testMutation.mutate(node.id)}
>
{testMutation.isPending && testMutation.variables === node.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
) : (
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
)}
</button>
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(node)}>
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除节点 ${node.name} 吗?`)) deleteMutation.mutate(node.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div> </div>
</div> </div>
{node.status !== 'offline' && (
<>
{/* Concurrency bar */} {/* Concurrency bar */}
<div className="mb-3"> <div className="mb-3">
<div className="flex items-center justify-between text-[11px] mb-1"> <div className="flex items-center justify-between text-[11px] mb-1">
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span> <span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span> <span style={{ color: 'var(--text-secondary)' }}>{currentJobs} / {maxConcurrency}</span>
</div> </div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}> <div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
<div <div
@@ -127,42 +292,101 @@ export default function BuildNodeManager() {
</div> </div>
</div> </div>
{/* Resource metrics */} {/* Info: 工作空间 / 心跳(后端暂无 CPU/内存/磁盘数据) */}
<div className="grid grid-cols-4 gap-3 text-[11px]"> <div className="grid grid-cols-2 gap-3 text-[11px]">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0">
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> <span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>工作空间</span>
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span> <span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
</div> {node.workspacePath || '—'}
<div className="flex items-center gap-1.5"> </span>
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span> <span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span> <span style={{ color: 'var(--text-secondary)' }}>{timeAgo(node.lastHeartbeat)}</span>
</div> </div>
</div> </div>
</>
)}
{/* Labels */} {/* Labels */}
{labels.length > 0 && (
<div className="flex items-center gap-1 mt-3 flex-wrap"> <div className="flex items-center gap-1 mt-3 flex-wrap">
{node.labels.map((label) => ( {labels.map((label) => (
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}> <span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<Tag className="w-2.5 h-2.5" /> <Tag className="w-2.5 h-2.5" />
{label} {label}
</span> </span>
))} ))}
</div> </div>
)}
</div> </div>
); );
})} })}
{filtered.length === 0 && (
<div
className="rounded-lg border p-10 text-center text-[12px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
>
暂无构建节点,点击右上角「添加节点」创建
</div>
)}
</div>
</>
)}
</div>
{/* 添加/编辑节点弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
{editing ? '编辑节点' : '添加节点'}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="build-host-01" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.10.101" />
</div> </div>
</div> </div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={form.labels} onChange={(e) => setForm({ ...form, labels: e.target.value })} placeholder="docker,go,java" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>最大并发</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={form.maxConcurrency} onChange={(e) => setForm({ ...form, maxConcurrency: e.target.value })} placeholder="2" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>工作空间路径</Label>
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.workspacePath} onChange={(e) => setForm({ ...form, workspacePath: e.target.value })} placeholder="/var/lib/flowstack/workspace" />
</div>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
disabled={saveMutation.isPending}
onClick={submit}
>
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{editing ? '保存' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+269 -50
View File
@@ -3,34 +3,23 @@ import {
Key, Key,
Plus, Plus,
Search, Search,
Eye,
EyeOff,
Copy, Copy,
Trash2, Trash2,
Edit2, Edit2,
Shield, Shield,
GitBranch, GitBranch,
Container Container,
Cloud,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Credential { import { apiClient } from '@/lib/api';
id: string; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
name: string; import { Label } from '@/components/ui/label';
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig'; import { Input } from '@/components/ui/input';
scope: string; import { Textarea } from '@/components/ui/textarea';
createdAt: string; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
usageCount: number;
}
const mockCredentials: Credential[] = [
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
];
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = { const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' }, ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
@@ -38,24 +27,111 @@ const typeConfig: Record<string, { icon: typeof Key; label: string; color: strin
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' }, docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' }, git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' }, kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' },
};
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
function getConfig(type: string) {
return typeConfig[type] ?? { ...fallbackConfig, label: type };
}
function fmtTime(v?: string): string {
if (!v || v.startsWith('0001-')) return '—';
return v.replace('T', ' ').slice(0, 16);
}
interface CredentialForm {
name: string;
type: string;
scope: string;
content: string;
}
const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' };
const inputStyle = {
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-primary)',
}; };
export default function CredentialManager() { export default function CredentialManager() {
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({}); const [typeFilter, setTypeFilter] = useState<string>('all');
const filtered = mockCredentials.filter(c => // 列表(跟随类型筛选)
c.name.includes(searchQuery) || c.type.includes(searchQuery) const credentialsQuery = useQuery({
queryKey: ['credentials', typeFilter],
queryFn: () => apiClient.listCredentials(typeFilter),
});
// 全量(用于类型统计卡数量)
const allQuery = useQuery({ queryKey: ['credentials', 'all'], queryFn: () => apiClient.listCredentials() });
const credentials: any[] = credentialsQuery.data ?? [];
const allCredentials: any[] = allQuery.data ?? [];
// ---- 弹窗 ----
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any | null>(null);
const [form, setForm] = useState<CredentialForm>(emptyForm);
const openDialog = (cred?: any) => {
setEditing(cred ?? null);
setForm(
cred
? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' }
: emptyForm,
); );
setDialogOpen(true);
const toggleSecret = (id: string) => {
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
}; };
const copyValue = (name: string) => { const invalidate = () => queryClient.invalidateQueries({ queryKey: ['credentials'] });
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
const saveMutation = useMutation({
mutationFn: (vars: { id?: any; body: any }) =>
vars.id ? apiClient.updateCredential(vars.id, vars.body) : apiClient.createCredential(vars.body),
onSuccess: (_data, vars) => {
toast.success(vars.id ? '凭证已更新' : '凭证已创建');
invalidate();
setDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deleteCredential(id),
onSuccess: () => {
toast.success('凭证已删除');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const submit = () => {
if (!form.name.trim()) {
toast.error('请填写凭证名称');
return;
}
// 内容加密存储(write-only):编辑时留空表示不修改
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
if (form.content.trim()) body.content = form.content.trim();
saveMutation.mutate({ id: editing?.id, body });
}; };
const copySummary = (cred: any) => {
const text = cred.summary || getConfig(cred.type).label;
navigator.clipboard
.writeText(text)
.then(() => toast.success('已复制', { description: text }))
.catch(() => toast.error('复制失败'));
};
const filtered = credentials.filter(
(c) => c.name?.includes(searchQuery) || c.scope?.includes(searchQuery) || c.type?.includes(searchQuery),
);
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div <div
@@ -70,6 +146,15 @@ export default function CredentialManager() {
> >
{filtered.length} {filtered.length}
</span> </span>
{typeFilter !== 'all' && (
<button
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}
onClick={() => setTypeFilter('all')}
>
{getConfig(typeFilter).label} ×
</button>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
@@ -88,7 +173,7 @@ export default function CredentialManager() {
<button <button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })} onClick={() => openDialog()}
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
新建凭证 新建凭证
@@ -97,21 +182,48 @@ export default function CredentialManager() {
</div> </div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{/* Type summary */} {credentialsQuery.isLoading ? (
<div className="grid grid-cols-5 gap-3 mb-4"> <div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
加载中...
</div>
) : credentialsQuery.isError ? (
<div className="flex flex-col items-center gap-3 py-16">
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(credentialsQuery.error as Error).message}</div>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
onClick={() => credentialsQuery.refetch()}
>
重试
</button>
</div>
) : (
<>
{/* Type summary(点击可按类型筛选) */}
<div className="grid grid-cols-6 gap-3 mb-4">
{Object.entries(typeConfig).map(([type, config]) => { {Object.entries(typeConfig).map(([type, config]) => {
const count = mockCredentials.filter(c => c.type === type).length; const count = allCredentials.filter((c) => c.type === type).length;
const Icon = config.icon; const Icon = config.icon;
const active = typeFilter === type;
return ( return (
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <button
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}> key={type}
onClick={() => setTypeFilter(active ? 'all' : type)}
className="rounded-lg border p-3 flex items-center gap-3 text-left transition-colors hover:bg-black/[0.02]"
style={{
background: 'var(--panel-bg)',
borderColor: active ? 'var(--accent-blue)' : 'var(--border-color)',
}}
>
<div className="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0" style={{ background: config.bg }}>
<Icon className="w-4 h-4" style={{ color: config.color }} /> <Icon className="w-4 h-4" style={{ color: config.color }} />
</div> </div>
<div> <div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div> <div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div> <div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
</div> </div>
</div> </button>
); );
})} })}
</div> </div>
@@ -121,7 +233,7 @@ export default function CredentialManager() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}> <tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => ( {['凭证名称', '类型', '使用范围', '使用次数', '最近使用', '创建时间', '摘要', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}> <th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h} {h}
</th> </th>
@@ -130,9 +242,8 @@ export default function CredentialManager() {
</thead> </thead>
<tbody> <tbody>
{filtered.map((cred) => { {filtered.map((cred) => {
const config = typeConfig[cred.type]; const config = getConfig(cred.type);
const Icon = config.icon; const Icon = config.icon;
const isRevealed = showSecrets[cred.id];
return ( return (
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}> <tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -148,35 +259,143 @@ export default function CredentialManager() {
{config.label} {config.label}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope || '—'}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount ?? 0}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.lastUsedAt)}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}> {cred.contentSet === false ? (
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'} <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未设置内容</span>
) : (
<>
<code
className="text-[11px] px-2 py-[3px] rounded font-mono max-w-[220px] truncate inline-block"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}
title={cred.summary || config.label}
>
{cred.summary || config.label}
</code> </code>
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5"> <button onClick={() => copySummary(cred)} className="p-1 rounded hover:bg-black/5" title="复制摘要">
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
</button>
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> <Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
</>
)}
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button> <button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(cred)}>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button> <Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除凭证 ${cred.name} 吗?`)) deleteMutation.mutate(cred.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div> </div>
</td> </td>
</tr> </tr>
); );
})} })}
{filtered.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
暂无凭证,点击右上角「新建凭证」创建
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)}
</div> </div>
{/* 新建/编辑凭证弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
{editing ? '编辑凭证' : '新建凭证'}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
<Input
className="h-8 text-[12px]"
style={inputStyle}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="production-ssh-key"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
{Object.entries(typeConfig).map(([type, config]) => (
<SelectItem key={type} value={type} className="text-[12px]">
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>使用范围</Label>
<Input
className="h-8 text-[12px]"
style={inputStyle}
value={form.scope}
onChange={(e) => setForm({ ...form, scope: e.target.value })}
placeholder="全局 / production-group"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>内容</Label>
<Textarea
className="text-[12px] min-h-[88px] font-mono"
style={inputStyle}
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder={editing ? '留空表示不修改' : '私钥 / 密码 / Token / kubeconfig / AK·SK 等'}
/>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
内容加密存储,保存后不可查看。
</div>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
disabled={saveMutation.isPending}
onClick={submit}
>
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{editing ? '保存' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+437 -55
View File
@@ -7,58 +7,242 @@ import {
XCircle, XCircle,
Edit2, Edit2,
Trash2, Trash2,
Terminal,
CloudCog, CloudCog,
Zap,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
interface Host { // 后端 tags/namespaces 为 JSON 字符串,安全解析为数组
id: string; function parseJsonArray(v: unknown): string[] {
try {
const arr = JSON.parse(String(v ?? '[]'));
return Array.isArray(arr) ? arr.map(String) : [];
} catch {
return [];
}
}
function splitComma(v: string): string[] {
return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
}
function fmtTime(v?: string): string {
if (!v || v.startsWith('0001-')) return '—';
return v.replace('T', ' ').slice(0, 19);
}
interface HostForm {
name: string; name: string;
ip: string; ip: string;
port: number; port: string;
username: string; username: string;
tags: string[]; tags: string;
status: 'online' | 'offline';
os: string; os: string;
remark: string;
} }
interface K8sCluster { const emptyHostForm: HostForm = { name: '', ip: '', port: '22', username: 'root', tags: '', os: '', remark: '' };
id: string;
interface ClusterForm {
name: string; name: string;
context: string; context: string;
apiServer: string;
version: string; version: string;
nodes: number; kubeconfig: string;
namespaces: string[];
status: 'connected' | 'disconnected';
} }
const mockHosts: Host[] = [ const emptyClusterForm: ClusterForm = { name: '', context: '', apiServer: '', version: '', kubeconfig: '' };
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
];
const mockClusters: K8sCluster[] = [ const inputStyle = {
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' }, background: 'var(--panel-bg-secondary)',
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' }, borderColor: 'var(--border-color-light)',
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' }, color: 'var(--text-primary)',
]; };
export default function EnvironmentManager() { export default function EnvironmentManager() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts'); const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = mockHosts.filter(h => // ---- 数据获取 ----
h.name.includes(searchQuery) || h.ip.includes(searchQuery) const hostsQuery = useQuery({ queryKey: ['hosts'], queryFn: () => apiClient.listHosts() });
); const clustersQuery = useQuery({ queryKey: ['clusters'], queryFn: () => apiClient.listClusters() });
const filteredClusters = mockClusters.filter(c => const hosts: any[] = hostsQuery.data ?? [];
c.name.includes(searchQuery) || c.context.includes(searchQuery) const clusters: any[] = clustersQuery.data ?? [];
// ---- 主机弹窗 ----
const [hostDialogOpen, setHostDialogOpen] = useState(false);
const [editingHost, setEditingHost] = useState<any | null>(null);
const [hostForm, setHostForm] = useState<HostForm>(emptyHostForm);
// ---- 集群弹窗 ----
const [clusterDialogOpen, setClusterDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<any | null>(null);
const [clusterForm, setClusterForm] = useState<ClusterForm>(emptyClusterForm);
const openHostDialog = (host?: any) => {
setEditingHost(host ?? null);
setHostForm(
host
? {
name: host.name ?? '',
ip: host.ip ?? '',
port: String(host.port ?? 22),
username: host.username ?? '',
tags: parseJsonArray(host.tags).join(','),
os: host.os ?? '',
remark: host.remark ?? '',
}
: emptyHostForm,
); );
setHostDialogOpen(true);
};
const openClusterDialog = (cluster?: any) => {
setEditingCluster(cluster ?? null);
setClusterForm(
cluster
? {
name: cluster.name ?? '',
context: cluster.context ?? '',
apiServer: cluster.apiServer ?? '',
version: cluster.version ?? '',
kubeconfig: '',
}
: emptyClusterForm,
);
setClusterDialogOpen(true);
};
// ---- 变更 ----
const invalidateHosts = () => queryClient.invalidateQueries({ queryKey: ['hosts'] });
const invalidateClusters = () => queryClient.invalidateQueries({ queryKey: ['clusters'] });
const hostSaveMutation = useMutation({
mutationFn: (vars: { id?: any; body: any }) =>
vars.id ? apiClient.updateHost(vars.id, vars.body) : apiClient.createHost(vars.body),
onSuccess: (_data, vars) => {
toast.success(vars.id ? '主机已更新' : '主机已创建');
invalidateHosts();
setHostDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
const hostDeleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deleteHost(id),
onSuccess: () => {
toast.success('主机已删除');
invalidateHosts();
},
onError: (err: Error) => toast.error(err.message),
});
const hostTestMutation = useMutation({
mutationFn: (id: any) => apiClient.testHost(id),
onSuccess: (result: any) => {
const latency = result?.latencyMs != null && result.latencyMs !== '' ? `${result.latencyMs}ms` : null;
if (result?.success) toast.success(result?.message || '连接成功', { description: latency ? `延迟 ${latency}` : undefined });
else toast.error(result?.message || '连接失败');
invalidateHosts();
},
onError: (err: Error) => toast.error(err.message),
});
const clusterSaveMutation = useMutation({
mutationFn: (vars: { id?: any; body: any }) =>
vars.id ? apiClient.updateCluster(vars.id, vars.body) : apiClient.createCluster(vars.body),
onSuccess: (_data, vars) => {
toast.success(vars.id ? '集群已更新' : '集群已创建');
invalidateClusters();
setClusterDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
const clusterDeleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deleteCluster(id),
onSuccess: () => {
toast.success('集群已删除');
invalidateClusters();
},
onError: (err: Error) => toast.error(err.message),
});
const clusterTestMutation = useMutation({
mutationFn: (id: any) => apiClient.testCluster(id),
onSuccess: (result: any) => {
if (result?.success) toast.success(result?.message || '连接成功');
else toast.error(result?.message || '连接失败');
invalidateClusters();
},
onError: (err: Error) => toast.error(err.message),
});
const submitHost = () => {
if (!hostForm.name.trim() || !hostForm.ip.trim()) {
toast.error('请填写主机名称和 IP 地址');
return;
}
hostSaveMutation.mutate({
id: editingHost?.id,
body: {
name: hostForm.name.trim(),
ip: hostForm.ip.trim(),
port: Number(hostForm.port) || 22,
username: hostForm.username.trim(),
tags: JSON.stringify(splitComma(hostForm.tags)),
os: hostForm.os.trim(),
remark: hostForm.remark.trim(),
},
});
};
const submitCluster = () => {
if (!clusterForm.name.trim()) {
toast.error('请填写集群名称');
return;
}
const body: any = {
name: clusterForm.name.trim(),
context: clusterForm.context.trim(),
apiServer: clusterForm.apiServer.trim(),
version: clusterForm.version.trim(),
};
// kubeconfig 只写不读:编辑时留空表示不修改
if (clusterForm.kubeconfig.trim()) body.kubeconfig = clusterForm.kubeconfig.trim();
clusterSaveMutation.mutate({ id: editingCluster?.id, body });
};
// ---- 统计(真实数据计算) ----
const tagsOf = (h: any) => parseJsonArray(h.tags);
const hostStats = [
{ label: '生产环境', list: hosts.filter((h) => tagsOf(h).some((t) => /prod/i.test(t))) },
{ label: '预发环境', list: hosts.filter((h) => tagsOf(h).some((t) => /stag/i.test(t))) },
{ label: '测试环境', list: hosts.filter((h) => tagsOf(h).some((t) => /test|qa|dev/i.test(t))) },
{ label: '总计', list: hosts },
].map((s) => ({ ...s, count: s.list.length, online: s.list.filter((h) => h.status === 'online').length }));
const clusterStats = [
{ label: '已连接', count: clusters.filter((c) => c.status === 'connected').length },
{ label: '未连接', count: clusters.filter((c) => c.status !== 'connected').length },
{ label: '总节点数', count: clusters.reduce((sum, c) => sum + (Number(c.nodes) || 0), 0) },
];
const filteredHosts = hosts.filter((h) => h.name?.includes(searchQuery) || h.ip?.includes(searchQuery));
const filteredClusters = clusters.filter((c) => c.name?.includes(searchQuery) || c.context?.includes(searchQuery));
const isLoading = activeTab === 'hosts' ? hostsQuery.isLoading : clustersQuery.isLoading;
const queryError = activeTab === 'hosts' ? hostsQuery.error : clustersQuery.error;
const refetch = activeTab === 'hosts' ? hostsQuery.refetch : clustersQuery.refetch;
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
@@ -109,7 +293,7 @@ export default function EnvironmentManager() {
<button <button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })} onClick={() => (activeTab === 'hosts' ? openHostDialog() : openClusterDialog())}
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
添加{activeTab === 'hosts' ? '主机' : '集群'} 添加{activeTab === 'hosts' ? '主机' : '集群'}
@@ -119,16 +303,28 @@ export default function EnvironmentManager() {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{activeTab === 'hosts' ? ( {isLoading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
加载中...
</div>
) : queryError ? (
<div className="flex flex-col items-center gap-3 py-16">
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(queryError as Error).message}</div>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
onClick={() => refetch()}
>
重试
</button>
</div>
) : activeTab === 'hosts' ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Host groups summary */} {/* Host groups summary */}
<div className="grid grid-cols-4 gap-3 mb-4"> <div className="grid grid-cols-4 gap-3 mb-4">
{[ {hostStats.map((stat) => (
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
{ label: '总计', count: 5, online: 4, color: '#0969da' },
].map((stat) => (
<div <div
key={stat.label} key={stat.label}
className="rounded-lg border p-4" className="rounded-lg border p-4"
@@ -171,7 +367,7 @@ export default function EnvironmentManager() {
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
{host.tags.map((tag) => ( {tagsOf(host).map((tag) => (
<span <span
key={tag} key={tag}
className="text-[10px] px-1.5 py-[2px] rounded font-medium" className="text-[10px] px-1.5 py-[2px] rounded font-medium"
@@ -182,7 +378,7 @@ export default function EnvironmentManager() {
))} ))}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os || '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{host.status === 'online' ? ( {host.status === 'online' ? (
@@ -197,19 +393,41 @@ export default function EnvironmentManager() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}> <button
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> className="p-1 rounded hover:bg-black/5"
title="测试连接"
disabled={hostTestMutation.isPending && hostTestMutation.variables === host.id}
onClick={() => hostTestMutation.mutate(host.id)}
>
{hostTestMutation.isPending && hostTestMutation.variables === host.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
) : (
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
)}
</button> </button>
<button className="p-1 rounded hover:bg-black/5" title="编辑"> <button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openHostDialog(host)}>
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
<button className="p-1 rounded hover:bg-black/5" title="删除"> <button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除主机 ${host.name} 吗?`)) hostDeleteMutation.mutate(host.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{filteredHosts.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
暂无主机,点击右上角「添加主机」创建
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -218,11 +436,7 @@ export default function EnvironmentManager() {
/* K8s Clusters */ /* K8s Clusters */
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-3 mb-4"> <div className="grid grid-cols-3 gap-3 mb-4">
{[ {clusterStats.map((stat) => (
{ label: '已连接', count: 2, color: '#1a7f37' },
{ label: '未连接', count: 1, color: '#cf222e' },
{ label: '总节点数', count: 11, color: '#0969da' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div> <div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
@@ -242,7 +456,9 @@ export default function EnvironmentManager() {
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} /> <CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<div> <div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div> <div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div> <div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
Context: {cluster.context || '—'} · <span className="font-mono">{cluster.apiServer || '—'}</span>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -255,24 +471,49 @@ export default function EnvironmentManager() {
> >
{cluster.status === 'connected' ? '已连接' : '未连接'} {cluster.status === 'connected' ? '已连接' : '未连接'}
</span> </span>
<button className="p-1 rounded hover:bg-black/5"> <button
className="p-1 rounded hover:bg-black/5"
title="测试连接"
disabled={clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id}
onClick={() => clusterTestMutation.mutate(cluster.id)}
>
{clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
) : (
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
)}
</button>
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openClusterDialog(cluster)}>
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除集群 ${cluster.name} 吗?`)) clusterDeleteMutation.mutate(cluster.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 gap-4 text-[12px]"> <div className="grid grid-cols-4 gap-4 text-[12px]">
<div> <div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div> <div style={{ color: 'var(--text-primary)' }}>{cluster.version || '—'}</div>
</div> </div>
<div> <div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div> <div style={{ color: 'var(--text-primary)' }}>{cluster.nodes ?? 0}</div>
</div> </div>
<div className="col-span-2"> <div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>最近心跳</div>
<div style={{ color: 'var(--text-primary)' }}>{fmtTime(cluster.lastHeartbeat)}</div>
</div>
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
{cluster.namespaces.map((ns) => ( {parseJsonArray(cluster.namespaces).map((ns) => (
<span <span
key={ns} key={ns}
className="text-[10px] px-1.5 py-[2px] rounded font-medium" className="text-[10px] px-1.5 py-[2px] rounded font-medium"
@@ -286,10 +527,151 @@ export default function EnvironmentManager() {
</div> </div>
</div> </div>
))} ))}
</div> {filteredClusters.length === 0 && (
<div
className="rounded-lg border p-10 text-center text-[12px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
>
暂无集群,点击右上角「添加集群」创建
</div> </div>
)} )}
</div> </div>
</div> </div>
)}
</div>
{/* 添加/编辑主机弹窗 */}
<Dialog open={hostDialogOpen} onOpenChange={setHostDialogOpen}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
{editingHost ? '编辑主机' : '添加主机'}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.name} onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })} placeholder="prod-web-01" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.ip} onChange={(e) => setHostForm({ ...hostForm, ip: e.target.value })} placeholder="192.168.1.101" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 端口</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.port} onChange={(e) => setHostForm({ ...hostForm, port: e.target.value })} placeholder="22" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.username} onChange={(e) => setHostForm({ ...hostForm, username: e.target.value })} placeholder="root" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.tags} onChange={(e) => setHostForm({ ...hostForm, tags: e.target.value })} placeholder="production,web" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>操作系统</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.os} onChange={(e) => setHostForm({ ...hostForm, os: e.target.value })} placeholder="Ubuntu 22.04" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>备注</Label>
<Textarea
className="text-[12px] min-h-[56px]"
style={inputStyle}
value={hostForm.remark}
onChange={(e) => setHostForm({ ...hostForm, remark: e.target.value })}
placeholder="可选"
/>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setHostDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
disabled={hostSaveMutation.isPending}
onClick={submitHost}
>
{hostSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{editingHost ? '保存' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加/编辑集群弹窗 */}
<Dialog open={clusterDialogOpen} onOpenChange={setClusterDialogOpen}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
{editingCluster ? '编辑集群' : '添加集群'}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.name} onChange={(e) => setClusterForm({ ...clusterForm, name: e.target.value })} placeholder="ack-production" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Context</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.context} onChange={(e) => setClusterForm({ ...clusterForm, context: e.target.value })} placeholder="ack-prod" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Server</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.apiServer} onChange={(e) => setClusterForm({ ...clusterForm, apiServer: e.target.value })} placeholder="https://xxx.xxx.aliyuncs.com:6443" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>版本</Label>
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.version} onChange={(e) => setClusterForm({ ...clusterForm, version: e.target.value })} placeholder="v1.28.3" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Kubeconfig</Label>
<Textarea
className="text-[12px] min-h-[88px] font-mono"
style={inputStyle}
value={clusterForm.kubeconfig}
onChange={(e) => setClusterForm({ ...clusterForm, kubeconfig: e.target.value })}
placeholder={editingCluster ? '留空表示不修改' : '粘贴 kubeconfig YAML(只写不读,保存后不可查看)'}
/>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
kubeconfig 加密存储、只写不读,保存后无法再次查看;编辑时留空表示不修改。
</div>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setClusterDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
disabled={clusterSaveMutation.isPending}
onClick={submitCluster}
>
{clusterSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{editingCluster ? '保存' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); );
} }
+70 -79
View File
@@ -5,7 +5,7 @@ import {
Server, AlertTriangle, FolderGit, Package Server, AlertTriangle, FolderGit, Package
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface Application { interface Application {
@@ -34,57 +34,6 @@ interface ResourceNode {
children?: ResourceNode[]; children?: ResourceNode[];
} }
const mockApps: Application[] = [
{
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-backend/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
},
{
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-web/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
},
{
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
},
{
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/data/overlays/production',
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
},
];
const mockResourceTree: ResourceNode[] = [
{
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
]
},
]
},
{
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
},
{
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
},
{
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
},
];
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = { const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 }, synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock }, 'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
@@ -109,18 +58,21 @@ const manifestConfig: Record<string, { label: string; icon: typeof Package }> =
export default function GitOpsDeploy() { export default function GitOpsDeploy() {
const [selectedAppId, setSelectedAppId] = useState<string | null>(null); const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list'); const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
const queryClient = useQueryClient();
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') }); const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }), mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
onSuccess: (data) => toast.success(data.message), onSuccess: (data) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: ['gitops'] });
},
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
}); });
const apps: Application[] = dbApps.length > 0 // dbApps 直接携带 syncStatus/healthStatus(sync 动作会真实推进状态)
? dbApps.map((db: any) => { const apps: Application[] = dbApps.map((db: any) => {
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name); const sync = (db.syncStatus || '').toLowerCase();
return { return {
id: String(db.id), id: String(db.id),
name: db.name, name: db.name,
@@ -129,18 +81,50 @@ export default function GitOpsDeploy() {
path: db.path, path: db.path,
cluster: db.cluster, cluster: db.cluster,
namespace: db.namespace, namespace: db.namespace,
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown', status: sync === 'synced' ? 'synced' : sync === 'syncing' ? 'syncing' : sync === 'outofsync' ? 'out-of-sync' : 'unknown',
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync', syncStatus: sync === 'synced' ? 'synced' : 'out-of-sync',
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any, health: (db.healthStatus || 'unknown').toLowerCase(),
lastSync: '刚刚', lastSync: db.lastSyncAt ? new Date(db.lastSyncAt).toLocaleString('zh-CN', { hour12: false }) : '-',
resources: 0, resources: 0,
manifestType: db.manifestType, manifestType: db.manifestType,
autoSync: db.syncPolicy === 'automated', autoSync: db.syncPolicy === 'automated',
}; };
}) });
: mockApps;
const selectedApp = apps.find(a => a.id === selectedAppId); const selectedApp = apps.find(a => a.id === selectedAppId) || apps[0];
const selectedName = selectedApp?.name;
// 资源树 / 差异:真实调用后端生成端点
const { data: treeData, isLoading: treeLoading } = useQuery({
queryKey: ['gitops', 'tree', selectedName],
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/tree`),
enabled: activeView === 'tree' && !!selectedName,
});
const { data: diffData, isLoading: diffLoading } = useQuery({
queryKey: ['gitops', 'diff', selectedName],
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/diff`),
enabled: activeView === 'diff' && !!selectedName,
});
// nodes+edges 扁平结构 → 树
const resourceTree: ResourceNode[] = (() => {
if (!treeData?.nodes) return [];
const byUid = new Map<string, ResourceNode>();
for (const n of treeData.nodes) {
byUid.set(n.uid, { id: n.uid, kind: n.kind, name: n.name, namespace: n.namespace, status: n.status });
}
const childSet = new Set<string>();
for (const e of treeData.edges || []) {
const parent = byUid.get(e.source);
const child = byUid.get(e.target);
if (parent && child) {
parent.children = parent.children || [];
parent.children.push(child);
childSet.add(e.target);
}
}
return treeData.nodes.filter((n: any) => !childSet.has(n.uid)).map((n: any) => byUid.get(n.uid)!);
})();
const renderResourceNode = (node: ResourceNode, depth: number = 0) => { const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
const health = healthConfig[node.status]; const health = healthConfig[node.status];
@@ -184,7 +168,7 @@ export default function GitOpsDeploy() {
</button> </button>
))} ))}
</div> </div>
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> <button onClick={() => { queryClient.invalidateQueries({ queryKey: ['gitops'] }); toast.success('已刷新'); }} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新 <RefreshCw className="w-3.5 h-3.5" />刷新
</button> </button>
</div> </div>
@@ -325,29 +309,36 @@ export default function GitOpsDeploy() {
) : activeView === 'tree' ? ( ) : activeView === 'tree' ? (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}> <div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span> <span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树{selectedName ? ` · ${selectedName}` : ''}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span> <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{resourceTree.length} 个顶级资源</span>
</div> </div>
<div className="py-2"> <div className="py-2">
{mockResourceTree.map(node => renderResourceNode(node))} {treeLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
{!treeLoading && resourceTree.map(node => renderResourceNode(node))}
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3> <h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异{selectedName ? ` · ${selectedName}` : ''}</h3>
{diffLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}> {(diffData?.diffs || []).map((d: any, i: number) => (
<div key={i} className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-3 px-3 py-2" style={{ background: d.status === 'modified' ? '#fff8c5' : '#f6f8fa' }}>
<AlertTriangle className="w-4 h-4" style={{ color: d.status === 'modified' ? '#9a6700' : '#6e7781' }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{d.kind}/{d.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{d.namespace}</span>
<span className="text-[11px] ml-auto" style={{ color: d.status === 'modified' ? '#9a6700' : 'var(--text-tertiary)' }}>{d.status}</span>
</div>
<pre className="px-4 py-2 text-[11px] font-mono whitespace-pre-wrap" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>{d.diff}</pre>
</div>
))}
{!diffLoading && (diffData?.diffs || []).length === 0 && (
<div className="flex items-center gap-3 p-3 rounded-md" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} /> <CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span> <span className="text-[12px]" style={{ color: '#1a7f37' }}>Git 与集群一致,无差异</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
</div> </div>
)}
</div> </div>
</div> </div>
)} )}
-20
View File
@@ -1,20 +0,0 @@
import { useState } from 'react'
import '../App.css'
export default function Home() {
const [count, setCount] = useState(0)
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
</>
)
}
+369 -106
View File
@@ -8,73 +8,185 @@ import {
ArrowRight, ArrowRight,
FileCode, FileCode,
GitBranch, GitBranch,
RefreshCw RefreshCw,
Loader2,
Plus,
Pencil,
Trash2,
Server,
Folder,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
// 与后端 jenkins.JobInfo 对齐:name 即压平全路径(folder/job)
interface JenkinsJob { interface JenkinsJob {
id: string;
name: string; name: string;
path: string; url: string;
type: 'freestyle' | 'pipeline' | 'multibranch'; type: 'folder' | 'freestyle' | 'pipeline' | 'multibranch';
lastBuild: string; status: 'success' | 'failed' | 'running' | 'unknown';
status: 'success' | 'failed' | 'unstable';
selected: boolean;
} }
const mockJobs: JenkinsJob[] = [ // 与后端 model.JenkinsServer 对齐(token 只写不读)
{ id: '1', name: 'backend-build', path: 'aiops/backend-build', type: 'pipeline', lastBuild: '2024-06-20 14:30', status: 'success', selected: false }, interface JenkinsServerItem {
{ id: '2', name: 'frontend-deploy', path: 'aiops/frontend-deploy', type: 'freestyle', lastBuild: '2024-06-20 13:15', status: 'success', selected: false }, id: number;
{ id: '3', name: 'gateway-service', path: 'platform/gateway-service', type: 'pipeline', lastBuild: '2024-06-19 18:45', status: 'failed', selected: false }, name: string;
{ id: '4', name: 'data-sync-job', path: 'data/data-sync-job', type: 'freestyle', lastBuild: '2024-06-18 02:00', status: 'unstable', selected: false }, baseUrl: string;
{ id: '5', name: 'mobile-app-build', path: 'mobile/mobile-app-build', type: 'multibranch', lastBuild: '2024-06-17 16:20', status: 'success', selected: false }, username: string;
]; tokenSet: boolean;
enabled: boolean;
}
const typeConfig = { const emptyServerForm = { name: '', baseUrl: '', username: '', token: '' };
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
folder: { label: '文件夹', color: '#6e7781', bg: '#f6f8fa' },
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' }, freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' }, pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' }, multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
}; };
const statusConfig = { const statusConfig: Record<string, { color: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37' }, success: { color: '#1a7f37', label: '成功' },
failed: { icon: AlertCircle, color: '#cf222e' }, failed: { color: '#cf222e', label: '失败' },
unstable: { icon: AlertCircle, color: '#9a6700' }, running: { color: '#9a6700', label: '运行中' },
unknown: { color: '#6e7781', label: '未知' },
}; };
export default function JenkinsImport() { export default function JenkinsImport() {
const queryClient = useQueryClient();
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect'); const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
const [jenkinsUrl, setJenkinsUrl] = useState('http://jenkins.company.com:8080'); const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
const [jenkinsUser, setJenkinsUser] = useState(''); const [showManage, setShowManage] = useState(false);
const [jenkinsToken, setJenkinsToken] = useState(''); const [serverFormOpen, setServerFormOpen] = useState(false);
const [jobs, setJobs] = useState<JenkinsJob[]>(mockJobs); const [editingServerId, setEditingServerId] = useState<number | null>(null);
const [serverForm, setServerForm] = useState(emptyServerForm);
const [selectedJobs, setSelectedJobs] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isConnecting, setIsConnecting] = useState(false); const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
// Jenkins 实例列表
const { data: servers = [], isLoading: serversLoading } = useQuery({
queryKey: ['jenkins-servers'],
queryFn: apiClient.listJenkinsServers,
});
const selectedServer: JenkinsServerItem | null = servers.find((s: JenkinsServerItem) => s.id === selectedServerId) || null;
// 真实拉取 Job 列表(进入选择/预览步骤后按实例加载)
const jobsQuery = useQuery({
queryKey: ['jenkins-jobs', selectedServerId],
queryFn: () => apiClient.listJenkinsJobs(selectedServerId!),
enabled: !!selectedServerId && (step === 'select' || step === 'preview'),
});
const jobs: JenkinsJob[] = jobsQuery.data || [];
const invalidateServers = () => queryClient.invalidateQueries({ queryKey: ['jenkins-servers'] });
// 新增 / 编辑实例(编辑时 Token 留空不改)
const saveServerMutation = useMutation({
mutationFn: (input: { id: number | null; form: typeof emptyServerForm }) => {
const body: Record<string, any> = { name: input.form.name, baseUrl: input.form.baseUrl, username: input.form.username };
if (input.form.token) body.token = input.form.token;
return input.id != null ? apiClient.updateJenkinsServer(input.id, body) : apiClient.createJenkinsServer(body);
},
onSuccess: (data: any, input) => {
toast.success(input.id != null ? '实例已更新' : '实例已创建');
if (input.id == null && data?.id) setSelectedServerId(data.id);
invalidateServers();
setServerFormOpen(false);
setEditingServerId(null);
setServerForm(emptyServerForm);
},
onError: (err: Error) => toast.error(err.message),
});
const deleteServerMutation = useMutation({
mutationFn: (id: number) => apiClient.deleteJenkinsServer(id),
onSuccess: (_data, id) => {
toast.success('实例已删除');
if (selectedServerId === id) setSelectedServerId(null);
invalidateServers();
},
onError: (err: Error) => toast.error(err.message),
});
// 真实连通测试
const testServerMutation = useMutation({
mutationFn: (id: number) => apiClient.testJenkinsServer(id),
onSuccess: (data: any) => {
if (data?.success) {
toast.success(data.message || '连接成功', { description: `延迟 ${data.latencyMs}ms` });
} else {
toast.error(data?.message || '连接失败');
}
},
onError: (err: Error) => toast.error('连接失败', { description: err.message }),
});
// 真实导入:创建 jenkins 类型流水线,重名跳过
const importMutation = useMutation({
mutationFn: () => apiClient.importJenkinsJobs(selectedServerId!, selectedJobs),
onSuccess: (data: any) => {
const imported = data?.imported ?? selectedJobs.length;
const skipped = data?.skipped ?? 0;
setImportResult({ imported, skipped });
toast.success(`成功导入 ${imported} 个(跳过 ${skipped} 个已存在)`);
setStep('done');
},
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
});
const handleConnect = () => { const handleConnect = () => {
setIsConnecting(true); if (!selectedServerId) {
setTimeout(() => { toast.error('请先选择 Jenkins 实例');
setIsConnecting(false); return;
}
setSelectedJobs([]);
setSearchQuery('');
setImportResult(null);
setStep('select'); setStep('select');
toast.success('连接成功', { description: `已连接到 ${jenkinsUrl}` });
}, 1500);
}; };
const toggleJob = (id: string) => { const startEditServer = (s: JenkinsServerItem) => {
setJobs(prev => prev.map(j => j.id === id ? { ...j, selected: !j.selected } : j)); setEditingServerId(s.id);
setServerForm({ name: s.name, baseUrl: s.baseUrl, username: s.username, token: '' });
setServerFormOpen(true);
}; };
const selectedCount = jobs.filter(j => j.selected).length; const handleSaveServer = () => {
if (!serverForm.name.trim() || !serverForm.baseUrl.trim()) {
const filteredJobs = jobs.filter(j => toast.error('名称和地址不能为空');
j.name.includes(searchQuery) || j.path.includes(searchQuery) return;
); }
saveServerMutation.mutate({ id: editingServerId, form: serverForm });
const handleImport = () => {
toast.success('导入完成', { description: `成功导入 ${selectedCount} 个Job` });
setStep('done');
}; };
const handleDeleteServer = (s: JenkinsServerItem) => {
if (!window.confirm(`确定删除实例「${s.name}」?`)) return;
deleteServerMutation.mutate(s.id);
};
// Job 勾选(folder 不可选)
const isSelectable = (job: JenkinsJob) => job.type !== 'folder';
const filteredJobs = jobs.filter(j => j.name.toLowerCase().includes(searchQuery.trim().toLowerCase()));
const selectableFiltered = filteredJobs.filter(isSelectable);
const allSelected = selectableFiltered.length > 0 && selectableFiltered.every(j => selectedJobs.includes(j.name));
const toggleJob = (name: string) => {
setSelectedJobs(prev => prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]);
};
const toggleAll = () => {
setSelectedJobs(prev => allSelected
? prev.filter(n => !selectableFiltered.some(j => j.name === n))
: Array.from(new Set([...prev, ...selectableFiltered.map(j => j.name)])));
};
const selectedJobEntries = selectedJobs
.map(name => jobs.find(j => j.name === name))
.filter((j): j is JenkinsJob => !!j);
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
@@ -102,54 +214,163 @@ export default function JenkinsImport() {
</div> </div>
<div> <div>
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2> <h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>输入 Jenkins 地址和认证信息以获取 Job 列表</p> <p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>选择已配置的 Jenkins 实例,或先在「管理」中维护实例与认证信息</p>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 地址</label> <label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 实例</label>
<input <div className="flex items-center gap-2">
value={jenkinsUrl} <select
onChange={(e) => setJenkinsUrl(e.target.value)} value={selectedServerId ?? ''}
placeholder="http://jenkins.company.com:8080" onChange={(e) => setSelectedServerId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]" className="flex-1 px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/> >
<option value="" disabled>{serversLoading ? '加载实例中...' : '请选择 Jenkins 实例'}</option>
{servers.map((s: JenkinsServerItem) => (
<option key={s.id} value={s.id}>{s.name}({s.baseUrl})</option>
))}
</select>
<button
onClick={() => selectedServerId && testServerMutation.mutate(selectedServerId)}
disabled={!selectedServerId || testServerMutation.isPending}
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border disabled:opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
{testServerMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
测试连接
</button>
<button
onClick={() => setShowManage(v => !v)}
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border"
style={{
borderColor: showManage ? 'var(--accent-blue)' : 'var(--border-color-light)',
color: showManage ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
<Server className="w-3.5 h-3.5" />
管理
</button>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {selectedServer && (
<p className="text-[11px] mt-1.5" style={{ color: 'var(--text-tertiary)' }}>
用户: {selectedServer.username || '匿名'} · Token: {selectedServer.tokenSet ? '已配置' : '未配置'} · {selectedServer.enabled ? '已启用' : '已禁用'}
</p>
)}
</div>
{/* 实例管理:列表 + 内联新增/编辑表单 */}
{showManage && (
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>实例管理</div>
{serversLoading && (
<div className="flex items-center gap-1.5 text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-3 h-3 animate-spin" />加载中...
</div>
)}
{!serversLoading && servers.length === 0 && (
<div className="text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>暂无实例,请在下方新增</div>
)}
{servers.map((s: JenkinsServerItem) => (
<div key={s.id} className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{s.name}</span>
{selectedServerId === s.id && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: '#0969da' }}>当前</span>
)}
{s.tokenSet ? (
<span className="text-[10px]" style={{ color: '#1a7f37' }}>Token 已配置</span>
) : (
<span className="text-[10px]" style={{ color: '#9a6700' }}>Token 未配置</span>
)}
</div>
<div className="text-[11px] font-mono truncate" style={{ color: 'var(--text-tertiary)' }}>{s.baseUrl} · {s.username || '匿名'}</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => setSelectedServerId(s.id)} disabled={selectedServerId === s.id}
className="px-2 py-[4px] text-[11px] font-medium rounded-md border disabled:opacity-40" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
使用
</button>
<button onClick={() => startEditServer(s)} title="编辑"
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors" style={{ color: 'var(--text-tertiary)' }}>
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteServer(s)} disabled={deleteServerMutation.isPending} title="删除"
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors disabled:opacity-50" style={{ color: '#cf222e' }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{serverFormOpen ? (
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
{editingServerId != null ? '编辑实例' : '新增实例'}
</div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>用户名</label> <label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
<input <input value={serverForm.name} onChange={e => setServerForm(p => ({ ...p, name: e.target.value }))}
value={jenkinsUser} placeholder="如:生产 Jenkins" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
onChange={(e) => setJenkinsUser(e.target.value)} style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
placeholder="admin"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div> </div>
<div> <div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>API Token</label> <label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>地址</label>
<input <input value={serverForm.baseUrl} onChange={e => setServerForm(p => ({ ...p, baseUrl: e.target.value }))}
type="password" placeholder="http://jenkins.company.com:8080" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
value={jenkinsToken} style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
onChange={(e) => setJenkinsToken(e.target.value)} </div>
placeholder="11xxxxxxxxxxxxxxxxxxxxxx" <div>
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]" <label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>用户名</label>
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} <input value={serverForm.username} onChange={e => setServerForm(p => ({ ...p, username: e.target.value }))}
/> placeholder="admin" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div>
<div>
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>API Token(只写不读)</label>
<input type="password" value={serverForm.token} onChange={e => setServerForm(p => ({ ...p, token: e.target.value }))}
placeholder={editingServerId != null ? '留空则不修改已保存的 Token' : '11xxxxxxxxxxxxxxxxxxxxxx'}
className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 pt-2"> <div className="flex justify-end gap-2 pt-1">
<button onClick={() => { setServerFormOpen(false); setEditingServerId(null); setServerForm(emptyServerForm); }}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
<button onClick={handleSaveServer} disabled={saveServerMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveServerMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
保存
</button>
</div>
</div>
) : (
<button onClick={() => { setEditingServerId(null); setServerForm(emptyServerForm); setServerFormOpen(true); }}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Plus className="w-3.5 h-3.5" />新增实例
</button>
)}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button <button
onClick={handleConnect} onClick={handleConnect}
disabled={isConnecting} disabled={!selectedServerId}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60" className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
> >
{isConnecting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Link className="w-3.5 h-3.5" />} <Link className="w-3.5 h-3.5" />
{isConnecting ? '连接中...' : '连接并获取Job'} 连接并获取Job
</button> </button>
{servers.length === 0 && !serversLoading && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>尚未配置实例,点击「管理」新增</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -163,65 +384,105 @@ export default function JenkinsImport() {
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} /> <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div> </div>
{selectedServer && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{selectedServer.name}</span>
)}
</div> </div>
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center gap-3">
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedCount}</span> 个 Job <span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedJobs.length}</span> 个 Job
</span>
<button onClick={() => setStep('connect')} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
上一步
</button>
</div> </div>
</div> </div>
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{jobsQuery.isLoading ? (
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
正在从 Jenkins 拉取 Job 列表...
</div>
) : jobsQuery.isError ? (
<div className="py-10 px-4 flex flex-col items-center gap-3 text-center">
<AlertCircle className="w-6 h-6" style={{ color: '#cf222e' }} />
<div className="text-[13px]" style={{ color: 'var(--text-primary)' }}>拉取 Job 失败: {(jobsQuery.error as Error)?.message}</div>
<button onClick={() => jobsQuery.refetch()} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />重试
</button>
</div>
) : filteredJobs.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
{jobs.length === 0 ? '该实例没有可用的 Job' : '没有匹配搜索的 Job'}
</div>
) : (
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}> <tr style={{ background: 'var(--panel-bg-secondary)' }}>
<th className="w-10 px-3 py-2"></th> <th className="w-10 px-3 py-2">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="w-4 h-4 rounded cursor-pointer"
style={{ accentColor: 'var(--accent-blue)' }}
/>
</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th> <th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th> <th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th> <th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>最后构建</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredJobs.map((job) => { {filteredJobs.map((job) => {
const type = typeConfig[job.type]; const type = typeConfig[job.type] || typeConfig.pipeline;
const status = statusConfig[job.status]; const status = statusConfig[job.status] || statusConfig.unknown;
const StatusIcon = status.icon; const selectable = isSelectable(job);
const checked = selectedJobs.includes(job.name);
return ( return (
<tr key={job.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}> <tr key={job.name} className="border-t hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)', opacity: selectable ? 1 : 0.6 }}>
<td className="px-3 py-3"> <td className="px-3 py-3">
<input <input
type="checkbox" type="checkbox"
checked={job.selected} checked={checked}
onChange={() => toggleJob(job.id)} disabled={!selectable}
className="w-4 h-4 rounded cursor-pointer" onChange={() => toggleJob(job.name)}
className="w-4 h-4 rounded cursor-pointer disabled:cursor-not-allowed"
style={{ accentColor: 'var(--accent-blue)' }} style={{ accentColor: 'var(--accent-blue)' }}
/> />
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div> <div className="flex items-center gap-1.5">
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{job.path}</div> {job.type === 'folder' && <Folder className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />}
<span className="text-[13px] font-medium" style={{ color: selectable ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>{job.name}</span>
</div>
{!selectable && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>文件夹不可导入,其子 Job 已压平列出</div>
)}
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span> <span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<StatusIcon className="w-3.5 h-3.5" style={{ color: status.color }} /> <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}> <span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
{job.status === 'success' ? '成功' : job.status === 'failed' ? '失败' : '不稳定'}
</span>
</div> </div>
</td> </td>
<td className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{job.lastBuild}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
onClick={() => selectedCount > 0 ? setStep('preview') : toast.error('请至少选择一个Job')} onClick={() => selectedJobs.length > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
> >
@@ -237,17 +498,17 @@ export default function JenkinsImport() {
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3> <h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
<div className="space-y-3"> <div className="space-y-3">
{jobs.filter(j => j.selected).map((job) => ( {selectedJobEntries.map((job) => (
<div key={job.id} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}> <div key={job.name} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
<FileCode className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} /> <FileCode className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div> <div className="text-[12px] font-medium truncate" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type].label} → AIOps 流水线</div> <div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type]?.label || job.type} → FlowStack 流水线</div>
</div> </div>
<ArrowRight className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> <ArrowRight className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 min-w-0">
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} /> <GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-green)' }} />
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>aiops-{job.name}</span> <span className="text-[12px] truncate" style={{ color: 'var(--text-secondary)' }}>{job.name.length > 40 ? job.name.slice(0, 40) + '…' : job.name}</span>
</div> </div>
</div> </div>
))} ))}
@@ -255,9 +516,10 @@ export default function JenkinsImport() {
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button> <button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
<button onClick={handleImport} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}> <button onClick={() => importMutation.mutate()} disabled={importMutation.isPending}
<Download className="w-3.5 h-3.5" /> className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
确认导入 {importMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
{importMutation.isPending ? '导入中...' : '确认导入'}
</button> </button>
</div> </div>
</div> </div>
@@ -270,18 +532,19 @@ export default function JenkinsImport() {
</div> </div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2> <h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
成功导入 {selectedCount} 个 Jenkins Job 为 AIOps 流水线 成功导入 {importResult?.imported ?? 0} 个 Jenkins Job 为 FlowStack 流水线
{importResult && importResult.skipped > 0 && `(跳过 ${importResult.skipped} 个已存在)`}
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
<button <button
onClick={() => { setStep('connect'); setJobs(mockJobs.map(j => ({ ...j, selected: false }))); }} onClick={() => { setStep('connect'); setSelectedJobs([]); setSearchQuery(''); setImportResult(null); }}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
> >
继续导入 继续导入
</button> </button>
<button <button
onClick={() => toast.info('跳转到流水线列表')} onClick={() => toast.info('请点击左侧【流水线】查看')}
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
> >
+363 -114
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, type CSSProperties } from 'react';
import { import {
Search, Search,
Plus, Plus,
@@ -13,97 +13,172 @@ import {
Filter, Filter,
LayoutGrid, LayoutGrid,
List, List,
MoreHorizontal
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Pipeline { // ---- 工具函数 ----
id: string; function fmtDuration(ms?: number | null): string {
name: string; if (ms == null || ms < 0) return '-';
description: string; const sec = ms / 1000;
lastStatus: 'success' | 'failed' | 'running' | 'pending'; if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
lastRunAt: string; if (sec < 60) return `${sec.toFixed(1)}s`;
triggerType: string; if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
duration: string; return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
branch: string;
deployType: 'host' | 'k8s';
} }
const mockPipelines: Pipeline[] = [ function fmtTime(iso?: string | null): string {
{ return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
id: '1', }
name: 'aiops-backend',
description: 'Go后端服务 - Docker构建 + K8s部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 14:30:15',
triggerType: 'Git Push',
duration: '3分22秒',
branch: 'master',
deployType: 'k8s',
},
{
id: '2',
name: 'aiops-web',
description: 'Vue前端应用 - npm构建 + 主机部署',
lastStatus: 'running',
lastRunAt: '2024-06-20 15:45:00',
triggerType: '手动',
duration: '1分12秒',
branch: 'master',
deployType: 'host',
},
{
id: '3',
name: 'gateway-api',
description: 'API网关服务 - Java构建 + K8s部署',
lastStatus: 'failed',
lastRunAt: '2024-06-20 13:20:00',
triggerType: '手动',
duration: '45秒',
branch: 'release/v2.1',
deployType: 'k8s',
},
{
id: '4',
name: 'admin-dashboard',
description: '管理后台 - React构建 + 主机部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 12:10:00',
triggerType: 'Cron',
duration: '2分05秒',
branch: 'master',
deployType: 'host',
},
{
id: '5',
name: 'data-processor',
description: '数据处理服务 - Python构建 + K8s部署',
lastStatus: 'pending',
lastRunAt: '-',
triggerType: 'Webhook',
duration: '-',
branch: 'develop',
deployType: 'k8s',
},
];
const statusConfig = { function timeAgo(iso?: string | null): string {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' }, if (!iso) return '-';
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' }, const diff = Date.now() - new Date(iso).getTime();
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' }, if (diff < 0) return '刚刚';
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' }, const s = Math.floor(diff / 1000);
if (s < 60) return `${s}秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}小时前`;
return `${Math.floor(h / 24)}天前`;
}
// ---- 类型(与后端 GET /api/pipelines 对齐)----
interface LastRun {
status: string;
startedAt: string;
durationMs: number;
triggerType: string;
}
interface PipelineDTO {
id: number;
name: string;
description?: string;
type: string; // native | jenkins | tekton
sourceBranch?: string;
deployType: string; // host | k8s | gitops
env?: string;
lastRun: LastRun | null;
}
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
running: { icon: Loader2, color: '#0969da', label: '执行中' },
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
none: { icon: Clock, color: '#6e7781', label: '未执行' },
};
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
k8s: { label: 'K8s', color: '#0969da', bg: '#ddf4ff' },
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
};
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
const triggerTypeLabels: Record<string, string> = {
manual: '手动',
webhook: 'Webhook',
cron: '定时',
batch: '批量',
};
interface CreatePipelineForm {
name: string;
description: string;
sourceRepo: string;
sourceBranch: string;
deployType: string;
env: string;
}
const emptyForm: CreatePipelineForm = {
name: '',
description: '',
sourceRepo: '',
sourceBranch: 'main',
deployType: 'host',
env: 'staging',
};
const inputStyle: CSSProperties = {
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-primary)',
}; };
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) { export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [pipelines] = useState<Pipeline[]>(mockPipelines); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
const filtered = pipelines.filter((p) => { const queryClient = useQueryClient();
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase()); const { data: pipelines = [], isLoading } = useQuery({
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter; queryKey: ['pipelines'],
queryFn: () => apiClient.listPipelines(),
});
const createMutation = useMutation({
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
onSuccess: (_data, body) => {
toast.success('流水线已创建', { description: body.name });
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
setCreateOpen(false);
setForm(emptyForm);
},
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
});
const runMutation = useMutation({
mutationFn: (id: number | string) => apiClient.createRun(id),
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
});
const deleteMutation = useMutation({
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
onSuccess: () => {
toast.success('流水线已删除');
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
},
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
});
const items = (pipelines as PipelineDTO[]).map((p) => {
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
return {
raw: p,
id: String(p.id),
name: p.name,
description: p.description || '',
branch: p.sourceBranch || '-',
type: p.type,
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
mappedStatus: p.lastRun?.status ?? 'pending',
status: statusConfig[displayStatus] || statusConfig.none,
isRunning: displayStatus === 'running',
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
};
});
const filtered = items.filter((p) => {
const q = searchQuery.toLowerCase();
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
return matchSearch && matchStatus; return matchSearch && matchStatus;
}); });
@@ -115,6 +190,20 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{ value: 'pending', label: '未执行' }, { value: 'pending', label: '未执行' },
]; ];
const handleDelete = (p: (typeof items)[number]) => {
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
deleteMutation.mutate(p.raw.id);
}
};
const submitCreate = () => {
if (!form.name.trim()) {
toast.error('请输入流水线名称');
return;
}
createMutation.mutate({ ...form, name: form.name.trim() });
};
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header toolbar */} {/* Header toolbar */}
@@ -130,7 +219,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
className="text-[11px] px-2 py-[2px] rounded-full font-medium" className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }} style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
> >
{filtered.length} {items.length}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -204,7 +293,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<button <button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })} onClick={() => setCreateOpen(true)}
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
新建流水线 新建流水线
@@ -214,18 +303,23 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{filtered.length === 0 ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}> <div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} /> <Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div> </div>
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p> <p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p> <p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
</div> </div>
) : viewMode === 'card' ? ( ) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((pipeline) => { {filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus]; const status = pipeline.status;
const StatusIcon = status.icon; const StatusIcon = status.icon;
return ( return (
<div <div
@@ -241,27 +335,34 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}> <div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="w-2 h-2 rounded-full" className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
style={{ background: status.color }} style={{ background: status.color }}
/> />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}> <span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
{pipeline.name} {pipeline.name}
</span> </span>
</div> </div>
<div className="flex items-center gap-1">
<span <span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide" className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
style={{ style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
> >
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'} {pipeline.deploy.label}
</span> </span>
{pipeline.type === 'jenkins' && (
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: '#fbefff', color: '#8250df' }}
>
Jenkins
</span>
)}
</div>
</div> </div>
{/* Card body */} {/* Card body */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}> <p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{pipeline.description} {pipeline.description || '暂无描述'}
</p> </p>
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -270,7 +371,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{pipeline.duration} {pipeline.durationText}
</div> </div>
</div> </div>
</div> </div>
@@ -279,21 +380,28 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
className="px-4 py-2 border-t flex items-center justify-between" className="px-4 py-2 border-t flex items-center justify-between"
style={{ borderColor: 'var(--border-color-light)' }} style={{ borderColor: 'var(--border-color-light)' }}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0">
<StatusIcon <StatusIcon
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
style={{ color: status.color }} style={{ color: status.color }}
/> />
<span className="text-[11px] font-medium" style={{ color: status.color }}> <span className="text-[11px] font-medium" style={{ color: status.color }}>
{status.label} {status.label}
</span> </span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}> <span
{pipeline.lastRunAt} className="text-[11px] truncate"
style={{ color: 'var(--text-tertiary)' }}
title={pipeline.timeAgoText || undefined}
>
{pipeline.timeText}
</span> </span>
</div> </div>
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button <button
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }} onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5" className="p-1 rounded hover:bg-black/5"
title="执行" title="执行"
> >
@@ -302,14 +410,20 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<button <button
className="p-1 rounded hover:bg-black/5" className="p-1 rounded hover:bg-black/5"
title="配置" title="配置"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
> >
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
<button <button
className="p-1 rounded hover:bg-black/5" className="p-1 rounded hover:bg-black/5"
title="删除" title="删除"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
> >
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> <Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
@@ -337,7 +451,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</thead> </thead>
<tbody> <tbody>
{filtered.map((pipeline) => { {filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus]; const status = pipeline.status;
const StatusIcon = status.icon; const StatusIcon = status.icon;
return ( return (
<tr <tr
@@ -348,10 +462,10 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} /> <div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
<div> <div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div> <div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div> <div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
</div> </div>
</div> </div>
</td> </td>
@@ -362,31 +476,65 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1">
<span <span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase" className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
style={{ style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
> >
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'} {pipeline.deploy.label}
</span> </span>
{pipeline.type === 'jenkins' && (
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: '#fbefff', color: '#8250df' }}
>
Jenkins
</span>
)}
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} /> <StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span> <span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
</div> </div>
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
{pipeline.timeText}
</div>
</td> </td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td> <td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5"> <button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} /> <Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button> </button>
<button className="p-1 rounded hover:bg-black/5"> <button
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> className="p-1 rounded hover:bg-black/5"
title="配置"
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
</div> </div>
</td> </td>
@@ -398,6 +546,107 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div> </div>
)} )}
</div> </div>
{/* 新建流水线弹窗 */}
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
名称 <span style={{ color: '#cf222e' }}>*</span>
</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="例如 aiops-backend"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
<input
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="例如 Go后端服务 - Docker构建 + K8s部署"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
<input
value={form.sourceRepo}
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
placeholder="https://gitee.com/xxx.git"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
<input
value={form.sourceBranch}
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
placeholder="main"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
<SelectValue placeholder="选择部署类型" />
</SelectTrigger>
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<SelectItem value="host">主机</SelectItem>
<SelectItem value="k8s">K8s</SelectItem>
<SelectItem value="gitops">GitOps</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
<SelectValue placeholder="选择环境" />
</SelectTrigger>
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<SelectItem value="dev">开发 (dev)</SelectItem>
<SelectItem value="test">测试 (test)</SelectItem>
<SelectItem value="staging">预发 (staging)</SelectItem>
<SelectItem value="production">生产 (production)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setCreateOpen(false)}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
>
取消
</button>
<button
onClick={submitCreate}
disabled={!form.name.trim() || createMutation.isPending}
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{createMutation.isPending ? '创建中...' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+5 -5
View File
@@ -54,8 +54,8 @@ export default function PluginManager() {
version: p.version, version: p.version,
latestVersion: p.latestVersion || p.version, latestVersion: p.latestVersion || p.version,
author: p.author || 'Unknown', author: p.author || 'Unknown',
installs: 0, installs: p.installs || 0,
rating: 4.5, rating: p.rating || null,
category: p.category || 'general', category: p.category || 'general',
installed: true, installed: true,
hasUpdate: p.latestVersion && p.latestVersion !== p.version, hasUpdate: p.latestVersion && p.latestVersion !== p.version,
@@ -71,7 +71,7 @@ export default function PluginManager() {
latestVersion: p.version, latestVersion: p.version,
author: p.author || 'Unknown', author: p.author || 'Unknown',
installs: p.installs || 0, installs: p.installs || 0,
rating: 4.5, rating: p.rating || null,
category: p.category || 'general', category: p.category || 'general',
installed: false, installed: false,
hasUpdate: false, hasUpdate: false,
@@ -228,8 +228,8 @@ export default function PluginManager() {
</div> </div>
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p> <p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span> {plugin.rating != null && <span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>}
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span> {plugin.installs > 0 && <span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>}
<span>作者: {plugin.author}</span> <span>作者: {plugin.author}</span>
<span className="font-mono">v{plugin.version}</span> <span className="font-mono">v{plugin.version}</span>
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>} {plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
+271 -167
View File
@@ -1,137 +1,148 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Filter, Search, GitBranch, Search, GitBranch,
CheckCircle2, XCircle, Clock, ChevronRight, Terminal, CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
RotateCcw, ExternalLink, Calendar, User, Container, Layers, RotateCcw, ExternalLink, Calendar, User, Container, Layers,
Wifi, WifiOff Wifi, WifiOff, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { usePipelineLogs } from '@/hooks/usePipelineLogs'; import { usePipelineLogs } from '@/hooks/usePipelineLogs';
interface PipelineRunStage { // ---- 工具函数 ----
name: string; function fmtDuration(ms?: number | null): string {
status: string; if (ms == null || ms < 0) return '-';
duration: string; const sec = ms / 1000;
podName?: string; if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
logs?: string[]; if (sec < 60) return `${sec.toFixed(1)}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
} }
interface ReleaseRecord { function fmtTime(iso?: string | null): string {
id: string; return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
}
function timeAgo(iso?: string | null): string {
if (!iso) return '-';
const diff = Date.now() - new Date(iso).getTime();
if (diff < 0) return '刚刚';
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}小时前`;
return `${Math.floor(h / 24)}天前`;
}
// ---- 类型(与后端 GET /api/runs 对齐)----
interface RunRecord {
id: number;
runId: string;
pipelineName: string; pipelineName: string;
branch: string; branch: string;
env: string; env: string;
deployType: 'host' | 'k8s' | 'gitops'; deployType?: string;
type: 'native' | 'jenkins' | 'tekton'; type?: string;
status: 'success' | 'failed' | 'running' | 'cancelled'; status: string;
triggerBy: string; triggerBy?: string;
startTime: string; triggerType?: string;
duration: string; startedAt: string;
stages: PipelineRunStage[]; completedAt?: string | null;
podStatus?: { name: string; status: string; restarts: number; age: string }[]; durationMs?: number;
buildNumber?: number;
externalUrl?: string;
} }
const mockRecords: ReleaseRecord[] = [ interface RunStage {
{ id: number;
id: 'pr-202410261430', name: string;
pipelineName: 'aiops-backend', nodeId: string;
branch: 'master', podName?: string;
env: 'production', status: string;
deployType: 'gitops', startedAt?: string | null;
type: 'tekton', completedAt?: string | null;
status: 'success', exitCode?: number | null;
triggerBy: '张三', }
startTime: '2024-10-26 14:30:00',
duration: '3m 42s', interface RunLog {
stages: [ id?: number;
{ name: 'git-clone', status: 'success', duration: '15s', podName: 'pr-abc-git-clone-xyz', logs: ['Cloning into \'/workspace/source\'...', 'HEAD is now at 7d9f4e2 feat: update api'] }, level?: string;
{ name: 'go-build', status: 'success', duration: '1m 30s', podName: 'pr-abc-go-build-xyz', logs: ['go build -o bin/app ./cmd/app', 'Build successful'] }, message?: string;
{ name: 'kaniko-build', status: 'success', duration: '1m 45s', podName: 'pr-abc-kaniko-xyz', logs: ['INFO[0000] Resolved base name...', 'Pushing image to registry...', 'Image pushed: registry/aiops:v202410261430'] }, source?: string;
{ name: 'update-manifest', status: 'success', duration: '8s', podName: 'pr-abc-update-xyz', logs: ['git clone gitops-repo', 'Updating image tag to v202410261430', 'git push origin main'] }, timestamp?: string;
], }
podStatus: [
{ name: 'aiops-backend-7d9f4-abc1', status: 'Running', restarts: 0, age: '5m' }, interface RunDetail {
{ name: 'aiops-backend-7d9f4-abc2', status: 'Running', restarts: 0, age: '5m' }, run: RunRecord;
{ name: 'aiops-backend-7d9f4-abc3', status: 'Running', restarts: 0, age: '5m' }, stages: RunStage[];
], logs: RunLog[];
}, }
{
id: 'pr-202410261415',
pipelineName: 'aiops-web',
branch: 'master',
env: 'production',
deployType: 'host',
type: 'native',
status: 'success',
triggerBy: '李四',
startTime: '2024-10-26 14:15:00',
duration: '2m 18s',
stages: [
{ name: '拉取代码', status: 'success', duration: '12s' },
{ name: 'npm-build', status: 'success', duration: '1m 30s' },
{ name: 'scp-deploy', status: 'success', duration: '30s' },
],
},
{
id: 'pr-202410261400',
pipelineName: 'gateway-api',
branch: 'release/v2.1',
env: 'staging',
deployType: 'k8s',
type: 'jenkins',
status: 'failed',
triggerBy: '王五',
startTime: '2024-10-26 14:00:00',
duration: '5m 10s',
stages: [
{ name: 'git-clone', status: 'success', duration: '18s' },
{ name: 'maven-build', status: 'success', duration: '2m 45s' },
{ name: 'kubectl-deploy', status: 'failed', duration: '2m 7s', logs: ['kubectl apply -f k8s/', 'Error: deployment gateway-api timeout', 'Pod gateway-api-xxx CrashLoopBackOff'] },
],
podStatus: [
{ name: 'gateway-api-xxx', status: 'CrashLoopBackOff', restarts: 5, age: '10m' },
],
},
{
id: 'pr-202410261330',
pipelineName: 'admin-dashboard',
branch: 'master',
env: 'staging',
deployType: 'host',
type: 'native',
status: 'running',
triggerBy: '赵六',
startTime: '2024-10-26 13:30:00',
duration: '进行中',
stages: [
{ name: '拉取代码', status: 'success', duration: '10s' },
{ name: '构建', status: 'success', duration: '1m 20s' },
{ name: '部署到主机', status: 'running', duration: '30s' },
{ name: '后置操作', status: 'pending', duration: '-' },
],
},
];
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = { const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' }, success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' }, failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '执行中' }, running: { icon: Clock, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
pending: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
queued: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' }, cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
}; };
const fallbackStatus = statusConfig.pending;
const typeConfig: Record<string, { label: string; color: string; bg: string }> = { const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' }, native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' }, jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' }, tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
}; };
const fallbackType = { label: '原生', color: '#0969da', bg: '#ddf4ff' };
type DetailTab = 'stages' | 'logs' | 'pods';
function stageDuration(stage: RunStage): string {
if (!stage.startedAt) return '-';
const start = new Date(stage.startedAt).getTime();
if (stage.completedAt) return fmtDuration(new Date(stage.completedAt).getTime() - start);
if (stage.status === 'running') return fmtDuration(Date.now() - start);
return '-';
}
function ExpandedRecord({ record, activeTab, setActiveTab }: { function ExpandedRecord({ record, activeTab, setActiveTab }: {
record: ReleaseRecord; record: RunRecord;
activeTab: 'stages' | 'logs' | 'pods'; activeTab: DetailTab;
setActiveTab: (t: 'stages' | 'logs' | 'pods') => void; setActiveTab: (t: DetailTab) => void;
}) { }) {
const isRunning = record.status === 'running'; const queryClient = useQueryClient();
const { logs, connected } = usePipelineLogs(record.id, isRunning && activeTab === 'logs');
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['run', record.runId],
queryFn: () => apiClient.getRun(record.runId),
refetchInterval: (query) => {
const st = (query.state.data as RunDetail | undefined)?.run?.status ?? record.status;
return st === 'running' || st === 'pending' || st === 'queued' ? 3000 : false;
},
});
const runStatus = detail?.run?.status ?? record.status;
const isRunning = runStatus === 'running';
const { logs: liveLogs, connected } = usePipelineLogs(record.runId, isRunning && activeTab === 'logs');
const stages: RunStage[] = detail?.stages ?? [];
const podStages = stages.filter((s) => s.podName);
const externalUrl = detail?.run?.externalUrl || record.externalUrl;
const retryMutation = useMutation({
mutationFn: () => apiClient.retryRun(record.runId),
onSuccess: (res) => {
toast.success('已重新触发', { description: `新的运行 ${res?.runId ?? record.runId}` });
queryClient.invalidateQueries({ queryKey: ['runs'] });
queryClient.invalidateQueries({ queryKey: ['run', record.runId] });
},
onError: (e: Error) => toast.error('重试失败', { description: e.message }),
});
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
return ( return (
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}> <div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
@@ -139,7 +150,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
{[ {[
{ id: 'stages' as const, label: '阶段', icon: Layers }, { id: 'stages' as const, label: '阶段', icon: Layers },
{ id: 'logs' as const, label: '日志', icon: Terminal }, { id: 'logs' as const, label: '日志', icon: Terminal },
...(record.podStatus ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []), ...(podStages.length > 0 ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
].map(tab => ( ].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} <button key={tab.id} onClick={() => setActiveTab(tab.id)}
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors" className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
@@ -155,12 +166,19 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
</div> </div>
<div className="p-4"> <div className="p-4">
{activeTab === 'stages' && ( {activeTab === 'stages' && (
detailLoading && stages.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : stages.length === 0 ? (
<p className="text-[12px] text-center py-6" style={{ color: 'var(--text-tertiary)' }}>暂无阶段数据</p>
) : (
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
{record.stages.map((stage, idx) => { {stages.map((stage, idx) => {
const st = statusConfig[stage.status] || statusConfig.success; const st = statusConfig[stage.status] || fallbackStatus;
const isLast = idx === record.stages.length - 1; const isLast = idx === stages.length - 1;
return ( return (
<div key={idx} className="flex items-center flex-1"> <div key={stage.id ?? idx} className="flex items-center flex-1">
<div className="flex flex-col items-center flex-1"> <div className="flex flex-col items-center flex-1">
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}> <div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> : {stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
@@ -169,97 +187,145 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />} <div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
</div> </div>
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div> <div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stage.duration}</div> <div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stageDuration(stage)}</div>
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>} {stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
</div> </div>
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < record.stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />} {!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
</div> </div>
); );
})} })}
</div> </div>
)
)} )}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}> <div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}> <div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} /> <Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span> <span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
{externalUrl && (
<a
href={externalUrl}
target="_blank"
rel="noreferrer"
className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1"
style={{ background: '#8250df26', color: '#a371f7' }}
>
<ExternalLink className="w-2.5 h-2.5" />外部构建
</a>
)}
<span className="flex-1" />
{isRunning && ( {isRunning && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium ml-auto" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}> <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
{connected ? '实时推送中' : '连接中...'} {connected ? '实时推送中' : '连接中...'}
</span> </span>
)} )}
</div> </div>
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}> <div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
{record.stages.map((stage, idx) => ( {logsToRender.length === 0 ? (
<div key={idx}> <div className="py-1" style={{ color: '#7d8590' }}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== {stage.name} ===</div> {isRunning ? '等待日志输出...' : detailLoading ? '日志加载中...' : '暂无日志'}
{stage.logs ? stage.logs.map((log, li) => (
<div key={li} className="py-[1px]" style={{ color: log.includes('Error') ? '#f85149' : '#e6edf3' }}>{log}</div>
)) : <div className="py-[1px]" style={{ color: '#7d8590' }}>No logs available</div>}
</div> </div>
))} ) : logsToRender.map((log, idx) => (
{logs.length > 0 && ( <div key={log.id ?? idx} className="py-[1px] flex items-start gap-1.5">
<div className="mt-2 pt-2 border-t" style={{ borderColor: '#21262d' }}> <span className="flex-shrink-0" style={{ color: '#7d8590' }}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== 实时日志 ===</div> [{log.timestamp ? new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}]
{logs.map((log, idx) => ( </span>
<div key={idx} className="py-[1px]" style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}> {log.source === 'jenkins' && (
<span style={{ color: '#7d8590' }}>[{new Date(log.timestamp || '').toLocaleTimeString()}]</span> {log.message} <span className="text-[9px] px-1 py-[1px] rounded font-medium flex-shrink-0 self-center" style={{ background: '#8250df33', color: '#a371f7' }}>Jenkins</span>
)}
<span style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
{log.message}
</span>
</div> </div>
))} ))}
</div> </div>
)}
</div>
</div> </div>
)} )}
{activeTab === 'pods' && record.podStatus && ( {activeTab === 'pods' && podStages.length > 0 && (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}> <div className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div>Pod 名称</div><div>状态</div><div>重启次数</div><div>运行时间</div> <div>Pod 名称</div><div>状态</div><div>运行时间</div>
</div> </div>
{record.podStatus.map((pod, idx) => ( {podStages.map((stage, idx) => {
<div key={idx} className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}> const st = statusConfig[stage.status] || fallbackStatus;
<div className="font-mono" style={{ color: 'var(--text-primary)' }}>{pod.name}</div> return (
<div key={stage.id ?? idx} className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="font-mono truncate" style={{ color: 'var(--text-primary)' }}>{stage.podName}</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Container className="w-3 h-3" style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }} /> <span className={`w-2 h-2 rounded-full ${stage.status === 'running' ? 'animate-pulse' : ''}`} style={{ background: st.color }} />
<span style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }}>{pod.status}</span> <span style={{ color: st.color }}>{st.label}</span>
</div> </div>
<div style={{ color: pod.restarts > 0 ? '#cf222e' : 'var(--text-secondary)' }}>{pod.restarts}</div> <div title={stage.startedAt ? fmtTime(stage.startedAt) : undefined} style={{ color: 'var(--text-tertiary)' }}>
<div style={{ color: 'var(--text-tertiary)' }}>{pod.age}</div> {timeAgo(stage.startedAt)}
</div> </div>
))} </div>
);
})}
</div> </div>
)} )}
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}> <div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<button onClick={() => toast.info('查看日志', { description: `${record.pipelineName} 完整日志` })} <button onClick={() => setActiveTab('logs')}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3 h-3" />查看日志 <Terminal className="w-3 h-3" />查看日志
</button> </button>
{record.status === 'failed' && ( {(record.status === 'failed' || record.status === 'cancelled') && (
<button onClick={() => toast.success('重试发布', { description: `${record.pipelineName} 重新开始发布` })} <button
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> onClick={() => retryMutation.mutate()}
<RotateCcw className="w-3 h-3" />重试 disabled={retryMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className={`w-3 h-3 ${retryMutation.isPending ? 'animate-spin' : ''}`} />{retryMutation.isPending ? '重试中...' : '重试'}
</button> </button>
)} )}
<button onClick={() => toast.info('查看流水线', { description: `跳转至 ${record.pipelineName}` })} <button onClick={() => toast.info('请点击左侧【流水线】进入对应编辑器')}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<ExternalLink className="w-3 h-3" />查看流水线 <ExternalLink className="w-3 h-3" />查看流水线
</button> </button>
{record.buildNumber != null && record.buildNumber > 0 && (
<span className="text-[11px] ml-auto" style={{ color: 'var(--text-tertiary)' }}>构建 #{record.buildNumber}</span>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
const tabOptions = [
{ id: 'all', label: '全部' },
{ id: 'success', label: '成功' },
{ id: 'failed', label: '失败' },
{ id: 'running', label: '执行中' },
];
export default function ReleaseRecords() { export default function ReleaseRecords() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusTab, setStatusTab] = useState<string>('all');
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'stages' | 'logs' | 'pods'>('stages'); const [activeTab, setActiveTab] = useState<DetailTab>('stages');
const filtered = mockRecords.filter(r => { // 统计始终基于全量列表
const matchSearch = r.pipelineName.includes(search) || r.id.includes(search); const { data: allRuns = [] } = useQuery({
const matchStatus = statusFilter === 'all' || r.status === statusFilter; queryKey: ['runs', 'all'],
return matchSearch && matchStatus; queryFn: () => apiClient.listRuns(),
});
// 列表按 Tab 使用服务端过滤(all 时与统计共用同一查询)
const { data: runs = [], isLoading } = useQuery({
queryKey: ['runs', statusTab],
queryFn: () => apiClient.listRuns({ status: statusTab }),
});
const runList = allRuns as RunRecord[];
const total = runList.length;
const successCount = runList.filter(r => r.status === 'success').length;
const failedCount = runList.filter(r => r.status === 'failed').length;
const runningCount = runList.filter(r => r.status === 'running' || r.status === 'pending' || r.status === 'queued').length;
const finished = total - runningCount;
const successRate = finished > 0 ? `${Math.round((successCount / finished) * 100)}%` : '-';
const filtered = (runs as RunRecord[]).filter(r => {
const q = search.toLowerCase();
const matchSearch = r.pipelineName.toLowerCase().includes(q) || r.runId.toLowerCase().includes(q);
return matchSearch;
}); });
return ( return (
@@ -270,50 +336,87 @@ export default function ReleaseRecords() {
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1> <h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span> <span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}> <div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} /> <Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID" <input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} /> className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
</div> </div>
<div className="relative">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="px-3 py-[5px] text-[12px] border rounded-md outline-none appearance-none pr-7"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<option value="all">全部状态</option><option value="success">成功</option><option value="failed">失败</option><option value="running">执行中</option><option value="cancelled">已取消</option>
</select>
<Filter className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--text-tertiary)' }} />
</div> </div>
{/* Stats + Tabs */}
<div className="px-5 py-2.5 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-5">
{[
{ label: '总次数', value: total, color: 'var(--text-primary)' },
{ label: '成功率', value: successRate, color: '#1a7f37' },
{ label: '成功', value: successCount, color: '#1a7f37' },
{ label: '失败', value: failedCount, color: '#cf222e' },
{ label: '执行中', value: runningCount, color: '#0969da' },
].map(stat => (
<div key={stat.label} className="flex items-baseline gap-1.5">
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</span>
<span className="text-[13px] font-semibold" style={{ color: stat.color }}>{stat.value}</span>
</div>
))}
</div>
<div className="flex items-center gap-1">
{tabOptions.map(tab => (
<button
key={tab.id}
onClick={() => setStatusTab(tab.id)}
className="px-3 py-[5px] text-[12px] font-medium rounded-md transition-colors"
style={{
background: statusTab === tab.id ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)',
color: statusTab === tab.id ? '#fff' : 'var(--text-secondary)',
}}
>
{tab.label}
</button>
))}
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>暂无发布记录</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试切换状态或执行一次流水线</p>
</div>
) : (
<div className="space-y-2"> <div className="space-y-2">
{filtered.map((record) => { {filtered.map((record) => {
const status = statusConfig[record.status]; const status = statusConfig[record.status] || fallbackStatus;
const StatusIcon = status.icon; const StatusIcon = status.icon;
const tp = typeConfig[record.type]; const tp = typeConfig[record.type || 'native'] || fallbackType;
const isExpanded = expandedId === record.id; const isExpanded = expandedId === record.runId;
const durationText = record.status === 'running'
? (record.durationMs && record.durationMs > 0 ? fmtDuration(record.durationMs) : '进行中')
: fmtDuration(record.durationMs);
return ( return (
<div key={record.id} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={record.runId} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Summary row */} {/* Summary row */}
<div onClick={() => setExpandedId(isExpanded ? null : record.id)} <div onClick={() => setExpandedId(isExpanded ? null : record.runId)}
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors"> className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} /> <StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
<div className="w-[130px]"> <div className="w-[130px]">
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div> <div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.id}</div> <div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.runId}</div>
</div> </div>
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
<GitBranch className="w-3 h-3" /> {record.branch} <GitBranch className="w-3 h-3" /> {record.branch}
</div> </div>
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div> <div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div> <div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy}</div> <div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy || '-'}</div>
<div className="flex items-center gap-1 text-[11px] w-[130px]" style={{ color: 'var(--text-tertiary)' }}><Calendar className="w-3 h-3" />{record.startTime}</div> <div className="flex items-center gap-1 text-[11px] w-[150px]" style={{ color: 'var(--text-tertiary)' }} title={timeAgo(record.startedAt)}><Calendar className="w-3 h-3" />{fmtTime(record.startedAt)}</div>
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{record.duration}</div> <div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{durationText}</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} /> <ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
</div> </div>
@@ -329,6 +432,7 @@ export default function ReleaseRecords() {
); );
})} })}
</div> </div>
)}
</div> </div>
</div> </div>
); );
+83 -36
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Palette, Monitor, Moon, Sun, Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface ThemeConfig { interface ThemeConfig {
mode: 'light' | 'dark' | 'system'; mode: 'light' | 'dark' | 'system';
@@ -14,6 +16,43 @@ interface ThemeConfig {
borderRadius: 'sharp' | 'soft' | 'round'; borderRadius: 'sharp' | 'soft' | 'round';
} }
interface NotificationConfig {
email: boolean;
browser: boolean;
pipelineSuccess: boolean;
pipelineFailed: boolean;
securityAlert: boolean;
}
const defaultTheme: ThemeConfig = {
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
};
const defaultNotifications: NotificationConfig = {
email: true,
browser: true,
pipelineSuccess: false,
pipelineFailed: true,
securityAlert: true,
};
// 后端 theme / notification 均以 JSON 字符串存储,安全解析并合并默认值
function safeParse<T extends object>(raw: unknown, fallback: T): T {
if (typeof raw !== 'string' || !raw) return fallback;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return { ...fallback, ...parsed };
return fallback;
} catch {
return fallback;
}
}
const colorOptions = [ const colorOptions = [
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' }, { name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' }, { name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
@@ -24,50 +63,57 @@ const colorOptions = [
]; ];
export default function SystemSettings() { export default function SystemSettings() {
const [theme, setTheme] = useState<ThemeConfig>({ const queryClient = useQueryClient();
mode: 'light', const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
primaryColor: '#0969da', const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
const [notifications, setNotifications] = useState({ const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
email: true,
browser: true,
pipelineSuccess: false,
pipelineFailed: true,
securityAlert: true,
});
const handleSave = () => { // 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
toast.success('设置已保存', { description: '主题和偏好设置已更新' }); useEffect(() => {
}; if (!settings) return;
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
}, [settings]);
const saveMutation = useMutation({
mutationFn: () => apiClient.updateSettings({
theme: JSON.stringify(theme),
notification: JSON.stringify(notifications),
}),
onSuccess: () => {
toast.success('设置已保存', { description: '主题和通知设置已更新' });
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: Error) => toast.error(err.message),
});
const handleReset = () => { const handleReset = () => {
setTheme({ setTheme(defaultTheme);
mode: 'light', setNotifications(defaultNotifications);
primaryColor: '#0969da', toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
toast.info('已恢复默认设置');
}; };
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */} {/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1> <h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
{!settings && (
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-3 h-3 animate-spin" />加载中
</span>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> <button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3.5 h-3.5" />恢复默认 <RotateCcw className="w-3.5 h-3.5" />恢复默认
</button> </button>
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}> <button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
<Save className="w-3.5 h-3.5" />保存设置 className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saveMutation.isPending ? '保存中...' : '保存设置'}
</button> </button>
</div> </div>
</div> </div>
@@ -91,7 +137,7 @@ export default function SystemSettings() {
{ id: 'dark', label: '深色', icon: Moon }, { id: 'dark', label: '深色', icon: Moon },
{ id: 'system', label: '跟随系统', icon: Monitor }, { id: 'system', label: '跟随系统', icon: Monitor },
].map(m => ( ].map(m => (
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as any }))} <button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] }))}
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors" className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{ style={{
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)', borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
@@ -127,7 +173,7 @@ export default function SystemSettings() {
{ id: 'medium', label: '中', size: '14px' }, { id: 'medium', label: '中', size: '14px' },
{ id: 'large', label: '大', size: '16px' }, { id: 'large', label: '大', size: '16px' },
].map(f => ( ].map(f => (
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as any }))} <button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as ThemeConfig['fontSize'] }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center" className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
style={{ style={{
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)', borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
@@ -150,7 +196,7 @@ export default function SystemSettings() {
{ id: 'comfortable', label: '舒适' }, { id: 'comfortable', label: '舒适' },
{ id: 'spacious', label: '宽松' }, { id: 'spacious', label: '宽松' },
].map(d => ( ].map(d => (
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as any }))} <button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as ThemeConfig['density'] }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors" className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{ style={{
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)', borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
@@ -172,7 +218,7 @@ export default function SystemSettings() {
{ id: 'soft', label: '圆角', radius: '6px' }, { id: 'soft', label: '圆角', radius: '6px' },
{ id: 'round', label: '大圆角', radius: '12px' }, { id: 'round', label: '大圆角', radius: '12px' },
].map(r => ( ].map(r => (
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as any }))} <button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as ThemeConfig['borderRadius'] }))}
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors" className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
style={{ style={{
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)', borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
@@ -203,7 +249,7 @@ export default function SystemSettings() {
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' }, { key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
].map(item => ( ].map(item => (
<label key={item.key} className="flex items-start gap-3 cursor-pointer"> <label key={item.key} className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={(notifications as any)[item.key]} <input type="checkbox" checked={notifications[item.key as keyof NotificationConfig]}
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))} onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /> className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div> <div>
@@ -232,6 +278,7 @@ export default function SystemSettings() {
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option> <option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
</select></div> </select></div>
</div> </div>
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
</div> </div>
</div> </div>
</div> </div>
+160 -223
View File
@@ -1,142 +1,46 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Save,
Download, Download,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
Settings,
ChevronDown, ChevronDown,
Monitor, Monitor,
Baby, Baby,
RefreshCw, RefreshCw,
Terminal Terminal,
AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface DevOpsTool { // 与后端 model.DeployTool 对齐
id: string; interface DeployTool {
name: string; id: number;
key: string; key: string;
description: string; name: string;
category: 'build' | 'deploy'; category: 'build' | 'deploy';
status: 'not-installed' | 'installed' | 'installing'; status: 'not-installed' | 'installed' | 'installing' | 'failed';
version: string; version: string;
installedVersion: string;
installMode: 'preinstalled' | 'lazy' | 'disabled'; installMode: 'preinstalled' | 'lazy' | 'disabled';
path: string; path: string;
enabled: boolean;
lastCheck: string | null;
errorMsg: string;
} }
const initialTools: DevOpsTool[] = [
{
id: 'jdk',
name: 'JDK (Java)',
key: 'jdk',
description: 'Java 开发工具包,流水线 Java/Maven 构建阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/jdk',
},
{
id: 'maven',
name: 'Maven',
key: 'maven',
description: 'Java 项目构建工具,流水线 Java 构建阶段使用(依赖 JDK)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/maven',
},
{
id: 'golang',
name: 'Go (Golang)',
key: 'golang',
description: 'Go 语言工具链,流水线 go build/test 阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/go',
},
{
id: 'nvm',
name: 'NVM (Node 版本管理)',
key: 'nvm',
description: 'Node 版本管理器,流水线 Node.js 构建阶段使用(node 依赖此工具)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/nvm',
},
{
id: 'nodejs',
name: 'Node.js + npm',
key: 'nodejs',
description: '前端构建工具,流水线 npm install/build 阶段使用(通过 NVM 安装)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/node',
},
{
id: 'git',
name: 'Git',
key: 'git',
description: '版本控制工具,流水线代码拉取使用(首次使用自动安装)',
category: 'build',
status: 'installed',
version: '2.43.0',
installMode: 'preinstalled',
path: '/usr/bin/git',
},
{
id: 'docker',
name: 'Docker CLI',
key: 'docker-cli',
description: 'Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)',
category: 'deploy',
status: 'installed',
version: '24.0.7',
installMode: 'preinstalled',
path: '/usr/bin/docker',
},
{
id: 'kubectl',
name: 'kubectl',
key: 'kubectl',
description: 'Kubernetes 命令行工具,流水线 K8s 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/kubectl',
},
{
id: 'helm',
name: 'Helm',
key: 'helm',
description: 'Kubernetes 包管理器,流水线 Helm 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/helm',
},
];
const categoryConfig = { const categoryConfig = {
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' }, build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' }, deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
}; };
const statusConfig = { const statusConfig: Record<string, { icon: typeof XCircle; label: string; color: string }> = {
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781', bg: '#f6f8fa' }, 'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781' },
'installed': { icon: CheckCircle2, label: '已安装', color: '#1a7f37', bg: '#dafbe1' }, installed: { icon: CheckCircle2, label: '已安装', color: '#1a7f37' },
'installing': { icon: Loader2, label: '安装中', color: '#9a6700', bg: '#fff8c5' }, installing: { icon: Loader2, label: '安装中', color: '#9a6700' },
failed: { icon: AlertCircle, label: '失败', color: '#cf222e' },
}; };
const installModeConfig = { const installModeConfig = {
@@ -145,54 +49,80 @@ const installModeConfig = {
disabled: '禁用不安装', disabled: '禁用不安装',
}; };
const formatTime = (v: string | null | undefined) => {
if (!v) return '';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const firstLine = (s?: string, max = 120) => {
const line = (s || '').trim().split('\n')[0] || '';
return line.length > max ? line.slice(0, max) + '…' : line;
};
export default function ToolConfig() { export default function ToolConfig() {
const [tools, setTools] = useState<DevOpsTool[]>(initialTools); const queryClient = useQueryClient();
const [mode, setMode] = useState<'local' | 'jenkins'>('local'); const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
const [installingId, setInstallingId] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
const handleInstall = (toolId: string) => { const { data: tools = [], isLoading } = useQuery({ queryKey: ['tools'], queryFn: apiClient.listTools });
setInstallingId(toolId); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
setTools(prev => prev.map(t => t.id === toolId ? { ...t, status: 'installing' } : t)); const toolMode: 'local' | 'jenkins' = settings?.toolMode === 'jenkins' ? 'jenkins' : 'local';
setTimeout(() => { const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ['tools'] });
setTools(prev => prev.map(t =>
t.id === toolId
? { ...t, status: 'installed', version: getDefaultVersion(toolId) }
: t
));
setInstallingId(null);
toast.success('安装完成', { description: `${getToolName(toolId)} 安装成功` });
}, 2000);
};
const handleTest = (toolId: string) => { // 本地 / Jenkins 模式切换(写入 settings.toolMode)
const tool = tools.find(t => t.id === toolId); const modeMutation = useMutation({
if (tool?.status === 'installed') { mutationFn: (mode: 'local' | 'jenkins') => apiClient.updateSettings({ toolMode: mode }),
toast.success('测试通过', { description: `${tool.name} v${tool.version} 运行正常` }); onSuccess: (_data, mode) => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
toast.success(mode === 'local' ? '已切换为本地模式' : '已切换为 Jenkins 模式');
},
onError: (err: Error) => toast.error(err.message),
});
// 安装策略切换
const modeChangeMutation = useMutation({
mutationFn: (input: { tool: DeployTool; installMode: DeployTool['installMode'] }) =>
apiClient.updateTool(input.tool.id, { installMode: input.installMode }),
onSuccess: (_data, input) => {
invalidateTools();
toast.success('策略已更新', { description: `${input.tool.name}: ${installModeConfig[input.installMode]}` });
},
onError: (err: Error) => toast.error(err.message),
});
// 检测(真实执行探测命令)
const checkMutation = useMutation({
mutationFn: (tool: DeployTool) => apiClient.checkTool(tool.id),
onSuccess: (data: any, tool) => {
invalidateTools();
if (data?.detected) {
toast.success(`检测到版本 ${data.detected}`, { description: firstLine(data.output) || tool.name });
} else { } else {
toast.error('测试失败', { description: `${tool?.name || toolId} 未安装` }); toast.error('未检测到', { description: firstLine(data?.output) || '检测命令未返回可用版本信息' });
} }
}; },
onError: (err: Error) => toast.error('检测失败', { description: err.message }),
});
const handleModeChange = (toolId: string, newMode: 'preinstalled' | 'lazy' | 'disabled') => { // 安装(保守策略:后端只真实检测,未装会提示手动安装)
setTools(prev => prev.map(t => t.id === toolId ? { ...t, installMode: newMode } : t)); const installMutation = useMutation({
setDropdownOpen(null); mutationFn: (tool: DeployTool) => apiClient.installTool(tool.id),
toast.info('策略已更新', { description: `${getToolName(toolId)} 安装策略改为: ${installModeConfig[newMode]}` }); onSuccess: (data: any) => {
}; invalidateTools();
if (data?.success) {
toast.success(data.message || '安装完成');
} else {
toast.warning(data.message || '未能完成安装,请参考输出信息手动安装');
}
},
onError: (err: Error) => toast.error('安装失败', { description: err.message }),
});
const getDefaultVersion = (id: string) => { const installedCount = tools.filter((t: DeployTool) => t.status === 'installed').length;
const versions: Record<string, string> = { const notInstalledCount = tools.filter((t: DeployTool) => t.status === 'not-installed').length;
jdk: '17.0.9', maven: '3.9.6', golang: '1.22.0', nvm: '0.39.7',
nodejs: '20.11.0', git: '2.43.0', docker: '24.0.7', kubectl: '1.28.3', helm: '3.14.0',
};
return versions[id] || '1.0.0';
};
const getToolName = (id: string) => tools.find(t => t.id === id)?.name || id;
const installedCount = tools.filter(t => t.status === 'installed').length;
const notInstalledCount = tools.filter(t => t.status === 'not-installed').length;
return ( return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}> <div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
@@ -208,22 +138,24 @@ export default function ToolConfig() {
{/* Mode toggle */} {/* Mode toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}> <div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button <button
onClick={() => setMode('local')} onClick={() => modeMutation.mutate('local')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors" disabled={modeMutation.isPending || toolMode === 'local'}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
style={{ style={{
background: mode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent', background: toolMode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)', color: toolMode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}} }}
> >
<Monitor className="w-3.5 h-3.5" /> <Monitor className="w-3.5 h-3.5" />
本地模式 本地模式
</button> </button>
<button <button
onClick={() => setMode('jenkins')} onClick={() => modeMutation.mutate('jenkins')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors" disabled={modeMutation.isPending || toolMode === 'jenkins'}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
style={{ style={{
background: mode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent', background: toolMode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)', color: toolMode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}} }}
> >
<Baby className="w-3.5 h-3.5" /> <Baby className="w-3.5 h-3.5" />
@@ -231,21 +163,13 @@ export default function ToolConfig() {
</button> </button>
</div> </div>
<button <button
onClick={() => toast.success('刷新完成', { description: '工具状态已更新' })} onClick={() => { invalidateTools(); }}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
> >
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
刷新 刷新
</button> </button>
<button
onClick={() => toast.success('配置已保存')}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
<Save className="w-3.5 h-3.5" />
保存
</button>
</div> </div>
</div> </div>
@@ -262,8 +186,8 @@ export default function ToolConfig() {
{[ {[
{ label: '已安装', count: installedCount, color: '#1a7f37' }, { label: '已安装', count: installedCount, color: '#1a7f37' },
{ label: '未安装', count: notInstalledCount, color: '#6e7781' }, { label: '未安装', count: notInstalledCount, color: '#6e7781' },
{ label: '构建工具', count: tools.filter(t => t.category === 'build').length, color: '#9a6700' }, { label: '构建工具', count: tools.filter((t: DeployTool) => t.category === 'build').length, color: '#9a6700' },
{ label: '部署工具', count: tools.filter(t => t.category === 'deploy').length, color: '#0969da' }, { label: '部署工具', count: tools.filter((t: DeployTool) => t.category === 'deploy').length, color: '#0969da' },
].map((stat) => ( ].map((stat) => (
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div> <div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
@@ -272,11 +196,11 @@ export default function ToolConfig() {
))} ))}
</div> </div>
{/* Tool list - screenshot style */} {/* Tool list */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Table header */} {/* Table header */}
<div <div
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }} style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
> >
<div>工具配置</div> <div>工具配置</div>
@@ -285,26 +209,39 @@ export default function ToolConfig() {
<div className="text-right">操作</div> <div className="text-right">操作</div>
</div> </div>
{tools.map((tool) => { {isLoading ? (
const cat = categoryConfig[tool.category]; <div className="px-4 py-10 flex items-center justify-center gap-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
const status = statusConfig[tool.status]; <Loader2 className="w-4 h-4 animate-spin" />
正在加载工具列表...
</div>
) : tools.length === 0 ? (
<div className="px-4 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
暂无工具数据
</div>
) : tools.map((tool: DeployTool) => {
const cat = categoryConfig[tool.category] || categoryConfig.build;
const status = statusConfig[tool.status] || statusConfig['not-installed'];
const StatusIcon = status.icon; const StatusIcon = status.icon;
const isInstalling = installingId === tool.id; const isInstalling = tool.status === 'installing';
const isChecking = checkMutation.isPending && checkMutation.variables?.id === tool.id;
const isInstallingNow = installMutation.isPending && installMutation.variables?.id === tool.id;
return ( return (
<div <div
key={tool.id} key={tool.id}
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-3 border-t items-center" className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-3 border-t items-center"
style={{ borderColor: 'var(--border-color-light)' }} style={{ borderColor: 'var(--border-color-light)' }}
> >
{/* Tool info */} {/* Tool info */}
<div> <div>
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div> <div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div> <div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
<div className="text-[11px] leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{tool.description}</div> {tool.status === 'installed' && tool.path && (
{tool.status === 'installed' && (
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div> <div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
)} )}
{tool.status === 'failed' && tool.errorMsg && (
<div className="text-[10px] mt-1" style={{ color: '#cf222e' }}>{tool.errorMsg}</div>
)}
</div> </div>
{/* Category */} {/* Category */}
@@ -320,25 +257,33 @@ export default function ToolConfig() {
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} /> <StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span> <span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
</div> </div>
{tool.status === 'installed' && ( {tool.status === 'installed' ? (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.version}</div> <div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.installedVersion || tool.version || '?'}</div>
) : (
tool.version && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
期望 v{tool.version}
</div>
)
)}
{tool.lastCheck && (
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)', opacity: 0.7 }}>检测: {formatTime(tool.lastCheck)}</div>
)} )}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{/* Install / Installing button */} {/* Install / Installing button */}
{tool.status === 'not-installed' ? ( {tool.status === 'installed' ? (
<button <button
onClick={() => handleInstall(tool.id)} disabled
disabled={isInstalling} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
> >
<Download className={`w-3.5 h-3.5 ${isInstalling ? 'animate-bounce' : ''}`} /> <CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
{isInstalling ? '安装中...' : '安装'} 已安装
</button> </button>
) : tool.status === 'installing' ? ( ) : isInstalling || isInstallingNow ? (
<button <button
disabled disabled
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50" className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
@@ -349,72 +294,64 @@ export default function ToolConfig() {
</button> </button>
) : ( ) : (
<button <button
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" onClick={() => installMutation.mutate(tool)}
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }} disabled={installMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
title="后端采用保守策略:仅真实检测,未安装时会提示手动安装"
> >
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} /> <Download className="w-3.5 h-3.5" />
已安装 安装
</button> </button>
)} )}
{/* Version / Install strategy dropdown */} {/* Install strategy dropdown */}
<div className="relative"> <div className="relative">
<button <button
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)} onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border" className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
> >
{tool.status === 'installed' ? tool.version : installModeConfig[tool.installMode]} {installModeConfig[tool.installMode]}
<ChevronDown className="w-3 h-3" /> <ChevronDown className="w-3 h-3" />
</button> </button>
{dropdownOpen === tool.id && ( {dropdownOpen === tool.id && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} /> <div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
<div <div
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[160px]" className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[170px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }} style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
> >
{tool.status !== 'installed' && (
<>
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div> <div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => ( {(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
<button <button
key={modeKey} key={modeKey}
onClick={() => handleModeChange(tool.id, modeKey)} onClick={() => { setDropdownOpen(null); modeChangeMutation.mutate({ tool, installMode: modeKey }); }}
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors flex items-center justify-between gap-2"
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }} style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
> >
{installModeConfig[modeKey]} {installModeConfig[modeKey]}
{tool.installMode === modeKey && <CheckCircle2 className="w-3 h-3" />}
</button> </button>
))} ))}
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} /> <div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
</> <div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本信息</div>
)} <div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>期望版本: {tool.version || '—'}</div>
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本</div> <div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>已安装: {tool.installedVersion || '—'}</div>
<button className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" style={{ color: 'var(--text-secondary)' }}>
{getDefaultVersion(tool.id)} (默认)
</button>
</div> </div>
</> </>
)} )}
</div> </div>
{/* Test button */} {/* Check button */}
<button <button
onClick={() => handleTest(tool.id)} onClick={() => checkMutation.mutate(tool)}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" disabled={checkMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
> >
测试 {isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
</button> 检测
{/* Settings button */}
<button
onClick={() => toast.info('工具配置', { description: `${tool.name} 高级配置功能开发中...` })}
className="p-[5px] rounded-md border transition-colors hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}
>
<Settings className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div> </div>
+172 -97
View File
@@ -1,55 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
CheckCircle2, AlertTriangle, CheckCircle2, AlertTriangle, Send, Loader2, Lock
Send
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
// 与后端 model.WebhookEndpoint 对齐(events 为 JSON 字符串,secret 只写不读)
interface WebhookItem { interface WebhookItem {
id: string; id: number;
name: string; name: string;
url: string; url: string;
events: string[]; secretSet: boolean;
secret: string; events: string;
active: boolean; active: boolean;
lastTriggered: string; lastTriggered: string | null;
lastStatus: 'success' | 'failed' | 'none'; lastStatus: 'success' | 'failed' | 'none';
} }
const initialWebhooks: WebhookItem[] = [
{
id: 'wh-1',
name: '飞书群通知',
url: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx',
events: ['pipeline.success', 'pipeline.failed'],
secret: 'whsec_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:30:15',
lastStatus: 'success',
},
{
id: 'wh-2',
name: '钉钉群通知',
url: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx',
events: ['pipeline.success', 'pipeline.failed', 'pipeline.started'],
secret: 'SEC_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:15:10',
lastStatus: 'success',
},
{
id: 'wh-3',
name: '自定义回调',
url: 'https://api.example.com/webhook/cicd',
events: ['pipeline.failed'],
secret: '',
active: false,
lastTriggered: '从未触发',
lastStatus: 'none',
},
];
const eventOptions = [ const eventOptions = [
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' }, { value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' }, { value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
@@ -61,44 +30,105 @@ const eventOptions = [
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' }, { value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
]; ];
export default function WebhookConfig() { const parseEvents = (raw: string): string[] => {
const [webhooks, setWebhooks] = useState<WebhookItem[]>(initialWebhooks); try {
const [showForm, setShowForm] = useState(false); const v = JSON.parse(raw);
const [editingId, setEditingId] = useState<string | null>(null); return Array.isArray(v) ? v.filter(e => typeof e === 'string') : [];
const [form, setForm] = useState({ name: '', url: '', secret: '', events: [] as string[] }); } catch {
return [];
const handleToggle = (id: string) => { }
setWebhooks(prev => prev.map(w => w.id === id ? { ...w, active: !w.active } : w));
}; };
const handleDelete = (id: string) => { const formatTime = (v: string | null | undefined) => {
setWebhooks(prev => prev.filter(w => w.id !== id)); if (!v) return '从未触发';
toast.success('Webhook 已删除'); const d = new Date(v);
if (Number.isNaN(d.getTime())) return '从未触发';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
export default function WebhookConfig() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingSecretSet, setEditingSecretSet] = useState(false);
const [form, setForm] = useState(emptyForm);
const { data: webhooks = [], isLoading } = useQuery({ queryKey: ['webhooks'], queryFn: apiClient.listWebhooks });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
// 新建 / 编辑(secret 留空表示不修改)
const saveMutation = useMutation({
mutationFn: (input: { editingId: number | null; form: typeof emptyForm }) => {
const body: Record<string, any> = { name: input.form.name, url: input.form.url, events: input.form.events };
if (input.form.secret) body.secret = input.form.secret;
return input.editingId != null
? apiClient.updateWebhook(input.editingId, body)
: apiClient.createWebhook(body);
},
onSuccess: (_data, input) => {
toast.success(input.editingId != null ? 'Webhook 已更新' : 'Webhook 已创建');
invalidate();
closeForm();
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => apiClient.deleteWebhook(id),
onSuccess: () => { toast.success('Webhook 已删除'); invalidate(); },
onError: (err: Error) => toast.error(err.message),
});
const toggleMutation = useMutation({
mutationFn: (wh: WebhookItem) => apiClient.updateWebhook(wh.id, { active: !wh.active }),
onSuccess: () => invalidate(),
onError: (err: Error) => toast.error(err.message),
});
// 测试发送:真实 HTTP POST + HMAC-SHA256 签名
const testMutation = useMutation({
mutationFn: (id: number) => apiClient.testWebhook(id),
onSuccess: (data: any) => {
invalidate();
if (data?.success) {
toast.success('测试发送成功', { description: data.message || `响应 ${data.statusCode}` });
} else {
toast.error('测试发送失败', { description: data?.message || '目标地址返回异常' });
}
},
onError: (err: Error) => toast.error('测试发送失败', { description: err.message }),
});
const closeForm = () => {
setShowForm(false);
setEditingId(null);
setEditingSecretSet(false);
setForm(emptyForm);
}; };
const handleSave = () => { const handleSave = () => {
if (!form.name || !form.url) { if (!form.name.trim() || !form.url.trim()) {
toast.error('请填写名称和 URL'); toast.error('请填写名称和 URL');
return; return;
} }
if (editingId) { saveMutation.mutate({ editingId, form });
setWebhooks(prev => prev.map(w => w.id === editingId ? { ...w, ...form } : w));
toast.success('Webhook 已更新');
} else {
setWebhooks(prev => [...prev, { ...form, id: `wh-${Date.now()}`, active: true, lastTriggered: '从未触发', lastStatus: 'none' }]);
toast.success('Webhook 已创建');
}
setShowForm(false);
setEditingId(null);
setForm({ name: '', url: '', secret: '', events: [] });
}; };
const startEdit = (w: WebhookItem) => { const startEdit = (wh: WebhookItem) => {
setForm({ name: w.name, url: w.url, secret: w.secret, events: [...w.events] }); setForm({ name: wh.name, url: wh.url, secret: '', events: parseEvents(wh.events) });
setEditingId(w.id); setEditingId(wh.id);
setEditingSecretSet(wh.secretSet);
setShowForm(true); setShowForm(true);
}; };
const handleDelete = (wh: WebhookItem) => {
if (!window.confirm(`确定删除 Webhook「${wh.name}」?`)) return;
deleteMutation.mutate(wh.id);
};
const toggleEvent = (event: string) => { const toggleEvent = (event: string) => {
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
@@ -117,7 +147,7 @@ export default function WebhookConfig() {
</span> </span>
</div> </div>
<button <button
onClick={() => { setShowForm(true); setEditingId(null); setForm({ name: '', url: '', secret: '', events: [] }); }} onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }} style={{ background: 'var(--accent-blue)' }}
> >
@@ -129,7 +159,7 @@ export default function WebhookConfig() {
<div className="flex-1 overflow-y-auto custom-scrollbar p-5"> <div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{showForm && ( {showForm && (
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId ? '编辑 Webhook' : '新建 Webhook'}</h3> <h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label> <div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
@@ -141,44 +171,79 @@ export default function WebhookConfig() {
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none" placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div> style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
</div> </div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选)</label> <div>
<label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,只写不读)</label>
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))} <input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
placeholder="用于验证请求来源" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none" placeholder={editingId != null && editingSecretSet ? '已设置密钥,留空保持不变' : '用于 HMAC-SHA256 签名验证,留空则不签名'}
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div> className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
<div><label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件</label> style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
<div className="grid grid-cols-2 gap-2"> {editingId != null && editingSecretSet && (
{eventOptions.map(evt => ( <p className="text-[11px] mt-1 flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
<label key={evt.value} className="flex items-start gap-2 p-2 rounded-md border cursor-pointer hover:bg-black/[0.02]" <Lock className="w-3 h-3" />已保存密钥不可读取;填写新值将覆盖,留空则不修改
style={{ borderColor: 'var(--border-color-light)', background: form.events.includes(evt.value) ? 'var(--accent-blue-light)' : 'transparent' }}> </p>
<input type="checkbox" checked={form.events.includes(evt.value)} onChange={() => toggleEvent(evt.value)} className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /> )}
<div><div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{evt.label}</div><div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{evt.desc}</div></div> </div>
</label> <div>
))} <label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件(点击选择)</label>
</div></div> <div className="flex flex-wrap gap-1.5">
{eventOptions.map(evt => {
const on = form.events.includes(evt.value);
return (
<button key={evt.value} type="button" title={evt.desc} onClick={() => toggleEvent(evt.value)}
className="text-[11px] px-2.5 py-[4px] rounded-full border font-medium transition-colors"
style={{
borderColor: on ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: on ? 'var(--accent-blue-light)' : 'transparent',
color: on ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{evt.label}
</button>
);
})}
</div>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowForm(false)} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button> <button onClick={closeForm} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
<button onClick={handleSave} className="px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>保存</button> <button onClick={handleSave} disabled={saveMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
保存
</button>
</div> </div>
</div> </div>
</div> </div>
)} )}
{isLoading ? (
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />正在加载 Webhook 列表...
</div>
) : webhooks.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
暂无 Webhook,点击右上角「新建 Webhook」创建
</div>
) : (
<div className="space-y-2"> <div className="space-y-2">
{webhooks.map((wh) => ( {webhooks.map((wh: WebhookItem) => (
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}> <div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span> <span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}> <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
{wh.active ? '已启用' : '已禁用'} {wh.active ? '已启用' : '已禁用'}
</span> </span>
{wh.secretSet && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<Lock className="w-2.5 h-2.5" />已签名
</span>
)}
</div> </div>
<div className="flex items-center gap-1 text-[12px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-1 text-[12px] font-mono mb-2 truncate" style={{ color: 'var(--text-tertiary)' }}>
<Webhook className="w-3 h-3" /> {wh.url} <Webhook className="w-3 h-3 flex-shrink-0" /> {wh.url}
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{wh.events.map(evt => { {parseEvents(wh.events).map(evt => {
const opt = eventOptions.find(o => o.value === evt); const opt = eventOptions.find(o => o.value === evt);
return ( return (
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}> <span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
@@ -186,31 +251,40 @@ export default function WebhookConfig() {
</span> </span>
); );
})} })}
{parseEvents(wh.events).length === 0 && (
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>未订阅任何事件</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => toast.success('测试发送', { description: `${wh.name} 测试消息已发送` })} <button
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="测试"> onClick={() => testMutation.mutate(wh.id)}
<Send className="w-3.5 h-3.5" /> disabled={testMutation.isPending && testMutation.variables === wh.id}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="测试发送">
{testMutation.isPending && testMutation.variables === wh.id
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Send className="w-3.5 h-3.5" />}
</button> </button>
<button onClick={() => startEdit(wh)} <button onClick={() => startEdit(wh)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑"> className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => handleToggle(wh.id)} <button onClick={() => toggleMutation.mutate(wh)} disabled={toggleMutation.isPending}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}> className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />} {wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
</button> </button>
<button onClick={() => handleDelete(wh.id)} <button onClick={() => handleDelete(wh)} disabled={deleteMutation.isPending}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="删除"> className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="删除">
<Trash2 className="w-3.5 h-3.5" /> {deleteMutation.isPending && deleteMutation.variables === wh.id
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
<span>最后触发: {wh.lastTriggered}</span> <span>最后触发: {formatTime(wh.lastTriggered)}</span>
{wh.lastStatus !== 'none' && ( {wh.lastStatus !== 'none' && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1" style={{ color: wh.lastStatus === 'success' ? '#1a7f37' : '#cf222e' }}>
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />} 状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
{wh.lastStatus === 'success' ? '成功' : '失败'} {wh.lastStatus === 'success' ? '成功' : '失败'}
</span> </span>
@@ -219,6 +293,7 @@ export default function WebhookConfig() {
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
</div> </div>
); );