fix(backends): Backend-Löschen verweigern wenn Routing-Regeln darauf zeigen (v1.1.142)

- Service: Pre-Check auf routing_rules.backend_id → ErrInUse vor DELETE
- Handler: ErrInUse → HTTP 409 Conflict statt 500 (response.Conflict helper)
- UI: Delete-Button deaktiviert + Tooltip "Von N Regeln verwendet" wenn Referenzen existieren

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 07:15:16 +02:00
parent 40414d472b
commit 7dd76d4b58
10 changed files with 52 additions and 12 deletions

View File

@@ -1 +1 @@
1.1.141
1.1.142

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
var version = "1.1.141"
var version = "1.1.142"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
var version = "1.1.141"
var version = "1.1.142"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.141"
var version = "1.1.142"
const (
// renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -117,6 +117,10 @@ func (h *BackendsHandler) Delete(c *gin.Context) {
response.NotFound(c, err)
return
}
if errors.Is(err, backends.ErrInUse) {
response.Conflict(c, err)
return
}
response.Internal(c, err)
return
}

View File

@@ -82,6 +82,10 @@ func Forbidden(c *gin.Context, err error) {
Err(c, http.StatusForbidden, err)
}
func Conflict(c *gin.Context, err error) {
Err(c, http.StatusConflict, err)
}
func Internal(c *gin.Context, err error) {
Err(c, http.StatusInternalServerError, err)
}

View File

@@ -14,7 +14,10 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrNotFound = errors.New("backend not found")
var (
ErrNotFound = errors.New("backend not found")
ErrInUse = errors.New("backend is referenced by one or more routing rules")
)
type Repo struct {
Pool *pgxpool.Pool
@@ -99,6 +102,15 @@ RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_ht
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
var n int
if err := r.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM routing_rules WHERE backend_id = $1`, id,
).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrInUse
}
tag, err := r.Pool.Exec(ctx, `DELETE FROM backends WHERE id = $1`, id)
if err != nil {
return err

View File

@@ -387,6 +387,7 @@
"serverHintCreate": "Speichern legt nur den Pool an. Server kommen im nächsten Schritt — Pool öffnen → „Server hinzufügen\".",
"actions": "Aktionen",
"deleteConfirm": "Backend-Pool {{name}} wirklich löschen? Alle Server im Pool werden mitentfernt.",
"deleteInUse": "Von {{n}} Routing-Regel(n) verwendet — zuerst diese löschen",
"backToList": "Zurück zur Übersicht",
"settingsCard": "Pool-Einstellungen",
"healthCheckHint": "HTTP-Pfad den HAProxy alle 5s pro Server prüft. Leer lassen = kein Health-Check.",

View File

@@ -387,6 +387,7 @@
"serverHintCreate": "Saving creates the pool only. Add servers in the next step — open the pool and click “Add server”.",
"actions": "Actions",
"deleteConfirm": "Really delete backend pool {{name}}? All servers in the pool will be removed too.",
"deleteInUse": "Used by {{n}} routing rule(s) — remove those first",
"backToList": "Back to backends",
"settingsCard": "Pool settings",
"healthCheckHint": "HTTP path HAProxy probes every 5s per server. Leave empty to disable health checks.",

View File

@@ -97,6 +97,15 @@ async function listDomains(): Promise<DomainFull[]> {
return (r.data.data as { domains?: DomainFull[] }).domains ?? []
}
interface RoutingRule { id: number; backend_id: number }
async function listRoutingRules(): Promise<RoutingRule[]> {
try {
const r = await apiClient.get('/routing-rules')
if (!isEnvelope(r.data)) return []
return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? []
} catch { return [] }
}
interface HAProxyStat {
backend: string; server: string; status: string
sessions: number; bytes_in: number; bytes_out: number
@@ -124,6 +133,7 @@ export default function BackendsPage() {
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: routingRules } = useQuery({ queryKey: ['routing-rules'], queryFn: listRoutingRules })
const { data: services } = useQuery({
queryKey: ['system', 'services'],
queryFn: async () => {
@@ -177,6 +187,9 @@ export default function BackendsPage() {
const domainsForBackend = (id: number) =>
(domains ?? []).filter(d => d.primary_backend_id === id)
const ruleCountForBackend = (id: number) =>
(routingRules ?? []).filter(r => r.backend_id === id).length
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<BackendFormValues>()
@@ -296,13 +309,18 @@ export default function BackendsPage() {
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => navigate(`/backends/${row.id}`)}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('backends.deleteConfirm', { name: row.name })}
/>
),
render: (_, row) => {
const n = ruleCountForBackend(row.id)
return (
<ActionButtons
onEdit={() => navigate(`/backends/${row.id}`)}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('backends.deleteConfirm', { name: row.name })}
deleteDisabled={n > 0}
deleteDisabledReason={t('backends.deleteInUse', { n })}
/>
)
},
},
]