package http import ( "context" "encoding/json" "errors" "log/slog" stdhttp "net/http" "strconv" "strings" "time" "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_schedules.go — HTML form-driven schedule CRUD against the slim // shape (cron + source-group multi-select + enabled). // hostSchedulesPage backs the list view. GroupNames maps source-group // ID → name for the per-row tag rendering, populated once on load so // the template doesn't need to do per-row store lookups. type hostSchedulesPage struct { hostChromeData Schedules []scheduleRow GroupNames map[string]string } // scheduleRow bundles a schedule with its derived "next run" + "last // run" data. The Schedule is embedded so existing template field // references (`$sc.ID`, `$sc.CronExpr`, etc) keep working when we // switch the iterating slice from []store.Schedule to []scheduleRow. type scheduleRow struct { store.Schedule NextRun *time.Time LastRun *time.Time LastJobID string LastStatus string // succeeded|failed|running|queued — empty when never fired } // scheduleFormData mirrors the form's wire shape — strings + bool for // round-trip on validation re-render. type scheduleFormData struct { CronExpr string Enabled bool } // scheduleEditPage backs both the new and edit form views. type scheduleEditPage struct { hostChromeData IsNew bool ScheduleID string // empty when IsNew Form scheduleFormData AvailableGroups []store.SourceGroup SelectedGroupIDs map[string]bool // gid → checked SaveAction string Error string } func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID) if err != nil { slog.Error("ui schedules: list", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) if err != nil { slog.Error("ui schedules: list groups", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } names := make(map[string]string, len(groups)) for _, g := range groups { names[g.ID] = g.Name } now := time.Now().UTC() rows := make([]scheduleRow, 0, len(scheds)) for _, sc := range scheds { row := scheduleRow{Schedule: sc} if sc.Enabled { if sched, err := cronParser.Parse(sc.CronExpr); err == nil { next := sched.Next(now).UTC() row.NextRun = &next } } if j, jerr := s.deps.Store.LatestJobBySchedule(r.Context(), host.ID, sc.ID); jerr == nil && j != nil { t := j.CreatedAt if j.StartedAt != nil { t = *j.StartedAt } row.LastRun = &t row.LastJobID = j.ID row.LastStatus = j.Status } rows = append(rows, row) } chrome := s.loadHostChrome(r, *host, "schedules", "schedules") chrome.ScheduleCount = len(scheds) chrome.SourceGroupCount = len(groups) view := s.baseView(u) view.Title = host.Name + " schedules · restic-manager" view.Page = hostSchedulesPage{ hostChromeData: chrome, Schedules: rows, GroupNames: names, } if err := s.deps.UI.Render(w, "host_schedules", view); err != nil { slog.Error("ui: render host_schedules", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } func (s *Server) handleUIScheduleNewGet(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 schedule new: list groups", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } view := s.baseView(u) view.Title = "New schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"), IsNew: true, Form: scheduleFormData{Enabled: true}, AvailableGroups: groups, SelectedGroupIDs: map[string]bool{}, SaveAction: "/hosts/" + host.ID + "/schedules/new", } if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { slog.Error("ui: render schedule_edit (new)", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } sid := chi.URLParam(r, "sid") sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui schedule edit: get", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) if err != nil { slog.Error("ui schedule edit: list groups", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } selected := make(map[string]bool, len(sc.SourceGroupIDs)) for _, gid := range sc.SourceGroupIDs { selected[gid] = true } view := s.baseView(u) view.Title = "Edit schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"), IsNew: false, ScheduleID: sid, Form: scheduleFormData{ CronExpr: sc.CronExpr, Enabled: sc.Enabled, }, AvailableGroups: groups, SelectedGroupIDs: selected, SaveAction: "/hosts/" + host.ID + "/schedules/" + sid + "/edit", } 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) } } // handleUIScheduleSave handles both create and update. On validation // error, re-renders with input intact + a banner. func (s *Server) handleUIScheduleSave(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 } sid := chi.URLParam(r, "sid") isNew := sid == "" form := scheduleFormData{ CronExpr: strings.TrimSpace(r.PostForm.Get("cron")), Enabled: r.PostForm.Get("enabled") == "1", } pickedIDs := r.PostForm["source_group_ids"] selected := make(map[string]bool, len(pickedIDs)) for _, gid := range pickedIDs { selected[gid] = true } // --- validation --- var errMsg string switch { case form.CronExpr == "": errMsg = "Cron expression is required." case len(pickedIDs) == 0: errMsg = "Pick at least one source group — a schedule has to know what to back up." } if errMsg == "" { if _, err := cronParser.Parse(form.CronExpr); err != nil { errMsg = "Cron didn't parse: " + err.Error() } } // Verify every picked group belongs to this host. if errMsg == "" { for _, gid := range pickedIDs { g, gerr := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid) if gerr != nil || g == nil { errMsg = "One of the picked source groups isn't on this host — refresh and try again." break } } } if errMsg != "" { s.renderScheduleFormError(w, r, u, host, sid, isNew, form, selected, errMsg) return } sc := store.Schedule{ ID: sid, HostID: host.ID, CronExpr: form.CronExpr, Enabled: form.Enabled, SourceGroupIDs: pickedIDs, } if isNew { sc.ID = ulid.Make().String() if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil { slog.Error("ui schedule save: create", "err", err) s.renderScheduleFormError(w, r, u, host, "", true, form, selected, "Couldn't create — see the server log for details.") return } } else { if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil { slog.Error("ui schedule save: update", "err", err) s.renderScheduleFormError(w, r, u, host, sid, false, form, selected, "Couldn't save — see the server log for details.") return } } s.pushScheduleSetAsync(host.ID) stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } sid := chi.URLParam(r, "sid") if err := s.deps.Store.DeleteSchedule(r.Context(), host.ID, sid); err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui schedule delete", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } s.pushScheduleSetAsync(host.ID) stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } // handleUIScheduleRun is the per-schedule Run-now action: dispatch // every source group the schedule references in a single shot, // reusing dispatchScheduledJob (the same path real cron fires take). // HTMX only — falls back to a 405 for non-HTMX callers (per-group // Run-now via the Sources tab is the JSON path). func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) { if u := s.requireUIUser(w, r); u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } sid := chi.URLParam(r, "sid") sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if len(sc.SourceGroupIDs) == 0 { stdhttp.Error(w, "this schedule has no source groups attached", stdhttp.StatusConflict) return } if s.deps.Hub == nil { stdhttp.Error(w, "ws hub not configured", stdhttp.StatusServiceUnavailable) return } conn := s.deps.Hub.Conn(host.ID) if conn == nil { stdhttp.Error(w, "host is offline — reconnect the agent and try again", stdhttp.StatusConflict) return } // Manual Run-now ignores Enabled. "Disabled" only suppresses // cron-tick firing; an ad-hoc one-off run is a separate intent // (and the dispatch is audit-logged inside dispatchBackupForGroup). // We dispatch inline rather than calling dispatchScheduledJob, // which short-circuits on !Enabled. ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() now := time.Now().UTC() type fired struct{ groupName, jobID string } dispatched := make([]fired, 0, len(sc.SourceGroupIDs)) for _, gid := range sc.SourceGroupIDs { g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid) if gerr != nil { slog.Warn("ui schedule run: load source group", "host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr) continue } jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) if jobID != "" { dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID}) } } if wantsHTML(r) { switch len(dispatched) { case 0: stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError) return case 1: // Single-group schedule: jump straight to the live job log, // same UX as per-source-group Run-now from the Sources tab. w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID) default: // Multi-group: stay on the schedules tab and toast the // summary. Direct the operator to one of the job logs via // the toast (the most recent job ID is fine). names := make([]string, 0, len(dispatched)) for _, f := range dispatched { names = append(names, f.groupName) } msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ") payload, _ := json.Marshal(map[string]any{ "rm:toast": map[string]string{"level": "success", "message": msg}, }) w.Header().Set("HX-Trigger", string(payload)) } } w.WriteHeader(stdhttp.StatusNoContent) } func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, sid string, isNew bool, form scheduleFormData, selected map[string]bool, msg string) { groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) if err != nil { slog.Error("ui schedule re-render: list groups", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } saveAction := "/hosts/" + host.ID + "/schedules/new" crumb := "new schedule" if !isNew { saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit" crumb = "edit schedule" } view := s.baseView(u) view.Title = "Schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb), IsNew: isNew, ScheduleID: sid, Form: form, AvailableGroups: groups, SelectedGroupIDs: selected, SaveAction: saveAction, Error: msg, } w.WriteHeader(stdhttp.StatusUnprocessableEntity) if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { slog.Error("ui: render schedule_edit (error)", "err", err) } } // loadHostForUI is a small helper shared across the host-detail tab // handlers — fetches the host by URL param, writing the appropriate // 404/500 + returning ok=false on failure. func (s *Server) loadHostForUI(w stdhttp.ResponseWriter, r *stdhttp.Request) (*store.Host, bool) { hostID := chi.URLParam(r, "id") if hostID == "" { stdhttp.NotFound(w, r) return nil, false } host, err := s.deps.Store.GetHost(r.Context(), hostID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return nil, false } slog.Error("ui host tab: get host", "host_id", hostID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return nil, false } return host, true }