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)
|
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
|
||||||
// Host detail (Snapshots tab is the default).
|
// Host detail (Snapshots tab is the default).
|
||||||
r.Get("/hosts/{id}", s.handleUIHostDetail)
|
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", 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).
|
// Repo tab (slice 4 fills in body).
|
||||||
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
|
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
|
||||||
// Schedules tab + create/edit/delete forms.
|
// Schedules tab + create/edit/delete forms.
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
stdhttp "net/http"
|
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
|
// ui_sources.go — HTML form-driven source-group CRUD. Mounts at:
|
||||||
// P2R-02 lights the tab; slice 2 fills in list, new, edit, delete,
|
// GET /hosts/{id}/sources — list
|
||||||
// and per-group Run-now.
|
// 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 {
|
type hostSourcesPage struct {
|
||||||
hostChromeData
|
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) {
|
func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
u := s.requireUIUser(w, r)
|
u := s.requireUIUser(w, r)
|
||||||
if u == nil {
|
if u == nil {
|
||||||
@@ -22,13 +82,344 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 := s.baseView(u, "dashboard")
|
||||||
view.Title = host.Name + " sources · restic-manager"
|
view.Title = host.Name + " sources · restic-manager"
|
||||||
view.Page = hostSourcesPage{
|
view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows}
|
||||||
hostChromeData: s.loadHostChrome(r, *host, "sources", "sources"),
|
|
||||||
}
|
|
||||||
if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
|
if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
|
||||||
slog.Error("ui: render host_sources", "err", err)
|
slog.Error("ui: render host_sources", "err", err)
|
||||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -186,6 +186,32 @@
|
|||||||
.host-row.clickable > .row-link { pointer-events: auto; }
|
.host-row.clickable > .row-link { pointer-events: auto; }
|
||||||
.host-row.clickable > .row-action { pointer-events: auto; }
|
.host-row.clickable > .row-action { pointer-events: auto; }
|
||||||
|
|
||||||
|
/* ---------- source-group rows (Sources tab) ---------- */
|
||||||
|
.src-row {
|
||||||
|
display: grid; align-items: center;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
column-gap: 18px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */
|
||||||
|
.keep-cell {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.keep-cell label {
|
||||||
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-fade);
|
||||||
|
}
|
||||||
|
.keep-cell input {
|
||||||
|
background: transparent; border: none; outline: none;
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 14px;
|
||||||
|
color: var(--ink); padding: 0; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- log viewer ---------- */
|
/* ---------- log viewer ---------- */
|
||||||
.log {
|
.log {
|
||||||
background: var(--bg); border: 1px solid var(--line-soft);
|
background: var(--bg); border: 1px solid var(--line-soft);
|
||||||
|
|||||||
@@ -2,12 +2,87 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "host_chrome" .}}
|
{{template "host_chrome" .}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
{{$host := $page.Host}}
|
||||||
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
|
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
|
||||||
<div class="empty-state">
|
|
||||||
<h3 class="text-base font-medium tracking-[-0.005em]">Sources tab — coming next.</h3>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
|
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[720px]">
|
||||||
The source-group editor lands in P2R-02 slice 2.
|
Each source group is a named bundle of paths plus the rule for how long its snapshots stick around.
|
||||||
|
Schedules point at one or more groups — one <span class="mono text-ink-mid">restic backup</span> runs per group,
|
||||||
|
tagged by name so <span class="mono text-ink-mid">forget</span> can apply retention cleanly.
|
||||||
</p>
|
</p>
|
||||||
|
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary whitespace-nowrap">+ New source group</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if eq (len $page.Groups) 0}}
|
||||||
|
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
|
||||||
|
<h3 class="text-base font-medium tracking-[-0.005em]">No source groups yet.</h3>
|
||||||
|
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
|
||||||
|
Create one to tell the agent what to back up. The group's name doubles as the snapshot tag.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5">
|
||||||
|
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary">+ New source group</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="panel rounded-[7px] overflow-hidden">
|
||||||
|
{{range $i, $row := $page.Groups}}
|
||||||
|
{{$g := $row.Group}}
|
||||||
|
<div class="src-row {{if not (eq $i 0)}}hairline{{end}}">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center" style="gap: 10px;">
|
||||||
|
<span class="tag mono" style="border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent);">{{$g.Name}}</span>
|
||||||
|
{{if $g.ConflictDimension}}
|
||||||
|
<span class="tag" title="keep-{{$g.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket. Either drop the keep-{{$g.ConflictDimension}} value or add a finer-grained schedule."
|
||||||
|
style="border-color: color-mix(in oklch, var(--warn), transparent 60%); color: var(--warn); cursor: help;">keep-{{$g.ConflictDimension}} · cadence mismatch</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="mono text-[12px] text-ink-mid mt-2">
|
||||||
|
{{len $g.Includes}} include{{if ne (len $g.Includes) 1}}s{{end}} ·
|
||||||
|
{{len $g.Excludes}} exclude{{if ne (len $g.Excludes) 1}}s{{end}} ·
|
||||||
|
{{$g.RetentionPolicy.Summary}}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11.5px] text-ink-fade mt-1">
|
||||||
|
{{if eq $row.UsedBy 0}}
|
||||||
|
used by 0 schedules
|
||||||
|
{{else}}
|
||||||
|
used by {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if gt $row.SnapshotCount 0}} · <span class="mono">{{$row.SnapshotCount}}</span> snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end" style="gap: 6px;">
|
||||||
|
{{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}}
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
hx-post="/hosts/{{$host.ID}}/source-groups/{{$g.ID}}/run"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-disabled-elt="this">Run now</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn" disabled
|
||||||
|
title="{{if eq (len $g.Includes) 0}}add at least one include path before running{{else}}host is offline{{end}}">Run now</button>
|
||||||
|
{{end}}
|
||||||
|
<a href="/hosts/{{$host.ID}}/sources/{{$g.ID}}/edit" class="btn">Edit</a>
|
||||||
|
{{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}}
|
||||||
|
<form method="post" action="/hosts/{{$host.ID}}/sources/{{$g.ID}}/delete" style="display: inline;"
|
||||||
|
onsubmit="return confirm('Delete source group "{{$g.Name}}"? Existing snapshots are not affected.');">
|
||||||
|
<button type="submit" class="btn btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11.5px] text-ink-fade mt-4 leading-[1.65]">
|
||||||
|
Run-now on a row dispatches one immediate backup using that group's paths and tag.
|
||||||
|
Group <span class="mono text-ink-mid">name</span> is used as the snapshot tag — renaming a group
|
||||||
|
doesn't retag existing snapshots.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{{define "title"}}{{.Title}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "host_chrome" .}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
{{$host := $page.Host}}
|
||||||
|
{{$f := $page.Form}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pb-24 pt-6">
|
||||||
|
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
|
||||||
|
{{if $page.IsNew}}New source group{{else}}Edit source group <span class="mono text-ink-mid">·</span> <span class="mono">{{$f.Name}}</span>{{end}}
|
||||||
|
</h1>
|
||||||
|
<p class="text-pretty text-[13px] text-ink-mute max-w-[720px] mt-2 leading-[1.6]">
|
||||||
|
What this group covers and how long its snapshots are worth keeping.
|
||||||
|
Snapshots produced for this group carry the group's name as a tag —
|
||||||
|
rename with care: existing snapshots keep the old tag and won't get retained
|
||||||
|
by a renamed group's policy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if $page.Error}}
|
||||||
|
<div class="mt-5 panel rounded-[6px] px-4 py-3 text-[13px]"
|
||||||
|
style="border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); color: var(--ink);">
|
||||||
|
{{$page.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="{{$page.SaveAction}}" class="grid grid-cols-12 gap-8 mt-7">
|
||||||
|
|
||||||
|
<div class="col-span-7 panel rounded-[7px] p-7">
|
||||||
|
|
||||||
|
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Identity</h3>
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="field-label" for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" class="field mono" value="{{$f.Name}}" autofocus
|
||||||
|
required pattern="[a-z0-9][a-z0-9_-]*" />
|
||||||
|
<div class="field-help">Used as the snapshot tag. Lowercase, no spaces; matches what <span class="mono text-ink-mid">restic forget --tag</span> sees.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">Paths</h3>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="field-label" for="includes">Includes <span class="text-ink-fade">· one path per line</span></label>
|
||||||
|
<textarea id="includes" name="includes" class="field mono" rows="4" style="resize: vertical;">{{$f.Includes}}</textarea>
|
||||||
|
<div class="field-help">What <span class="mono text-ink-mid">restic backup</span> walks. Agent runs as root with <span class="mono text-ink-mid">CAP_DAC_READ_SEARCH</span>, so any readable path is fair game.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<label class="field-label" for="excludes">Excludes <span class="text-ink-fade">· optional, one pattern per line</span></label>
|
||||||
|
<textarea id="excludes" name="excludes" class="field mono" rows="3" style="resize: vertical;">{{$f.Excludes}}</textarea>
|
||||||
|
<div class="field-help">Passed straight through as <span class="mono text-ink-mid">--exclude</span> args.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">
|
||||||
|
Retention
|
||||||
|
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">applied nightly · all blank = keep everything</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{{if and (not $page.IsNew) $f.ConflictDimension}}
|
||||||
|
<div class="mb-3.5 flex gap-3 items-start rounded-[6px] px-3.5 py-3"
|
||||||
|
style="border: 1px solid color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">
|
||||||
|
<div class="text-[16px] leading-none text-warn pt-[1px]">⚠</div>
|
||||||
|
<div class="text-[12.5px] text-ink-mid leading-[1.55]">
|
||||||
|
<strong class="text-ink">keep-{{$f.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket.</strong>
|
||||||
|
Either drop <span class="mono text-ink">keep-{{$f.ConflictDimension}}</span> or add a finer-grained schedule.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div class="keep-cell"><label>Keep last</label><input type="number" min="0" name="keep_last" value="{{$f.KeepLast}}" placeholder="—" /></div>
|
||||||
|
<div class="keep-cell"><label>Hourly</label><input type="number" min="0" name="keep_hourly" value="{{$f.KeepHourly}}" placeholder="—" /></div>
|
||||||
|
<div class="keep-cell"><label>Daily</label><input type="number" min="0" name="keep_daily" value="{{$f.KeepDaily}}" placeholder="—" /></div>
|
||||||
|
<div class="keep-cell"><label>Weekly</label><input type="number" min="0" name="keep_weekly" value="{{$f.KeepWeekly}}" placeholder="—" /></div>
|
||||||
|
<div class="keep-cell"><label>Monthly</label><input type="number" min="0" name="keep_monthly" value="{{$f.KeepMonthly}}" placeholder="—" /></div>
|
||||||
|
<div class="keep-cell"><label>Yearly</label><input type="number" min="0" name="keep_yearly" value="{{$f.KeepYearly}}" placeholder="—" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11.5px] text-ink-fade mt-3 leading-[1.55]">
|
||||||
|
Blank fields stay unset (no constraint on that bucket). Forget runs nightly on the cadence configured on the
|
||||||
|
<a href="/hosts/{{$host.ID}}/repo" class="text-accent underline" style="text-underline-offset: 2px;">Repo tab</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-7 pt-4 border-t border-line-soft">
|
||||||
|
Retry on offline
|
||||||
|
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">cron-fired runs only</span>
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3.5">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="retry_max">Max attempts</label>
|
||||||
|
<input type="number" min="0" id="retry_max" name="retry_max" class="field mono" value="{{$f.RetryMax}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="retry_backoff_seconds">Initial backoff (sec)</label>
|
||||||
|
<input type="number" min="0" id="retry_backoff_seconds" name="retry_backoff_seconds" class="field mono" value="{{$f.RetryBackoffSeconds}}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-help mt-2">
|
||||||
|
Each retry doubles the wait. <strong>Manual run-now ignores this</strong> — it just fails immediately if the agent is offline.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-4 border-t border-line-soft flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create group{{else}}Save changes{{end}}</button>
|
||||||
|
<a href="/hosts/{{$host.ID}}/sources" class="btn btn-lg">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="col-span-5">
|
||||||
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3.5">How this fits</div>
|
||||||
|
<ol class="list-none p-0 m-0 text-[13px]">
|
||||||
|
<li class="relative pl-9 pb-4">
|
||||||
|
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
|
||||||
|
<div class="font-medium">Save here</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Bumps the host's schedule version; the agent picks up the new paths/retention on its next push (within seconds when online).</div>
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-9 pb-4">
|
||||||
|
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
|
||||||
|
<div class="font-medium">Schedules pointing here change behaviour</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Any schedule that includes this group in its picker now backs up the new paths next time it fires.</div>
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-9 pb-4">
|
||||||
|
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
|
||||||
|
<div class="font-medium">Retention applies on the next nightly forget</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Existing tagged snapshots get re-evaluated against the new keep-* rules. Untagged or differently-tagged snapshots are untouched.</div>
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-9">
|
||||||
|
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
|
||||||
|
<div class="font-medium">Run-now from the Sources list</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Want to test? Save, go back to <a href="/hosts/{{$host.ID}}/sources" class="text-accent underline">Sources</a>, hit Run-now on this row.</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user