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. 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 { return } host, ok := s.loadHostForUI(w, r) 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: 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 } // 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) 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, } }