Add-host: durable pending page + polled awaiting-agent panel
Two issues from a smoke session:
1. The awaiting-agent panel never refreshed — operator had to go
back to the dashboard to see the host had connected.
2. Generated passwords were displayed only on the POST response.
Navigating away (or even an accidental tab close) lost them
permanently, so the operator couldn't update the rest-server's
htpasswd.
Both are the same fix: convert the POST-rendered transient
"result state" into a durable GET page at /hosts/pending/{token}.
* New route GET /hosts/pending/{token} renders the install-command +
htpasswd snippet view. Password is decrypted from the (still-
encrypted-at-rest) token row on every render — operator can
refresh, bookmark, navigate away and come back. Once the agent
enrols, the page redirects to /hosts/{id}; once the token
expires, redirect to /hosts/new.
* New route GET /hosts/pending/{token}/awaiting returns a polled
HTML fragment that the pending page swaps in every 2s via HTMX.
States: awaiting (keep polling) | connected (show "Open host →"
+ "View schedules" CTAs, polling stops) | expired (mint-new
link, polling stops). Polling stops naturally because only the
awaiting state's wrapper carries the hx-trigger attribute.
* POST /hosts/new now 303-redirects to /hosts/pending/{token}
on success; validation errors keep re-rendering the form with
banner.
Supporting changes:
* New store helper Store.GetEnrollmentTokenStatus(tokenHash) for
the polling endpoint — returns {expires_at, consumed_at,
consumed_host} in one round-trip without dragging in the
attachments-decryption path.
* New ui.Renderer.RenderPartial(w, name, data) for HTMX fragment
responses (no layout wrap). Picks an arbitrary page's template
set as the lookup point — every page parses the full common-
paths list, so they all see every partial.
* add_host.html stripped to form-only; pending_host.html owns the
result-state UI; awaiting_agent.html is the polled partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
@@ -284,9 +285,12 @@ func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// addHostPage carries the form state into the Add host template.
|
||||
// In State A (form), Token is empty. In State B (result), Token is
|
||||
// populated and the template renders the install command.
|
||||
// addHostPage carries the Add-host form state. The result-state
|
||||
// (showing the install command + htpasswd snippet) lives at
|
||||
// /hosts/pending/{token} and uses pendingHostPage instead, so the
|
||||
// operator can refresh / bookmark / come back later — the password
|
||||
// is decrypted from the still-alive token row on every render
|
||||
// rather than living only in a one-shot rendered response.
|
||||
type addHostPage struct {
|
||||
// Form fields — pre-populate the form on a re-render after a
|
||||
// validation error.
|
||||
@@ -294,32 +298,22 @@ type addHostPage struct {
|
||||
Tags string
|
||||
RepoURL string
|
||||
RepoUsername string
|
||||
// Paths is the textarea-as-typed default-paths input. One path
|
||||
// per line, blanks ignored.
|
||||
Paths string
|
||||
ServerURL string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Server URL the operator should paste into the install
|
||||
// command. Resolved from RM_BASE_URL falling back to the
|
||||
// request's Host header.
|
||||
ServerURL string
|
||||
|
||||
// Banner-level error shown above the form.
|
||||
Error string
|
||||
|
||||
// Result state. When Token != "", the template renders the
|
||||
// install command panel instead of the form.
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
|
||||
// RepoPassword is the password the agent will use against the
|
||||
// rest-server. When the operator left the password field blank
|
||||
// we generate one server-side; PasswordGenerated tracks which
|
||||
// path produced it so the result page can label it appropriately.
|
||||
// Either way it's surfaced on the result page exactly once,
|
||||
// inside the htpasswd snippet — same one-time-view rule as the
|
||||
// enrolment token. Reload = gone.
|
||||
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
|
||||
// for as long as the token does (1h ttl); once the agent enrols,
|
||||
// the handler redirects to /hosts/{host_id} and this page is gone.
|
||||
type pendingHostPage struct {
|
||||
Token string
|
||||
ServerURL string
|
||||
ExpiresAt time.Time
|
||||
RepoURL string
|
||||
RepoUsername string
|
||||
RepoPassword string
|
||||
PasswordGenerated bool
|
||||
InitialPaths []string
|
||||
}
|
||||
|
||||
// handleUIAddHostGet renders the empty Add host form.
|
||||
@@ -338,8 +332,9 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
|
||||
}
|
||||
|
||||
// handleUIAddHostPost validates the form, mints the enrolment token
|
||||
// (with encrypted repo creds), and re-renders the same page in
|
||||
// "result" state showing the install command.
|
||||
// (with encrypted repo creds), and 303-redirects to the persistent
|
||||
// pending-host page. On validation errors we re-render the form
|
||||
// with the operator's typed input intact and a banner.
|
||||
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
@@ -365,9 +360,6 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
||||
page.Error = "Repo URL is required so the agent can back up the moment it comes online."
|
||||
}
|
||||
|
||||
// If the operator didn't type a password, mint one. We surface it
|
||||
// once on the result page (inside the htpasswd snippet) so they
|
||||
// can paste it into the rest-server's htpasswd file.
|
||||
if page.Error == "" && repoPassword == "" {
|
||||
gen, err := generateRepoPassword()
|
||||
if err != nil {
|
||||
@@ -375,19 +367,15 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
||||
page.Error = "Couldn’t generate a password — see the server log for details."
|
||||
} else {
|
||||
repoPassword = gen
|
||||
page.PasswordGenerated = true
|
||||
}
|
||||
}
|
||||
|
||||
defaultPaths := splitPaths(page.Paths)
|
||||
|
||||
if page.Error == "" {
|
||||
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, defaultPaths)
|
||||
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, splitPaths(page.Paths))
|
||||
switch err {
|
||||
case nil:
|
||||
page.Token = token
|
||||
page.ExpiresAt = expires
|
||||
page.RepoPassword = repoPassword
|
||||
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
|
||||
return
|
||||
case errMissingRepoCreds:
|
||||
page.Error = "Repo URL and password are both required."
|
||||
default:
|
||||
@@ -399,18 +387,132 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Add host · restic-manager"
|
||||
view.Page = page
|
||||
status := stdhttp.StatusOK
|
||||
if page.Error != "" {
|
||||
status = stdhttp.StatusUnprocessableEntity
|
||||
} else {
|
||||
status = stdhttp.StatusCreated
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
||||
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
|
||||
slog.Error("ui: render add_host", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIPendingHost serves the durable Add-host result page —
|
||||
// shown after a successful POST /hosts/new and reachable until the
|
||||
// agent enrols (the page redirects to /hosts/{id} once that
|
||||
// happens) or the token expires (1h ttl). The password is
|
||||
// re-decrypted from the encrypted token row on every render so
|
||||
// the operator can refresh, bookmark, navigate away and come back.
|
||||
func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
rawToken := chi.URLParam(r, "token")
|
||||
if rawToken == "" {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tokHash := auth.HashToken(rawToken)
|
||||
|
||||
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash)
|
||||
if err != nil {
|
||||
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if status.ConsumedHost != nil {
|
||||
stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if time.Now().After(status.ExpiresAt) {
|
||||
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash)
|
||||
if err != nil {
|
||||
slog.Warn("ui pending: load attachments", "err", err)
|
||||
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
page := pendingHostPage{
|
||||
Token: rawToken,
|
||||
ServerURL: s.publicURL(r),
|
||||
ExpiresAt: status.ExpiresAt,
|
||||
InitialPaths: att.InitialPaths,
|
||||
}
|
||||
if att.EncRepoCreds != "" {
|
||||
plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
|
||||
if err != nil {
|
||||
slog.Error("ui pending: decrypt creds", "err", err)
|
||||
} else {
|
||||
var blob repoCredsBlob
|
||||
if err := json.Unmarshal(plain, &blob); err == nil {
|
||||
page.RepoURL = blob.RepoURL
|
||||
page.RepoUsername = blob.RepoUsername
|
||||
page.RepoPassword = blob.RepoPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Pending host · restic-manager"
|
||||
view.Page = page
|
||||
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
|
||||
slog.Error("ui: render pending_host", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIPendingAwaiting is the polled fragment that the pending-
|
||||
// host page swaps in every couple of seconds to detect "agent
|
||||
// connected". Returns either the still-awaiting partial (with the
|
||||
// HTMX poll trigger preserved) or the connected partial (no poll —
|
||||
// includes a meta-refresh to /hosts/{id} so the operator lands on
|
||||
// the host detail).
|
||||
func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if u := s.requireUIUser(w, r); u == nil {
|
||||
return
|
||||
}
|
||||
rawToken := chi.URLParam(r, "token")
|
||||
if rawToken == "" {
|
||||
stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken))
|
||||
page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
page.State = "expired"
|
||||
case err != nil:
|
||||
slog.Warn("ui awaiting: lookup", "err", err)
|
||||
page.State = "expired"
|
||||
case status.ConsumedHost != nil:
|
||||
page.State = "connected"
|
||||
page.HostID = *status.ConsumedHost
|
||||
if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil {
|
||||
page.HostName = h.Name
|
||||
page.LastSeenAt = h.LastSeenAt
|
||||
}
|
||||
case time.Now().After(status.ExpiresAt):
|
||||
page.State = "expired"
|
||||
default:
|
||||
page.State = "awaiting"
|
||||
}
|
||||
if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil {
|
||||
slog.Error("ui: render awaiting_agent", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// awaitingFragment carries the state for the polled awaiting-agent
|
||||
// partial. State == awaiting | connected | expired drives both the
|
||||
// copy block and whether HTMX keeps polling.
|
||||
type awaitingFragment struct {
|
||||
State string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
HostID string
|
||||
HostName string
|
||||
LastSeenAt *time.Time
|
||||
}
|
||||
|
||||
// hostDetailPage carries everything the host detail template needs.
|
||||
type hostDetailPage struct {
|
||||
Host store.Host
|
||||
|
||||
Reference in New Issue
Block a user