feat: AI预测分析接入真实后端数据 + 修复CloudBilling文件损坏

- AIPredictiveAnalytics: 替换3组硬编码mock为实时数据驱动预测
  - 指标预测: 基于dashboard metrics时序数据(cpu/memory/disk)线性回归计算斜率与R²
  - 容量规划: 基于日均增长率推算阈值触及时间(天),动态生成AI扩容建议
  - 异常预测: 基于告警规则阈值+当前趋势+事件频率计算异常触发概率
  - 概览卡片数据驱动化(置信度基于R²拟合度,高风险/容量预警计数实时计算)
- CloudBilling.tsx: 修复read_file导致的行号前缀损坏(501行全部含N|前缀)
This commit is contained in:
attacker committed 2026-06-14 21:13:49 +08:00
1 parent 0ebe22d142
commit 2da4f52209
17 files changed
+2299 -322

No files matched your search

+476
View File
@@ -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(&current)
} 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(&current)
}
// 检查阈值
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
}
+121 -9
View File
@@ -363,7 +363,7 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) {
sr, err := conn.Search(ldap.NewSearchRequest( sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, baseFilter, 200, 0, false, baseFilter,
[]string{"uid", "cn", "mail", "dn"}, []string{"uid", "cn", "mail", "dn", "telephoneNumber", "title", "department"},
nil, nil,
)) ))
if err != nil { if err != nil {
@@ -372,18 +372,24 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) {
} }
type LdapUser struct { type LdapUser struct {
DN string `json:"dn"` DN string `json:"dn"`
UID string `json:"uid"` UID string `json:"uid"`
CN string `json:"cn"` CN string `json:"cn"`
Mail string `json:"mail"` Mail string `json:"mail"`
Phone string `json:"phone"`
Title string `json:"title"`
Department string `json:"department"`
} }
var users []LdapUser var users []LdapUser
for _, e := range sr.Entries { for _, e := range sr.Entries {
users = append(users, LdapUser{ users = append(users, LdapUser{
DN: e.DN, DN: e.DN,
UID: e.GetAttributeValue("uid"), UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"), CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"), 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}) 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: "删除成功"}) 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: "修改成功"})
}
+21
View File
@@ -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-ou", ldapHandler.CreateLDAPOU)
ldapGroup.POST("/create-user", ldapHandler.CreateLDAPUser) ldapGroup.POST("/create-user", ldapHandler.CreateLDAPUser)
ldapGroup.POST("/delete-user", ldapHandler.DeleteLDAPUser) 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) 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) resourceModelHandler := handlers.NewResourceModelHandler(db)
resourceModels := authorized.Group("/resource-models") resourceModels := authorized.Group("/resource-models")
+108
View File
@@ -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"`
}
+2
View File
@@ -89,5 +89,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.AuditLog{}, &models.AuditLog{},
&models.LDAPConfig{}, &models.LDAPConfig{},
&models.ResourceModel{}, &models.ResourceModel{},
&models.CloudBilling{},
&models.BillingAlertRule{},
) )
} }
+70
View File
@@ -70,6 +70,9 @@ func generateMockData(db *gorm.DB) error {
if err := generateNavigations(db); err != nil { if err := generateNavigations(db); err != nil {
return err return err
} }
if err := generateBillings(db); err != nil {
return err
}
return nil return nil
} }
@@ -564,6 +567,73 @@ func generateNavigations(db *gorm.DB) error {
return nil 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 { func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length) b := make([]byte, length)
+5 -6
View File
@@ -2,17 +2,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ConfirmProvider } from '@/components/ui/confirm-dialog';
import { Login } from '@/components/auth/Login'; import { Login } from '@/components/auth/Login';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { Dashboard } from '@/components/dashboard/Dashboard'; import { Dashboard } from '@/components/dashboard/Dashboard';
import { ServerManager } from '@/components/server/ServerManager'; 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 { CloudAccountManager } from '@/components/cloud/CloudAccountManager';
import { DomainManager } from '@/components/cloud/DomainManager'; import { DomainManager } from '@/components/cloud/DomainManager';
import { RegionManager } from '@/components/cloud/RegionManager'; import { RegionManager } from '@/components/cloud/RegionManager';
import { PlatformManager } from '@/components/cloud/PlatformManager'; import { PlatformManager } from '@/components/cloud/PlatformManager';
import { CloudBilling } from '@/components/cloud/CloudBilling';
import { K8sClusters } from '@/components/k8s/K8sClusters'; import { K8sClusters } from '@/components/k8s/K8sClusters';
import { TopologyView } from '@/components/k8s/TopologyView'; import { TopologyView } from '@/components/k8s/TopologyView';
import { AlertManager } from '@/components/monitor/AlertManager'; import { AlertManager } from '@/components/monitor/AlertManager';
@@ -50,19 +49,18 @@ function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfirmProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}> <Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="servers" element={<ServerManager />} /> <Route path="servers" element={<ServerManager />} />
<Route path="webshell" element={<WebShell />} />
<Route path="batch-import" element={<BatchImport />} />
<Route path="resource-models" element={<ResourceModelManager />} />
<Route path="cloud-accounts" element={<CloudAccountManager />} /> <Route path="cloud-accounts" element={<CloudAccountManager />} />
<Route path="domains" element={<DomainManager />} /> <Route path="domains" element={<DomainManager />} />
<Route path="regions" element={<RegionManager />} /> <Route path="regions" element={<RegionManager />} />
<Route path="platforms" element={<PlatformManager />} /> <Route path="platforms" element={<PlatformManager />} />
<Route path="cloud-billing" element={<CloudBilling />} />
<Route path="clusters" element={<K8sClusters />} /> <Route path="clusters" element={<K8sClusters />} />
<Route path="nodes" element={<K8sClusters />} /> <Route path="nodes" element={<K8sClusters />} />
<Route path="workloads" element={<K8sClusters />} /> <Route path="workloads" element={<K8sClusters />} />
@@ -102,6 +100,7 @@ function App() {
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ConfirmProvider>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
); );
@@ -1,10 +1,8 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,25 +16,22 @@ import {
TrendingDown, TrendingDown,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
Clock, HardDrive,
Server,
Cpu, Cpu,
MemoryStick, MemoryStick,
HardDrive,
Network, Network,
Database, Database,
Activity, Activity,
BarChart3, BarChart3,
LineChart, LineChart,
PieChart,
Calendar, Calendar,
Sparkles, Sparkles,
Zap, Zap,
ArrowUpRight, ArrowUpRight,
ArrowDownRight,
Minus, Minus,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { dashboardStatsApi, serverApi, alertApi } from '@/services/api';
interface PredictionMetric { interface PredictionMetric {
name: string; name: string;
@@ -50,6 +45,7 @@ interface PredictionMetric {
interface CapacityForecast { interface CapacityForecast {
resource: string; resource: string;
metricKey: string;
currentUsage: number; currentUsage: number;
predictedUsage: number; predictedUsage: number;
threshold: number; threshold: number;
@@ -65,73 +61,279 @@ interface AnomalyPrediction {
suggestedAction: string; suggestedAction: string;
} }
const mockPredictions: PredictionMetric[] = [ /** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */
{ name: 'CPU使用率', current: 45, predicted: 68, unit: '%', trend: 'up', confidence: 87, timeRange: '24小时后' }, const METRIC_DEFS = [
{ name: '内存使用率', current: 62, predicted: 58, unit: '%', trend: 'down', confidence: 82, timeRange: '24小时后' }, { key: 'cpu', label: 'CPU使用率', exprKey: 'cpu_usage', threshold: 85 },
{ name: '磁盘使用率', current: 78, predicted: 82, unit: '%', trend: 'up', confidence: 95, timeRange: '7天后' }, { key: 'memory', label: '内存使用率', exprKey: 'memory_usage', threshold: 85 },
{ name: '网络流量', current: 450, predicted: 680, unit: 'Mbps', trend: 'up', confidence: 76, timeRange: '1小时后' }, { key: 'disk', label: '磁盘使用率', exprKey: 'disk_usage', threshold: 90 },
]; ] as const;
const mockCapacityForecasts: CapacityForecast[] = [ const TIME_RANGE_LABELS: Record<string, string> = {
{ '1h': '1小时后',
resource: '数据库磁盘空间', '24h': '24小时后',
currentUsage: 78, '7d': '7天后',
predictedUsage: 95, '30d': '30天后',
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 mockAnomalyPredictions: AnomalyPrediction[] = [ const TIME_RANGE_HOURS: Record<string, number> = {
{ '1h': 1,
metric: '订单服务响应时间', '24h': 24,
predictedAnomaly: '响应时间可能超过阈值', '7d': 168,
probability: 73, '30d': 720,
timeframe: '未来2小时', };
suggestedAction: '检查订单服务负载,考虑扩容',
}, /** 简单最小二乘线性回归,返回斜率和 R² */
{ function linearRegression(values: number[]): { slope: number; r2: number } {
metric: '数据库连接数', const n = values.length;
predictedAnomaly: '连接数可能达到上限', if (n < 2) return { slope: 0, r2: 0 };
probability: 65, const xs = values.map((_, i) => i);
timeframe: '未来4小时', const meanX = xs.reduce((a, b) => a + b, 0) / n;
suggestedAction: '优化连接池配置,检查慢查询', const meanY = values.reduce((a, b) => a + b, 0) / n;
}, let num = 0,
{ den = 0,
metric: '日志写入速率', ssTot = 0,
predictedAnomaly: '磁盘IO可能达到瓶颈', ssRes = 0;
probability: 45, for (let i = 0; i < n; i++) {
timeframe: '未来6小时', num += (xs[i] - meanX) * (values[i] - meanY);
suggestedAction: '监控磁盘IO,考虑日志归档', 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<string, Record<string, string>> = {
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<string, string> = {
cpu: '检查高负载进程,优化CPU密集型任务,考虑横向扩容',
memory: '排查内存泄漏,优化进程内存配置,考虑增加节点',
disk: '清理历史数据和日志,实施数据归档策略,扩容磁盘',
service: '检查服务健康状态与依赖组件,查看服务日志',
pod: '检查Pod重启原因,查看容器日志,排查OOM或健康检查失败',
};
return actions[metricKey] || '建议检查相关服务状态并持续监控';
}
export function AIPredictiveAnalytics() { export function AIPredictiveAnalytics() {
const [selectedTimeRange, setSelectedTimeRange] = useState('24h'); const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [loading, setLoading] = useState(true);
const handleRefresh = () => { // 原始数据
setIsAnalyzing(true); const [rawMetrics, setRawMetrics] = useState<any[]>([]);
setTimeout(() => setIsAnalyzing(false), 1500); const [serverStats, setServerStats] = useState<any>({});
const [alertEvents, setAlertEvents] = useState<any[]>([]);
const [alertRules, setAlertRules] = useState<any[]>([]);
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<string, string> = {
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) => { const getTrendIcon = (trend: string) => {
switch (trend) { switch (trend) {
case 'up': case 'up':
@@ -164,7 +366,7 @@ export function AIPredictiveAnalytics() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-slate-100">AI 预测分析</h1> <h1 className="text-2xl font-bold text-slate-100">AI 预测分析</h1>
<p className="text-sm text-slate-500">基于历史数据的智能预测和容量规划</p> <p className="text-sm text-slate-500">基于实时指标时序数据的线性回归预测与容量规划</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -181,7 +383,7 @@ export function AIPredictiveAnalytics() {
</SelectContent> </SelectContent>
</Select> </Select>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleRefresh}> <Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleRefresh}>
{isAnalyzing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Activity className="h-4 w-4" />} {isAnalyzing || loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Activity className="h-4 w-4" />}
</Button> </Button>
</div> </div>
</div> </div>
@@ -192,16 +394,15 @@ export function AIPredictiveAnalytics() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-400">预测准确率</p> <p className="text-sm text-slate-400">预测置信度</p>
<p className="text-2xl font-bold text-slate-100">87.3%</p> <p className="text-2xl font-bold text-slate-100">{accuracy}%</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" /> <CheckCircle className="h-5 w-5 text-emerald-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1 mt-2 text-xs text-emerald-400"> <div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<ArrowUpRight className="h-3 w-3" /> <span>基于 {rawMetrics.length} 个时序数据点回归拟合 R²</span>
<span>+2.1% 较上周</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -210,15 +411,15 @@ export function AIPredictiveAnalytics() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-400">预测告警数</p> <p className="text-sm text-slate-400">高风险预测</p>
<p className="text-2xl font-bold text-slate-100">12</p> <p className="text-2xl font-bold text-slate-100">{predictedAlertCount}</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-amber-400" /> <AlertTriangle className="h-5 w-5 text-amber-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1 mt-2 text-xs text-amber-400"> <div className="flex items-center gap-1 mt-2 text-xs text-amber-400">
<span>未来24小时内</span> <span>概率 ≥50% 的异常预测</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -228,14 +429,14 @@ export function AIPredictiveAnalytics() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-400">容量预警</p> <p className="text-sm text-slate-400">容量预警</p>
<p className="text-2xl font-bold text-slate-100">3</p> <p className="text-2xl font-bold text-slate-100">{capacityWarningCount}</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<HardDrive className="h-5 w-5 text-red-400" /> <HardDrive className="h-5 w-5 text-red-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1 mt-2 text-xs text-red-400"> <div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<span>需要关注</span> <span>14天内触及阈值</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -244,15 +445,15 @@ export function AIPredictiveAnalytics() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-400">AI模型版本</p> <p className="text-sm text-slate-400">监控指标数</p>
<p className="text-2xl font-bold text-slate-100">v2.4.1</p> <p className="text-2xl font-bold text-slate-100">{predictions.length}</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-purple-400" /> <Sparkles className="h-5 w-5 text-purple-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1 mt-2 text-xs text-purple-400"> <div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
<span>已是最新版本</span> <span>CPU / 内存 / 磁盘</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -284,63 +485,70 @@ export function AIPredictiveAnalytics() {
</CardTitle> </CardTitle>
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10"> <Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<Sparkles className="h-3 w-3 mr-1" /> <Sparkles className="h-3 w-3 mr-1" />
AI 预测 线性回归预测
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> {loading ? (
{mockPredictions.map((prediction, idx) => ( <div className="flex items-center justify-center py-12 text-slate-500">
<div <Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载指标数据...
key={idx} </div>
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50" ) : predictions.length === 0 ? (
> <div className="text-center py-12 text-slate-500">暂无指标数据</div>
<div className="flex items-center justify-between mb-3"> ) : (
<div className="flex items-center gap-2"> <div className="grid grid-cols-2 gap-4">
{prediction.name.includes('CPU') && <Cpu className="h-4 w-4 text-blue-400" />} {predictions.map((prediction, idx) => (
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 text-emerald-400" />} <div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 text-amber-400" />} <div className="flex items-center justify-between mb-3">
{prediction.name.includes('网络') && <Network className="h-4 w-4 text-purple-400" />} <div className="flex items-center gap-2">
<span className="font-medium text-slate-200">{prediction.name}</span> {prediction.name.includes('CPU') && <Cpu className="h-4 w-4 text-blue-400" />}
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 text-emerald-400" />}
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 text-amber-400" />}
{prediction.name.includes('网络') && <Network className="h-4 w-4 text-purple-400" />}
<span className="font-medium text-slate-200">{prediction.name}</span>
</div>
<div className="flex items-center gap-1">
{getTrendIcon(prediction.trend)}
<span className={`text-sm ${getTrendColor(prediction.trend)}`}>
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
</span>
</div>
</div> </div>
<div className="flex items-center gap-1">
{getTrendIcon(prediction.trend)}
<span className={`text-sm ${getTrendColor(prediction.trend)}`}>
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-3"> <div className="grid grid-cols-2 gap-4 mb-3">
<div> <div>
<p className="text-xs text-slate-500 mb-1">当前值</p> <p className="text-xs text-slate-500 mb-1">当前值</p>
<p className="text-lg font-bold text-slate-100"> <p className="text-lg font-bold text-slate-100">
{prediction.current}{prediction.unit} {prediction.current}
</p> {prediction.unit}
</div> </p>
<div> </div>
<p className="text-xs text-slate-500 mb-1">预测值 ({prediction.timeRange})</p> <div>
<p className={`text-lg font-bold ${getTrendColor(prediction.trend)}`}> <p className="text-xs text-slate-500 mb-1">预测值 ({prediction.timeRange})</p>
{prediction.predicted}{prediction.unit} <p className={`text-lg font-bold ${getTrendColor(prediction.trend)}`}>
</p> {prediction.predicted}
{prediction.unit}
</p>
</div>
</div> </div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-400">置信度</span> <span className="text-xs text-slate-400">置信度</span>
<div className="w-16 h-1.5 bg-slate-700 rounded-full overflow-hidden"> <div className="w-16 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div <div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500" className="h-full bg-gradient-to-r from-blue-500 to-purple-500"
style={{ width: `${prediction.confidence}%` }} style={{ width: `${prediction.confidence}%` }}
/> />
</div>
<span className="text-xs text-purple-400">{prediction.confidence}%</span>
</div> </div>
<span className="text-xs text-purple-400">{prediction.confidence}%</span>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -353,76 +561,89 @@ export function AIPredictiveAnalytics() {
<HardDrive className="h-5 w-5 text-amber-400" /> <HardDrive className="h-5 w-5 text-amber-400" />
容量规划预警 容量规划预警
</CardTitle> </CardTitle>
<span className="text-xs text-slate-500">基于日均增长率推算阈值触及时间</span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> {loading ? (
{mockCapacityForecasts.map((forecast, idx) => ( <div className="flex items-center justify-center py-12 text-slate-500">
<div <Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载容量数据...
key={idx} </div>
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50" ) : capacityForecasts.length === 0 ? (
> <div className="text-center py-12 text-slate-500">暂无容量数据</div>
<div className="flex items-center justify-between mb-3"> ) : (
<div className="flex items-center gap-2"> <div className="space-y-4">
<Database className="h-5 w-5 text-blue-400" /> {capacityForecasts.map((forecast, idx) => (
<span className="font-medium text-slate-200">{forecast.resource}</span> <div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
</div> <div className="flex items-center justify-between mb-3">
<Badge <div className="flex items-center gap-2">
variant="outline" <Database className="h-5 w-5 text-blue-400" />
className={ <span className="font-medium text-slate-200">{forecast.resource}</span>
forecast.daysUntilThreshold <= 7 </div>
? 'border-red-500/30 text-red-400 bg-red-500/10' <Badge
variant="outline"
className={
forecast.daysUntilThreshold <= 7
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 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 <= 14
? 'border-amber-500/30 text-amber-400 bg-amber-500/10' ? '警告'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' : '正常'}
} </Badge>
>
{forecast.daysUntilThreshold <= 7
? '紧急'
: forecast.daysUntilThreshold <= 14
? '警告'
: '正常'}
</Badge>
</div>
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-400">使用率趋势</span>
<span className="text-slate-400">
预计 <span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span> 后达到阈值
</span>
</div> </div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div className="flex h-full"> <div className="mb-4">
<div <div className="flex items-center justify-between text-sm mb-1">
className="bg-blue-500" <span className="text-slate-400">7天使用率预测</span>
style={{ width: `${forecast.currentUsage}%` }} <span className="text-slate-400">
/> {forecast.daysUntilThreshold >= 999 ? (
<div '增长平缓,暂无触顶风险'
className="bg-gradient-to-r from-blue-500/50 to-amber-500" ) : (
style={{ width: `${forecast.predictedUsage - forecast.currentUsage}%` }} <>
/> 预计{' '}
<span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span>{' '}
后达到阈值
</>
)}
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div className="flex h-full">
<div className="bg-blue-500" style={{ width: `${forecast.currentUsage}%` }} />
<div
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
style={{
width: `${Math.max(0, forecast.predictedUsage - forecast.currentUsage)}%`,
}}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
<span className="text-red-400">阈值 {forecast.threshold}%</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
<span className="text-red-400">阈值 {forecast.threshold}%</span>
</div>
</div>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700"> <div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" /> <Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
<div> <div>
<span className="text-sm text-slate-400">AI建议:</span> <span className="text-sm text-slate-400">AI建议:</span>
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p> <p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -435,95 +656,101 @@ export function AIPredictiveAnalytics() {
<AlertTriangle className="h-5 w-5 text-red-400" /> <AlertTriangle className="h-5 w-5 text-red-400" />
异常事件预测 异常事件预测
</CardTitle> </CardTitle>
<span className="text-xs text-slate-500">基于告警规则阈值与实时趋势概率推算</span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> {loading ? (
{mockAnomalyPredictions.map((anomaly, idx) => ( <div className="flex items-center justify-center py-12 text-slate-500">
<div <Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载异常预测...
key={idx} </div>
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50" ) : dedupedAnomalies.length === 0 ? (
> <div className="text-center py-12 text-slate-500">暂无异常预测数据</div>
<div className="flex items-start justify-between"> ) : (
<div className="flex items-start gap-3"> <div className="space-y-3">
<div {dedupedAnomalies.map((anomaly, idx) => (
className={`w-10 h-10 rounded-lg flex items-center justify-center ${ <div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
anomaly.probability >= 70 <div className="flex items-start justify-between">
? 'bg-red-500/20' <div className="flex items-start gap-3">
: anomaly.probability >= 50 <div
? 'bg-amber-500/20' className={`w-10 h-10 rounded-lg flex items-center justify-center ${
: 'bg-blue-500/20'
}`}
>
<AlertTriangle
className={`h-5 w-5 ${
anomaly.probability >= 70 anomaly.probability >= 70
? 'text-red-400' ? 'bg-red-500/20'
: anomaly.probability >= 50 : anomaly.probability >= 50
? 'text-amber-400' ? 'bg-amber-500/20'
: 'text-blue-400' : 'bg-blue-500/20'
}`} }`}
/> >
<AlertTriangle
className={`h-5 w-5 ${
anomaly.probability >= 70
? 'text-red-400'
: anomaly.probability >= 50
? 'text-amber-400'
: 'text-blue-400'
}`}
/>
</div>
<div>
<h4 className="font-medium text-slate-200">{anomaly.metric}</h4>
<p className="text-sm text-slate-400 mt-1">{anomaly.predictedAnomaly}</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-xs text-slate-500">{anomaly.timeframe}</span>
<span className="text-xs text-slate-500">
概率:{' '}
<span className={anomaly.probability >= 70 ? 'text-red-400' : 'text-amber-400'}>
{anomaly.probability}%
</span>
</span>
</div>
</div>
</div> </div>
<div> <div className="w-16 h-16 relative shrink-0">
<h4 className="font-medium text-slate-200">{anomaly.metric}</h4> <svg className="w-full h-full transform -rotate-90">
<p className="text-sm text-slate-400 mt-1">{anomaly.predictedAnomaly}</p> <circle
<div className="flex items-center gap-4 mt-2"> cx="32"
<span className="text-xs text-slate-500 flex items-center gap-1"> cy="32"
<Clock className="h-3 w-3" /> r="28"
{anomaly.timeframe} stroke="currentColor"
</span> strokeWidth="4"
<span className="text-xs text-slate-500"> fill="transparent"
概率: <span className={anomaly.probability >= 70 ? 'text-red-400' : 'text-amber-400'}>{anomaly.probability}%</span> className="text-slate-700"
</span> />
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
strokeDasharray={`${anomaly.probability * 1.76} 176`}
className={
anomaly.probability >= 70
? 'text-red-400'
: anomaly.probability >= 50
? 'text-amber-400'
: 'text-blue-400'
}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-slate-200">{anomaly.probability}%</span>
</div> </div>
</div> </div>
</div> </div>
<div className="w-16 h-16 relative"> <div className="mt-3 p-3 bg-slate-900 rounded-lg border border-slate-700">
<svg className="w-full h-full transform -rotate-90"> <div className="flex items-start gap-2">
<circle <Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
cx="32" <div>
cy="32" <span className="text-sm text-slate-400">建议操作:</span>
r="28" <p className="text-sm text-slate-300 mt-1">{anomaly.suggestedAction}</p>
stroke="currentColor" </div>
strokeWidth="4"
fill="transparent"
className="text-slate-700"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
strokeDasharray={`${anomaly.probability * 1.76} 176`}
className={
anomaly.probability >= 70
? 'text-red-400'
: anomaly.probability >= 50
? 'text-amber-400'
: 'text-blue-400'
}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-slate-200">{anomaly.probability}%</span>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-3 p-3 bg-slate-900 rounded-lg border border-slate-700"> ))}
<div className="flex items-start gap-2"> </div>
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" /> )}
<div>
<span className="text-sm text-slate-400">建议操作:</span>
<p className="text-sm text-slate-300 mt-1">{anomaly.suggestedAction}</p>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -21,6 +21,7 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { ansibleApi } from '@/services/api'; import { ansibleApi } from '@/services/api';
interface Playbook { interface Playbook {
@@ -80,6 +81,7 @@ interface Group {
} }
const AnsibleManager: React.FC = () => { const AnsibleManager: React.FC = () => {
const _confirm = useConfirm();
const [playbooks, setPlaybooks] = useState<Playbook[]>([]); const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [inventories, setInventories] = useState<Inventory[]>([]); const [inventories, setInventories] = useState<Inventory[]>([]);
@@ -181,7 +183,7 @@ const AnsibleManager: React.FC = () => {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('确定删除此Playbook吗?')) return; { const ok = await _confirm({ title: '删除 Playbook', description: '确定删除此Playbook吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try { try {
await ansibleApi.deletePlaybook(id); await ansibleApi.deletePlaybook(id);
toast.success('删除成功'); toast.success('删除成功');
@@ -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<string, string> = {
aliyun: '阿里云',
tencent: '腾讯云',
aws: 'AWS',
huawei: '华为云',
azure: 'Azure',
};
const providerColors: Record<string, string> = {
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<any>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [records, setRecords] = useState<any[]>([]);
const [recordsLoading, setRecordsLoading] = useState(true);
const [filterProvider, setFilterProvider] = useState('');
const [filterStart, setFilterStart] = useState('');
const [filterEnd, setFilterEnd] = useState('');
const [rules, setRules] = useState<any[]>([]);
const [rulesLoading, setRulesLoading] = useState(true);
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<any>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null);
const [submitting, setSubmitting] = useState(false);
const emptyRecord = { provider: 'aliyun', billing_date: '', cost: '', service: '', region: '', account_name: '', description: '' };
const [recordForm, setRecordForm] = useState<any>(emptyRecord);
const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true };
const [ruleForm, setRuleForm] = useState<any>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-100">云账单管理</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => { loadStats(); if (activeTab === 'records') loadRecords(); if (activeTab === 'alerts') loadRules(); }}>
<RefreshCw className="h-4 w-4 mr-1" />刷新
</Button>
{activeTab === 'records' && (
<Button size="sm" onClick={openAddRecord}>
<Plus className="h-4 w-4 mr-1" />记录成本
</Button>
)}
{activeTab === 'alerts' && (
<Button size="sm" onClick={openAddRule}>
<Plus className="h-4 w-4 mr-1" />添加规则
</Button>
)}
</div>
</div>
<div className="flex gap-1 border-b border-slate-800">
{([
{ key: 'overview', label: '概览统计' },
{ key: 'records', label: '成本记录' },
{ key: 'alerts', label: '预警规则' },
] as const).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'text-blue-400 border-blue-400'
: 'text-slate-400 border-transparent hover:text-slate-200'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'overview' && (
<div className="space-y-4">
{statsLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : stats ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
icon={<Wallet className="h-5 w-5" />}
label="今日成本"
value={formatCurrency(stats.today_cost || 0)}
trend={stats.today_cost - (stats.yesterday_cost || 0)}
trendLabel={`昨日 ${formatCurrency(stats.yesterday_cost || 0)}`}
/>
<StatCard
icon={<CalendarDays className="h-5 w-5" />}
label="本月成本"
value={formatCurrency(stats.month_cost || 0)}
/>
<StatCard
icon={<TrendingUp className="h-5 w-5" />}
label="年度成本"
value={formatCurrency(stats.year_cost || 0)}
/>
<StatCard
icon={<AlertTriangle className="h-5 w-5" />}
label="活跃预警"
value={`${stats.alerts?.length || 0} 条`}
alert={stats.alerts?.length > 0}
/>
</div>
{stats.alerts?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-3">
<Bell className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-amber-300">成本预警</span>
</div>
<div className="space-y-2">
{stats.alerts.map((a: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge>
<span className="text-slate-400">
{a.provider} · {a.metric === 'daily' ? '日' : '月'}成本
</span>
</div>
<span className="text-slate-300">
当前 <span className="text-amber-300 font-medium">{formatCurrency(a.current)}</span>
{' '}{a.operator}{' '}
阈值 <span className="text-red-400 font-medium">{formatCurrency(a.threshold)}</span>
</span>
</div>
))}
</div>
</div>
)}
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<h3 className="text-sm font-medium text-slate-200 mb-4">近 30 天成本趋势</h3>
<TrendChart data={stats.month_trend || []} />
</CardContent>
</Card>
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<h3 className="text-sm font-medium text-slate-200 mb-4">本月厂商成本分布</h3>
{(stats.provider_dist || []).length === 0 ? (
<p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>
) : (
<div className="space-y-3">
<div className="flex h-8 rounded-lg overflow-hidden">
{stats.provider_dist.map((p: any, i: number) => (
<div
key={i}
className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`}
style={{ width: `${p.percent}%` }}
title={`${providerLabels[p.provider] || p.provider}: ${formatCurrency(p.cost)} (${p.percent.toFixed(1)}%)`}
>
{p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''}
</div>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{stats.provider_dist.map((p: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} />
<span className="text-slate-300">{providerLabels[p.provider] || p.provider}</span>
</div>
<div className="text-right">
<span className="text-slate-200 font-medium">{formatCurrency(p.cost)}</span>
<span className="text-slate-500 ml-2">{p.percent.toFixed(1)}%</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</>
) : null}
</div>
)}
{activeTab === 'records' && (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs text-slate-400 mb-1">厂商</Label>
<Select value={filterProvider || 'all'} onValueChange={(v) => setFilterProvider(v === 'all' ? '' : v)}>
<SelectTrigger className="w-32 h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-slate-400 mb-1">开始日期</Label>
<Input type="date" className="w-40 h-9" value={filterStart} onChange={(e) => setFilterStart(e.target.value)} />
</div>
<div>
<Label className="text-xs text-slate-400 mb-1">结束日期</Label>
<Input type="date" className="w-40 h-9" value={filterEnd} onChange={(e) => setFilterEnd(e.target.value)} />
</div>
<Button variant="outline" size="sm" onClick={loadRecords}>
<RefreshCw className="h-4 w-4 mr-1" />查询
</Button>
</div>
{recordsLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : records.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Wallet className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无成本记录</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-800">
<table className="w-full text-sm">
<thead className="bg-slate-900/80 text-slate-400">
<tr>
<th className="px-4 py-2.5 text-left font-medium">日期</th>
<th className="px-4 py-2.5 text-left font-medium">厂商</th>
<th className="px-4 py-2.5 text-left font-medium">服务</th>
<th className="px-4 py-2.5 text-left font-medium">区域</th>
<th className="px-4 py-2.5 text-left font-medium">账号</th>
<th className="px-4 py-2.5 text-right font-medium">费用</th>
<th className="px-4 py-2.5 text-left font-medium">说明</th>
<th className="px-4 py-2.5 text-center font-medium">操作</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-t border-slate-800 hover:bg-slate-800/30">
<td className="px-4 py-2.5 text-slate-300">{r.billing_date}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${providerColors[r.provider] || 'bg-slate-500'}`} />
{providerLabels[r.provider] || r.provider}
</span>
</td>
<td className="px-4 py-2.5 text-slate-400">{r.service || '-'}</td>
<td className="px-4 py-2.5 text-slate-400">{r.region || '-'}</td>
<td className="px-4 py-2.5 text-slate-400">{r.account_name || '-'}</td>
<td className="px-4 py-2.5 text-right text-slate-200 font-medium">{formatCurrency(r.cost)}</td>
<td className="px-4 py-2.5 text-slate-500 max-w-xs truncate">{r.description || '-'}</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRecord(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => handleDeleteRecord(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'alerts' && (
<div className="space-y-3">
{rulesLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : rules.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Bell className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无预警规则</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rules.map((r) => (
<Card key={r.id} className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className={`h-4 w-4 ${r.enabled ? 'text-amber-400' : 'text-slate-600'}`} />
<span className="text-sm font-medium text-slate-200">{r.name}</span>
</div>
<Switch checked={r.enabled} onCheckedChange={() => handleToggleRule(r.id)} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-slate-500">厂商</span>
<span className="ml-2 text-slate-300">{r.provider ? (providerLabels[r.provider] || r.provider) : '全部'}</span>
</div>
<div>
<span className="text-slate-500">指标</span>
<span className="ml-2 text-slate-300">{r.metric === 'daily' ? '日成本' : '月累计'}</span>
</div>
<div>
<span className="text-slate-500">阈值</span>
<span className="ml-2 text-amber-300 font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
</div>
{r.last_triggered && (
<div>
<span className="text-slate-500">最后触发</span>
<span className="ml-2 text-slate-400">{new Date(r.last_triggered).toLocaleString('zh-CN')}</span>
</div>
)}
</div>
<div className="flex justify-end gap-1 mt-3">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRule(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => handleDeleteRule(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingRecord ? '编辑成本记录' : '记录成本'}</DialogTitle>
<DialogDescription>记录一条每日云资源成本</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-slate-300">厂商 *</Label>
<Select value={recordForm.provider} onValueChange={(v) => setRecordForm({ ...recordForm, provider: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm text-slate-300">日期 *</Label>
<Input type="date" className="mt-1" value={recordForm.billing_date} onChange={(e) => setRecordForm({ ...recordForm, billing_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-slate-300">费用(元) *</Label>
<Input type="number" step="0.01" className="mt-1" placeholder="0.00" value={recordForm.cost} onChange={(e) => setRecordForm({ ...recordForm, cost: e.target.value })} />
</div>
<div>
<Label className="text-sm text-slate-300">服务</Label>
<Input className="mt-1" placeholder="ECS, RDS..." value={recordForm.service} onChange={(e) => setRecordForm({ ...recordForm, service: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-slate-300">区域</Label>
<Input className="mt-1" placeholder="cn-hangzhou" value={recordForm.region} onChange={(e) => setRecordForm({ ...recordForm, region: e.target.value })} />
</div>
<div>
<Label className="text-sm text-slate-300">账号名称</Label>
<Input className="mt-1" placeholder="主账号" value={recordForm.account_name} onChange={(e) => setRecordForm({ ...recordForm, account_name: e.target.value })} />
</div>
</div>
<div>
<Label className="text-sm text-slate-300">备注</Label>
<Input className="mt-1" placeholder="可选" value={recordForm.description} onChange={(e) => setRecordForm({ ...recordForm, description: e.target.value })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRecordDialogOpen(false)}>取消</Button>
<Button onClick={handleSaveRecord} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingRule ? '编辑预警规则' : '添加预警规则'}</DialogTitle>
<DialogDescription>设置成本阈值,超标时自动预警</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-sm text-slate-300">规则名称 *</Label>
<Input className="mt-1" placeholder="如:日成本超 500 元预警" value={ruleForm.name} onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-slate-300">厂商</Label>
<Select value={ruleForm.provider || 'all'} onValueChange={(v) => setRuleForm({ ...ruleForm, provider: v === 'all' ? '' : v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部厂商</SelectItem>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm text-slate-300">指标</Label>
<Select value={ruleForm.metric} onValueChange={(v) => setRuleForm({ ...ruleForm, metric: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">日成本</SelectItem>
<SelectItem value="monthly">月累计</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-slate-300">比较</Label>
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value=">">大于 &gt;</SelectItem>
<SelectItem value=">=">大于等于 &ge;</SelectItem>
<SelectItem value="<">小于 &lt;</SelectItem>
<SelectItem value="<=">小于等于 &le;</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm text-slate-300">阈值(元) *</Label>
<Input type="number" step="0.01" className="mt-1" placeholder="500.00" value={ruleForm.threshold} onChange={(e) => setRuleForm({ ...ruleForm, threshold: e.target.value })} />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={ruleForm.enabled} onCheckedChange={(v) => setRuleForm({ ...ruleForm, enabled: v })} />
<Label className="text-sm text-slate-300">启用</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRuleDialogOpen(false)}>取消</Button>
<Button onClick={handleSaveRule} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function StatCard({ icon, label, value, trend, trendLabel, alert }: {
icon: React.ReactNode;
label: string;
value: string;
trend?: number;
trendLabel?: string;
alert?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${alert ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-800 bg-slate-900/50'}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-slate-400 text-sm">{label}</span>
<span className={alert ? 'text-amber-400' : 'text-slate-500'}>{icon}</span>
</div>
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
{trendLabel && (
<div className="flex items-center gap-1 mt-1 text-xs">
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 text-red-400" />}
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 text-green-400" />}
<span className="text-slate-500">{trendLabel}</span>
</div>
)}
</div>
);
}
function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
if (data.length === 0) {
return <p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>;
}
const max = Math.max(...data.map((d) => d.cost), 1);
const recent = data.slice(-30);
return (
<div>
<div className="flex items-end gap-0.5 h-40">
{recent.map((d, i) => {
const h = Math.max((d.cost / max) * 100, 2);
return (
<div
key={i}
className="flex-1 group relative"
style={{ height: '100%' }}
>
<div
className="absolute bottom-0 w-full rounded-t-sm bg-gradient-to-t from-blue-600/60 to-blue-400/80 group-hover:from-blue-500 group-hover:to-blue-300 transition-colors"
style={{ height: `${h}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-slate-800 text-slate-200 px-2 py-1 rounded border border-slate-700 z-10">
{d.date}: {formatCurrency(d.cost)}
</div>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>{recent[0]?.date}</span>
<span>{recent[recent.length - 1]?.date}</span>
</div>
</div>
);
}
+2 -5
View File
@@ -21,7 +21,6 @@ import {
Users, Users,
Menu, Menu,
X, X,
Box,
Network, Network,
CloudCog, CloudCog,
Globe, Globe,
@@ -61,14 +60,12 @@ const menuItems: MenuItem[] = [
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' }, { id: 'domains', name: '域名管理', icon: Globe, path: '/domains' },
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' }, { id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' }, { id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
{ id: 'resource-models', name: '资源模型', icon: Box, path: '/resource-models' }, { id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
{ id: 'webshell', name: 'WebShell', icon: Terminal, path: '/webshell' },
{ id: 'batch-import', name: '批量导入', icon: FileCode, path: '/batch-import' },
], ],
}, },
{ {
id: 'k8s', id: 'k8s',
name: 'K8s集群管理', name: 'Kubernetes管理',
icon: Cloud, icon: Cloud,
children: [ children: [
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' }, { id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' },
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -93,6 +94,7 @@ function resolveChannelRef(value: string, channels: any[]): { type: string; name
} }
export function AlertManager() { export function AlertManager() {
const _confirm = useConfirm();
const [activeTab, setActiveTab] = useState('events'); const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all'); const [severityFilter, setSeverityFilter] = useState<string>('all');
@@ -164,7 +166,7 @@ export function AlertManager() {
}; };
const handleDeleteRule = async (id: any) => { const handleDeleteRule = async (id: any) => {
if (!window.confirm('确定删除此告警规则?')) return; { const ok = await _confirm({ title: '删除告警规则', description: '确定删除此告警规则?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try { try {
await alertApi.deleteRule(id); await alertApi.deleteRule(id);
loadData(); loadData();
@@ -275,7 +277,7 @@ export function AlertManager() {
}; };
const handleDeleteChannel = async (id: any) => { const handleDeleteChannel = async (id: any) => {
if (!window.confirm('确定删除此通知渠道?')) return; { const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try { try {
await alertApi.deleteNotifyChannel(id); await alertApi.deleteNotifyChannel(id);
if (editingChannel && editingChannel.id === id) resetChannelForm(); if (editingChannel && editingChannel.id === id) resetChannelForm();
@@ -22,6 +22,7 @@ import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/services/api'; import { alertCenterApi, alertApi } from '@/services/api';
interface Alert { interface Alert {
@@ -68,6 +69,7 @@ interface NotificationChannel {
} }
const AlertCenter: React.FC = () => { const AlertCenter: React.FC = () => {
const _confirm = useConfirm();
const [alerts, setAlerts] = useState<Alert[]>([]); const [alerts, setAlerts] = useState<Alert[]>([]);
const [rules, setRules] = useState<AlertRule[]>([]); const [rules, setRules] = useState<AlertRule[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]); const [channels, setChannels] = useState<NotificationChannel[]>([]);
@@ -215,7 +217,7 @@ const AlertCenter: React.FC = () => {
}; };
const handleDeleteChannel = async (id: number) => { const handleDeleteChannel = async (id: number) => {
if (!window.confirm('确定删除此通知渠道?')) return; { const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try { try {
await alertApi.deleteNotifyChannel(id); await alertApi.deleteNotifyChannel(id);
toast.success('渠道已删除'); toast.success('渠道已删除');
@@ -407,10 +409,6 @@ const AlertCenter: React.FC = () => {
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
刷新 刷新
</Button> </Button>
<Button variant="outline" onClick={() => toast.info('批量操作功能开发中')}>
<Check className="w-4 h-4 mr-2" />
批量确认
</Button>
</div> </div>
</div> </div>
@@ -516,10 +514,6 @@ const AlertCenter: React.FC = () => {
刷新 刷新
</Button> </Button>
</div> </div>
<Button onClick={() => toast.info('创建规则功能开发中')}>
<Plus className="w-4 h-4 mr-2" />
新建规则
</Button>
</div> </div>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{rules.map((rule) => ( {rules.map((rule) => (
@@ -20,6 +20,7 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { dashboardApi } from '@/services/api'; import { dashboardApi } from '@/services/api';
interface Dashboard { interface Dashboard {
@@ -73,6 +74,7 @@ interface Datasource {
} }
const GrafanaDashboard: React.FC = () => { const GrafanaDashboard: React.FC = () => {
const _confirm = useConfirm();
const [dashboards, setDashboards] = useState<Dashboard[]>([]); const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [datasources, setDatasources] = useState<Datasource[]>([]); const [datasources, setDatasources] = useState<Datasource[]>([]);
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null); const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
@@ -201,7 +203,7 @@ const GrafanaDashboard: React.FC = () => {
}; };
const handleDeleteDashboard = async (id: string) => { const handleDeleteDashboard = async (id: string) => {
if (!confirm('确定删除此仪表盘吗?')) return; { const ok = await _confirm({ title: '删除仪表盘', description: '确定删除此仪表盘吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try { try {
await dashboardApi.deleteDashboard(id); await dashboardApi.deleteDashboard(id);
toast.success('删除成功'); toast.success('删除成功');
+124 -24
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -85,6 +87,7 @@ const emptyForm = {
}; };
export function SystemManager() { export function SystemManager() {
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState('users'); const [activeTab, setActiveTab] = useState('users');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
@@ -108,6 +111,8 @@ export function SystemManager() {
const [ldapUsers, setLdapUsers] = useState<any[]>([]); const [ldapUsers, setLdapUsers] = useState<any[]>([]);
const [ldapSelected, setLdapSelected] = useState<Set<string>>(new Set()); const [ldapSelected, setLdapSelected] = useState<Set<string>>(new Set());
const [ldapLoading, setLdapLoading] = useState(false); const [ldapLoading, setLdapLoading] = useState(false);
const [ldapImportOUs, setLdapImportOUs] = useState<any[]>([]);
const [ldapImportOUDN, setLdapImportOUDN] = useState('');
// LDAP 配置 // LDAP 配置
const [ldapConfig, setLdapConfig] = useState<any>(null); const [ldapConfig, setLdapConfig] = useState<any>(null);
@@ -135,6 +140,8 @@ export function SystemManager() {
const [ldapTestPass, setLdapTestPass] = useState(''); const [ldapTestPass, setLdapTestPass] = useState('');
const [ldapUserTesting, setLdapUserTesting] = useState(false); const [ldapUserTesting, setLdapUserTesting] = useState(false);
const [ldapUserTestResult, setLdapUserTestResult] = useState<any>(null); const [ldapUserTestResult, setLdapUserTestResult] = useState<any>(null);
const [editLdapUser, setEditLdapUser] = useState<any>(null);
const [editLdapForm, setEditLdapForm] = useState({ cn: '', mail: '', phone: '', title: '' });
const loadUsers = async () => { const loadUsers = async () => {
setLoading(true); setLoading(true);
@@ -155,6 +162,14 @@ export function SystemManager() {
loadLdapConfig(); loadLdapConfig();
}, []); }, []);
// LDAP 导入 Dialog 打开时加载 OU 和用户
useEffect(() => {
if (importDialogOpen === 'ldap') {
ldapApi.listOUs().then(ous => setLdapImportOUs(Array.isArray(ous) ? ous : [])).catch(() => {});
loadLdapUsers('');
}
}, [importDialogOpen]);
const filteredUsers = users.filter( const filteredUsers = users.filter(
(u) => (u) =>
(u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) || (u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -244,7 +259,8 @@ export function SystemManager() {
}; };
const handleDelete = async (user: any) => { 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 { try {
await userApi.deleteUser(user.id); await userApi.deleteUser(user.id);
await loadUsers(); await loadUsers();
@@ -304,10 +320,10 @@ export function SystemManager() {
}; };
// 加载 LDAP 用户列表 // 加载 LDAP 用户列表
const loadLdapUsers = async () => { const loadLdapUsers = async (ouDn?: string) => {
setLdapLoading(true); setLdapLoading(true);
try { try {
const res = await ldapApi.listUsers(); const res = await ldapApi.listUsers(ouDn);
setLdapUsers(Array.isArray(res) ? res : []); setLdapUsers(Array.isArray(res) ? res : []);
setLdapSelected(new Set()); setLdapSelected(new Set());
} catch { } catch {
@@ -425,9 +441,9 @@ export function SystemManager() {
} catch (e: any) { } catch (e: any) {
const msg = e?.message || ''; const msg = e?.message || '';
if (msg.includes('Already Exists') || msg.includes('68')) { if (msg.includes('Already Exists') || msg.includes('68')) {
alert(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`); toast.error(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`);
} else { } else {
alert('创建失败: ' + msg); toast.error('创建失败: ' + msg);
} }
} finally { setLdapSaving(false); } } finally { setLdapSaving(false); }
}; };
@@ -439,16 +455,30 @@ export function SystemManager() {
setCreateUserOpen(false); setCreateUserOpen(false);
setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' }); setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' });
await loadLdapOUUsers(selectedOUDN); await loadLdapOUUsers(selectedOUDN);
} catch (e: any) { alert(e?.message || '创建失败'); } } catch (e: any) { toast.error(e?.message || '创建失败'); }
finally { setLdapSaving(false); } finally { setLdapSaving(false); }
}; };
const handleDeleteLDAPEntry = async (dn: string, silent = 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 { try {
await ldapApi.deleteUser(dn); await ldapApi.deleteUser(dn);
if (!silent) await loadLdapOUUsers(selectedOUDN); 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 () => { const handleLdapUserTest = async () => {
@@ -508,7 +538,7 @@ export function SystemManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}> <Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
批量导入用户 批量导入
</Button> </Button>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}> <Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
<Network className="h-4 w-4 mr-2" /> <Network className="h-4 w-4 mr-2" />
@@ -1264,14 +1294,24 @@ export function SystemManager() {
<code className="text-xs text-slate-500">{user.dn}</code> <code className="text-xs text-slate-500">{user.dn}</code>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="icon" variant="ghost"
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10" size="icon"
onClick={() => handleDeleteLDAPEntry(user.dn)} className="h-6 w-6 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10"
> onClick={() => openEditLdapUser(user)}
<Trash2 className="h-3 w-3" /> >
</Button> <Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDeleteLDAPEntry(user.dn)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -1286,6 +1326,49 @@ export function SystemManager() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* 编辑 LDAP 用户 Dialog */}
<Dialog open={!!editLdapUser} onOpenChange={(v) => !v && setEditLdapUser(null)}>
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Edit className="h-5 w-5 text-blue-400" />
编辑 LDAP 用户
</DialogTitle>
<DialogDescription className="text-slate-500">
修改 {editLdapUser?.uid} 的属性
</DialogDescription>
</DialogHeader>
{editLdapUser && (
<div className="space-y-3 py-2">
<div>
<label className="text-xs text-slate-500 mb-1 block">用户名(只读)</label>
<Input value={editLdapUser.uid || ''} disabled className="bg-slate-800/50 border-slate-700 text-slate-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">显示名称 (cn)</label>
<Input value={editLdapForm.cn} onChange={(e) => setEditLdapForm({ ...editLdapForm, cn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">邮箱</label>
<Input value={editLdapForm.mail} onChange={(e) => setEditLdapForm({ ...editLdapForm, mail: e.target.value })} placeholder="user@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">电话</label>
<Input value={editLdapForm.phone} onChange={(e) => setEditLdapForm({ ...editLdapForm, phone: e.target.value })} placeholder="13800138000" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">职位/头衔</label>
<Input value={editLdapForm.title} onChange={(e) => setEditLdapForm({ ...editLdapForm, title: e.target.value })} placeholder="运维工程师" className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditLdapUser(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleUpdateLdapUser}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* User Detail Dialog */} {/* User Detail Dialog */}
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}> <Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
<DialogContent className="bg-slate-900 border-slate-800"> <DialogContent className="bg-slate-900 border-slate-800">
@@ -1476,7 +1559,7 @@ export function SystemManager() {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2"> <DialogTitle className="text-slate-100 flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-400" /> <Upload className="h-5 w-5 text-blue-400" />
批量导入用户 批量导入用户(CSV)
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">
上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。 上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。
@@ -1586,16 +1669,33 @@ export function SystemManager() {
{!importResult ? ( {!importResult ? (
<> <>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<p className="text-xs text-slate-500"> <div className="flex items-center gap-2">
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '点击刷新从 LDAP 获取用户列表'} <span className="text-xs text-slate-500 whitespace-nowrap">选择 OU</span>
</p> <select
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={loadLdapUsers} disabled={ldapLoading}> className="bg-slate-800 border border-slate-700 text-slate-200 text-xs rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={ldapImportOUDN}
onChange={(e) => {
setLdapImportOUDN(e.target.value);
loadLdapUsers(e.target.value);
}}
>
<option value="">根 (全部)</option>
{ldapImportOUs.map((ou) => (
<option key={ou.dn} value={ou.dn}>{ou.name}</option>
))}
</select>
</div>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 ml-auto" onClick={() => loadLdapUsers(ldapImportOUDN)} disabled={ldapLoading}>
{ldapLoading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1.5" />} {ldapLoading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
刷新列表 刷新
</Button> </Button>
</div> </div>
<p className="text-xs text-slate-500">
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '选择 OU 或点击刷新获取用户列表'}
</p>
{ldapUsers.length > 0 && ( {ldapUsers.length > 0 && (
<div className="max-h-80 overflow-auto rounded-lg border border-slate-800"> <div className="max-h-80 overflow-auto rounded-lg border border-slate-800">
<Table> <Table>
@@ -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<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(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<Required<ConfirmOptions>>({
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<boolean>((resolve) => setResolver(() => resolve));
}, []);
const handleConfirm = () => {
setOpen(false);
resolver?.(true);
setResolver(null);
};
const handleCancel = () => {
setOpen(false);
resolver?.(false);
setResolver(null);
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent className="max-w-sm bg-slate-900 border-slate-800" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="text-slate-100">{options.title}</DialogTitle>
{options.description && (
<DialogDescription className="text-slate-400">{options.description}</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleCancel}>
{options.cancelText}
</Button>
<Button
variant={options.variant === 'destructive' ? 'destructive' : 'default'}
className={options.variant === 'destructive'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'}
onClick={handleConfirm}
>
{options.confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmContext.Provider>
);
}
+42 -1
View File
@@ -58,7 +58,7 @@ export const ldapApi = {
return http.post('/admin/ldap-config/test', data || {}); return http.post('/admin/ldap-config/test', data || {});
}, },
listUsers: async (ouDn?: string) => { 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[]) => { batchImport: async (users: any[]) => {
return http.post('/admin/ldap-config/import', { users }); return http.post('/admin/ldap-config/import', { users });
@@ -75,6 +75,9 @@ export const ldapApi = {
deleteUser: async (dn: string) => { deleteUser: async (dn: string) => {
return http.post('/admin/ldap-config/delete-user', { dn }); 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 ==================== // ==================== 仪表盘 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<ListResponse>('/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<ListResponse>('/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 ==================== // ==================== 区域管理 API ====================
export const regionApi = { export const regionApi = {
list: async () => { list: async () => {