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:
2026-05-02 12:59:24 +01:00
parent 8fb1c100fd
commit c1f85da55f
8 changed files with 464 additions and 238 deletions
File diff suppressed because one or more lines are too long
+91 -192
View File
@@ -6,211 +6,110 @@
<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>
{{/* ============================================================
State A · form
============================================================ */}}
<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}}
<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 hosts display name. Most operators use the boxs 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&#10;/home&#10;/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&nbsp;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 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>
<div class="flex gap-2 pt-5 border-t border-line-soft">
<button type="submit" class="btn btn-primary btn-lg">Mint token &amp; 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>
{{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>
<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>
{{end}}
{{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>
<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&#10;/home&#10;/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&nbsp;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>
<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>
<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>
<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 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 — 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 &amp; show install command</button>
<a href="/" class="btn btn-lg">Cancel</a>
</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">
<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.
<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 … | bash</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 drops the agent binary as root, 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 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>
</div>
</aside>
<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}}
</form>
</div>
{{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}}