P2 redesign · phase 2: store rewrite — sources, slim schedules, repo maintenance
Go-side data model rebuilt against migration 0008. The fat-Schedule
shape (paths/excludes/tags/retention/manual/kind/options/hooks) is
gone; that surface lives on source_groups now.
* store/types.go
- Schedule slimmed to {id, host_id, cron, enabled, source_group_ids,
timestamps}. SourceGroupIDs populated by Get/List, accepted on
Create/Update so callers pass desired junction state in one shape.
- SourceGroup added: name (= snapshot tag), includes/excludes,
retention_policy, retry_max + retry_backoff_seconds, cached
conflict_dimension.
- HostRepoMaintenance added: forget/prune/check cadences + enabled.
- PendingRun added: offline-retry queue.
- Host loses RepoInitialisedAt; gains BandwidthUpKBps + BandwidthDownKBps.
- RetentionPolicy moves home from "schedule field" to "source group
field" but the type itself + Summary() method unchanged.
* store/sources.go (new) — CRUD + GetByName + ConflictDimension cache.
Group writes bump host_schedule_version; conflict cache writes don't
(server-internal projection, agent doesn't see it).
* store/maintenance.go (new) — CreateDefault is idempotent (INSERT OR
IGNORE). UpdateRepoMaintenance doesn't bump schedule version because
these run on the server's own ticker, not the agent's local cron.
* store/pending.go (new) — Enqueue / DueRunsForRetry / Bump / Delete.
* store/schedules.go — rewritten for slim shape + junction CRUD.
Update wipes the schedule_source_groups junction wholesale and
re-inserts (simpler than diffing). Adds SchedulesUsingGroup for
retention-conflict detection + UI labels.
* store/hosts.go — drops repo_initialised_at scan, adds bandwidth scan.
New SetHostBandwidth helper.
* HTTP layer — temporarily stubbed during this rewrite (501 returns
with redesign_in_progress error code). Phase 3 fills these in
against the new shape:
- schedules.go REST CRUD
- schedule_push.go agent reconciliation
- ui_schedules.go HTML form CRUD
Run-now-per-host + Init-repo handlers in ui_handlers.go also stubbed
— both go away in the new model (Run-now per source group; auto-init
at host enrolment).
* enrollment.go — replaces "seed manual schedule from typed paths"
with "seed default source group + repo-maintenance row." The default
group gets the typed paths as its includes; operator edits later
via Sources tab.
* ws/handler.go — drops the MarkHostRepoInitialised projection (column
is gone; auto-init makes it derivable from latest init job's status).
Tests:
* store: existing schedule test rewritten for slim shape + junction;
new sources_test.go covers source-group CRUD, name uniqueness,
conflict cache, repo-maintenance defaults + idempotent seed,
pending-runs queue lifecycle.
* http: schedules_test.go and schedule_push_test.go deleted — both
exercised the obsolete fat-schedule API. Phase 3 rewrites them
against the new endpoints.
go test ./... green. cmd/server + cmd/agent build. The UI is broken
end-to-end (schedules / sources / repo tabs all hit 501 stubs); Phase 3
restores REST + on-the-wire reconciliation; Phase 4 rewires the UI
templates against the new model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -143,146 +142,23 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIRunBackup is the form-submit twin of POST /api/hosts/{id}/jobs
|
||||
// that the dashboard / host-detail "Run now" buttons call via
|
||||
// hx-post. On success it sets HX-Redirect → /jobs/{job_id} so the
|
||||
// operator lands on the live log viewer for the job they just
|
||||
// kicked off.
|
||||
// handleUIRunBackup and handleUIInitRepo are stubbed during the P2
|
||||
// redesign data-model rewrite. The dashboard per-host Run-now button
|
||||
// is going away (operator clicks into host detail then a per-source-
|
||||
// group Run-now), and Init-repo becomes implicit at host enrolment
|
||||
// (auto-init dispatched server-side). Phase 4 of the redesign wires
|
||||
// the new per-source-group Run-now via /hosts/{id}/source-groups/{gid}/run.
|
||||
|
||||
func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != 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 host.RepoInitialisedAt == nil {
|
||||
stdhttp.Error(w,
|
||||
"this host's repo hasn't been initialised yet — click Initialise repo first",
|
||||
stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pick, err := s.pickRunNowSchedule(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, pick.Paths)
|
||||
if code != "" {
|
||||
stdhttp.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
// HTMX (with hx-post + hx-swap=none) doesn't honour HX-Redirect
|
||||
// when the response itself is a 3xx — fetch follows the redirect
|
||||
// first and the header is lost. Branch on the HX-Request marker
|
||||
// so HTMX gets a 200 + HX-Redirect (client-side window.location
|
||||
// hop), while plain form-post / curl callers get the 303.
|
||||
target := "/jobs/" + res.JobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w,
|
||||
"per-host Run-now is being replaced by per-source-group Run-now — see P2 redesign Phase 4",
|
||||
stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// pickRunNowSchedule chooses which schedule a generic per-host
|
||||
// "Run now" button should dispatch when the operator hasn't picked
|
||||
// one explicitly. Picks in priority order: the host's only enabled
|
||||
// manual schedule, then its only enabled schedule of any kind.
|
||||
// Returns a friendly error if there's nothing to run, or if the
|
||||
// operator needs to disambiguate.
|
||||
func (s *Server) pickRunNowSchedule(ctx context.Context, hostID string) (*store.Schedule, error) {
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, errFmt("internal: %s", err)
|
||||
}
|
||||
enabled := make([]store.Schedule, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Enabled {
|
||||
enabled = append(enabled, r)
|
||||
}
|
||||
}
|
||||
if len(enabled) == 0 {
|
||||
return nil, errFmt("this host has no enabled schedules — add one in the Schedules tab")
|
||||
}
|
||||
manuals := []store.Schedule{}
|
||||
for _, r := range enabled {
|
||||
if r.Manual {
|
||||
manuals = append(manuals, r)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(manuals) == 1:
|
||||
s := manuals[0]
|
||||
return &s, nil
|
||||
case len(enabled) == 1:
|
||||
s := enabled[0]
|
||||
return &s, nil
|
||||
default:
|
||||
return nil, errFmt("this host has %d schedules — pick one from the Schedules tab", len(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
func errFmt(format string, args ...any) error {
|
||||
return errFmtf(format, args...)
|
||||
}
|
||||
|
||||
// handleUIInitRepo dispatches a one-shot `restic init` job for a
|
||||
// host. Surfaced in the run-now panel as a red "Initialise repo"
|
||||
// button when host.repo_initialised_at IS NULL. On success it
|
||||
// redirects to the live log page just like Run-now.
|
||||
func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobInit, nil)
|
||||
if code != "" {
|
||||
stdhttp.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
target := "/jobs/" + res.JobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w,
|
||||
"manual Init-repo is being replaced by auto-init at host enrolment — see P2 redesign Phase 6",
|
||||
stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// addHostPage carries the Add-host form state. The result-state
|
||||
|
||||
Reference in New Issue
Block a user