package web import ( "net/http" "sort" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // ChartData holds aggregated time-series data for Chart.js. type ChartData struct { Labels []string `json:"labels"` AvgMemory []float64 `json:"avg_memory"` PeakMemory []float64 `json:"peak_memory"` CatalogLimit float64 `json:"catalog_limit"` } // handleApps renders the fleet-wide app list page. func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { period := r.URL.Query().Get("period") since := parsePeriod(period, 7*24*time.Hour) sortBy := r.URL.Query().Get("sort") order := r.URL.Query().Get("order") summary, err := s.store.GetFleetAppSummary(since) if err != nil { s.logger.Printf("[ERROR] GetFleetAppSummary: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } sortFleetSummary(summary, sortBy, order) totalApps := len(summary) totalDeployments := 0 appsWithErrors := 0 for _, app := range summary { totalDeployments += app.DeploymentCount if app.TotalErrors > 0 { appsWithErrors++ } } csrfToken := s.getCSRFToken(r) data := map[string]interface{}{ "Apps": summary, "Period": period, "TotalApps": totalApps, "TotalDeployments": totalDeployments, "AppsWithErrors": appsWithErrors, "Sort": sortBy, "Order": order, "CSRFToken": csrfToken, } if err := s.templates.ExecuteTemplate(w, "apps.html", data); err != nil { s.logger.Printf("[ERROR] apps.html template: %v", err) } } // handleAppDetail renders the per-app detail page. func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName string) { period := r.URL.Query().Get("period") since := parsePeriod(period, 7*24*time.Hour) customers, _ := s.store.GetAppCustomerBreakdown(appName, since) history, _ := s.store.GetAppTelemetryHistory(appName, since) issues, _ := s.store.GetAppIssues(appName, 20) // Get fleet summary to find this app's summary fleetAll, _ := s.store.GetFleetAppSummary(since) var appSummary *store.FleetAppSummary for i := range fleetAll { if fleetAll[i].AppName == appName { appSummary = &fleetAll[i] break } } // Suggested mem_limit: ceil(P95 * 1.2), rounded up to nearest 32M var suggestedLimit int if appSummary != nil && appSummary.P95MemoryMB > 0 { raw := appSummary.P95MemoryMB * 1.2 suggestedLimit = ((int(raw) + 31) / 32) * 32 } chartData := aggregateHistoryForChart(history, appSummary) csrfToken := s.getCSRFToken(r) data := map[string]interface{}{ "AppName": appName, "Summary": appSummary, "Customers": customers, "Issues": issues, "ChartData": chartData, "SuggestedLimit": suggestedLimit, "Period": period, "CSRFToken": csrfToken, } if err := s.templates.ExecuteTemplate(w, "app_detail.html", data); err != nil { s.logger.Printf("[ERROR] app_detail.html template: %v", err) } } // parsePeriod converts a period string to a time.Time cutoff. func parsePeriod(s string, defaultDur time.Duration) time.Time { switch s { case "24h": return time.Now().Add(-24 * time.Hour) case "7d": return time.Now().Add(-7 * 24 * time.Hour) case "30d": return time.Now().Add(-30 * 24 * time.Hour) default: return time.Now().Add(-defaultDur) } } // sortFleetSummary sorts the fleet summary slice in place. func sortFleetSummary(summary []store.FleetAppSummary, sortBy, order string) { desc := order != "asc" sort.Slice(summary, func(i, j int) bool { var less bool switch sortBy { case "memory": less = summary[i].AvgMemoryMB < summary[j].AvgMemoryMB case "errors": less = summary[i].TotalErrors < summary[j].TotalErrors default: // deployments less = summary[i].DeploymentCount < summary[j].DeploymentCount } if desc { return !less } return less }) } // aggregateHistoryForChart groups history points into hourly buckets for Chart.js. func aggregateHistoryForChart(history []store.AppTelemetryPoint, summary *store.FleetAppSummary) ChartData { type bucket struct { avgMemSum float64 peakMemMax float64 count int } buckets := make(map[string]*bucket) var bucketOrder []string for _, p := range history { key := p.ReportedAt.UTC().Format("2006-01-02 15:00") if _, ok := buckets[key]; !ok { buckets[key] = &bucket{} bucketOrder = append(bucketOrder, key) } b := buckets[key] b.avgMemSum += p.MemoryAvgMB if p.MemoryPeakMB > b.peakMemMax { b.peakMemMax = p.MemoryPeakMB } b.count++ } cd := ChartData{ Labels: make([]string, 0, len(bucketOrder)), AvgMemory: make([]float64, 0, len(bucketOrder)), PeakMemory: make([]float64, 0, len(bucketOrder)), } for _, key := range bucketOrder { b := buckets[key] cd.Labels = append(cd.Labels, key) avgMem := 0.0 if b.count > 0 { avgMem = b.avgMemSum / float64(b.count) } cd.AvgMemory = append(cd.AvgMemory, round2(avgMem)) cd.PeakMemory = append(cd.PeakMemory, round2(b.peakMemMax)) } if summary != nil { cd.CatalogLimit = parseLimitMB(summary.CatalogLimit) } return cd } // parseLimitMB parses a memory limit string like "512M" or "2G" to MB float64. func parseLimitMB(s string) float64 { s = strings.TrimSpace(s) if s == "" { return 0 } s = strings.ToUpper(s) if strings.HasSuffix(s, "G") { v, _ := strconv.ParseFloat(s[:len(s)-1], 64) return v * 1024 } if strings.HasSuffix(s, "M") { v, _ := strconv.ParseFloat(s[:len(s)-1], 64) return v } v, _ := strconv.ParseFloat(s, 64) return v } // round2 rounds a float64 to 2 decimal places. func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } // memoryColor returns a CSS class based on current memory vs catalog limit. func memoryColor(currentMB float64, limitStr string) string { limit := parseLimitMB(limitStr) if limit <= 0 { return "" } ratio := currentMB / limit if ratio >= 1.0 { return "mem-danger" } if ratio >= 0.5 { return "mem-warn" } return "mem-ok" } // accuracyClass returns accuracy indicator CSS class for P95 vs catalog limit. func accuracyClass(p95MB float64, limitStr string) string { if p95MB <= 0 || limitStr == "" { return "" } limit := parseLimitMB(limitStr) if limit <= 0 { return "" } if p95MB > limit { return "danger" } if p95MB*2 > limit { return "warn" } return "ok" } // getCSRFToken retrieves the CSRF token from the session cookie. func (s *Server) getCSRFToken(r *http.Request) string { cookie, err := r.Cookie("hub_session") if err != nil { return "" } s.sessionsMu.RLock() defer s.sessionsMu.RUnlock() if sess, ok := s.sessions[cookie.Value]; ok { return sess.csrfToken } return "" }