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.
This commit is contained in:
2026-05-03 11:49:17 +01:00
parent da9ed4c3d4
commit 27a995e812
2 changed files with 17 additions and 0 deletions
+14
View File
@@ -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)
+3
View File
@@ -66,6 +66,9 @@
{{if gt $row.UsedBy 0}}
<button class="btn btn-danger" disabled
title="remove this group from {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}} first">Delete</button>
{{else if eq (len $page.Groups) 1}}
<button class="btn btn-danger" disabled
title="this is the host's only source group — create another one first">Delete</button>
{{else}}
<form method="post" action="/hosts/{{$host.ID}}/sources/{{$g.ID}}/delete" style="display: inline;"
onsubmit="return confirm('Delete source group &quot;{{$g.Name}}&quot;? Existing snapshots are not affected.');">