e871b05b38
CI / Test (linux/amd64) (pull_request) Successful in 34s
CI / Lint (pull_request) Failing after 16s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 20s
CI / Build (linux/arm64) (pull_request) Successful in 21s
Cleanup pass over the repo so CI can enforce lint going forward
without the only-new-issues escape hatch:
* gofumpt -w across the tree (31 hits, all formatting)
* misspell --fix (25 hits, US-locale spelling) — but reverted on
api.JobCancelled = "cancelled" since that literal is the wire +
DB CHECK constraint value, plus matched the case in store/fleet.go
back to "cancelled" and added //nolint:misspell on both for the
next time someone reaches for the auto-fix
* Wrap every `defer rows.Close()` / `defer stmt.Close()` /
`defer res.Body.Close()` in `defer func() { _ = .Close() }()`
to satisfy errcheck without losing the close itself
* websocket.Dial callers (1 prod, 4 tests) now capture + close the
upgrade response Body — coder/websocket can return res with a nil
Body on success, so the test deferred-closes guard against that
* Annotate the two genuine-by-design nilerr cases with //nolint
comments explaining why nil-on-error is the contract (cookie
missing = no session; ctx cancelled mid-backoff = clean shutdown)
* Add brief godoc on the 10 exported const groups + types that
revive flagged (api.HostOS/HostArch/JobKind/JobStatus/LogStream/
ErrorCode, restic.EventKind, store.Role, web.FS)
* Drop the unused (*Server).userByID method
* Inline the unparam baseView(active) — every UI page is under
the dashboard primary nav today
Result: `golangci-lint run ./...` reports 0 issues. CI lint job
no longer needs only-new-issues: true; X-06 follow-up entry in
tasks.md removed.
423 lines
13 KiB
Go
423 lines
13 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 []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) {
|
|
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
|
|
}
|
|
|
|
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: scheds,
|
|
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
|
|
}
|