P2R-02 slice 1: host-detail sub-tab skeleton

Extract header/vitals/sub-tabs into a host_chrome partial that every
host-detail tab page renders. Sources / Schedules / Repo go from
inert divs to real <a> links backed by stub pages that share the
chrome and a 'coming next' body — slices 2/3/4 fill them in.

Also re-establishes the version indicator (host_schedule_version vs
agent's applied_schedule_version) in the header.

Drops the legacy fat-schedule list/edit templates that referenced
fields removed by the P2 redesign (Manual / Paths / RetentionPolicy
on Schedule); the new templates land in slice 3.
This commit is contained in:
2026-05-03 11:37:55 +01:00
parent 84914fd6c5
commit 079b4bed70
14 changed files with 336 additions and 411 deletions
+4
View File
@@ -185,6 +185,10 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
// Sources tab (slice 2 fills in CRUD).
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
// Repo tab (slice 4 fills in body).
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
// Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
+37 -2
View File
@@ -397,9 +397,44 @@ type awaitingFragment struct {
LastSeenAt *time.Time
}
// hostChromeData is the field set the host_chrome partial reads from
// every host-detail-tab page's Page struct. Embed it as the first
// (anonymous) field of the page struct so .Page.Host / .Page.SubTab
// resolve via field promotion in the template.
type hostChromeData struct {
Host store.Host
SubTab string // snapshots | sources | schedules | repo
Crumb string // breadcrumb tail ("snapshots" / "sources" / etc)
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
// renders in the chrome (sub-tab badges + version indicator). On any
// non-fatal store error it logs and degrades to zeros — better to
// render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
d.SourceGroupCount = len(groups)
} else {
slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err)
}
if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil {
d.ScheduleCount = len(scheds)
} else {
slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err)
}
if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
d.ScheduleVersion = v
}
return d
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
Host store.Host
hostChromeData
Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters).
@@ -443,7 +478,7 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
view := s.baseView(u, "dashboard")
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
Host: *host,
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
Snapshots: shown,
SnapshotsShown: len(shown),
}
+34
View File
@@ -0,0 +1,34 @@
package http
import (
"log/slog"
stdhttp "net/http"
)
// ui_repo.go — HTML form-driven repo-tab handlers (connection,
// bandwidth caps, maintenance cadences, danger-zone re-init). Slice
// 1 of P2R-02 lights the tab; slice 4 fills in the body.
type hostRepoPage struct {
hostChromeData
}
func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
view := s.baseView(u, "dashboard")
view.Title = host.Name + " repo · restic-manager"
view.Page = hostRepoPage{
hostChromeData: s.loadHostChrome(r, *host, "repo", "repo"),
}
if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
slog.Error("ui: render host_repo", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
+59 -14
View File
@@ -1,38 +1,83 @@
package http
import (
"errors"
"log/slog"
stdhttp "net/http"
"github.com/go-chi/chi/v5"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// ui_schedules.go — HTML form-driven schedule CRUD.
//
// Stubbed during the P2 redesign template rewrite. Phase 4 of the
// redesign rebuilds the schedule editor against the new slim shape
// (cron + source-group multi-select + enabled), the source-group
// list/edit pages, and the repo-maintenance tab. Until then these
// routes return 501; the dashboard's host-row "View →" link is the
// only operator entry point that still works.
// ui_schedules.go — HTML form-driven schedule CRUD against the slim
// shape (cron + source-group multi-select + enabled). The list view
// is live as of slice 1 of P2R-02; the new/edit/delete/run handlers
// land in slice 3.
// hostSchedulesPage is the data the schedules-tab template renders.
type hostSchedulesPage struct {
hostChromeData
}
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
view := s.baseView(u, "dashboard")
view.Title = host.Name + " schedules · restic-manager"
view.Page = hostSchedulesPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "schedules"),
}
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) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
}
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
}
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
}
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
}
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented)
}
// 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
}
+34
View File
@@ -0,0 +1,34 @@
package http
import (
"log/slog"
stdhttp "net/http"
)
// ui_sources.go — HTML form-driven source-group CRUD. Slice 1 of
// P2R-02 lights the tab; slice 2 fills in list, new, edit, delete,
// and per-group Run-now.
type hostSourcesPage struct {
hostChromeData
}
func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
view := s.baseView(u, "dashboard")
view.Title = host.Name + " sources · restic-manager"
view.Page = hostSourcesPage{
hostChromeData: s.loadHostChrome(r, *host, "sources", "sources"),
}
if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
slog.Error("ui: render host_sources", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}