P1-27: Add host flow — form + minted-token result page
GET /hosts/new renders the focused two-column form (hostname,
tags, repo URL/username/password). POST /hosts/new validates,
mints a one-time token via the new mintEnrollmentToken helper —
shared with the existing JSON /api/enrollment-tokens endpoint —
and re-renders the same page in result state showing:
- the install command with RM_SERVER + RM_TOKEN filled in (and
an inline copy-to-clipboard button),
- an "awaiting agent connection" panel with the hostname
pre-filled,
- a troubleshooting list pointing at the most common reasons
the agent doesn't appear,
- back-to-dashboard / add-another-host links.
publicURL() resolves RM_BASE_URL first, falling back to scheme +
Host on the inbound request — useful for local smoke without a
proxy.
Browser-verified end-to-end: form submit → token minted → install
command renders with the right values from the form input.
template fn formatRelTime now accepts time.Time *or* *time.Time
so templates can pass either without fighting Go's lack of an
address-of operator.
Deferred: download-preconfigured-installer (a templated .sh with
the values baked in) — copy-paste covers v1; nice-to-have later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,37 +195,51 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if req.RepoURL == "" || req.RepoPassword == "" {
|
||||
token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword)
|
||||
switch err {
|
||||
case nil:
|
||||
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
|
||||
case errMissingRepoCreds:
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
|
||||
"repo_url and repo_password are required so the agent can run backups on first connect")
|
||||
return
|
||||
default:
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
}
|
||||
}
|
||||
|
||||
// errMissingRepoCreds is returned by mintEnrollmentToken when the
|
||||
// operator hasn't supplied the URL+password pair the agent needs.
|
||||
// Sentinel error so HTML and JSON handlers can map it to their own
|
||||
// surface (form re-render with banner / 400 with code).
|
||||
var errMissingRepoCreds = errAuth("missing_repo_creds")
|
||||
|
||||
// mintEnrollmentToken creates a fresh one-time enrollment token and
|
||||
// stashes the AEAD-encrypted repo creds on its row. Returns the raw
|
||||
// token (shown to the operator exactly once) and the expiry time.
|
||||
//
|
||||
// Shared by the JSON endpoint and the HTML "Add host" flow.
|
||||
func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, repoPassword string) (string, time.Time, error) {
|
||||
if repoURL == "" || repoPassword == "" {
|
||||
return "", time.Time{}, errMissingRepoCreds
|
||||
}
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
tokHash := auth.HashToken(token)
|
||||
|
||||
enc, err := s.encryptRepoCreds(repoCredsBlob{
|
||||
RepoURL: req.RepoURL, RepoUsername: req.RepoUsername, RepoPassword: req.RepoPassword,
|
||||
RepoURL: repoURL, RepoUsername: repoUsername, RepoPassword: repoPassword,
|
||||
}, []byte("token:"+tokHash))
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
const ttl = time.Hour
|
||||
if err := s.deps.Store.CreateEnrollmentToken(r.Context(), tokHash, ttl, enc); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
if err := s.deps.Store.CreateEnrollmentToken(ctx, tokHash, ttl, enc); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(ttl).UTC(),
|
||||
})
|
||||
return token, time.Now().Add(ttl).UTC(), nil
|
||||
}
|
||||
|
||||
// rebindTokenCreds decrypts the creds attached to the token (if any),
|
||||
|
||||
@@ -133,6 +133,9 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/logout", s.handleUILogoutPost)
|
||||
// HTMX action endpoint for "Run now" buttons on the dashboard.
|
||||
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup)
|
||||
// Add host flow.
|
||||
r.Get("/hosts/new", s.handleUIAddHostGet)
|
||||
r.Post("/hosts/new", s.handleUIAddHostPost)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -163,6 +165,117 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
w.WriteHeader(stdhttp.StatusNoContent)
|
||||
}
|
||||
|
||||
// addHostPage carries the form state into the Add host template.
|
||||
// In State A (form), Token is empty. In State B (result), Token is
|
||||
// populated and the template renders the install command.
|
||||
type addHostPage struct {
|
||||
// Form fields — pre-populate the form on a re-render after a
|
||||
// validation error.
|
||||
Hostname string
|
||||
Tags string
|
||||
RepoURL string
|
||||
RepoUsername string
|
||||
|
||||
// Server URL the operator should paste into the install
|
||||
// command. Resolved from RM_BASE_URL falling back to the
|
||||
// request's Host header.
|
||||
ServerURL string
|
||||
|
||||
// Banner-level error shown above the form.
|
||||
Error string
|
||||
|
||||
// Result state. When Token != "", the template renders the
|
||||
// install command panel instead of the form.
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// handleUIAddHostGet renders the empty Add host form.
|
||||
func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Add host · restic-manager"
|
||||
view.Page = addHostPage{ServerURL: s.publicURL(r)}
|
||||
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
|
||||
slog.Error("ui: render add_host", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIAddHostPost validates the form, mints the enrolment token
|
||||
// (with encrypted repo creds), and re-renders the same page in
|
||||
// "result" state showing the install command.
|
||||
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page := addHostPage{
|
||||
Hostname: strings.TrimSpace(r.PostForm.Get("hostname")),
|
||||
Tags: strings.TrimSpace(r.PostForm.Get("tags")),
|
||||
RepoURL: strings.TrimSpace(r.PostForm.Get("repo_url")),
|
||||
RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")),
|
||||
ServerURL: s.publicURL(r),
|
||||
}
|
||||
repoPassword := r.PostForm.Get("repo_password")
|
||||
|
||||
if page.Hostname == "" {
|
||||
page.Error = "Hostname is required."
|
||||
} else if page.RepoURL == "" || repoPassword == "" {
|
||||
page.Error = "Repo URL and password are both required so the agent can back up the moment it comes online."
|
||||
}
|
||||
|
||||
if page.Error == "" {
|
||||
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword)
|
||||
switch err {
|
||||
case nil:
|
||||
page.Token = token
|
||||
page.ExpiresAt = expires
|
||||
case errMissingRepoCreds:
|
||||
page.Error = "Repo URL and password are both required."
|
||||
default:
|
||||
slog.Error("ui add_host: mint token", "err", err)
|
||||
page.Error = "Couldn’t mint a token — see the server log for details."
|
||||
}
|
||||
}
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Add host · restic-manager"
|
||||
view.Page = page
|
||||
status := stdhttp.StatusOK
|
||||
if page.Error != "" {
|
||||
status = stdhttp.StatusUnprocessableEntity
|
||||
} else {
|
||||
status = stdhttp.StatusCreated
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
|
||||
slog.Error("ui: render add_host", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publicURL is what the operator should paste into the install
|
||||
// command. Prefers RM_BASE_URL (set by the operator's reverse
|
||||
// proxy config) and falls back to scheme + Host of the inbound
|
||||
// request — useful for local smoke without a proxy.
|
||||
func (s *Server) publicURL(r *stdhttp.Request) string {
|
||||
if s.deps.Cfg.BaseURL != "" {
|
||||
return strings.TrimRight(s.deps.Cfg.BaseURL, "/")
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// userByID fetches the full store.User the UI session represents.
|
||||
// Returns the user, ok-flag, error. Used by handlers that need the
|
||||
// store-side row (e.g. for audit_log.user_id) rather than just the
|
||||
|
||||
@@ -69,14 +69,28 @@ func formatBytes(n int64) template.HTML {
|
||||
return template.HTML(fmt.Sprintf(`%s <span class="text-ink-mute text-[11px]">%s</span>`, num, unit))
|
||||
}
|
||||
|
||||
// formatRelTime renders a *time.Time as a short relative string like
|
||||
// "3m ago" / "2d ago" / "5w ago". Future times render as "in Xs".
|
||||
// Nil pointer returns "—".
|
||||
func formatRelTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
// formatRelTime renders a time as a short relative string like
|
||||
// "3m ago" / "2d ago" / "5w ago". Future times render as
|
||||
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
|
||||
// pass either without fighting Go's lack of an address-of operator.
|
||||
// Anything else returns "—".
|
||||
func formatRelTime(v any) string {
|
||||
var t time.Time
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
t = x
|
||||
case *time.Time:
|
||||
if x == nil {
|
||||
return "—"
|
||||
}
|
||||
d := time.Since(*t)
|
||||
t = *x
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
d := time.Since(t)
|
||||
suffix := "ago"
|
||||
if d < 0 {
|
||||
d = -d
|
||||
|
||||
@@ -59,7 +59,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
- [x] **P1-24** (M) Dashboard: fleet summary tiles + host table (status dot + row accent + os/arch + last backup + repo size + snapshots + alerts + tags + run-now). Backed by `GET /api/hosts` + `GET /api/fleet/summary` (JSON) and a server-rendered HTML view. Empty state hands the operator the install command. HTMX `Run now` button posts to `/hosts/{id}/run-backup`.
|
||||
- [ ] **P1-25** (M) Host detail page: snapshots tab + run-now button
|
||||
- [ ] **P1-26** (M) Live job log viewer (WS-driven, auto-scroll, cancel button)
|
||||
- [ ] **P1-27** (M) "Add host" flow: form takes hostname + repo URL/username/password, mints token (TTL 1h), shows the operator a copy-friendly install command **and** a one-click "download preconfigured installer" — a `install-<hostname>.sh` with `RM_SERVER` + `RM_TOKEN` already templated in (cf. UrBackup Internet-mode push installer). Encrypted repo creds ride on the token row and get pushed to the agent on first WS connect (see secrets/keyring task).
|
||||
- [~] **P1-27** (M) "Add host" flow: form takes hostname + repo URL/username/password, mints token (TTL 1h), re-renders the same page in result-state with the install command (`RM_SERVER` + `RM_TOKEN` filled in), copy button, and an awaiting-agent panel. Encrypted repo creds ride on the token row (P1-32) and get pushed to the agent on first WS connect (P1-33). **Deferred:** one-click "download preconfigured installer" `install-<hostname>.sh` (cf. UrBackup Internet-mode push installer) — copy-paste covers it for v1.
|
||||
- [x] **P1-28** (S) Tailwind build via `tailwindcss` standalone binary (no Node) — Makefile downloads pinned v3.4.17 into `bin/tailwindcss`, builds `web/styles/input.css` → `web/static/css/styles.css`, embedded into the binary via `web.FS`. `make build` runs Tailwind first.
|
||||
|
||||
### Install scripts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,177 @@
|
||||
{{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><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>
|
||||
<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 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">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</label>
|
||||
<input id="ah-pass" name="repo_password" type="password" class="field" required>
|
||||
<div class="field-help">Encrypted at rest using the server’s AEAD key. Pushed to the agent only over the authenticated WebSocket.</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>
|
||||
<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>
|
||||
|
||||
<div class="snippet mt-6 panel">
|
||||
<div class="snippet-head">
|
||||
<span>Install command · paste-and-run</span>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn"
|
||||
data-snippet="curl -fsSL {{$page.ServerURL}}/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} sh"
|
||||
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.sh</span> | sudo \
|
||||
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
|
||||
RM_TOKEN=<span class="var">{{$page.Token}}</span> sh</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>
|
||||
</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> 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>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user