Files
HY2XS_flamy/apps/service/metrics_collector.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

183 lines
5.4 KiB
Go

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
}