c6237d4004
Closes the schedule foundations slice — operator can now drive the
plumbing P2-01..03 landed without touching the JSON API.
* New routes:
- GET /hosts/{id}/schedules (list)
- GET /hosts/{id}/schedules/new (create form)
- POST /hosts/{id}/schedules/new (create)
- GET /hosts/{id}/schedules/{sid}/edit (edit form)
- POST /hosts/{id}/schedules/{sid}/edit (update)
- POST /hosts/{id}/schedules/{sid}/delete (delete, confirm-then-redirect)
* List view (web/templates/pages/schedules_list.html):
status, cron, paths, retention summary, tags, edit/delete buttons.
Header shows "version N · agent in sync" or "agent at vM" when the
push hasn't been ack'd yet — backed by host_schedule_version +
applied_schedule_version. Empty-state CTA points at /schedules/new.
* Create/edit form (web/templates/pages/schedule_edit.html, shared):
cron expression with five quick-pick presets (daily 3am / every 6h
/ @hourly / weekly Sun / monthly 1st), paths textarea (one per
line), excludes textarea, tags (comma-separated), retention as six
numeric fields (mirrors restic's --keep-* flags one-for-one),
bandwidth caps, enabled toggle. Side panel explains the
reconciliation flow so the operator knows what saving actually
does. Validation errors re-render with operator's input intact.
* internal/server/http/ui_schedules.go owns the handlers; reuses
the same validateSchedule + pushScheduleSetAsync used by the JSON
API path. Each save audit-logs schedule.created / schedule.updated
/ schedule.deleted (matching the JSON API actions).
* store.RetentionPolicy gains a Summary() method ("last=7, d=14,
w=4" or "—"). Used by the list view's table cell so templates
don't have to do any conditional retention rendering.
* Two new template helpers: list (string varargs → []string, used
for the cron preset row) and joinComma (sibling to joinDot for
the rare list that wants commas). RetentionPolicy.Summary covers
the schedule-list case but the helpers are general.
* host_detail.html secondary tabs row converted from inert <div>s
into <a> links. Snapshots active by default; Schedules now points
at the new page. Jobs/Repo/Settings remain inert until their
P2 owners ship.
Hooks UI deferred to P2-15 (lands with the hook execution path).
Single-kind UI (backup only) by design — other kinds get a UI when
their job dispatch lands in P2-05..08.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
468 lines
14 KiB
Go
468 lines
14 KiB
Go
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...)
|
|
}
|