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