package api import ( "errors" "log/slog" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/stats" "github.com/alexei/tinyforge/internal/store" ) const ( // defaultHistoryWindow is used when no ?window= param is provided or the // value fails to parse. Matches the default retention so the "last 2h" // view always has data when collection is enabled. defaultHistoryWindow = 2 * time.Hour maxHistoryWindow = 24 * time.Hour ) // parseWindow reads the ?window= query (Go duration string, e.g. "1h", "30m") // and returns a bounded duration. func parseWindow(r *http.Request) time.Duration { raw := r.URL.Query().Get("window") if raw == "" { return defaultHistoryWindow } d, err := time.ParseDuration(raw) if err != nil || d <= 0 { return defaultHistoryWindow } if d > maxHistoryWindow { return maxHistoryWindow } return d } // sinceTimestamp converts a duration into a Unix-seconds cutoff. func sinceTimestamp(window time.Duration) int64 { return time.Now().UTC().Add(-window).Unix() } // getSystemStats handles GET /api/system/stats — current host snapshot. // When the Docker daemon is unreachable (e.g. Docker Desktop stopped) the // handler returns 503 so the frontend can show a dedicated unavailable // state instead of treating it as a generic 5xx failure. func (s *Server) getSystemStats(w http.ResponseWriter, r *http.Request) { if s.docker == nil { respondError(w, http.StatusServiceUnavailable, "Docker is not available") return } sys, err := s.docker.GetSystemStats(r.Context()) if err != nil { slog.Warn("system stats unavailable", "error", err) respondError(w, http.StatusServiceUnavailable, "Docker is not available") return } respondJSON(w, http.StatusOK, sys) } // getSystemStatsHistory handles GET /api/system/stats/history?window=1h. func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) { samples, err := s.store.ListSystemStatsSamples(sinceTimestamp(parseWindow(r))) if err != nil { slog.Error("failed to list system stats samples", "error", err) respondError(w, http.StatusInternalServerError, "failed to list samples") return } if samples == nil { samples = []store.SystemStatsSample{} } respondJSON(w, http.StatusOK, samples) } // getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history. func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) { instanceID := chi.URLParam(r, "iid") if _, err := s.store.GetInstanceByID(instanceID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "instance") return } slog.Error("failed to get instance", "instance_id", instanceID, "error", err) respondError(w, http.StatusInternalServerError, "failed to get instance") return } samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r))) if err != nil { slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err) respondError(w, http.StatusInternalServerError, "failed to list samples") return } if samples == nil { samples = []store.ContainerStatsSample{} } respondJSON(w, http.StatusOK, samples) } // getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot. func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") site, err := s.store.GetStaticSiteByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "site") return } slog.Error("failed to get site", "site_id", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get site") return } if site.ContainerID == "" { respondError(w, http.StatusConflict, "site has no container") return } if s.docker == nil { respondError(w, http.StatusServiceUnavailable, "Docker is not available") return } cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID) if err != nil { slog.Error("failed to get site stats", "site_id", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get site stats") return } respondJSON(w, http.StatusOK, cs) } // getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history. func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetStaticSiteByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "site") return } slog.Error("failed to get site", "site_id", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get site") return } samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r))) if err != nil { slog.Error("failed to list site stats samples", "site_id", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to list samples") return } if samples == nil { samples = []store.ContainerStatsSample{} } respondJSON(w, http.StatusOK, samples) } // streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true. // Reuses the shared container log streamer so the SSE + multiplex handling // matches /api/projects/.../instances/.../logs exactly. func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") site, err := s.store.GetStaticSiteByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "site") return } slog.Error("failed to get site", "site_id", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get site") return } if site.ContainerID == "" { respondError(w, http.StatusConflict, "site has no container") return } s.streamLogsForContainer(w, r, site.ContainerID) } // listTopContainersByCPU handles GET /api/system/stats/top?limit=5&by=cpu. // Returns the top-N most recent samples across containers, sorted by CPU or // memory. Useful for a system dashboard "top consumers" widget without // requiring the frontend to aggregate per-container history on its own. func (s *Server) listTopContainersByCPU(w http.ResponseWriter, r *http.Request) { limit := 5 if raw := r.URL.Query().Get("limit"); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 50 { limit = n } } by := r.URL.Query().Get("by") if by != "memory" { by = "cpu" } // Samples from the last 2 minutes window so "top" reflects near-current // load, not long-dead rows. samples, err := s.store.ListAllRecentContainerStatsSamples(sinceTimestamp(2 * time.Minute)) if err != nil { slog.Error("failed to list container samples for top", "error", err) respondError(w, http.StatusInternalServerError, "failed to list samples") return } // Keep only the latest sample per container. latest := make(map[string]store.ContainerStatsSample, len(samples)) for _, sm := range samples { if prev, ok := latest[sm.ContainerID]; !ok || sm.TS > prev.TS { latest[sm.ContainerID] = sm } } top := make([]store.ContainerStatsSample, 0, len(latest)) for _, sm := range latest { top = append(top, sm) } // Partial-sort by the requested metric, descending. For small N a simple // insertion-like approach is plenty. sortContainerSamples(top, by) if len(top) > limit { top = top[:limit] } respondJSON(w, http.StatusOK, top) } // sortContainerSamples sorts in place by CPU (or memory) descending. // Note: ListContainerStatsSamples with empty ownerID returns no rows — the // caller uses per-owner-type queries and merges; this helper is applied to // the already-merged slice. func sortContainerSamples(s []store.ContainerStatsSample, by string) { // O(n^2) is fine — N is small (bounded by the number of containers). for i := 1; i < len(s); i++ { for j := i; j > 0; j-- { var less bool if by == "memory" { less = s[j].MemoryUsage > s[j-1].MemoryUsage } else { less = s[j].CPUPercent > s[j-1].CPUPercent } if !less { break } s[j-1], s[j] = s[j], s[j-1] } } }