From 8a05969953e069b3494e08439bd2ef3d67d01268 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sat, 2 May 2026 12:59:24 +0100 Subject: [PATCH] Add-host: durable pending page + polled awaiting-agent panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/server/http/server.go | 6 + internal/server/http/ui_handlers.go | 192 ++++++++++---- internal/server/ui/ui.go | 18 ++ internal/store/enrollment.go | 44 ++++ web/static/css/styles.css | 2 +- web/templates/pages/add_host.html | 283 +++++++-------------- web/templates/pages/pending_host.html | 98 +++++++ web/templates/partials/awaiting_agent.html | 59 +++++ 8 files changed, 464 insertions(+), 238 deletions(-) create mode 100644 web/templates/pages/pending_host.html create mode 100644 web/templates/partials/awaiting_agent.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index ed8fd6f..3cf80a9 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -151,6 +151,12 @@ func (s *Server) routes(r chi.Router) { // Add host flow. r.Get("/hosts/new", s.handleUIAddHostGet) 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). r.Get("/hosts/{id}", s.handleUIHostDetail) // Schedules tab + create/edit/delete forms. diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 0f95825..5afad0c 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "io/fs" "log/slog" @@ -284,9 +285,12 @@ func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) } -// 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. +// addHostPage carries the Add-host form state. The result-state +// (showing the install command + htpasswd snippet) lives at +// /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 { // Form fields — pre-populate the form on a re-render after a // validation error. @@ -294,32 +298,22 @@ type addHostPage struct { Tags string RepoURL string RepoUsername string - // Paths is the textarea-as-typed default-paths input. One path - // per line, blanks ignored. Paths string + ServerURL string + Error 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 - - // 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. +// pendingHostPage is the GET /hosts/pending/{token} view. Lives +// for as long as the token does (1h ttl); once the agent enrols, +// the handler redirects to /hosts/{host_id} and this page is gone. +type pendingHostPage struct { + Token string + ServerURL string + ExpiresAt time.Time + RepoURL string + RepoUsername string RepoPassword string - PasswordGenerated bool + InitialPaths []string } // 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 -// (with encrypted repo creds), and re-renders the same page in -// "result" state showing the install command. +// (with encrypted repo creds), and 303-redirects to the persistent +// 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) { u := s.requireUIUser(w, r) 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." } - // 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 == "" { gen, err := generateRepoPassword() 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." } else { repoPassword = gen - page.PasswordGenerated = true } } - defaultPaths := splitPaths(page.Paths) - 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 { case nil: - page.Token = token - page.ExpiresAt = expires - page.RepoPassword = repoPassword + stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) + return case errMissingRepoCreds: page.Error = "Repo URL and password are both required." default: @@ -399,18 +387,132 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques 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) + w.WriteHeader(stdhttp.StatusUnprocessableEntity) if err := s.deps.UI.Render(w, "add_host", view); err != nil { 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. type hostDetailPage struct { Host store.Host diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 6e3192a..04ab397 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -90,6 +90,7 @@ func New() (*Renderer, error) { "templates/partials/nav.html", "templates/partials/host_row.html", "templates/partials/toast.html", + "templates/partials/awaiting_agent.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) } +// 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 // chrome-less; everything else uses the standard navigation chrome. func layoutFor(page string) string { diff --git a/internal/store/enrollment.go b/internal/store/enrollment.go index 41d7435..4a2a0ff 100644 --- a/internal/store/enrollment.go +++ b/internal/store/enrollment.go @@ -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) { diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 34129f7..c57c614 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/templates/pages/add_host.html b/web/templates/pages/add_host.html index 92e2121..2876261 100644 --- a/web/templates/pages/add_host.html +++ b/web/templates/pages/add_host.html @@ -6,211 +6,110 @@
Dashboard/Add host
- {{if eq $page.Token ""}} +

Add a host

+

+ 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. +

- {{/* ============================================================ - State A · form - ============================================================ */}} -

Add a host

-

- 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. -

- - {{if $page.Error}} -
- {{$page.Error}} -
- {{end}} - -
- -
- -

Host

-
- - -
Becomes the host’s display name. Most operators use the box’s actual hostname so logs line up.
-
-
- - -
Free-form. Used for filtering and grouping on the dashboard.
-
- -

Initial schedule · manual

-
- - -
- These paths become an initial manual schedule on the new host — manual = no cron, only fires when you click Run now. You can edit this schedule (or add automated ones alongside it) from the host's Schedules tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule. -
-
- -

Restic repository

-
- - -
Whatever restic -r would accept. Most fleets terminate at a restic/rest-server; s3: and b2: URLs work equally well.
-
-
- - -
For rest-server with htpasswd, this is the per-host user.
-
-
- - -
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 htpasswd snippet you’ll need to run on the rest-server).
-
- -
- - Cancel -
- -
- - - -
- - {{else}} - - {{/* ============================================================ - State B · token minted - ============================================================ */}} -
-

