P2R-02 slice 2: Sources tab — list, new/edit form, delete, Run-now
Sources tab now lists every source group on the host with per-row
counts (used-by-N-schedules, snapshot count by tag), the v4
conflict tag (keep-* dimension that has no compatible cadence),
and Run-now / Edit / Delete actions. Run-now reuses the existing
HTMX-aware /hosts/{id}/source-groups/{gid}/run handler.
New /hosts/{id}/sources/new and /sources/{gid}/edit form: name +
includes/excludes textareas + the 3×2 keep-* retention grid +
retry-on-offline knobs. Server-side validation re-renders with the
operator's input intact; the inline conflict banner shows above the
retention grid when ConflictDimension is set.
Delete blocks (UI + server) when the group is referenced by any
schedule. Every successful mutation calls pushScheduleSetAsync so
an online agent re-arms within seconds.
Adds .src-row and .keep-cell to input.css for the row + retention
grid layout.
This commit is contained in:
@@ -185,8 +185,13 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
|
||||
// Host detail (Snapshots tab is the default).
|
||||
r.Get("/hosts/{id}", s.handleUIHostDetail)
|
||||
// Sources tab (slice 2 fills in CRUD).
|
||||
// Sources tab + source-group CRUD forms.
|
||||
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
|
||||
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
|
||||
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
|
||||
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
|
||||
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
|
||||
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
|
||||
// Repo tab (slice 4 fills in body).
|
||||
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
|
||||
// Schedules tab + create/edit/delete forms.
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// ui_sources.go — HTML form-driven source-group CRUD. Slice 1 of
|
||||
// P2R-02 lights the tab; slice 2 fills in list, new, edit, delete,
|
||||
// and per-group Run-now.
|
||||
// ui_sources.go — HTML form-driven source-group CRUD. Mounts at:
|
||||
// GET /hosts/{id}/sources — list
|
||||
// GET /hosts/{id}/sources/new — empty form
|
||||
// POST /hosts/{id}/sources/new — create
|
||||
// GET /hosts/{id}/sources/{gid}/edit — populated form
|
||||
// POST /hosts/{id}/sources/{gid}/edit — update
|
||||
// POST /hosts/{id}/sources/{gid}/delete — delete
|
||||
//
|
||||
// Per-group Run-now is handled by run_group.go's HTMX-aware
|
||||
// /hosts/{id}/source-groups/{gid}/run handler.
|
||||
|
||||
// hostSourcesPage backs the list view. Each row carries the group plus
|
||||
// the cheap aggregates the row UI shows (used-by-N-schedules,
|
||||
// snapshot count by tag).
|
||||
type hostSourcesPage struct {
|
||||
hostChromeData
|
||||
Groups []sourceGroupRow
|
||||
}
|
||||
|
||||
type sourceGroupRow struct {
|
||||
Group store.SourceGroup
|
||||
UsedBy int
|
||||
SnapshotCount int
|
||||
}
|
||||
|
||||
// sourceFormData carries form state across re-render-on-error. Keep
|
||||
// keep-* fields as strings so an empty input round-trips as "" (not
|
||||
// "0"), preserving the operator's intent.
|
||||
type sourceFormData struct {
|
||||
Name string
|
||||
Includes string // newline-joined for the textarea
|
||||
Excludes string // newline-joined for the textarea
|
||||
KeepLast string
|
||||
KeepHourly string
|
||||
KeepDaily string
|
||||
KeepWeekly string
|
||||
KeepMonthly string
|
||||
KeepYearly string
|
||||
RetryMax int
|
||||
RetryBackoffSeconds int
|
||||
ConflictDimension string
|
||||
}
|
||||
|
||||
// sourceGroupEditPage backs both the new and edit form views.
|
||||
type sourceGroupEditPage struct {
|
||||
hostChromeData
|
||||
IsNew bool
|
||||
GroupID string // empty when IsNew
|
||||
Form sourceFormData
|
||||
SaveAction string
|
||||
Error string
|
||||
}
|
||||
|
||||
// nameRE matches the same shape the wireframe + UI hint advertise:
|
||||
// lowercase alnum, optional `_-`, no leading punctuation. Mirrors what
|
||||
// works as a restic --tag.
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
@@ -22,13 +82,344 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
|
||||
if err != nil {
|
||||
slog.Error("ui sources: list groups", "host_id", host.ID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot counts per tag — single fetch, then bucket by tag.
|
||||
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID)
|
||||
if err != nil {
|
||||
slog.Warn("ui sources: list snapshots", "host_id", host.ID, "err", err)
|
||||
}
|
||||
snapByTag := make(map[string]int, len(groups))
|
||||
for _, sn := range snaps {
|
||||
for _, tag := range sn.Tags {
|
||||
snapByTag[tag]++
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]sourceGroupRow, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
usedBy, lerr := s.deps.Store.SchedulesUsingGroup(r.Context(), g.ID)
|
||||
if lerr != nil {
|
||||
slog.Warn("ui sources: usage lookup", "group_id", g.ID, "err", lerr)
|
||||
}
|
||||
rows = append(rows, sourceGroupRow{
|
||||
Group: g,
|
||||
UsedBy: len(usedBy),
|
||||
SnapshotCount: snapByTag[g.Name],
|
||||
})
|
||||
}
|
||||
|
||||
chrome := s.loadHostChrome(r, *host, "sources", "sources")
|
||||
// loadHostChrome already counted groups; reuse count we just got.
|
||||
chrome.SourceGroupCount = len(groups)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " sources · restic-manager"
|
||||
view.Page = hostSourcesPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "sources", "sources"),
|
||||
}
|
||||
view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows}
|
||||
if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
|
||||
slog.Error("ui: render host_sources", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
host, ok := s.loadHostForUI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "New source group · " + host.Name + " · restic-manager"
|
||||
view.Page = sourceGroupEditPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"),
|
||||
IsNew: true,
|
||||
Form: sourceFormData{RetryMax: 3, RetryBackoffSeconds: 60},
|
||||
SaveAction: "/hosts/" + host.ID + "/sources/new",
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
|
||||
slog.Error("ui: render source_group_edit (new)", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
host, ok := s.loadHostForUI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gid := chi.URLParam(r, "gid")
|
||||
g, err := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("ui sources: get group", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = g.Name + " · " + host.Name + " · restic-manager"
|
||||
view.Page = sourceGroupEditPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name),
|
||||
IsNew: false,
|
||||
GroupID: gid,
|
||||
Form: formFromGroup(*g),
|
||||
SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit",
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
|
||||
slog.Error("ui: render source_group_edit (edit)", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUISourceGroupSave handles both the create (gid empty) and the
|
||||
// update (gid set) POST. Validates server-side; on error re-renders
|
||||
// the form with the operator's typed input intact + a banner. On
|
||||
// success, redirects back to the list.
|
||||
func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
host, ok := s.loadHostForUI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gid := chi.URLParam(r, "gid")
|
||||
isNew := gid == ""
|
||||
|
||||
form := parseSourceForm(r.PostForm)
|
||||
|
||||
// --- validation ---
|
||||
var errMsg string
|
||||
switch {
|
||||
case form.Name == "":
|
||||
errMsg = "Name is required."
|
||||
case !nameRE.MatchString(form.Name):
|
||||
errMsg = "Name must be lowercase letters, digits, dashes, or underscores (and start with a letter or digit)."
|
||||
}
|
||||
keepLast, err := parseKeep(form.KeepLast)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Keep last must be a non-negative whole number."
|
||||
}
|
||||
keepHourly, err := parseKeep(form.KeepHourly)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Hourly must be a non-negative whole number."
|
||||
}
|
||||
keepDaily, err := parseKeep(form.KeepDaily)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Daily must be a non-negative whole number."
|
||||
}
|
||||
keepWeekly, err := parseKeep(form.KeepWeekly)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Weekly must be a non-negative whole number."
|
||||
}
|
||||
keepMonthly, err := parseKeep(form.KeepMonthly)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Monthly must be a non-negative whole number."
|
||||
}
|
||||
keepYearly, err := parseKeep(form.KeepYearly)
|
||||
if errMsg == "" && err != nil {
|
||||
errMsg = "Yearly must be a non-negative whole number."
|
||||
}
|
||||
|
||||
// Name uniqueness (per host). On rename, exclude self.
|
||||
if errMsg == "" {
|
||||
if existing, gerr := s.deps.Store.GetSourceGroupByName(r.Context(), host.ID, form.Name); gerr == nil && existing != nil && existing.ID != gid {
|
||||
errMsg = "A source group named \"" + form.Name + "\" already exists on this host."
|
||||
}
|
||||
}
|
||||
|
||||
if errMsg != "" {
|
||||
s.renderSourceFormError(w, r, u, host, gid, isNew, form, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
g := store.SourceGroup{
|
||||
ID: gid,
|
||||
HostID: host.ID,
|
||||
Name: form.Name,
|
||||
Includes: splitLines(form.Includes),
|
||||
Excludes: splitLines(form.Excludes),
|
||||
RetentionPolicy: store.RetentionPolicy{
|
||||
KeepLast: keepLast, KeepHourly: keepHourly, KeepDaily: keepDaily,
|
||||
KeepWeekly: keepWeekly, KeepMonthly: keepMonthly, KeepYearly: keepYearly,
|
||||
},
|
||||
RetryMax: form.RetryMax,
|
||||
RetryBackoffSeconds: form.RetryBackoffSeconds,
|
||||
}
|
||||
|
||||
if isNew {
|
||||
g.ID = ulid.Make().String()
|
||||
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
|
||||
slog.Error("ui sources: create", "err", err)
|
||||
s.renderSourceFormError(w, r, u, host, "", true, form, "Couldn't create — see the server log for details.")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
|
||||
slog.Error("ui sources: update", "err", err)
|
||||
s.renderSourceFormError(w, r, u, host, gid, false, form, "Couldn't save — see the server log for details.")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.pushScheduleSetAsync(host.ID)
|
||||
|
||||
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
host, ok := s.loadHostForUI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gid := chi.URLParam(r, "gid")
|
||||
|
||||
using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), gid)
|
||||
if err != nil {
|
||||
slog.Error("ui sources: usage check", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(using) > 0 {
|
||||
// Shouldn't happen via the UI (delete button is disabled when
|
||||
// in use); guard anyway against form-replay / curl.
|
||||
stdhttp.Error(w, "remove this group from its schedules 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)
|
||||
return
|
||||
}
|
||||
slog.Error("ui sources: delete", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.pushScheduleSetAsync(host.ID)
|
||||
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// renderSourceFormError re-renders the edit form with the user's
|
||||
// typed input intact + an error banner. Returns 422 to signal "form
|
||||
// rejected" while still returning HTML (mirrors handleUIAddHostPost).
|
||||
func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) {
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Source group · " + host.Name + " · restic-manager"
|
||||
saveAction := "/hosts/" + host.ID + "/sources/new"
|
||||
crumb := "new source group"
|
||||
if !isNew {
|
||||
saveAction = "/hosts/" + host.ID + "/sources/" + gid + "/edit"
|
||||
crumb = form.Name
|
||||
}
|
||||
view.Page = sourceGroupEditPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "sources", crumb),
|
||||
IsNew: isNew,
|
||||
GroupID: gid,
|
||||
Form: form,
|
||||
SaveAction: saveAction,
|
||||
Error: msg,
|
||||
}
|
||||
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
||||
if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
|
||||
slog.Error("ui: render source_group_edit (error)", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- form parsing helpers ---
|
||||
|
||||
func parseSourceForm(v map[string][]string) sourceFormData {
|
||||
get := func(k string) string { return strings.TrimSpace(firstVal(v, k)) }
|
||||
rmax, _ := strconv.Atoi(get("retry_max"))
|
||||
rback, _ := strconv.Atoi(get("retry_backoff_seconds"))
|
||||
return sourceFormData{
|
||||
Name: get("name"),
|
||||
Includes: firstVal(v, "includes"), // textarea — preserve internal whitespace
|
||||
Excludes: firstVal(v, "excludes"),
|
||||
KeepLast: get("keep_last"),
|
||||
KeepHourly: get("keep_hourly"),
|
||||
KeepDaily: get("keep_daily"),
|
||||
KeepWeekly: get("keep_weekly"),
|
||||
KeepMonthly: get("keep_monthly"),
|
||||
KeepYearly: get("keep_yearly"),
|
||||
RetryMax: rmax,
|
||||
RetryBackoffSeconds: rback,
|
||||
}
|
||||
}
|
||||
|
||||
func firstVal(v map[string][]string, k string) string {
|
||||
if vs, ok := v[k]; ok && len(vs) > 0 {
|
||||
return vs[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseKeep maps an empty string → nil pointer (no constraint),
|
||||
// "0" / "N" → *int. Negative or non-numeric → error.
|
||||
func parseKeep(s string) (*int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return nil, errors.New("invalid")
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if p := strings.TrimSpace(line); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formFromGroup(g store.SourceGroup) sourceFormData {
|
||||
keep := func(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
return sourceFormData{
|
||||
Name: g.Name,
|
||||
Includes: strings.Join(g.Includes, "\n"),
|
||||
Excludes: strings.Join(g.Excludes, "\n"),
|
||||
KeepLast: keep(g.RetentionPolicy.KeepLast),
|
||||
KeepHourly: keep(g.RetentionPolicy.KeepHourly),
|
||||
KeepDaily: keep(g.RetentionPolicy.KeepDaily),
|
||||
KeepWeekly: keep(g.RetentionPolicy.KeepWeekly),
|
||||
KeepMonthly: keep(g.RetentionPolicy.KeepMonthly),
|
||||
KeepYearly: keep(g.RetentionPolicy.KeepYearly),
|
||||
RetryMax: g.RetryMax,
|
||||
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
||||
ConflictDimension: g.ConflictDimension,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user