|
|
|
@@ -0,0 +1,467 @@
|
|
|
|
|
package http
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
stdhttp "net/http"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
|
|
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// schedulesListPage carries everything the Schedules tab needs.
|
|
|
|
|
type schedulesListPage struct {
|
|
|
|
|
Host store.Host
|
|
|
|
|
Schedules []store.Schedule
|
|
|
|
|
Version int64
|
|
|
|
|
AppliedVersion int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// scheduleEditPage drives both the Create form (Schedule.ID empty)
|
|
|
|
|
// and the Edit form (Schedule populated). Errors come back via Error
|
|
|
|
|
// to be rendered as a banner; FormValues holds the just-submitted
|
|
|
|
|
// raw fields so a failed POST can re-render with the operator's
|
|
|
|
|
// typed input still in place.
|
|
|
|
|
type scheduleEditPage struct {
|
|
|
|
|
Host store.Host
|
|
|
|
|
IsNew bool
|
|
|
|
|
ScheduleID string
|
|
|
|
|
Error string
|
|
|
|
|
// Form values — strings so partial input survives validation
|
|
|
|
|
// errors (e.g. operator typed "abc" into keep_last).
|
|
|
|
|
CronExpr string
|
|
|
|
|
PathsRaw string
|
|
|
|
|
ExcludesRaw string
|
|
|
|
|
TagsRaw string
|
|
|
|
|
KeepLast string
|
|
|
|
|
KeepHourly string
|
|
|
|
|
KeepDaily string
|
|
|
|
|
KeepWeekly string
|
|
|
|
|
KeepMonthly string
|
|
|
|
|
KeepYearly string
|
|
|
|
|
LimitUpKBps string
|
|
|
|
|
LimitDownKBps string
|
|
|
|
|
Enabled bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleUISchedulesList renders the Schedules sub-tab on a host.
|
|
|
|
|
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
|
|
|
u := s.requireUIUser(w, r)
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
version, _ := s.deps.Store.GetHostScheduleVersion(r.Context(), hostID)
|
|
|
|
|
|
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
|
|
|
view.Title = host.Name + " · schedules · restic-manager"
|
|
|
|
|
view.Page = schedulesListPage{
|
|
|
|
|
Host: *host,
|
|
|
|
|
Schedules: rows,
|
|
|
|
|
Version: version,
|
|
|
|
|
AppliedVersion: host.AppliedScheduleVersion,
|
|
|
|
|
}
|
|
|
|
|
if err := s.deps.UI.Render(w, "schedules_list", view); err != nil {
|
|
|
|
|
slog.Error("ui: render schedules_list", "err", err)
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleUIScheduleNewGet renders the empty Create form.
|
|
|
|
|
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
|
|
|
u := s.requireUIUser(w, r)
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
|
|
|
view.Title = "New schedule · " + host.Name
|
|
|
|
|
view.Page = scheduleEditPage{
|
|
|
|
|
Host: *host,
|
|
|
|
|
IsNew: true,
|
|
|
|
|
CronExpr: "0 3 * * *",
|
|
|
|
|
Enabled: true,
|
|
|
|
|
}
|
|
|
|
|
s.renderScheduleEdit(w, view)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleUIScheduleEditGet renders the Edit form pre-filled from the
|
|
|
|
|
// existing schedule row.
|
|
|
|
|
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
|
|
|
u := s.requireUIUser(w, r)
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
|
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
sched, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
page := scheduleEditPage{
|
|
|
|
|
Host: *host,
|
|
|
|
|
IsNew: false,
|
|
|
|
|
ScheduleID: sched.ID,
|
|
|
|
|
CronExpr: sched.CronExpr,
|
|
|
|
|
PathsRaw: strings.Join(sched.Paths, "\n"),
|
|
|
|
|
ExcludesRaw: strings.Join(sched.Excludes, "\n"),
|
|
|
|
|
TagsRaw: strings.Join(sched.Tags, ", "),
|
|
|
|
|
Enabled: sched.Enabled,
|
|
|
|
|
}
|
|
|
|
|
page.KeepLast = intStringPtr(sched.RetentionPolicy.KeepLast)
|
|
|
|
|
page.KeepHourly = intStringPtr(sched.RetentionPolicy.KeepHourly)
|
|
|
|
|
page.KeepDaily = intStringPtr(sched.RetentionPolicy.KeepDaily)
|
|
|
|
|
page.KeepWeekly = intStringPtr(sched.RetentionPolicy.KeepWeekly)
|
|
|
|
|
page.KeepMonthly = intStringPtr(sched.RetentionPolicy.KeepMonthly)
|
|
|
|
|
page.KeepYearly = intStringPtr(sched.RetentionPolicy.KeepYearly)
|
|
|
|
|
page.LimitUpKBps = intStringPtr(sched.Options.LimitUploadKBps)
|
|
|
|
|
page.LimitDownKBps = intStringPtr(sched.Options.LimitDownloadKBps)
|
|
|
|
|
|
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
|
|
|
view.Title = "Edit schedule · " + host.Name
|
|
|
|
|
view.Page = page
|
|
|
|
|
s.renderScheduleEdit(w, view)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleUIScheduleSave handles POST for both create and update. The
|
|
|
|
|
// edit form posts to /hosts/{id}/schedules/new (for create) or
|
|
|
|
|
// /hosts/{id}/schedules/{sid}/edit (for update); we branch on whether
|
|
|
|
|
// {sid} is present in the route params.
|
|
|
|
|
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
|
|
|
u := s.requireUIUser(w, r)
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
|
|
|
storeUser, _, err := s.userByID(r, u.ID)
|
|
|
|
|
if err != nil || storeUser == nil {
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
page := scheduleEditPage{
|
|
|
|
|
Host: *host,
|
|
|
|
|
IsNew: scheduleID == "",
|
|
|
|
|
ScheduleID: scheduleID,
|
|
|
|
|
CronExpr: strings.TrimSpace(r.PostForm.Get("cron_expr")),
|
|
|
|
|
PathsRaw: r.PostForm.Get("paths"),
|
|
|
|
|
ExcludesRaw: r.PostForm.Get("excludes"),
|
|
|
|
|
TagsRaw: strings.TrimSpace(r.PostForm.Get("tags")),
|
|
|
|
|
KeepLast: strings.TrimSpace(r.PostForm.Get("keep_last")),
|
|
|
|
|
KeepHourly: strings.TrimSpace(r.PostForm.Get("keep_hourly")),
|
|
|
|
|
KeepDaily: strings.TrimSpace(r.PostForm.Get("keep_daily")),
|
|
|
|
|
KeepWeekly: strings.TrimSpace(r.PostForm.Get("keep_weekly")),
|
|
|
|
|
KeepMonthly: strings.TrimSpace(r.PostForm.Get("keep_monthly")),
|
|
|
|
|
KeepYearly: strings.TrimSpace(r.PostForm.Get("keep_yearly")),
|
|
|
|
|
LimitUpKBps: strings.TrimSpace(r.PostForm.Get("limit_up_kbps")),
|
|
|
|
|
LimitDownKBps: strings.TrimSpace(r.PostForm.Get("limit_down_kbps")),
|
|
|
|
|
Enabled: r.PostForm.Get("enabled") == "on",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert the raw form values into store-shape data, surfacing
|
|
|
|
|
// the first parse error as a banner.
|
|
|
|
|
paths := splitPaths(page.PathsRaw)
|
|
|
|
|
excludes := splitPaths(page.ExcludesRaw)
|
|
|
|
|
tags := splitCSV(page.TagsRaw)
|
|
|
|
|
|
|
|
|
|
retention, err := parseRetention(page)
|
|
|
|
|
if err != nil {
|
|
|
|
|
page.Error = err.Error()
|
|
|
|
|
s.renderEditPage(w, u, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
options, err := parseOptions(page)
|
|
|
|
|
if err != nil {
|
|
|
|
|
page.Error = err.Error()
|
|
|
|
|
s.renderEditPage(w, u, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate against the same rules the JSON API uses (cron, paths,
|
|
|
|
|
// hooks-on-non-backup) — the UI only handles backup kind today,
|
|
|
|
|
// so we hardcode kind=backup here.
|
|
|
|
|
apiShape := scheduleAPI{
|
|
|
|
|
Kind: api.JobBackup,
|
|
|
|
|
CronExpr: page.CronExpr,
|
|
|
|
|
Paths: paths,
|
|
|
|
|
}
|
|
|
|
|
if code, msg := validateSchedule(&apiShape); code != "" {
|
|
|
|
|
page.Error = uiErrorMessage(code, msg)
|
|
|
|
|
s.renderEditPage(w, u, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if page.IsNew {
|
|
|
|
|
row := store.Schedule{
|
|
|
|
|
ID: ulid.Make().String(),
|
|
|
|
|
HostID: hostID,
|
|
|
|
|
Kind: string(api.JobBackup),
|
|
|
|
|
CronExpr: page.CronExpr,
|
|
|
|
|
Paths: paths,
|
|
|
|
|
Excludes: excludes,
|
|
|
|
|
Tags: tags,
|
|
|
|
|
RetentionPolicy: retention,
|
|
|
|
|
Options: options,
|
|
|
|
|
Enabled: page.Enabled,
|
|
|
|
|
}
|
|
|
|
|
if err := s.deps.Store.CreateSchedule(r.Context(), &row); err != nil {
|
|
|
|
|
page.Error = "Couldn't save schedule — see server log."
|
|
|
|
|
slog.Error("ui schedule create", "err", err)
|
|
|
|
|
s.renderEditPage(w, u, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
|
|
|
ID: ulid.Make().String(),
|
|
|
|
|
UserID: &storeUser.ID,
|
|
|
|
|
Actor: "user",
|
|
|
|
|
Action: "schedule.created",
|
|
|
|
|
TargetKind: ptr("schedule"),
|
|
|
|
|
TargetID: &row.ID,
|
|
|
|
|
TS: nowUTC(),
|
|
|
|
|
})
|
|
|
|
|
s.pushScheduleSetAsync(hostID)
|
|
|
|
|
} else {
|
|
|
|
|
existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
existing.CronExpr = page.CronExpr
|
|
|
|
|
existing.Paths = paths
|
|
|
|
|
existing.Excludes = excludes
|
|
|
|
|
existing.Tags = tags
|
|
|
|
|
existing.RetentionPolicy = retention
|
|
|
|
|
existing.Options = options
|
|
|
|
|
existing.Enabled = page.Enabled
|
|
|
|
|
if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil {
|
|
|
|
|
page.Error = "Couldn't save schedule — see server log."
|
|
|
|
|
slog.Error("ui schedule update", "err", err)
|
|
|
|
|
s.renderEditPage(w, u, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
|
|
|
ID: ulid.Make().String(),
|
|
|
|
|
UserID: &storeUser.ID,
|
|
|
|
|
Actor: "user",
|
|
|
|
|
Action: "schedule.updated",
|
|
|
|
|
TargetKind: ptr("schedule"),
|
|
|
|
|
TargetID: &scheduleID,
|
|
|
|
|
TS: nowUTC(),
|
|
|
|
|
})
|
|
|
|
|
s.pushScheduleSetAsync(hostID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleUIScheduleDelete is the POST target of the Delete buttons on
|
|
|
|
|
// the list view. Confirm-then-redirect; no AJAX.
|
|
|
|
|
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
|
|
|
u := s.requireUIUser(w, r)
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
|
|
|
storeUser, _, err := s.userByID(r, u.ID)
|
|
|
|
|
if err != nil || storeUser == nil {
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
stdhttp.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
|
|
|
ID: ulid.Make().String(),
|
|
|
|
|
UserID: &storeUser.ID,
|
|
|
|
|
Actor: "user",
|
|
|
|
|
Action: "schedule.deleted",
|
|
|
|
|
TargetKind: ptr("schedule"),
|
|
|
|
|
TargetID: &scheduleID,
|
|
|
|
|
TS: nowUTC(),
|
|
|
|
|
})
|
|
|
|
|
s.pushScheduleSetAsync(hostID)
|
|
|
|
|
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) renderScheduleEdit(w stdhttp.ResponseWriter, view ui.ViewData) {
|
|
|
|
|
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
|
|
|
|
|
slog.Error("ui: render schedule_edit", "err", err)
|
|
|
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) renderEditPage(w stdhttp.ResponseWriter, u *ui.User, page scheduleEditPage) {
|
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
|
|
|
if page.IsNew {
|
|
|
|
|
view.Title = "New schedule · " + page.Host.Name
|
|
|
|
|
} else {
|
|
|
|
|
view.Title = "Edit schedule · " + page.Host.Name
|
|
|
|
|
}
|
|
|
|
|
view.Page = page
|
|
|
|
|
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
|
|
|
|
s.renderScheduleEdit(w, view)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ----- helpers --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// splitCSV parses comma-separated values into a clean []string —
|
|
|
|
|
// leading/trailing whitespace trimmed, blanks dropped.
|
|
|
|
|
func splitCSV(s string) []string {
|
|
|
|
|
out := []string{}
|
|
|
|
|
for _, p := range strings.Split(s, ",") {
|
|
|
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
|
|
|
out = append(out, t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseRetention(p scheduleEditPage) (store.RetentionPolicy, error) {
|
|
|
|
|
var r store.RetentionPolicy
|
|
|
|
|
for _, f := range []struct {
|
|
|
|
|
raw string
|
|
|
|
|
dest **int
|
|
|
|
|
name string
|
|
|
|
|
}{
|
|
|
|
|
{p.KeepLast, &r.KeepLast, "keep last"},
|
|
|
|
|
{p.KeepHourly, &r.KeepHourly, "keep hourly"},
|
|
|
|
|
{p.KeepDaily, &r.KeepDaily, "keep daily"},
|
|
|
|
|
{p.KeepWeekly, &r.KeepWeekly, "keep weekly"},
|
|
|
|
|
{p.KeepMonthly, &r.KeepMonthly, "keep monthly"},
|
|
|
|
|
{p.KeepYearly, &r.KeepYearly, "keep yearly"},
|
|
|
|
|
} {
|
|
|
|
|
v, err := parsePosInt(f.raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return r, errFmtf("%s: %s", f.name, err)
|
|
|
|
|
}
|
|
|
|
|
*f.dest = v
|
|
|
|
|
}
|
|
|
|
|
return r, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseOptions(p scheduleEditPage) (store.ScheduleOptions, error) {
|
|
|
|
|
var o store.ScheduleOptions
|
|
|
|
|
up, err := parsePosInt(p.LimitUpKBps)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return o, errFmtf("limit upload: %s", err)
|
|
|
|
|
}
|
|
|
|
|
o.LimitUploadKBps = up
|
|
|
|
|
down, err := parsePosInt(p.LimitDownKBps)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return o, errFmtf("limit download: %s", err)
|
|
|
|
|
}
|
|
|
|
|
o.LimitDownloadKBps = down
|
|
|
|
|
return o, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parsePosInt turns a possibly-empty string into *int. Empty → nil
|
|
|
|
|
// (no value). Non-empty must parse as a positive int.
|
|
|
|
|
func parsePosInt(raw string) (*int, error) {
|
|
|
|
|
if raw == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
v, err := strconv.Atoi(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errFmtf("must be a whole number")
|
|
|
|
|
}
|
|
|
|
|
if v < 0 {
|
|
|
|
|
return nil, errFmtf("must be non-negative")
|
|
|
|
|
}
|
|
|
|
|
return &v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func intStringPtr(p *int) string {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return strconv.Itoa(*p)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// uiErrorMessage maps the JSON-API validation codes to operator-
|
|
|
|
|
// friendly banner text.
|
|
|
|
|
func uiErrorMessage(code, msg string) string {
|
|
|
|
|
switch code {
|
|
|
|
|
case "missing_cron_expr":
|
|
|
|
|
return "Cron expression is required."
|
|
|
|
|
case "invalid_cron_expr":
|
|
|
|
|
return "Cron expression doesn't parse: " + msg
|
|
|
|
|
case "missing_paths":
|
|
|
|
|
return "At least one backup path is required (one per line)."
|
|
|
|
|
case "invalid_kind":
|
|
|
|
|
return "Unsupported schedule kind."
|
|
|
|
|
default:
|
|
|
|
|
return msg
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// errFmtf wraps fmt.Errorf so the validators read consistently.
|
|
|
|
|
func errFmtf(format string, args ...any) error {
|
|
|
|
|
return fmt.Errorf(format, args...)
|
|
|
|
|
}
|