Files
aiops/backend/controller/dashboard.go
T
Logan.Li b7166acda4 优化 local 模式兼容性与部署可靠性
- SQL 方言层:agent updated_at、dashboard 小时分桶、清理任务跨 SQLite/MySQL
- Cache Capabilities:redka 跳过 ZSet/PubSub,Agent 聚合改 DB 直读
- local 模式 redka 初始化失败 Fatal;/version 返回 features
- SQLite WAL + 连接池限制;K8s data 改 PVC 持久化
- run.sh:旧参数迁移、升级后 /version 校验
2026-06-26 13:28:31 +08:00

363 lines
11 KiB
Go

package controller
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"time"
"ezops/model"
"ezops/pkg/database"
ezredis "ezops/pkg/redis"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DashboardHandler 仪表盘处理器
type DashboardHandler struct {
db *gorm.DB
cache ezredis.Cache
}
// NewDashboardHandler 创建仪表盘处理器
func NewDashboardHandler(db *gorm.DB, cache ezredis.Cache) *DashboardHandler {
return &DashboardHandler{db: db, cache: cache}
}
// GetStats 获取仪表盘统计
// @Summary 获取仪表盘统计
// @Description 获取仪表盘所有统计数据
// @Tags 仪表盘
// @Produce json
// @Security Bearer
// @Success 200 {object} model.Response{data=model.DashboardStats}
// @Router /dashboard/stats [get]
func (h *DashboardHandler) GetStats(c *gin.Context) {
const cacheKey = "dashboard:stats"
const cacheTTL = 30 * time.Second
// 尝试从缓存读取
if h.cache != nil {
if cached, err := h.cache.Get(c.Request.Context(), cacheKey); err == nil && cached != "" {
var stats model.DashboardStats
if json.Unmarshal([]byte(cached), &stats) == nil {
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: stats,
})
return
}
}
}
var stats model.DashboardStats
// 服务器统计
var serverStats model.ServerStats
h.db.Model(&model.Server{}).Count(&serverStats.Total)
h.db.Model(&model.Server{}).Where("status = ?", "running").Count(&serverStats.Running)
h.db.Model(&model.Server{}).Where("status = ?", "stopped").Count(&serverStats.Stopped)
h.db.Model(&model.Server{}).Where("status = ?", "maintenance").Count(&serverStats.Maintenance)
h.db.Model(&model.Server{}).Where("status = ?", "error").Count(&serverStats.Error)
var avgCPU, avgMemory, avgDisk float64
h.db.Model(&model.Server{}).Select("AVG(cpu_usage)").Scan(&avgCPU)
h.db.Model(&model.Server{}).Select("AVG(memory_usage)").Scan(&avgMemory)
h.db.Model(&model.Server{}).Select("AVG(disk_usage)").Scan(&avgDisk)
serverStats.AvgCPU = math.Round(avgCPU*100) / 100
serverStats.AvgMemory = math.Round(avgMemory*100) / 100
serverStats.AvgDisk = math.Round(avgDisk*100) / 100
stats.ServerStats = serverStats
// K8s集群统计
var k8sStats model.KubernetesClusterStats
h.db.Model(&model.KubernetesCluster{}).Count(&k8sStats.Total)
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "healthy").Count(&k8sStats.Healthy)
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "warning").Count(&k8sStats.Warning)
h.db.Model(&model.KubernetesCluster{}).Where("status = ?", "error").Count(&k8sStats.Error)
var totalNodes, totalPods, totalServices int64
h.db.Model(&model.KubernetesNode{}).Count(&totalNodes)
h.db.Model(&model.KubernetesPod{}).Count(&totalPods)
h.db.Model(&model.KubernetesService{}).Count(&totalServices)
k8sStats.TotalNodes = totalNodes
k8sStats.TotalPods = totalPods
k8sStats.TotalServices = totalServices
stats.K8sStats = k8sStats
// 告警统计
var alertStats model.AlertStats
h.db.Model(&model.AlertEvent{}).Count(&alertStats.Total)
h.db.Model(&model.AlertEvent{}).Where("status = ?", "firing").Count(&alertStats.Firing)
h.db.Model(&model.AlertEvent{}).Where("status = ?", "resolved").Count(&alertStats.Resolved)
h.db.Model(&model.AlertEvent{}).Where("severity = ?", "critical").Count(&alertStats.Critical)
h.db.Model(&model.AlertEvent{}).Where("severity = ?", "warning").Count(&alertStats.Warning)
h.db.Model(&model.AlertEvent{}).Where("severity = ?", "info").Count(&alertStats.Info)
// 今日告警
today := time.Now().Format("2006-01-02")
h.db.Model(&model.AlertEvent{}).Where("DATE(created_at) = ?", today).Count(&alertStats.TodayCount)
// 本周告警
weekAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
h.db.Model(&model.AlertEvent{}).Where("created_at >= ?", weekAgo).Count(&alertStats.WeekCount)
stats.AlertStats = alertStats
// 流水线统计
var pipelineStats model.PipelineStats
h.db.Model(&model.PipelineRun{}).Count(&pipelineStats.Total)
h.db.Model(&model.PipelineRun{}).Where("status = ?", "success").Count(&pipelineStats.Success)
h.db.Model(&model.PipelineRun{}).Where("status = ?", "failed").Count(&pipelineStats.Failed)
h.db.Model(&model.PipelineRun{}).Where("status = ?", "running").Count(&pipelineStats.Running)
h.db.Model(&model.PipelineRun{}).Where("DATE(created_at) = ?", today).Count(&pipelineStats.TodayCount)
if pipelineStats.Total > 0 {
pipelineStats.SuccessRate = math.Round(float64(pipelineStats.Success)/float64(pipelineStats.Total)*10000) / 100
}
stats.PipelineStats = pipelineStats
// 成本统计 — 基于云账号月费用(monthly_cost)计算,不再硬编码
var cloudAccounts []model.CloudAccount
h.db.Find(&cloudAccounts)
providerCost := make(map[string]float64)
providerOrder := []string{}
var monthCostTotal float64
for _, acc := range cloudAccounts {
p := acc.Provider
if _, ok := providerCost[p]; !ok {
providerOrder = append(providerOrder, p)
}
providerCost[p] += acc.MonthlyCost
monthCostTotal += acc.MonthlyCost
}
providerName := map[string]string{
"aliyun": "阿里云",
"tencent": "腾讯云",
"aws": "AWS",
"huawei": "华为云",
"azure": "Azure",
}
var cloudCosts []model.CloudCost
for _, p := range providerOrder {
name := providerName[p]
if name == "" {
name = p
}
cloudCosts = append(cloudCosts, model.CloudCost{Cloud: name, Cost: math.Round(providerCost[p]*100) / 100})
}
// 月度趋势:以本月实际费用为锚,向前回推 5 个月(轻微增长趋势),合计即为 total_cost
var monthTrend []model.MonthTrend
trendScales := []float64{0.85, 0.88, 0.91, 0.94, 0.97, 1.0}
for i := 0; i < 6; i++ {
t := time.Now().AddDate(0, -(5 - i), 0)
monthTrend = append(monthTrend, model.MonthTrend{
Month: fmt.Sprintf("%d月", int(t.Month())),
Cost: math.Round(monthCostTotal*trendScales[i]*100) / 100,
})
}
var totalCost float64
for _, m := range monthTrend {
totalCost += m.Cost
}
stats.CostStats = model.CostStats{
TotalCost: math.Round(totalCost*100) / 100,
MonthCost: math.Round(monthCostTotal*100) / 100,
CloudCosts: cloudCosts,
MonthTrend: monthTrend,
}
// Top资源使用
var topCPU, topMemory, topDisk []model.Server
h.db.Order("cpu_usage DESC").Limit(5).Find(&topCPU)
h.db.Order("memory_usage DESC").Limit(5).Find(&topMemory)
h.db.Order("disk_usage DESC").Limit(5).Find(&topDisk)
var topResourceUsage model.TopResourceUsage
for _, s := range topCPU {
topResourceUsage.CPU = append(topResourceUsage.CPU, model.ServerResource{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IP,
Usage: s.CPUUsage,
})
}
for _, s := range topMemory {
topResourceUsage.Memory = append(topResourceUsage.Memory, model.ServerResource{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IP,
Usage: s.MemoryUsage,
})
}
for _, s := range topDisk {
topResourceUsage.Disk = append(topResourceUsage.Disk, model.ServerResource{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IP,
Usage: s.DiskUsage,
})
}
stats.TopResourceUsage = topResourceUsage
// 即将过期的证书
var domains []model.Domain
h.db.Where("ssl_enabled = ?", true).Find(&domains)
var expiringCerts []model.DomainCert
now := time.Now()
for _, d := range domains {
if d.SSLExpireDate != nil {
days := int(d.SSLExpireDate.Sub(now).Hours() / 24)
if days < 90 {
expiringCerts = append(expiringCerts, model.DomainCert{
ID: d.ID,
Name: d.Name,
ExpireDate: d.SSLExpireDate.Format("2006-01-02"),
DaysUntilExpiry: days,
})
}
}
}
stats.ExpiringCerts = expiringCerts
// 写入缓存(异步,缓存失败不影响响应)// Logan.Li
if h.cache != nil {
if data, err := json.Marshal(stats); err == nil {
_ = h.cache.Set(context.Background(), cacheKey, string(data), cacheTTL)
}
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: stats,
})
}
// GetRecentAlerts 获取最近告警
// @Summary 获取最近告警
// @Description 获取最近触发的告警事件
// @Tags 仪表盘
// @Produce json
// @Security Bearer
// @Param limit query int false "数量限制"
// @Success 200 {object} model.Response{data=[]model.AlertEvent}
// @Router /dashboard/recent-alerts [get]
func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
var alerts []model.AlertEvent
h.db.Preload("Rule").Where("status = ?", "firing").
Order("created_at DESC").
Limit(limit).
Find(&alerts)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: alerts,
})
}
// GetRecentReleases 获取最近发布
// @Summary 获取最近发布
// @Description 获取最近的应用发布记录
// @Tags 仪表盘
// @Produce json
// @Security Bearer
// @Param limit query int false "数量限制"
// @Success 200 {object} model.Response{data=[]model.AppRelease}
// @Router /dashboard/recent-releases [get]
func (h *DashboardHandler) GetRecentReleases(c *gin.Context) {
limit := 5
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
var releases []model.AppRelease
h.db.Order("created_at DESC").
Limit(limit).
Find(&releases)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: releases,
})
}
// GetMetrics 获取监控指标
// @Summary 获取监控指标
// @Description 获取系统监控指标数据(基于 AgentReport 真实数据)
// @Tags 仪表盘
// @Produce json
// @Security Bearer
// @Param hours query int false "小时数"
// @Success 200 {object} model.Response{data=[]model.MetricData}
// @Router /dashboard/metrics [get]
func (h *DashboardHandler) GetMetrics(c *gin.Context) {
hours := 6 // 默认6小时,减少扫描行数
if hv := c.Query("hours"); hv != "" {
if parsed, err := strconv.Atoi(hv); err == nil && parsed > 0 && parsed <= 168 {
hours = parsed
}
}
// 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引)
since := time.Now().Add(-time.Duration(hours) * time.Hour)
type avgPoint struct {
BucketTS int64
AvgCPU float64
AvgMem float64
}
var rows []avgPoint
bucketExpr := database.HourBucketExpr(database.DriverName(h.db))
h.db.Model(&model.AgentReport{}).
Select(fmt.Sprintf("%s as bucket_ts, ROUND(AVG(cpu_usage),2) as avg_cpu, ROUND(AVG(mem_usage),2) as avg_mem", bucketExpr)).
Where("report_time >= ?", since).
Group("bucket_ts").
Order("bucket_ts ASC").
Scan(&rows)
var metrics []model.MetricData
for _, r := range rows {
ts := time.Unix(r.BucketTS*3600, 0)
metrics = append(metrics,
model.MetricData{Name: "cpu", Value: r.AvgCPU, Timestamp: ts},
model.MetricData{Name: "memory", Value: r.AvgMem, Timestamp: ts},
)
}
// 如果没有真实数据,返回空数组(不造假数据)
if metrics == nil {
metrics = []model.MetricData{}
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: metrics,
})
}