From 27a995e812b637cc84ab0382a54faa0ee18d4058 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 11:49:17 +0100 Subject: [PATCH] P2R-02 slice 2 follow-up: refuse to delete a host's last source group Belt-and-braces: the UI now disables the Delete button when a group is the only one on the host (with a tooltip explaining why), and the server-side handler returns 409 if a curl/form-replay tries anyway. Every host needs at least one source group to be backup-able, so the 'last group on a fresh host' case is a meaningful accident to guard against. --- internal/server/http/ui_sources.go | 14 ++++++++++++++ web/templates/pages/host_sources.html | 3 +++ 2 files changed, 17 insertions(+) diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go index 8d48c90..cdcd978 100644 --- a/internal/server/http/ui_sources.go +++ b/internal/server/http/ui_sources.go @@ -310,6 +310,20 @@ func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp. return } + // Refuse to delete the host's last source group — every host + // needs at least one to be backup-able. UI disables the button + // in this case; this guards against form-replay / curl. + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui sources: count groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(groups) <= 1 { + stdhttp.Error(w, "this is the host's only source group — create another one first", stdhttp.StatusConflict) + return + } + if err := s.deps.Store.DeleteSourceGroup(r.Context(), host.ID, gid); err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html index 6452fff..80abfc7 100644 --- a/web/templates/pages/host_sources.html +++ b/web/templates/pages/host_sources.html @@ -66,6 +66,9 @@ {{if gt $row.UsedBy 0}} + {{else if eq (len $page.Groups) 1}} + {{else}}