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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
ServerURL string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
// Server URL the operator should paste into the install
|
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
|
||||||
// command. Resolved from RM_BASE_URL falling back to the
|
// for as long as the token does (1h ttl); once the agent enrols,
|
||||||
// request's Host header.
|
// the handler redirects to /hosts/{host_id} and this page is gone.
|
||||||
ServerURL string
|
type pendingHostPage struct {
|
||||||
|
Token string
|
||||||
// Banner-level error shown above the form.
|
ServerURL string
|
||||||
Error string
|
ExpiresAt time.Time
|
||||||
|
RepoURL string
|
||||||
// Result state. When Token != "", the template renders the
|
RepoUsername string
|
||||||
// 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.
|
|
||||||
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 = "Couldn’t generate a password — see the server log for details."
|
page.Error = "Couldn’t 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,211 +6,110 @@
|
|||||||
|
|
||||||
<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 ""}}
|
<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]">
|
||||||
|
Mints a one-time enrolment token (TTL 1 hour) and binds the repo
|
||||||
|
credentials to it. The token can only be used once — generate a fresh
|
||||||
|
one if it expires or you typed something wrong.
|
||||||
|
</p>
|
||||||
|
|
||||||
{{/* ============================================================
|
{{if $page.Error}}
|
||||||
State A · form
|
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]"
|
||||||
============================================================ */}}
|
style="background: color-mix(in oklch, var(--bad), transparent 88%);
|
||||||
<h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">Add a host</h1>
|
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
|
||||||
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
|
color: oklch(0.85 0.10 25);">
|
||||||
Mints a one-time enrolment token (TTL 1 hour) and binds the repo
|
{{$page.Error}}
|
||||||
credentials to it. The token can only be used once — generate a fresh
|
|
||||||
one if it expires or you typed something wrong.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{if $page.Error}}
|
|
||||||
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]"
|
|
||||||
style="background: color-mix(in oklch, var(--bad), transparent 88%);
|
|
||||||
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
|
|
||||||
color: oklch(0.85 0.10 25);">
|
|
||||||
{{$page.Error}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form method="post" action="/hosts/new" class="grid grid-cols-12 gap-8 mt-7">
|
|
||||||
|
|
||||||
<div class="col-span-7 panel rounded-[7px] px-8 py-7">
|
|
||||||
|
|
||||||
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4">Host</h3>
|
|
||||||
<div class="mb-5">
|
|
||||||
<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}}">
|
|
||||||
<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 class="mb-7">
|
|
||||||
<label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label>
|
|
||||||
<input id="ah-tags" name="tags" type="text" class="field mono" placeholder="prod, db" value="{{$page.Tags}}">
|
|
||||||
<div class="field-help">Free-form. Used for filtering and grouping on the dashboard.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Initial schedule <span class="text-ink-fade font-normal">· manual</span></h3>
|
|
||||||
<div class="mb-7">
|
|
||||||
<label class="field-label" for="ah-paths">Paths <span class="text-ink-fade font-normal">· one per line</span></label>
|
|
||||||
<textarea id="ah-paths" name="paths" rows="3" class="field mono"
|
|
||||||
style="resize: vertical;"
|
|
||||||
placeholder="/etc /home /var/lib/postgresql">{{$page.Paths}}</textarea>
|
|
||||||
<div class="field-help">
|
|
||||||
These paths become an <strong>initial manual schedule</strong> on the new host — manual = no cron, only fires when you click <span class="mono text-ink-mid">Run now</span>. You can edit this schedule (or add automated ones alongside it) from the host's <strong>Schedules</strong> tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Restic repository</h3>
|
|
||||||
<div class="mb-5">
|
|
||||||
<label class="field-label" for="ah-url">Repo URL</label>
|
|
||||||
<input id="ah-url" name="repo_url" type="text" class="field mono" required
|
|
||||||
placeholder="rest:https://restic.lab/host-name/"
|
|
||||||
value="{{$page.RepoURL}}">
|
|
||||||
<div class="field-help">Whatever <span class="mono text-ink-mid">restic -r</span> would accept. Most fleets terminate at a <span class="mono text-ink-mid">restic/rest-server</span>; <span class="mono text-ink-mid">s3:</span> and <span class="mono text-ink-mid">b2:</span> URLs work equally well.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-5">
|
|
||||||
<label class="field-label" for="ah-user">Repo username <span class="text-ink-fade font-normal">· optional</span></label>
|
|
||||||
<input id="ah-user" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}">
|
|
||||||
<div class="field-help">For <span class="mono text-ink-mid">rest-server</span> with htpasswd, this is the per-host user.</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<input id="ah-pass" name="repo_password" type="password" class="field">
|
|
||||||
<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 and surface it once on the next page (alongside the <span class="mono text-ink-mid">htpasswd</span> snippet you’ll need to run on the rest-server).</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-5 border-t border-line-soft">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">Mint token & show install command</button>
|
|
||||||
<a href="/" class="btn btn-lg">Cancel</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="col-span-5">
|
|
||||||
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">What happens next</div>
|
|
||||||
|
|
||||||
<ol class="list-none p-0 m-0 space-y-4">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</li>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</li>
|
|
||||||
<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>
|
|
||||||
<div class="text-[13px] text-ink font-medium">The host appears on the dashboard within seconds</div>
|
|
||||||
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Server pushes the encrypted repo creds over the WS on first <span class="mono text-ink-mid">hello</span>; agent decrypts and persists to <span class="mono text-ink-mid">secrets.enc</span>.</div>
|
|
||||||
</li>
|
|
||||||
<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>
|
|
||||||
<div class="text-[13px] text-ink font-medium">You hit “Run backup now”</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>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div class="mt-8 panel rounded-[6px] px-4 py-3.5">
|
|
||||||
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-warn mb-1.5">Prerequisite</div>
|
|
||||||
<p class="text-pretty text-[12px] text-ink-mid leading-[1.55]">
|
|
||||||
<span class="mono text-ink">restic</span> ≥ 0.16 must already be installed on the target host. The agent does not install it for you — different distros, different package managers, too much surface area to maintain.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
</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>
|
</div>
|
||||||
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
|
{{end}}
|
||||||
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}}
|
<form method="post" action="/hosts/new" class="grid grid-cols-12 gap-8 mt-7">
|
||||||
<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%);">
|
<div class="col-span-7 panel rounded-[7px] px-8 py-7">
|
||||||
<span>
|
|
||||||
Run on the rest-server box first
|
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4">Host</h3>
|
||||||
{{if $page.PasswordGenerated}}
|
<div class="mb-5">
|
||||||
<span class="mono text-[10.5px] px-1.5 py-0.5 ml-2 rounded-[3px]"
|
<label class="field-label" for="ah-name">Hostname</label>
|
||||||
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>
|
<input id="ah-name" name="hostname" type="text" class="field mono" autofocus required value="{{$page.Hostname}}">
|
||||||
{{end}}
|
<div class="field-help">Becomes the host's display name. Most operators use the box's actual hostname so logs line up.</div>
|
||||||
<span class="text-ink-fade ml-2">· this is the only time you’ll see the password</span>
|
</div>
|
||||||
</span>
|
<div class="mb-7">
|
||||||
<div class="flex gap-2">
|
<label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label>
|
||||||
<button type="button" class="btn"
|
<input id="ah-tags" name="tags" type="text" class="field mono" placeholder="prod, db" value="{{$page.Tags}}">
|
||||||
data-snippet="echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}"
|
<div class="field-help">Free-form. Used for filtering and grouping on the dashboard.</div>
|
||||||
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Initial schedule <span class="text-ink-fade font-normal">· manual</span></h3>
|
||||||
|
<div class="mb-7">
|
||||||
|
<label class="field-label" for="ah-paths">Paths <span class="text-ink-fade font-normal">· one per line</span></label>
|
||||||
|
<textarea id="ah-paths" name="paths" rows="3" class="field mono"
|
||||||
|
style="resize: vertical;"
|
||||||
|
placeholder="/etc /home /var/lib/postgresql">{{$page.Paths}}</textarea>
|
||||||
|
<div class="field-help">
|
||||||
|
These paths become an <strong>initial manual schedule</strong> on the new host — manual = no cron, only fires when you click <span class="mono text-ink-mid">Run now</span>. You can edit this schedule (or add automated ones alongside it) from the host's <strong>Schedules</strong> tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Restic repository</h3>
|
||||||
<div class="snippet-head">
|
<div class="mb-5">
|
||||||
<span>Install command · paste-and-run on the host you’re backing up</span>
|
<label class="field-label" for="ah-url">Repo URL</label>
|
||||||
<div class="flex gap-2">
|
<input id="ah-url" name="repo_url" type="text" class="field mono" required
|
||||||
<button type="button" class="btn"
|
placeholder="rest:https://restic.lab/host-name/"
|
||||||
data-snippet="curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} bash"
|
value="{{$page.RepoURL}}">
|
||||||
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
|
<div class="field-help">Whatever <span class="mono text-ink-mid">restic -r</span> would accept. Most fleets terminate at a <span class="mono text-ink-mid">restic/rest-server</span>; <span class="mono text-ink-mid">s3:</span> and <span class="mono text-ink-mid">b2:</span> URLs work equally well.</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<pre>curl -fsSL <span class="var">{{$page.ServerURL}}/install/install.sh</span> | sudo \
|
<div class="mb-5">
|
||||||
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
|
<label class="field-label" for="ah-user">Repo username <span class="text-ink-fade font-normal">· optional</span></label>
|
||||||
RM_TOKEN=<span class="var">{{$page.Token}}</span> bash</pre>
|
<input id="ah-user" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}">
|
||||||
|
<div class="field-help">For <span class="mono text-ink-mid">rest-server</span> with htpasswd, this is the per-host user.</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<input id="ah-pass" name="repo_password" type="password" class="field">
|
||||||
|
<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 class="flex gap-2 pt-5 border-t border-line-soft">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Mint token & show install command</button>
|
||||||
|
<a href="/" class="btn btn-lg">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-6 mt-7">
|
<aside class="col-span-5">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">What happens next</div>
|
||||||
|
|
||||||
<div class="col-span-7 panel rounded-[7px] px-7 py-6">
|
<ol class="list-none p-0 m-0 space-y-4">
|
||||||
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
|
<li class="relative pl-9">
|
||||||
<div class="flex items-center gap-3">
|
<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="dot dot-offline pulse"></span>
|
<div class="text-[13px] text-ink font-medium">You get a one-time install command</div>
|
||||||
<span class="mono text-[14px] text-ink">{{if $page.Hostname}}{{$page.Hostname}}{{else}}new host{{end}}</span>
|
<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>
|
||||||
<span class="text-[12px] text-ink-mute">— enrolment will mark this online</span>
|
</li>
|
||||||
</div>
|
<li class="relative pl-9">
|
||||||
<div class="mt-4 px-3 py-2.5 rounded-[5px] mono text-[11.5px] text-ink-mute leading-[1.7]"
|
<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>
|
||||||
style="background: var(--bg); border: 1px solid var(--line-soft);">
|
<div class="text-[13px] text-ink font-medium">You run it on the box you want to back up</div>
|
||||||
<div>{{$page.ExpiresAt.Format "15:04:05.000"}} <span class="text-ink-mid">server</span> token minted · 1h ttl</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>
|
||||||
<div class="text-ink-fade"> awaiting POST /api/agents/enroll …</div>
|
</li>
|
||||||
</div>
|
<li class="relative pl-9">
|
||||||
<p class="mt-4 text-[12.5px] text-ink-mid leading-[1.6]">
|
<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>
|
||||||
Enrolment will create a <span class="mono text-ink">manual</span> schedule from the paths above. Find it (and add automated ones) under
|
<div class="text-[13px] text-ink font-medium">The host appears on the dashboard within seconds</div>
|
||||||
<span class="mono text-ink">Host > Schedules</span> once the agent connects.
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Server pushes the encrypted repo creds over the WS on first <span class="mono text-ink-mid">hello</span>; agent decrypts and persists to <span class="mono text-ink-mid">secrets.enc</span>.</div>
|
||||||
|
</li>
|
||||||
|
<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>
|
||||||
|
<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. Add automated schedules from the host's Schedules tab whenever you're ready.</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="mt-8 panel rounded-[6px] px-4 py-3.5">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-warn mb-1.5">Prerequisite</div>
|
||||||
|
<p class="text-pretty text-[12px] text-ink-mid leading-[1.55]">
|
||||||
|
<span class="mono text-ink">restic</span> ≥ 0.16 must already be installed on the target host. The agent does not install it for you — different distros, different package managers, too much surface area to maintain.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside class="col-span-5">
|
</form>
|
||||||
<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> 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 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>
|
|
||||||
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user