Add-host: durable pending page + polled awaiting-agent panel
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

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:
2026-05-02 12:59:24 +01:00
parent 148e61b33b
commit 8a05969953
8 changed files with 464 additions and 238 deletions
+6
View File
@@ -151,6 +151,12 @@ func (s *Server) routes(r chi.Router) {
// Add host flow. // Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet) r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost) r.Post("/hosts/new", s.handleUIAddHostPost)
// Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once
// the agent enrols.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default). // Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail) r.Get("/hosts/{id}", s.handleUIHostDetail)
// Schedules tab + create/edit/delete forms. // Schedules tab + create/edit/delete forms.
+143 -41
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"io/fs" "io/fs"
"log/slog" "log/slog"
@@ -284,9 +285,12 @@ func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
} }
// addHostPage carries the form state into the Add host template. // addHostPage carries the Add-host form state. The result-state
// In State A (form), Token is empty. In State B (result), Token is // (showing the install command + htpasswd snippet) lives at
// populated and the template renders the install command. // /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 { type addHostPage struct {
// Form fields — pre-populate the form on a re-render after a // Form fields — pre-populate the form on a re-render after a
// validation error. // validation error.
@@ -294,32 +298,22 @@ type addHostPage struct {
Tags string Tags string
RepoURL string RepoURL string
RepoUsername string RepoUsername string
// Paths is the textarea-as-typed default-paths input. One path
// per line, blanks ignored.
Paths string Paths 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 ServerURL string
// Banner-level error shown above the form.
Error string Error string
}
// Result state. When Token != "", the template renders the // pendingHostPage is the GET /hosts/pending/{token} view. Lives
// install command panel instead of the form. // 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 Token string
ServerURL string
ExpiresAt time.Time ExpiresAt time.Time
RepoURL string
// RepoPassword is the password the agent will use against the RepoUsername string
// 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.
RepoPassword string RepoPassword string
PasswordGenerated bool InitialPaths []string
} }
// handleUIAddHostGet renders the empty Add host form. // 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 // handleUIAddHostPost validates the form, mints the enrolment token
// (with encrypted repo creds), and re-renders the same page in // (with encrypted repo creds), and 303-redirects to the persistent
// "result" state showing the install command. // 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) { func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r) u := s.requireUIUser(w, r)
if u == nil { 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." 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 == "" { if page.Error == "" && repoPassword == "" {
gen, err := generateRepoPassword() gen, err := generateRepoPassword()
if err != nil { if err != nil {
@@ -375,19 +367,15 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
page.Error = "Couldnt generate a password — see the server log for details." page.Error = "Couldnt generate a password — see the server log for details."
} else { } else {
repoPassword = gen repoPassword = gen
page.PasswordGenerated = true
} }
} }
defaultPaths := splitPaths(page.Paths)
if page.Error == "" { 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 { switch err {
case nil: case nil:
page.Token = token stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
page.ExpiresAt = expires return
page.RepoPassword = repoPassword
case errMissingRepoCreds: case errMissingRepoCreds:
page.Error = "Repo URL and password are both required." page.Error = "Repo URL and password are both required."
default: default:
@@ -399,18 +387,132 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
view := s.baseView(u, "dashboard") view := s.baseView(u, "dashboard")
view.Title = "Add host · restic-manager" view.Title = "Add host · restic-manager"
view.Page = page view.Page = page
status := stdhttp.StatusOK w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if page.Error != "" {
status = stdhttp.StatusUnprocessableEntity
} else {
status = stdhttp.StatusCreated
}
w.WriteHeader(status)
if err := s.deps.UI.Render(w, "add_host", view); err != nil { if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err) 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. // hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct { type hostDetailPage struct {
Host store.Host Host store.Host
+18
View File
@@ -90,6 +90,7 @@ func New() (*Renderer, error) {
"templates/partials/nav.html", "templates/partials/nav.html",
"templates/partials/host_row.html", "templates/partials/host_row.html",
"templates/partials/toast.html", "templates/partials/toast.html",
"templates/partials/awaiting_agent.html",
} }
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
@@ -126,6 +127,23 @@ func (r *Renderer) Render(w io.Writer, page string, data ViewData) error {
return t.ExecuteTemplate(w, layoutFor(page), data) return t.ExecuteTemplate(w, layoutFor(page), data)
} }
// RenderPartial writes a named partial template to w *without* the
// layout wrap. Used by HTMX endpoints that swap fragments into
// already-rendered pages. The partial is looked up in any page's
// template set (every page parses the full common-paths list, so
// they all see every partial). Pick "dashboard" arbitrarily as the
// lookup point — partials are layout-agnostic.
func (r *Renderer) RenderPartial(w io.Writer, name string, data ViewData) error {
t, ok := r.pages["dashboard"]
if !ok {
return fmt.Errorf("ui: renderer has no pages registered")
}
if data.Version == "" {
data.Version = "dev"
}
return t.ExecuteTemplate(w, name, data)
}
// layoutFor picks the layout name for a page. Login + bootstrap go // layoutFor picks the layout name for a page. Login + bootstrap go
// chrome-less; everything else uses the standard navigation chrome. // chrome-less; everything else uses the standard navigation chrome.
func layoutFor(page string) string { func layoutFor(page string) string {
+44
View File
@@ -116,6 +116,50 @@ func (s *Store) GetEnrollmentTokenAttachments(ctx context.Context, tokenHash str
return out, nil return out, nil
} }
// EnrollmentTokenStatus is what the awaiting-agent panel polls for
// after Add-host. Returned by GetEnrollmentTokenStatus; the
// consuming code branches on Consumed + the (optional) ConsumedHost.
type EnrollmentTokenStatus struct {
ExpiresAt time.Time
ConsumedAt *time.Time
ConsumedHost *string
}
// GetEnrollmentTokenStatus reports whether a token has been
// consumed yet (the agent has called /api/agents/enroll). Returns
// ErrNotFound if the token is unknown — the polling endpoint maps
// that to "token expired or invalid; stop polling".
func (s *Store) GetEnrollmentTokenStatus(ctx context.Context, tokenHash string) (EnrollmentTokenStatus, error) {
row := s.db.QueryRowContext(ctx,
`SELECT expires_at, consumed_at, consumed_host
FROM enrollment_tokens WHERE token_hash = ?`,
tokenHash)
var (
expiresAt string
consumedAt, host sql.NullString
)
if err := row.Scan(&expiresAt, &consumedAt, &host); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return EnrollmentTokenStatus{}, ErrNotFound
}
return EnrollmentTokenStatus{}, fmt.Errorf("store: get enrollment token status: %w", err)
}
out := EnrollmentTokenStatus{}
if t, err := time.Parse(time.RFC3339Nano, expiresAt); err == nil {
out.ExpiresAt = t
}
if consumedAt.Valid {
if t, err := time.Parse(time.RFC3339Nano, consumedAt.String); err == nil {
out.ConsumedAt = &t
}
}
if host.Valid {
s := host.String
out.ConsumedHost = &s
}
return out, nil
}
// PurgeExpiredEnrollmentTokens deletes long-expired token rows. Tokens // PurgeExpiredEnrollmentTokens deletes long-expired token rows. Tokens
// retained for ~24h after expiry so audit traces still resolve them. // retained for ~24h after expiry so audit traces still resolve them.
func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) { func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) {
File diff suppressed because one or more lines are too long
+6 -107
View File
@@ -6,11 +6,6 @@
<div class="crumbs"><a href="/">Dashboard</a><span class="sep">/</span><span class="text-ink-mid">Add host</span></div> <div class="crumbs"><a href="/">Dashboard</a><span class="sep">/</span><span class="text-ink-mid">Add host</span></div>
{{if eq $page.Token ""}}
{{/* ============================================================
State A · form
============================================================ */}}
<h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">Add a host</h1> <h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">Add a host</h1>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]"> <p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
Mints a one-time enrolment token (TTL 1 hour) and binds the repo Mints a one-time enrolment token (TTL 1 hour) and binds the repo
@@ -35,7 +30,7 @@
<div class="mb-5"> <div class="mb-5">
<label class="field-label" for="ah-name">Hostname</label> <label class="field-label" for="ah-name">Hostname</label>
<input id="ah-name" name="hostname" type="text" class="field mono" autofocus required value="{{$page.Hostname}}"> <input id="ah-name" name="hostname" type="text" class="field mono" autofocus required value="{{$page.Hostname}}">
<div class="field-help">Becomes the hosts display name. Most operators use the boxs actual hostname so logs line up.</div> <div class="field-help">Becomes the host's display name. Most operators use the box's actual hostname so logs line up.</div>
</div> </div>
<div class="mb-7"> <div class="mb-7">
<label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label> <label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label>
@@ -70,7 +65,7 @@
<div class="mb-7"> <div class="mb-7">
<label class="field-label" for="ah-pass">Repo password <span class="text-ink-fade font-normal">· optional — leave blank to generate</span></label> <label class="field-label" for="ah-pass">Repo password <span class="text-ink-fade font-normal">· optional — leave blank to generate</span></label>
<input id="ah-pass" name="repo_password" type="password" class="field"> <input id="ah-pass" name="repo_password" type="password" class="field">
<div class="field-help">Encrypted at rest using the servers AEAD key, pushed to the agent only over the authenticated WebSocket. Leave blank and well mint a 24-byte URL-safe random password and surface it once on the next page (alongside the <span class="mono text-ink-mid">htpasswd</span> snippet youll need to run on the rest-server).</div> <div class="field-help">Encrypted at rest using the server's AEAD key, pushed to the agent only over the authenticated WebSocket. Leave blank and we'll mint a 24-byte URL-safe random password — you'll see it on the next page (and can come back to it from the dashboard's pending-host link until the agent connects).</div>
</div> </div>
<div class="flex gap-2 pt-5 border-t border-line-soft"> <div class="flex gap-2 pt-5 border-t border-line-soft">
@@ -87,12 +82,12 @@
<li class="relative pl-9"> <li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span> <span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="text-[13px] text-ink font-medium">You get a one-time install command</div> <div class="text-[13px] text-ink font-medium">You get a one-time install command</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">A <span class="mono text-ink-mid">curl … | sh</span> snippet with the server URL and a 1h token baked in.</div> <div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">A <span class="mono text-ink-mid">curl … | bash</span> snippet with the server URL and a 1h token baked in.</div>
</li> </li>
<li class="relative pl-9"> <li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span> <span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="text-[13px] text-ink font-medium">You run it on the box you want to back up</div> <div class="text-[13px] text-ink font-medium">You run it on the box you want to back up</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Installer creates a service user, drops the agent binary, registers a sandboxed systemd unit, and enrols.</div> <div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Installer drops the agent binary as root, registers a sandboxed systemd unit, and enrols.</div>
</li> </li>
<li class="relative pl-9"> <li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span> <span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
@@ -101,8 +96,8 @@
</li> </li>
<li class="relative pl-9"> <li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span> <span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
<div class="text-[13px] text-ink font-medium">You hit “Run backup now”</div> <div class="text-[13px] text-ink font-medium">You hit “Run now” on the manual schedule</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">First snapshot lands in the repo. Subsequent ones run on whatever schedule you set (Phase 2).</div> <div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">First snapshot lands in the repo. Add automated schedules from the host's Schedules tab whenever you're ready.</div>
</li> </li>
</ol> </ol>
@@ -116,101 +111,5 @@
</form> </form>
{{else}}
{{/* ============================================================
State B · token minted
============================================================ */}}
<div class="flex items-center gap-3 mt-2.5">
<h1 class="text-2xl font-medium tracking-[-0.012em]">Token minted</h1>
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%);
color: var(--ok);
border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">
expires {{relTime $page.ExpiresAt}}
</span>
</div>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
Run the snippet below on the target box. The host will appear on the
dashboard within a few seconds of the agent connecting.
</p>
{{if and $page.RepoUsername $page.RepoPassword}}
<div class="snippet mt-6 panel" style="border-color: color-mix(in oklch, var(--warn), transparent 60%);">
<div class="snippet-head" style="background: color-mix(in oklch, var(--warn), transparent 92%);">
<span>
Run on the rest-server box first
{{if $page.PasswordGenerated}}
<span class="mono text-[10.5px] px-1.5 py-0.5 ml-2 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">password generated</span>
{{end}}
<span class="text-ink-fade ml-2">· this is the only time youll see the password</span>
</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>echo '<span class="var">{{$page.RepoPassword}}</span>' | sudo htpasswd -B -i <span class="var">/path/to/htpasswd</span> <span class="var">{{$page.RepoUsername}}</span></pre>
<div class="px-4 pt-1 pb-3 text-[12px] text-ink-mute leading-[1.55]">
Replace <span class="mono text-ink-mid">/path/to/htpasswd</span> with whatever your <span class="mono text-ink-mid">restic/rest-server</span> reads (typically the file passed via <span class="mono text-ink-mid">--htpasswd-file</span>, or <span class="mono text-ink-mid">/data/.htpasswd</span> in the official Docker image). The <span class="mono text-ink-mid">-i</span> flag reads the password from stdin so it never appears in your shells process list. Then either send <span class="mono text-ink-mid">SIGHUP</span> to the rest-server process or restart the container to pick up the new entry.
</div>
</div>
{{end}}
<div class="snippet mt-4 panel">
<div class="snippet-head">
<span>Install command · paste-and-run on the host youre backing up</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} bash"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>curl -fsSL <span class="var">{{$page.ServerURL}}/install/install.sh</span> | sudo \
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
RM_TOKEN=<span class="var">{{$page.Token}}</span> bash</pre>
</div>
<div class="grid grid-cols-12 gap-6 mt-7">
<div class="col-span-7 panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="mono text-[14px] text-ink">{{if $page.Hostname}}{{$page.Hostname}}{{else}}new host{{end}}</span>
<span class="text-[12px] text-ink-mute">— enrolment will mark this online</span>
</div>
<div class="mt-4 px-3 py-2.5 rounded-[5px] mono text-[11.5px] text-ink-mute leading-[1.7]"
style="background: var(--bg); border: 1px solid var(--line-soft);">
<div>{{$page.ExpiresAt.Format "15:04:05.000"}} <span class="text-ink-mid">server</span> token minted · 1h ttl</div>
<div class="text-ink-fade"> awaiting POST /api/agents/enroll …</div>
</div>
<p class="mt-4 text-[12.5px] text-ink-mid leading-[1.6]">
Enrolment will create a <span class="mono text-ink">manual</span> schedule from the paths above. Find it (and add automated ones) under
<span class="mono text-ink">Host &gt; Schedules</span> once the agent connects.
</p>
</div>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">If the agent doesnt appear</div>
<ul class="list-none p-0 m-0 text-[13px] text-ink-mid leading-[1.55]">
<li class="py-2 border-b border-line-soft text-pretty">Check the box can reach <span class="mono text-ink">{{$page.ServerURL}}</span> over HTTPS.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">restic --version</span> ≥ 0.16 — the installer wont bail on this, but backups will fail.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">journalctl -u restic-manager-agent -n 50</span> on the target box.</li>
<li class="py-2 text-pretty">Token expired? <a href="/hosts/new" class="underline underline-offset-4 decoration-line">Mint a new one</a> — theyre cheap.</li>
</ul>
</aside>
</div>
<div class="mt-7 flex gap-2">
<a href="/" class="btn btn-lg">← Back to dashboard</a>
<a href="/hosts/new" class="btn btn-lg">Add another host</a>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
+98
View File
@@ -0,0 +1,98 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pt-9 pb-24">
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/hosts/new">Add host</a><span class="sep">/</span>
<span class="text-ink-mid">pending</span>
</div>
<div class="flex items-center gap-3 mt-2.5">
<h1 class="text-2xl font-medium tracking-[-0.012em]">Pending host</h1>
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%);
color: var(--ok);
border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">
expires {{relTime $page.ExpiresAt}}
</span>
</div>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[680px]">
Token's still alive — refresh the page or come back later until the
agent enrols. Credentials are decrypted from the (still-encrypted-at-rest)
token row each render, so you can recover them if you've already lost
the snippets below. Once the agent connects this page redirects to
the host detail.
</p>
{{if and $page.RepoUsername $page.RepoPassword}}
<div class="snippet mt-6 panel" style="border-color: color-mix(in oklch, var(--warn), transparent 60%);">
<div class="snippet-head" style="background: color-mix(in oklch, var(--warn), transparent 92%);">
<span>
Run on the rest-server box first
<span class="text-ink-fade ml-2">· paste-and-run after replacing the htpasswd path</span>
</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>echo '<span class="var">{{$page.RepoPassword}}</span>' | sudo htpasswd -B -i <span class="var">/path/to/htpasswd</span> <span class="var">{{$page.RepoUsername}}</span></pre>
<div class="px-4 pt-1 pb-3 text-[12px] text-ink-mute leading-[1.55]">
Replace <span class="mono text-ink-mid">/path/to/htpasswd</span> with whatever your <span class="mono text-ink-mid">restic/rest-server</span> reads (typically the file passed via <span class="mono text-ink-mid">--htpasswd-file</span>, or <span class="mono text-ink-mid">/data/.htpasswd</span> in the official Docker image). The <span class="mono text-ink-mid">-i</span> flag reads the password from stdin so it never appears in your shell's process list. Then either send <span class="mono text-ink-mid">SIGHUP</span> to the rest-server process or restart the container to pick up the new entry.
</div>
</div>
{{end}}
<div class="snippet mt-4 panel">
<div class="snippet-head">
<span>Install command · paste-and-run on the host you're backing up</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} bash"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>curl -fsSL <span class="var">{{$page.ServerURL}}/install/install.sh</span> | sudo \
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
RM_TOKEN=<span class="var">{{$page.Token}}</span> bash</pre>
</div>
<div class="grid grid-cols-12 gap-6 mt-7">
<div class="col-span-7"
hx-get="/hosts/pending/{{$page.Token}}/awaiting"
hx-trigger="load, every 2s"
hx-swap="outerHTML">
{{/* Fallback content; HTMX swaps this out within 2s of load. */}}
<div class="panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="text-[12px] text-ink-mute">checking…</span>
</div>
</div>
</div>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">If the agent doesn't appear</div>
<ul class="list-none p-0 m-0 text-[13px] text-ink-mid leading-[1.55]">
<li class="py-2 border-b border-line-soft text-pretty">Check the box can reach <span class="mono text-ink">{{$page.ServerURL}}</span>.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">restic --version</span> ≥ 0.16 — the installer won't bail on this, but backups will fail.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">journalctl -u restic-manager-agent -n 50</span> on the target box.</li>
<li class="py-2 text-pretty">Token expired? <a href="/hosts/new" class="underline underline-offset-4 decoration-line">Mint a new one</a> — they're cheap.</li>
</ul>
</aside>
</div>
<div class="mt-7 flex gap-2">
<a href="/" class="btn btn-lg">← Back to dashboard</a>
<a href="/hosts/new" class="btn btn-lg">Add another host</a>
</div>
</div>
{{end}}
@@ -0,0 +1,59 @@
{{define "awaiting_agent"}}
{{$page := .Page}}
{{/*
Polled status fragment for the Add-host pending page. Wrapper
carries the HTMX poll trigger only while State == "awaiting" —
state == "connected" or "expired" both stop polling. The wrapper
is what HTMX swaps via hx-swap=outerHTML, so the trigger getting
removed is what stops the loop.
*/}}
<div class="col-span-7"
{{if eq $page.State "awaiting"}}hx-get="/hosts/pending/{{$page.Token}}/awaiting"
hx-trigger="every 2s"
hx-swap="outerHTML"{{end}}>
{{if eq $page.State "connected"}}
<div class="panel rounded-[7px] px-7 py-6"
style="border-color: color-mix(in oklch, var(--ok), transparent 60%);">
<div class="text-[11px] uppercase tracking-[0.1em] font-semibold text-ok mb-3">Agent connected</div>
<div class="flex items-center gap-3">
<span class="dot dot-online"></span>
<span class="mono text-[14px] text-ink">{{$page.HostName}}</span>
<span class="text-[12px] text-ink-mute">— enrolled, online{{if $page.LastSeenAt}}, last heartbeat {{relTime $page.LastSeenAt}}{{end}}</span>
</div>
<div class="mt-4 flex gap-2">
<a href="/hosts/{{$page.HostID}}" class="btn btn-primary">Open host →</a>
<a href="/hosts/{{$page.HostID}}/schedules" class="btn">View schedules</a>
</div>
</div>
{{else if eq $page.State "expired"}}
<div class="panel rounded-[7px] px-7 py-6"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[11px] uppercase tracking-[0.1em] font-semibold text-bad mb-3">Token expired</div>
<p class="text-[13px] text-ink-mid leading-[1.55]">
The 1-hour window has elapsed without the agent connecting.
Mint a fresh token and run the new install command.
</p>
<div class="mt-4">
<a href="/hosts/new" class="btn btn-primary">Mint a new token</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="text-[12px] text-ink-mute">— polling every 2s; this page redirects to the host detail when enrolment lands.</span>
</div>
<div class="mt-4 px-3 py-2.5 rounded-[5px] mono text-[11.5px] text-ink-mute leading-[1.7]"
style="background: var(--bg); border: 1px solid var(--line-soft);">
<div>token expires <span class="text-ink-mid">{{relTime $page.ExpiresAt}}</span></div>
<div class="text-ink-fade">awaiting POST /api/agents/enroll …</div>
</div>
</div>
{{end}}
</div>
{{end}}