P2R-02 slice 3: Schedules tab — slim list, new/edit form, delete, Run-now
Schedules list: status (enabled/paused) + cron + source-group tags + actions (Run-now when enabled+online, Edit, Delete). Run-now reuses dispatchScheduledJob — same path real cron fires take, so each referenced source group runs as its own backup with its own tag. Falls back to a 409 if the agent is offline. Schedule new/edit form: cron input with five preset chips (quick-pick @hourly / nightly / 6h / weekly / monthly), source-group multi-pick rendered as styled checkbox cards (visual state tracks the underlying box via a tiny inline script), enabled toggle. No paths/excludes/retention/kind on the schedule itself — those live on source groups now. Server-side validation re-renders with the operator's input + ticked groups intact. Every successful mutation calls pushScheduleSetAsync. Adds .schd-row, .preset-chip, .picker styles.
This commit is contained in:
@@ -1,23 +1,49 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"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). The list view
|
||||
// is live as of slice 1 of P2R-02; the new/edit/delete/run handlers
|
||||
// land in slice 3.
|
||||
// shape (cron + source-group multi-select + enabled).
|
||||
|
||||
// hostSchedulesPage is the data the schedules-tab template renders.
|
||||
// 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 []store.Schedule
|
||||
GroupNames map[string]string
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -29,10 +55,33 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ
|
||||
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
|
||||
}
|
||||
|
||||
chrome := s.loadHostChrome(r, *host, "schedules", "schedules")
|
||||
chrome.ScheduleCount = len(scheds)
|
||||
chrome.SourceGroupCount = len(groups)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " schedules · restic-manager"
|
||||
view.Page = hostSchedulesPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "schedules", "schedules"),
|
||||
hostChromeData: chrome,
|
||||
Schedules: scheds,
|
||||
GroupNames: names,
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "host_schedules", view); err != nil {
|
||||
slog.Error("ui: render host_schedules", "err", err)
|
||||
@@ -41,23 +90,274 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ
|
||||
}
|
||||
|
||||
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
|
||||
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, "dashboard")
|
||||
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) {
|
||||
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
|
||||
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, "dashboard")
|
||||
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) {
|
||||
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
|
||||
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) {
|
||||
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
|
||||
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) {
|
||||
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
|
||||
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 !sc.Enabled {
|
||||
stdhttp.Error(w, "schedule is paused — enable it first or use per-group Run-now from the Sources tab",
|
||||
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
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
s.dispatchScheduledJob(ctx, host.ID, conn, sid, time.Now().UTC())
|
||||
|
||||
if wantsHTML(r) {
|
||||
// HX-Redirect would jump to a single job log, but a multi-group
|
||||
// fire produces N jobs. Bounce back to the list — the operator
|
||||
// can drill into individual jobs from there.
|
||||
w.Header().Set("HX-Redirect", "/hosts/"+host.ID+"/schedules")
|
||||
}
|
||||
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, "dashboard")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user