From 229f89fee2e6616dfbeedc4f7c6336546a3dde8f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Fri, 1 May 2026 19:19:06 +0100 Subject: [PATCH] P1-23 / P1-28: base layout, login, session-aware nav + Tailwind build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-28: Tailwind standalone CLI wired into the Makefile. `make tailwind` downloads the pinned v3.4.17 binary into bin/tailwindcss (gitignored), builds web/styles/input.css → web/static/css/styles.css. `make build` now runs the CSS pass first; `make tailwind-watch` for dev. Output is embedded in the binary via web.FS — single static binary, no Node. The CSS source carries every component class the v1 mockups defined (status dots, buttons, host row, log viewer, progress bar, fields, chips, snippet panel, empty state) so screens that land later can just reach for them. P1-23: html/template tree at web/templates with two layouts (base with chrome, chromeless for login + bootstrap), one nav partial, and two pages (dashboard placeholder, login). internal/server/ui parses the tree at startup; ui_handlers.go in the http package wires: GET / dashboard (303 → /login when unauthed) GET /login sign-in form POST /login consume form, mint session cookie, 303 → / POST /logout drop cookie, 303 → /login GET /static/* embedded Tailwind bundle The HTML login flow shares store/session logic with /api/auth/login via a new authenticateAndSession helper — same security guarantees, two surface representations (HTML form / JSON). Verified end-to-end: bootstrap → form-login → authed dashboard → sign-out → 303 cycle works in the browser; Tailwind output emits only the component classes referenced in the live templates (9.6kB minified). Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 30 +++- cmd/server/main.go | 16 +- internal/server/http/auth.go | 44 +++-- internal/server/http/server.go | 22 ++- internal/server/http/ui_handlers.go | 166 ++++++++++++++++++ internal/server/ui/doc.go | 3 - internal/server/ui/ui.go | 135 +++++++++++++++ tailwind.config.js | 39 +++++ tasks.md | 4 +- web/embed.go | 11 ++ web/static/css/styles.css | 3 + web/styles/input.css | 231 ++++++++++++++++++++++++++ web/templates/layouts/base.html | 22 +++ web/templates/layouts/chromeless.html | 16 ++ web/templates/pages/dashboard.html | 24 +++ web/templates/pages/login.html | 46 +++++ web/templates/partials/nav.html | 41 +++++ 17 files changed, 823 insertions(+), 30 deletions(-) create mode 100644 internal/server/http/ui_handlers.go delete mode 100644 internal/server/ui/doc.go create mode 100644 internal/server/ui/ui.go create mode 100644 tailwind.config.js create mode 100644 web/embed.go create mode 100644 web/static/css/styles.css create mode 100644 web/styles/input.css create mode 100644 web/templates/layouts/base.html create mode 100644 web/templates/layouts/chromeless.html create mode 100644 web/templates/pages/dashboard.html create mode 100644 web/templates/pages/login.html create mode 100644 web/templates/partials/nav.html diff --git a/Makefile b/Makefile index 9af7fb1..2970715 100644 --- a/Makefile +++ b/Makefile @@ -10,17 +10,41 @@ GOFLAGS := -trimpath DOCKER_IMAGE ?= ghcr.io/dcglab/restic-manager DOCKER_TAG ?= dev -.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release +# Tailwind standalone CLI — single binary, no Node toolchain. +# See spec.md §4.1 / tasks.md P1-28 for why. +TAILWIND_VERSION ?= v3.4.17 +TAILWIND_OS := $(shell uname -s | tr A-Z a-z) +TAILWIND_ARCH := $(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') +TAILWIND_BIN := $(BIN_DIR)/tailwindcss +TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-$(TAILWIND_OS)-$(TAILWIND_ARCH) +TAILWIND_INPUT := web/styles/input.css +TAILWIND_OUTPUT := web/static/css/styles.css + +.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch help: @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' -build: server agent ## Build server + agent into ./bin +build: tailwind server agent ## Build server + agent into ./bin (incl. Tailwind CSS) server: ## Build the server binary @mkdir -p $(BIN_DIR) CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) ./cmd/server +$(TAILWIND_BIN): + @mkdir -p $(BIN_DIR) + @echo "==> downloading tailwindcss $(TAILWIND_VERSION) ($(TAILWIND_OS)/$(TAILWIND_ARCH))" + curl -fsSL -o $@ "$(TAILWIND_URL)" + chmod +x $@ + +tailwind: $(TAILWIND_BIN) ## Build the CSS bundle from web/styles/input.css + @mkdir -p $$(dirname $(TAILWIND_OUTPUT)) + $(TAILWIND_BIN) -c tailwind.config.js -i $(TAILWIND_INPUT) -o $(TAILWIND_OUTPUT) --minify + +tailwind-watch: $(TAILWIND_BIN) ## Watch and rebuild on every save + @mkdir -p $$(dirname $(TAILWIND_OUTPUT)) + $(TAILWIND_BIN) -c tailwind.config.js -i $(TAILWIND_INPUT) -o $(TAILWIND_OUTPUT) --watch + agent: ## Build the agent binary @mkdir -p $(BIN_DIR) CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(AGENT_BIN) ./cmd/agent @@ -42,7 +66,7 @@ tidy: ## go mod tidy go mod tidy clean: ## Remove build artifacts - rm -rf $(BIN_DIR) coverage.out coverage.html + rm -rf $(BIN_DIR) coverage.out coverage.html $(TAILWIND_OUTPUT) run-server: server ## Build and run the server $(SERVER_BIN) diff --git a/cmd/server/main.go b/cmd/server/main.go index e2bc71c..d74b7df 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -79,11 +80,18 @@ func run() error { hub := ws.NewHub() + renderer, err := ui.New() + if err != nil { + return fmt.Errorf("ui: %w", err) + } + deps := rmhttp.Deps{ - Cfg: cfg, - Store: st, - AEAD: aead, - Hub: hub, + Cfg: cfg, + Store: st, + AEAD: aead, + Hub: hub, + UI: renderer, + Version: version, } // First-run bootstrap: if the users table is empty, mint a one-time diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 981a556..6c0fc2e 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -34,23 +34,35 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } - - u, err := s.deps.Store.GetUserByUsername(r.Context(), req.Username) + u, err := s.authenticateAndSession(w, r, req.Username, req.Password) if err != nil { - // Same response for unknown user vs bad password — don't leak - // existence to a probing attacker. writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "") return } - if err := auth.VerifyPassword(u.PasswordHash, req.Password); err != nil { - writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "") - return + writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)}) +} + +// authenticateAndSession verifies credentials, mints a session cookie, +// records the login + audit, and returns the user. Any failure +// (unknown user, wrong password, db error) is collapsed into a single +// error — the caller decides how to surface it. Shared by JSON and +// HTML login flows. +func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Request, + username, password string, +) (*store.User, error) { + u, err := s.deps.Store.GetUserByUsername(r.Context(), username) + if err != nil { + // Same response for unknown user vs bad password — don't leak + // existence to a probing attacker. + return nil, errInvalidCredentials + } + if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { + return nil, errInvalidCredentials } token, err := auth.NewToken() if err != nil { - writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") - return + return nil, err } now := time.Now().UTC() sess := store.Session{ @@ -61,8 +73,7 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { UA: r.UserAgent(), } if err := s.deps.Store.CreateSession(r.Context(), sess, auth.HashToken(token)); err != nil { - writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") - return + return nil, err } _ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now) @@ -83,10 +94,17 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { Action: "auth.login", TS: now, }) - - writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)}) + return u, nil } +// errInvalidCredentials is the sentinel returned by +// authenticateAndSession for any failure that maps to a 401 in HTTP. +var errInvalidCredentials = errAuth("invalid_credentials") + +type errAuth string + +func (e errAuth) Error() string { return string(e) } + func (s *Server) handleLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) { if c, err := r.Cookie(sessionCookieName); err == nil { _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 132ed08..2599945 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -6,7 +6,6 @@ package http import ( "context" "errors" - "fmt" stdhttp "net/http" "time" @@ -15,6 +14,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -26,6 +26,10 @@ type Deps struct { Store *store.Store AEAD *crypto.AEAD Hub *ws.Hub + UI *ui.Renderer + // Version is the binary's build version, surfaced in the chrome. + // Empty falls back to "dev". + Version string // BootstrapToken (optional, populated only on first run) is the raw // admin-bootstrap token printed in the server logs. While set, the // /bootstrap endpoint accepts it to create the first admin user. @@ -112,10 +116,18 @@ func (s *Server) routes(r chi.Router) { r.Get("/agent/binary", s.handleAgentBinary) r.Get("/install/*", s.handleInstallAsset) - // UI handlers will hang off / — Phase 1 will add them. - r.Get("/", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { - _, _ = fmt.Fprint(w, "restic-manager — UI not yet implemented") - }) + // Static assets (Tailwind CSS bundle, future favicon). + r.Mount("/static/", staticHandler()) + + // HTML UI. The renderer is required — fail loud if the binary + // was built without templates (impossible in practice given + // embed, but guards bad test wiring). + if s.deps.UI != nil { + r.Get("/", s.handleUIDashboard) + r.Get("/login", s.handleUILoginGet) + r.Post("/login", s.handleUILoginPost) + r.Post("/logout", s.handleUILogoutPost) + } } // Start begins listening. Blocks until ListenAndServe returns diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go new file mode 100644 index 0000000..40cc729 --- /dev/null +++ b/internal/server/http/ui_handlers.go @@ -0,0 +1,166 @@ +package http + +import ( + "errors" + "io/fs" + "log/slog" + stdhttp "net/http" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" + "gitea.dcglab.co.uk/steve/restic-manager/web" +) + +// ----- static assets (Tailwind CSS, future favicon, etc) ------------- + +// staticHandler serves files embedded under web/static/ at /static/*. +// Returns 404 for anything missing rather than the fs default 500. +func staticHandler() stdhttp.Handler { + sub, err := fs.Sub(web.FS, "static") + if err != nil { + // Embed.FS panics live at compile time; if Sub fails the binary + // is genuinely broken — surface it loudly. + panic("web: static subtree missing: " + err.Error()) + } + return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub))) +} + +// ----- session helpers ------------------------------------------------ + +// sessionUser resolves the request's session cookie to a User, or +// (nil, nil) if the cookie is missing/expired/invalid. A non-nil +// error means an underlying store failure; treat that as 500. +func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, nil + } + sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) + if err != nil { + // Treat "not found" / "expired" as "no session", not as fatal. + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + return nil, err + } + u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + return nil, err + } + return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil +} + +// requireUIUser resolves the session and 303-redirects to /login if +// there isn't one. Returns nil + emits the redirect when unauthed. +// (HTML twin of jobs.go's API-style requireUser, which returns 401.) +func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User { + u, err := s.sessionUser(r) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return nil + } + if u == nil { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return nil + } + return u +} + +// baseView populates the fields the nav partial needs on every +// authenticated page. +func (s *Server) baseView(u *ui.User, active string) ui.ViewData { + return ui.ViewData{ + User: u, + Active: active, + Version: s.version(), + } +} + +// version returns the binary's build version — passed in via Deps so +// cmd/server's `var version` ends up here. +func (s *Server) version() string { + if s.deps.Version != "" { + return s.deps.Version + } + return "dev" +} + +// ----- handlers ------------------------------------------------------- + +// handleUIDashboard is the root page. Auth-gated; falls through to +// /login if there is no session. +func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + view := s.baseView(u, "dashboard") + if err := s.deps.UI.Render(w, "dashboard", view); err != nil { + slog.Error("ui: render dashboard", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +// handleUILoginGet renders the login form. If the user is already +// signed in we redirect them home — login is for the unauthenticated. +func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if u, _ := s.sessionUser(r); u != nil { + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) + return + } + view := ui.ViewData{Version: s.version()} + if err := s.deps.UI.Render(w, "login", view); err != nil { + slog.Error("ui: render login", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +// handleUILoginPost consumes the form, validates, mints a session, +// and either redirects to / on success or re-renders the form with +// an error banner on failure. +func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + + if _, err := s.authenticateAndSession(w, r, username, password); err != nil { + // Re-render the form. Single generic message — see + // authenticateAndSession's note on not leaking user existence. + view := ui.ViewData{ + Version: s.version(), + Username: username, + Error: "Invalid username or password.", + } + w.WriteHeader(stdhttp.StatusUnauthorized) + if err := s.deps.UI.Render(w, "login", view); err != nil { + slog.Error("ui: render login (post-fail)", "err", err) + } + return + } + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} + +// handleUILogoutPost is the form-submit twin of /api/auth/logout. It +// drops the session cookie and redirects to /login. +func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil { + _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) + } + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.deps.Cfg.CookieSecure, + SameSite: stdhttp.SameSiteLaxMode, + }) + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) +} diff --git a/internal/server/ui/doc.go b/internal/server/ui/doc.go deleted file mode 100644 index 04a068c..0000000 --- a/internal/server/ui/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package ui renders the HTMX/Tailwind frontend from server-side -// html/templates. -package ui diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go new file mode 100644 index 0000000..b17bced --- /dev/null +++ b/internal/server/ui/ui.go @@ -0,0 +1,135 @@ +// Package ui renders the HTMX/Tailwind frontend from server-side +// html/templates. Templates live under web/templates and are +// embedded into the binary via web.FS. +// +// Lifecycle: +// - At startup, parse every layout, partial, and page into a +// single *template.Template tree. +// - To render a page, call Render(w, "", ViewData{...}). +// Render walks the page's template definitions (which override +// the {{block "content"}} / {{block "title"}} placeholders in +// the chosen layout) and writes the result. +package ui + +import ( + "fmt" + "html/template" + "io" + "io/fs" + "path" + "strings" + + "gitea.dcglab.co.uk/steve/restic-manager/web" +) + +// ViewData is the common frame every template renders against. +// Page handlers populate Page with their own concrete shape and the +// renderer wraps it. +type ViewData struct { + // Title is rendered in ; layouts/base default to + // "restic-manager" if absent. Pages that {{define "title"}} win. + Title string + + // User is the currently signed-in user, or nil if the session + // cookie is missing/invalid. The nav uses this to decide + // whether to show "Sign out" or "Sign in". + User *User + + // Active is the slug of the currently active primary nav tab + // ("dashboard" / "repos" / "alerts" / "audit" / "settings"). + // The nav partial highlights the matching tab. + Active string + + // OpenAlerts is shown next to the Alerts tab when > 0. + OpenAlerts int + + // Version is the build version printed in the top-right of the + // chrome. Falls back to "dev" if the binary wasn't built with + // -ldflags -X main.version=… + Version string + + // Username pre-fills the login form on a re-render after a bad + // attempt. Login-only. + Username string + + // Error is a single banner-level error string. Login uses it + // today; other pages can adopt the same field. + Error string + + // Page carries page-specific data. Concrete type is the page's + // own struct. + Page any +} + +// User is the minimal projection of the authenticated user that the +// templates need. Avoids leaking store internals into the view. +type User struct { + ID string + Username string + Role string +} + +// Renderer holds the parsed templates. +type Renderer struct { + pages map[string]*template.Template +} + +// New parses every layout, partial, and page from web.FS into one +// template tree per page. Pages associate with a layout via the +// path under templates/pages/: anything at templates/pages/login.html +// wraps in templates/layouts/chromeless.html, everything else wraps +// in templates/layouts/base.html. +// +// Returns an error if any template fails to parse — fail loud at +// startup, not at request time. +func New() (*Renderer, error) { + // All layouts + partials are shared. + commonPaths := []string{ + "templates/layouts/base.html", + "templates/layouts/chromeless.html", + "templates/partials/nav.html", + } + + pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") + if err != nil { + return nil, fmt.Errorf("ui: glob pages: %w", err) + } + if len(pageEntries) == 0 { + return nil, fmt.Errorf("ui: no pages found under templates/pages/") + } + + r := &Renderer{pages: make(map[string]*template.Template, len(pageEntries))} + for _, p := range pageEntries { + base := strings.TrimSuffix(path.Base(p), ".html") + t, err := template.New(base).ParseFS(web.FS, append(append([]string{}, commonPaths...), p)...) + if err != nil { + return nil, fmt.Errorf("ui: parse %s: %w", p, err) + } + r.pages[base] = t + } + return r, nil +} + +// Render writes the named page (e.g. "dashboard", "login") to w, +// wrapped in the appropriate layout. layoutFor decides which. +func (r *Renderer) Render(w io.Writer, page string, data ViewData) error { + t, ok := r.pages[page] + if !ok { + return fmt.Errorf("ui: unknown page %q", page) + } + if data.Version == "" { + data.Version = "dev" + } + return t.ExecuteTemplate(w, layoutFor(page), 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 { + switch page { + case "login", "bootstrap": + return "chromeless" + default: + return "base" + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..86a8218 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,39 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + // Source files Tailwind scans for class usage. Keep this list tight — + // anything outside it produces zero CSS. + content: [ + './web/templates/**/*.html', + ], + theme: { + extend: { + fontFamily: { + // Used by `class="font-sans"` / `class="font-mono"`. Most code + // reaches for the explicit `.mono` component class instead. + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], + }, + colors: { + // Semantic tokens — match :root in web/styles/input.css. + // Tailwind utilities like `text-ink`, `bg-panel` resolve here. + 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: { + DEFAULT: 'oklch(0.96 0.005 250)', + mid: 'oklch(0.78 0.005 250)', + mute: 'oklch(0.58 0.006 250)', + 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)', + }, + }, + }, + plugins: [], +}; diff --git a/tasks.md b/tasks.md index 4826609..45b6e8e 100644 --- a/tasks.md +++ b/tasks.md @@ -55,12 +55,12 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. ### UI (HTMX + Tailwind) -- [ ] **P1-23** (M) Base layout, login page, session-aware nav +- [x] **P1-23** (M) Base layout, login page, session-aware nav - [ ] **P1-24** (M) Dashboard: host cards (status dot, last backup, repo size) - [ ] **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-28** (S) Tailwind build via `tailwindcss` standalone binary (no Node) +- [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 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..43d63dc --- /dev/null +++ b/web/embed.go @@ -0,0 +1,11 @@ +// Package web embeds the html/template tree and the static assets +// (compiled Tailwind CSS) so the server is a single static binary. +// +// Templates live at templates/, static at static/. Both trees are +// served by internal/server/ui and internal/server/http respectively. +package web + +import "embed" + +//go:embed templates/* static/* +var FS embed.FS diff --git a/web/static/css/styles.css b/web/static/css/styles.css new file mode 100644 index 0000000..5610986 --- /dev/null +++ b/web/static/css/styles.css @@ -0,0 +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%)}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}@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-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,.sub-tab.active{color:var(--ink)}.sub-tab.active{border-color:var(--ink)}.field-label{color:var(--ink-mid);display:block;font-size:12px;margin-bottom: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.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.progress-fill.bad{background:var(--bad)}.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}.mx-auto{margin-left:auto;margin-right:auto}.mb-10{margin-bottom:2.5rem}.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\.5{margin-left:.375rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.min-h-screen{min-height:100vh}.w-\[360px\]{width:360px}.max-w-\[1280px\]{max-width:1280px}.max-w-\[520px\]{max-width:520px}.flex-1{flex:1 1 0%}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-5{gap:1.25rem}.text-pretty{text-wrap:pretty}.rounded-\[5px\]{border-radius:5px}.border{border-width:1px}.border-t{border-top-width:1px}.border-line-soft{border-color:oklch(.23 .008 250)}.px-3{padding-left:.75rem;padding-right:.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-4{padding-bottom:1rem;padding-top:1rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.text-center{text-align:center}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.leading-\[1\.65\]{line-height:1.65}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.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)}.no-underline{text-decoration-line:none} \ No newline at end of file diff --git a/web/styles/input.css b/web/styles/input.css new file mode 100644 index 0000000..c25226d --- /dev/null +++ b/web/styles/input.css @@ -0,0 +1,231 @@ +/* ============================================================ + * v1 design tokens + components. + * + * Source of truth for the operator-console register. Anything not + * defined here doesn't exist in v1. New components get added here + * first, then templated. + * + * Built via the Tailwind standalone CLI (no Node): + * make tailwind + * outputs: + * web/static/css/styles.css + * ============================================================ */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* surface */ + --bg: oklch(0.17 0.006 250); + --panel: oklch(0.20 0.007 250); + --panel-hi: oklch(0.23 0.008 250); + + /* line */ + --line: oklch(0.27 0.010 250); + --line-soft: oklch(0.23 0.008 250); + + /* ink */ + --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); + + /* state */ + --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 */ + --accent: oklch(0.82 0.12 195); + } + + html, body { + 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'; + } + ::selection { background: color-mix(in oklch, var(--accent), transparent 70%); } +} + +@layer components { + /* ---------- typography helpers ---------- */ + .mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-variant-numeric: tabular-nums; + } + + /* ---------- surface helpers ---------- */ + .panel { background: var(--panel); border: 1px solid var(--line-soft); } + .hairline { box-shadow: inset 0 -1px 0 var(--line-soft); } + + /* ---------- status dots ---------- */ + .dot { width: 7px; height: 7px; border-radius: 9999px; display: inline-block; } + .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%, 100% { 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%); } + } + + /* ---------- buttons ---------- */ + .btn { + font-size: 12px; font-weight: 500; + padding: 6px 11px; border-radius: 5px; + background: transparent; + border: 1px solid var(--line); + color: var(--ink-mid); + transition: all 120ms ease; + cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + text-decoration: none; + } + .btn:hover { background: var(--panel-hi); color: var(--ink); } + .btn:disabled, .btn[disabled] { opacity: 0.4; cursor: not-allowed; pointer-events: none; } + .btn-primary { color: oklch(0.18 0.01 195); background: var(--accent); border-color: var(--accent); } + .btn-primary:hover { filter: brightness(1.08); } + .btn-ghost { border-color: transparent; } + .btn-ghost:hover { background: var(--panel-hi); border-color: transparent; } + .btn-danger { color: var(--bad); border-color: color-mix(in oklch, var(--bad), transparent 70%); } + .btn-danger:hover { + background: color-mix(in oklch, var(--bad), transparent 88%); + border-color: color-mix(in oklch, var(--bad), transparent 50%); + color: oklch(0.85 0.10 25); + } + .btn-lg { font-size: 13px; padding: 9px 14px; } + .btn-block { width: 100%; justify-content: center; } + + /* ---------- nav tabs ---------- */ + .nav-tab { + font-size: 13px; padding: 18px 0; + color: var(--ink-mute); + border-bottom: 2px solid transparent; + margin-right: 28px; + cursor: pointer; + text-decoration: none; + } + .nav-tab.active { color: var(--ink); border-color: var(--accent); } + .nav-tab:hover { color: var(--ink); } + + /* secondary tabs (host detail sub-nav) */ + .sub-tab { + font-size: 13px; padding: 12px 0; + color: var(--ink-mute); + border-bottom: 1.5px solid transparent; + margin-right: 24px; + cursor: pointer; + text-decoration: none; + } + .sub-tab.active { color: var(--ink); border-color: var(--ink); } + + /* ---------- tags ---------- */ + .tag { + display: inline-flex; align-items: center; gap: 5px; + font-size: 11px; line-height: 1; padding: 4px 7px; + border: 1px solid var(--line); color: var(--ink-mid); + border-radius: 3px; letter-spacing: 0.01em; + } + .tag-removable .x { color: var(--ink-fade); cursor: pointer; padding-left: 2px; } + + /* ---------- form fields ---------- */ + .field-label { font-size: 12px; color: var(--ink-mid); margin-bottom: 6px; display: block; } + .field-help { font-size: 12px; color: var(--ink-mute); margin-top: 6px; line-height: 1.55; } + .field-error { font-size: 12px; color: oklch(0.85 0.10 25); margin-top: 6px; } + .field { + width: 100%; padding: 9px 12px; + background: var(--bg); border: 1px solid var(--line-soft); + color: var(--ink); border-radius: 5px; + font-size: 13px; font-family: inherit; outline: none; + transition: border-color 120ms ease; + } + .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; } + .field-prefix { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + font-family: 'JetBrains Mono', monospace; font-size: 12px; + color: var(--ink-mute); pointer-events: none; + } + + /* ---------- host row (the dashboard's load-bearing component) ---------- */ + .host-row { + display: grid; align-items: center; + grid-template-columns: 24px 1.4fr 0.95fr 1.5fr 0.75fr 0.7fr 0.7fr 1.1fr 92px; + padding: 11px 16px; font-size: 13px; + border-left: 3px solid transparent; + } + .host-row.head { + padding-top: 10px; padding-bottom: 10px; + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.08em; + } + .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); } + + /* ---------- log viewer ---------- */ + .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 { display: grid; grid-template-columns: 14ch 8ch 1fr; column-gap: 14px; padding: 1px 16px; align-items: baseline; } + .log-line:first-child { padding-top: 12px; } + .log-line:last-child { padding-bottom: 12px; } + .log-ts { color: var(--ink-fade); } + .log-tag { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-fade); } + .log-stream-stdout { color: var(--ink-mid); } + .log-stream-stderr { color: oklch(0.78 0.13 50); } + .log-stream-event { color: var(--accent); } + + /* ---------- progress bar ---------- */ + .progress-track { + background: var(--bg); border: 1px solid var(--line-soft); + height: 6px; border-radius: 9999px; overflow: hidden; + } + .progress-fill { height: 100%; background: var(--accent); border-radius: 9999px; transition: width 250ms ease; } + .progress-fill.ok { background: var(--ok); } + .progress-fill.bad { background: var(--bad); } + + /* ---------- crumbs ---------- */ + .crumbs { font-size: 12px; color: var(--ink-mute); } + .crumbs a { color: var(--ink-mute); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line); } + .crumbs .sep { color: var(--ink-fade); margin: 0 8px; } + + /* ---------- install snippet ---------- */ + .snippet { border: 1px solid var(--line-soft); border-radius: 6px; overflow: hidden; } + .snippet-head { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 14px; border-bottom: 1px solid var(--line-soft); + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.1em; + } + .snippet pre { + margin: 0; padding: 14px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; color: var(--ink-mid); line-height: 1.7; + white-space: pre-wrap; word-break: break-all; + } + .snippet pre .var { color: var(--accent); } + + /* ---------- empty state ---------- */ + .empty-state { + text-align: center; padding: 60px 40px; + border: 1px dashed var(--line); border-radius: 8px; + background: + radial-gradient(ellipse at top, color-mix(in oklch, var(--accent), transparent 95%), transparent 60%), + var(--panel); + } +} diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html new file mode 100644 index 0000000..216a5df --- /dev/null +++ b/web/templates/layouts/base.html @@ -0,0 +1,22 @@ +{{define "base"}}<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{block "title" .}}restic-manager{{end}} + + + + + + + + {{template "nav" .}} + +
+ {{block "content" .}}{{end}} +
+ + + +{{end}} diff --git a/web/templates/layouts/chromeless.html b/web/templates/layouts/chromeless.html new file mode 100644 index 0000000..024bffa --- /dev/null +++ b/web/templates/layouts/chromeless.html @@ -0,0 +1,16 @@ +{{define "chromeless"}} + + + + + {{block "title" .}}restic-manager{{end}} + + + + + + + {{block "content" .}}{{end}} + + +{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..89b6664 --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,24 @@ +{{define "title"}}Dashboard · restic-manager{{end}} + +{{define "content"}} +
+ + +
+

Dashboard

+

+ Base layout + Tailwind build wired through (P1-23 / P1-28). The fleet + summary, host table and recent activity strip land with + P1-24; until then this screen exists + to prove the chrome renders. +

+ +
+ +
+{{end}} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..0e8abf2 --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,46 @@ +{{define "title"}}Sign in · restic-manager{{end}} + +{{define "content"}} +
+ +
+
+
restic-manager
+
+ +

Sign in to continue

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ +
+

+ Forgot your password? An admin can reset it from + Settings → Users. + There’s no recovery email — this is self-hosted infrastructure. +

+
+
+ +
+ restic-manager {{.Version}} +
+ +
+{{end}} diff --git a/web/templates/partials/nav.html b/web/templates/partials/nav.html new file mode 100644 index 0000000..4a75dbc --- /dev/null +++ b/web/templates/partials/nav.html @@ -0,0 +1,41 @@ +{{define "nav"}} +
+ +
+
+
+ restic-manager + {{.Version}} +
+
+ {{if .User}} + {{.User.Username}} +
+ +
+ {{else}} + Sign in + {{end}} +
+
+
+ + +
+ +
+
+{{end}}