git ignore
This commit is contained in:
commit
3d302ee541
|
|
@ -0,0 +1,418 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"satoru/internal/store"
|
||||||
|
"satoru/internal/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "satoru_session"
|
||||||
|
sessionTTL = 24 * time.Hour * 14
|
||||||
|
)
|
||||||
|
|
||||||
|
type app struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join("data", "satoru.db")
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
a := &app{store: st}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
fileServer := http.FileServer(http.Dir("web/static"))
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
|
||||||
|
|
||||||
|
r.Get("/", a.handleHome)
|
||||||
|
r.Get("/account/password", a.handlePasswordPage)
|
||||||
|
r.Post("/account/password", a.handlePasswordSubmit)
|
||||||
|
r.Post("/sites", a.handleSiteCreate)
|
||||||
|
r.Post("/sites/{id}/run", a.handleSiteRun)
|
||||||
|
r.Get("/signup", a.handleSignupPage)
|
||||||
|
r.Post("/signup", a.handleSignupSubmit)
|
||||||
|
r.Get("/signin", a.handleSigninPage)
|
||||||
|
r.Post("/signin", a.handleSigninSubmit)
|
||||||
|
r.Post("/signout", a.handleSignoutSubmit)
|
||||||
|
|
||||||
|
addr := ":8080"
|
||||||
|
log.Printf("satoru listening on http://localhost%s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, r); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := a.currentUser(r.Context(), r)
|
||||||
|
if err != nil {
|
||||||
|
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sites, err := a.store.ListSites(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to load sites", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := webui.DashboardData{
|
||||||
|
Now: time.Now(),
|
||||||
|
User: user,
|
||||||
|
Sites: sites,
|
||||||
|
RuntimeChecks: runtimeChecks(),
|
||||||
|
FlashMessage: r.URL.Query().Get("msg"),
|
||||||
|
WorkflowStages: defaultWorkflowStages(),
|
||||||
|
}
|
||||||
|
templ.Handler(webui.Dashboard(data)).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := a.currentUser(r.Context(), r); err == nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templ.Handler(webui.Signup(webui.AuthPageData{})).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSignupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := normalizeUsername(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
form := webui.AuthPageData{Username: username}
|
||||||
|
if !validUsername(username) {
|
||||||
|
form.Error = "Username must be 3-32 chars using letters, numbers, ., _, or -."
|
||||||
|
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(password) < 8 {
|
||||||
|
form.Error = "Password must be at least 8 characters."
|
||||||
|
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to create account", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := a.store.CreateUser(r.Context(), username, string(hashBytes))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrUsernameTaken) {
|
||||||
|
form.Error = "That username is already registered."
|
||||||
|
templ.Handler(webui.Signup(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "failed to create account", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||||
|
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := a.currentUser(r.Context(), r); err == nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templ.Handler(webui.Signin(webui.AuthPageData{})).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSigninSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := normalizeUsername(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
form := webui.AuthPageData{Username: username}
|
||||||
|
|
||||||
|
user, err := a.store.UserByUsername(r.Context(), username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
form.Error = "Invalid username or password."
|
||||||
|
templ.Handler(webui.Signin(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "failed to sign in", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
form.Error = "Invalid username or password."
|
||||||
|
templ.Handler(webui.Signin(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||||
|
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSignoutSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err == nil && c.Value != "" {
|
||||||
|
_ = a.store.DeleteSessionByTokenHash(r.Context(), hashToken(c.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := a.currentUser(r.Context(), r)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
templ.Handler(webui.ChangePassword(webui.PasswordPageData{User: user})).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := a.currentUser(r.Context(), r)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current := r.FormValue("current_password")
|
||||||
|
next := r.FormValue("new_password")
|
||||||
|
confirm := r.FormValue("confirm_password")
|
||||||
|
|
||||||
|
form := webui.PasswordPageData{User: user}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||||
|
form.Error = "Current password is incorrect."
|
||||||
|
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(next) < 8 {
|
||||||
|
form.Error = "New password must be at least 8 characters."
|
||||||
|
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next != confirm {
|
||||||
|
form.Error = "New password and confirmation do not match."
|
||||||
|
templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashBytes, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to update password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.UpdateUserPasswordHash(r.Context(), user.ID, string(hashBytes)); err != nil {
|
||||||
|
http.Error(w, "failed to update password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/?msg=password-updated", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := a.currentUser(r.Context(), r); err != nil {
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
|
||||||
|
host := strings.TrimSpace(r.FormValue("host"))
|
||||||
|
remotePath := strings.TrimSpace(r.FormValue("remote_path"))
|
||||||
|
if sshUser == "" || host == "" || remotePath == "" {
|
||||||
|
http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.store.CreateSite(r.Context(), sshUser, host, remotePath); err != nil {
|
||||||
|
http.Error(w, "failed to add site", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := a.currentUser(r.Context(), r); err != nil {
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid site id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := a.store.SiteByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "failed to load site", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, output := runSSHHello(r.Context(), site)
|
||||||
|
if err := a.store.UpdateSiteRunResult(r.Context(), site.ID, status, output, time.Now()); err != nil {
|
||||||
|
http.Error(w, "failed to store run result", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?msg=site-ran", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||||
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(sessionTTL)
|
||||||
|
if err := a.store.CreateSession(r.Context(), userID, hashToken(token), expiresAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
Expires: expiresAt,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, error) {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return store.User{}, http.ErrNoCookie
|
||||||
|
}
|
||||||
|
return a.store.UserBySessionTokenHash(ctx, hashToken(c.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
var usernamePattern = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`)
|
||||||
|
|
||||||
|
func normalizeUsername(v string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUsername(v string) bool {
|
||||||
|
return usernamePattern.MatchString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeChecks() []webui.RuntimeCheck {
|
||||||
|
tools := []string{"restic", "rsync", "ssh"}
|
||||||
|
out := make([]webui.RuntimeCheck, 0, len(tools))
|
||||||
|
for _, name := range tools {
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: path})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSSHHello(ctx context.Context, site store.Site) (string, string) {
|
||||||
|
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(cmdCtx, "ssh", target, "echo hello from satoru")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := strings.TrimSpace(string(out))
|
||||||
|
if output == "" {
|
||||||
|
output = "(no output)"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "failed", output
|
||||||
|
}
|
||||||
|
return "ok", output
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultWorkflowStages() []webui.WorkflowStage {
|
||||||
|
return []webui.WorkflowStage{
|
||||||
|
{Title: "Pull from Edge over SSH", Description: "Satoru connects to Linux edge hosts using local keys and pulls approved paths."},
|
||||||
|
{Title: "Stage on Backup Server", Description: "Pulled data lands on the backup host first, keeping edge systems isolated from B2."},
|
||||||
|
{Title: "Restic to B2", Description: "Restic runs centrally on this Satoru instance and uploads snapshots to Backblaze B2."},
|
||||||
|
{Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
module satoru
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.977
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
|
modernc.org/sqlite v1.44.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||||
|
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||||
|
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Site struct {
|
||||||
|
ID int64
|
||||||
|
SSHUser string
|
||||||
|
Host string
|
||||||
|
RemotePath string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastRunStatus sql.NullString
|
||||||
|
LastRunOutput sql.NullString
|
||||||
|
LastRunAt sql.NullTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Store{db: db}
|
||||||
|
if err := s.migrate(context.Background()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate(ctx context.Context) error {
|
||||||
|
const usersSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`
|
||||||
|
|
||||||
|
const sessionsSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);`
|
||||||
|
|
||||||
|
const sitesSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ssh_user TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
remote_path TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_run_status TEXT,
|
||||||
|
last_run_output TEXT,
|
||||||
|
last_run_at DATETIME
|
||||||
|
);`
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, usersSQL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.migrateUsersLegacyEmail(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrateUsersLegacyEmail(ctx context.Context) error {
|
||||||
|
cols, err := tableColumns(ctx, s.db, "users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cols["username"] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN username TEXT`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cols["email"] {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `UPDATE users SET username = lower(trim(email)) WHERE username IS NULL`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM users`).Scan(&count); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
isAdmin := count == 0
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`,
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
boolToInt(isAdmin),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueUsernameErr(err) {
|
||||||
|
return User{}, ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userByIDTx(ctx, tx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, username, password_hash, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?`
|
||||||
|
return scanUser(s.db.QueryRowContext(ctx, q, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserBySessionTokenHash(ctx context.Context, tokenHash string) (User, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ? AND s.expires_at > CURRENT_TIMESTAMP`
|
||||||
|
return scanUser(s.db.QueryRowContext(ctx, q, tokenHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
userID,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt.UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string) (Site, error) {
|
||||||
|
res, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO sites (ssh_user, host, remote_path) VALUES (?, ?, ?)`,
|
||||||
|
sshUser,
|
||||||
|
host,
|
||||||
|
remotePath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
return s.SiteByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
||||||
|
FROM sites
|
||||||
|
ORDER BY id DESC`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Site
|
||||||
|
for rows.Next() {
|
||||||
|
site, err := scanSite(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, site)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
||||||
|
FROM sites
|
||||||
|
WHERE id = ?`
|
||||||
|
return scanSite(s.db.QueryRowContext(ctx, q, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sites SET last_run_status = ?, last_run_output = ?, last_run_at = ? WHERE id = ?`,
|
||||||
|
status,
|
||||||
|
output,
|
||||||
|
at.UTC().Format(time.RFC3339),
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, username, password_hash, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?`
|
||||||
|
return scanUser(tx.QueryRowContext(ctx, q, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row scanner) (User, error) {
|
||||||
|
var user User
|
||||||
|
var isAdmin int
|
||||||
|
if err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &isAdmin, &user.CreatedAt); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user.IsAdmin = isAdmin == 1
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSite(row scanner) (Site, error) {
|
||||||
|
var site Site
|
||||||
|
if err := row.Scan(
|
||||||
|
&site.ID,
|
||||||
|
&site.SSHUser,
|
||||||
|
&site.Host,
|
||||||
|
&site.RemotePath,
|
||||||
|
&site.CreatedAt,
|
||||||
|
&site.LastRunStatus,
|
||||||
|
&site.LastRunOutput,
|
||||||
|
&site.LastRunAt,
|
||||||
|
); err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
return site, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUsernameTaken = errors.New("username already registered")
|
||||||
|
|
||||||
|
func isUniqueUsernameErr(err error) bool {
|
||||||
|
return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name string
|
||||||
|
var typ string
|
||||||
|
var notNull int
|
||||||
|
var dflt sql.NullString
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cols[name] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cols, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthPageData struct {
|
||||||
|
Username string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Signup(data AuthPageData) templ.Component {
|
||||||
|
return authPage(
|
||||||
|
"Sign up",
|
||||||
|
"Create your account",
|
||||||
|
"First account created becomes the initial admin.",
|
||||||
|
"/signup",
|
||||||
|
"Create account",
|
||||||
|
"/signin",
|
||||||
|
"Already have an account? Sign in",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Signin(data AuthPageData) templ.Component {
|
||||||
|
return authPage(
|
||||||
|
"Sign in",
|
||||||
|
"Welcome back",
|
||||||
|
"Sign in to manage backup infrastructure.",
|
||||||
|
"/signin",
|
||||||
|
"Sign in",
|
||||||
|
"/signup",
|
||||||
|
"Need an account? Sign up",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authPage(
|
||||||
|
title string,
|
||||||
|
heading string,
|
||||||
|
description string,
|
||||||
|
action string,
|
||||||
|
submitLabel string,
|
||||||
|
switchHref string,
|
||||||
|
switchLabel string,
|
||||||
|
data AuthPageData,
|
||||||
|
) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||||
|
errBlock := ""
|
||||||
|
if data.Error != "" {
|
||||||
|
errBlock = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(data.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>%s · Satoru</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card auth-card">
|
||||||
|
<p class="eyebrow">Satoru</p>
|
||||||
|
<h1>%s</h1>
|
||||||
|
<p class="muted">%s</p>
|
||||||
|
%s
|
||||||
|
<form class="stack" method="post" action="%s">
|
||||||
|
<label class="stack">
|
||||||
|
<span>Username</span>
|
||||||
|
<input type="text" name="username" value="%s" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">%s</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted"><a href="%s">%s</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(title),
|
||||||
|
html.EscapeString(heading),
|
||||||
|
html.EscapeString(description),
|
||||||
|
errBlock,
|
||||||
|
html.EscapeString(action),
|
||||||
|
html.EscapeString(data.Username),
|
||||||
|
html.EscapeString(submitLabel),
|
||||||
|
html.EscapeString(switchHref),
|
||||||
|
html.EscapeString(switchLabel),
|
||||||
|
))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
|
"satoru/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeCheck struct {
|
||||||
|
Name string
|
||||||
|
Installed bool
|
||||||
|
Details string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowStage struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardData struct {
|
||||||
|
Now time.Time
|
||||||
|
User store.User
|
||||||
|
Sites []store.Site
|
||||||
|
RuntimeChecks []RuntimeCheck
|
||||||
|
WorkflowStages []WorkflowStage
|
||||||
|
FlashMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dashboard(data DashboardData) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||||
|
role := "Operator"
|
||||||
|
if data.User.IsAdmin {
|
||||||
|
role = "Admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
var checks strings.Builder
|
||||||
|
for _, c := range data.RuntimeChecks {
|
||||||
|
status := "missing"
|
||||||
|
if c.Installed {
|
||||||
|
status = "installed"
|
||||||
|
}
|
||||||
|
checks.WriteString(fmt.Sprintf(`<li class="status %s"><strong>%s</strong><span>%s</span></li>`,
|
||||||
|
status,
|
||||||
|
html.EscapeString(c.Name),
|
||||||
|
html.EscapeString(c.Details)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var flows strings.Builder
|
||||||
|
for _, s := range data.WorkflowStages {
|
||||||
|
flows.WriteString(fmt.Sprintf(`<li class="workflow-step"><strong>%s</strong><p>%s</p></li>`,
|
||||||
|
html.EscapeString(s.Title),
|
||||||
|
html.EscapeString(s.Description)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sites strings.Builder
|
||||||
|
if len(data.Sites) == 0 {
|
||||||
|
sites.WriteString(`<p class="muted">No sites added yet. Add your first Linux SSH site below.</p>`)
|
||||||
|
}
|
||||||
|
for _, site := range data.Sites {
|
||||||
|
last := "Never run"
|
||||||
|
if site.LastRunAt.Valid {
|
||||||
|
last = site.LastRunAt.Time.Local().Format(time.RFC1123)
|
||||||
|
}
|
||||||
|
runStatus := "pending"
|
||||||
|
if site.LastRunStatus.Valid {
|
||||||
|
runStatus = site.LastRunStatus.String
|
||||||
|
}
|
||||||
|
runOutput := "(no output yet)"
|
||||||
|
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
|
||||||
|
runOutput = site.LastRunOutput.String
|
||||||
|
}
|
||||||
|
|
||||||
|
sites.WriteString(fmt.Sprintf(`
|
||||||
|
<article class="site-card">
|
||||||
|
<div class="site-head">
|
||||||
|
<h3>%s@%s</h3>
|
||||||
|
<form method="post" action="/sites/%d/run">
|
||||||
|
<button class="button" type="submit">Run</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Path: <code>%s</code></p>
|
||||||
|
<p class="muted">Last run: %s</p>
|
||||||
|
<p class="muted">Status: <span class="pill %s">%s</span></p>
|
||||||
|
<pre class="output">%s</pre>
|
||||||
|
</article>`,
|
||||||
|
html.EscapeString(site.SSHUser),
|
||||||
|
html.EscapeString(site.Host),
|
||||||
|
site.ID,
|
||||||
|
html.EscapeString(site.RemotePath),
|
||||||
|
html.EscapeString(last),
|
||||||
|
html.EscapeString(runStatus),
|
||||||
|
html.EscapeString(runStatus),
|
||||||
|
html.EscapeString(runOutput),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
flash := ""
|
||||||
|
if data.FlashMessage != "" {
|
||||||
|
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage)))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Satoru Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card dashboard-card">
|
||||||
|
<div class="row split">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Satoru</p>
|
||||||
|
<h1>Managed Sites Overview</h1>
|
||||||
|
<p class="muted">Signed in as <strong>%s</strong> (%s).</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<a class="button ghost" href="/account/password">Change password</a>
|
||||||
|
<form method="post" action="/signout"><button class="button ghost" type="submit">Sign out</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
%s
|
||||||
|
<section>
|
||||||
|
<h2>Host Runtime Status</h2>
|
||||||
|
<ul class="status-list">%s</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Planned Workflow</h2>
|
||||||
|
<ol class="workflow">%s</ol>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Add Site</h2>
|
||||||
|
<form class="grid-3" method="post" action="/sites">
|
||||||
|
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
|
||||||
|
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
|
||||||
|
<label class="stack"><span>Path</span><input name="remote_path" placeholder="/var/www" required /></label>
|
||||||
|
<button class="button" type="submit">Add site</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Managed Sites</h2>
|
||||||
|
%s
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(data.User.Username),
|
||||||
|
html.EscapeString(role),
|
||||||
|
flash,
|
||||||
|
checks.String(),
|
||||||
|
flows.String(),
|
||||||
|
sites.String(),
|
||||||
|
))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFlash(code string) string {
|
||||||
|
switch code {
|
||||||
|
case "site-added":
|
||||||
|
return "Site added."
|
||||||
|
case "site-ran":
|
||||||
|
return "Run completed."
|
||||||
|
case "site-invalid":
|
||||||
|
return "All site fields are required."
|
||||||
|
case "password-updated":
|
||||||
|
return "Password updated."
|
||||||
|
default:
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
|
"satoru/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Home(now time.Time, user store.User) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||||
|
role := "Operator"
|
||||||
|
if user.IsAdmin {
|
||||||
|
role = "Admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
if user.ID == 0 {
|
||||||
|
body = `<p class="muted">Sign in to continue.</p>
|
||||||
|
<div class="row">
|
||||||
|
<a class="button" href="/signin">Sign in</a>
|
||||||
|
<a class="button ghost" href="/signup">Sign up</a>
|
||||||
|
</div>`
|
||||||
|
} else {
|
||||||
|
body = fmt.Sprintf(`<p class="muted">Signed in as <strong>%s</strong> (%s).</p>
|
||||||
|
<p class="muted">Current server time: %s</p>
|
||||||
|
<form method="post" action="/signout">
|
||||||
|
<button class="button ghost" type="submit">Sign out</button>
|
||||||
|
</form>`, html.EscapeString(user.Username), role, now.Format(time.RFC1123))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Satoru</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<p class="eyebrow">Satoru</p>
|
||||||
|
<h1>Backup Control Plane</h1>
|
||||||
|
%s
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`, body))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
|
"satoru/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordPageData struct {
|
||||||
|
User store.User
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChangePassword(data PasswordPageData) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
||||||
|
errBlock := ""
|
||||||
|
if data.Error != "" {
|
||||||
|
errBlock = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(data.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Change Password · Satoru</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card auth-card">
|
||||||
|
<p class="eyebrow">Satoru</p>
|
||||||
|
<h1>Change Password</h1>
|
||||||
|
<p class="muted">User: <strong>%s</strong></p>
|
||||||
|
%s
|
||||||
|
<form class="stack" method="post" action="/account/password">
|
||||||
|
<label class="stack">
|
||||||
|
<span>Current password</span>
|
||||||
|
<input type="password" name="current_password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span>New password</span>
|
||||||
|
<input type="password" name="new_password" autocomplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span>Confirm new password</span>
|
||||||
|
<input type="password" name="confirm_password" autocomplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">Update password</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted"><a href="/">Back to dashboard</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(data.User.Username),
|
||||||
|
errBlock,
|
||||||
|
))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0f172a;
|
||||||
|
--fg: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--card: #111827;
|
||||||
|
--border: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(680px, calc(100vw - 2rem));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--card) 85%, black);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
width: min(980px, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(480px, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #082f49;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--card) 75%, black);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
background: color-mix(in srgb, #ef4444 22%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fecaca;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
background: color-mix(in srgb, #22c55e 18%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #bbf7d0;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.installed {
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.missing {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step p {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.ok {
|
||||||
|
border-color: #22c55e;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.failed {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
background: color-mix(in srgb, var(--card) 80%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue