ui: P2R-12 hook editor — source-group form + host-default Repo section
Source-group edit form gains pre/post hook textareas with a service-
user warning banner; bodies AEAD-encrypted on save (per-group AD).
Repo page adds a 'Host-default hooks' panel above the danger zone
with the same shape; saved via POST /hosts/{id}/repo/hooks.
This commit is contained in:
@@ -229,6 +229,7 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
|
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
|
||||||
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
|
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
|
||||||
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
|
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
|
||||||
|
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
||||||
// Admin credentials form (separate slot for prune-capable user).
|
// Admin credentials form (separate slot for prune-capable user).
|
||||||
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
|
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
|
||||||
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
||||||
|
|||||||
@@ -79,11 +79,16 @@ type hostRepoPage struct {
|
|||||||
UntaggedSnapshots int
|
UntaggedSnapshots int
|
||||||
GroupNames []string // ordered, for stable rendering
|
GroupNames []string // ordered, for stable rendering
|
||||||
|
|
||||||
|
// Host-default hooks (decrypted plaintext for round-trip in form).
|
||||||
|
HostPreHook string
|
||||||
|
HostPostHook string
|
||||||
|
|
||||||
// Inline form-error banners. Empty when no error for that section.
|
// Inline form-error banners. Empty when no error for that section.
|
||||||
CredentialsError string
|
CredentialsError string
|
||||||
AdminCredsError string
|
AdminCredsError string
|
||||||
BandwidthError string
|
BandwidthError string
|
||||||
MaintenanceError string
|
MaintenanceError string
|
||||||
|
HooksError string
|
||||||
|
|
||||||
// Highlight which form was just submitted, for the success-state
|
// Highlight which form was just submitted, for the success-state
|
||||||
// border (subtle UX nicety; empty = no recent save).
|
// border (subtle UX nicety; empty = no recent save).
|
||||||
@@ -179,6 +184,10 @@ func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRep
|
|||||||
p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps)
|
p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host-default hooks (decrypt for round-trip in the form).
|
||||||
|
p.HostPreHook = s.decryptHookOrFallback("", host.PreHookDefault, host.ID, "pre")
|
||||||
|
p.HostPostHook = s.decryptHookOrFallback("", host.PostHookDefault, host.ID, "post")
|
||||||
|
|
||||||
// Maintenance — auto-seed defaults if missing.
|
// Maintenance — auto-seed defaults if missing.
|
||||||
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
|
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
|
||||||
if err != nil && errors.Is(err, store.ErrNotFound) {
|
if err != nil && errors.Is(err, store.ErrNotFound) {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// ui_repo_hooks.go — host-default pre/post hook editor on the Repo
|
||||||
|
// page (P2R-12). Per-source-group hooks live on the source group
|
||||||
|
// edit form; this surface lets the operator set defaults that apply
|
||||||
|
// to every group that doesn't override them.
|
||||||
|
//
|
||||||
|
// POST /hosts/{id}/repo/hooks takes pre_hook + post_hook form
|
||||||
|
// fields; encrypts each with the AEAD key (per-host AD bytes); and
|
||||||
|
// persists the (possibly empty) blobs via store.SetHostHooks.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
stdhttp "net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleUIRepoHooksSave(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
|
||||||
|
}
|
||||||
|
pre := r.PostForm.Get("pre_hook")
|
||||||
|
post := r.PostForm.Get("post_hook")
|
||||||
|
|
||||||
|
preEnc, err := s.EncryptHookForHost(host.ID, "pre", pre)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui repo hooks: encrypt pre", "err", err)
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postEnc, err := s.EncryptHookForHost(host.ID, "post", post)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui repo hooks: encrypt post", "err", err)
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.Store.SetHostHooks(r.Context(), host.ID, preEnc, postEnc); err != nil {
|
||||||
|
slog.Error("ui repo hooks: persist", "err", err)
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=hooks", stdhttp.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ type sourceFormData struct {
|
|||||||
RetryMax int
|
RetryMax int
|
||||||
RetryBackoffSeconds int
|
RetryBackoffSeconds int
|
||||||
ConflictDimension string
|
ConflictDimension string
|
||||||
|
PreHook string // plaintext; encrypted on save
|
||||||
|
PostHook string
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceGroupEditPage backs both the new and edit form views.
|
// sourceGroupEditPage backs both the new and edit form views.
|
||||||
@@ -173,11 +175,14 @@ func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp
|
|||||||
}
|
}
|
||||||
view := s.baseView(u)
|
view := s.baseView(u)
|
||||||
view.Title = g.Name + " · " + host.Name + " · restic-manager"
|
view.Title = g.Name + " · " + host.Name + " · restic-manager"
|
||||||
|
form := formFromGroup(*g)
|
||||||
|
form.PreHook = s.decryptHookOrFallback(g.PreHook, "", host.ID, "pre")
|
||||||
|
form.PostHook = s.decryptHookOrFallback(g.PostHook, "", host.ID, "post")
|
||||||
view.Page = sourceGroupEditPage{
|
view.Page = sourceGroupEditPage{
|
||||||
hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name),
|
hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name),
|
||||||
IsNew: false,
|
IsNew: false,
|
||||||
GroupID: gid,
|
GroupID: gid,
|
||||||
Form: formFromGroup(*g),
|
Form: form,
|
||||||
SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit",
|
SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit",
|
||||||
}
|
}
|
||||||
if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
|
if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
|
||||||
@@ -253,6 +258,20 @@ func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypt hook bodies (empty → empty stored, clearing the column).
|
||||||
|
preEnc, err := s.EncryptHookForGroup(host.ID, "pre", form.PreHook)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui sources: encrypt pre_hook", "err", err)
|
||||||
|
s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt pre-hook — see the server log.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postEnc, err := s.EncryptHookForGroup(host.ID, "post", form.PostHook)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ui sources: encrypt post_hook", "err", err)
|
||||||
|
s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt post-hook — see the server log.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
g := store.SourceGroup{
|
g := store.SourceGroup{
|
||||||
ID: gid,
|
ID: gid,
|
||||||
HostID: host.ID,
|
HostID: host.ID,
|
||||||
@@ -265,6 +284,8 @@ func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Re
|
|||||||
},
|
},
|
||||||
RetryMax: form.RetryMax,
|
RetryMax: form.RetryMax,
|
||||||
RetryBackoffSeconds: form.RetryBackoffSeconds,
|
RetryBackoffSeconds: form.RetryBackoffSeconds,
|
||||||
|
PreHook: preEnc,
|
||||||
|
PostHook: postEnc,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isNew {
|
if isNew {
|
||||||
@@ -381,6 +402,8 @@ func parseSourceForm(v map[string][]string) sourceFormData {
|
|||||||
KeepYearly: get("keep_yearly"),
|
KeepYearly: get("keep_yearly"),
|
||||||
RetryMax: rmax,
|
RetryMax: rmax,
|
||||||
RetryBackoffSeconds: rback,
|
RetryBackoffSeconds: rback,
|
||||||
|
PreHook: firstVal(v, "pre_hook"),
|
||||||
|
PostHook: firstVal(v, "post_hook"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,5 +458,7 @@ func formFromGroup(g store.SourceGroup) sourceFormData {
|
|||||||
RetryMax: g.RetryMax,
|
RetryMax: g.RetryMax,
|
||||||
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
||||||
ConflictDimension: g.ConflictDimension,
|
ConflictDimension: g.ConflictDimension,
|
||||||
|
// PreHook/PostHook are decrypted on render (handler-side, not
|
||||||
|
// here) since formFromGroup has no AEAD reference.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ---------- Host-default hooks ---------- */}}
|
||||||
|
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-9 mb-3.5">Host-default hooks</h2>
|
||||||
|
<form method="post" action="/hosts/{{$host.ID}}/repo/hooks" class="panel rounded-[7px] p-5">
|
||||||
|
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
|
||||||
|
Defaults applied to every backup that doesn't set its own. Per-source-group hooks (on the
|
||||||
|
<a href="/hosts/{{$host.ID}}/sources" class="text-accent">Sources</a> tab) override these.
|
||||||
|
</p>
|
||||||
|
<div class="text-[12px] text-warn leading-[1.55] mb-3"
|
||||||
|
style="background: color-mix(in oklch, var(--warn), transparent 92%); border: 1px solid color-mix(in oklch, var(--warn), transparent 75%); padding: 8px 10px; border-radius: 5px;">
|
||||||
|
Hooks run as the agent service user — root on Linux, LocalSystem on Windows.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="field-label" for="host_pre_hook">Pre-backup hook (default)</label>
|
||||||
|
<textarea id="host_pre_hook" name="pre_hook" class="field mono" rows="3" style="resize: vertical;"
|
||||||
|
placeholder="# default; per-group overrides win">{{$page.HostPreHook}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="field-label" for="host_post_hook">Post-backup hook (default)</label>
|
||||||
|
<textarea id="host_post_hook" name="post_hook" class="field mono" rows="3" style="resize: vertical;"
|
||||||
|
placeholder="# RM_JOB_STATUS in env">{{$page.HostPostHook}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Save host-default hooks</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{{/* ---------- Danger zone ---------- */}}
|
{{/* ---------- Danger zone ---------- */}}
|
||||||
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-bad mt-9 mb-3.5">Danger zone</h2>
|
<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"
|
<div class="panel rounded-[7px] p-5"
|
||||||
|
|||||||
@@ -95,6 +95,27 @@
|
|||||||
Each retry doubles the wait. <strong>Manual run-now ignores this</strong> — it just fails immediately if the agent is offline.
|
Each retry doubles the wait. <strong>Manual run-now ignores this</strong> — it just fails immediately if the agent is offline.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-7 pt-4 border-t border-line-soft">
|
||||||
|
Hooks
|
||||||
|
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">backup jobs only</span>
|
||||||
|
</h3>
|
||||||
|
<div class="text-[12px] text-warn leading-[1.55] mb-3"
|
||||||
|
style="background: color-mix(in oklch, var(--warn), transparent 92%); border: 1px solid color-mix(in oklch, var(--warn), transparent 75%); padding: 8px 10px; border-radius: 5px;">
|
||||||
|
Hooks run as the agent service user — root on Linux, LocalSystem on Windows. Treat them like any other root cron entry.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="field-label" for="pre_hook">Pre-backup hook</label>
|
||||||
|
<textarea id="pre_hook" name="pre_hook" class="field mono" rows="3" style="resize: vertical;"
|
||||||
|
placeholder="# e.g. systemctl stop myapp">{{$f.PreHook}}</textarea>
|
||||||
|
<div class="field-help mt-1">Non-zero exit aborts the backup. Stored AEAD-encrypted.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="field-label" for="post_hook">Post-backup hook</label>
|
||||||
|
<textarea id="post_hook" name="post_hook" class="field mono" rows="3" style="resize: vertical;"
|
||||||
|
placeholder="# RM_JOB_STATUS={succeeded|failed} is in env">{{$f.PostHook}}</textarea>
|
||||||
|
<div class="field-help mt-1">Always runs. <span class="mono">RM_JOB_STATUS</span> is set to the backup's outcome. Stored AEAD-encrypted.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 pt-4 border-t border-line-soft flex gap-2">
|
<div class="mt-8 pt-4 border-t border-line-soft flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create group{{else}}Save changes{{end}}</button>
|
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create group{{else}}Save changes{{end}}</button>
|
||||||
<a href="/hosts/{{$host.ID}}/sources" class="btn btn-lg">Cancel</a>
|
<a href="/hosts/{{$host.ID}}/sources" class="btn btn-lg">Cancel</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user