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; the rest of the fields hold the just- // submitted raw values 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 Manual 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, Manual: sched.Manual, } 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", Manual: r.PostForm.Get("manual") == "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. Manual // schedules skip the cron-expr requirement; everything else // applies the same. apiShape := scheduleAPI{ Kind: api.JobBackup, CronExpr: page.CronExpr, Paths: paths, Manual: page.Manual, } 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, Manual: page.Manual, } 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 existing.Manual = page.Manual 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) } // handleUIScheduleRun is the POST target of per-schedule Run-now // buttons. Reuses dispatchScheduledJob (the same code path used by // the agent's local cron firing) so manual + automated runs flow // through identical job lifecycle. Sets HX-Redirect to the live // log on success. func (s *Server) handleUIScheduleRun(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 } if !s.deps.Hub.Connected(hostID) { stdhttp.Error(w, "agent is offline", stdhttp.StatusBadRequest) return } _ = host jobID, err := s.dispatchScheduleNow(r.Context(), hostID, scheduleID, nil) if err != nil { stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest) return } target := "/jobs/" + jobID if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", target) w.WriteHeader(stdhttp.StatusOK) return } stdhttp.Redirect(w, r, target, 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...) }