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:
@@ -116,6 +116,50 @@ func (s *Store) GetEnrollmentTokenAttachments(ctx context.Context, tokenHash str
|
||||
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
|
||||
// retained for ~24h after expiry so audit traces still resolve them.
|
||||
func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) {
|
||||
|
||||
Reference in New Issue
Block a user