package httpapi import ( "context" "fmt" "time" "github.com/dtoro/oikos/internal/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/jackc/pgx/v5/pgtype" ) // ─── Metrics ─────────────────────────────────────────────────────────── func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) { if req.Params.EntityId == nil || *req.Params.EntityId == "" { return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput) } entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId) if err != nil { return nil, err } from := time.Now().Add(-24 * time.Hour) if req.Params.From != nil { from = *req.Params.From } to := time.Now() if req.Params.To != nil { to = *req.Params.To } var metricNames []string if req.Params.Metric != nil && len(*req.Params.Metric) > 0 { metricNames = *req.Params.Metric } else { // metric omitted: report every metric recorded for this entity in range. rows, err := s.pool.Query(ctx, ` SELECT DISTINCT metric FROM metric_samples WHERE entity_id = $1 AND ts >= $2 AND ts <= $3 ORDER BY metric`, entityID, from, to) if err != nil { return nil, err } for rows.Next() { var name string if err := rows.Scan(&name); err != nil { rows.Close() return nil, err } metricNames = append(metricNames, name) } if err := rows.Err(); err != nil { return nil, err } } items := []gen.MetricSeries{} for _, metricName := range metricNames { series := gen.MetricSeries{ EntityId: entityID.String(), Metric: metricName, Rollup: gen.MetricSeriesRollupRaw, } rows, err := s.pool.Query(ctx, ` SELECT ts, value FROM metric_samples WHERE entity_id = $1 AND metric = $2 AND ts >= $3 AND ts <= $4 ORDER BY ts ASC`, entityID, metricName, from, to) if err != nil { return nil, err } samples := []struct { Avg *float32 `json:"avg"` Count *int `json:"count"` Max *float32 `json:"max"` Min *float32 `json:"min"` Ts time.Time `json:"ts"` Value *float32 `json:"value"` }{} for rows.Next() { var ts time.Time var val float64 if err := rows.Scan(&ts, &val); err != nil { rows.Close() return nil, err } f := float32(val) samples = append(samples, struct { Avg *float32 `json:"avg"` Count *int `json:"count"` Max *float32 `json:"max"` Min *float32 `json:"min"` Ts time.Time `json:"ts"` Value *float32 `json:"value"` }{Value: &f, Ts: ts}) } rows.Close() if rows.Err() != nil { return nil, rows.Err() } series.Samples = samples items = append(items, series) } if items == nil { items = []gen.MetricSeries{} } return gen.QueryMetrics200JSONResponse{Items: items}, nil } func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) { entityID, err := s.resolveEntityID(ctx, req.EntityId) if err != nil { return nil, err } from := time.Now().Add(-7 * 24 * time.Hour) if req.Params.From != nil { from = *req.Params.From } rows, err := s.pool.Query(ctx, ` SELECT metric, ROUND(avg(value)::numeric, 2) AS avg_val, ROUND(stddev(value)::numeric, 2) AS std_val, count(*) AS sample_count, ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope FROM metric_samples WHERE entity_id = $1 AND ts >= $2 GROUP BY metric ORDER BY metric`, entityID, from) if err != nil { return nil, err } defer rows.Close() items := []gen.Trend{} for rows.Next() { var t gen.Trend var avgVal, stdVal, slopeNum pgtype.Numeric var sampleCount int if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil { return nil, err } // Determine direction. if slopeNum.Valid { f, _ := slopeNum.Float64Value() t.Slope = float32Ptr(float32(f.Float64)) if f.Float64 > 0.01 { t.Direction = gen.TrendDirectionImproving } else if f.Float64 < -0.01 { t.Direction = gen.TrendDirectionDegrading } else { t.Direction = gen.TrendDirectionStable } } else { t.Direction = gen.TrendDirectionUnknown } items = append(items, t) } if rows.Err() != nil { return nil, rows.Err() } if items == nil { items = []gen.Trend{} } return gen.GetTrends200JSONResponse{Items: items}, nil } func float32Ptr(f float32) *float32 { return &f }