Token minted

- - expires {{relTime $page.ExpiresAt}} - + {{if $page.Error}} +
+ {{$page.Error}}
-

- Run the snippet below on the target box. The host will appear on the - dashboard within a few seconds of the agent connecting. -

+ {{end}} - {{if and $page.RepoUsername $page.RepoPassword}} -
-
- - Run on the rest-server box first - {{if $page.PasswordGenerated}} - password generated - {{end}} - · this is the only time you’ll see the password - -
- +
+ +
+ +

Host

+
+ + +
Becomes the host's display name. Most operators use the box's actual hostname so logs line up.
+
+
+ + +
Free-form. Used for filtering and grouping on the dashboard.
+
+ +

Initial schedule · manual

+
+ + +
+ These paths become an initial manual schedule on the new host — manual = no cron, only fires when you click Run now. You can edit this schedule (or add automated ones alongside it) from the host's Schedules tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
-
echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}
-
- Replace /path/to/htpasswd with whatever your restic/rest-server reads (typically the file passed via --htpasswd-file, or /data/.htpasswd in the official Docker image). The -i flag reads the password from stdin so it never appears in your shell’s process list. Then either send SIGHUP to the rest-server process or restart the container to pick up the new entry. -
-
- {{end}} -
-
- Install command · paste-and-run on the host you’re backing up -
- -
+

Restic repository

+
+ + +
Whatever restic -r would accept. Most fleets terminate at a restic/rest-server; s3: and b2: URLs work equally well.
-
curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo \
-  RM_SERVER={{$page.ServerURL}} \
-  RM_TOKEN={{$page.Token}} bash
+
+ + +
For rest-server with htpasswd, this is the per-host user.
+
+
+ + +
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).
+
+ +
+ + Cancel +
+
-
+ - - -
- - - - {{end}} +
{{end}} diff --git a/web/templates/pages/pending_host.html b/web/templates/pages/pending_host.html new file mode 100644 index 0000000..78d4d87 --- /dev/null +++ b/web/templates/pages/pending_host.html @@ -0,0 +1,98 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{$page := .Page}} +
+ +
+ Dashboard/ + Add host/ + pending +
+ +
+

Pending host

+ + expires {{relTime $page.ExpiresAt}} + +
+

+ 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. +

+ + {{if and $page.RepoUsername $page.RepoPassword}} +
+
+ + Run on the rest-server box first + · paste-and-run after replacing the htpasswd path + +
+ +
+
+
echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}
+
+ Replace /path/to/htpasswd with whatever your restic/rest-server reads (typically the file passed via --htpasswd-file, or /data/.htpasswd in the official Docker image). The -i flag reads the password from stdin so it never appears in your shell's process list. Then either send SIGHUP to the rest-server process or restart the container to pick up the new entry. +
+
+ {{end}} + +
+
+ Install command · paste-and-run on the host you're backing up +
+ +
+
+
curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo \
+  RM_SERVER={{$page.ServerURL}} \
+  RM_TOKEN={{$page.Token}} bash
+
+ +
+ +
+ {{/* Fallback content; HTMX swaps this out within 2s of load. */}} +
+
Awaiting agent connection
+
+ + checking… +
+
+
+ + + +
+ + + +
+{{end}} diff --git a/web/templates/partials/awaiting_agent.html b/web/templates/partials/awaiting_agent.html new file mode 100644 index 0000000..cd8e965 --- /dev/null +++ b/web/templates/partials/awaiting_agent.html @@ -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. +*/}} +
+ + {{if eq $page.State "connected"}} +
+
Agent connected
+
+ + {{$page.HostName}} + — enrolled, online{{if $page.LastSeenAt}}, last heartbeat {{relTime $page.LastSeenAt}}{{end}} +
+ +
+ + {{else if eq $page.State "expired"}} +
+
Token expired
+

+ The 1-hour window has elapsed without the agent connecting. + Mint a fresh token and run the new install command. +

+ +
+ + {{else}} +
+
Awaiting agent connection
+
+ + — polling every 2s; this page redirects to the host detail when enrolment lands. +
+
+
token expires {{relTime $page.ExpiresAt}}
+
awaiting POST /api/agents/enroll …
+
+
+ {{end}} + +
+{{end}}