Files
restic-manager/internal/server/http/ui_schedules.go
T
steve 9dbed025e0 ui: F1 — populate OpenAlerts in baseView so nav badge updates everywhere
Flagged in review of 35dee98: the Alerts tab badge should show the
open count from any page, not just /alerts. baseView now takes the
request and queries store.ListAlerts(Status: "open") to fill
view.OpenAlerts on every page render. All call sites updated.
2026-05-04 20:19:09 +01:00

457 lines
14 KiB
Go

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(r, 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(r, 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(r, 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(r, 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
}