ops_dashboard.go 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "log"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. )
  10. func (s *OpsService) GetDashboardOverview(ctx context.Context, filter *OpsDashboardFilter) (*OpsDashboardOverview, error) {
  11. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  12. return nil, err
  13. }
  14. if s.opsRepo == nil {
  15. return nil, infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available")
  16. }
  17. if filter == nil {
  18. return nil, infraerrors.BadRequest("OPS_FILTER_REQUIRED", "filter is required")
  19. }
  20. if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
  21. return nil, infraerrors.BadRequest("OPS_TIME_RANGE_REQUIRED", "start_time/end_time are required")
  22. }
  23. if filter.StartTime.After(filter.EndTime) {
  24. return nil, infraerrors.BadRequest("OPS_TIME_RANGE_INVALID", "start_time must be <= end_time")
  25. }
  26. // Resolve query mode (requested via query param, or DB default).
  27. filter.QueryMode = s.resolveOpsQueryMode(ctx, filter.QueryMode)
  28. overview, err := s.opsRepo.GetDashboardOverview(ctx, filter)
  29. if err != nil && shouldFallbackOpsPreagg(filter, err) {
  30. rawFilter := cloneOpsFilterWithMode(filter, OpsQueryModeRaw)
  31. overview, err = s.opsRepo.GetDashboardOverview(ctx, rawFilter)
  32. }
  33. if err != nil {
  34. if errors.Is(err, ErrOpsPreaggregatedNotPopulated) {
  35. return nil, infraerrors.Conflict("OPS_PREAGG_NOT_READY", "Pre-aggregated ops metrics are not populated yet")
  36. }
  37. return nil, err
  38. }
  39. // Best-effort system health + jobs; dashboard metrics should still render if these are missing.
  40. if metrics, err := s.opsRepo.GetLatestSystemMetrics(ctx, 1); err == nil {
  41. // Attach config-derived limits so the UI can show "current / max" for connection pools.
  42. // These are best-effort and should never block the dashboard rendering.
  43. if s != nil && s.cfg != nil {
  44. if s.cfg.Database.MaxOpenConns > 0 {
  45. metrics.DBMaxOpenConns = intPtr(s.cfg.Database.MaxOpenConns)
  46. }
  47. if s.cfg.Redis.PoolSize > 0 {
  48. metrics.RedisPoolSize = intPtr(s.cfg.Redis.PoolSize)
  49. }
  50. }
  51. overview.SystemMetrics = metrics
  52. } else if err != nil && !errors.Is(err, sql.ErrNoRows) {
  53. log.Printf("[Ops] GetLatestSystemMetrics failed: %v", err)
  54. }
  55. if heartbeats, err := s.opsRepo.ListJobHeartbeats(ctx); err == nil {
  56. overview.JobHeartbeats = heartbeats
  57. } else {
  58. log.Printf("[Ops] ListJobHeartbeats failed: %v", err)
  59. }
  60. overview.HealthScore = computeDashboardHealthScore(time.Now().UTC(), overview)
  61. return overview, nil
  62. }
  63. func (s *OpsService) resolveOpsQueryMode(ctx context.Context, requested OpsQueryMode) OpsQueryMode {
  64. if requested.IsValid() {
  65. // Allow "auto" to be disabled via config until preagg is proven stable in production.
  66. // Forced `preagg` via query param still works.
  67. if requested == OpsQueryModeAuto && s != nil && s.cfg != nil && !s.cfg.Ops.UsePreaggregatedTables {
  68. return OpsQueryModeRaw
  69. }
  70. return requested
  71. }
  72. mode := OpsQueryModeAuto
  73. if s != nil && s.settingRepo != nil {
  74. if raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsQueryModeDefault); err == nil {
  75. mode = ParseOpsQueryMode(raw)
  76. }
  77. }
  78. if mode == OpsQueryModeAuto && s != nil && s.cfg != nil && !s.cfg.Ops.UsePreaggregatedTables {
  79. return OpsQueryModeRaw
  80. }
  81. return mode
  82. }