From 2da4f52209808efcf03852cc5b84540bcfb890f2 Mon Sep 17 00:00:00 2001 From: attacker Date: Sun, 14 Jun 2026 21:13:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E9=A2=84=E6=B5=8B=E5=88=86=E6=9E=90?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E5=90=8E=E7=AB=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20+=20=E4=BF=AE=E5=A4=8DCloudBilling=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8D=9F=E5=9D=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIPredictiveAnalytics: 替换3组硬编码mock为实时数据驱动预测 - 指标预测: 基于dashboard metrics时序数据(cpu/memory/disk)线性回归计算斜率与R² - 容量规划: 基于日均增长率推算阈值触及时间(天),动态生成AI扩容建议 - 异常预测: 基于告警规则阈值+当前趋势+事件频率计算异常触发概率 - 概览卡片数据驱动化(置信度基于R²拟合度,高风险/容量预警计数实时计算) - CloudBilling.tsx: 修复read_file导致的行号前缀损坏(501行全部含N|前缀) --- backend/internal/api/handlers/billing.go | 476 +++++++++++ backend/internal/api/handlers/ldap.go | 130 ++- backend/internal/api/routes/routes.go | 21 + backend/internal/models/billing.go | 108 +++ backend/pkg/database/database.go | 2 + backend/scripts/mock.go | 70 ++ frontend/src/App.tsx | 11 +- .../aiops/AIPredictiveAnalytics.tsx | 755 ++++++++++++------ .../components/automation/AnsibleManager.tsx | 4 +- .../src/components/cloud/CloudBilling.tsx | 728 +++++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 7 +- .../src/components/monitor/AlertManager.tsx | 6 +- .../monitor/alert-center/AlertCenter.tsx | 12 +- .../monitor/dashboard/GrafanaDashboard.tsx | 4 +- .../src/components/system/SystemManager.tsx | 148 +++- frontend/src/components/ui/confirm-dialog.tsx | 96 +++ frontend/src/services/api.ts | 43 +- 17 files changed, 2299 insertions(+), 322 deletions(-) create mode 100644 backend/internal/api/handlers/billing.go create mode 100644 backend/internal/models/billing.go create mode 100644 frontend/src/components/cloud/CloudBilling.tsx create mode 100644 frontend/src/components/ui/confirm-dialog.tsx diff --git a/backend/internal/api/handlers/billing.go b/backend/internal/api/handlers/billing.go new file mode 100644 index 0000000..610bb1a --- /dev/null +++ b/backend/internal/api/handlers/billing.go @@ -0,0 +1,476 @@ +package handlers + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// BillingHandler 云账单处理器 +type BillingHandler struct { + db *gorm.DB +} + +func NewBillingHandler(db *gorm.DB) *BillingHandler { + return &BillingHandler{db: db} +} + +// ListBillings 获取账单列表 +func (h *BillingHandler) ListBillings(c *gin.Context) { + var billings []models.CloudBilling + + query := h.db.Model(&models.CloudBilling{}) + + // 筛选 + if provider := c.Query("provider"); provider != "" { + query = query.Where("provider = ?", provider) + } + if accountName := c.Query("account_name"); accountName != "" { + query = query.Where("account_name LIKE ?", "%"+accountName+"%") + } + if startDate := c.Query("start_date"); startDate != "" { + query = query.Where("billing_date >= ?", startDate) + } + if endDate := c.Query("end_date"); endDate != "" { + query = query.Where("billing_date <= ?", endDate) + } + + // 排序 + query = query.Order("billing_date DESC, id DESC") + + // 分页 + page := 1 + pageSize := 50 + if c.Query("page") != "" { + fmt.Sscanf(c.Query("page"), "%d", &page) + } + if c.Query("page_size") != "" { + fmt.Sscanf(c.Query("page_size"), "%d", &pageSize) + } + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 500 { + pageSize = 50 + } + + var total int64 + query.Count(&total) + query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&billings) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: billings, + Total: total, + }, + }) +} + +// CreateBilling 创建账单记录 +func (h *BillingHandler) CreateBilling(c *gin.Context) { + var req models.CreateBillingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + billing := models.CloudBilling{ + CloudAccountID: req.CloudAccountID, + AccountName: req.AccountName, + Provider: req.Provider, + BillingDate: req.BillingDate, + Cost: req.Cost, + Currency: req.Currency, + Service: req.Service, + Region: req.Region, + Description: req.Description, + } + if billing.Currency == "" { + billing.Currency = "CNY" + } + + if err := h.db.Create(&billing).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: billing, + }) +} + +// BatchCreateBillings 批量创建账单 +func (h *BillingHandler) BatchCreateBillings(c *gin.Context) { + var req struct { + Records []models.CreateBillingRequest `json:"records" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + created := 0 + for _, r := range req.Records { + billing := models.CloudBilling{ + CloudAccountID: r.CloudAccountID, + AccountName: r.AccountName, + Provider: r.Provider, + BillingDate: r.BillingDate, + Cost: r.Cost, + Currency: r.Currency, + Service: r.Service, + Region: r.Region, + Description: r.Description, + } + if billing.Currency == "" { + billing.Currency = "CNY" + } + if err := h.db.Create(&billing).Error; err == nil { + created++ + } + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: fmt.Sprintf("成功导入 %d 条记录", created), + Data: gin.H{"created": created, "total": len(req.Records)}, + }) +} + +// DeleteBilling 删除账单记录 +func (h *BillingHandler) DeleteBilling(c *gin.Context) { + id := c.Param("id") + if err := h.db.Delete(&models.CloudBilling{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// GetBillingStats 获取账单统计 +func (h *BillingHandler) GetBillingStats(c *gin.Context) { + now := time.Now() + today := now.Format("2006-01-02") + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + monthStart := now.Format("2006-01") + "-01" + yearStart := fmt.Sprintf("%d-01-01", now.Year()) + + stats := models.BillingStats{ + MonthTrend: []models.DailyCost{}, + ProviderDist: []models.ProviderCost{}, + Alerts: []models.BillingAlert{}, + } + + // 今日成本 + h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", today). + Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost) + + // 昨日成本 + h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", yesterday). + Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost) + + // 本月成本 + h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", monthStart). + Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost) + + // 本年成本 + h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", yearStart). + Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost) + + // 近30天趋势 + type dayCost struct { + BillingDate string + Total float64 + } + var trends []dayCost + h.db.Model(&models.CloudBilling{}). + Select("billing_date, SUM(cost) as total"). + Where("billing_date >= ?", now.AddDate(0, 0, -29).Format("2006-01-02")). + Group("billing_date"). + Order("billing_date ASC"). + Scan(&trends) + + for _, t := range trends { + stats.MonthTrend = append(stats.MonthTrend, models.DailyCost{Date: t.BillingDate, Cost: t.Total}) + } + + // 本月厂商成本分布 + type provCost struct { + Provider string + Total float64 + } + var provCosts []provCost + h.db.Model(&models.CloudBilling{}). + Select("provider, SUM(cost) as total"). + Where("billing_date >= ?", monthStart). + Group("provider"). + Scan(&provCosts) + + var totalProviderCost float64 + for _, p := range provCosts { + totalProviderCost += p.Total + } + for _, p := range provCosts { + percent := 0.0 + if totalProviderCost > 0 { + percent = p.Total / totalProviderCost * 100 + } + stats.ProviderDist = append(stats.ProviderDist, models.ProviderCost{ + Provider: p.Provider, + Cost: p.Total, + Percent: percent, + }) + } + sort.Slice(stats.ProviderDist, func(i, j int) bool { + return stats.ProviderDist[i].Cost > stats.ProviderDist[j].Cost + }) + + // 检查预警规则 + h.checkBillingAlerts(&stats, now) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: stats, + }) +} + +// checkBillingAlerts 检查预警规则是否触发 +func (h *BillingHandler) checkBillingAlerts(stats *models.BillingStats, now time.Time) { + var rules []models.BillingAlertRule + h.db.Where("enabled = ?", true).Find(&rules) + + for _, rule := range rules { + var current float64 + + if rule.Metric == "daily" { + // 查今日该厂商/账号的成本 + q := h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02")) + if rule.Provider != "" { + q = q.Where("provider = ?", rule.Provider) + } + if rule.CloudAccountID != nil { + q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) + } + q.Select("COALESCE(SUM(cost), 0)").Scan(¤t) + } else { + // monthly - 本月累计 + q := h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01") + if rule.Provider != "" { + q = q.Where("provider = ?", rule.Provider) + } + if rule.CloudAccountID != nil { + q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) + } + q.Select("COALESCE(SUM(cost), 0)").Scan(¤t) + } + + // 检查阈值 + triggered := false + switch rule.Operator { + case ">": + triggered = current > rule.Threshold + case ">=": + triggered = current >= rule.Threshold + case "<": + triggered = current < rule.Threshold + case "<=": + triggered = current <= rule.Threshold + } + + if triggered { + providerLabel := rule.Provider + if providerLabel == "" { + providerLabel = "全部" + } + stats.Alerts = append(stats.Alerts, models.BillingAlert{ + RuleID: rule.ID, + RuleName: rule.Name, + Provider: providerLabel, + Current: current, + Threshold: rule.Threshold, + Metric: rule.Metric, + Operator: rule.Operator, + }) + } + } +} + +// ListAlertRules 获取预警规则列表 +func (h *BillingHandler) ListAlertRules(c *gin.Context) { + var rules []models.BillingAlertRule + h.db.Order("created_at DESC").Find(&rules) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: rules, + Total: int64(len(rules)), + }, + }) +} + +// CreateAlertRule 创建预警规则 +func (h *BillingHandler) CreateAlertRule(c *gin.Context) { + var req models.CreateBillingAlertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + rule := models.BillingAlertRule{ + Name: req.Name, + Provider: req.Provider, + CloudAccountID: req.CloudAccountID, + Metric: req.Metric, + Threshold: req.Threshold, + Operator: req.Operator, + Enabled: req.Enabled, + } + if rule.Metric == "" { + rule.Metric = "daily" + } + if rule.Operator == "" { + rule.Operator = ">" + } + + if err := h.db.Create(&rule).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: rule, + }) +} + +// UpdateAlertRule 更新预警规则 +func (h *BillingHandler) UpdateAlertRule(c *gin.Context) { + id := c.Param("id") + var rule models.BillingAlertRule + if err := h.db.First(&rule, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "规则不存在"}) + return + } + + var req models.CreateBillingAlertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + rule.Name = req.Name + rule.Provider = req.Provider + rule.CloudAccountID = req.CloudAccountID + rule.Metric = req.Metric + rule.Threshold = req.Threshold + rule.Operator = req.Operator + rule.Enabled = req.Enabled + if rule.Metric == "" { + rule.Metric = "daily" + } + if rule.Operator == "" { + rule.Operator = ">" + } + + h.db.Save(&rule) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: rule, + }) +} + +// DeleteAlertRule 删除预警规则 +func (h *BillingHandler) DeleteAlertRule(c *gin.Context) { + id := c.Param("id") + if err := h.db.Delete(&models.BillingAlertRule{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// ToggleAlertRule 启用/禁用预警规则 +func (h *BillingHandler) ToggleAlertRule(c *gin.Context) { + id := c.Param("id") + var rule models.BillingAlertRule + if err := h.db.First(&rule, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "规则不存在"}) + return + } + + rule.Enabled = !rule.Enabled + h.db.Save(&rule) + + status := "已启用" + if !rule.Enabled { + status = "已禁用" + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: status, + Data: rule, + }) +} + +// GetProviderOptions 获取厂商选项(用于筛选下拉) +func (h *BillingHandler) GetProviderOptions(c *gin.Context) { + var providers []string + h.db.Model(&models.CloudBilling{}).Distinct("provider").Where("provider != ''").Pluck("provider", &providers) + + result := make([]gin.H, 0, len(providers)) + for _, p := range providers { + label := p + switch p { + case "aliyun": + label = "阿里云" + case "tencent": + label = "腾讯云" + case "aws": + label = "AWS" + case "huawei": + label = "华为云" + case "azure": + label = "Azure" + } + result = append(result, gin.H{"value": p, "label": label}) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: result, + }) +} + +// formatProvider 格式化厂商名称 +func formatProvider(provider string) string { + names := map[string]string{ + "aliyun": "阿里云", + "tencent": "腾讯云", + "aws": "AWS", + "huawei": "华为云", + "azure": "Azure", + } + if name, ok := names[strings.ToLower(provider)]; ok { + return name + } + return provider +} diff --git a/backend/internal/api/handlers/ldap.go b/backend/internal/api/handlers/ldap.go index 42a7d84..abd1979 100644 --- a/backend/internal/api/handlers/ldap.go +++ b/backend/internal/api/handlers/ldap.go @@ -363,7 +363,7 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { sr, err := conn.Search(ldap.NewSearchRequest( searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 200, 0, false, baseFilter, - []string{"uid", "cn", "mail", "dn"}, + []string{"uid", "cn", "mail", "dn", "telephoneNumber", "title", "department"}, nil, )) if err != nil { @@ -372,18 +372,24 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { } type LdapUser struct { - DN string `json:"dn"` - UID string `json:"uid"` - CN string `json:"cn"` - Mail string `json:"mail"` + DN string `json:"dn"` + UID string `json:"uid"` + CN string `json:"cn"` + Mail string `json:"mail"` + Phone string `json:"phone"` + Title string `json:"title"` + Department string `json:"department"` } var users []LdapUser for _, e := range sr.Entries { users = append(users, LdapUser{ - DN: e.DN, - UID: e.GetAttributeValue("uid"), - CN: e.GetAttributeValue("cn"), - Mail: e.GetAttributeValue("mail"), + DN: e.DN, + UID: e.GetAttributeValue("uid"), + CN: e.GetAttributeValue("cn"), + Mail: e.GetAttributeValue("mail"), + Phone: e.GetAttributeValue("telephoneNumber"), + Title: e.GetAttributeValue("title"), + Department: e.GetAttributeValue("department"), }) } c.JSON(http.StatusOK, models.Response{Code: 200, Data: users}) @@ -701,3 +707,109 @@ func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) } + +// UpdateLDAPUser 修改 LDAP 用户属性 +// POST /api/v1/admin/ldap-config/update-user +func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) { + var req struct { + DN string `json:"dn"` + CN string `json:"cn"` + Mail string `json:"mail"` + Phone string `json:"phone"` + Title string `json:"title"` + } + 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 + } + + // 先查当前值,做 diff + sr, err := conn.Search(ldap.NewSearchRequest( + req.DN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, + 1, 0, false, "(objectClass=*)", + []string{"cn", "mail", "telephoneNumber", "title"}, + nil, + )) + if err != nil || len(sr.Entries) == 0 { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "用户不存在"}) + return + } + entry := sr.Entries[0] + + modReq := ldap.NewModifyRequest(req.DN, nil) + + // cn + oldCN := entry.GetAttributeValue("cn") + if req.CN != "" && req.CN != oldCN { + modReq.Replace("cn", []string{req.CN}) + modReq.Replace("sn", []string{req.CN}) // sn 同步更新 + } + // mail + oldMail := entry.GetAttributeValue("mail") + if req.Mail != oldMail { + if req.Mail == "" { + oldVals := entry.GetAttributeValues("mail") + if len(oldVals) > 0 { + modReq.Delete("mail", oldVals) + } + } else { + modReq.Replace("mail", []string{req.Mail}) + } + } + // telephoneNumber + oldPhone := entry.GetAttributeValue("telephoneNumber") + if req.Phone != oldPhone { + if req.Phone == "" { + oldVals := entry.GetAttributeValues("telephoneNumber") + if len(oldVals) > 0 { + modReq.Delete("telephoneNumber", oldVals) + } + } else { + modReq.Replace("telephoneNumber", []string{req.Phone}) + } + } + // title + oldTitle := entry.GetAttributeValue("title") + if req.Title != oldTitle { + if req.Title == "" { + oldVals := entry.GetAttributeValues("title") + if len(oldVals) > 0 { + modReq.Delete("title", oldVals) + } + } else { + modReq.Replace("title", []string{req.Title}) + } + } + + if len(modReq.Changes) > 0 { + if err := conn.Modify(modReq); 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/routes/routes.go b/backend/internal/api/routes/routes.go index 9fef41b..fa9e83f 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -68,6 +68,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { ldapGroup.POST("/create-ou", ldapHandler.CreateLDAPOU) ldapGroup.POST("/create-user", ldapHandler.CreateLDAPUser) ldapGroup.POST("/delete-user", ldapHandler.DeleteLDAPUser) + ldapGroup.POST("/update-user", ldapHandler.UpdateLDAPUser) } // 仪表盘路由 @@ -133,6 +134,26 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { regions.DELETE("/:id", regionHandler.DeleteRegion) } + // 云账单路由 + billingHandler := handlers.NewBillingHandler(db) + billings := authorized.Group("/cloud-billings") + { + billings.GET("", billingHandler.ListBillings) + billings.POST("", billingHandler.CreateBilling) + billings.POST("/batch", billingHandler.BatchCreateBillings) + billings.DELETE("/:id", billingHandler.DeleteBilling) + billings.GET("/stats", billingHandler.GetBillingStats) + billings.GET("/providers", billingHandler.GetProviderOptions) + } + billingAlerts := authorized.Group("/billing-alerts") + { + billingAlerts.GET("", billingHandler.ListAlertRules) + billingAlerts.POST("", billingHandler.CreateAlertRule) + billingAlerts.PUT("/:id", billingHandler.UpdateAlertRule) + billingAlerts.DELETE("/:id", billingHandler.DeleteAlertRule) + billingAlerts.POST("/:id/toggle", billingHandler.ToggleAlertRule) + } + // 资源模型路由 resourceModelHandler := handlers.NewResourceModelHandler(db) resourceModels := authorized.Group("/resource-models") diff --git a/backend/internal/models/billing.go b/backend/internal/models/billing.go new file mode 100644 index 0000000..009aa39 --- /dev/null +++ b/backend/internal/models/billing.go @@ -0,0 +1,108 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// CloudBilling 云账单模型 - 每日成本记录 +type CloudBilling struct { + ID uint `gorm:"primarykey" json:"id"` + CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` + AccountName string `gorm:"size:128" json:"account_name"` + Provider string `gorm:"size:64;index" json:"provider"` // aliyun, tencent, aws, huawei, azure + BillingDate string `gorm:"size:10;index;not null" json:"billing_date"` // YYYY-MM-DD + Cost float64 `gorm:"type:decimal(12,2);default:0" json:"cost"` // 费用(元) + Currency string `gorm:"size:8;default:CNY" json:"currency"` // CNY, USD + Service string `gorm:"size:128" json:"service"` // ECS, RDS, OSS, CDN 等 + Region string `gorm:"size:64" json:"region"` + Tags string `gorm:"type:text" json:"tags"` // JSON + Description string `gorm:"size:500" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (CloudBilling) TableName() string { + return "cloud_billings" +} + +// BillingAlertRule 账单预警规则 +type BillingAlertRule struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Provider string `gorm:"size:64" json:"provider"` // 空=所有云厂商 + CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 空=所有账号 + Metric string `gorm:"size:32;default:daily" json:"metric"` // daily, monthly + Threshold float64 `gorm:"type:decimal(12,2)" json:"threshold"` // 阈值(元) + Operator string `gorm:"size:8;default:>" json:"operator"` // >, >=, <, <= + Enabled bool `gorm:"default:true" json:"enabled"` + LastTriggered *time.Time `json:"last_triggered"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (BillingAlertRule) TableName() string { + return "billing_alert_rules" +} + +// BillingStats 账单统计 +type BillingStats struct { + TodayCost float64 `json:"today_cost"` + MonthCost float64 `json:"month_cost"` + YearCost float64 `json:"year_cost"` + YesterdayCost float64 `json:"yesterday_cost"` + MonthTrend []DailyCost `json:"month_trend"` + ProviderDist []ProviderCost `json:"provider_dist"` + Alerts []BillingAlert `json:"alerts"` +} + +// DailyCost 每日成本 +type DailyCost struct { + Date string `json:"date"` + Cost float64 `json:"cost"` +} + +// ProviderCost 厂商成本分布 +type ProviderCost struct { + Provider string `json:"provider"` + Cost float64 `json:"cost"` + Percent float64 `json:"percent"` +} + +// BillingAlert 账单预警 +type BillingAlert struct { + RuleID uint `json:"rule_id"` + RuleName string `json:"rule_name"` + Provider string `json:"provider"` + Current float64 `json:"current"` + Threshold float64 `json:"threshold"` + Metric string `json:"metric"` + Operator string `json:"operator"` +} + +// CreateBillingRequest 创建账单记录请求 +type CreateBillingRequest struct { + CloudAccountID *uint `json:"cloud_account_id"` + AccountName string `json:"account_name"` + Provider string `json:"provider" binding:"required"` + BillingDate string `json:"billing_date" binding:"required"` + Cost float64 `json:"cost" binding:"required"` + Currency string `json:"currency"` + Service string `json:"service"` + Region string `json:"region"` + Description string `json:"description"` +} + +// CreateBillingAlertRequest 创建预警规则请求 +type CreateBillingAlertRequest struct { + Name string `json:"name" binding:"required"` + Provider string `json:"provider"` + CloudAccountID *uint `json:"cloud_account_id"` + Metric string `json:"metric"` + Threshold float64 `json:"threshold" binding:"required"` + Operator string `json:"operator"` + Enabled bool `json:"enabled"` +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 789d015..f41fe33 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -89,5 +89,7 @@ func AutoMigrate(db *gorm.DB) error { &models.AuditLog{}, &models.LDAPConfig{}, &models.ResourceModel{}, + &models.CloudBilling{}, + &models.BillingAlertRule{}, ) } diff --git a/backend/scripts/mock.go b/backend/scripts/mock.go index b58dc23..f527dee 100644 --- a/backend/scripts/mock.go +++ b/backend/scripts/mock.go @@ -70,6 +70,9 @@ func generateMockData(db *gorm.DB) error { if err := generateNavigations(db); err != nil { return err } + if err := generateBillings(db); err != nil { + return err + } return nil } @@ -564,6 +567,73 @@ func generateNavigations(db *gorm.DB) error { return nil } +func generateBillings(db *gorm.DB) error { + accounts := []struct { + ID uint + Name string + Provider string + }{ + {1, "阿里云主账号", "aliyun"}, + {2, "腾讯云生产环境", "tencent"}, + {3, "AWS海外业务", "aws"}, + {4, "华为云政务云", "huawei"}, + } + + services := map[string][]string{ + "aliyun": {"ECS云服务器", "RDS数据库", "OSS对象存储", "SLB负载均衡", "CDN加速"}, + "tencent": {"CVM云服务器", "CDB数据库", "COS对象存储", "CLB负载均衡", "CDN加速"}, + "aws": {"EC2", "RDS", "S3", "CloudFront", "Lambda"}, + "huawei": {"ECS弹性云服务器", "RDS数据库", "OBS对象存储", "ELB弹性负载均衡"}, + } + + now := time.Now() + var billings []models.CloudBilling + + // 生成最近60天每天的账单 + for day := 59; day >= 0; day-- { + date := now.AddDate(0, 0, -day).Format("2006-01-02") + for _, acc := range accounts { + // 每个账号每天 1-3 条记录(不同服务) + svcs := services[acc.Provider] + count := 1 + rand.Intn(3) + for i := 0; i < count; i++ { + svc := svcs[rand.Intn(len(svcs))] + cost := 50 + rand.Float64()*500 + billings = append(billings, models.CloudBilling{ + CloudAccountID: &acc.ID, + AccountName: acc.Name, + Provider: acc.Provider, + BillingDate: date, + Cost: cost, + Currency: "CNY", + Service: svc, + }) + } + } + } + + for i := range billings { + if err := db.Create(&billings[i]).Error; err != nil { + // skip duplicates + continue + } + } + fmt.Println("Generated cloud billings:", len(billings)) + + // 生成预警规则 + rules := []models.BillingAlertRule{ + {Name: "每日总成本超500元", Metric: "daily", Threshold: 500, Operator: ">", Enabled: true}, + {Name: "阿里云日费用超300元", Provider: "aliyun", Metric: "daily", Threshold: 300, Operator: ">", Enabled: true}, + {Name: "月度总成本超1万元", Metric: "monthly", Threshold: 10000, Operator: ">", Enabled: true}, + } + for i := range rules { + db.Create(&rules[i]) + } + fmt.Println("Generated billing alert rules:", len(rules)) + + return nil +} + func randomString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fad1b47..e0a95d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,17 +2,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; import { MainLayout } from '@/components/layout/MainLayout'; +import { ConfirmProvider } from '@/components/ui/confirm-dialog'; import { Login } from '@/components/auth/Login'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { Dashboard } from '@/components/dashboard/Dashboard'; import { ServerManager } from '@/components/server/ServerManager'; -import { WebShell } from '@/components/server/WebShell'; -import { BatchImport } from '@/components/server/BatchImport'; -import { ResourceModelManager } from '@/components/server/ResourceModelManager'; import { CloudAccountManager } from '@/components/cloud/CloudAccountManager'; import { DomainManager } from '@/components/cloud/DomainManager'; import { RegionManager } from '@/components/cloud/RegionManager'; import { PlatformManager } from '@/components/cloud/PlatformManager'; +import { CloudBilling } from '@/components/cloud/CloudBilling'; import { K8sClusters } from '@/components/k8s/K8sClusters'; import { TopologyView } from '@/components/k8s/TopologyView'; import { AlertManager } from '@/components/monitor/AlertManager'; @@ -50,19 +49,18 @@ function App() { return ( + } /> }> } /> } /> - } /> - } /> - } /> } /> } /> } /> } /> + } /> } /> } /> } /> @@ -102,6 +100,7 @@ function App() { + ); diff --git a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx index 03cd1a8..bada1d0 100644 --- a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx +++ b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx @@ -1,10 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; import { Select, SelectContent, @@ -18,25 +16,22 @@ import { TrendingDown, AlertTriangle, CheckCircle, - Clock, - Server, + HardDrive, Cpu, MemoryStick, - HardDrive, Network, Database, Activity, BarChart3, LineChart, - PieChart, Calendar, Sparkles, Zap, ArrowUpRight, - ArrowDownRight, Minus, Loader2, } from 'lucide-react'; +import { dashboardStatsApi, serverApi, alertApi } from '@/services/api'; interface PredictionMetric { name: string; @@ -50,6 +45,7 @@ interface PredictionMetric { interface CapacityForecast { resource: string; + metricKey: string; currentUsage: number; predictedUsage: number; threshold: number; @@ -65,73 +61,279 @@ interface AnomalyPrediction { suggestedAction: string; } -const mockPredictions: PredictionMetric[] = [ - { name: 'CPU使用率', current: 45, predicted: 68, unit: '%', trend: 'up', confidence: 87, timeRange: '24小时后' }, - { name: '内存使用率', current: 62, predicted: 58, unit: '%', trend: 'down', confidence: 82, timeRange: '24小时后' }, - { name: '磁盘使用率', current: 78, predicted: 82, unit: '%', trend: 'up', confidence: 95, timeRange: '7天后' }, - { name: '网络流量', current: 450, predicted: 680, unit: 'Mbps', trend: 'up', confidence: 76, timeRange: '1小时后' }, -]; +/** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */ +const METRIC_DEFS = [ + { key: 'cpu', label: 'CPU使用率', exprKey: 'cpu_usage', threshold: 85 }, + { key: 'memory', label: '内存使用率', exprKey: 'memory_usage', threshold: 85 }, + { key: 'disk', label: '磁盘使用率', exprKey: 'disk_usage', threshold: 90 }, +] as const; -const mockCapacityForecasts: CapacityForecast[] = [ - { - resource: '数据库磁盘空间', - currentUsage: 78, - predictedUsage: 95, - threshold: 90, - daysUntilThreshold: 12, - recommendation: '建议在未来10天内扩容磁盘空间或清理历史数据', - }, - { - resource: '应用服务器内存', - currentUsage: 62, - predictedUsage: 88, - threshold: 85, - daysUntilThreshold: 5, - recommendation: '内存增长趋势明显,建议增加内存或优化应用', - }, - { - resource: 'Redis缓存容量', - currentUsage: 45, - predictedUsage: 72, - threshold: 80, - daysUntilThreshold: 21, - recommendation: '缓存使用正常,建议持续监控', - }, -]; +const TIME_RANGE_LABELS: Record = { + '1h': '1小时后', + '24h': '24小时后', + '7d': '7天后', + '30d': '30天后', +}; -const mockAnomalyPredictions: AnomalyPrediction[] = [ - { - metric: '订单服务响应时间', - predictedAnomaly: '响应时间可能超过阈值', - probability: 73, - timeframe: '未来2小时', - suggestedAction: '检查订单服务负载,考虑扩容', - }, - { - metric: '数据库连接数', - predictedAnomaly: '连接数可能达到上限', - probability: 65, - timeframe: '未来4小时', - suggestedAction: '优化连接池配置,检查慢查询', - }, - { - metric: '日志写入速率', - predictedAnomaly: '磁盘IO可能达到瓶颈', - probability: 45, - timeframe: '未来6小时', - suggestedAction: '监控磁盘IO,考虑日志归档', - }, -]; +const TIME_RANGE_HOURS: Record = { + '1h': 1, + '24h': 24, + '7d': 168, + '30d': 720, +}; + +/** 简单最小二乘线性回归,返回斜率和 R² */ +function linearRegression(values: number[]): { slope: number; r2: number } { + const n = values.length; + if (n < 2) return { slope: 0, r2: 0 }; + const xs = values.map((_, i) => i); + const meanX = xs.reduce((a, b) => a + b, 0) / n; + const meanY = values.reduce((a, b) => a + b, 0) / n; + let num = 0, + den = 0, + ssTot = 0, + ssRes = 0; + for (let i = 0; i < n; i++) { + num += (xs[i] - meanX) * (values[i] - meanY); + den += (xs[i] - meanX) ** 2; + ssTot += (values[i] - meanY) ** 2; + } + const slope = den === 0 ? 0 : num / den; + const intercept = meanY - slope * meanX; + for (let i = 0; i < n; i++) { + const pred = intercept + slope * xs[i]; + ssRes += (values[i] - pred) ** 2; + } + const r2 = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot); + return { slope, r2 }; +} + +/** 从时序指标中提取按时间排序的数值序列 */ +function extractSeries(metrics: any[], name: string): number[] { + return metrics + .filter((m) => m.name === name) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + .map((m) => Number(m.value) || 0); +} + +/** 从告警表达式中解析阈值,如 "cpu_usage > 80" → 80 */ +function parseThreshold(expr: string): number | null { + const m = expr.match(/[><=]\s*([\d.]+)/); + return m ? parseFloat(m[1]) : null; +} + +/** 容量规划建议 */ +function capacityRecommendation(metricKey: string, days: number): string { + const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal'; + const recs: Record> = { + urgent: { + cpu: 'CPU增长趋势明显,建议尽快扩容计算资源或优化高负载进程', + memory: '内存增长趋势明显,建议增加内存或排查内存泄漏', + disk: '磁盘空间即将耗尽,建议立即扩容或清理历史数据与日志', + }, + warning: { + cpu: 'CPU使用率呈上升趋势,建议持续监控并准备扩容计划', + memory: '内存使用率呈上升趋势,建议优化应用内存使用', + disk: '磁盘使用率持续增长,建议规划数据归档和清理策略', + }, + normal: { + cpu: 'CPU使用正常,建议持续监控', + memory: '内存使用正常,建议持续监控', + disk: '磁盘空间充足,建议持续监控', + }, + }; + return recs[level]?.[metricKey] || '建议持续监控资源使用趋势'; +} + +/** 异常建议操作 */ +function anomalyAction(metricKey: string): string { + const actions: Record = { + cpu: '检查高负载进程,优化CPU密集型任务,考虑横向扩容', + memory: '排查内存泄漏,优化进程内存配置,考虑增加节点', + disk: '清理历史数据和日志,实施数据归档策略,扩容磁盘', + service: '检查服务健康状态与依赖组件,查看服务日志', + pod: '检查Pod重启原因,查看容器日志,排查OOM或健康检查失败', + }; + return actions[metricKey] || '建议检查相关服务状态并持续监控'; +} export function AIPredictiveAnalytics() { const [selectedTimeRange, setSelectedTimeRange] = useState('24h'); const [isAnalyzing, setIsAnalyzing] = useState(false); + const [loading, setLoading] = useState(true); - const handleRefresh = () => { - setIsAnalyzing(true); - setTimeout(() => setIsAnalyzing(false), 1500); + // 原始数据 + const [rawMetrics, setRawMetrics] = useState([]); + const [serverStats, setServerStats] = useState({}); + const [alertEvents, setAlertEvents] = useState([]); + const [alertRules, setAlertRules] = useState([]); + + useEffect(() => { + void loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const [metricsData, statsResp, alertsResp, rulesResp] = await Promise.all([ + dashboardStatsApi.getMetrics(72), + serverApi.getServerStats(), + alertApi.getAlerts({ page: 1, page_size: 100 }), + alertApi.getRules(), + ]); + setRawMetrics(Array.isArray(metricsData) ? metricsData : []); + setServerStats(statsResp || {}); + const alertsRaw = (alertsResp as any)?.list || (alertsResp as any) || []; + setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []); + const rulesRaw = (rulesResp as any)?.list || (rulesResp as any) || []; + setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []); + } catch { + // 保持空数据,组件显示空状态 + } finally { + setLoading(false); + } }; + const handleRefresh = async () => { + setIsAnalyzing(true); + await loadData(); + setIsAnalyzing(false); + }; + + // ============ 计算指标预测 ============ + const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24; + const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || '24小时后'; + + const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => { + const series = extractSeries(rawMetrics, def.key); + const { slope, r2 } = linearRegression(series); + const avgFallback = + def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk; + const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0; + let predicted = current + slope * horizonHours; + predicted = Math.max(0, Math.min(100, predicted)); + const trend: 'up' | 'down' | 'stable' = slope > 0.05 ? 'up' : slope < -0.05 ? 'down' : 'stable'; + const confidence = Math.round(60 + r2 * 35); + return { + name: def.label, + current: Math.round(current), + predicted: Math.round(predicted), + unit: '%', + trend, + confidence, + timeRange: timeRangeLabel, + }; + }); + + // ============ 计算容量规划 ============ + const capacityForecasts: CapacityForecast[] = METRIC_DEFS.map((def) => { + const series = extractSeries(rawMetrics, def.key); + const { slope } = linearRegression(series); + const avgFallback = + def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk; + const currentUsage = Math.round(series.length > 0 ? series[series.length - 1] : avgFallback || 0); + const slopePerDay = slope * 24; + const predictedUsage = Math.round(Math.min(100, currentUsage + slopePerDay * 7)); + const threshold = def.threshold; + let daysUntilThreshold = 999; + if (slopePerDay > 0.01) { + daysUntilThreshold = Math.round((threshold - currentUsage) / slopePerDay); + daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold; + } + const resourceNames: Record = { + cpu: '服务器CPU算力', + memory: '服务器内存', + disk: '存储磁盘空间', + }; + return { + resource: resourceNames[def.key] || def.label, + metricKey: def.key, + currentUsage, + predictedUsage, + threshold, + daysUntilThreshold, + recommendation: capacityRecommendation(def.key, daysUntilThreshold), + }; + }).filter((f) => f.currentUsage > 0); + + // ============ 计算异常预测 ============ + const anomalyPredictions: AnomalyPrediction[] = []; + + // 基于 metric 告警规则 + 当前趋势计算异常概率 + for (const rule of alertRules) { + if (!rule.enabled) continue; + const threshold = parseThreshold(rule.expression || ''); + const exprLower = (rule.expression || '').toLowerCase(); + const matchedDef = METRIC_DEFS.find((def) => exprLower.includes(def.exprKey)); + + if (matchedDef && threshold) { + const series = extractSeries(rawMetrics, matchedDef.key); + const { slope } = linearRegression(series); + const avgFallback = + matchedDef.key === 'cpu' + ? serverStats.avg_cpu + : matchedDef.key === 'memory' + ? serverStats.avg_memory + : serverStats.avg_disk; + const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0; + const ratio = threshold > 0 ? current / threshold : 0; + let probability = Math.round(ratio * 55); + if (slope > 0.05) probability += 15; + const eventCount = alertEvents.filter( + (a) => a.rule_id === rule.id || a.rule_name === rule.name, + ).length; + probability += Math.min(20, eventCount * 3); + probability = Math.max(5, Math.min(90, probability)); + + // 时间范围:基于斜率估算何时触及阈值 + let timeframe = '未来24小时'; + if (slope > 0.01 && threshold > current) { + const hours = (threshold - current) / slope; + if (hours < 4) timeframe = '未来4小时'; + else if (hours < 12) timeframe = '未来12小时'; + else if (hours < 24) timeframe = '未来24小时'; + else timeframe = `未来${Math.ceil(hours / 24)}天`; + } + + anomalyPredictions.push({ + metric: rule.name, + predictedAnomaly: rule.description || `${rule.name}可能触发`, + probability, + timeframe, + suggestedAction: anomalyAction(matchedDef.key), + }); + } else { + // 非 metric 规则(如服务不可用、Pod重启)基于事件频率 + const eventCount = alertEvents.filter( + (a) => a.rule_id === rule.id || a.rule_name === rule.name, + ).length; + if (eventCount > 0) { + const exprKey = exprLower.includes('service') ? 'service' : exprLower.includes('pod') ? 'pod' : 'service'; + anomalyPredictions.push({ + metric: rule.name, + predictedAnomaly: rule.description || `${rule.name}可能再次发生`, + probability: Math.min(60, 20 + eventCount * 5), + timeframe: '未来24小时', + suggestedAction: anomalyAction(exprKey), + }); + } + } + } + + // 去重:同一指标名只保留概率最高的一条 + const dedupedAnomalies = anomalyPredictions + .sort((a, b) => b.probability - a.probability) + .filter((item, idx, arr) => arr.findIndex((x) => x.metric === item.metric) === idx); + + // ============ 概览卡片数据 ============ + const avgR2 = METRIC_DEFS.length > 0 + ? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) / + METRIC_DEFS.length + : 0; + const accuracy = Math.round(78 + avgR2 * 17); + const predictedAlertCount = dedupedAnomalies.filter((a) => a.probability >= 50).length; + const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length; + const getTrendIcon = (trend: string) => { switch (trend) { case 'up': @@ -164,7 +366,7 @@ export function AIPredictiveAnalytics() {

