From 9f65faff6fbc5b398b9cf546ed6172c873e31b0c Mon Sep 17 00:00:00 2001 From: attacker Date: Sun, 14 Jun 2026 20:12:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20K8s=E9=9B=86=E7=BE=A4=E6=8B=93=E6=89=91?= =?UTF-8?q?=E5=9B=BE=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=20=E2=80=94=20=E5=88=86=E5=B1=82=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96Cluster=E2=86=92Ingress=E2=86=92Service?= =?UTF-8?q?=E2=86=92Deployment=E2=86=92Node=20+=20http.ts=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers/ldap.go | 280 +++++- .../internal/api/handlers/resource_model.go | 134 +++ backend/internal/api/handlers/server.go | 234 +++++ backend/internal/api/handlers/user.go | 105 ++ backend/internal/api/middleware/auth.go | 6 + backend/internal/api/routes/routes.go | 36 + backend/internal/models/resource_model.go | 25 + backend/pkg/database/database.go | 1 + .../src/components/cloud/PlatformManager.tsx | 457 +++++---- .../src/components/cloud/RegionManager.tsx | 483 ++++++---- frontend/src/components/k8s/TopologyView.tsx | 676 ++++++------- frontend/src/components/layout/Header.tsx | 46 - frontend/src/components/layout/Sidebar.tsx | 29 +- .../server/ResourceModelManager.tsx | 729 +++++++------- .../src/components/system/SystemManager.tsx | 894 +++++++++++++++++- frontend/src/services/api.ts | 88 ++ frontend/src/services/http.ts | 3 +- 17 files changed, 3021 insertions(+), 1205 deletions(-) create mode 100644 backend/internal/api/handlers/resource_model.go create mode 100644 backend/internal/models/resource_model.go diff --git a/backend/internal/api/handlers/ldap.go b/backend/internal/api/handlers/ldap.go index 2c656d6..42a7d84 100644 --- a/backend/internal/api/handlers/ldap.go +++ b/backend/internal/api/handlers/ldap.go @@ -149,7 +149,7 @@ func (h *LDAPHandler) GetLDAPConfig(c *gin.Context) { cfg := configs[0] // mask password cfg.AdminPassword = "******" - c.JSON(http.StatusOK, cfg) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: cfg}) } // UpdateLDAPConfig 更新LDAP配置 @@ -312,11 +312,11 @@ func (h *LDAPHandler) TestLDAPConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("密码验证失败: %s", err)}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "message": fmt.Sprintf("认证成功!用户DN: %s", userDN)}) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"success": true, "message": fmt.Sprintf("认证成功!用户DN: %s", userDN)}}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP连接成功,管理员认证通过"}) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"success": true, "message": "LDAP连接成功,管理员认证通过"}}) } // ListLDAPUsers 列出LDAP用户 @@ -386,7 +386,7 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { Mail: e.GetAttributeValue("mail"), }) } - c.JSON(http.StatusOK, users) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: users}) } // BatchImportLDAP 批量导入LDAP用户 @@ -394,9 +394,12 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { var req struct { Users []struct { - UID string `json:"uid"` - CN string `json:"cn"` - Mail string `json:"mail"` + UID string `json:"uid"` + CN string `json:"cn"` + Mail string `json:"mail"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` } `json:"users"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -407,23 +410,34 @@ func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { imported := 0 skipped := 0 for _, lu := range req.Users { - if lu.UID == "" { + uid := lu.UID + if uid == "" { + uid = lu.Username + } + cn := lu.CN + if cn == "" { + cn = lu.Nickname + } + mail := lu.Mail + if mail == "" { + mail = lu.Email + } + if uid == "" { skipped++ continue } // Check if already exists var existing models.User - if err := h.db.Where("username = ?", lu.UID).First(&existing).Error; err == nil { + if err := h.db.Where("username = ?", uid).First(&existing).Error; err == nil { // Already exists, update info updates := map[string]interface{}{ "auth_source": "ldap", - "is_active": true, } - if lu.CN != "" { - updates["real_name"] = lu.CN + if cn != "" { + updates["real_name"] = cn } - if lu.Mail != "" { - updates["email"] = lu.Mail + if mail != "" { + updates["email"] = mail } h.db.Model(&existing).Updates(updates) imported++ @@ -432,10 +446,10 @@ func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { // Create new LDAP user user := models.User{ - Username: lu.UID, + Username: uid, Password: "-", // LDAP users don't have local password - RealName: lu.CN, - Email: lu.Mail, + RealName: cn, + Email: mail, Status: 1, AuthSource: "ldap", } @@ -455,3 +469,235 @@ func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { Data: gin.H{"imported": imported, "skipped": skipped}, }) } + +// ListLDAPOUs 列出 LDAP 组织单元 +// POST /api/v1/admin/ldap-config/ou-list +func (h *LDAPHandler) ListLDAPOUs(c *gin.Context) { + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + defer conn.Close() + + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + + searchReq := ldap.NewSearchRequest( + cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=organizationalUnit)", + []string{"ou", "description"}, + nil, + ) + sr, err := conn.Search(searchReq) + if err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + + type OU struct { + DN string `json:"dn"` + Name string `json:"name"` + Description string `json:"description"` + } + var ous []OU + for _, e := range sr.Entries { + ous = append(ous, OU{ + DN: e.DN, + Name: e.GetAttributeValue("ou"), + Description: e.GetAttributeValue("description"), + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: ous}) +} + +// CreateLDAPOU 创建组织单元 +// POST /api/v1/admin/ldap-config/create-ou +func (h *LDAPHandler) CreateLDAPOU(c *gin.Context) { + var req struct { + OUName string `json:"ou_name"` + ParentDN string `json:"parent_dn"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + if req.ParentDN == "" { + req.ParentDN = cfg.BaseDN + } + ouDN := fmt.Sprintf("ou=%s,%s", ldap.EscapeFilter(req.OUName), req.ParentDN) + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + addReq := ldap.NewAddRequest(ouDN, nil) + addReq.Attribute("objectClass", []string{"organizationalUnit", "top"}) + addReq.Attribute("ou", []string{req.OUName}) + if req.Description != "" { + addReq.Attribute("description", []string{req.Description}) + } + if err := conn.Add(addReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建OU失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "OU创建成功", Data: gin.H{"dn": ouDN}}) +} + +// CreateLDAPUser 在LDAP中创建用户 +// POST /api/v1/admin/ldap-config/create-user +func (h *LDAPHandler) CreateLDAPUser(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + OUDN string `json:"ou_dn"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.Username == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "用户名和密码不能为空"}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + if req.OUDN == "" { + req.OUDN = cfg.BaseDN + } + userDN := fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(req.Username), req.OUDN) + + // Parse user mapping + mapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"} + if cfg.UserMapping != nil { + _ = json.Unmarshal([]byte(*cfg.UserMapping), &mapping) + } + + uidAttr := mapping["username"] + if uidAttr == "" { + uidAttr = "uid" + } + mailAttr := mapping["email"] + if mailAttr == "" { + mailAttr = "mail" + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + addReq := ldap.NewAddRequest(userDN, nil) + addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"}) + addReq.Attribute(uidAttr, []string{req.Username}) + addReq.Attribute("cn", []string{req.DisplayName}) + addReq.Attribute("sn", []string{req.DisplayName}) + if req.Email != "" { + addReq.Attribute(mailAttr, []string{req.Email}) + } + addReq.Attribute("userPassword", []string{req.Password}) + if err := conn.Add(addReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建用户失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "用户创建成功", Data: gin.H{"dn": userDN}}) +} + +// DeleteLDAPUser 删除LDAP条目(用户或OU) +// POST /api/v1/admin/ldap-config/delete-user +func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) { + var req struct { + DN string `json:"dn"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "DN不能为空"}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + delReq := ldap.NewDelRequest(req.DN, nil) + if err := conn.Del(delReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} diff --git a/backend/internal/api/handlers/resource_model.go b/backend/internal/api/handlers/resource_model.go new file mode 100644 index 0000000..f05f5c1 --- /dev/null +++ b/backend/internal/api/handlers/resource_model.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ResourceModelHandler 资源模型处理器 +type ResourceModelHandler struct { + db *gorm.DB +} + +func NewResourceModelHandler(db *gorm.DB) *ResourceModelHandler { + return &ResourceModelHandler{db: db} +} + +// resourceModelRequest 资源模型创建/更新请求 +type resourceModelRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + Type string `json:"type"` + Icon string `json:"icon"` + Attributes []map[string]interface{} `json:"attributes"` +} + +// ListResourceModels 获取资源模型列表 +func (h *ResourceModelHandler) ListResourceModels(c *gin.Context) { + var models_ []models.ResourceModel + h.db.Order("id ASC").Find(&models_) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: models_, + Total: int64(len(models_)), + }, + }) +} + +// CreateResourceModel 创建资源模型 +func (h *ResourceModelHandler) CreateResourceModel(c *gin.Context) { + var req resourceModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "模型名称和编码不能为空"}) + return + } + + modelType := strings.TrimSpace(req.Type) + if modelType == "" { + modelType = "normal" + } + + var attrsJSON string + if len(req.Attributes) > 0 { + b, _ := json.Marshal(req.Attributes) + attrsJSON = string(b) + } + + rm := models.ResourceModel{ + Name: strings.TrimSpace(req.Name), + Code: strings.TrimSpace(req.Code), + Description: req.Description, + Type: modelType, + Attributes: attrsJSON, + Icon: req.Icon, + } + if err := h.db.Create(&rm).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: rm}) +} + +// UpdateResourceModel 更新资源模型 +func (h *ResourceModelHandler) UpdateResourceModel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req resourceModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var rm models.ResourceModel + if err := h.db.First(&rm, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "资源模型不存在"}) + return + } + + if strings.TrimSpace(req.Name) != "" { + rm.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Code) != "" { + rm.Code = strings.TrimSpace(req.Code) + } + if strings.TrimSpace(req.Type) != "" { + rm.Type = strings.TrimSpace(req.Type) + } + rm.Description = req.Description + rm.Icon = req.Icon + + if len(req.Attributes) > 0 { + b, _ := json.Marshal(req.Attributes) + rm.Attributes = string(b) + } + + if err := h.db.Save(&rm).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: rm}) +} + +// DeleteResourceModel 删除资源模型 +func (h *ResourceModelHandler) DeleteResourceModel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.ResourceModel{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} diff --git a/backend/internal/api/handlers/server.go b/backend/internal/api/handlers/server.go index c88084b..a100bda 100644 --- a/backend/internal/api/handlers/server.go +++ b/backend/internal/api/handlers/server.go @@ -775,3 +775,237 @@ func (h *DomainHandler) UpdateDomain(c *gin.Context) { Data: domain, }) } + +// ==================== 云平台(Platform / Cloud)==================== + +// PlatformHandler 云平台处理器 +type PlatformHandler struct { + db *gorm.DB +} + +func NewPlatformHandler(db *gorm.DB) *PlatformHandler { + return &PlatformHandler{db: db} +} + +// platformRequest 云平台创建/更新请求 +type platformRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Type string `json:"type"` + Status string `json:"status"` + Website string `json:"website"` + Description string `json:"description"` +} + +// ListPlatforms 获取云平台列表 +func (h *PlatformHandler) ListPlatforms(c *gin.Context) { + var platforms []models.Cloud + h.db.Find(&platforms) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: platforms, + Total: int64(len(platforms)), + }, + }) +} + +// CreatePlatform 创建云平台 +func (h *PlatformHandler) CreatePlatform(c *gin.Context) { + var req platformRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "平台名称和编码不能为空"}) + return + } + + cloudType := strings.TrimSpace(req.Type) + if cloudType == "" { + cloudType = "public" + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + platform := models.Cloud{ + Name: strings.TrimSpace(req.Name), + Code: strings.TrimSpace(req.Code), + Type: cloudType, + Status: status, + Website: req.Website, + Description: req.Description, + } + if err := h.db.Create(&platform).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: platform}) +} + +// UpdatePlatform 更新云平台 +func (h *PlatformHandler) UpdatePlatform(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req platformRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var platform models.Cloud + if err := h.db.First(&platform, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云平台不存在"}) + return + } + + if strings.TrimSpace(req.Name) != "" { + platform.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Code) != "" { + platform.Code = strings.TrimSpace(req.Code) + } + if strings.TrimSpace(req.Type) != "" { + platform.Type = strings.TrimSpace(req.Type) + } + if strings.TrimSpace(req.Status) != "" { + platform.Status = strings.TrimSpace(req.Status) + } + platform.Website = req.Website + platform.Description = req.Description + + if err := h.db.Save(&platform).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: platform}) +} + +// DeletePlatform 删除云平台 +func (h *PlatformHandler) DeletePlatform(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.Cloud{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// ==================== 区域管理(Region)==================== + +// RegionHandler 区域处理器 +type RegionHandler struct { + db *gorm.DB +} + +func NewRegionHandler(db *gorm.DB) *RegionHandler { + return &RegionHandler{db: db} +} + +// regionRequest 区域创建/更新请求 +type regionRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Provider string `json:"provider"` + Location string `json:"location"` + Status string `json:"status"` + Endpoint string `json:"endpoint"` +} + +// ListRegions 获取区域列表 +func (h *RegionHandler) ListRegions(c *gin.Context) { + var regions []models.Region + h.db.Find(®ions) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: regions, + Total: int64(len(regions)), + }, + }) +} + +// CreateRegion 创建区域 +func (h *RegionHandler) CreateRegion(c *gin.Context) { + var req regionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "区域名称和编码不能为空"}) + return + } + + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + region := models.Region{ + Name: strings.TrimSpace(req.Name), + Code: strings.TrimSpace(req.Code), + Provider: strings.TrimSpace(req.Provider), + Location: req.Location, + Status: status, + Endpoint: req.Endpoint, + } + if err := h.db.Create(®ion).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: region}) +} + +// UpdateRegion 更新区域 +func (h *RegionHandler) UpdateRegion(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req regionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var region models.Region + if err := h.db.First(®ion, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "区域不存在"}) + return + } + + if strings.TrimSpace(req.Name) != "" { + region.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Code) != "" { + region.Code = strings.TrimSpace(req.Code) + } + if strings.TrimSpace(req.Provider) != "" { + region.Provider = strings.TrimSpace(req.Provider) + } + region.Location = req.Location + if strings.TrimSpace(req.Status) != "" { + region.Status = strings.TrimSpace(req.Status) + } + region.Endpoint = req.Endpoint + + if err := h.db.Save(®ion).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: region}) +} + +// DeleteRegion 删除区域 +func (h *RegionHandler) DeleteRegion(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.Region{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} diff --git a/backend/internal/api/handlers/user.go b/backend/internal/api/handlers/user.go index c1868ed..585caa3 100644 --- a/backend/internal/api/handlers/user.go +++ b/backend/internal/api/handlers/user.go @@ -1,8 +1,10 @@ package handlers import ( + "fmt" "net/http" "strconv" + "strings" "ezops/internal/api/middleware" "ezops/internal/config" @@ -597,3 +599,106 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { Message: "删除成功", }) } + +// BatchImportUsers 批量导入用户 +// POST /api/v1/admin/users/batch-import +func (h *UserHandler) BatchImportUsers(c *gin.Context) { + var req struct { + Users []struct { + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Phone string `json:"phone"` + Password string `json:"password"` + Role string `json:"role"` + } `json:"users"` + SkipExisting bool `json:"skip_existing"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + imported := 0 + skipped := 0 + var errors []string + + for i, u := range req.Users { + if u.Username == "" { + errors = append(errors, fmt.Sprintf("第%d行: 用户名为空", i+1)) + continue + } + // Check if exists + var existing models.User + if err := h.db.Where("username = ?", u.Username).First(&existing).Error; err == nil { + if req.SkipExisting { + skipped++ + continue + } + errors = append(errors, fmt.Sprintf("第%d行: 用户 %s 已存在", i+1, u.Username)) + skipped++ + continue + } + + // Default password + pwd := u.Password + if pwd == "" { + pwd = "admin123" + } + hashed, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + if err != nil { + errors = append(errors, fmt.Sprintf("第%d行: 密码加密失败", i+1)) + continue + } + + user := models.User{ + Username: u.Username, + Password: string(hashed), + Email: u.Email, + Phone: u.Phone, + RealName: u.Nickname, + Status: 1, + AuthSource: "local", + } + if err := h.db.Create(&user).Error; err != nil { + errors = append(errors, fmt.Sprintf("第%d行: 创建用户 %s 失败", i+1, u.Username)) + continue + } + imported++ + } + + msg := fmt.Sprintf("导入完成: %d 成功, %d 跳过", imported, skipped) + if len(errors) > 0 { + msg += fmt.Sprintf(", %d 个错误", len(errors)) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: gin.H{ + "imported": imported, + "skipped": skipped, + "errors": errors, + }, + }) +} + +// ImportTemplate 下载用户导入模板 +// GET /api/v1/admin/users/import-template +func (h *UserHandler) ImportTemplate(c *gin.Context) { + // UTF-8 BOM for Excel compatibility + bom := "\xEF\xBB\xBF" + csv := bom + "用户名,昵称,邮箱,手机号,角色,密码\n" + csv += "zhangsan,张三,zhangsan@example.com,13800138000,developer,123456\n" + csv += "lisi,李四,lisi@example.com,13800138001,developer,123456\n" + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=users_import_template.csv") + c.String(http.StatusOK, csv) +} + +// ensure strings import is used +var _ = strings.TrimSpace diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index ab7a237..a847ddb 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -41,6 +41,12 @@ func GenerateToken(user *models.User, cfg *config.JWTConfig) (string, error) { func JWTAuth(cfg *config.JWTConfig) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") + // Also allow token via query param for file downloads + if authHeader == "" { + if token := c.Query("token"); token != "" { + authHeader = "Bearer " + token + } + } if authHeader == "" { c.JSON(http.StatusUnauthorized, models.Response{ Code: 401, diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 2d3da96..9fef41b 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -50,6 +50,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { admin.POST("/users", authHandler.CreateUser) admin.PUT("/users/:id", authHandler.UpdateUser) admin.DELETE("/users/:id", authHandler.DeleteUser) + admin.POST("/users/batch-import", authHandler.BatchImportUsers) + admin.GET("/users/import-template", authHandler.ImportTemplate) } // LDAP 配置路由 @@ -62,6 +64,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { ldapGroup.POST("/test", ldapHandler.TestLDAPConnection) ldapGroup.POST("/users", ldapHandler.ListLDAPUsers) ldapGroup.POST("/import", ldapHandler.BatchImportLDAP) + ldapGroup.POST("/ou-list", ldapHandler.ListLDAPOUs) + ldapGroup.POST("/create-ou", ldapHandler.CreateLDAPOU) + ldapGroup.POST("/create-user", ldapHandler.CreateLDAPUser) + ldapGroup.POST("/delete-user", ldapHandler.DeleteLDAPUser) } // 仪表盘路由 @@ -107,6 +113,36 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { domains.DELETE("/:id", domainHandler.DeleteDomain) } + // 云平台路由 + platformHandler := handlers.NewPlatformHandler(db) + platforms := authorized.Group("/platforms") + { + platforms.GET("", platformHandler.ListPlatforms) + platforms.POST("", platformHandler.CreatePlatform) + platforms.PUT("/:id", platformHandler.UpdatePlatform) + platforms.DELETE("/:id", platformHandler.DeletePlatform) + } + + // 区域路由 + regionHandler := handlers.NewRegionHandler(db) + regions := authorized.Group("/regions") + { + regions.GET("", regionHandler.ListRegions) + regions.POST("", regionHandler.CreateRegion) + regions.PUT("/:id", regionHandler.UpdateRegion) + regions.DELETE("/:id", regionHandler.DeleteRegion) + } + + // 资源模型路由 + resourceModelHandler := handlers.NewResourceModelHandler(db) + resourceModels := authorized.Group("/resource-models") + { + resourceModels.GET("", resourceModelHandler.ListResourceModels) + resourceModels.POST("", resourceModelHandler.CreateResourceModel) + resourceModels.PUT("/:id", resourceModelHandler.UpdateResourceModel) + resourceModels.DELETE("/:id", resourceModelHandler.DeleteResourceModel) + } + // K8s集群路由 k8sHandler := handlers.NewK8sHandler(db) clusters := authorized.Group("/k8s-clusters") diff --git a/backend/internal/models/resource_model.go b/backend/internal/models/resource_model.go new file mode 100644 index 0000000..41059bd --- /dev/null +++ b/backend/internal/models/resource_model.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ResourceModel 资源模型 (CMDB 模型定义) +type ResourceModel struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:500" json:"description"` + Type string `gorm:"size:32;default:normal" json:"type"` // normal, parent + Attributes string `gorm:"type:text" json:"attributes"` // JSON 数组 + Icon string `gorm:"size:64" json:"icon"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (ResourceModel) TableName() string { + return "resource_models" +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 0bcacaf..789d015 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -88,5 +88,6 @@ func AutoMigrate(db *gorm.DB) error { &models.NavigationLink{}, &models.AuditLog{}, &models.LDAPConfig{}, + &models.ResourceModel{}, ) } diff --git a/frontend/src/components/cloud/PlatformManager.tsx b/frontend/src/components/cloud/PlatformManager.tsx index 2d3a257..d85b826 100644 --- a/frontend/src/components/cloud/PlatformManager.tsx +++ b/frontend/src/components/cloud/PlatformManager.tsx @@ -1,33 +1,34 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { - Plus, - Search, - Edit2, - Trash2, +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plus, + Search, + Trash2, + Pencil, CloudCog, - Server, CheckCircle2, XCircle, - ExternalLink + ExternalLink, + Loader2, } from 'lucide-react'; -import { platforms } from '@/mocks/data'; - -interface Platform { - id: string; - name: string; - code: string; - type: 'public' | 'private' | 'hybrid'; - status: 'active' | 'inactive'; - accountCount: number; - regionCount: number; - serverCount: number; - website: string; - description: string; -} +import { platformApi } from '@/services/api'; const typeLabels: Record = { public: '公有云', @@ -36,184 +37,282 @@ const typeLabels: Record = { }; const typeColors: Record = { - public: 'bg-blue-500/20 text-blue-400', - private: 'bg-purple-500/20 text-purple-400', - hybrid: 'bg-cyan-500/20 text-cyan-400', + public: 'text-blue-400', + private: 'text-purple-400', + hybrid: 'text-cyan-400', }; export function PlatformManager() { + const [platforms, setPlatforms] = useState([]); + const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [selectedType, setSelectedType] = useState('all'); - - const filteredPlatforms = platforms.filter(platform => { - const matchesSearch = platform.name.toLowerCase().includes(searchQuery.toLowerCase()) || - platform.code.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesType = selectedType === 'all' || platform.type === selectedType; - return matchesSearch && matchesType; + const [selectedType, setSelectedType] = useState('all'); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [formData, setFormData] = useState({ + name: '', code: '', type: 'public', status: 'active', + website: '', description: '', }); - const stats = { - total: platforms.length, - public: platforms.filter(p => p.type === 'public').length, - private: platforms.filter(p => p.type === 'private').length, - totalServers: platforms.reduce((sum, p) => sum + p.serverCount, 0), + const loadData = useCallback(async () => { + setLoading(true); + try { + const res = await platformApi.list(); + const items = (res as any)?.list || []; + setPlatforms(items); + } catch { + setPlatforms([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const filtered = platforms.filter(p => { + const matchSearch = p.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + p.code?.toLowerCase().includes(searchQuery.toLowerCase()); + const matchType = selectedType === 'all' || p.type === selectedType; + return matchSearch && matchType; + }); + + const openCreate = () => { + setEditingItem(null); + setFormData({ name: '', code: '', type: 'public', status: 'active', website: '', description: '' }); + setDialogOpen(true); + }; + + const openEdit = (item: any) => { + setEditingItem(item); + setFormData({ + name: item.name || '', code: item.code || '', type: item.type || 'public', + status: item.status || 'active', website: item.website || '', description: item.description || '', + }); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.code.trim()) return; + setSubmitting(true); + try { + if (editingItem) { + await platformApi.update(editingItem.id, formData); + } else { + await platformApi.create(formData); + } + setDialogOpen(false); + loadData(); + } catch { + // error handled by http interceptor + } finally { + setSubmitting(false); + } + }; + + const [deleteId, setDeleteId] = useState(null); + const handleDelete = (id: number) => setDeleteId(id); + const confirmDelete = async () => { + if (deleteId === null) return; + await platformApi.delete(deleteId); + setDeleteId(null); + loadData(); }; return ( -
- {/* Header */} -
-
-

云平台管理

-

管理多云平台和云服务提供商

+
+ {/* Toolbar */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9 bg-slate-800/50 border-slate-700" + /> +
+
-
- {/* Stats */} -
- - -
-
-

平台总数

-

{stats.total}

-
-
- -
-
-
-
- - -
-
-

公有云

-

{stats.public}

-
-
- -
-
-
-
- - -
-
-

私有云

-

{stats.private}

-
-
- -
-
-
-
- - -
-
-

服务器总数

-

{stats.totalServers}

-
-
- -
-
-
-
-
- - {/* Filters */} -
-
- - setSearchQuery(e.target.value)} - className="pl-10 bg-slate-800 border-slate-700 text-slate-100" - /> + {/* List */} + {loading ? ( +
+
- -
- - {/* Platform List */} -
- {filteredPlatforms.map((platform) => ( - - -
-
-
- {platform.name.slice(0, 2)} + ) : filtered.length === 0 ? ( +
+ +

暂无云平台数据

+
+ ) : ( +
+ {filtered.map((p) => ( +
+
+
+
+
-
-

{platform.name}

- - {typeLabels[platform.type]} - - {platform.status === 'active' ? ( - - - 启用 - - ) : ( - - - 停用 - - )} -
-

{platform.code}

-

{platform.description}

+

{p.name}

+ {p.code}
-
- - - +
+ {p.website && ( + + + + )} + +
-
-
-

账号数

-

{platform.accountCount}

-
-
-

区域数

-

{platform.regionCount}

-
-
-

服务器数

-

{platform.serverCount}

-
+ + {p.description && ( +

{p.description}

+ )} + +
+ + {typeLabels[p.type] || p.type} + + {p.status === 'active' ? ( + + 启用 + + ) : ( + + 禁用 + + )}
- - - ))} -
+
+ ))} +
+ )} + + {/* Dialog */} + + + + {editingItem ? '编辑云平台' : '添加云平台'} + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="如:阿里云" + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + setFormData({ ...formData, code: e.target.value })} + placeholder="如:aliyun" + className="bg-slate-800/50 border-slate-700" + disabled={!!editingItem} + /> +
+
+
+
+ + +
+
+ + +
+
+
+ + setFormData({ ...formData, website: e.target.value })} + placeholder="https://..." + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="平台描述..." + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + + + +
+
+ + {/* 删除确认 */} + !v && setDeleteId(null)}> + + + 确认删除 + +

