P2-04: schedule editor UI
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled

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>
This commit is contained in:
2026-05-02 11:44:40 +01:00
parent 6450bf1b88
commit 160d788bae
9 changed files with 770 additions and 3 deletions
+27
View File
@@ -2,6 +2,8 @@ package store
import (
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -103,6 +105,31 @@ type RetentionPolicy struct {
KeepYearly *int `json:"keep_yearly,omitempty"`
}
// Summary renders a compact human view of the policy for templates
// and logs — "last=7, d=14, w=4" or "—" when nothing is set.
func (p RetentionPolicy) Summary() string {
parts := []string{}
for _, kv := range []struct {
k string
v *int
}{
{"last", p.KeepLast},
{"h", p.KeepHourly},
{"d", p.KeepDaily},
{"w", p.KeepWeekly},
{"m", p.KeepMonthly},
{"y", p.KeepYearly},
} {
if kv.v != nil {
parts = append(parts, fmt.Sprintf("%s=%d", kv.k, *kv.v))
}
}
if len(parts) == 0 {
return "—"
}
return strings.Join(parts, ", ")
}
// ScheduleOptions covers per-schedule knobs that aren't core to the
// command itself — currently bandwidth caps. Stored as JSON so
// future fields don't churn the schema.