// backend/performance.go package main import ( "bytes" "context" "encoding/json" "io" "math" "net/http" "os" "runtime" "strconv" "strings" "sync/atomic" "time" gocpu "github.com/shirou/gopsutil/v3/cpu" godisk "github.com/shirou/gopsutil/v3/disk" ) var serverStartedAt = time.Now() var lastCPUUsageBits uint64 // atomic float64 bits func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) } func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) } func buildPerfSnapshot() map[string]any { var ms runtime.MemStats runtime.ReadMemStats(&ms) s := getSettings() recordDir, _ := resolvePathRelativeToApp(s.RecordDir) var diskFreeBytes uint64 var diskTotalBytes uint64 var diskUsedPercent float64 diskPath := recordDir if recordDir != "" { if u, err := godisk.Usage(recordDir); err == nil && u != nil { diskFreeBytes = u.Free diskTotalBytes = u.Total diskUsedPercent = u.UsedPercent } } // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() resp := map[string]any{ "ts": time.Now().UTC().Format(time.RFC3339Nano), "serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx) "uptimeSec": time.Since(serverStartedAt).Seconds(), "cpuPercent": func() float64 { v := getLastCPUUsage() if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { return 0 } if v == 0 { // Fallback: direkt messen, falls Controller nicht läuft / noch nicht gemessen hat if p, err := gocpu.Percent(0, false); err == nil && len(p) > 0 { return p[0] } } return v }(), "diskPath": diskPath, "diskFreeBytes": diskFreeBytes, "diskTotalBytes": diskTotalBytes, "diskUsedPercent": diskUsedPercent, "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, // ✅ statt LowDiskPauseBelowGB aus Settings "diskPauseBelowGB": pauseGB, "diskResumeAboveGB": resumeGB, // ✅ optional, aber sehr hilfreich (Debug/UI) "diskInFlightBytes": inFlight, "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), "diskPauseNeedBytes": pauseNeed, "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), "diskResumeNeedBytes": resumeNeed, "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), "goroutines": runtime.NumGoroutine(), "mem": map[string]any{ "alloc": ms.Alloc, "heapAlloc": ms.HeapAlloc, "heapInuse": ms.HeapInuse, "sys": ms.Sys, "numGC": ms.NumGC, }, } sem := map[string]any{} if genSem != nil { sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()} } if previewSem != nil { sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()} } if thumbSem != nil { sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()} } if durSem != nil { sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()} } if len(sem) > 0 { resp["sem"] = sem } return resp } func pingHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusNoContent) } func perfHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } resp := buildPerfSnapshot() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(resp) } func perfStreamHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } fl, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) return } // Optional: client kann Intervall mitgeben: /api/stream?ms=5000 ms := 5000 if q := r.URL.Query().Get("ms"); q != "" { if v, err := strconv.Atoi(q); err == nil { // clamp: 1000..30000 if v < 1000 { v = 1000 } if v > 30000 { v = 30000 } ms = v } } w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Connection", "keep-alive") // hilfreich hinter nginx/proxies: w.Header().Set("X-Accel-Buffering", "no") ctx := r.Context() // sofort erstes Event schicken send := func() error { payload := buildPerfSnapshot() var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(payload); err != nil { return err } // event: perf _, _ = io.WriteString(w, "event: perf\n") _, _ = io.WriteString(w, "data: ") _, _ = w.Write(buf.Bytes()) _, _ = io.WriteString(w, "\n") fl.Flush() return nil } // initial _ = send() t := time.NewTicker(time.Duration(ms) * time.Millisecond) hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts defer t.Stop() defer hb.Stop() for { select { case <-ctx.Done(): return case <-t.C: _ = send() case <-hb.C: // SSE Kommentar als Heartbeat _, _ = io.WriteString(w, ": keep-alive\n\n") fl.Flush() } } } func startAdaptiveSemController(ctx context.Context) { targetHi := 85.0 targetLo := 65.0 if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { targetHi = f } } if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { targetLo = f } } // Warmup (erste Messung kann 0 sein) _, _ = gocpu.Percent(200*time.Millisecond, false) t := time.NewTicker(2 * time.Second) go func() { defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: p, err := gocpu.Percent(0, false) if err != nil || len(p) == 0 { continue } usage := p[0] setLastCPUUsage(usage) // Preview ist am teuersten → konservativ if usage > targetHi { previewSem.SetMax(previewSem.Max() - 1) genSem.SetMax(genSem.Max() - 1) thumbSem.SetMax(thumbSem.Max() - 1) } else if usage < targetLo { previewSem.SetMax(previewSem.Max() + 1) genSem.SetMax(genSem.Max() + 1) thumbSem.SetMax(thumbSem.Max() + 1) } } } }() }