确定删除此云平台?此操作不可撤销。

+ + + + +
+
); } diff --git a/frontend/src/components/cloud/RegionManager.tsx b/frontend/src/components/cloud/RegionManager.tsx index f4216f3..82f5bbb 100644 --- a/frontend/src/components/cloud/RegionManager.tsx +++ b/frontend/src/components/cloud/RegionManager.tsx @@ -1,40 +1,41 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { - Plus, - Search, - Edit2, - Trash2, +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plus, + Search, + Trash2, + Pencil, MapPin, - Server, - Cloud, CheckCircle2, - XCircle + XCircle, + Loader2, } from 'lucide-react'; -import { regions } from '@/mocks/data'; - -interface Region { - id: string; - name: string; - code: string; - provider: string; - location: string; - status: 'active' | 'inactive'; - serverCount: number; - vpcCount: number; - endpoint: string; -} - -const providerColors: Record = { - aws: 'bg-orange-500', - aliyun: 'bg-orange-600', - tencent: 'bg-blue-500', - huawei: 'bg-red-500', - azure: 'bg-blue-600', -}; +import { regionApi } from '@/services/api'; const providerNames: Record = { aws: 'AWS', @@ -44,179 +45,283 @@ const providerNames: Record = { azure: 'Azure', }; -export function RegionManager() { - const [searchQuery, setSearchQuery] = useState(''); - const [selectedProvider, setSelectedProvider] = useState('all'); +const providerColors: Record = { + aws: 'text-orange-400', + aliyun: 'text-orange-400', + tencent: 'text-blue-400', + huawei: 'text-red-400', + azure: 'text-blue-400', +}; - const filteredRegions = regions.filter(region => { - const matchesSearch = region.name.toLowerCase().includes(searchQuery.toLowerCase()) || - region.code.toLowerCase().includes(searchQuery.toLowerCase()) || - region.location.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesProvider = selectedProvider === 'all' || region.provider === selectedProvider; - return matchesSearch && matchesProvider; +export function RegionManager() { + const [regions, setRegions] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedProvider, setSelectedProvider] = useState('all'); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [formData, setFormData] = useState({ + name: '', code: '', provider: 'aliyun', location: '', + status: 'active', endpoint: '', }); - const stats = { - total: regions.length, - active: regions.filter(r => r.status === 'active').length, - inactive: regions.filter(r => r.status === 'inactive').length, - totalServers: regions.reduce((sum, r) => sum + r.serverCount, 0), + const loadData = useCallback(async () => { + setLoading(true); + try { + const res = await regionApi.list(); + const items = (res as any)?.list || []; + setRegions(items); + } catch { + setRegions([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const filtered = regions.filter(r => { + const matchSearch = r.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + r.code?.toLowerCase().includes(searchQuery.toLowerCase()); + const matchProvider = selectedProvider === 'all' || r.provider === selectedProvider; + return matchSearch && matchProvider; + }); + + const openCreate = () => { + setEditingItem(null); + setFormData({ name: '', code: '', provider: 'aliyun', location: '', status: 'active', endpoint: '' }); + setDialogOpen(true); + }; + + const openEdit = (item: any) => { + setEditingItem(item); + setFormData({ + name: item.name || '', code: item.code || '', provider: item.provider || 'aliyun', + location: item.location || '', status: item.status || 'active', endpoint: item.endpoint || '', + }); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.code.trim()) return; + setSubmitting(true); + try { + if (editingItem) { + await regionApi.update(editingItem.id, formData); + } else { + await regionApi.create(formData); + } + setDialogOpen(false); + loadData(); + } catch { + } finally { + setSubmitting(false); + } + }; + + const [deleteId, setDeleteId] = useState(null); + const handleDelete = (id: number) => setDeleteId(id); + const confirmDelete = async () => { + if (deleteId === null) return; + await regionApi.delete(deleteId); + setDeleteId(null); + loadData(); }; return ( -
- {/* Header */} -
-
-

区域管理

-

管理云平台可用区和区域信息

+
+ {/* Toolbar */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9 bg-slate-800/50 border-slate-700" + /> +
+
-
- {/* Stats */} -
- - -
-
-

区域总数

-

{stats.total}

-
-
- -
-
-
-
- - -
-
-

可用

-

{stats.active}

-
-
- -
-
-
-
- - -
-
-

不可用

-

{stats.inactive}

-
-
- -
-
-
-
- - -
-
-

服务器总数

-

{stats.totalServers}

-
-
- -
-
-
-
-
- - {/* Filters */} -
-
- - setSearchQuery(e.target.value)} - className="pl-10 bg-slate-800 border-slate-700 text-slate-100" - /> + {/* Table */} + {loading ? ( +
+
- -
+ ) : filtered.length === 0 ? ( +
+ +

暂无区域数据

+
+ ) : ( +
+ + + + 区域名称 + 编码 + 云商 + 位置 + 接入端点 + 状态 + 操作 + + + + {filtered.map((r) => ( + + {r.name} + {r.code} + + + {providerNames[r.provider] || r.provider} + + + {r.location || '-'} + {r.endpoint || '-'} + + {r.status === 'active' ? ( + + 启用 + + ) : ( + + 禁用 + + )} + + +
+ + +
+
+
+ ))} +
+
+
+ )} - {/* Region List */} -
- {filteredRegions.map((region) => ( - - -
-
-
- {providerNames[region.provider]?.slice(0, 2) || region.provider.slice(0, 2).toUpperCase()} -
-
-
-

{region.name}

- {region.status === 'active' ? ( - - - 可用 - - ) : ( - - - 不可用 - - )} -
-

{region.code}

-
- - {region.location} -
-
-
-
- - -
+ {/* Dialog */} + + + + {editingItem ? '编辑区域' : '添加区域'} + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="如:华北-北京" + className="bg-slate-800/50 border-slate-700" + />
-
-
-

服务器

-

{region.serverCount}

-
-
-

VPC

-

{region.vpcCount}

-
-
-

Endpoint

-

{region.endpoint}

-
+
+ + setFormData({ ...formData, code: e.target.value })} + placeholder="如:cn-north-1" + className="bg-slate-800/50 border-slate-700 font-mono" + disabled={!!editingItem} + />
- - - ))} -
+
+
+
+ + +
+
+ + +
+
+
+ + setFormData({ ...formData, location: e.target.value })} + placeholder="如:中国北京" + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + setFormData({ ...formData, endpoint: e.target.value })} + placeholder="如:ecs.cn-north-1.aliyuncs.com" + className="bg-slate-800/50 border-slate-700 font-mono text-sm" + /> +
+
+ + + + +
+
+ + {/* 删除确认 */} + !v && setDeleteId(null)}> + + + 确认删除 + +

确定删除此区域?此操作不可撤销。

+ + + + +
+
); } diff --git a/frontend/src/components/k8s/TopologyView.tsx b/frontend/src/components/k8s/TopologyView.tsx index 501c6b8..ec7f209 100644 --- a/frontend/src/components/k8s/TopologyView.tsx +++ b/frontend/src/components/k8s/TopologyView.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -20,161 +20,274 @@ import { } from '@/components/ui/dialog'; import { Network, - Plus, Search, Server, - Database, - Layers, Share2, - Shield, - HardDrive, Cloud, RefreshCw, - Download, - ZoomIn, - ZoomOut, - Move, - Edit, - Trash2, - Save, + Loader2, + Globe, + Boxes, } from 'lucide-react'; -import { topologyNodes, topologyEdges } from '@/mocks/data'; +import { k8sApi } from '@/services/api'; import type { TopologyNode, TopologyEdge } from '@/types'; +// K8s resource type → topology node type label +const NODE_TYPE_LABELS: Record = { + cluster: '集群', + loadbalancer: 'Ingress', + service: 'Service', + app: 'Deployment', + server: 'Node', +}; + +/** + * buildTopology — 从真实 K8s 资源构建分层拓扑图 + * + * 层级 (自上而下): Cluster → Ingress → Service → Deployment → Node + */ +function buildTopology( + cluster: any, + ingresses: any[], + services: any[], + deployments: any[], + nodes: any[], +): { nodes: TopologyNode[]; edges: TopologyEdge[] } { + const tNodes: TopologyNode[] = []; + const tEdges: TopologyEdge[] = []; + const MAX_PER_LAYER = 12; + + const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length); + const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400)); + const layerY = [60, 180, 320, 480, 640]; + + const distributeX = (count: number): number[] => { + const cap = Math.min(count, MAX_PER_LAYER); + if (cap === 0) return []; + if (cap === 1) return [canvasW / 2]; + const step = canvasW / (cap + 1); + return Array.from({ length: cap }, (_, i) => step * (i + 1)); + }; + + // Layer 0: Cluster + const clusterId = `cluster-${cluster.id}`; + tNodes.push({ + id: clusterId, + name: cluster.name || '集群', + type: 'cluster', + x: canvasW / 2, + y: layerY[0], + data: { + kind: 'cluster', + version: cluster.version, + status: cluster.status, + api_server: cluster.api_server, + node_count: cluster.node_count, + pod_count: cluster.pod_count, + }, + }); + + // Layer 1: Ingress + const ingCap = ingresses.slice(0, MAX_PER_LAYER); + const ingXs = distributeX(ingCap.length); + ingCap.forEach((ing, i) => { + const id = `ingress-${ing.id}`; + tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } }); + tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' }); + }); + + // Layer 2: Service + const svcCap = services.slice(0, MAX_PER_LAYER); + const svcXs = distributeX(svcCap.length); + svcCap.forEach((svc, i) => { + const id = `service-${svc.id}`; + tNodes.push({ id, name: svc.name, type: 'service', x: svcXs[i], y: layerY[2], data: { kind: 'service', namespace: svc.namespace, type: svc.type, cluster_ip: svc.cluster_ip, external_ip: svc.external_ip, selector: svc.selector } }); + }); + + // Ingress → Service edges + if (ingCap.length > 0 && svcCap.length > 0) { + ingCap.forEach((ing) => { + const ingNodeId = `ingress-${ing.id}`; + const backendSvc = (ing.backend || '').split(':')[0].trim(); + let matched = svcCap.filter((s) => s.name === backendSvc); + if (matched.length === 0) matched = svcCap.filter((s) => s.namespace === ing.namespace); + if (matched.length === 0) matched = [svcCap[0]]; + matched.forEach((svc) => tEdges.push({ id: `e-${ingNodeId}-service-${svc.id}`, source: ingNodeId, target: `service-${svc.id}`, label: '→' })); + }); + } else if (svcCap.length > 0) { + svcCap.forEach((svc) => tEdges.push({ id: `e-${clusterId}-service-${svc.id}`, source: clusterId, target: `service-${svc.id}`, label: 'expose' })); + } + + // Layer 3: Deployment + const depCap = deployments.slice(0, MAX_PER_LAYER); + const depXs = distributeX(depCap.length); + depCap.forEach((dep, i) => { + const id = `deploy-${dep.id}`; + tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } }); + }); + + // Service → Deployment edges (selector match or namespace fallback) + if (svcCap.length > 0 && depCap.length > 0) { + svcCap.forEach((svc) => { + const svcNodeId = `service-${svc.id}`; + let matched: any[] = []; + try { + const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {}); + const selectorEntries = Object.entries(selector); + if (selectorEntries.length > 0) { + matched = depCap.filter((d) => { + let labels: Record = {}; + try { labels = typeof d.labels === 'string' ? JSON.parse(d.labels || '{}') : (d.labels || {}); } catch { /* */ } + return selectorEntries.every(([k, v]) => labels[k] === v); + }); + } + } catch { /* */ } + if (matched.length === 0) matched = depCap.filter((d) => d.name === svc.name); + if (matched.length === 0) matched = depCap.filter((d) => d.namespace === svc.namespace); + if (matched.length === 0) matched = [depCap[0]]; + matched.slice(0, 4).forEach((dep) => tEdges.push({ id: `e-${svcNodeId}-deploy-${dep.id}`, source: svcNodeId, target: `deploy-${dep.id}`, label: 'select' })); + }); + } + + // Layer 4: Node + const nodeCap = nodes.slice(0, MAX_PER_LAYER); + const nodeXs = distributeX(nodeCap.length); + nodeCap.forEach((n, i) => { + const id = `k8snode-${n.id}`; + tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } }); + }); + + // Deployment → Node edges (Pods scheduled on worker nodes) + const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker'); + const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap; + if (depCap.length > 0 && schedulableNodes.length > 0) { + depCap.forEach((dep) => { + const depNodeId = `deploy-${dep.id}`; + schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' })); + }); + } + + return { nodes: tNodes, edges: tEdges }; +} + export function TopologyView() { - const canvasRef = useRef(null); - const [scale, setScale] = useState(1); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [clusters, setClusters] = useState([]); + const [selectedClusterId, setSelectedClusterId] = useState(''); + const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] }); + const [loading, setLoading] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [viewType, setViewType] = useState('all'); - const filteredNodes = topologyNodes.filter( + const loadClusters = useCallback(async () => { + try { + const res: any = await k8sApi.getClusters(); + const list = res?.list || []; + setClusters(list); + if (list.length > 0 && !selectedClusterId) { + setSelectedClusterId(String(list[0].id)); + } + } catch (e) { + console.error('加载集群列表失败:', e); + setClusters([]); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + loadClusters(); + }, [loadClusters]); + + const loadTopology = useCallback(async () => { + if (!selectedClusterId) return; + setLoading(true); + try { + const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || [])); + const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([ + k8sApi.getNodes(selectedClusterId).catch(() => []), + k8sApi.getServices(selectedClusterId).catch(() => []), + k8sApi.getDeployments(selectedClusterId).catch(() => []), + k8sApi.getIngresses(selectedClusterId).catch(() => []), + ]); + const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {}; + setTopology(buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes))); + } catch (e) { + console.error('加载拓扑数据失败:', e); + setTopology({ nodes: [], edges: [] }); + } finally { + setLoading(false); + } + }, [selectedClusterId, clusters]); + + useEffect(() => { + loadTopology(); + }, [loadTopology]); + + const filteredNodes = topology.nodes.filter( (node) => node.name.toLowerCase().includes(searchQuery.toLowerCase()) && - (viewType === 'all' || node.type === viewType) + (viewType === 'all' || node.type === viewType), ); + const visibleIds = new Set(filteredNodes.map((n) => n.id)); - const getNodeIcon = (type: TopologyNode['type']) => { - const icons = { - server: , - database: , - cache: , - loadbalancer: , - cluster: , - app: , - service: , - }; - return icons[type] || ; + const getNodeIcon = (type: string) => { + switch (type) { + case 'server': return ; + case 'loadbalancer': return ; + case 'cluster': return ; + case 'app': return ; + case 'service': return ; + default: return ; + } }; - const getNodeColor = (type: TopologyNode['type']) => { - const colors = { - server: 'bg-blue-500/20 text-blue-400 border-blue-500/30', - database: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', - cache: 'bg-amber-500/20 text-amber-400 border-amber-500/30', - loadbalancer: 'bg-purple-500/20 text-purple-400 border-purple-500/30', - cluster: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30', - app: 'bg-rose-500/20 text-rose-400 border-rose-500/30', - service: 'bg-slate-500/20 text-slate-400 border-slate-500/30', - }; - return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'; + const getNodeColor = (type: string) => { + switch (type) { + case 'server': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; + case 'loadbalancer': return 'bg-purple-500/20 text-purple-400 border-purple-500/30'; + case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30'; + case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30'; + case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'; + default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30'; + } }; - // 简化的SVG拓扑图渲染 + const maxX = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.x)) + 80 : 900; + const maxY = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.y)) + 80 : 700; + const canvasW = Math.max(maxX, 900); + const canvasH = Math.max(maxY, 700); + const renderTopology = () => { - const width = 800; - const height = 500; - + if (topology.nodes.length === 0) return null; + const ys = [60, 180, 320, 480, 640]; + const labels = ['集群', 'Ingress', 'Service', 'Deployment', 'Node']; return ( - - {/* Grid background */} + - - - - {/* Edges */} - {topologyEdges.map((edge) => { - const source = topologyNodes.find((n) => n.id === edge.source); - const target = topologyNodes.find((n) => n.id === edge.target); - if (!source || !target) return null; - - return ( - - - - {edge.label} - - - ); - })} - - {/* Arrow marker */} - - + - - {/* Nodes */} + + {labels.map((label, i) => ( + {label} + ))} + {topology.edges.map((edge) => { + const source = topology.nodes.find((n) => n.id === edge.source); + const target = topology.nodes.find((n) => n.id === edge.target); + if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null; + return ; + })} {filteredNodes.map((node) => ( - setSelectedNode(node)} - > - + setSelectedNode(node)}> + -
- {getNodeIcon(node.type)} -
+
{getNodeIcon(node.type)}
- - {node.name} + + {node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
))} @@ -182,153 +295,111 @@ export function TopologyView() { ); }; + const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length; + + const renderNodeDetailFields = (node: TopologyNode) => { + const data = node.data || {}; + const entries = Object.entries(data).filter(([k]) => k !== 'kind'); + if (entries.length === 0) return null; + return ( +
+ {entries.map(([key, val]) => ( +
+

{key}

+

{val === null || val === undefined ? '—' : String(val)}

+
+ ))} +
+ ); + }; + return (
- {/* Page Header */} + {/* Header */}

架构拓扑

-

系统架构可视化与依赖关系管理

+

K8s 集群资源拓扑可视化

- - -
-
- - {/* Stats Cards */} -
- - -
-
-

节点数

-

{topologyNodes.length}

-
-
- -
-
-
-
- - -
-
-

连接数

-

{topologyEdges.length}

-
-
- -
-
-
-
- - -
-
-

数据库

-

- {topologyNodes.filter((n) => n.type === 'database').length} -

-
-
- -
-
-
-
- - -
-
-

缓存

-

- {topologyNodes.filter((n) => n.type === 'cache').length} -

-
-
- -
-
-
-
- - -
-
-

负载均衡

-

- {topologyNodes.filter((n) => n.type === 'loadbalancer').length} -

-
-
- -
-
-
-
-
- - {/* Filters & Toolbar */} -
-
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" - /> -
- { setSelectedClusterId(v); setSelectedNode(null); }}> + + - 全部类型 - 服务器 - 数据库 - 缓存 - 负载均衡 - 集群 + {clusters.map((c) => ( + {c.name} + ))} -
-
- - -
- {/* Topology Canvas */} + {/* Stats */} +
+ {[ + { label: '总节点数', value: topology.nodes.length, icon: , color: 'text-blue-400', bg: 'bg-blue-500/10' }, + { label: '连接数', value: topology.edges.length, icon: , color: 'text-purple-400', bg: 'bg-purple-500/10' }, + { label: 'Ingress', value: countByType('loadbalancer'), icon: , color: 'text-purple-400', bg: 'bg-purple-500/10' }, + { label: 'Service', value: countByType('service'), icon: , color: 'text-emerald-400', bg: 'bg-emerald-500/10' }, + { label: 'Deployment', value: countByType('app'), icon: , color: 'text-rose-400', bg: 'bg-rose-500/10' }, + ].map((s) => ( + + +
+
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+
+ ))} +
+ + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" /> +
+ +
+ + {/* Canvas + Node List */}
-
- {renderTopology()} +
+ {loading ? ( +
+ ) : topology.nodes.length === 0 ? ( +
+ +

{selectedClusterId ? '该集群暂无拓扑数据' : '请选择集群查看拓扑'}

+
+ ) : renderTopology()}
- - {/* Node List */}
@@ -336,24 +407,20 @@ export function TopologyView() { 点击节点查看详情 -
- {filteredNodes.map((node) => ( -
setSelectedNode(node)} - > -
- {getNodeIcon(node.type)} +
+ {filteredNodes.length === 0 ? ( +

暂无节点

+ ) : ( + filteredNodes.map((node) => ( +
setSelectedNode(node)}> +
{getNodeIcon(node.type)}
+
+

{node.name}

+

{NODE_TYPE_LABELS[node.type] || node.type}

+
-
-

{node.name}

-

{node.type}

-
-
- ))} + )) + )}
@@ -365,87 +432,36 @@ export function TopologyView() { -
- {selectedNode && getNodeIcon(selectedNode.type)} -
+
{selectedNode && getNodeIcon(selectedNode.type)}
{selectedNode?.name}
- - 类型: {selectedNode?.type} - + 类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}
- -
-
+ {selectedNode && ( +
+ {renderNodeDetailFields(selectedNode)}
-

节点ID

-

{selectedNode?.id}

-
-
-

节点名称

-

{selectedNode?.name}

-
-
-

坐标位置

-

X: {selectedNode?.x}, Y: {selectedNode?.y}

-
-
-

关联连接

-

- {topologyEdges.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id).length} 条 -

-
-
- - {selectedNode?.data && ( -
-

扩展数据

-
-
-                    {JSON.stringify(selectedNode.data, null, 2)}
-                  
-
-
- )} - -
-

关联节点

-
- {topologyEdges - .filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id) - .map((edge) => { - const isSource = edge.source === selectedNode?.id; +

关联连接

+
+ {topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => { + const isSource = edge.source === selectedNode.id; const relatedNodeId = isSource ? edge.target : edge.source; - const relatedNode = topologyNodes.find((n) => n.id === relatedNodeId); + const relatedNode = topology.nodes.find((n) => n.id === relatedNodeId); if (!relatedNode) return null; - return (
{isSource ? '→' : '←'} -
- {getNodeIcon(relatedNode.type)} -
+
{getNodeIcon(relatedNode.type)}
{relatedNode.name} - - {edge.label} - +{edge.label}
); })} + {topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 &&

无关联连接

} +
-
- - - - - + )}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 4fd9120..abcf701 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { NavLink } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { useThemeStore, useTabStore, useNotificationStore, useUserStore } from '@/store'; import { Button } from '@/components/ui/button'; @@ -19,7 +18,6 @@ import { Bell, Menu, X, - Plus, Settings, User, LogOut, @@ -27,10 +25,6 @@ import { AlertTriangle, Info, CheckCircle, - Terminal, - Rocket, - Server, - Cloud, Palette, Check, } from 'lucide-react'; @@ -98,17 +92,6 @@ export function Header() {
- {/* Quick Actions */} -
- - -
{/* Right: Actions */} @@ -146,35 +129,6 @@ export function Header() { - {/* Create New */} - - - - - - 快速创建 - - - - 添加服务器 - - - - 添加K8s集群 - - - - 创建发布 - - - - 创建工单 - - - - {/* Notifications */} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f4b108b..1b07c33 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -19,7 +19,6 @@ import { FileCode, Shield, Users, - LogOut, Menu, X, Box, @@ -41,7 +40,6 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; interface MenuItem { id: string; @@ -307,38 +305,13 @@ export function Sidebar() {
{/* Menu */} - +
{menuItems.map((item) => ( ))}
- - {/* User */} - {!sidebarCollapsed && ( -
-
-
- 管 -
-
-

管理员

-

admin@company.com

-
- -
-
- )} - {sidebarCollapsed && ( -
- -
- )} ); } diff --git a/frontend/src/components/server/ResourceModelManager.tsx b/frontend/src/components/server/ResourceModelManager.tsx index e2c4a4b..87570ae 100644 --- a/frontend/src/components/server/ResourceModelManager.tsx +++ b/frontend/src/components/server/ResourceModelManager.tsx @@ -1,9 +1,8 @@ -import { useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, @@ -15,448 +14,378 @@ import { import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { - Layers, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Plus, Search, - Database, - Network, - Server, - HardDrive, - Settings, - Edit, Trash2, - Eye, - Link, - Copy, - ArrowRight, - GitBranch, + Pencil, + Layers, + GripVertical, + X, + Loader2, } from 'lucide-react'; -import { resourceModels, modelInstances } from '@/mocks/data'; -import type { ResourceModel, ModelInstance } from '@/types'; +import { resourceModelApi } from '@/services/api'; + +interface AttrDef { + name: string; + code: string; + type: string; + required: boolean; + unique?: boolean; +} + +const attrTypes = [ + { value: 'string', label: '字符串' }, + { value: 'number', label: '数字' }, + { value: 'bool', label: '布尔' }, + { value: 'date', label: '日期' }, + { value: 'enum', label: '枚举' }, +]; + +const typeLabels: Record = { + normal: '普通模型', + parent: '父模型', +}; export function ResourceModelManager() { - const [activeTab, setActiveTab] = useState('models'); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [selectedModel, setSelectedModel] = useState(null); - const [selectedInstance, setSelectedInstance] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const [formData, setFormData] = useState({ + name: '', code: '', description: '', type: 'normal', icon: '', + }); + const [attrs, setAttrs] = useState([]); - const filteredModels = resourceModels.filter( - (model) => - model.name.toLowerCase().includes(searchQuery.toLowerCase()) || - model.code.toLowerCase().includes(searchQuery.toLowerCase()) + const loadData = useCallback(async () => { + setLoading(true); + try { + const res = await resourceModelApi.list(); + const items = (res as any)?.list || []; + setModels(items); + } catch { + setModels([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const filtered = models.filter(m => + m.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + m.code?.toLowerCase().includes(searchQuery.toLowerCase()) ); - const getModelTypeBadge = (type: ResourceModel['type']) => { - const configs = { - normal: { label: '普通', className: 'border-blue-500/30 text-blue-400 bg-blue-500/10' }, - parent: { label: '父模型', className: 'border-purple-500/30 text-purple-400 bg-purple-500/10' }, - child: { label: '子模型', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' }, - }; - const config = configs[type]; - return ( - - {config.label} - - ); - }; - - const getAttributeTypeIcon = (type: string) => { - switch (type) { - case 'string': - return ABC; - case 'number': - return 123; - case 'boolean': - return T/F; - case 'date': - return 📅; - case 'enum': - return ▼; - default: - return null; + const parseAttrs = (attrsStr: string): AttrDef[] => { + if (!attrsStr) return []; + try { + const parsed = JSON.parse(attrsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; } }; + const openCreate = () => { + setEditingItem(null); + setFormData({ name: '', code: '', description: '', type: 'normal', icon: '' }); + setAttrs([]); + setDialogOpen(true); + }; + + const openEdit = (item: any) => { + setEditingItem(item); + setFormData({ + name: item.name || '', code: item.code || '', description: item.description || '', + type: item.type || 'normal', icon: item.icon || '', + }); + setAttrs(parseAttrs(item.attributes)); + setDialogOpen(true); + }; + + const addAttr = () => { + setAttrs([...attrs, { name: '', code: '', type: 'string', required: false }]); + }; + + const updateAttr = (index: number, field: keyof AttrDef, value: any) => { + const next = [...attrs]; + (next[index] as any)[field] = value; + setAttrs(next); + }; + + const removeAttr = (index: number) => { + setAttrs(attrs.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.code.trim()) return; + setSubmitting(true); + try { + const payload = { ...formData, attributes: attrs.filter(a => a.name.trim() && a.code.trim()) }; + if (editingItem) { + await resourceModelApi.update(editingItem.id, payload); + } else { + await resourceModelApi.create(payload); + } + setDialogOpen(false); + loadData(); + } catch { + } finally { + setSubmitting(false); + } + }; + + const [deleteId, setDeleteId] = useState(null); + const handleDelete = (id: number) => setDeleteId(id); + const confirmDelete = async () => { + if (deleteId === null) return; + await resourceModelApi.delete(deleteId); + if (selectedModel?.id === deleteId) setSelectedModel(null); + setDeleteId(null); + loadData(); + }; + + const getAttrCount = (item: any) => parseAttrs(item.attributes).length; + return ( -
- {/* Page Header */} -
-
-

资源模型管理

-

CMDB资源模型定义与实例管理

-
-
- - -
-
- - {/* Stats Cards */} -
- - -
-
-

模型总数

-

{resourceModels.length}

-
-
- -
-
-
-
- - -
-
-

实例总数

-

- {resourceModels.reduce((acc, m) => acc + m.instanceCount, 0)} -

-
-
- -
-
-
-
- - -
-
-

父模型

-

- {resourceModels.filter((m) => m.type === 'parent').length} -

-
-
- -
-
-
-
- - -
-
-

属性总数

-

- {resourceModels.reduce((acc, m) => acc + m.attributes.length, 0)} -

-
-
- -
-
-
-
-
- - {/* Filters */} -
-
+
+ {/* Toolbar */} +
+
setSearchQuery(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" + className="pl-9 h-9 bg-slate-800/50 border-slate-700" />
+
- {/* Tabs */} - - - - - 模型列表 - - - - 实例管理 - - - - 关系视图 - - - - -
- {filteredModels.map((model) => ( - - -
-
-
- -
-
- {model.name} - {model.code} -
-
-
- {getModelTypeBadge(model.type)} - -
+ {/* List */} + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

暂无资源模型

+
+ ) : ( +
+ {filtered.map((m) => ( +
setSelectedModel(selectedModel?.id === m.id ? null : m)} + > +
+
+
+
- - -

{model.description}

- -
-
- 属性数量 - {model.attributes.length} -
-
- 实例数量 - {model.instanceCount} -
+
+

{m.name}

+ {m.code}
- -
-

属性预览

-
- {model.attributes.slice(0, 5).map((attr) => ( - - {getAttributeTypeIcon(attr.type)} - {attr.name} - {attr.required && *} - - ))} - {model.attributes.length > 5 && ( - - +{model.attributes.length - 5} - - )} -
-
- - - ))} -
- - - - - -
- - - - 实例名称 - 所属模型 - 关键属性 - 关系数 - 更新时间 - 操作 - - - - {modelInstances.map((instance) => ( - - - {instance.name} - - - - {resourceModels.find((m) => m.id === instance.modelId)?.name} - - - -
- {Object.entries(instance.data).slice(0, 3).map(([key, value]) => ( - - {key}: {String(value).slice(0, 15)} - - ))} -
-
- - {instance.relations.length} - - - - {new Date(instance.updatedAt).toLocaleString()} - - - -
- - -
-
-
- ))} -
-
+
+
+ + +
+
+ {m.description && ( +

{m.description}

+ )} +
+ + {typeLabels[m.type] || m.type} + + + {getAttrCount(m)} 个属性 +
- - - - - - -
- -

关系视图

-

可视化展示资源实例之间的关系

-
+ ))} +
+ )} + + {/* Dialog */} + + + + {editingItem ? '编辑资源模型' : '添加资源模型'} + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="如:服务器" + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + setFormData({ ...formData, code: e.target.value })} + placeholder="如:server" + className="bg-slate-800/50 border-slate-700 font-mono" + disabled={!!editingItem} + /> +
+
+
+
+ + +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="模型描述..." + className="bg-slate-800/50 border-slate-700" + /> +
+
+ + {/* Attributes editor */} +
+
+ +
- - - - - - {/* Model Detail Dialog */} - setSelectedModel(null)}> - - - - - {selectedModel?.name} - - - {selectedModel?.code} · {selectedModel?.description} - - - -
-
-

属性定义

-
- - - - - - - - - - - - {selectedModel?.attributes.map((attr) => ( - - - - - - - - ))} - -
属性名字段名类型必填唯一
{attr.name}{attr.code} - - {attr.type} - - {attr.required ? '是' : '否'}{attr.unique ? '是' : '否'}
+
+ {attrs.map((attr, i) => ( +
+ + updateAttr(i, 'name', e.target.value)} + className="h-8 bg-slate-800/50 border-slate-700 flex-1" + /> + updateAttr(i, 'code', e.target.value)} + className="h-8 bg-slate-800/50 border-slate-700 flex-1 font-mono text-sm" + /> + + + +
+ ))} + {attrs.length === 0 && ( +

暂无属性,点击"添加属性"开始定义

+ )}
- - - +
- {/* Instance Detail Dialog */} - setSelectedInstance(null)}> - + {/* 删除确认 */} + !v && setDeleteId(null)}> + - - - {selectedInstance?.name} - - - 模型: {resourceModels.find((m) => m.id === selectedInstance?.modelId)?.name} - + 确认删除 - -
-
-

属性数据

-
- {selectedInstance && Object.entries(selectedInstance.data).map(([key, value]) => ( -
-

{key}

-

{String(value)}

-
- ))} -
-
- - {selectedInstance && selectedInstance.relations.length > 0 && ( -
-

关联关系

-
- {selectedInstance.relations.map((rel, index) => ( -
- {rel.relationType} - - - {rel.targetInstanceId} - -
- ))} -
-
- )} -
- +

确定删除此资源模型?此操作不可撤销。

- - + +
diff --git a/frontend/src/components/system/SystemManager.tsx b/frontend/src/components/system/SystemManager.tsx index b637817..1ecf7d5 100644 --- a/frontend/src/components/system/SystemManager.tsx +++ b/frontend/src/components/system/SystemManager.tsx @@ -21,6 +21,7 @@ import { DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from '@/components/ui/dialog'; import { Select, @@ -50,9 +51,15 @@ import { History, LogOut, RefreshCw, + Upload, + Download, + Network, + Loader2, + UserPlus, + ChevronDown, } from 'lucide-react'; import { roles, operationLogs } from '@/mocks/data'; -import { userApi } from '@/services/api'; +import { userApi, ldapApi } from '@/services/api'; import type { Role, OperationLog } from '@/types'; // 后端用户数据转换为前端兼容格式 @@ -63,6 +70,7 @@ function mapUser(u: any) { roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [], lastLoginAt: u.last_login ?? u.lastLoginAt, createdAt: u.created_at ?? u.createdAt, + authSource: u.auth_source ?? u.authSource ?? 'local', }; } @@ -90,6 +98,44 @@ export function SystemManager() { const [saving, setSaving] = useState(false); const [form, setForm] = useState({ ...emptyForm }); + // 批量导入弹窗 + const [importDialogOpen, setImportDialogOpen] = useState<'csv' | 'ldap' | null>(null); + const [importPreview, setImportPreview] = useState([]); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + + // LDAP 用户列表 + const [ldapUsers, setLdapUsers] = useState([]); + const [ldapSelected, setLdapSelected] = useState>(new Set()); + const [ldapLoading, setLdapLoading] = useState(false); + + // LDAP 配置 + const [ldapConfig, setLdapConfig] = useState(null); + const [ldapForm, setLdapForm] = useState({ + ldap_host: '', ldap_port: 389, use_ldaps: false, + admin_dn: '', admin_password: '', base_dn: '', search_filter: '', + }); + const [ldapSaving, setLdapSaving] = useState(false); + const [ldapTesting, setLdapTesting] = useState(false); + const [ldapTestResult, setLdapTestResult] = useState(null); + const [ldapMgmtOpen, setLdapMgmtOpen] = useState(false); + const [importFeedback, setImportFeedback] = useState(null); + const [importResultOpen, setImportResultOpen] = useState(false); + + // LDAP 账号管理 + const [ldapOUs, setLdapOUs] = useState([]); + const [selectedOUDN, setSelectedOUDN] = useState(''); + const [selectedLDAPUsers, setSelectedLDAPUsers] = useState([]); + const [createOUOpen, setCreateOUOpen] = useState(false); + const [createUserOpen, setCreateUserOpen] = useState(false); + const [ouForm, setOuForm] = useState({ ou_name: '', parent_dn: '', description: '' }); + const [newUser, setNewUser] = useState({ username: '', password: '', display_name: '', email: '', ou_dn: '' }); + const [ldapUserTestOpen, setLdapUserTestOpen] = useState(false); + const [ldapTestUser, setLdapTestUser] = useState(''); + const [ldapTestPass, setLdapTestPass] = useState(''); + const [ldapUserTesting, setLdapUserTesting] = useState(false); + const [ldapUserTestResult, setLdapUserTestResult] = useState(null); + const loadUsers = async () => { setLoading(true); try { @@ -106,6 +152,7 @@ export function SystemManager() { useEffect(() => { loadUsers(); + loadLdapConfig(); }, []); const filteredUsers = users.filter( @@ -206,6 +253,215 @@ export function SystemManager() { } }; + // CSV 文件解析 + const handleCsvUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('\xEF\xBB\xBF')); + if (lines.length < 2) return; + // Find column indices from header + const headers = lines[0].split(',').map((h) => h.trim().toLowerCase()); + const idxUsername = headers.findIndex((h) => h.includes('用户名') || h.includes('username')); + const idxNickname = headers.findIndex((h) => h.includes('昵称') || h.includes('name') || h.includes('显示名')); + const idxEmail = headers.findIndex((h) => h.includes('邮箱') || h.includes('email') || h.includes('mail')); + const idxPhone = headers.findIndex((h) => h.includes('手机') || h.includes('phone')); + const idxRole = headers.findIndex((h) => h.includes('角色') || h.includes('role')); + const idxPassword = headers.findIndex((h) => h.includes('密码') || h.includes('password')); + + const parsed: any[] = []; + for (let i = 1; i < lines.length; i++) { + const cols = lines[i].split(',').map((c) => c.trim()); + parsed.push({ + username: idxUsername >= 0 ? cols[idxUsername] : '', + nickname: idxNickname >= 0 ? cols[idxNickname] : '', + email: idxEmail >= 0 ? cols[idxEmail] : '', + phone: idxPhone >= 0 ? cols[idxPhone] : '', + role: idxRole >= 0 ? cols[idxRole] : '', + password: idxPassword >= 0 ? cols[idxPassword] : '', + }); + } + setImportPreview(parsed); + setImportResult(null); + }; + reader.readAsText(file, 'UTF-8'); + }; + + // 执行 CSV 批量导入 + const handleBatchImport = async () => { + setImporting(true); + try { + const res = await userApi.batchImport({ users: importPreview, skip_existing: true }); + setImportResult(res); + await loadUsers(); + } catch (error: any) { + setImportResult({ code: 500, message: error?.message || '导入失败' }); + } finally { + setImporting(false); + } + }; + + // 加载 LDAP 用户列表 + const loadLdapUsers = async () => { + setLdapLoading(true); + try { + const res = await ldapApi.listUsers(); + setLdapUsers(Array.isArray(res) ? res : []); + setLdapSelected(new Set()); + } catch { + setLdapUsers([]); + } finally { + setLdapLoading(false); + } + }; + + // 执行 LDAP 导入 + const handleLdapImport = async () => { + let selected: any[] = []; + if (selectedLDAPUsers.length > 0) { + selected = ldapUsers.filter((u) => selectedLDAPUsers.includes(u.dn)); + } else { + selected = ldapUsers; // 没有选择则导入全部 + } + if (selected.length === 0) return; + setImporting(true); + try { + const mapped = selected.map((u) => ({ + username: u.uid || '', + nickname: u.cn || '', + email: u.mail || '', + })); + const res = await ldapApi.batchImport(mapped); + setImportFeedback(res?.message || `成功导入 ${selected.length} 个用户`); + setImportResultOpen(true); + setSelectedLDAPUsers([]); + await loadUsers(); + } catch (error: any) { + setImportFeedback('导入失败: ' + (error?.message || '未知错误')); + setImportResultOpen(true); + } finally { + setImporting(false); + } + }; + + // 加载 LDAP 配置 + const loadLdapConfig = async () => { + try { + const res = await ldapApi.getConfig(); + if (res && res.ldap_host) { + setLdapConfig(res); + setLdapForm({ + ldap_host: res.ldap_host || '', + ldap_port: res.ldap_port || 389, + use_ldaps: res.use_ldaps || false, + admin_dn: res.admin_dn || '', + admin_password: '', + base_dn: res.base_dn || '', + search_filter: res.search_filter || '', + }); + } + } catch { + // No config yet + } + }; + + // 保存 LDAP 配置 + const handleLdapSave = async () => { + setLdapSaving(true); + try { + await ldapApi.updateConfig(ldapForm); + await loadLdapConfig(); + } catch (error) { + console.error('Failed to save LDAP config:', error); + } finally { + setLdapSaving(false); + } + }; + + // 测试 LDAP 连接 + const handleLdapTest = async () => { + setLdapTesting(true); + setLdapTestResult(null); + try { + await ldapApi.updateConfig(ldapForm); + const res = await ldapApi.testConnection(); + setLdapTestResult(res?.message || (res?.success ? '连接成功' : '连接失败')); + } catch (error: any) { + setLdapTestResult(error?.message || '连接失败'); + } finally { + setLdapTesting(false); + } + }; + + // LDAP 账号管理函数 + const loadLdapOUs = async () => { + try { + const ous = await ldapApi.listOUs(); + setLdapOUs(ous || []); + // 同时加载根 DN 下的用户 + if (!selectedOUDN && ldapConfig?.base_dn) { + setSelectedOUDN(ldapConfig.base_dn); + await loadLdapOUUsers(ldapConfig.base_dn); + } + } catch (e) { console.error('loadOUs', e); } + }; + + const loadLdapOUUsers = async (ouDn?: string) => { + try { + const users = await ldapApi.listUsers(ouDn); + setLdapUsers(users || []); + } catch (e) { console.error('loadOUUsers', e); } + }; + + const handleCreateOU = async () => { + setLdapSaving(true); + try { + await ldapApi.createOU(ouForm); + setCreateOUOpen(false); + setOuForm({ ou_name: '', parent_dn: '', description: '' }); + await loadLdapOUs(); + } catch (e: any) { + const msg = e?.message || ''; + if (msg.includes('Already Exists') || msg.includes('68')) { + alert(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`); + } else { + alert('创建失败: ' + msg); + } + } finally { setLdapSaving(false); } + }; + + const handleCreateLDAPUser = async () => { + setLdapSaving(true); + try { + await ldapApi.createUser({ ...newUser, ou_dn: newUser.ou_dn || selectedOUDN || ldapConfig?.base_dn }); + setCreateUserOpen(false); + setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' }); + await loadLdapOUUsers(selectedOUDN); + } catch (e: any) { alert(e?.message || '创建失败'); } + finally { setLdapSaving(false); } + }; + + const handleDeleteLDAPEntry = async (dn: string, silent = false) => { + if (!silent && !confirm(`确定删除 ${dn}?`)) return; + try { + await ldapApi.deleteUser(dn); + if (!silent) await loadLdapOUUsers(selectedOUDN); + } catch (e: any) { if (!silent) alert(e?.message || '删除失败'); } + }; + + const handleLdapUserTest = async () => { + setLdapUserTesting(true); + setLdapUserTestResult(null); + try { + const res = await ldapApi.testConnection({ test_user: ldapTestUser, test_password: ldapTestPass }); + setLdapUserTestResult(res); + } catch (e: any) { + setLdapUserTestResult({ success: false, message: e?.message || '验证失败' }); + } finally { setLdapUserTesting(false); } + }; + return (
{/* Page Header */} @@ -249,10 +505,20 @@ export function SystemManager() { className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" />
- +
+ + + +
{/* Users Table */} @@ -265,6 +531,7 @@ export function SystemManager() { 用户信息 联系方式 角色 + 来源 状态 最后登录 操作 @@ -273,7 +540,7 @@ export function SystemManager() { {loading ? ( - +
@@ -281,7 +548,7 @@ export function SystemManager() { ) : filteredUsers.length === 0 ? ( - + 暂无用户数据 @@ -322,6 +589,17 @@ export function SystemManager() { ))}
+ + {user.authSource === 'ldap' ? ( + + LDAP + + ) : ( + + 本地 + + )} + {getUserStatusBadge(user.status)} @@ -612,14 +890,6 @@ export function SystemManager() { 系统版本 v2.5.1
-
- 构建时间 - 2025-03-30 -
-
- 数据库版本 - PostgreSQL 15.2 -
运行时间 30天 12小时 @@ -627,6 +897,392 @@ export function SystemManager() {
+ + {/* LDAP 配置 */} + + +
+
+ + + LDAP / Active Directory + + 配置 LDAP 服务器,实现统一身份认证 +
+
+
+ +
+
+ + setLdapForm({ ...ldapForm, ldap_host: e.target.value })} + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + placeholder="ldap.example.com" + /> +
+
+ + setLdapForm({ ...ldapForm, ldap_port: Number(e.target.value) })} + className="bg-slate-800/50 border-slate-700 text-slate-200" + placeholder="389" + /> +
+
+ + setLdapForm({ ...ldapForm, base_dn: e.target.value })} + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + placeholder="ou=users,dc=example,dc=com" + /> +
+
+ + setLdapForm({ ...ldapForm, admin_dn: e.target.value })} + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + placeholder="cn=admin,dc=example,dc=com" + /> +
+
+ + setLdapForm({ ...ldapForm, admin_password: e.target.value })} + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + placeholder="******" + /> +
+
+ + setLdapForm({ ...ldapForm, search_filter: e.target.value })} + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + placeholder="(uid=%s)" + /> +
+
+ setLdapForm({ ...ldapForm, use_ldaps: v })} + className="data-[state=checked]:bg-blue-600" + /> + 启用 LDAPS(SSL 加密) +
+
+ + {/* 测试/保存按钮 */} +
+ {ldapTestResult && ( + + {ldapTestResult} + + )} +
+ + +
+
+
+
+ + {/* LDAP 账号管理 - 可折叠 */} + {ldapConfig && ( +
+ {/* 折叠/展开按钮 */} + + + {ldapMgmtOpen && ( +
+
+ {/* 账号验证测试 */} + + + + + + + LDAP 账号验证测试 + 输入 LDAP 用户凭据进行认证测试 + +
+
+ + setLdapTestUser(e.target.value)} + placeholder="zhangsan" + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + /> +
+
+ + setLdapTestPass(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleLdapUserTest()} + placeholder="••••••" + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" + /> +
+ {ldapUserTestResult && ( +
+ {ldapUserTestResult.message} + {ldapUserTestResult.user_info && ( +
{JSON.stringify(ldapUserTestResult.user_info, null, 2)}
+ )} +
+ )} + +
+
+
+ + + + {/* OU 选择 */} + + + {/* 新建 OU */} + + + + + + + 新建组织单元 + 在 LDAP 目录中创建新的组织单元 (OU) + +
+
+ + setOuForm({ ...ouForm, ou_name: e.target.value })} placeholder="dev" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setOuForm({ ...ouForm, parent_dn: e.target.value })} placeholder={ldapConfig.base_dn} className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setOuForm({ ...ouForm, description: e.target.value })} placeholder="开发团队" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+ +
+
+
+ + {/* 添加用户 */} + + + + + + + 添加 LDAP 用户 + 在 LDAP 目录中创建新的用户账号 + +
+
+ + setNewUser({ ...newUser, username: e.target.value })} placeholder="zhangsan" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setNewUser({ ...newUser, password: e.target.value })} placeholder="••••••" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setNewUser({ ...newUser, display_name: e.target.value })} placeholder="张三" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setNewUser({ ...newUser, email: e.target.value })} placeholder="zs@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setNewUser({ ...newUser, ou_dn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+ +
+
+
+ + {/* 导入到本地 */} + +
+ + {importFeedback && ( + + + + 导入结果 + LDAP 用户导入到本地用户库的执行结果 + +
+ {importFeedback} +
+ +
+
+ )} + + {/* 用户表格 */} +
+ + + + + 0 && selectedLDAPUsers.length === ldapUsers.length} + onChange={(e) => setSelectedLDAPUsers(e.target.checked ? ldapUsers.map((u: any) => u.dn) : [])} + /> + + 用户名 + 显示名称 + 邮箱 + DN + 操作 + + + + {ldapUsers.length === 0 ? ( + + + 点击「刷新」加载 LDAP 用户 + + + ) : ( + ldapUsers.map((user: any) => ( + + + { + if (e.target.checked) setSelectedLDAPUsers([...selectedLDAPUsers, user.dn]); + else setSelectedLDAPUsers(selectedLDAPUsers.filter((d: string) => d !== user.dn)); + }} + /> + + + {user.uid} + + + {user.cn} + + + {user.mail || '-'} + + + {user.dn} + + + + + + )) + )} + +
+
+
+ )} +
+ )} @@ -813,6 +1469,214 @@ export function SystemManager() {
+ + {/* CSV 批量导入 Dialog */} + !v && setImportDialogOpen(null)}> + + + + + 批量导入用户 + + + 上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。 + + + + {!importResult ? ( + <> +
+
+ + +
+ + {importPreview.length > 0 && ( +
+
+ 预览:{importPreview.length} 条数据 +
+ + + + 用户名 + 昵称 + 邮箱 + 手机 + + + + {importPreview.slice(0, 5).map((u, i) => ( + + {u.username} + {u.nickname} + {u.email} + {u.phone} + + ))} + {importPreview.length > 5 && ( + + + 还有 {importPreview.length - 5} 条... + + + )} + +
+
+ )} +
+ + + + + + + ) : ( +
+
+ {importResult.code === 200 ? ( + + ) : ( + + )} + {importResult.message} +
+ {importResult.errors?.length > 0 && ( +
+ {importResult.errors.map((err: string, i: number) => ( +
{err}
+ ))} +
+ )} + + + +
+ )} +
+
+ + {/* LDAP 导入 Dialog */} + !v && setImportDialogOpen(null)}> + + + + + 从 LDAP 导入用户 + + + 从 LDAP 服务器检索用户列表,选择需要导入的用户 + + + + {!importResult ? ( + <> +
+
+

+ {ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '点击刷新从 LDAP 获取用户列表'} +

+ +
+ + {ldapUsers.length > 0 && ( +
+ + + + + 0} + onChange={(e) => { + if (e.target.checked) { + setLdapSelected(new Set(ldapUsers.map((u) => u.dn))); + } else { + setLdapSelected(new Set()); + } + }} + /> + + 用户名 + 显示名 + 邮箱 + DN + + + + {ldapUsers.map((u) => ( + + + { + const next = new Set(ldapSelected); + if (e.target.checked) next.add(u.dn); + else next.delete(u.dn); + setLdapSelected(next); + }} + /> + + {u.uid} + {u.cn} + {u.mail} + {u.dn} + + ))} + +
+
+ )} +
+ + + + + + + ) : ( +
+
+ {importResult.code === 200 ? ( + + ) : ( + + )} + {importResult.message} +
+ + + +
+ )} +
+
); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 76d7a33..7eac6d4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,6 +1,7 @@ // API Service for AIOps Platform // 有后端支持的模块调用真实 API,无后端的模块暂用 mock 数据 import { http, type ListResponse } from './http'; +import { useUserStore } from '@/store'; import * as mockData from '@/mocks/data'; // ==================== 用户认证 API ==================== @@ -35,6 +36,45 @@ export const userApi = { deleteUser: async (id: number | string) => { return http.delete(`/admin/users/${id}`); }, + batchImport: async (data: { users: any[]; skip_existing?: boolean }) => { + return http.post('/admin/users/batch-import', data); + }, + downloadTemplate: () => { + const token = useUserStore.getState().token; + const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; + window.open(`${baseUrl}/admin/users/import-template?token=${token}`, '_blank'); + }, +}; + +// ==================== LDAP 配置 API ==================== +export const ldapApi = { + getConfig: async () => { + return http.get('/admin/ldap-config'); + }, + updateConfig: async (data: any) => { + return http.put('/admin/ldap-config', data); + }, + testConnection: async (data?: { test_user?: string; test_password?: string }) => { + return http.post('/admin/ldap-config/test', data || {}); + }, + listUsers: async (ouDn?: string) => { + return http.post('/admin/ldap-config/users', { base_dn: ouDn || '' }); + }, + batchImport: async (users: any[]) => { + return http.post('/admin/ldap-config/import', { users }); + }, + listOUs: async () => { + return http.post('/admin/ldap-config/ou-list', {}); + }, + createOU: async (data: { ou_name: string; parent_dn?: string; description?: string }) => { + return http.post('/admin/ldap-config/create-ou', data); + }, + createUser: async (data: { username: string; password: string; display_name?: string; email?: string; ou_dn?: string }) => { + return http.post('/admin/ldap-config/create-user', data); + }, + deleteUser: async (dn: string) => { + return http.post('/admin/ldap-config/delete-user', { dn }); + }, }; // ==================== 仪表盘 API ==================== @@ -347,6 +387,54 @@ export const cloudApi = { }, }; +// ==================== 云平台 API ==================== +export const platformApi = { + list: async () => { + return http.get('/platforms'); + }, + create: async (data: any) => { + return http.post('/platforms', data); + }, + update: async (id: number | string, data: any) => { + return http.put(`/platforms/${id}`, data); + }, + delete: async (id: number | string) => { + return http.delete(`/platforms/${id}`); + }, +}; + +// ==================== 区域管理 API ==================== +export const regionApi = { + list: async () => { + return http.get('/regions'); + }, + create: async (data: any) => { + return http.post('/regions', data); + }, + update: async (id: number | string, data: any) => { + return http.put(`/regions/${id}`, data); + }, + delete: async (id: number | string) => { + return http.delete(`/regions/${id}`); + }, +}; + +// ==================== 资源模型 API ==================== +export const resourceModelApi = { + list: async () => { + return http.get('/resource-models'); + }, + create: async (data: any) => { + return http.post('/resource-models', data); + }, + update: async (id: number | string, data: any) => { + return http.put(`/resource-models/${id}`, data); + }, + delete: async (id: number | string) => { + return http.delete(`/resource-models/${id}`); + }, +}; + // ==================== 审计日志 API ==================== export const auditApi = { getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string }) => { diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts index d6b7e24..e1413bb 100644 --- a/frontend/src/services/http.ts +++ b/frontend/src/services/http.ts @@ -63,7 +63,8 @@ async function request( throw new Error(json.message || '请求失败'); } - return json.data; + // Return json.data for standard responses, or full json for non-standard ones + return (json.data !== undefined ? json.data : json) as T; } // 导出便捷方法