From 0ac91a7c598b6dda11c6d0e5e8b2b74b2c890a66 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 27 Jul 2026 15:57:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20per-Backend=20timeout=20server=20+=20Al?= =?UTF-8?q?erts-Deeplink=20+=20Retention=20+=20Cert-Prune=20=E2=80=94=20v1?= =?UTF-8?q?.3.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(backends): per-Backend `server_timeout_seconds` (nullable, Default 60s). Rendert `timeout server s` im HAProxy-Backend-Block — für langsam antwortende Upstreams (KI-/Inferenz-Server mit gepufferter Antwort). Migration 0044 (+CHECK 1..86400), Model/Repo/Template/UI + Render-Test. - fix(ui): Dashboard-Alert-Karte verlinkt auf /alerts?tab=events; Alerts-Seite respektiert ?tab= (Deeplink landete bisher auf leerem Channels-Tab). - feat(scheduler): alert_events-Retention (90d) im täglichen Cleanup-Tick — Schutz vor unbounded growth der node-lokalen Health-Event-History. - fix(cluster): Cert-Sync prunt jetzt lokale .pem die der Primary nicht mehr hat (Waisen gelöschter Domains); schützt _default.pem + eigenen Node-Cert. - fix(security): x/text v0.37→v0.39 (GO-2026-5970, Infinite-Loop; via ACME+goose aktiv aufgerufen — govulncheck-Release-Gate). Co-Authored-By: Claude Opus 4.8 --- VERSION | 2 +- cmd/edgeguard-scheduler/main.go | 30 +++++++++++++++ go.mod | 14 +++---- go.sum | 32 ++++++++-------- .../0044_backend_server_timeout.sql | 27 +++++++++++++ internal/handlers/cluster_certsync.go | 34 +++++++++++++++++ internal/haproxy/haproxy.cfg.tpl | 5 +++ internal/haproxy/haproxy_test.go | 38 +++++++++++++++++++ internal/models/backend.go | 4 ++ internal/services/alerts/alerts.go | 18 +++++++++ internal/services/backends/backends.go | 31 ++++++++------- management-ui/src/i18n/locales/de/common.json | 2 + management-ui/src/i18n/locales/en/common.json | 2 + management-ui/src/pages/Alerts/index.tsx | 11 +++++- management-ui/src/pages/Backends/Detail.tsx | 14 ++++++- management-ui/src/pages/Backends/index.tsx | 2 + management-ui/src/pages/Dashboard/index.tsx | 2 +- 17 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 internal/database/migrations/0044_backend_server_timeout.sql diff --git a/VERSION b/VERSION index 785cda8..8c9698a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.3 \ No newline at end of file +1.3.4 \ No newline at end of file diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index d1f2e42..e3c02c5 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -104,6 +104,12 @@ const ( auditCleanupInterval = 24 * time.Hour auditRetentionDays = 90 + // alertRetentionDays — alert_events wächst sonst unbegrenzt (node-lokale + // Health-Events: backend.down, mem.high, cert.expiring …). Läuft im + // selben täglichen Tick wie der Audit-Cleanup. Fester Default, kein + // Setup-Override (Events sind reine Diagnose-History). + alertRetentionDays = 90 + // backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und // prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN). // Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das @@ -272,6 +278,7 @@ func main() { runDiskCheck(ctx, alertSvc, alertDedupe) case <-auditTick.C: runAuditCleanup(ctx, auditRepo, setupStore) + runAlertCleanup(ctx, alertSvc) case <-backendDownTick.C: runBackendDownCheck(ctx, pool, alertSvc, alertDedupe) case <-memTick.C: @@ -317,6 +324,29 @@ func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store } } +// runAlertCleanup löscht alert_events älter als alertRetentionDays. +// Schutz vor unbounded growth — auf einer aktiven Box feuern backend.down/ +// mem.high/cert.expiring über Monate tausende Rows (die Tabelle ist +// node-lokal, wird also nirgends sonst abgeräumt). Best-effort: Fehler +// werden nur geloggt. +func runAlertCleanup(ctx context.Context, a *alerts.Service) { + if a == nil { + return + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + n, err := a.Cleanup(cctx, alertRetentionDays) + if err != nil { + slog.Warn("scheduler: alert cleanup failed", + "keep_days", alertRetentionDays, "error", err) + return + } + if n > 0 { + slog.Info("scheduler: alert cleanup", + "deleted", n, "keep_days", alertRetentionDays) + } +} + // runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen: // - >= 90% → Critical (error). Box ist akut gefährdet — beim // nächsten Backup-Run oder größeren apt-Update droht "no space diff --git a/go.mod b/go.mod index dfffeaa..d1951d8 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.27.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 ) @@ -78,12 +78,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/binaryregexp v0.2.0 // indirect diff --git a/go.sum b/go.sum index 167885c..c6efcdf 100644 --- a/go.sum +++ b/go.sum @@ -186,24 +186,24 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/database/migrations/0044_backend_server_timeout.sql b/internal/database/migrations/0044_backend_server_timeout.sql new file mode 100644 index 0000000..08a6e4f --- /dev/null +++ b/internal/database/migrations/0044_backend_server_timeout.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin + +-- Per-Backend `timeout server` (Sekunden). NULL = defaults-Timeout (60s, +-- siehe haproxy.cfg.tpl). Gedacht für Upstreams die LANGE für die Antwort +-- brauchen und dabei NICHT streamen — z. B. KI-/Inferenz-Server, die eine +-- gepufferte Antwort erst nach Minuten schicken. Ohne Override kappt der +-- 60s-defaults-Timeout diese Requests. +-- +-- Bewusst NULL-per-default: Backends ohne Langläufer-Workload behalten den +-- kurzen Timeout (Connection-Hygiene / Slowloris-Schutz, vgl. v1.3.2). +-- Der Renderer setzt `timeout server s` NUR wenn ein Wert gesetzt ist. +-- +-- CHECK 1..86400: mind. 1s, max. 24h — verhindert 0/negativ (würde HAProxy- +-- Config sprengen bzw. „unendlich" bedeuten) und absurd hohe Werte. +ALTER TABLE backends + ADD COLUMN IF NOT EXISTS server_timeout_seconds INTEGER + CONSTRAINT backends_server_timeout_range + CHECK (server_timeout_seconds IS NULL + OR (server_timeout_seconds BETWEEN 1 AND 86400)); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE backends DROP COLUMN IF EXISTS server_timeout_seconds; +-- +goose StatementEnd diff --git a/internal/handlers/cluster_certsync.go b/internal/handlers/cluster_certsync.go index 06b0f89..fe2cc07 100644 --- a/internal/handlers/cluster_certsync.go +++ b/internal/handlers/cluster_certsync.go @@ -109,6 +109,40 @@ func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggre slog.Info("cert-sync: updated", "file", name) } + // Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat. + // Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem + // Secondary als Waisen liegen — der Sync oben ist write-only, „nicht + // mitgeschickt" ≠ „gelöscht". Geschützt bleiben: + // _default.pem — Self-Signed-Fallback + // .pem — eigener Node-Cert (steht NICHT im Primary-Payload) + // Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein + // versehentliches Leerräumen bei unvollständiger Primary-Antwort. + if len(payload.Certs) > 0 { + protected := map[string]bool{"_default.pem": true} + var localFQDN string + if err := pool.QueryRow(ctx, + `SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" { + protected[localFQDN+".pem"] = true + } + if entries, err := os.ReadDir(tlsCertDir); err == nil { + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] { + continue + } + if _, ok := payload.Certs[name]; ok { + continue // vom Primary gepflegt — behalten + } + if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil { + slog.Warn("cert-sync: prune failed", "file", name, "error", err) + continue + } + changed = true + slog.Info("cert-sync: pruned orphan", "file", name) + } + } + } + if changed { if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil { slog.Warn("cert-sync: haproxy reload failed", "error", err) diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index a114ed1..92669a6 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -226,6 +226,11 @@ backend eg_backend_{{$b.ID}} {{- if $b.WebSocket}} timeout tunnel 1h {{- end}} + {{- if $b.ServerTimeoutSeconds}} + # Override des defaults-`timeout server 60s` für langsame Upstreams + # (z. B. KI-Server mit gepufferter Antwort). Wert per Backend gepflegt. + timeout server {{$b.ServerTimeoutSeconds}}s + {{- end}} {{- if $b.HealthCheckPath}} option httpchk http-check send meth GET uri {{$b.HealthCheckPath}} diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index aabddd6..b0b7830 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -554,6 +554,44 @@ func TestRender_WebSocketEmitsTunnelTimeout(t *testing.T) { } } +func TestRender_ServerTimeoutOverride(t *testing.T) { + tmo := 300 + v := View{ + Backends: []BackendView{ + { + Backend: models.Backend{ID: 11, Name: "ai", Scheme: "http", + LBAlgorithm: "roundrobin", ServerTimeoutSeconds: &tmo, Active: true}, + Servers: []models.BackendServer{ + {BackendID: 11, Name: "ai-1", Address: "10.0.5.30", Port: 8000, Weight: 100, Active: true}, + }, + }, + { + Backend: models.Backend{ID: 12, Name: "web", Scheme: "http", + LBAlgorithm: "roundrobin", Active: true}, + Servers: []models.BackendServer{ + {BackendID: 12, Name: "web-1", Address: "10.0.5.31", Port: 8080, Weight: 100, Active: true}, + }, + }, + }, + } + out := renderView(t, v) + idxAI := strings.Index(out, "backend eg_backend_11") + idxWeb := strings.Index(out, "backend eg_backend_12") + if idxAI < 0 || idxWeb < 0 { + t.Fatalf("backend sections missing in output:\n%s", out) + } + aiBlock := out[idxAI:idxWeb] + webBlock := out[idxWeb:] + // ai (nil-Override gesetzt) soll `timeout server 300s` bekommen … + if !strings.Contains(aiBlock, "timeout server 300s") { + t.Errorf("ai-Block sollte `timeout server 300s` enthalten:\n%s", aiBlock) + } + // … web (kein Override) soll KEINE eigene timeout-server-Zeile bekommen. + if strings.Contains(webBlock, "timeout server") { + t.Errorf("web-Block soll KEIN eigenes `timeout server` enthalten:\n%s", webBlock) + } +} + func TestRender_MultiServerPool(t *testing.T) { v := View{ Backends: []BackendView{ diff --git a/internal/models/backend.go b/internal/models/backend.go index 21a1e0e..cdcbd90 100644 --- a/internal/models/backend.go +++ b/internal/models/backend.go @@ -12,6 +12,10 @@ type Backend struct { LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"` WebSocket bool `gorm:"column:websocket" json:"websocket"` ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"` + // ServerTimeoutSeconds überschreibt `timeout server` für dieses + // Backend (Sekunden). nil = defaults-Timeout (60s). Für langsam + // antwortende Upstreams (KI-/Inferenz-Server ohne Streaming). + ServerTimeoutSeconds *int `gorm:"column:server_timeout_seconds" json:"server_timeout_seconds,omitempty"` Active bool `gorm:"column:active" json:"active"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` diff --git a/internal/services/alerts/alerts.go b/internal/services/alerts/alerts.go index d2292a9..8dcfcdd 100644 --- a/internal/services/alerts/alerts.go +++ b/internal/services/alerts/alerts.go @@ -194,6 +194,24 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit) return out, rows.Err() } +// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl +// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int — +// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte +// unter pgx mit einem Encode-Fehler (vgl. waf PurgeAlerts, v1.3.3). +func (s *Service) Cleanup(ctx context.Context, keepDays int) (int64, error) { + if keepDays <= 0 { + return 0, nil + } + tag, err := s.Pool.Exec(ctx, + `DELETE FROM alert_events WHERE fired_at < NOW() - make_interval(days => $1)`, + keepDays, + ) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + // Fire dispatch'ed einen Event an alle aktiven Channels und persistiert // das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON // dokumentiert, der Event selbst landet in jedem Fall in der History. diff --git a/internal/services/backends/backends.go b/internal/services/backends/backends.go index cc49c05..2a6e0bc 100644 --- a/internal/services/backends/backends.go +++ b/internal/services/backends/backends.go @@ -26,8 +26,8 @@ type Repo struct { func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } const baseSelect = ` -SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, - created_at, updated_at +SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, + server_timeout_seconds, active, created_at, updated_at FROM backends ` @@ -65,11 +65,13 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e b.LBAlgorithm = "roundrobin" } row := r.Pool.QueryRow(ctx, ` -INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, - created_at, updated_at`, - b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active) +INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, + server_timeout_seconds, active) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, + server_timeout_seconds, active, created_at, updated_at`, + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, + b.ServerTimeoutSeconds, b.Active) return scanBackend(row) } @@ -85,12 +87,14 @@ UPDATE backends SET lb_algorithm = $4, websocket = $5, force_http1 = $6, - active = $7, + server_timeout_seconds = $7, + active = $8, updated_at = NOW() -WHERE id = $8 -RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, - created_at, updated_at`, - b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active, id) +WHERE id = $9 +RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, + server_timeout_seconds, active, created_at, updated_at`, + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, + b.ServerTimeoutSeconds, b.Active, id) out, err := scanBackend(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -125,7 +129,8 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) { var b models.Backend if err := row.Scan( &b.ID, &b.Name, &b.Scheme, - &b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1, &b.Active, + &b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1, + &b.ServerTimeoutSeconds, &b.Active, &b.CreatedAt, &b.UpdatedAt, ); err != nil { return nil, err diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 528588d..d80d35b 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -592,6 +592,8 @@ "websocketHint": "An: erlaubt langlebige WebSocket-/Long-Poll-Verbindungen (z. B. Proxmox-Console, SSH-WS, AsyncAPI) — Tunnel-Idle 1h statt 60s. Aus: strikte HTTP-Timeouts.", "forceHttp1": "HTTP/1.1 erzwingen", "forceHttp1Hint": "Deaktiviert HTTP/2 (h2) auf der Backend-Verbindung — HAProxy handelt nur noch HTTP/1.1 aus. Nötig für Backends die kein h2 unterstützen (z. B. ältere nginx-Konfigurationen ohne h2-Modul, Legacy-Apps).", + "serverTimeout": "Antwort-Timeout (timeout server)", + "serverTimeoutHint": "Wie lange HAProxy auf die Antwort dieses Backends wartet, bevor es abbricht. Leer = Default (60s). Höher setzen für langsame Upstreams die NICHT streamen (z. B. KI-/Inferenz-Server mit gepufferter Antwort). Achtung: gilt als Inaktivitäts-Timeout — streamende Backends (SSE/chunked) brauchen das meist nicht.", "servers": "Server", "noServers": "kein Server", "noServersWarning": "Ein oder mehrere aktive Backends haben keine Server konfiguriert.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 57fd94b..3a827d7 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -592,6 +592,8 @@ "websocketHint": "On: allow long-lived WebSocket / long-poll connections (Proxmox console, SSH-over-WS, AsyncAPI) — tunnel idle 1h instead of 60s. Off: strict HTTP timeouts.", "forceHttp1": "Force HTTP/1.1", "forceHttp1Hint": "Disables HTTP/2 (h2) on the backend connection — HAProxy negotiates HTTP/1.1 only. Required for backends that don't support h2 (e.g. older nginx configs without the h2 module, legacy apps).", + "serverTimeout": "Response timeout (timeout server)", + "serverTimeoutHint": "How long HAProxy waits for this backend's response before aborting. Empty = default (60s). Raise it for slow upstreams that do NOT stream (e.g. AI/inference servers with a buffered response). Note: this is an inactivity timeout — streaming backends (SSE/chunked) usually don't need it.", "servers": "Servers", "noServers": "no server", "noServersWarning": "One or more active backends have no servers configured.", diff --git a/management-ui/src/pages/Alerts/index.tsx b/management-ui/src/pages/Alerts/index.tsx index 9266f99..c7a5c67 100644 --- a/management-ui/src/pages/Alerts/index.tsx +++ b/management-ui/src/pages/Alerts/index.tsx @@ -8,6 +8,7 @@ import { } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'react-router-dom' import dayjs from 'dayjs' import apiClient, { isEnvelope } from '../../api/client' @@ -69,6 +70,14 @@ export default function AlertsPage() { const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' + // Tab über ?tab= steuerbar, damit der Dashboard-Deeplink + // (/alerts?tab=events) direkt auf der Event-History landet statt auf + // dem Channels-Tab. Default bleibt 'channels' für den Direktaufruf. + const [searchParams, setSearchParams] = useSearchParams() + const activeTab = searchParams.get('tab') === 'events' ? 'events' : 'channels' + const setActiveTab = (key: string) => + setSearchParams(key === 'events' ? { tab: 'events' } : {}, { replace: true }) + const channels = useQuery({ queryKey: ['alerts', 'channels'], queryFn: async () => { @@ -304,7 +313,7 @@ export default function AlertsPage() { description={t('alerts.scopeDesc')} /> - d.id), }} @@ -205,6 +210,11 @@ export default function BackendDetailPage() { extra={t('backends.forceHttp1Hint')}> + + + diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 0fb0432..1416dd9 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -26,6 +26,7 @@ interface Backend { lb_algorithm: 'roundrobin' | 'leastconn' | 'source' websocket: boolean force_http1: boolean + server_timeout_seconds?: number | null active: boolean created_at: string updated_at: string @@ -266,6 +267,7 @@ export default function BackendsPage() { {v} {row.websocket && WS} + {row.server_timeout_seconds ? {row.server_timeout_seconds}s : null} ), }, diff --git a/management-ui/src/pages/Dashboard/index.tsx b/management-ui/src/pages/Dashboard/index.tsx index c8c1fcd..c28e855 100644 --- a/management-ui/src/pages/Dashboard/index.tsx +++ b/management-ui/src/pages/Dashboard/index.tsx @@ -361,7 +361,7 @@ export default function DashboardPage() { } - action={{t('dashboard.alertsCard.viewAll')} →} + action={{t('dashboard.alertsCard.viewAll')} →} /> )}