updated
This commit is contained in:
parent
82f8b5269d
commit
19b473f8bd
417
backend/main.go
417
backend/main.go
@ -11,22 +11,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
gocpu "github.com/shirou/gopsutil/v3/cpu"
|
||||
godisk "github.com/shirou/gopsutil/v3/disk"
|
||||
)
|
||||
|
||||
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
|
||||
@ -213,13 +208,6 @@ var (
|
||||
jobsMu = sync.Mutex{}
|
||||
)
|
||||
|
||||
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 startPreviewIdleKiller() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
go func() {
|
||||
@ -254,8 +242,8 @@ func startPreviewIdleKiller() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
initFFmpegSemaphores()
|
||||
startAdaptiveSemController(context.Background())
|
||||
initFFmpegLimiters()
|
||||
startCPUUsageSampler(context.Background())
|
||||
startPreviewIdleKiller()
|
||||
|
||||
initSSE()
|
||||
@ -328,228 +316,6 @@ func detectFFprobePath() string {
|
||||
return "ffprobe"
|
||||
}
|
||||
|
||||
// ---------- Dynamic Semaphore (resizeable by load controller) ----------
|
||||
|
||||
type DynSem struct {
|
||||
mu sync.Mutex
|
||||
in int
|
||||
max int
|
||||
cap int
|
||||
}
|
||||
|
||||
func NewDynSem(initial, cap int) *DynSem {
|
||||
if cap < 1 {
|
||||
cap = 1
|
||||
}
|
||||
if initial < 1 {
|
||||
initial = 1
|
||||
}
|
||||
if initial > cap {
|
||||
initial = cap
|
||||
}
|
||||
return &DynSem{max: initial, cap: cap}
|
||||
}
|
||||
|
||||
func (s *DynSem) Acquire(ctx context.Context) error {
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.in < s.max {
|
||||
s.in++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DynSem) Release() {
|
||||
s.mu.Lock()
|
||||
if s.in > 0 {
|
||||
s.in--
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *DynSem) SetMax(n int) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > s.cap {
|
||||
n = s.cap
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.max = n
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *DynSem) Max() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.max
|
||||
}
|
||||
|
||||
func (s *DynSem) Cap() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cap
|
||||
}
|
||||
|
||||
func (s *DynSem) InUse() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.in
|
||||
}
|
||||
|
||||
var (
|
||||
genSem *DynSem
|
||||
previewSem *DynSem
|
||||
thumbSem *DynSem
|
||||
durSem *DynSem
|
||||
)
|
||||
|
||||
func clamp(n, lo, hi int) int {
|
||||
if n < lo {
|
||||
return lo
|
||||
}
|
||||
if n > hi {
|
||||
return hi
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envInt(name string) (int, bool) {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func initFFmpegSemaphores() {
|
||||
cpu := runtime.NumCPU()
|
||||
if cpu <= 0 {
|
||||
cpu = 2
|
||||
}
|
||||
|
||||
// Defaults (heuristisch)
|
||||
previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ
|
||||
thumbN := clamp(cpu, 2, 12) // Frames -> darf höher
|
||||
genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein
|
||||
durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt
|
||||
|
||||
// ENV Overrides (optional)
|
||||
if n, ok := envInt("PREVIEW_WORKERS"); ok {
|
||||
previewN = clamp(n, 1, 32)
|
||||
}
|
||||
if n, ok := envInt("THUMB_WORKERS"); ok {
|
||||
thumbN = clamp(n, 1, 64)
|
||||
}
|
||||
if n, ok := envInt("GEN_WORKERS"); ok {
|
||||
genN = clamp(n, 1, 16)
|
||||
}
|
||||
if n, ok := envInt("DUR_WORKERS"); ok {
|
||||
durN = clamp(n, 1, 64)
|
||||
}
|
||||
|
||||
// Caps (Obergrenzen) – können via ENV überschrieben werden
|
||||
previewCap := clamp(cpu, 2, 12)
|
||||
thumbCap := clamp(cpu*2, 4, 32)
|
||||
genCap := clamp((cpu+1)/2, 2, 12)
|
||||
durCap := clamp(cpu*2, 4, 32)
|
||||
|
||||
if n, ok := envInt("PREVIEW_CAP"); ok {
|
||||
previewCap = clamp(n, 1, 64)
|
||||
}
|
||||
if n, ok := envInt("THUMB_CAP"); ok {
|
||||
thumbCap = clamp(n, 1, 128)
|
||||
}
|
||||
if n, ok := envInt("GEN_CAP"); ok {
|
||||
genCap = clamp(n, 1, 64)
|
||||
}
|
||||
if n, ok := envInt("DUR_CAP"); ok {
|
||||
durCap = clamp(n, 1, 128)
|
||||
}
|
||||
|
||||
// Initial max (Startwerte)
|
||||
previewSem = NewDynSem(previewN, previewCap)
|
||||
thumbSem = NewDynSem(thumbN, thumbCap)
|
||||
genSem = NewDynSem(genN, genCap)
|
||||
durSem = NewDynSem(durN, durCap)
|
||||
|
||||
/*
|
||||
fmt.Printf(
|
||||
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
|
||||
previewSem.Max(), previewSem.Cap(),
|
||||
thumbSem.Max(), thumbSem.Cap(),
|
||||
genSem.Max(), genSem.Cap(),
|
||||
durSem.Max(), durSem.Cap(),
|
||||
cpu,
|
||||
)
|
||||
|
||||
fmt.Printf(
|
||||
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
|
||||
previewN, thumbN, genN, durN, cpu,
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type durEntry struct {
|
||||
size int64
|
||||
mod time.Time
|
||||
@ -565,185 +331,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
|
||||
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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/perf/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 shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
||||
// returns: (shouldDelete, sizeBytes, thresholdBytes)
|
||||
|
||||
|
||||
343
backend/performance.go
Normal file
343
backend/performance.go
Normal file
@ -0,0 +1,343 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Server-Startzeit für Perf/Uptime
|
||||
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 startCPUUsageSampler(ctx context.Context) {
|
||||
_, _ = 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]
|
||||
if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 {
|
||||
continue
|
||||
}
|
||||
if usage > 100 {
|
||||
usage = 100
|
||||
}
|
||||
|
||||
setLastCPUUsage(usage)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---------- Feste ffmpeg/ffprobe Limiter ----------
|
||||
|
||||
type FixedLimiter chan struct{}
|
||||
|
||||
func NewFixedLimiter(n int) FixedLimiter {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return make(FixedLimiter, n)
|
||||
}
|
||||
|
||||
func (l FixedLimiter) Acquire(ctx context.Context) error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
select {
|
||||
case l <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (l FixedLimiter) Release() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-l:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (l FixedLimiter) Limit() int {
|
||||
return cap(l)
|
||||
}
|
||||
|
||||
func (l FixedLimiter) InUse() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
var (
|
||||
previewSem FixedLimiter
|
||||
thumbSem FixedLimiter
|
||||
genSem FixedLimiter
|
||||
durSem FixedLimiter
|
||||
)
|
||||
|
||||
func envInt(name string) (int, bool) {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func initFFmpegLimiters() {
|
||||
previewN := 2
|
||||
thumbN := 4
|
||||
genN := 2
|
||||
durN := 8
|
||||
|
||||
if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 {
|
||||
previewN = n
|
||||
}
|
||||
if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 {
|
||||
thumbN = n
|
||||
}
|
||||
if n, ok := envInt("GEN_WORKERS"); ok && n > 0 {
|
||||
genN = n
|
||||
}
|
||||
if n, ok := envInt("DUR_WORKERS"); ok && n > 0 {
|
||||
durN = n
|
||||
}
|
||||
|
||||
previewSem = NewFixedLimiter(previewN)
|
||||
thumbSem = NewFixedLimiter(thumbN)
|
||||
genSem = NewFixedLimiter(genN)
|
||||
durSem = NewFixedLimiter(durN)
|
||||
|
||||
setLastCPUUsage(0)
|
||||
startCPUUsageSampler(context.Background())
|
||||
}
|
||||
|
||||
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(),
|
||||
"uptimeSec": time.Since(serverStartedAt).Seconds(),
|
||||
"cpuPercent": func() float64 {
|
||||
v := getLastCPUUsage()
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}(),
|
||||
"diskPath": diskPath,
|
||||
"diskFreeBytes": diskFreeBytes,
|
||||
"diskTotalBytes": diskTotalBytes,
|
||||
"diskUsedPercent": diskUsedPercent,
|
||||
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
|
||||
|
||||
"diskPauseBelowGB": pauseGB,
|
||||
"diskResumeAboveGB": resumeGB,
|
||||
|
||||
"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 previewSem != nil {
|
||||
sem["preview"] = map[string]any{
|
||||
"inUse": previewSem.InUse(),
|
||||
"limit": previewSem.Limit(),
|
||||
}
|
||||
}
|
||||
if thumbSem != nil {
|
||||
sem["thumb"] = map[string]any{
|
||||
"inUse": thumbSem.InUse(),
|
||||
"limit": thumbSem.Limit(),
|
||||
}
|
||||
}
|
||||
if genSem != nil {
|
||||
sem["gen"] = map[string]any{
|
||||
"inUse": genSem.InUse(),
|
||||
"limit": genSem.Limit(),
|
||||
}
|
||||
}
|
||||
if durSem != nil {
|
||||
sem["dur"] = map[string]any{
|
||||
"inUse": durSem.InUse(),
|
||||
"limit": durSem.Limit(),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
ms := 5000
|
||||
if q := r.URL.Query().Get("ms"); q != "" {
|
||||
if v, err := strconv.Atoi(q); err == nil {
|
||||
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")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
send := func() error {
|
||||
payload := buildPerfSnapshot()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(w, "event: perf\n")
|
||||
_, _ = io.WriteString(w, "data: ")
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
_, _ = io.WriteString(w, "\n")
|
||||
fl.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = send()
|
||||
|
||||
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
|
||||
hb := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
defer hb.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = send()
|
||||
case <-hb.C:
|
||||
_, _ = io.WriteString(w, ": keep-alive\n\n")
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3435,7 +3435,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
||||
<header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
|
||||
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
@ -3589,7 +3589,7 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
||||
<div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
|
||||
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
|
||||
</div>
|
||||
|
||||
@ -34,7 +34,7 @@ export default function Checkbox({
|
||||
}, [indeterminate, checked])
|
||||
|
||||
return (
|
||||
<div className={['group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
|
||||
<div className={['relative z-0 group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
|
||||
@ -2905,57 +2905,51 @@ export default function FinishedDownloads({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color={allPageSelected ? 'amber' : 'indigo'}
|
||||
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => {
|
||||
if (allPageSelected) deselectPageRows(pageRows)
|
||||
else selectPageRows(pageRows)
|
||||
}}
|
||||
className={[
|
||||
'w-full inline-flex items-center justify-center gap-2 font-medium',
|
||||
allPageSelected
|
||||
? 'text-amber-700 ring-amber-200 hover:bg-amber-50 dark:text-amber-300 dark:ring-amber-500/20 dark:hover:bg-amber-500/10'
|
||||
: 'text-indigo-700 ring-indigo-200 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/10',
|
||||
].join(' ')}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{allPageSelected ? (
|
||||
<XMarkIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
|
||||
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
variant="secondary"
|
||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={clearSelection}
|
||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
|
||||
className="w-full justify-center"
|
||||
>
|
||||
<XMarkIcon className="size-4 shrink-0" />
|
||||
<span>Auswahl aufheben</span>
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => void bulkKeepSelected()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10"
|
||||
className="w-full justify-center"
|
||||
>
|
||||
<ArchiveBoxIcon className="size-4 shrink-0" />
|
||||
<span>Behalten</span>
|
||||
Behalten
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color="red"
|
||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => void bulkDeleteSelected()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10"
|
||||
className="w-full justify-center"
|
||||
>
|
||||
<TrashIcon className="size-4 shrink-0" />
|
||||
<span>Löschen</span>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -2972,35 +2966,25 @@ export default function FinishedDownloads({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color={allPageSelected ? 'amber' : 'indigo'}
|
||||
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => {
|
||||
if (allPageSelected) deselectPageRows(pageRows)
|
||||
else selectPageRows(pageRows)
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 font-medium',
|
||||
allPageSelected
|
||||
? 'text-amber-700 ring-amber-200 hover:bg-amber-50 dark:text-amber-300 dark:ring-amber-500/20 dark:hover:bg-amber-500/10'
|
||||
: 'text-indigo-700 ring-indigo-200 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/10',
|
||||
].join(' ')}
|
||||
>
|
||||
{allPageSelected ? (
|
||||
<XMarkIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
|
||||
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
variant="secondary"
|
||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={clearSelection}
|
||||
className="inline-flex items-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<XMarkIcon className="size-4 shrink-0" />
|
||||
<span>Auswahl aufheben</span>
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 dark:bg-white/10" />
|
||||
@ -3008,23 +2992,23 @@ export default function FinishedDownloads({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => void bulkKeepSelected()}
|
||||
className="inline-flex items-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10"
|
||||
>
|
||||
<ArchiveBoxIcon className="size-4 shrink-0" />
|
||||
<span>Behalten</span>
|
||||
Behalten
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color="red"
|
||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
onClick={() => void bulkDeleteSelected()}
|
||||
className="inline-flex items-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<TrashIcon className="size-4 shrink-0" />
|
||||
<span>Löschen</span>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -811,7 +811,7 @@ export default function FinishedDownloadsCardsView({
|
||||
{!isSmall ? (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-30 transition-opacity duration-150',
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
|
||||
@ -509,7 +509,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-30 transition-opacity duration-150',
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user