package server import ( "context" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/danielgtaylor/huma/v2" "glchat/internal/sysinfo" ) // metricsPath — ручка живого дашборда инстанса. Вынесена в константу: её // опрос раз в секунду исключён из общего лимита API (см. registerRoutes). const metricsPath = "/api/v1/instance/metrics" // healthCacheTTL — как долго переиспользуются результаты тяжёлых проверок // (запрос к БД и к SFU). Метрики при этом считаются на каждый запрос, поэтому // ручку можно опрашивать раз в секунду. const healthCacheTTL = 5 * time.Second // healthCache хранит последний healthcheck: дашборд обновляется раз в секунду, // а проверки с сетевыми вызовами чаще раза в healthCacheTTL не нужны. type healthCache struct { mu sync.Mutex checks []metricsCheck at time.Time } type metricsCPU struct { Percent float64 `json:"percent" doc:"Загрузка процессора в процентах от доступного времени"` Cores float64 `json:"cores" doc:"Доступные ядра (лимит контейнера или ядра хоста)"` Source string `json:"source" doc:"Источник данных: cgroup или host"` } type metricsMemory struct { UsedBytes int64 `json:"used_bytes"` TotalBytes int64 `json:"total_bytes"` Percent float64 `json:"percent"` Source string `json:"source" doc:"Источник данных: cgroup (лимит) или host"` } type metricsDisk struct { UsedBytes int64 `json:"used_bytes"` TotalBytes int64 `json:"total_bytes"` FreeBytes int64 `json:"free_bytes"` } type metricsDatabase struct { Bytes int64 `json:"bytes" doc:"Размер файла базы"` WalBytes int64 `json:"wal_bytes" doc:"Размер журнала WAL"` } // metricsCheck — одна проверка инстанса. name и status машиночитаемые, текст // сообщения технический (локализуется на клиенте по name). type metricsCheck struct { Name string `json:"name"` Status string `json:"status" enum:"ok,warn,failed,off"` Message string `json:"message,omitempty"` } type metricsPayload struct { CPU metricsCPU `json:"cpu"` Memory metricsMemory `json:"memory"` HostCPU metricsCPU `json:"host_cpu"` HostMemory metricsMemory `json:"host_memory"` Disk metricsDisk `json:"disk"` Database metricsDatabase `json:"database"` Checks []metricsCheck `json:"checks"` Health string `json:"health" enum:"ok,warn,failed"` UptimeSeconds int64 `json:"uptime_seconds"` Version string `json:"version"` Commit string `json:"commit"` CollectedAt string `json:"collected_at"` } type metricsOutput struct { Body struct { Metrics metricsPayload `json:"metrics"` } } // registerMetricsRoutes описывает дашборд инстанса: живые метрики, состояние // диска и базы, healthcheck и версия (AGENT.md 7.19). func (s *Server) registerMetricsRoutes(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "getInstanceMetrics", Method: http.MethodGet, Path: "/instance/metrics", Summary: "Живые метрики инстанса (только администратор)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, _ *struct{}) (*metricsOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } if !user.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "instance admin is required") } // Свой лимит: раз в секунду — это 60 запросов в минуту, под общим // лимитом API такой опрос съедал бы половину бюджета (AGENT.md 8.6). if allowed, retryAfter := s.metricsLimiter.Allow("metrics:" + formatSnowflake(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } output := &metricsOutput{} output.Body.Metrics = s.collectMetrics(ctx) return output, nil }) } // collectMetrics собирает метрики и (при необходимости) обновляет healthcheck. // Недоступные источники не ломают ответ: они попадают в проверку «metrics», // а соответствующий блок остаётся пустым (так ведёт себя, например, macOS без // cgroup и /proc). func (s *Server) collectMetrics(ctx context.Context) metricsPayload { payload := metricsPayload{ UptimeSeconds: int64(time.Since(startedAt).Seconds()), Version: s.cfg.Version, Commit: s.cfg.Commit, CollectedAt: time.Now().UTC().Format(time.RFC3339Nano), } var problems []string if s.cpuSampler == nil { problems = append(problems, "cpu: sampler is not configured") } else if percent, cores, source, err := s.cpuSampler.CPUPercent(); err != nil { problems = append(problems, "cpu: "+err.Error()) } else { payload.CPU = metricsCPU{Percent: round1(percent), Cores: cores, Source: source} } if memory, err := sysinfo.ReadMemory(s.sysinfoPaths); err != nil { problems = append(problems, "memory: "+err.Error()) } else { payload.Memory = metricsMemory{ UsedBytes: memory.UsedBytes, TotalBytes: memory.TotalBytes, Percent: round1(memory.Percent()), Source: memory.Source, } } // Второй ряд дашборда — вся машина: видно, сколько ресурсов у хоста всего. if s.hostCPUSampler == nil { problems = append(problems, "host cpu: sampler is not configured") } else if percent, cores, source, err := s.hostCPUSampler.CPUPercent(); err != nil { problems = append(problems, "host cpu: "+err.Error()) } else { payload.HostCPU = metricsCPU{Percent: round1(percent), Cores: cores, Source: source} } if memory, err := sysinfo.ReadHostMemory(s.sysinfoPaths); err != nil { problems = append(problems, "host memory: "+err.Error()) } else { payload.HostMemory = metricsMemory{ UsedBytes: memory.UsedBytes, TotalBytes: memory.TotalBytes, Percent: round1(memory.Percent()), Source: memory.Source, } } if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil { problems = append(problems, "disk: "+err.Error()) } else { payload.Disk = metricsDisk{ UsedBytes: disk.UsedBytes, TotalBytes: disk.TotalBytes, FreeBytes: disk.FreeBytes, } } dbPath := s.databasePath() payload.Database = metricsDatabase{ Bytes: sysinfo.FileBytes(dbPath), // WAL живёт рядом с базой и в пике бывает больше её самой. WalBytes: sysinfo.FileBytes(dbPath + "-wal"), } payload.Checks = s.instanceChecks(ctx, problems) payload.Health = summarizeHealth(payload.Checks) return payload } // databasePath возвращает путь к файлу базы: в конфиге он может быть не задан // (сборка конфига вручную, тесты), тогда берём его у открытой базы. func (s *Server) databasePath() string { if s.cfg.DatabasePath != "" { return s.cfg.DatabasePath } if s.db != nil { return s.db.Path() } return "" } // instanceChecks отдаёт проверки инстанса из кэша либо пересчитывает их. func (s *Server) instanceChecks(ctx context.Context, problems []string) []metricsCheck { s.health.mu.Lock() defer s.health.mu.Unlock() if s.health.checks == nil || time.Since(s.health.at) >= healthCacheTTL { s.health.checks = s.buildChecks(ctx) s.health.at = time.Now() } if len(problems) == 0 { return s.health.checks } // Проблемы с источниками метрик показываем отдельной проверкой, не затирая // кэш: причина обычно постоянная (нет cgroup), а не разовая. checks := make([]metricsCheck, 0, len(s.health.checks)+1) checks = append(checks, s.health.checks...) checks = append(checks, metricsCheck{ Name: "metrics", Status: "warn", Message: strings.Join(problems, "; "), }) return checks } // buildChecks выполняет проверки: приложение, база, диск, SFU, Gateway. func (s *Server) buildChecks(ctx context.Context) []metricsCheck { checks := []metricsCheck{{ Name: "app", Status: "ok", Message: s.cfg.Version, }} // База: ping и версия схемы (миграции применяются при старте). if s.db != nil { checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second) if err := s.db.Ready(checkCtx); err != nil { checks = append(checks, metricsCheck{Name: "database", Status: "failed", Message: err.Error()}) } else { version, err := s.db.SchemaVersion(checkCtx) switch { case err != nil: checks = append(checks, metricsCheck{Name: "database", Status: "warn", Message: err.Error()}) default: checks = append(checks, metricsCheck{ Name: "database", Status: "ok", Message: fmt.Sprintf("schema_version=%d", version), }) } } cancel() } else { checks = append(checks, metricsCheck{Name: "database", Status: "off"}) } // Диск: предупреждаем заранее, до заполнения раздела. if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil { checks = append(checks, metricsCheck{Name: "disk", Status: "warn", Message: err.Error()}) } else { status := "ok" switch freePercent := float64(disk.FreeBytes) / float64(disk.TotalBytes) * 100; { case freePercent < 5: status = "failed" case freePercent < 15: status = "warn" } checks = append(checks, metricsCheck{ Name: "disk", Status: status, Message: fmt.Sprintf("%.1f%% free", float64(disk.FreeBytes)/float64(disk.TotalBytes)*100), }) } // Запись в каталог данных: база, вложения и звуки должны быть записываемы. checks = append(checks, s.checkDataWritable()) // SFU: заодно проверяются ключи LiveKit — их неверность ломает модерацию. switch { case !s.voice.Enabled(): checks = append(checks, metricsCheck{Name: "voice", Status: "off"}) case !s.voiceAdmin.Enabled(): checks = append(checks, metricsCheck{Name: "voice", Status: "off", Message: "api url is not set"}) default: checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() if err := s.voiceAdmin.Ping(checkCtx); err != nil { checks = append(checks, metricsCheck{Name: "voice", Status: "failed", Message: err.Error()}) } else { checks = append(checks, metricsCheck{Name: "voice", Status: "ok", Message: "RoomService"}) } } if s.gateway != nil { checks = append(checks, metricsCheck{ Name: "gateway", Status: "ok", Message: fmt.Sprintf("sessions=%d", s.gateway.ActiveSessions()), }) } return checks } // checkDataWritable проверяет, что в каталоге данных можно создавать файлы. func (s *Server) checkDataWritable() metricsCheck { probe := filepath.Join(s.cfg.DataDir, ".healthcheck") // Путь собирается из каталога данных инстанса, а не из пользовательского ввода. file, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // каталог данных задаёт конфигурация if err != nil { return metricsCheck{Name: "storage", Status: "failed", Message: err.Error()} } _ = file.Close() _ = os.Remove(probe) return metricsCheck{Name: "storage", Status: "ok"} } // summarizeHealth сводит проверки к общему состоянию: failed → failed, // warn/off пропускаем как «не критично», иначе ok. func summarizeHealth(checks []metricsCheck) string { summary := "ok" for _, check := range checks { switch check.Status { case "failed": return "failed" case "warn": summary = "warn" } } return summary } func round1(value float64) float64 { return float64(int(value*10+0.5)) / 10 }