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:
2026-05-01 20:16:54 +01:00
parent 86f7c17d9d
commit 9795492f2e
7 changed files with 344 additions and 23 deletions
+20 -6
View File
@@ -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 "—"
}
t = *x
default:
return "—"
}
d := time.Since(*t)
if t.IsZero() {
return "—"
}
d := time.Since(t)
suffix := "ago"
if d < 0 {
d = -d