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:
1 parent
0ebe22d142
commit
2da4f52209
17 files changed
+2299
-322
No files matched your search
@@ -0,0 +1,476 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BillingHandler 云账单处理器
|
||||||
|
type BillingHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBillingHandler(db *gorm.DB) *BillingHandler {
|
||||||
|
return &BillingHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBillings 获取账单列表
|
||||||
|
func (h *BillingHandler) ListBillings(c *gin.Context) {
|
||||||
|
var billings []models.CloudBilling
|
||||||
|
|
||||||
|
query := h.db.Model(&models.CloudBilling{})
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
if provider := c.Query("provider"); provider != "" {
|
||||||
|
query = query.Where("provider = ?", provider)
|
||||||
|
}
|
||||||
|
if accountName := c.Query("account_name"); accountName != "" {
|
||||||
|
query = query.Where("account_name LIKE ?", "%"+accountName+"%")
|
||||||
|
}
|
||||||
|
if startDate := c.Query("start_date"); startDate != "" {
|
||||||
|
query = query.Where("billing_date >= ?", startDate)
|
||||||
|
}
|
||||||
|
if endDate := c.Query("end_date"); endDate != "" {
|
||||||
|
query = query.Where("billing_date <= ?", endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
query = query.Order("billing_date DESC, id DESC")
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
page := 1
|
||||||
|
pageSize := 50
|
||||||
|
if c.Query("page") != "" {
|
||||||
|
fmt.Sscanf(c.Query("page"), "%d", &page)
|
||||||
|
}
|
||||||
|
if c.Query("page_size") != "" {
|
||||||
|
fmt.Sscanf(c.Query("page_size"), "%d", &pageSize)
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 500 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
query.Count(&total)
|
||||||
|
query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&billings)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: models.ListResponse{
|
||||||
|
List: billings,
|
||||||
|
Total: total,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBilling 创建账单记录
|
||||||
|
func (h *BillingHandler) CreateBilling(c *gin.Context) {
|
||||||
|
var req models.CreateBillingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
billing := models.CloudBilling{
|
||||||
|
CloudAccountID: req.CloudAccountID,
|
||||||
|
AccountName: req.AccountName,
|
||||||
|
Provider: req.Provider,
|
||||||
|
BillingDate: req.BillingDate,
|
||||||
|
Cost: req.Cost,
|
||||||
|
Currency: req.Currency,
|
||||||
|
Service: req.Service,
|
||||||
|
Region: req.Region,
|
||||||
|
Description: req.Description,
|
||||||
|
}
|
||||||
|
if billing.Currency == "" {
|
||||||
|
billing.Currency = "CNY"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&billing).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "创建成功",
|
||||||
|
Data: billing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreateBillings 批量创建账单
|
||||||
|
func (h *BillingHandler) BatchCreateBillings(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Records []models.CreateBillingRequest `json:"records" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created := 0
|
||||||
|
for _, r := range req.Records {
|
||||||
|
billing := models.CloudBilling{
|
||||||
|
CloudAccountID: r.CloudAccountID,
|
||||||
|
AccountName: r.AccountName,
|
||||||
|
Provider: r.Provider,
|
||||||
|
BillingDate: r.BillingDate,
|
||||||
|
Cost: r.Cost,
|
||||||
|
Currency: r.Currency,
|
||||||
|
Service: r.Service,
|
||||||
|
Region: r.Region,
|
||||||
|
Description: r.Description,
|
||||||
|
}
|
||||||
|
if billing.Currency == "" {
|
||||||
|
billing.Currency = "CNY"
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&billing).Error; err == nil {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: fmt.Sprintf("成功导入 %d 条记录", created),
|
||||||
|
Data: gin.H{"created": created, "total": len(req.Records)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBilling 删除账单记录
|
||||||
|
func (h *BillingHandler) DeleteBilling(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.db.Delete(&models.CloudBilling{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBillingStats 获取账单统计
|
||||||
|
func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
monthStart := now.Format("2006-01") + "-01"
|
||||||
|
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
||||||
|
|
||||||
|
stats := models.BillingStats{
|
||||||
|
MonthTrend: []models.DailyCost{},
|
||||||
|
ProviderDist: []models.ProviderCost{},
|
||||||
|
Alerts: []models.BillingAlert{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日成本
|
||||||
|
h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", today).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost)
|
||||||
|
|
||||||
|
// 昨日成本
|
||||||
|
h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", yesterday).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost)
|
||||||
|
|
||||||
|
// 本月成本
|
||||||
|
h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", monthStart).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost)
|
||||||
|
|
||||||
|
// 本年成本
|
||||||
|
h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", yearStart).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost)
|
||||||
|
|
||||||
|
// 近30天趋势
|
||||||
|
type dayCost struct {
|
||||||
|
BillingDate string
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
var trends []dayCost
|
||||||
|
h.db.Model(&models.CloudBilling{}).
|
||||||
|
Select("billing_date, SUM(cost) as total").
|
||||||
|
Where("billing_date >= ?", now.AddDate(0, 0, -29).Format("2006-01-02")).
|
||||||
|
Group("billing_date").
|
||||||
|
Order("billing_date ASC").
|
||||||
|
Scan(&trends)
|
||||||
|
|
||||||
|
for _, t := range trends {
|
||||||
|
stats.MonthTrend = append(stats.MonthTrend, models.DailyCost{Date: t.BillingDate, Cost: t.Total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本月厂商成本分布
|
||||||
|
type provCost struct {
|
||||||
|
Provider string
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
var provCosts []provCost
|
||||||
|
h.db.Model(&models.CloudBilling{}).
|
||||||
|
Select("provider, SUM(cost) as total").
|
||||||
|
Where("billing_date >= ?", monthStart).
|
||||||
|
Group("provider").
|
||||||
|
Scan(&provCosts)
|
||||||
|
|
||||||
|
var totalProviderCost float64
|
||||||
|
for _, p := range provCosts {
|
||||||
|
totalProviderCost += p.Total
|
||||||
|
}
|
||||||
|
for _, p := range provCosts {
|
||||||
|
percent := 0.0
|
||||||
|
if totalProviderCost > 0 {
|
||||||
|
percent = p.Total / totalProviderCost * 100
|
||||||
|
}
|
||||||
|
stats.ProviderDist = append(stats.ProviderDist, models.ProviderCost{
|
||||||
|
Provider: p.Provider,
|
||||||
|
Cost: p.Total,
|
||||||
|
Percent: percent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(stats.ProviderDist, func(i, j int) bool {
|
||||||
|
return stats.ProviderDist[i].Cost > stats.ProviderDist[j].Cost
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查预警规则
|
||||||
|
h.checkBillingAlerts(&stats, now)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBillingAlerts 检查预警规则是否触发
|
||||||
|
func (h *BillingHandler) checkBillingAlerts(stats *models.BillingStats, now time.Time) {
|
||||||
|
var rules []models.BillingAlertRule
|
||||||
|
h.db.Where("enabled = ?", true).Find(&rules)
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
var current float64
|
||||||
|
|
||||||
|
if rule.Metric == "daily" {
|
||||||
|
// 查今日该厂商/账号的成本
|
||||||
|
q := h.db.Model(&models.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02"))
|
||||||
|
if rule.Provider != "" {
|
||||||
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
|
}
|
||||||
|
if rule.CloudAccountID != nil {
|
||||||
|
q = q.Where("cloud_account_id = ?", *rule.CloudAccountID)
|
||||||
|
}
|
||||||
|
q.Select("COALESCE(SUM(cost), 0)").Scan(¤t)
|
||||||
|
} else {
|
||||||
|
// monthly - 本月累计
|
||||||
|
q := h.db.Model(&models.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01")
|
||||||
|
if rule.Provider != "" {
|
||||||
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
|
}
|
||||||
|
if rule.CloudAccountID != nil {
|
||||||
|
q = q.Where("cloud_account_id = ?", *rule.CloudAccountID)
|
||||||
|
}
|
||||||
|
q.Select("COALESCE(SUM(cost), 0)").Scan(¤t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查阈值
|
||||||
|
triggered := false
|
||||||
|
switch rule.Operator {
|
||||||
|
case ">":
|
||||||
|
triggered = current > rule.Threshold
|
||||||
|
case ">=":
|
||||||
|
triggered = current >= rule.Threshold
|
||||||
|
case "<":
|
||||||
|
triggered = current < rule.Threshold
|
||||||
|
case "<=":
|
||||||
|
triggered = current <= rule.Threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
if triggered {
|
||||||
|
providerLabel := rule.Provider
|
||||||
|
if providerLabel == "" {
|
||||||
|
providerLabel = "全部"
|
||||||
|
}
|
||||||
|
stats.Alerts = append(stats.Alerts, models.BillingAlert{
|
||||||
|
RuleID: rule.ID,
|
||||||
|
RuleName: rule.Name,
|
||||||
|
Provider: providerLabel,
|
||||||
|
Current: current,
|
||||||
|
Threshold: rule.Threshold,
|
||||||
|
Metric: rule.Metric,
|
||||||
|
Operator: rule.Operator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlertRules 获取预警规则列表
|
||||||
|
func (h *BillingHandler) ListAlertRules(c *gin.Context) {
|
||||||
|
var rules []models.BillingAlertRule
|
||||||
|
h.db.Order("created_at DESC").Find(&rules)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: models.ListResponse{
|
||||||
|
List: rules,
|
||||||
|
Total: int64(len(rules)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAlertRule 创建预警规则
|
||||||
|
func (h *BillingHandler) CreateAlertRule(c *gin.Context) {
|
||||||
|
var req models.CreateBillingAlertRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := models.BillingAlertRule{
|
||||||
|
Name: req.Name,
|
||||||
|
Provider: req.Provider,
|
||||||
|
CloudAccountID: req.CloudAccountID,
|
||||||
|
Metric: req.Metric,
|
||||||
|
Threshold: req.Threshold,
|
||||||
|
Operator: req.Operator,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
}
|
||||||
|
if rule.Metric == "" {
|
||||||
|
rule.Metric = "daily"
|
||||||
|
}
|
||||||
|
if rule.Operator == "" {
|
||||||
|
rule.Operator = ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&rule).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "创建成功",
|
||||||
|
Data: rule,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAlertRule 更新预警规则
|
||||||
|
func (h *BillingHandler) UpdateAlertRule(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var rule models.BillingAlertRule
|
||||||
|
if err := h.db.First(&rule, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "规则不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.CreateBillingAlertRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.Name = req.Name
|
||||||
|
rule.Provider = req.Provider
|
||||||
|
rule.CloudAccountID = req.CloudAccountID
|
||||||
|
rule.Metric = req.Metric
|
||||||
|
rule.Threshold = req.Threshold
|
||||||
|
rule.Operator = req.Operator
|
||||||
|
rule.Enabled = req.Enabled
|
||||||
|
if rule.Metric == "" {
|
||||||
|
rule.Metric = "daily"
|
||||||
|
}
|
||||||
|
if rule.Operator == "" {
|
||||||
|
rule.Operator = ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Save(&rule)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "更新成功",
|
||||||
|
Data: rule,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAlertRule 删除预警规则
|
||||||
|
func (h *BillingHandler) DeleteAlertRule(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.db.Delete(&models.BillingAlertRule{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleAlertRule 启用/禁用预警规则
|
||||||
|
func (h *BillingHandler) ToggleAlertRule(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var rule models.BillingAlertRule
|
||||||
|
if err := h.db.First(&rule, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "规则不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.Enabled = !rule.Enabled
|
||||||
|
h.db.Save(&rule)
|
||||||
|
|
||||||
|
status := "已启用"
|
||||||
|
if !rule.Enabled {
|
||||||
|
status = "已禁用"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: status,
|
||||||
|
Data: rule,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderOptions 获取厂商选项(用于筛选下拉)
|
||||||
|
func (h *BillingHandler) GetProviderOptions(c *gin.Context) {
|
||||||
|
var providers []string
|
||||||
|
h.db.Model(&models.CloudBilling{}).Distinct("provider").Where("provider != ''").Pluck("provider", &providers)
|
||||||
|
|
||||||
|
result := make([]gin.H, 0, len(providers))
|
||||||
|
for _, p := range providers {
|
||||||
|
label := p
|
||||||
|
switch p {
|
||||||
|
case "aliyun":
|
||||||
|
label = "阿里云"
|
||||||
|
case "tencent":
|
||||||
|
label = "腾讯云"
|
||||||
|
case "aws":
|
||||||
|
label = "AWS"
|
||||||
|
case "huawei":
|
||||||
|
label = "华为云"
|
||||||
|
case "azure":
|
||||||
|
label = "Azure"
|
||||||
|
}
|
||||||
|
result = append(result, gin.H{"value": p, "label": label})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatProvider 格式化厂商名称
|
||||||
|
func formatProvider(provider string) string {
|
||||||
|
names := map[string]string{
|
||||||
|
"aliyun": "阿里云",
|
||||||
|
"tencent": "腾讯云",
|
||||||
|
"aws": "AWS",
|
||||||
|
"huawei": "华为云",
|
||||||
|
"azure": "Azure",
|
||||||
|
}
|
||||||
|
if name, ok := names[strings.ToLower(provider)]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
}
|
||||||
@@ -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: "修改成功"})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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=">">大于 ></SelectItem>
|
||||||
|
<SelectItem value=">=">大于等于 ≥</SelectItem>
|
||||||
|
<SelectItem value="<">小于 <</SelectItem>
|
||||||
|
<SelectItem value="<=">小于等于 ≤</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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('删除成功');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in new issue
Block a user