feat: per-Backend timeout server + Alerts-Deeplink + Retention + Cert-Prune — v1.3.4
- feat(backends): per-Backend `server_timeout_seconds` (nullable, Default 60s). Rendert `timeout server <N>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 <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,12 @@ const (
|
|||||||
auditCleanupInterval = 24 * time.Hour
|
auditCleanupInterval = 24 * time.Hour
|
||||||
auditRetentionDays = 90
|
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
|
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
|
||||||
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
||||||
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
||||||
@@ -272,6 +278,7 @@ func main() {
|
|||||||
runDiskCheck(ctx, alertSvc, alertDedupe)
|
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||||
case <-auditTick.C:
|
case <-auditTick.C:
|
||||||
runAuditCleanup(ctx, auditRepo, setupStore)
|
runAuditCleanup(ctx, auditRepo, setupStore)
|
||||||
|
runAlertCleanup(ctx, alertSvc)
|
||||||
case <-backendDownTick.C:
|
case <-backendDownTick.C:
|
||||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||||
case <-memTick.C:
|
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:
|
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
|
||||||
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
|
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
|
||||||
// nächsten Backup-Run oder größeren apt-Update droht "no space
|
// nächsten Backup-Run oder größeren apt-Update droht "no space
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
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
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.39.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
rsc.io/binaryregexp v0.2.0 // indirect
|
rsc.io/binaryregexp v0.2.0 // indirect
|
||||||
|
|||||||
32
go.sum
32
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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
@@ -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 <N>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
|
||||||
@@ -109,6 +109,40 @@ func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggre
|
|||||||
slog.Info("cert-sync: updated", "file", name)
|
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
|
||||||
|
// <lokaler-FQDN>.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 changed {
|
||||||
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||||
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ backend eg_backend_{{$b.ID}}
|
|||||||
{{- if $b.WebSocket}}
|
{{- if $b.WebSocket}}
|
||||||
timeout tunnel 1h
|
timeout tunnel 1h
|
||||||
{{- end}}
|
{{- 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}}
|
{{- if $b.HealthCheckPath}}
|
||||||
option httpchk
|
option httpchk
|
||||||
http-check send meth GET uri {{$b.HealthCheckPath}}
|
http-check send meth GET uri {{$b.HealthCheckPath}}
|
||||||
|
|||||||
@@ -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) {
|
func TestRender_MultiServerPool(t *testing.T) {
|
||||||
v := View{
|
v := View{
|
||||||
Backends: []BackendView{
|
Backends: []BackendView{
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ type Backend struct {
|
|||||||
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
||||||
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
||||||
ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"`
|
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"`
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
|
|||||||
return out, rows.Err()
|
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
|
// Fire dispatch'ed einen Event an alle aktiven Channels und persistiert
|
||||||
// das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON
|
// das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON
|
||||||
// dokumentiert, der Event selbst landet in jedem Fall in der History.
|
// dokumentiert, der Event selbst landet in jedem Fall in der History.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ type Repo struct {
|
|||||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||||
|
|
||||||
const baseSelect = `
|
const baseSelect = `
|
||||||
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||||
created_at, updated_at
|
server_timeout_seconds, active, created_at, updated_at
|
||||||
FROM backends
|
FROM backends
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -65,11 +65,13 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e
|
|||||||
b.LBAlgorithm = "roundrobin"
|
b.LBAlgorithm = "roundrobin"
|
||||||
}
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active)
|
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
server_timeout_seconds, active)
|
||||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
created_at, updated_at`,
|
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active)
|
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)
|
return scanBackend(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +87,14 @@ UPDATE backends SET
|
|||||||
lb_algorithm = $4,
|
lb_algorithm = $4,
|
||||||
websocket = $5,
|
websocket = $5,
|
||||||
force_http1 = $6,
|
force_http1 = $6,
|
||||||
active = $7,
|
server_timeout_seconds = $7,
|
||||||
|
active = $8,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $8
|
WHERE id = $9
|
||||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||||
created_at, updated_at`,
|
server_timeout_seconds, active, created_at, updated_at`,
|
||||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active, id)
|
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
|
||||||
|
b.ServerTimeoutSeconds, b.Active, id)
|
||||||
out, err := scanBackend(row)
|
out, err := scanBackend(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -125,7 +129,8 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) {
|
|||||||
var b models.Backend
|
var b models.Backend
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&b.ID, &b.Name, &b.Scheme,
|
&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,
|
&b.CreatedAt, &b.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"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).",
|
"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",
|
"servers": "Server",
|
||||||
"noServers": "kein Server",
|
"noServers": "kein Server",
|
||||||
"noServersWarning": "Ein oder mehrere aktive Backends haben keine Server konfiguriert.",
|
"noServersWarning": "Ein oder mehrere aktive Backends haben keine Server konfiguriert.",
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"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).",
|
"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",
|
"servers": "Servers",
|
||||||
"noServers": "no server",
|
"noServers": "no server",
|
||||||
"noServersWarning": "One or more active backends have no servers configured.",
|
"noServersWarning": "One or more active backends have no servers configured.",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
@@ -69,6 +70,14 @@ export default function AlertsPage() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
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({
|
const channels = useQuery({
|
||||||
queryKey: ['alerts', 'channels'],
|
queryKey: ['alerts', 'channels'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -304,7 +313,7 @@ export default function AlertsPage() {
|
|||||||
description={t('alerts.scopeDesc')}
|
description={t('alerts.scopeDesc')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs items={[
|
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||||
{
|
{
|
||||||
key: 'channels',
|
key: 'channels',
|
||||||
label: t('alerts.tabs.channels'),
|
label: t('alerts.tabs.channels'),
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ interface Backend {
|
|||||||
id: number; name: string; scheme: string
|
id: number; name: string; scheme: string
|
||||||
health_check_path?: string | null
|
health_check_path?: string | null
|
||||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||||
websocket: boolean; force_http1: boolean; active: boolean
|
websocket: boolean; force_http1: boolean
|
||||||
|
server_timeout_seconds?: number | null
|
||||||
|
active: boolean
|
||||||
}
|
}
|
||||||
interface BackendFormValues {
|
interface BackendFormValues {
|
||||||
name: string; scheme: 'http' | 'https'
|
name: string; scheme: 'http' | 'https'
|
||||||
health_check_path?: string
|
health_check_path?: string
|
||||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||||
websocket: boolean; force_http1: boolean; active: boolean
|
websocket: boolean; force_http1: boolean
|
||||||
|
server_timeout_seconds?: number | null
|
||||||
|
active: boolean
|
||||||
domain_ids?: number[]
|
domain_ids?: number[]
|
||||||
}
|
}
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
@@ -174,6 +178,7 @@ export default function BackendDetailPage() {
|
|||||||
lb_algorithm: backend.lb_algorithm,
|
lb_algorithm: backend.lb_algorithm,
|
||||||
websocket: backend.websocket,
|
websocket: backend.websocket,
|
||||||
force_http1: backend.force_http1,
|
force_http1: backend.force_http1,
|
||||||
|
server_timeout_seconds: backend.server_timeout_seconds ?? undefined,
|
||||||
active: backend.active,
|
active: backend.active,
|
||||||
domain_ids: attached.map(d => d.id),
|
domain_ids: attached.map(d => d.id),
|
||||||
}}
|
}}
|
||||||
@@ -205,6 +210,11 @@ export default function BackendDetailPage() {
|
|||||||
extra={t('backends.forceHttp1Hint')}>
|
extra={t('backends.forceHttp1Hint')}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t('backends.serverTimeout')} name="server_timeout_seconds"
|
||||||
|
extra={t('backends.serverTimeoutHint')}>
|
||||||
|
<InputNumber min={1} max={86400} step={30}
|
||||||
|
style={{ width: '100%' }} addonAfter="s" placeholder="60 (default)" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface Backend {
|
|||||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||||
websocket: boolean
|
websocket: boolean
|
||||||
force_http1: boolean
|
force_http1: boolean
|
||||||
|
server_timeout_seconds?: number | null
|
||||||
active: boolean
|
active: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -266,6 +267,7 @@ export default function BackendsPage() {
|
|||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tag>{v}</Tag>
|
<Tag>{v}</Tag>
|
||||||
{row.websocket && <Tag color="cyan">WS</Tag>}
|
{row.websocket && <Tag color="cyan">WS</Tag>}
|
||||||
|
{row.server_timeout_seconds ? <Tag color="gold">{row.server_timeout_seconds}s</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ export default function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
action={<Link to="/alerts" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} →</Link>}
|
action={<Link to="/alerts?tab=events" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} →</Link>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user