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
|
||||
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
|
||||
|
||||
14
go.mod
14
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
|
||||
|
||||
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.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=
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
<Tabs items={[
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||
{
|
||||
key: 'channels',
|
||||
label: t('alerts.tabs.channels'),
|
||||
|
||||
@@ -20,13 +20,17 @@ interface Backend {
|
||||
id: number; name: string; scheme: string
|
||||
health_check_path?: string | null
|
||||
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 {
|
||||
name: string; scheme: 'http' | 'https'
|
||||
health_check_path?: string
|
||||
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[]
|
||||
}
|
||||
interface BackendServer {
|
||||
@@ -174,6 +178,7 @@ export default function BackendDetailPage() {
|
||||
lb_algorithm: backend.lb_algorithm,
|
||||
websocket: backend.websocket,
|
||||
force_http1: backend.force_http1,
|
||||
server_timeout_seconds: backend.server_timeout_seconds ?? undefined,
|
||||
active: backend.active,
|
||||
domain_ids: attached.map(d => d.id),
|
||||
}}
|
||||
@@ -205,6 +210,11 @@ export default function BackendDetailPage() {
|
||||
extra={t('backends.forceHttp1Hint')}>
|
||||
<Switch />
|
||||
</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">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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() {
|
||||
<Space size={4}>
|
||||
<Tag>{v}</Tag>
|
||||
{row.websocket && <Tag color="cyan">WS</Tag>}
|
||||
{row.server_timeout_seconds ? <Tag color="gold">{row.server_timeout_seconds}s</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -361,7 +361,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
</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