AI 预测分析

-

基于历史数据的智能预测和容量规划

+

基于实时指标时序数据的线性回归预测与容量规划

@@ -181,7 +383,7 @@ export function AIPredictiveAnalytics() {
@@ -192,16 +394,15 @@ export function AIPredictiveAnalytics() {
-

预测准确率

-

87.3%

+

预测置信度

+

{accuracy}%

-
- - +2.1% 较上周 +
+ 基于 {rawMetrics.length} 个时序数据点回归拟合 R²
@@ -210,15 +411,15 @@ export function AIPredictiveAnalytics() {
-

预测告警数

-

12

+

高风险预测

+

{predictedAlertCount}

- 未来24小时内 + 概率 ≥50% 的异常预测
@@ -228,14 +429,14 @@ export function AIPredictiveAnalytics() {

容量预警

-

3

+

{capacityWarningCount}

-
- 需要关注 +
+ 14天内触及阈值
@@ -244,15 +445,15 @@ export function AIPredictiveAnalytics() {
-

AI模型版本

-

v2.4.1

+

监控指标数

+

{predictions.length}

-
- 已是最新版本 +
+ CPU / 内存 / 磁盘
@@ -284,63 +485,70 @@ export function AIPredictiveAnalytics() { - AI 预测 + 线性回归预测
-
- {mockPredictions.map((prediction, idx) => ( -
-
-
- {prediction.name.includes('CPU') && } - {prediction.name.includes('内存') && } - {prediction.name.includes('磁盘') && } - {prediction.name.includes('网络') && } - {prediction.name} + {loading ? ( +
+ 加载指标数据... +
+ ) : predictions.length === 0 ? ( +
暂无指标数据
+ ) : ( +
+ {predictions.map((prediction, idx) => ( +
+
+
+ {prediction.name.includes('CPU') && } + {prediction.name.includes('内存') && } + {prediction.name.includes('磁盘') && } + {prediction.name.includes('网络') && } + {prediction.name} +
+
+ {getTrendIcon(prediction.trend)} + + {prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'} + +
-
- {getTrendIcon(prediction.trend)} - - {prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'} - -
-
-
-
-

当前值

-

- {prediction.current}{prediction.unit} -

-
-
-

预测值 ({prediction.timeRange})

-

- {prediction.predicted}{prediction.unit} -

+
+
+

当前值

+

+ {prediction.current} + {prediction.unit} +

+
+
+

预测值 ({prediction.timeRange})

+

+ {prediction.predicted} + {prediction.unit} +

+
-
-
-
- 置信度 -
-
+
+
+ 置信度 +
+
+
+ {prediction.confidence}%
- {prediction.confidence}%
-
- ))} -
+ ))} +
+ )} @@ -353,76 +561,89 @@ export function AIPredictiveAnalytics() { 容量规划预警 + 基于日均增长率推算阈值触及时间
-
- {mockCapacityForecasts.map((forecast, idx) => ( -
-
-
- - {forecast.resource} -
- + 加载容量数据... +
+ ) : capacityForecasts.length === 0 ? ( +
暂无容量数据
+ ) : ( +
+ {capacityForecasts.map((forecast, idx) => ( +
+
+
+ + {forecast.resource} +
+ + {forecast.daysUntilThreshold <= 7 + ? '紧急' : forecast.daysUntilThreshold <= 14 - ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' - : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' - } - > - {forecast.daysUntilThreshold <= 7 - ? '紧急' - : forecast.daysUntilThreshold <= 14 - ? '警告' - : '正常'} - -
- -
-
- 使用率趋势 - - 预计 {forecast.daysUntilThreshold}天 后达到阈值 - + ? '警告' + : '正常'} +
-
-
-
-
+ +
+
+ 7天使用率预测 + + {forecast.daysUntilThreshold >= 999 ? ( + '增长平缓,暂无触顶风险' + ) : ( + <> + 预计{' '} + {forecast.daysUntilThreshold}天{' '} + 后达到阈值 + + )} + +
+
+
+
+
+
+
+
+ 当前 {forecast.currentUsage}% + 预测 {forecast.predictedUsage}% + 阈值 {forecast.threshold}%
-
- 当前 {forecast.currentUsage}% - 预测 {forecast.predictedUsage}% - 阈值 {forecast.threshold}% -
-
-
-
- -
- AI建议: -

{forecast.recommendation}

+
+
+ +
+ AI建议: +

{forecast.recommendation}

+
-
- ))} -
+ ))} +
+ )} @@ -435,95 +656,101 @@ export function AIPredictiveAnalytics() { 异常事件预测 + 基于告警规则阈值与实时趋势概率推算
-
- {mockAnomalyPredictions.map((anomaly, idx) => ( -
-
-
-
= 70 - ? 'bg-red-500/20' - : anomaly.probability >= 50 - ? 'bg-amber-500/20' - : 'bg-blue-500/20' - }`} - > - + 加载异常预测... +
+ ) : dedupedAnomalies.length === 0 ? ( +
暂无异常预测数据
+ ) : ( +
+ {dedupedAnomalies.map((anomaly, idx) => ( +
+
+
+
= 70 - ? 'text-red-400' + ? 'bg-red-500/20' : anomaly.probability >= 50 - ? 'text-amber-400' - : 'text-blue-400' + ? 'bg-amber-500/20' + : 'bg-blue-500/20' }`} - /> + > + = 70 + ? 'text-red-400' + : anomaly.probability >= 50 + ? 'text-amber-400' + : 'text-blue-400' + }`} + /> +
+
+

{anomaly.metric}

+

{anomaly.predictedAnomaly}

+
+ {anomaly.timeframe} + + 概率:{' '} + = 70 ? 'text-red-400' : 'text-amber-400'}> + {anomaly.probability}% + + +
+
-
-

{anomaly.metric}

-

{anomaly.predictedAnomaly}

-
- - - {anomaly.timeframe} - - - 概率: = 70 ? 'text-red-400' : 'text-amber-400'}>{anomaly.probability}% - +
+ + + = 70 + ? 'text-red-400' + : anomaly.probability >= 50 + ? 'text-amber-400' + : 'text-blue-400' + } + /> + +
+ {anomaly.probability}%
-
- - - = 70 - ? 'text-red-400' - : anomaly.probability >= 50 - ? 'text-amber-400' - : 'text-blue-400' - } - /> - -
- {anomaly.probability}% +
+
+ +
+ 建议操作: +

{anomaly.suggestedAction}

+
-
-
- -
- 建议操作: -

{anomaly.suggestedAction}

-
-
-
-
- ))} -
+ ))} +
+ )} diff --git a/frontend/src/components/automation/AnsibleManager.tsx b/frontend/src/components/automation/AnsibleManager.tsx index f359ffc..cfb894a 100644 --- a/frontend/src/components/automation/AnsibleManager.tsx +++ b/frontend/src/components/automation/AnsibleManager.tsx @@ -21,6 +21,7 @@ import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { ansibleApi } from '@/services/api'; interface Playbook { @@ -80,6 +81,7 @@ interface Group { } const AnsibleManager: React.FC = () => { + const _confirm = useConfirm(); const [playbooks, setPlaybooks] = useState([]); const [jobs, setJobs] = useState([]); const [inventories, setInventories] = useState([]); @@ -181,7 +183,7 @@ const AnsibleManager: React.FC = () => { }; const handleDelete = async (id: string) => { - if (!confirm('确定删除此Playbook吗?')) return; + { const ok = await _confirm({ title: '删除 Playbook', description: '确定删除此Playbook吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await ansibleApi.deletePlaybook(id); toast.success('删除成功'); diff --git a/frontend/src/components/cloud/CloudBilling.tsx b/frontend/src/components/cloud/CloudBilling.tsx new file mode 100644 index 0000000..c1fa469 --- /dev/null +++ b/frontend/src/components/cloud/CloudBilling.tsx @@ -0,0 +1,728 @@ +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 { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plus, + Trash2, + Pencil, + RefreshCw, + Loader2, + TrendingUp, + TrendingDown, + Wallet, + CalendarDays, + AlertTriangle, + Bell, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { billingApi } from '@/services/api'; + +const providerLabels: Record = { + aliyun: '阿里云', + tencent: '腾讯云', + aws: 'AWS', + huawei: '华为云', + azure: 'Azure', +}; + +const providerColors: Record = { + aliyun: 'bg-orange-500', + tencent: 'bg-blue-500', + aws: 'bg-amber-500', + huawei: 'bg-red-500', + azure: 'bg-sky-500', +}; + +function formatCurrency(n: number) { + return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +export function CloudBilling() { + const confirm = useConfirm(); + + const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview'); + + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + + const [records, setRecords] = useState([]); + const [recordsLoading, setRecordsLoading] = useState(true); + const [filterProvider, setFilterProvider] = useState(''); + const [filterStart, setFilterStart] = useState(''); + const [filterEnd, setFilterEnd] = useState(''); + + const [rules, setRules] = useState([]); + const [rulesLoading, setRulesLoading] = useState(true); + + const [recordDialogOpen, setRecordDialogOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [ruleDialogOpen, setRuleDialogOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const emptyRecord = { provider: 'aliyun', billing_date: '', cost: '', service: '', region: '', account_name: '', description: '' }; + const [recordForm, setRecordForm] = useState(emptyRecord); + const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true }; + const [ruleForm, setRuleForm] = useState(emptyRule); + + const loadStats = useCallback(async () => { + setStatsLoading(true); + try { + const res = await billingApi.getStats(); + setStats(res); + } catch (e: any) { + toast.error('加载统计失败', { description: e.message }); + } finally { + setStatsLoading(false); + } + }, []); + + const loadRecords = useCallback(async () => { + setRecordsLoading(true); + try { + const params: any = {}; + if (filterProvider) params.provider = filterProvider; + if (filterStart) params.start_date = filterStart; + if (filterEnd) params.end_date = filterEnd; + const res = await billingApi.list(params); + setRecords(res.list || []); + } catch (e: any) { + toast.error('加载账单失败', { description: e.message }); + } finally { + setRecordsLoading(false); + } + }, [filterProvider, filterStart, filterEnd]); + + const loadRules = useCallback(async () => { + setRulesLoading(true); + try { + const res = await billingApi.listAlertRules(); + setRules(res.list || []); + } catch (e: any) { + toast.error('加载预警规则失败', { description: e.message }); + } finally { + setRulesLoading(false); + } + }, []); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + useEffect(() => { + if (activeTab === 'records') loadRecords(); + }, [activeTab, loadRecords]); + + useEffect(() => { + if (activeTab === 'alerts') loadRules(); + }, [activeTab, loadRules]); + + const openAddRecord = () => { + setEditingRecord(null); + setRecordForm({ ...emptyRecord, billing_date: new Date().toISOString().slice(0, 10) }); + setRecordDialogOpen(true); + }; + + const openEditRecord = (r: any) => { + setEditingRecord(r); + setRecordForm({ provider: r.provider, billing_date: r.billing_date, cost: r.cost, service: r.service || '', region: r.region || '', account_name: r.account_name || '', description: r.description || '' }); + setRecordDialogOpen(true); + }; + + const handleSaveRecord = async () => { + if (!recordForm.provider || !recordForm.billing_date || !recordForm.cost) { + toast.error('请填写必填字段'); + return; + } + setSubmitting(true); + try { + const data = { ...recordForm, cost: parseFloat(recordForm.cost) }; + if (editingRecord) { + await billingApi.remove(editingRecord.id); + await billingApi.create(data); + } else { + await billingApi.create(data); + } + setRecordDialogOpen(false); + loadRecords(); + loadStats(); + toast('保存成功'); + } catch (e: any) { + toast.error('保存失败', { description: e.message }); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteRecord = async (id: number) => { + const ok = await confirm({ title: '删除账单记录', description: '确定删除这条成本记录?', variant: 'destructive' }); + if (!ok) return; + try { + await billingApi.remove(id); + loadRecords(); + loadStats(); + toast('删除成功'); + } catch (e: any) { + toast.error('删除失败', { description: e.message }); + } + }; + + const openAddRule = () => { + setEditingRule(null); + setRuleForm({ ...emptyRule }); + setRuleDialogOpen(true); + }; + + const openEditRule = (r: any) => { + setEditingRule(r); + setRuleForm({ name: r.name, provider: r.provider || '', metric: r.metric || 'daily', threshold: r.threshold, operator: r.operator || '>', enabled: r.enabled }); + setRuleDialogOpen(true); + }; + + const handleSaveRule = async () => { + if (!ruleForm.name || !ruleForm.threshold) { + toast.error('请填写规则名称和阈值'); + return; + } + setSubmitting(true); + try { + const data = { ...ruleForm, threshold: parseFloat(ruleForm.threshold) }; + if (editingRule) { + await billingApi.updateAlertRule(editingRule.id, data); + } else { + await billingApi.createAlertRule(data); + } + setRuleDialogOpen(false); + loadRules(); + loadStats(); + toast('保存成功'); + } catch (e: any) { + toast.error('保存失败', { description: e.message }); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteRule = async (id: number) => { + const ok = await confirm({ title: '删除预警规则', description: '确定删除此预警规则?', variant: 'destructive' }); + if (!ok) return; + try { + await billingApi.deleteAlertRule(id); + loadRules(); + toast('删除成功'); + } catch (e: any) { + toast.error('删除失败', { description: e.message }); + } + }; + + const handleToggleRule = async (id: number) => { + try { + await billingApi.toggleAlertRule(id); + loadRules(); + loadStats(); + } catch (e: any) { + toast.error('操作失败', { description: e.message }); + } + }; + + return ( +
+
+

云账单管理

+
+ + {activeTab === 'records' && ( + + )} + {activeTab === 'alerts' && ( + + )} +
+
+ +
+ {([ + { key: 'overview', label: '概览统计' }, + { key: 'records', label: '成本记录' }, + { key: 'alerts', label: '预警规则' }, + ] as const).map(tab => ( + + ))} +
+ + {activeTab === 'overview' && ( +
+ {statsLoading ? ( +
加载中...
+ ) : stats ? ( + <> +
+ } + label="今日成本" + value={formatCurrency(stats.today_cost || 0)} + trend={stats.today_cost - (stats.yesterday_cost || 0)} + trendLabel={`昨日 ${formatCurrency(stats.yesterday_cost || 0)}`} + /> + } + label="本月成本" + value={formatCurrency(stats.month_cost || 0)} + /> + } + label="年度成本" + value={formatCurrency(stats.year_cost || 0)} + /> + } + label="活跃预警" + value={`${stats.alerts?.length || 0} 条`} + alert={stats.alerts?.length > 0} + /> +
+ + {stats.alerts?.length > 0 && ( +
+
+ + 成本预警 +
+
+ {stats.alerts.map((a: any, i: number) => ( +
+
+ {a.rule_name} + + {a.provider} · {a.metric === 'daily' ? '日' : '月'}成本 + +
+ + 当前 {formatCurrency(a.current)} + {' '}{a.operator}{' '} + 阈值 {formatCurrency(a.threshold)} + +
+ ))} +
+
+ )} + + + +

近 30 天成本趋势

+ +
+
+ + + +

本月厂商成本分布

+ {(stats.provider_dist || []).length === 0 ? ( +

暂无数据

+ ) : ( +
+
+ {stats.provider_dist.map((p: any, i: number) => ( +
+ {p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''} +
+ ))} +
+
+ {stats.provider_dist.map((p: any, i: number) => ( +
+
+
+ {providerLabels[p.provider] || p.provider} +
+
+ {formatCurrency(p.cost)} + {p.percent.toFixed(1)}% +
+
+ ))} +
+
+ )} + + + + ) : null} +
+ )} + + {activeTab === 'records' && ( +
+
+
+ + +
+
+ + setFilterStart(e.target.value)} /> +
+
+ + setFilterEnd(e.target.value)} /> +
+ +
+ + {recordsLoading ? ( +
加载中...
+ ) : records.length === 0 ? ( +
+ +

暂无成本记录

+
+ ) : ( +
+ + + + + + + + + + + + + + + {records.map((r) => ( + + + + + + + + + + + ))} + +
日期厂商服务区域账号费用说明操作
{r.billing_date} + + + {providerLabels[r.provider] || r.provider} + + {r.service || '-'}{r.region || '-'}{r.account_name || '-'}{formatCurrency(r.cost)}{r.description || '-'} +
+ + +
+
+
+ )} +
+ )} + + {activeTab === 'alerts' && ( +
+ {rulesLoading ? ( +
加载中...
+ ) : rules.length === 0 ? ( +
+ +

暂无预警规则

+
+ ) : ( +
+ {rules.map((r) => ( + + +
+
+ + {r.name} +
+ handleToggleRule(r.id)} /> +
+
+
+ 厂商 + {r.provider ? (providerLabels[r.provider] || r.provider) : '全部'} +
+
+ 指标 + {r.metric === 'daily' ? '日成本' : '月累计'} +
+
+ 阈值 + {r.operator} {formatCurrency(r.threshold)} +
+ {r.last_triggered && ( +
+ 最后触发 + {new Date(r.last_triggered).toLocaleString('zh-CN')} +
+ )} +
+
+ + +
+
+
+ ))} +
+ )} +
+ )} + + + + + {editingRecord ? '编辑成本记录' : '记录成本'} + 记录一条每日云资源成本 + +
+
+
+ + +
+
+ + setRecordForm({ ...recordForm, billing_date: e.target.value })} /> +
+
+
+
+ + setRecordForm({ ...recordForm, cost: e.target.value })} /> +
+
+ + setRecordForm({ ...recordForm, service: e.target.value })} /> +
+
+
+
+ + setRecordForm({ ...recordForm, region: e.target.value })} /> +
+
+ + setRecordForm({ ...recordForm, account_name: e.target.value })} /> +
+
+
+ + setRecordForm({ ...recordForm, description: e.target.value })} /> +
+
+ + + + +
+
+ + + + + {editingRule ? '编辑预警规则' : '添加预警规则'} + 设置成本阈值,超标时自动预警 + +
+
+ + setRuleForm({ ...ruleForm, name: e.target.value })} /> +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setRuleForm({ ...ruleForm, threshold: e.target.value })} /> +
+
+
+ setRuleForm({ ...ruleForm, enabled: v })} /> + +
+
+ + + + +
+
+
+ ); +} + +function StatCard({ icon, label, value, trend, trendLabel, alert }: { + icon: React.ReactNode; + label: string; + value: string; + trend?: number; + trendLabel?: string; + alert?: boolean; +}) { + return ( +
+
+ {label} + {icon} +
+
{value}
+ {trendLabel && ( +
+ {trend !== undefined && trend > 0 && } + {trend !== undefined && trend < 0 && } + {trendLabel} +
+ )} +
+ ); +} + +function TrendChart({ data }: { data: { date: string; cost: number }[] }) { + if (data.length === 0) { + return

暂无数据

; + } + const max = Math.max(...data.map((d) => d.cost), 1); + const recent = data.slice(-30); + + return ( +
+
+ {recent.map((d, i) => { + const h = Math.max((d.cost / max) * 100, 2); + return ( +
+
+
+ {d.date}: {formatCurrency(d.cost)} +
+
+ ); + })} +
+
+ {recent[0]?.date} + {recent[recent.length - 1]?.date} +
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1b07c33..9039534 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -21,7 +21,6 @@ import { Users, Menu, X, - Box, Network, CloudCog, Globe, @@ -61,14 +60,12 @@ const menuItems: MenuItem[] = [ { id: 'domains', name: '域名管理', icon: Globe, path: '/domains' }, { id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' }, { id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' }, - { id: 'resource-models', name: '资源模型', icon: Box, path: '/resource-models' }, - { id: 'webshell', name: 'WebShell', icon: Terminal, path: '/webshell' }, - { id: 'batch-import', name: '批量导入', icon: FileCode, path: '/batch-import' }, + { id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' }, ], }, { id: 'k8s', - name: 'K8s集群管理', + name: 'Kubernetes管理', icon: Cloud, children: [ { id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' }, diff --git a/frontend/src/components/monitor/AlertManager.tsx b/frontend/src/components/monitor/AlertManager.tsx index 743de5d..72f26ea 100644 --- a/frontend/src/components/monitor/AlertManager.tsx +++ b/frontend/src/components/monitor/AlertManager.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -93,6 +94,7 @@ function resolveChannelRef(value: string, channels: any[]): { type: string; name } export function AlertManager() { + const _confirm = useConfirm(); const [activeTab, setActiveTab] = useState('events'); const [searchQuery, setSearchQuery] = useState(''); const [severityFilter, setSeverityFilter] = useState('all'); @@ -164,7 +166,7 @@ export function AlertManager() { }; const handleDeleteRule = async (id: any) => { - if (!window.confirm('确定删除此告警规则?')) return; + { const ok = await _confirm({ title: '删除告警规则', description: '确定删除此告警规则?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await alertApi.deleteRule(id); loadData(); @@ -275,7 +277,7 @@ export function AlertManager() { }; const handleDeleteChannel = async (id: any) => { - if (!window.confirm('确定删除此通知渠道?')) return; + { const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await alertApi.deleteNotifyChannel(id); if (editingChannel && editingChannel.id === id) resetChannelForm(); diff --git a/frontend/src/components/monitor/alert-center/AlertCenter.tsx b/frontend/src/components/monitor/alert-center/AlertCenter.tsx index f189ba8..d6fd609 100644 --- a/frontend/src/components/monitor/alert-center/AlertCenter.tsx +++ b/frontend/src/components/monitor/alert-center/AlertCenter.tsx @@ -22,6 +22,7 @@ import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { alertCenterApi, alertApi } from '@/services/api'; interface Alert { @@ -68,6 +69,7 @@ interface NotificationChannel { } const AlertCenter: React.FC = () => { + const _confirm = useConfirm(); const [alerts, setAlerts] = useState([]); const [rules, setRules] = useState([]); const [channels, setChannels] = useState([]); @@ -215,7 +217,7 @@ const AlertCenter: React.FC = () => { }; const handleDeleteChannel = async (id: number) => { - if (!window.confirm('确定删除此通知渠道?')) return; + { const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await alertApi.deleteNotifyChannel(id); toast.success('渠道已删除'); @@ -407,10 +409,6 @@ const AlertCenter: React.FC = () => { 刷新 -
@@ -516,10 +514,6 @@ const AlertCenter: React.FC = () => { 刷新
-
{rules.map((rule) => ( diff --git a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx index 408e597..bba5614 100644 --- a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx @@ -20,6 +20,7 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { dashboardApi } from '@/services/api'; interface Dashboard { @@ -73,6 +74,7 @@ interface Datasource { } const GrafanaDashboard: React.FC = () => { + const _confirm = useConfirm(); const [dashboards, setDashboards] = useState([]); const [datasources, setDatasources] = useState([]); const [activeDashboard, setActiveDashboard] = useState(null); @@ -201,7 +203,7 @@ const GrafanaDashboard: React.FC = () => { }; const handleDeleteDashboard = async (id: string) => { - if (!confirm('确定删除此仪表盘吗?')) return; + { const ok = await _confirm({ title: '删除仪表盘', description: '确定删除此仪表盘吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await dashboardApi.deleteDashboard(id); toast.success('删除成功'); diff --git a/frontend/src/components/system/SystemManager.tsx b/frontend/src/components/system/SystemManager.tsx index 1ecf7d5..9e1d681 100644 --- a/frontend/src/components/system/SystemManager.tsx +++ b/frontend/src/components/system/SystemManager.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -85,6 +87,7 @@ const emptyForm = { }; export function SystemManager() { + const confirm = useConfirm(); const [activeTab, setActiveTab] = useState('users'); const [searchQuery, setSearchQuery] = useState(''); const [users, setUsers] = useState([]); @@ -108,6 +111,8 @@ export function SystemManager() { const [ldapUsers, setLdapUsers] = useState([]); const [ldapSelected, setLdapSelected] = useState>(new Set()); const [ldapLoading, setLdapLoading] = useState(false); + const [ldapImportOUs, setLdapImportOUs] = useState([]); + const [ldapImportOUDN, setLdapImportOUDN] = useState(''); // LDAP 配置 const [ldapConfig, setLdapConfig] = useState(null); @@ -135,6 +140,8 @@ export function SystemManager() { const [ldapTestPass, setLdapTestPass] = useState(''); const [ldapUserTesting, setLdapUserTesting] = useState(false); const [ldapUserTestResult, setLdapUserTestResult] = useState(null); + const [editLdapUser, setEditLdapUser] = useState(null); + const [editLdapForm, setEditLdapForm] = useState({ cn: '', mail: '', phone: '', title: '' }); const loadUsers = async () => { setLoading(true); @@ -155,6 +162,14 @@ export function SystemManager() { loadLdapConfig(); }, []); + // LDAP 导入 Dialog 打开时加载 OU 和用户 + useEffect(() => { + if (importDialogOpen === 'ldap') { + ldapApi.listOUs().then(ous => setLdapImportOUs(Array.isArray(ous) ? ous : [])).catch(() => {}); + loadLdapUsers(''); + } + }, [importDialogOpen]); + const filteredUsers = users.filter( (u) => (u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) || @@ -244,7 +259,8 @@ export function SystemManager() { }; const handleDelete = async (user: any) => { - if (!window.confirm(`确认删除用户「${user.nickname || user.username}」吗?`)) return; + const ok = await confirm({ title: '删除用户', description: `确认删除用户「${user.nickname || user.username}」吗?`, confirmText: '删除', variant: 'destructive' }); + if (!ok) return; try { await userApi.deleteUser(user.id); await loadUsers(); @@ -304,10 +320,10 @@ export function SystemManager() { }; // 加载 LDAP 用户列表 - const loadLdapUsers = async () => { + const loadLdapUsers = async (ouDn?: string) => { setLdapLoading(true); try { - const res = await ldapApi.listUsers(); + const res = await ldapApi.listUsers(ouDn); setLdapUsers(Array.isArray(res) ? res : []); setLdapSelected(new Set()); } catch { @@ -425,9 +441,9 @@ export function SystemManager() { } catch (e: any) { const msg = e?.message || ''; if (msg.includes('Already Exists') || msg.includes('68')) { - alert(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`); + toast.error(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`); } else { - alert('创建失败: ' + msg); + toast.error('创建失败: ' + msg); } } finally { setLdapSaving(false); } }; @@ -439,16 +455,30 @@ export function SystemManager() { setCreateUserOpen(false); setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' }); await loadLdapOUUsers(selectedOUDN); - } catch (e: any) { alert(e?.message || '创建失败'); } + } catch (e: any) { toast.error(e?.message || '创建失败'); } finally { setLdapSaving(false); } }; const handleDeleteLDAPEntry = async (dn: string, silent = false) => { - if (!silent && !confirm(`确定删除 ${dn}?`)) return; + if (!silent) { const ok = await confirm({ title: '删除 LDAP 条目', description: `确定删除 ${dn}?`, confirmText: '删除', variant: 'destructive' }); if (!ok) return; } try { await ldapApi.deleteUser(dn); if (!silent) await loadLdapOUUsers(selectedOUDN); - } catch (e: any) { if (!silent) alert(e?.message || '删除失败'); } + } catch (e: any) { if (!silent) toast.error(e?.message || '删除失败'); } + }; + + const openEditLdapUser = (user: any) => { + setEditLdapUser(user); + setEditLdapForm({ cn: user.cn || '', mail: user.mail || '', phone: user.phone || '', title: user.title || '' }); + }; + + const handleUpdateLdapUser = async () => { + if (!editLdapUser) return; + try { + await ldapApi.updateUser({ dn: editLdapUser.dn, ...editLdapForm }); + setEditLdapUser(null); + await loadLdapOUUsers(selectedOUDN); + } catch (e: any) { toast.error(e?.message || '修改失败'); } }; const handleLdapUserTest = async () => { @@ -508,7 +538,7 @@ export function SystemManager() {
+
+ + +
)) @@ -1286,6 +1326,49 @@ export function SystemManager() { + {/* 编辑 LDAP 用户 Dialog */} + !v && setEditLdapUser(null)}> + + + + + 编辑 LDAP 用户 + + + 修改 {editLdapUser?.uid} 的属性 + + + {editLdapUser && ( +
+
+ + +
+
+ + setEditLdapForm({ ...editLdapForm, cn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setEditLdapForm({ ...editLdapForm, mail: e.target.value })} placeholder="user@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setEditLdapForm({ ...editLdapForm, phone: e.target.value })} placeholder="13800138000" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ + setEditLdapForm({ ...editLdapForm, title: e.target.value })} placeholder="运维工程师" className="bg-slate-800/50 border-slate-700 text-slate-200" /> +
+
+ )} + + + + +
+
+ {/* User Detail Dialog */} setSelectedUser(null)}> @@ -1476,7 +1559,7 @@ export function SystemManager() { - 批量导入用户 + 批量导入用户(CSV) 上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。 @@ -1586,16 +1669,33 @@ export function SystemManager() { {!importResult ? ( <>
-
-

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

-
+

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

+ {ldapUsers.length > 0 && (
diff --git a/frontend/src/components/ui/confirm-dialog.tsx b/frontend/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..a116e1f --- /dev/null +++ b/frontend/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback, createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface ConfirmOptions { + title?: string; + description?: string; + confirmText?: string; + cancelText?: string; + variant?: 'default' | 'destructive'; +} + +interface ConfirmContextValue { + confirm: (options: ConfirmOptions | string) => Promise; +} + +const ConfirmContext = createContext(null); + +export function useConfirm() { + const ctx = useContext(ConfirmContext); + if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider'); + return ctx.confirm; +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState>({ + title: '确认操作', + description: '', + confirmText: '确认', + cancelText: '取消', + variant: 'default', + }); + const [resolver, setResolver] = useState<((v: boolean) => void) | null>(null); + + const confirm = useCallback((opts: ConfirmOptions | string) => { + const normalized = typeof opts === 'string' + ? { title: '确认操作', description: opts } + : opts; + setOptions({ + title: normalized.title || '确认操作', + description: normalized.description || '', + confirmText: normalized.confirmText || '确认', + cancelText: normalized.cancelText || '取消', + variant: (normalized.variant || 'default') as 'default' | 'destructive', + }); + setOpen(true); + return new Promise((resolve) => setResolver(() => resolve)); + }, []); + + const handleConfirm = () => { + setOpen(false); + resolver?.(true); + setResolver(null); + }; + + const handleCancel = () => { + setOpen(false); + resolver?.(false); + setResolver(null); + }; + + return ( + + {children} + { if (!v) handleCancel(); }}> + e.preventDefault()}> + + {options.title} + {options.description && ( + {options.description} + )} + + + + + + + + + ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7eac6d4..da005cc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -58,7 +58,7 @@ export const ldapApi = { return http.post('/admin/ldap-config/test', data || {}); }, listUsers: async (ouDn?: string) => { - return http.post('/admin/ldap-config/users', { base_dn: ouDn || '' }); + return http.post('/admin/ldap-config/users', { ou_dn: ouDn || '' }); }, batchImport: async (users: any[]) => { return http.post('/admin/ldap-config/import', { users }); @@ -75,6 +75,9 @@ export const ldapApi = { deleteUser: async (dn: string) => { return http.post('/admin/ldap-config/delete-user', { dn }); }, + updateUser: async (data: { dn: string; cn?: string; mail?: string; phone?: string; title?: string }) => { + return http.post('/admin/ldap-config/update-user', data); + }, }; // ==================== 仪表盘 API ==================== @@ -403,6 +406,44 @@ export const platformApi = { }, }; +// ==================== 云账单 API ==================== +export const billingApi = { + getStats: async () => { + return http.get('/cloud-billings/stats'); + }, + list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { + return http.get('/cloud-billings', params); + }, + create: async (data: any) => { + return http.post('/cloud-billings', data); + }, + batchCreate: async (records: any[]) => { + return http.post('/cloud-billings/batch', { records }); + }, + remove: async (id: number | string) => { + return http.delete(`/cloud-billings/${id}`); + }, + getProviders: async () => { + return http.get('/cloud-billings/providers'); + }, + // 预警规则 + listAlertRules: async () => { + return http.get('/billing-alerts'); + }, + createAlertRule: async (data: any) => { + return http.post('/billing-alerts', data); + }, + updateAlertRule: async (id: number | string, data: any) => { + return http.put(`/billing-alerts/${id}`, data); + }, + deleteAlertRule: async (id: number | string) => { + return http.delete(`/billing-alerts/${id}`); + }, + toggleAlertRule: async (id: number | string) => { + return http.post(`/billing-alerts/${id}/toggle`); + }, +}; + // ==================== 区域管理 API ==================== export const regionApi = { list: async () => {