Files
oci-portal/internal/service/overview.go
T
2026-07-22 16:51:23 +08:00

231 lines
7.2 KiB
Go

package service
import (
"context"
"fmt"
"sort"
"time"
"oci-portal/internal/model"
)
// OverviewTenants 是租户数量与类别分布。
type OverviewTenants struct {
Total int `json:"total"`
Alive int `json:"alive"`
Dead int `json:"dead"`
ByType map[string]int `json:"byType"`
}
// OverviewCheck 是测活快照聚合;实例数为各配置默认区域之和,
// CoveredConfigs/TotalConfigs 用于前端标注覆盖度。
type OverviewCheck struct {
HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"`
TotalConfigs int `json:"totalConfigs"`
InstanceCount int `json:"instanceCount"`
LastCheckedAt *time.Time `json:"lastCheckedAt"`
}
// OverviewCostDay 是一天的跨租户成本合计。
type OverviewCostDay struct {
Day string `json:"day"`
Amount float64 `json:"amount"`
}
// OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。
type OverviewCost struct {
HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"`
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
// Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个
// (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。
Series []OverviewCostSeries `json:"series"`
}
// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。
type OverviewCostSeries struct {
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
}
// OverviewTasks 是后台任务数量统计。
type OverviewTasks struct {
Total int `json:"total"`
Active int `json:"active"`
HealthCheck int `json:"healthCheck"`
Cost int `json:"cost"`
Snatch int `json:"snatch"`
}
// Overview 是总览页聚合数据。
type Overview struct {
Tenants OverviewTenants `json:"tenants"`
Check OverviewCheck `json:"check"`
Cost OverviewCost `json:"cost"`
Tasks OverviewTasks `json:"tasks"`
}
// Overview 聚合总览数据:租户分布、测活/成本快照与任务统计,全部读本地库不发云端请求。
func (s *OciConfigService) Overview(ctx context.Context) (*Overview, error) {
var out Overview
if err := s.overviewTenants(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewCheck(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewCost(ctx, &out); err != nil {
return nil, err
}
if err := s.overviewTasks(ctx, &out); err != nil {
return nil, err
}
return &out, nil
}
func (s *OciConfigService) overviewTenants(ctx context.Context, out *Overview) error {
configs, err := s.List(ctx)
if err != nil {
return err
}
out.Tenants.Total = len(configs)
out.Tenants.ByType = map[string]int{}
for _, cfg := range configs {
out.Tenants.ByType[cfg.AccountType]++
switch cfg.AliveStatus {
case model.AliveStatusAlive:
out.Tenants.Alive++
case model.AliveStatusDead, model.AliveStatusSuspended:
out.Tenants.Dead++
}
}
return nil
}
// hasActiveTask 判断是否存在指定类型且调度中的任务。
func (s *OciConfigService) hasActiveTask(ctx context.Context, taskType string) (bool, error) {
var n int64
err := s.db.WithContext(ctx).Model(&model.Task{}).
Where("type = ? AND status = ?", taskType, model.TaskStatusActive).Count(&n).Error
if err != nil {
return false, fmt.Errorf("count %s tasks: %w", taskType, err)
}
return n > 0, nil
}
func (s *OciConfigService) overviewCheck(ctx context.Context, out *Overview) error {
has, err := s.hasActiveTask(ctx, model.TaskTypeHealthCheck)
if err != nil {
return err
}
out.Check.HasActiveTask = has
out.Check.TotalConfigs = out.Tenants.Total
var snaps []model.CheckSnapshot
err = s.db.WithContext(ctx).
Where("oci_config_id IN (?)", s.db.Model(&model.OciConfig{}).Select("id")).
Find(&snaps).Error
if err != nil {
return fmt.Errorf("load check snapshots: %w", err)
}
out.Check.CoveredConfigs = len(snaps)
for _, snap := range snaps {
out.Check.InstanceCount += snap.InstanceCount
if out.Check.LastCheckedAt == nil || snap.CheckedAt.After(*out.Check.LastCheckedAt) {
t := snap.CheckedAt
out.Check.LastCheckedAt = &t
}
}
return nil
}
func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) error {
has, err := s.hasActiveTask(ctx, model.TaskTypeCost)
if err != nil {
return err
}
out.Cost.HasActiveTask = has
since := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
var snaps []model.CostSnapshot
err = s.db.WithContext(ctx).
Where("day >= ? AND oci_config_id IN (?)", since, s.db.Model(&model.OciConfig{}).Select("id")).
Order("day").Find(&snaps).Error
if err != nil {
return fmt.Errorf("load cost snapshots: %w", err)
}
aggregateCostDays(&out.Cost, snaps)
return nil
}
// aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)。
// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的
// 主币种,其余币种只出现在 Series 里。
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
perCur := map[string]map[string]float64{}
dayOrder := map[string][]string{}
covered := map[uint]bool{}
for _, snap := range snaps {
byDay := perCur[snap.Currency]
if byDay == nil {
byDay = map[string]float64{}
perCur[snap.Currency] = byDay
}
if _, ok := byDay[snap.Day]; !ok {
dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day)
}
byDay[snap.Day] += snap.Amount
covered[snap.OciConfigID] = true
}
cost.CoveredConfigs = len(covered)
cost.Days = []OverviewCostDay{}
cost.Series = costSeries(perCur, dayOrder)
if len(cost.Series) > 0 {
cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days
}
}
// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。
func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries {
series := make([]OverviewCostSeries, 0, len(perCur))
for currency, byDay := range perCur {
item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))}
for _, day := range dayOrder[currency] {
item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
item.Total += byDay[day]
}
series = append(series, item)
}
sort.Slice(series, func(i, j int) bool {
if series[i].Total != series[j].Total {
return series[i].Total > series[j].Total
}
return series[i].Currency < series[j].Currency
})
return series
}
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
var tasks []model.Task
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
return fmt.Errorf("list tasks: %w", err)
}
out.Tasks.Total = len(tasks)
for _, t := range tasks {
if t.Status == model.TaskStatusActive {
out.Tasks.Active++
}
switch t.Type {
case model.TaskTypeHealthCheck:
out.Tasks.HealthCheck++
case model.TaskTypeCost:
out.Tasks.Cost++
case model.TaskTypeSnatch:
out.Tasks.Snatch++
}
}
return nil
}