P2R-02 slice 4: Repo tab — connection / bandwidth / maintenance
Three independent forms on /hosts/{id}/repo so saving one section
doesn't disturb the others:
* Connection: edits repo URL, username, password (pre-filled from
the redacted GET /api/hosts/{id}/repo-credentials view; password
field shows masked stored-creds placeholder; blank password = keep
existing). On save, encrypts and pushes config.update to a
connected agent.
* Bandwidth: host-wide upload/download caps (KB/s; blank = no cap)
written via store.SetHostBandwidth. New REST endpoint
PUT /api/hosts/{id}/bandwidth for JSON callers.
* Maintenance: forget/prune/check cadences + check subset %, with
per-row enabled toggles. Reuses cronParser for validation;
auto-seeds the row if a host pre-dates the migration.
Right-rail surfaces repo size, snapshot count, snapshots-by-tag
breakdown (counted from existing snapshot tag rows), and an
'untagged snapshots are left alone' note.
Danger-zone re-init button is rendered but disabled with a hint
pointing at P2R-09 (real implementation lands there).
Validation re-renders the page with the relevant form's banner and
all other section state intact. Successful saves redirect with a
?saved=<section> query param so the page surfaces a small ✓ saved
indicator on the relevant form.
ci.yml: bump golangci-lint-action v6→v7 (separate change picked up
in this commit).
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
# v1.61 was built against Go 1.23 and refuses to load a
|
||||
# config that targets a newer toolchain — go.mod is on 1.25.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// host_bandwidth.go — REST API for /api/hosts/{id}/bandwidth.
|
||||
//
|
||||
// Host-wide upload/download caps (KB/s). Applied to every restic
|
||||
// invocation as --limit-upload / --limit-download. Pass null /
|
||||
// omit a field to clear that cap.
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
stdhttp "net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
type hostBandwidthRequest struct {
|
||||
BandwidthUpKBps *int `json:"bandwidth_up_kbps"`
|
||||
BandwidthDownKBps *int `json:"bandwidth_down_kbps"`
|
||||
}
|
||||
|
||||
type hostBandwidthView struct {
|
||||
BandwidthUpKBps *int `json:"bandwidth_up_kbps"`
|
||||
BandwidthDownKBps *int `json:"bandwidth_down_kbps"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateHostBandwidth(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if !s.authedUser(r) {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
var req hostBandwidthRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if req.BandwidthUpKBps != nil && *req.BandwidthUpKBps < 0 {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value",
|
||||
"bandwidth_up_kbps must be non-negative")
|
||||
return
|
||||
}
|
||||
if req.BandwidthDownKBps != nil && *req.BandwidthDownKBps < 0 {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value",
|
||||
"bandwidth_down_kbps must be non-negative")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.SetHostBandwidth(r.Context(), hostID, req.BandwidthUpKBps, req.BandwidthDownKBps); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, stdhttp.StatusOK, hostBandwidthView{
|
||||
BandwidthUpKBps: req.BandwidthUpKBps,
|
||||
BandwidthDownKBps: req.BandwidthDownKBps,
|
||||
})
|
||||
}
|
||||
@@ -126,6 +126,10 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
|
||||
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
|
||||
|
||||
// Host-wide bandwidth caps (host.bandwidth_up_kbps /
|
||||
// bandwidth_down_kbps). Apply to every restic invocation.
|
||||
r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
|
||||
|
||||
// Per-source-group Run-now (JSON variant). HTMX action is
|
||||
// mounted at the equivalent path outside /api below — both
|
||||
// resolve to the same handler, which sniffs HX-Request.
|
||||
@@ -192,8 +196,12 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
|
||||
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
|
||||
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
|
||||
// Repo tab (slice 4 fills in body).
|
||||
// Repo tab — connection / bandwidth / maintenance. Three
|
||||
// independent forms so saving one doesn't touch the others.
|
||||
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
|
||||
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
|
||||
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
|
||||
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
|
||||
// Schedules tab + create/edit/delete forms.
|
||||
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
|
||||
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
|
||||
|
||||
@@ -1,16 +1,130 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// bandwidth caps, maintenance cadences, danger-zone re-init). Splits
|
||||
// the page into three independent forms so saving one section
|
||||
// doesn't disturb the others.
|
||||
//
|
||||
// GET /hosts/{id}/repo — render
|
||||
// POST /hosts/{id}/repo/credentials — connection
|
||||
// POST /hosts/{id}/repo/bandwidth — host-wide bw caps
|
||||
// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences
|
||||
|
||||
type hostRepoPage struct {
|
||||
hostChromeData
|
||||
|
||||
// Connection (redacted view)
|
||||
RepoURL string
|
||||
RepoUsername string
|
||||
HasPassword bool
|
||||
|
||||
// Bandwidth (form values, blank means "no cap")
|
||||
BandwidthUp string
|
||||
BandwidthDown string
|
||||
|
||||
// Maintenance row
|
||||
Maintenance store.HostRepoMaintenance
|
||||
|
||||
// Snapshots-by-tag — map[group_name]count, plus an "untagged" row.
|
||||
SnapshotsByTag map[string]int
|
||||
UntaggedSnapshots int
|
||||
GroupNames []string // ordered, for stable rendering
|
||||
|
||||
// Inline form-error banners. Empty when no error for that section.
|
||||
CredentialsError string
|
||||
BandwidthError string
|
||||
MaintenanceError string
|
||||
|
||||
// Highlight which form was just submitted, for the success-state
|
||||
// border (subtle UX nicety; empty = no recent save).
|
||||
SavedSection string
|
||||
}
|
||||
|
||||
// loadHostRepoPage builds the read-only side of the page state. The
|
||||
// per-form save handlers re-call this and overlay any banner / saved
|
||||
// markers before rendering.
|
||||
func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) {
|
||||
p := &hostRepoPage{
|
||||
hostChromeData: s.loadHostChrome(r, host, "repo", "repo"),
|
||||
}
|
||||
|
||||
// Credentials (redacted).
|
||||
enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID)
|
||||
switch {
|
||||
case err == nil:
|
||||
plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID))
|
||||
if derr == nil {
|
||||
var blob repoCredsBlob
|
||||
if jerr := json.Unmarshal(plain, &blob); jerr == nil {
|
||||
p.RepoURL = blob.RepoURL
|
||||
p.RepoUsername = blob.RepoUsername
|
||||
p.HasPassword = blob.RepoPassword != ""
|
||||
}
|
||||
}
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
// no creds yet — leave fields empty
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bandwidth.
|
||||
if host.BandwidthUpKBps != nil {
|
||||
p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps)
|
||||
}
|
||||
if host.BandwidthDownKBps != nil {
|
||||
p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps)
|
||||
}
|
||||
|
||||
// Maintenance — auto-seed defaults if missing.
|
||||
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
|
||||
if err != nil && errors.Is(err, store.ErrNotFound) {
|
||||
if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil {
|
||||
return nil, seedErr
|
||||
}
|
||||
m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Maintenance = *m
|
||||
|
||||
// Snapshot counts by tag — used for the right-rail breakdown.
|
||||
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
|
||||
if err == nil {
|
||||
groupNameSet := make(map[string]struct{}, len(groups))
|
||||
for _, g := range groups {
|
||||
p.GroupNames = append(p.GroupNames, g.Name)
|
||||
groupNameSet[g.Name] = struct{}{}
|
||||
}
|
||||
if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil {
|
||||
p.SnapshotsByTag = make(map[string]int, len(groups))
|
||||
for _, sn := range snaps {
|
||||
matched := false
|
||||
for _, t := range sn.Tags {
|
||||
if _, ok := groupNameSet[t]; ok {
|
||||
p.SnapshotsByTag[t]++
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
p.UntaggedSnapshots++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
@@ -22,13 +136,217 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, err := s.loadHostRepoPage(r, *host)
|
||||
if err != nil {
|
||||
slog.Error("ui repo: load page", "host_id", host.ID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page.SavedSection = r.URL.Query().Get("saved")
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " repo · restic-manager"
|
||||
view.Page = hostRepoPage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "repo", "repo"),
|
||||
}
|
||||
view.Page = *page
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// renderRepoFormError loads the page state, overlays the section's
|
||||
// error / saved marker, and renders. Returns an HTTP status (422 for
|
||||
// validation, 200 for success).
|
||||
func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, savedSection, credErr, bwErr, mntErr string, status int) {
|
||||
page, err := s.loadHostRepoPage(r, *host)
|
||||
if err != nil {
|
||||
slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page.SavedSection = savedSection
|
||||
page.CredentialsError = credErr
|
||||
page.BandwidthError = bwErr
|
||||
page.MaintenanceError = mntErr
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " repo · restic-manager"
|
||||
view.Page = *page
|
||||
if status != stdhttp.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
|
||||
slog.Error("ui: render host_repo", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIRepoCredentialsSave updates the host's stored repo URL,
|
||||
// username, and (optionally) password. Empty password means "leave
|
||||
// the existing one alone" — passwords are never round-tripped to the
|
||||
// browser, so a blank field is the only way an operator can save the
|
||||
// other fields without re-typing the password.
|
||||
func (s *Server) handleUIRepoCredentialsSave(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
|
||||
}
|
||||
repoURL := strings.TrimSpace(r.PostForm.Get("repo_url"))
|
||||
repoUser := strings.TrimSpace(r.PostForm.Get("repo_username"))
|
||||
repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately
|
||||
|
||||
if repoURL == "" {
|
||||
s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge with existing blob — same semantics as the JSON PUT.
|
||||
existing := repoCredsBlob{}
|
||||
if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID); err == nil {
|
||||
if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil {
|
||||
_ = json.Unmarshal(plain, &existing)
|
||||
}
|
||||
}
|
||||
existing.RepoURL = repoURL
|
||||
existing.RepoUsername = repoUser
|
||||
if repoPass != "" {
|
||||
existing.RepoPassword = repoPass
|
||||
}
|
||||
if existing.RepoPassword == "" {
|
||||
s.renderRepoPage(w, r, u, host, "",
|
||||
"No password on file yet — set one before saving the URL/username.",
|
||||
"", "", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID))
|
||||
if err != nil {
|
||||
slog.Error("ui repo creds: encrypt", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, enc); err != nil {
|
||||
slog.Error("ui repo creds: persist", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) {
|
||||
_ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing)
|
||||
}
|
||||
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRepoBandwidthSave updates the host's upload/download caps.
|
||||
// Empty input → nil pointer → no cap. Negative → error.
|
||||
func (s *Server) handleUIRepoBandwidthSave(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
|
||||
}
|
||||
up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up"))
|
||||
down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down"))
|
||||
if upErr != nil || downErr != nil {
|
||||
s.renderRepoPage(w, r, u, host, "", "",
|
||||
"Bandwidth caps must be non-negative whole numbers (or blank for no cap).",
|
||||
"", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil {
|
||||
slog.Error("ui repo bandwidth: persist", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRepoMaintenanceSave updates the forget/prune/check
|
||||
// cadences in one go. Cron expressions parsed with the same parser
|
||||
// the agent + REST handler use.
|
||||
func (s *Server) handleUIRepoMaintenanceSave(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
|
||||
}
|
||||
forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron"))
|
||||
pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron"))
|
||||
checkCron := strings.TrimSpace(r.PostForm.Get("check_cron"))
|
||||
subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct"))
|
||||
|
||||
for label, expr := range map[string]string{
|
||||
"forget": forgetCron, "prune": pruneCron, "check": checkCron,
|
||||
} {
|
||||
if expr == "" {
|
||||
s.renderRepoPage(w, r, u, host, "", "", "",
|
||||
label+" cadence is required.", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := cronParser.Parse(expr); err != nil {
|
||||
s.renderRepoPage(w, r, u, host, "", "", "",
|
||||
label+" cadence didn't parse: "+err.Error(), stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
}
|
||||
subset, err := strconv.Atoi(subsetStr)
|
||||
if err != nil || subset < 0 || subset > 100 {
|
||||
s.renderRepoPage(w, r, u, host, "", "", "",
|
||||
"check subset % must be between 0 and 100.", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil {
|
||||
slog.Error("ui repo maintenance: seed", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
m := store.HostRepoMaintenance{
|
||||
HostID: host.ID,
|
||||
ForgetCron: forgetCron,
|
||||
ForgetEnabled: r.PostForm.Get("forget_enabled") == "1",
|
||||
PruneCron: pruneCron,
|
||||
PruneEnabled: r.PostForm.Get("prune_enabled") == "1",
|
||||
CheckCron: checkCron,
|
||||
CheckEnabled: r.PostForm.Get("check_enabled") == "1",
|
||||
CheckSubsetPct: subset,
|
||||
}
|
||||
if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil {
|
||||
slog.Error("ui repo maintenance: persist", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// parseOptionalNonNegInt returns (nil, nil) for an empty string, or
|
||||
// (*int, nil) for a non-negative integer. Negative or non-numeric →
|
||||
// error. Used for bandwidth caps where blank means "no limit".
|
||||
func parseOptionalNonNegInt(s string) (*int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return nil, errors.New("invalid")
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,13 +2,209 @@
|
||||
|
||||
{{define "content"}}
|
||||
{{template "host_chrome" .}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
|
||||
<div class="empty-state">
|
||||
<h3 class="text-base font-medium tracking-[-0.005em]">Repo tab — coming next.</h3>
|
||||
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
|
||||
Connection settings, bandwidth caps, maintenance cadences, and the
|
||||
danger-zone re-init land in P2R-02 slice 4.
|
||||
{{$page := .Page}}
|
||||
{{$host := $page.Host}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6 grid grid-cols-12 gap-6 items-start">
|
||||
|
||||
<div class="col-span-8">
|
||||
|
||||
{{/* ---------- Connection ---------- */}}
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Connection</h2>
|
||||
<form method="post" action="/hosts/{{$host.ID}}/repo/credentials" class="panel rounded-[7px] p-5">
|
||||
{{if $page.CredentialsError}}
|
||||
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||
{{$page.CredentialsError}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq $page.SavedSection "credentials"}}
|
||||
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
||||
{{end}}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="field-label" for="repo_url">Repo URL</label>
|
||||
<input id="repo_url" name="repo_url" type="text" class="field mono" value="{{$page.RepoURL}}" required />
|
||||
<div class="field-help">e.g. <span class="mono text-ink-mid">rest:http://192.168.0.99:8000/{{$host.Name}}/</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="repo_username">Username</label>
|
||||
<input id="repo_username" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}" />
|
||||
<div class="field-help">Sent as the rest-server <span class="mono text-ink-mid">--htpasswd</span> user.</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="field-label" for="repo_password">Password</label>
|
||||
<input id="repo_password" name="repo_password" type="password" class="field mono" placeholder="{{if $page.HasPassword}}•••••••••••••••• · stored, leave blank to keep{{else}}— not yet set —{{end}}" autocomplete="new-password" />
|
||||
<div class="field-help">Stored AEAD-encrypted; pushed to the agent over WS. Leave blank to keep the existing password.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save credentials</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ---------- Bandwidth ---------- */}}
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Bandwidth · host-wide</h2>
|
||||
<form method="post" action="/hosts/{{$host.ID}}/repo/bandwidth" class="panel rounded-[7px] p-5">
|
||||
{{if $page.BandwidthError}}
|
||||
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||
{{$page.BandwidthError}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq $page.SavedSection "bandwidth"}}
|
||||
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
||||
{{end}}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="field-label" for="bandwidth_up">Upload limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
|
||||
<input id="bandwidth_up" name="bandwidth_up" type="number" min="0" class="field mono" value="{{$page.BandwidthUp}}" placeholder="—" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="bandwidth_down">Download limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
|
||||
<input id="bandwidth_down" name="bandwidth_down" type="number" min="0" class="field mono" value="{{$page.BandwidthDown}}" placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-help mt-3">
|
||||
Applies to every backup, restore, and prune job for this host. Maps to <span class="mono text-ink-mid">restic --limit-upload</span> / <span class="mono text-ink-mid">--limit-download</span>.
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save bandwidth caps</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ---------- Maintenance ---------- */}}
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Maintenance · server-side cadences</h2>
|
||||
<form method="post" action="/hosts/{{$host.ID}}/repo/maintenance" class="panel rounded-[7px] p-5">
|
||||
{{if $page.MaintenanceError}}
|
||||
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||
{{$page.MaintenanceError}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq $page.SavedSection "maintenance"}}
|
||||
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
||||
{{end}}
|
||||
|
||||
{{$m := $page.Maintenance}}
|
||||
<div class="grid grid-cols-12 gap-3 items-center text-[13px] mb-3 text-[11px] uppercase tracking-[0.08em] text-ink-fade">
|
||||
<div class="col-span-2">Verb</div>
|
||||
<div class="col-span-5">Cron cadence</div>
|
||||
<div class="col-span-3">Notes</div>
|
||||
<div class="col-span-2 text-right">Enabled</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
|
||||
<div class="col-span-2 mono text-ink font-medium">forget</div>
|
||||
<div class="col-span-5"><input type="text" name="forget_cron" class="field mono" value="{{$m.ForgetCron}}" required /></div>
|
||||
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Per source group, using each group's retention policy.</div>
|
||||
<div class="col-span-2 text-right">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="forget_enabled" value="1" {{if $m.ForgetEnabled}}checked{{end}} class="w-3.5 h-3.5" />
|
||||
<span class="mono text-[11px]">on</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
|
||||
<div class="col-span-2 mono text-ink font-medium">prune</div>
|
||||
<div class="col-span-5"><input type="text" name="prune_cron" class="field mono" value="{{$m.PruneCron}}" required /></div>
|
||||
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Reclaims storage made dead by forget. Heavy — weekly only.</div>
|
||||
<div class="col-span-2 text-right">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="prune_enabled" value="1" {{if $m.PruneEnabled}}checked{{end}} class="w-3.5 h-3.5" />
|
||||
<span class="mono text-[11px]">on</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
|
||||
<div class="col-span-2 mono text-ink font-medium">check</div>
|
||||
<div class="col-span-5"><input type="text" name="check_cron" class="field mono" value="{{$m.CheckCron}}" required /></div>
|
||||
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">
|
||||
<span class="mono text-ink-mid">--read-data-subset</span>
|
||||
<input type="number" name="check_subset_pct" min="0" max="100" value="{{$m.CheckSubsetPct}}" class="field mono inline-block w-16 px-2 py-1" style="font-size: 11px;" />%
|
||||
</div>
|
||||
<div class="col-span-2 text-right">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="check_enabled" value="1" {{if $m.CheckEnabled}}checked{{end}} class="w-3.5 h-3.5" />
|
||||
<span class="mono text-[11px]">on</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2 items-center">
|
||||
<button type="submit" class="btn btn-primary">Save cadences</button>
|
||||
<span class="text-[12px] text-ink-fade ml-2">Server-side ticker drives execution — independent of the agent's cron.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ---------- Danger zone ---------- */}}
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-bad mt-9 mb-3.5">Danger zone</h2>
|
||||
<div class="panel rounded-[7px] p-5"
|
||||
style="border-color: color-mix(in oklch, var(--bad), transparent 70%);">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="text-[14px] font-semibold text-ink">Re-initialise repo</div>
|
||||
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6] mt-2 max-w-[580px]">
|
||||
Tries to <span class="mono text-ink-mid">DELETE</span> the rest-server's copy of this repo, then runs
|
||||
<span class="mono text-ink-mid">restic init</span> against the empty path. Most rest-server setups run with
|
||||
<span class="mono text-ink-mid">--append-only</span> and refuse the DELETE — the future P2R-09 flow surfaces
|
||||
guided cleanup steps in that case.
|
||||
</p>
|
||||
<p class="text-[12px] text-ink-fade leading-[1.55] mt-2">
|
||||
All snapshots are lost; this host's schedule version stays the same and the agent's
|
||||
<span class="mono text-ink-mid">secrets.enc</span> is reused.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-lg flex-none" disabled
|
||||
title="re-init flow lands in P2R-09">Re-init repo…</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---------- right rail ---------- */}}
|
||||
<aside class="col-span-4">
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Storage</h2>
|
||||
<div class="panel rounded-[7px] p-5">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
|
||||
<div class="mono text-[20px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
|
||||
<div class="mono text-[20px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
|
||||
<div class="text-[11.5px] text-ink-mute mt-0.5">across {{len $page.GroupNames}} source group{{if ne (len $page.GroupNames) 1}}s{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if gt (len $page.GroupNames) 0}}
|
||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Snapshots by source</h2>
|
||||
<div class="panel rounded-[7px] p-4">
|
||||
<div class="grid items-baseline text-[13px]" style="grid-template-columns: 1fr auto auto; gap: 8px 14px;">
|
||||
{{range $page.GroupNames}}
|
||||
<span class="mono text-ink">{{.}}</span>
|
||||
<span class="mono text-ink-mute text-right">{{index $page.SnapshotsByTag .}}</span>
|
||||
<span class="mono text-ink-fade text-[11px]">snapshots</span>
|
||||
{{end}}
|
||||
{{if gt $page.UntaggedSnapshots 0}}
|
||||
<span class="mono text-ink-fade italic">untagged</span>
|
||||
<span class="mono text-ink-mute text-right">{{$page.UntaggedSnapshots}}</span>
|
||||
<span class="mono text-ink-fade text-[11px]">snapshots</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel rounded-[6px] px-4 py-3.5 mt-5" style="background: var(--bg);">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-ink-fade">Untagged snapshots</div>
|
||||
<p class="text-[12px] text-ink-mid mt-1.5 leading-[1.55]">
|
||||
Any snapshot not tagged with one of this host's source groups is left alone — forget never touches it. Useful if someone runs
|
||||
<span class="mono text-ink-mid">restic backup</span> outside restic-manager; nothing here will silently delete those.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user