package service import ( "regexp" "strings" "github.com/sirupsen/logrus" "hy2xs-admin/dao" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/util" "sync" "time" ) const hysteriaVersionCacheTTL = 15 * time.Minute const statsRetention = 7 * 24 * time.Hour var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) var hysteriaVersionRe = regexp.MustCompile(`(?i)v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?`) func normalizeHysteriaVersion(raw string) string { cleaned := strings.TrimSpace(raw) cleaned = ansiRe.ReplaceAllString(cleaned, "") match := hysteriaVersionRe.FindString(cleaned) if match == "" { return "-" } return strings.TrimSpace(match) } type metricsSnapshot struct { CollectedAt int64 System vo.DashboardSystemVo Hysteria vo.DashboardHysteriaVo OnlinePeers int64 OnlineDevices int64 CollectorState vo.DataHealthVo HysteriaState vo.DataHealthVo } var metricsStore = struct { sync.RWMutex snapshot metricsSnapshot lastSuccessAt int64 version string versionAt time.Time }{} func getCachedHysteriaVersion(now time.Time) string { metricsStore.RLock() if metricsStore.version != "" && now.Sub(metricsStore.versionAt) < hysteriaVersionCacheTTL { v := metricsStore.version metricsStore.RUnlock() return v } metricsStore.RUnlock() content, err := util.Exec(util.GetHysteria2BinPath() + " version") if err != nil { metricsStore.RLock() v := metricsStore.version metricsStore.RUnlock() if v != "" { return v } return "-" } normalized := normalizeHysteriaVersion(content) if normalized == "-" { logrus.Debugf("hysteria version parse failed, raw output: %q", strings.TrimSpace(content)) } metricsStore.Lock() metricsStore.version = normalized metricsStore.versionAt = now metricsStore.Unlock() return normalized } func CollectMetricsSnapshot() { nowMs := time.Now().UnixMilli() s := metricsSnapshot{ CollectedAt: nowMs, CollectorState: vo.DataHealthVo{Status: "ok", LastSuccessAt: nowMs}, HysteriaState: vo.DataHealthVo{Status: "ok", LastSuccessAt: nowMs}, } if cpuPercent, err := util.GetCpuPercent(); err == nil { s.System.CpuPercent = cpuPercent } if memPercent, err := util.GetMemPercent(); err == nil { s.System.MemPercent = memPercent } if memInfo, err := util.GetMemInfo(); err == nil { s.System.MemUsedBytes = memInfo.Used s.System.MemTotalBytes = memInfo.Total } if diskPercent, err := util.GetDiskPercent(); err == nil { s.System.DiskPercent = diskPercent } if diskInfo, err := util.GetDiskInfo(); err == nil { s.System.DiskUsedBytes = diskInfo.Used s.System.DiskTotalBytes = diskInfo.Total } // Два независимых источника, два независимых вопроса. // // Раньше второй выводился из первого: общий Hysteria2Online отдавал пустую // карту БЕЗ ошибки, когда systemctl говорил «служба неактивна», и сюда // приходило `err == nil` — то есть `ApiReachable = true` выставлялся, ни // разу не сходив в Traffic Stats API. Дашборд утверждал «Hysteria // остановлена» и «API доступен, онлайн 0» одновременно. serviceState := Hysteria2ServiceState() s.Hysteria.ServiceState = string(serviceState) s.Hysteria.Running = serviceState == HysteriaServiceActive s.Hysteria.Version = getCachedHysteriaVersion(time.Now()) if onlineMap, err := Hysteria2Online(); err == nil { s.Hysteria.ApiReachable = true s.OnlinePeers = int64(len(onlineMap)) for _, c := range onlineMap { s.OnlineDevices += c } } else { s.Hysteria.ApiReachable = false s.Hysteria.LastApiError = "dashboard.error.trafficApiUnavailable" s.HysteriaState.Status = "error" s.HysteriaState.MessageKey = "dashboard.error.trafficApiUnavailable" } metricsStore.Lock() metricsStore.snapshot = s metricsStore.lastSuccessAt = nowMs metricsStore.Unlock() running := int64(0) if s.Hysteria.Running { running = 1 } diskPath := "/" if configured := util.GetEnvDiskPath(); configured != "" { diskPath = configured } metric := entity.MetricSample{ SampledAt: &nowMs, CpuPercent: &s.System.CpuPercent, Load1: func() *float64 { v := float64(0); return &v }(), MemUsedBytes: func() *int64 { v := int64(s.System.MemUsedBytes); return &v }(), MemTotalBytes: func() *int64 { v := int64(s.System.MemTotalBytes); return &v }(), MemPercent: &s.System.MemPercent, DiskPath: &diskPath, DiskUsedBytes: func() *int64 { v := int64(s.System.DiskUsedBytes); return &v }(), DiskTotalBytes: func() *int64 { v := int64(s.System.DiskTotalBytes); return &v }(), DiskPercent: &s.System.DiskPercent, HysteriaRunning: &running, OnlinePeers: &s.OnlinePeers, OnlineDevices: &s.OnlineDevices, } _ = dao.SaveMetricSample(metric) } func CleanupStatsRetention() { now := time.Now() cutoffMs := now.Add(-statsRetention).UnixMilli() hourMs := int64(time.Hour / time.Millisecond) dayMs := int64(24 * time.Hour / time.Millisecond) cutoffHour := cutoffMs - (cutoffMs % hourMs) cutoffDay := cutoffMs - (cutoffMs % dayMs) _ = dao.CleanupMetricSample(cutoffMs) _ = dao.CleanupTrafficSample(cutoffMs) _ = dao.CleanupTrafficAggregateHourly(cutoffHour) _ = dao.CleanupTrafficAggregateDaily(cutoffDay) } func DashboardSnapshot() metricsSnapshot { metricsStore.RLock() defer metricsStore.RUnlock() return metricsStore.snapshot }